├── .gitignore ├── custom_components └── person_location │ ├── helpers │ ├── __init__.py │ ├── entity.py │ ├── template.py │ ├── api.py │ └── duration_distance.py │ ├── manifest.json │ ├── pyproject.toml │ ├── services.yaml │ ├── api.py │ ├── system_health.py │ ├── camera.py │ ├── translations │ └── en.json │ ├── const.py │ ├── __init__.py │ ├── sensor.py │ └── process_trigger.py ├── .flake8 ├── hacs.json ├── docs ├── images │ ├── PersonHomeState.png │ ├── SamplePersonLocation.png │ ├── CustomizationsExample.png │ ├── home_assistant_map_card.png │ ├── TriggerEntitiesConfigExample.png │ ├── camera.rod_location_google.png │ ├── camera.rod_location_mapbox.png │ ├── camera.rod_location_mapquest.png │ ├── camera.combined_location_radar.png │ ├── powered_by_google_on_non_white.png │ ├── camera.combined_location_google.png │ ├── camera.combined_location_mapbox.png │ ├── camera.rod_location_mapbox_pitched.png │ └── DataVsOptions.md └── integration_quality_checklist.md ├── .vscode └── settings.json ├── .isort.cfg ├── info.md ├── automation_folder ├── HA-Start.yaml └── person_location_detection.yaml └── .github └── workflows └── validate.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | **.bak 2 | **/__pycache__/ 3 | -------------------------------------------------------------------------------- /custom_components/person_location/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | extend-ignore = E501 4 | exclude = .git -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Person Location Reverse Geocode", 3 | "country": "US" 4 | } -------------------------------------------------------------------------------- /docs/images/PersonHomeState.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/PersonHomeState.png -------------------------------------------------------------------------------- /docs/images/SamplePersonLocation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/SamplePersonLocation.png -------------------------------------------------------------------------------- /docs/images/CustomizationsExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/CustomizationsExample.png -------------------------------------------------------------------------------- /docs/images/home_assistant_map_card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/home_assistant_map_card.png -------------------------------------------------------------------------------- /docs/images/TriggerEntitiesConfigExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/TriggerEntitiesConfigExample.png -------------------------------------------------------------------------------- /docs/images/camera.rod_location_google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/camera.rod_location_google.png -------------------------------------------------------------------------------- /docs/images/camera.rod_location_mapbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/camera.rod_location_mapbox.png -------------------------------------------------------------------------------- /docs/images/camera.rod_location_mapquest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/camera.rod_location_mapquest.png -------------------------------------------------------------------------------- /docs/images/camera.combined_location_radar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/camera.combined_location_radar.png -------------------------------------------------------------------------------- /docs/images/powered_by_google_on_non_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/powered_by_google_on_non_white.png -------------------------------------------------------------------------------- /docs/images/camera.combined_location_google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/camera.combined_location_google.png -------------------------------------------------------------------------------- /docs/images/camera.combined_location_mapbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/camera.combined_location_mapbox.png -------------------------------------------------------------------------------- /docs/images/camera.rod_location_mapbox_pitched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodpayne/home-assistant_person_location/HEAD/docs/images/camera.rod_location_mapbox_pitched.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": false, 3 | "python.linting.enabled": true, 4 | "python.linting.flake8Enabled": true, 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | atomic = True 4 | multi_line_output = 3 5 | include_trailing_comma = True 6 | force_grid_wrap = 0 7 | use_parentheses = True 8 | ensure_newline_before_comments = True 9 | line_length = 88 10 | 11 | -------------------------------------------------------------------------------- /custom_components/person_location/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "person_location", 3 | "name": "Person Location", 4 | "after_dependencies": [ 5 | "person", 6 | "waze_travel_time" 7 | ], 8 | "codeowners": [ 9 | "@rodpayne" 10 | ], 11 | "config_flow": true, 12 | "dependencies": [ 13 | "device_tracker", 14 | "mobile_app", 15 | "sensor", 16 | "zone" 17 | ], 18 | "documentation": "https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#table-of-contents", 19 | "integration_type": "service", 20 | "iot_class": "calculated", 21 | "issue_tracker": "https://github.com/rodpayne/home-assistant_person_location/issues", 22 | "loggers": ["custom_components.person_location"], 23 | "requirements": [ 24 | "pywaze>=1.1.1" 25 | ], 26 | "version": "2025.12.17" 27 | } -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Person Location Custom Integration 2 | 3 | ## Combine the status of multiple device trackers 4 | This custom integration will look at all device trackers for a particular person and combine them into a single person location sensor, `sensor._location`. Device tracker state change events are monitored rather than being polled, making a composite, averaging the states, or calculating a probability. 5 | 6 | ## Make presence detection not so binary 7 | When a person is detected as moving between `Home` and `Away`, instead of going straight to `Home` or `Away`, this will temporarily set the person's location state to `Just Arrived` or `Just Left` so that automations can be triggered appropriately. 8 | 9 | ## Reverse geocode the location and make distance calculations 10 | When the person location sensor changes it can be reverse geocoded using Open Street Map, Google Maps, MapQuest, and/or Radar.com and the distance from home (miles and minutes) calculated with `WazeRouteCalculator`. 11 | 12 | ### [Open repository README](https://github.com/rodpayne/home-assistant_person_location#home-assistant-person-location-custom-integration) for all available installation and configuration details. 13 | -------------------------------------------------------------------------------- /automation_folder/HA-Start.yaml: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------------------------------------------- 2 | # These actions are taken when Home Assistant starts up. (Entirely optional.) 3 | #---------------------------------------------------------------------------------------------------------- 4 | 5 | - alias: HA Start 6 | initial_state: 'on' 7 | trigger: 8 | platform: homeassistant 9 | event: start 10 | action: 11 | # - create person sensors to pretty things up until the update happens 12 | - service: python_script.set_state 13 | data_template: 14 | entity_id: 'sensor.rod_location' 15 | state: 'Unknown' 16 | friendly_name: 'Rod (HA restarted)' 17 | allow_create: True 18 | - service: python_script.set_state 19 | data_template: 20 | entity_id: 'sensor.pam_location' 21 | state: 'Unknown' 22 | friendly_name: 'Pam (HA restarted)' 23 | allow_create: True 24 | # - Update person sensors after iCloud3 device_tracker has been created 25 | - delay: 26 | seconds: 30 27 | - service: device_tracker.icloud3_update 28 | data_template: 29 | command: location 30 | -------------------------------------------------------------------------------- /custom_components/person_location/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 88 3 | target-version = "py311" 4 | 5 | [tool.ruff.lint] 6 | # Core lint rules: PEP8, PEP257, imports, docstrings, complexity 7 | select = [ 8 | "E", # PEP8 errors 9 | "F", # Pyflakes 10 | "W", # Warnings 11 | "C90", # Cyclomatic complexity 12 | "D", # Docstring rules (PEP257) 13 | "I", # Import sorting 14 | "UP", # Upgrade syntax (modern Python) 15 | "ANN", # Type annotations 16 | "TCH" # Typing + constant ordering checks 17 | ] 18 | ignore = [ 19 | "E501" # Ignore long lines (Black handles wrapping) 20 | ] 21 | 22 | [tool.ruff.format] 23 | quote-style = "double" 24 | indent-style = "space" 25 | 26 | [tool.ruff.lint.isort] 27 | # Import sorting rules 28 | known-first-party = ["homeassistant"] 29 | combine-as-imports = true 30 | force-sort-within-sections = true 31 | lines-between-types = 1 32 | 33 | [tool.ruff.lint.pydocstyle] 34 | # Docstring style 35 | convention = "pep257" 36 | 37 | [tool.ruff.lint.per-file-ignores] 38 | # Example: ignore missing docstrings in tests 39 | "tests/*" = ["D100", "D101", "D102", "D103"] 40 | 41 | [tool.ruff.lint.mccabe] 42 | # Flag errors (`C901`) whenever the complexity level exceeds 50. 43 | max-complexity = 50 -------------------------------------------------------------------------------- /custom_components/person_location/services.yaml: -------------------------------------------------------------------------------- 1 | 2 | reverse_geocode: 3 | # Description of the service 4 | description: Service to reverse geocode the location in a person status sensor. 5 | # Different fields that your service accepts 6 | fields: 7 | # Key of the field 8 | entity_id: 9 | # Description of the field 10 | description: Name of the entitity to examine and update 11 | # Example value that can be passed for this field 12 | example: "sensor.rod_location" 13 | force_update: 14 | description: Should geocoding be done even if the distance is less than MIN_DISTANCE_TRAVELLED (5 meters)? (optional) 15 | example: True 16 | friendly_name_template: 17 | description: Template for the entitiy's friendly_name (optional) 18 | example: "Rod (Rod's iPhone) is in " 19 | 20 | 21 | geocode_api_on: 22 | # Description of the service 23 | description: Resume using the Open Street Map API and WazeRouteCalculator. 24 | 25 | geocode_api_off: 26 | # Description of the service 27 | description: Pause using the Open Street Map API. This may be necessary if a malfunction causes an excessive number of calls or if the external site is unreachable. The Nominatim Usage Policy says, "Apps must make sure that they can switch off the service at our request at any time (in particular, switching should be possible without requiring a software update)." This also pauses the WazeRouteCalculator calls. 28 | 29 | process_trigger: 30 | # Description of the service 31 | description: Service to process the changes of a device tracker or sensor. 32 | fields: 33 | # Key of the field 34 | entity_id: 35 | # Description of the field 36 | description: Name of the device tracker or sensor that has changed. 37 | # Example value that can be passed for this field 38 | example: "device_tracker.rod_iphone" 39 | from_state: 40 | description: The previous state. 41 | example: "Home" 42 | to_state: 43 | description: The updated state. 44 | example: "Away" 45 | -------------------------------------------------------------------------------- /custom_components/person_location/helpers/entity.py: -------------------------------------------------------------------------------- 1 | """helpers/entity.py - Helpers for entity lifecycle""" 2 | 3 | import logging 4 | import re 5 | from typing import Iterable 6 | 7 | from homeassistant.const import STATE_UNAVAILABLE 8 | from homeassistant.helpers import entity_registry as er 9 | 10 | from ..const import ( 11 | CONF_CREATE_SENSORS, 12 | DATA_CONFIGURATION, 13 | DOMAIN, 14 | ) 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | # ------------------------------------------------------------------------------- 19 | 20 | # Base ends with "_location"; suffix can contain underscores 21 | _TEMPLATE_RE = re.compile(r"^(?P.+_location)_(?P.+)_template$") 22 | 23 | 24 | def _extract_base_and_suffix(unique_id: str) -> tuple[str, str] | None: 25 | if not unique_id: 26 | return None 27 | m = _TEMPLATE_RE.match(unique_id) 28 | if not m: 29 | return None 30 | _LOGGER.debug( 31 | "[_extract_base_and_suffix] base: %s, suffix: %s", 32 | m.group("base"), 33 | m.group("suffix"), 34 | ) 35 | return m.group("base"), m.group("suffix") 36 | 37 | 38 | async def prune_orphan_template_entities( 39 | hass, 40 | *, 41 | platform_domain: str, # your integration's domain string in the registry 42 | entity_domain: str = "sensor", 43 | allowed_suffixes: Iterable[str], 44 | ) -> list[str]: 45 | registry = er.async_get(hass) 46 | allowed = set(allowed_suffixes or []) 47 | removed: list[str] = [] 48 | 49 | for entity in list(registry.entities.values()): 50 | if entity.domain != entity_domain: 51 | continue 52 | if entity.platform != platform_domain: 53 | continue 54 | 55 | pair = _extract_base_and_suffix(entity.unique_id) 56 | if not pair: 57 | continue 58 | base_id, suffix = pair 59 | 60 | if suffix in allowed: 61 | continue 62 | 63 | # Check state before removing 64 | state_obj = hass.states.get(entity.entity_id) 65 | if state_obj is None or state_obj.state != STATE_UNAVAILABLE: 66 | # Entity is either active or unknown, so skip removal 67 | continue 68 | 69 | removed.append(entity.entity_id) 70 | registry.async_remove(entity.entity_id) 71 | 72 | return removed 73 | 74 | 75 | # ------------------------------------------------------------------------------- 76 | -------------------------------------------------------------------------------- /docs/images/DataVsOptions.md: -------------------------------------------------------------------------------- 1 | # Person Location Integration — Configuration Data vs Options 2 | 3 | This document clarifies which settings belong in `config_entry.data` versus `config_entry.options`, and the implications for how the configuration and options flows should be structured. 4 | 5 | --- 6 | 7 | ## Separation of Settings 8 | 9 | | Setting | Location | Rationale | 10 | |---------|----------|-----------| 11 | | **API keys** (`CONF_GOOGLE_API_KEY`, `CONF_MAPBOX_API_KEY`, `CONF_MAPQUEST_API_KEY`, `CONF_OSM_API_KEY`, `CONF_RADAR_API_KEY`) | `data` | Static credentials; define integration identity. | 12 | | **Region / Language** (`CONF_REGION`, `CONF_LANGUAGE`) | `data` | Affect geocoding behavior; structural. | 13 | | **Output platform** (`CONF_OUTPUT_PLATFORM`) | `data` | Determines entity domain type; structural. | 14 | | **Create sensors list** (`CONF_CREATE_SENSORS`) | `data` | Defines which entities exist. | 15 | | **Devices list** (`CONF_DEVICES`) | `data` | Defines tracked devices; structural. | 16 | | **Providers list** (`CONF_PROVIDERS`) | `data` | Defines external map/camera providers; structural. | 17 | | **Follow person integration** (`CONF_FOLLOW_PERSON_INTEGRATION`) | `data` | Affects entity creation tied to `person` entities. | 18 | | **Friendly name template** (`CONF_FRIENDLY_NAME_TEMPLATE`) | `options` | Purely presentation/UX. | 19 | | **Hours extended away / minutes just arrived / minutes just left** (`CONF_HOURS_EXTENDED_AWAY`, `CONF_MINUTES_JUST_ARRIVED`, `CONF_MINUTES_JUST_LEFT`) | `options` | Behavioral thresholds; runtime‑tunable. | 20 | | **Show zone when away** (`CONF_SHOW_ZONE_WHEN_AWAY`) | `options` | UX toggle; runtime‑tunable. | 21 | 22 | --- 23 | 24 | ## Implications for the Flow 25 | 26 | ### ConfigFlow (`config_entry.data`) 27 | - Handles **structural configuration**: 28 | - API keys 29 | - Region / language 30 | - Output platform 31 | - Create sensors list 32 | - Devices 33 | - Providers 34 | - Follow person integration 35 | - These define the *identity* of the integration and the entity set. 36 | - Saved via `async_create_entry(..., data=...)`. 37 | 38 | ### OptionsFlow (`config_entry.options`) 39 | - Handles **runtime‑tunable behavior**: 40 | - Friendly name template 41 | - Hours/minutes thresholds 42 | - Show zone toggle 43 | - These settings adjust behavior or presentation of existing entities. 44 | - Saved via `async_update_entry(..., options=...)`. 45 | 46 | --- 47 | 48 | ## Guiding Principle 49 | 50 | - If changing a setting **adds or removes entities**, it belongs in **`data`**. 51 | - If it only **changes how existing entities behave or display**, it belongs in **`options`**. 52 | 53 | --- -------------------------------------------------------------------------------- /custom_components/person_location/api.py: -------------------------------------------------------------------------------- 1 | """API Client Wrapper.""" 2 | 3 | import asyncio 4 | import logging 5 | import socket 6 | import traceback 7 | 8 | import aiohttp 9 | import async_timeout 10 | 11 | TIMEOUT = 10 12 | 13 | _LOGGER: logging.Logger = logging.getLogger(__package__) 14 | 15 | HEADERS = {"Content-type": "application/json; charset=UTF-8"} 16 | 17 | 18 | class PersonLocation_aiohttp_Client: 19 | def __init__(self, session: aiohttp.ClientSession) -> None: 20 | self._session = session 21 | 22 | async def async_get_data( 23 | self, method: str, url: str, data: dict = {}, headers: dict = {} 24 | ) -> dict: 25 | """Get data from the API.""" 26 | 27 | return await self.api_wrapper(method, url, data, headers) 28 | 29 | async def api_wrapper( 30 | self, method: str, url: str, data: dict = {}, headers: dict = {} 31 | ) -> dict: 32 | """Get information from the API.""" 33 | try: 34 | async with async_timeout.timeout(TIMEOUT): 35 | if method == "get": 36 | response = await self._session.get(url, headers=headers) 37 | return await response.json() 38 | 39 | elif method == "put": 40 | response = await self._session.put(url, headers=headers, json=data) 41 | return await response.json() 42 | 43 | elif method == "patch": 44 | response = await self._session.patch( 45 | url, headers=headers, json=data 46 | ) 47 | return await response.json() 48 | 49 | elif method == "post": 50 | response = await self._session.post(url, headers=headers, json=data) 51 | return await response.json() 52 | except asyncio.TimeoutError as exception: 53 | error_message = f"Timeout error fetching information from {url.split('?',1)[0]} - {exception}" 54 | _LOGGER.error(error_message) 55 | return {"error": error_message} 56 | 57 | except (KeyError, TypeError) as exception: 58 | error_message = ( 59 | f"Error parsing information from {url.split('?',1)[0]} - {exception}" 60 | ) 61 | _LOGGER.error(error_message) 62 | return {"error": error_message} 63 | 64 | except (aiohttp.ClientError, socket.gaierror) as exception: 65 | error_message = ( 66 | f"Error fetching information from {url.split('?',1)[0]} - {exception}" 67 | ) 68 | _LOGGER.error(error_message) 69 | return {"error": error_message} 70 | 71 | except Exception as e: # pylint: disable=broad-except 72 | error_message = f"Something wrong happened! - {type(e).__name__}: {e}" 73 | _LOGGER.error(error_message) 74 | _LOGGER.debug(traceback.format_exc()) 75 | return {"error": error_message} 76 | -------------------------------------------------------------------------------- /automation_folder/person_location_detection.yaml: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------------------------------------------- 2 | # Determine person location from device tracker indicators 3 | #---------------------------------------------------------------------------------------------------------- 4 | 5 | - alias: Person Location Update 6 | initial_state: 'on' 7 | mode: queued 8 | max: 10 9 | trigger: 10 | - platform: state 11 | entity_id: 12 | # - device_tracker.rod_iphone 13 | # - device_tracker.rod_iphone_2 14 | # - device_tracker.rod_iphone_app 15 | # - device_tracker.rod_ipad 16 | # - device_tracker.rod_ipad_2 17 | # - device_tracker.rod_ipad_app 18 | - sensor.ford_focus_location 19 | # - device_tracker.rod_wristwatch 20 | # - device_tracker.pams_iphone_app 21 | # - device_tracker.pams_iphone 22 | # - device_tracker.toms_iphone 23 | action: 24 | - service: person_location.process_trigger 25 | data_template: 26 | entity_id: '{{trigger.entity_id}}' 27 | from_state: '{{trigger.from_state.state}}' 28 | to_state: '{{trigger.to_state.state}}' 29 | 30 | # New way to do it without listing every entity that mobile app creates: 31 | - alias: Person Location Device Tracker Updated 32 | initial_state: 'on' 33 | mode: queued 34 | max: 10 35 | trigger: 36 | - platform: event 37 | event_type: state_changed 38 | condition: 39 | - condition: template 40 | value_template: > 41 | {{ trigger.event.data.entity_id.startswith('device_tracker.') 42 | and (not trigger.event.data.entity_id.endswith('_location')) 43 | and trigger.event.data.new_state.state != 'NotSet' }} 44 | action: 45 | - service: person_location.process_trigger 46 | data_template: 47 | entity_id: '{{trigger.event.data.entity_id}}' 48 | from_state: '{{trigger.event.data.old_state.state}}' 49 | to_state: '{{trigger.event.data.new_state.state}}' 50 | 51 | #- alias: Person Sensor Update for router home 52 | # initial_state: 'on' 53 | # mode: queued 54 | # max: 10 55 | # trigger: 56 | # - platform: state 57 | # entity_id: device_tracker.iphoneyallphone 58 | # to: 'home' 59 | # action: 60 | # - service: person_location.process_trigger 61 | # data_template: 62 | # entity_id: '{{trigger.entity_id}}' 63 | # from_state: '{{trigger.from_state.state}}' 64 | # to_state: '{{trigger.to_state.state}}' 65 | 66 | #- alias: Person Sensor Update for router not_home 67 | # initial_state: 'on' 68 | # mode: queued 69 | # max: 10 70 | # trigger: 71 | # - platform: state 72 | # entity_id: device_tracker.iphoneyallphone 73 | # to: 'not_home' 74 | # for: 75 | # seconds: 300 76 | # action: 77 | # - service: person_location.process_trigger 78 | # data_template: 79 | # entity_id: '{{trigger.entity_id}}' 80 | # from_state: '{{trigger.from_state.state}}' 81 | # to_state: '{{trigger.to_state.state}}' 82 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions 3 | on: 4 | push: 5 | # branches: [ development ] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | validate_version: 10 | name: Validate version consistency 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Check version consistency 16 | run: | 17 | # Extract version from manifest.json 18 | MANIFEST_VERSION=$(jq -r '.version' custom_components/person_location/manifest.json) 19 | 20 | # Extract version from const.py 21 | CONST_VERSION=$(grep 'VERSION = ' custom_components/person_location/const.py | head -n 1 | cut -d'"' -f2) 22 | 23 | # Compare versions 24 | if [ "$MANIFEST_VERSION" != "$CONST_VERSION" ]; then 25 | echo "Version mismatch!" 26 | echo "manifest.json version: $MANIFEST_VERSION" 27 | echo "consty.py version: $CONST_VERSION" 28 | exit 1 29 | fi 30 | 31 | echo "Versions match: $MANIFEST_VERSION" 32 | validate_hacs: 33 | name: HACS 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Validate 38 | uses: hacs/action@main 39 | with: 40 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | CATEGORY: integration 42 | validate_hassfest: 43 | name: Hassfest 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v3 47 | - uses: home-assistant/actions/hassfest@master 48 | test_advice_flake8: 49 | name: Test + advice with flake8 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v2 53 | with: 54 | fetch-depth: 0 55 | - name: Set up Python 3.10 56 | uses: actions/setup-python@v4 57 | with: 58 | python-version: "3.10" 59 | - name: Cache 60 | uses: actions/cache@v4 61 | with: 62 | path: ~/.cache/pip 63 | key: pip-flake8 64 | - name: Install dependencies 65 | run: | 66 | python -m pip install --upgrade pip wheel 67 | python -m pip install --upgrade flake8 wemake-python-styleguide 68 | python -m pip install --upgrade flake8-quotes 69 | - name: Lint with flake8 70 | run: | 71 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=python_scripts 72 | - name: Don't mind this 73 | run: | 74 | flake8 . --inline-quotes 'double' --count --exit-zero --max-complexity=15 --max-line-length=90 --statistics --select=I,P,WPS305,C812,E203,W503,E800 75 | - name: Docstrings 76 | run: | 77 | flake8 . --inline-quotes 'double' --count --exit-zero --max-complexity=15 --max-line-length=90 --statistics --select=D,DAR 78 | - name: Unused stuff 79 | run: | 80 | echo "Some stuff may not be used, but is used in commented out code." 81 | echo "Make sure you check with the find command before you remove anything!" 82 | flake8 . --inline-quotes '"' --count --exit-zero --max-complexity=15 --max-line-length=90 --statistics --select=F 83 | echo "Some stuff may not be used, but is used in commented out code." 84 | echo "Make sure you check with the find command before you remove anything!" 85 | - name: General stats 86 | run: | 87 | flake8 . --inline-quotes 'double' --count --exit-zero --max-complexity=15 --max-line-length=90 --statistics --ignore=I,P,WPS305,C812,E203,W503,E800,D,DAR,F 88 | -------------------------------------------------------------------------------- /custom_components/person_location/system_health.py: -------------------------------------------------------------------------------- 1 | """Provide info to system health.""" 2 | 3 | # See https://developers.home-assistant.io/blog/2020/11/09/system-health-and-templates 4 | 5 | from homeassistant.components import system_health 6 | from homeassistant.core import HomeAssistant, callback 7 | 8 | from .const import ( 9 | CONF_FROM_YAML, 10 | DATA_ATTRIBUTES, 11 | DATA_CONFIG_ENTRY, 12 | DATA_CONFIGURATION, 13 | DATA_ENTITY_INFO, 14 | DATA_STATE, 15 | DOMAIN, 16 | INFO_GEOCODE_COUNT, 17 | INFO_LOCALITY, 18 | INFO_TRIGGER_COUNT, 19 | VERSION, 20 | ) 21 | 22 | 23 | @callback 24 | def async_register( 25 | hass: HomeAssistant, register: system_health.SystemHealthRegistration 26 | ) -> None: 27 | """Register system health callbacks.""" 28 | register.async_register_info(system_health_info) 29 | 30 | 31 | async def system_health_info(hass: HomeAssistant) -> dict: 32 | """Get system health info (Settings -> System -> Repairs ⋮ System information).""" 33 | return_info = {} 34 | return_info["Version"] = VERSION 35 | 36 | if ( 37 | DATA_STATE in hass.data[DOMAIN] 38 | and DATA_ATTRIBUTES in hass.data[DOMAIN] 39 | and DATA_ENTITY_INFO in hass.data[DOMAIN] 40 | ): 41 | apiState = hass.data[DOMAIN][DATA_STATE] 42 | apiAttributesObject = hass.data[DOMAIN][DATA_ATTRIBUTES] 43 | entity_info = hass.data[DOMAIN][DATA_ENTITY_INFO] 44 | 45 | return_info["State"] = apiState 46 | 47 | if DATA_CONFIGURATION in hass.data[DOMAIN]: 48 | configured_from_yaml = hass.data[DOMAIN][DATA_CONFIGURATION][CONF_FROM_YAML] 49 | else: 50 | configured_from_yaml = False 51 | 52 | if DATA_CONFIG_ENTRY in hass.data[DOMAIN]: 53 | flow_config_state = return_info["Integration Configuration"] = hass.data[ 54 | DOMAIN 55 | ][DATA_CONFIG_ENTRY].state 56 | if configured_from_yaml: 57 | return_info["Integration Configuration"] = f"yaml + {flow_config_state}" 58 | else: 59 | return_info["Integration Configuration"] = flow_config_state 60 | else: 61 | return_info["Integration Configuration"] = "yaml only" 62 | 63 | attr_value = apiAttributesObject["api_calls_requested"] 64 | if attr_value != 0: 65 | return_info["Geolocation Calls Requested"] = attr_value 66 | 67 | attr_value = apiAttributesObject["startup"] 68 | if attr_value: 69 | return_info["Startup In Progress"] = attr_value 70 | 71 | attr_value = apiAttributesObject["api_calls_skipped"] 72 | if attr_value != 0: 73 | return_info["Geolocation Calls Skipped"] = attr_value 74 | 75 | attr_value = apiAttributesObject["api_calls_throttled"] 76 | if attr_value != 0: 77 | return_info["Geolocation Calls Throttled"] = attr_value 78 | 79 | attr_value = apiAttributesObject["api_error_count"] 80 | if attr_value != 0: 81 | return_info["Geolocation Error Count"] = attr_value 82 | 83 | attr_value = apiAttributesObject["waze_error_count"] 84 | if attr_value != 0: 85 | return_info["WAZE Error Count"] = attr_value 86 | 87 | for sensor in entity_info: 88 | if ( 89 | INFO_TRIGGER_COUNT in entity_info[sensor] 90 | and entity_info[sensor][INFO_TRIGGER_COUNT] != 0 91 | ): 92 | return_info[sensor] = ( 93 | str(entity_info[sensor][INFO_GEOCODE_COUNT]) 94 | + " geolocated for " 95 | + str(entity_info[sensor][INFO_TRIGGER_COUNT]) 96 | + " triggers, last = " 97 | + entity_info[sensor][INFO_LOCALITY] 98 | ) 99 | 100 | return return_info 101 | -------------------------------------------------------------------------------- /docs/integration_quality_checklist.md: -------------------------------------------------------------------------------- 1 | ## See [Integration quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/) for descriptions. 2 | 3 | ## Bronze 4 | - [X] `action-setup` - Service actions are registered in async_setup 5 | - [n/a] `appropriate-polling` - If it's a polling integration, set an appropriate polling interval 6 | - [X] `brands` - Has branding assets available for the integration 7 | - [ ] `common-modules` - Place common patterns in common modules 8 | - [ ] `config-flow-test-coverage` - Full test coverage for the config flow 9 | - [X] `config-flow` - Integration needs to be able to be set up via the UI 10 | - [ ] Uses `data_description` to give context to fields 11 | - [ ] Uses `ConfigEntry.data` and `ConfigEntry.options` correctly 12 | - [ ] `dependency-transparency` - Dependency transparency 13 | - [ ] `docs-actions` - The documentation describes the provided service actions that can be used 14 | - [ ] `docs-high-level-description` - The documentation includes a high-level description of the integration brand, product, or service 15 | - [ ] `docs-installation-instructions` - The documentation provides step-by-step installation instructions for the integration, including, if needed, prerequisites 16 | - [ ] `docs-removal-instructions` - The documentation provides removal instructions 17 | - [ ] `entity-event-setup` - Entity events are subscribed in the correct lifecycle methods 18 | - [X] `entity-unique-id` - Entities have a unique ID 19 | - [/] `has-entity-name` - Entities use has_entity_name = True - *This has been set for the template sensors and the map cameras, and is workinmg well. Unfortunately, when set for the target sensors, it causes them to prefix the friendly name with the device name and this cannot be overridden by our friendly name template.* 20 | - [ ] `runtime-data` - Use ConfigEntry.runtime_data to store runtime data 21 | - [X] `test-before-configure` - Test a connection in the config flow 22 | - [ ] `test-before-setup` - Check during integration initialization if we are able to set it up correctly 23 | - [X] `unique-config-entry` - Don't allow the same device or service to be able to be set up twice 24 | 25 | ## Silver 26 | - [ ] `action-exceptions` - Service actions raise exceptions when encountering failures 27 | - [ ] `config-entry-unloading` - Support config entry unloading 28 | - [ ] `docs-configuration-parameters` - The documentation describes all integration configuration options 29 | - [ ] `docs-installation-parameters` - The documentation describes all integration installation parameters 30 | - [ ] `entity-unavailable` - Mark entity unavailable if appropriate 31 | - [ ] `integration-owner` - Has an integration owner 32 | - [ ] `log-when-unavailable` - If internet/device/service is unavailable, log once when unavailable and once when back connected 33 | - [ ] `parallel-updates` - Number of parallel updates is specified 34 | - [ ] `reauthentication-flow` - Reauthentication needs to be available via the UI 35 | - [ ] `test-coverage` - Above 95% test coverage for all integration modules 36 | 37 | ## Gold 38 | - [X] `devices` - The integration creates devices 39 | - [ ] `diagnostics` - Implements diagnostics 40 | - [ ] `discovery-update-info` - Integration uses discovery info to update network information 41 | - [ ] `discovery` - Devices can be discovered 42 | - [ ] `docs-data-update` - The documentation describes how data is updated 43 | - [ ] `docs-examples` - The documentation provides automation examples the user can use. 44 | - [ ] `docs-known-limitations` - The documentation describes known limitations of the integration (not to be confused with bugs) 45 | - [ ] `docs-supported-devices` - The documentation describes known supported / unsupported devices 46 | - [ ] `docs-supported-functions` - The documentation describes the supported functionality, including entities, and platforms 47 | - [ ] `docs-troubleshooting` - The documentation provides troubleshooting information 48 | - [ ] `docs-use-cases` - The documentation describes use cases to illustrate how this integration can be used 49 | - [ ] `dynamic-devices` - Devices added after integration setup 50 | - [ ] `entity-category` - Entities are assigned an appropriate EntityCategory 51 | - [ ] `entity-device-class` - Entities use device classes where possible 52 | - [ ] `entity-disabled-by-default` - Integration disables less popular (or noisy) entities 53 | - [ ] `entity-translations` - Entities have translated names 54 | - [ ] `exception-translations` - Exception messages are translatable 55 | - [ ] `icon-translations` - Entities implement icon translations 56 | - [X] `reconfiguration-flow` - Integrations should have a reconfigure flow 57 | - [ ] `repair-issues` - Repair issues and repair flows are used when user intervention is needed 58 | - [X] `stale-devices` - Stale devices are removed 59 | 60 | ## Platinum 61 | - [ ] `async-dependency` - Dependency is async 62 | - [ ] `inject-websession` - The integration dependency supports passing in a websession 63 | - [ ] `strict-typing` - Strict typing 64 | -------------------------------------------------------------------------------- /custom_components/person_location/camera.py: -------------------------------------------------------------------------------- 1 | """Support for map as a camera (hybrid config).""" 2 | 3 | import asyncio 4 | import logging 5 | 6 | import httpx 7 | 8 | from homeassistant.components.camera import Camera 9 | from homeassistant.const import STATE_PROBLEM, STATE_UNKNOWN 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.exceptions import TemplateError 12 | from homeassistant.helpers.entity import DeviceInfo 13 | from homeassistant.helpers.httpx_client import get_async_client 14 | from homeassistant.helpers.template import Template 15 | from homeassistant.util import slugify 16 | 17 | from .const import ( 18 | CONF_CONTENT_TYPE, 19 | CONF_GOOGLE_API_KEY, 20 | CONF_MAPBOX_API_KEY, 21 | CONF_MAPQUEST_API_KEY, 22 | CONF_NAME, 23 | CONF_OSM_API_KEY, 24 | CONF_PROVIDERS, 25 | CONF_RADAR_API_KEY, 26 | CONF_STATE, 27 | CONF_STILL_IMAGE_URL, 28 | CONF_VERIFY_SSL, 29 | DATA_CONFIGURATION, 30 | DOMAIN, 31 | VERSION, 32 | ) 33 | 34 | _LOGGER = logging.getLogger(__name__) 35 | GET_IMAGE_TIMEOUT = 10 36 | 37 | CAMERA_PARENT_DEVICE = DeviceInfo( 38 | identifiers={(DOMAIN, "map_camera")}, 39 | name="Map Camera", 40 | manufacturer="rodpayne", 41 | model="Map Camera Group", 42 | ) 43 | 44 | 45 | def normalize_provider(hass: HomeAssistant, provider: dict) -> dict: 46 | """Ensure provider dict has Template objects and defaults.""" 47 | 48 | def _as_template(value): 49 | if isinstance(value, Template): 50 | tpl = value 51 | else: 52 | tpl = Template(str(value), hass) 53 | tpl.hass = hass 54 | return tpl 55 | 56 | return { 57 | CONF_NAME: provider[CONF_NAME], 58 | CONF_STILL_IMAGE_URL: _as_template(provider[CONF_STILL_IMAGE_URL]), 59 | CONF_STATE: _as_template(provider[CONF_STATE]), 60 | CONF_CONTENT_TYPE: provider.get(CONF_CONTENT_TYPE, "image/jpeg"), 61 | CONF_VERIFY_SSL: provider.get(CONF_VERIFY_SSL, True), 62 | } 63 | 64 | 65 | async def async_setup_platform( 66 | hass, config, async_add_entities, discovery_info=None 67 | ) -> None: 68 | """Set up cameras from YAML config.""" 69 | 70 | _LOGGER.debug("async_setup_platform: config = %s", config) 71 | async_add_entities([PersonLocationCamera(hass, normalize_provider(hass, config))]) 72 | 73 | 74 | async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities) -> None: 75 | """Set up cameras from config entry providers.""" 76 | 77 | _LOGGER.debug("[async_setup_entry] entry: %s", entry) 78 | providers = entry.data.get(CONF_PROVIDERS, []) 79 | entities = [ 80 | PersonLocationCamera(hass, normalize_provider(hass, p)) for p in providers 81 | ] 82 | async_add_entities(entities, update_before_add=True) 83 | 84 | 85 | class PersonLocationCamera(Camera): 86 | """A person_location implementation of a map camera.""" 87 | 88 | def __init__(self, hass: HomeAssistant, provider) -> None: 89 | super().__init__() 90 | self.hass = hass 91 | self._name = provider[CONF_NAME] 92 | _LOGGER.debug("PersonLocationCamera: creating name = %s", self._name) 93 | self._attr_unique_id = f"map_camera_{slugify(self._name)}" 94 | self._attr_has_entity_name = True 95 | self._attr_name = provider[CONF_NAME].replace("_", " ").title() 96 | self._still_image_url = provider[CONF_STILL_IMAGE_URL] 97 | self._still_image_url.hass = self.hass 98 | self._state_template = provider[CONF_STATE] 99 | self._state_template.hass = self.hass 100 | self.content_type = provider.get(CONF_CONTENT_TYPE, "image/jpeg") 101 | self.verify_ssl = provider.get(CONF_VERIFY_SSL, True) 102 | self._auth = None 103 | self._state = STATE_UNKNOWN 104 | self._attr_icon = "mdi:map-outline" 105 | self._last_url = None 106 | self._last_image = None 107 | self._attr_device_info = CAMERA_PARENT_DEVICE 108 | 109 | cfg = self.hass.data[DOMAIN][DATA_CONFIGURATION] 110 | self._template_variables = { 111 | "parse_result": False, 112 | "google_api_key": cfg[CONF_GOOGLE_API_KEY], 113 | "mapbox_api_key": cfg[CONF_MAPBOX_API_KEY], 114 | "mapquest_api_key": cfg[CONF_MAPQUEST_API_KEY], 115 | "osm_api_key": cfg[CONF_OSM_API_KEY], 116 | "radar_api_key": cfg[CONF_RADAR_API_KEY], 117 | } 118 | 119 | @property 120 | def name(self) -> str: 121 | return self._name 122 | 123 | @property 124 | def state(self): 125 | return self._state 126 | 127 | async def async_camera_image(self, width=None, height=None) -> bytes | None: 128 | """Return bytes of camera image.""" 129 | try: 130 | self._last_url, self._last_image = await asyncio.shield( 131 | self._async_camera_image() 132 | ) 133 | except asyncio.CancelledError as err: 134 | _LOGGER.warning("Timeout getting camera image from %s", self._name) 135 | raise err 136 | return self._last_image 137 | 138 | async def _async_camera_image(self) -> tuple[str | None, bytes | None]: 139 | """Return a still image response from the camera.""" 140 | if not self.enabled: 141 | return self._last_url, self._last_image 142 | 143 | try: 144 | url = self._still_image_url.async_render(**self._template_variables) 145 | except TemplateError as err: 146 | _LOGGER.error( 147 | "Error parsing url template %s: %s", self._still_image_url, err 148 | ) 149 | return self._last_url, self._last_image 150 | 151 | try: 152 | new_state = self._state_template.async_render(parse_result=False) 153 | except TemplateError as err: 154 | _LOGGER.error( 155 | "Error parsing state template %s: %s", self._state_template, err 156 | ) 157 | new_state = STATE_PROBLEM 158 | 159 | if new_state != self._state: 160 | self._state = new_state 161 | self.async_schedule_update_ha_state() 162 | 163 | if (url == self._last_url) or url == "None": 164 | return self._last_url, self._last_image 165 | 166 | response = None 167 | try: 168 | async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) 169 | response = await async_client.get( 170 | url, auth=self._auth, timeout=GET_IMAGE_TIMEOUT 171 | ) 172 | response.raise_for_status() 173 | image = response.content 174 | except httpx.TimeoutException: 175 | _LOGGER.error("Timeout getting camera image from %s", self._name) 176 | self._state = STATE_PROBLEM 177 | return self._last_url, self._last_image 178 | except (httpx.RequestError, httpx.HTTPStatusError) as err: 179 | _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) 180 | self._state = STATE_PROBLEM 181 | return self._last_url, self._last_image 182 | finally: 183 | if response: 184 | await response.aclose() 185 | 186 | return url, image 187 | -------------------------------------------------------------------------------- /custom_components/person_location/helpers/template.py: -------------------------------------------------------------------------------- 1 | """helpers/template.py - Helpers for template validation""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.core import HomeAssistant 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | # ----------------- normalize_template ----------------- 11 | 12 | 13 | def normalize_template(s: str) -> str: 14 | import re 15 | 16 | if not isinstance(s, str): 17 | return s 18 | # Replace literal backslash-n first (one or more in a row) 19 | s = re.sub(r"(\\n)+", " ", s) 20 | # Replace real newlines of any flavor 21 | s = re.sub(r"[\r\n]+", " ", s) 22 | # Collapse runs of whitespace 23 | s = re.sub(r"\s{2,}", " ", s) 24 | return s.strip() 25 | 26 | 27 | # ----------------- validate_template ----------------- 28 | 29 | 30 | async def validate_template( 31 | hass: HomeAssistant, 32 | template_str: str, 33 | template_variables: dict, 34 | *, 35 | expected: str = "text", # "text" or "url" 36 | variables: dict[str, Any] | None = None, 37 | check_entities: bool = True, 38 | strict: bool = True, 39 | ) -> dict[str, Any]: 40 | """Validate a Jinja template in HA. 41 | 42 | Returns: 43 | { 44 | "ok": bool, 45 | "error": Optional[str], 46 | "rendered": Optional[str], 47 | "entities": set[str], 48 | "domains": set[str], 49 | "all_states": bool, 50 | "missing_entities": list[str] 51 | } 52 | """ 53 | import inspect 54 | from urllib.parse import urlparse 55 | 56 | from homeassistant.exceptions import TemplateError 57 | from homeassistant.helpers.template import Template 58 | 59 | result: dict[str, Any] = { 60 | "ok": False, 61 | "error": None, 62 | "rendered": None, 63 | "entities": set(), 64 | "domains": set(), 65 | "all_states": False, 66 | "missing_entities": [], 67 | } 68 | 69 | # Note: always set `error` if returning `ok` False 70 | 71 | tpl_text = normalize_template(template_str) 72 | tpl = Template( 73 | tpl_text, hass 74 | ) # HA's sandboxed Template class [1](https://deepwiki.com/home-assistant/core/2.3-event-system-and-templating) 75 | try: 76 | # Call once; if it's awaitable, await it; otherwise use it directly. For legacy Core versions. 77 | maybe = tpl.async_render_to_info( 78 | variables=template_variables or {}, strict=strict 79 | ) 80 | info = await maybe if inspect.isawaitable(maybe) else maybe 81 | 82 | # If the engine captured an exception, treat as failure 83 | exc = getattr(info, "exception", None) 84 | if exc: 85 | result["error"] = f"{exc.__class__.__name__}: {exc}" 86 | return result 87 | 88 | # Result can be a method or an attribute depending on HA version 89 | rendered_attr = getattr(info, "result", None) 90 | if callable(rendered_attr): 91 | rendered = rendered_attr() # result() method 92 | elif rendered_attr is not None: 93 | rendered = rendered_attr # result attribute 94 | else: 95 | rendered = getattr(info, "_result", None) # legacy fallback 96 | if isinstance(rendered, str): 97 | rendered = rendered.strip() 98 | 99 | result.update( 100 | rendered=rendered, 101 | entities=set(getattr(info, "entities", set())), 102 | domains=set(getattr(info, "domains", set())), 103 | all_states=bool(getattr(info, "all_states", False)), 104 | ) 105 | 106 | # Optional type checks 107 | if expected == "url": 108 | if not isinstance(rendered, str) or not rendered: 109 | raise ValueError("Rendered value is empty or not a string") 110 | u = urlparse(rendered) 111 | if u.scheme not in ("http", "https") or not u.netloc: 112 | raise ValueError(f"Rendered value is not a valid URL: {rendered!r}") 113 | 114 | # Optional entity existence check 115 | if check_entities and not result["all_states"]: 116 | missing = [e for e in result["entities"] if hass.states.get(e) is None] 117 | result["missing_entities"] = missing 118 | 119 | result["ok"] = True 120 | _LOGGER.debug("[validate_template] result=%s", result) 121 | return result 122 | 123 | except TemplateError as te: 124 | # Jinja/HA template errors (syntax, undefined vars) bubble up as TemplateError 125 | first_line = str(te).splitlines()[0] 126 | result["error"] = f"{first_line}" 127 | _LOGGER.debug("[validate_template] TemplateError result=%s", result) 128 | return result 129 | except Exception as ex: 130 | result["error"] = f"{type(ex).__name__}: {ex}" 131 | _LOGGER.debug("[validate_template] Exception result=%s", result) 132 | return result 133 | 134 | 135 | # ----------------- Friendly Name Template Test ----------------- 136 | 137 | 138 | async def test_friendly_name_template(hass: HomeAssistant, template_str: str) -> dict: 139 | """Render a preview of friendly_name for the supplied template_str""" 140 | from homeassistant.core import State 141 | from homeassistant.helpers.template import Template as HATemplate 142 | 143 | _LOGGER.debug("HATemplate type = %s", type(HATemplate)) 144 | 145 | if not isinstance(template_str, str) or not template_str.strip(): 146 | return None 147 | 148 | # This rendering is using the following example states. 149 | # TODO: we could use actual live state after triggers are configured 150 | # (if we could decide which one to show). 151 | 152 | friendly_name_location = "is in Spanish Fork" 153 | 154 | target = State( 155 | "sensor.rod_location", # entity_id 156 | "Just Left", # state 157 | { 158 | "source_type": "gps", 159 | "latitude": 40.12703438635704, 160 | "longitude": -111.63607706862837, 161 | "gps_accuracy": 6, 162 | "altitude": 1434, 163 | "vertical_accuracy": 30, 164 | "entity_picture": "/local/rod-phone.png", 165 | "source": "device_tracker.rod_iphone_16", 166 | "reported_state": "Away", 167 | "person_name": "Rod", 168 | "location_time": "2025-10-26 18:31:21.062200", 169 | "icon": "mdi:help-circle", 170 | "zone": "away", 171 | "bread_crumbs": "Home> Spanish Fork", 172 | "compass_bearing": 246.6, 173 | "direction": "away from home", 174 | "version": "person_location 2025.10.25", 175 | "attribution": '"Powered by Radar"; "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright"; "powered by Google"; "Data by Waze App. https://waze.com"; ', 176 | "meters_from_home": 2641.1, 177 | "miles_from_home": 1.6, 178 | "Radar": "1386 N Canyon Creek Pkwy, Spanish Fork, UT 84660 US", 179 | "Open_Street_Map": "North Marketplace Drive Spanish Fork Utah County Utah 84660 United States of America", 180 | "Google_Maps": "1386 N Cyn Crk Pkwy, Spanish Fork, UT 84660, USA", 181 | "locality": "Spanish Fork", 182 | "driving_miles": "2.52", 183 | "driving_minutes": "5.2", 184 | "friendly_name": "Rod (Rod-iPhone-16) is in Spanish Fork", 185 | "speed": 1.0, 186 | }, 187 | ) 188 | 189 | sourceObject = State( 190 | "device_tracker.rod_iphone_16", 191 | "not_home", 192 | { 193 | "source_type": "gps", 194 | "battery_level": 85, 195 | "latitude": 40.12703438635704, 196 | "longitude": -111.63607706862837, 197 | "gps_accuracy": 6, 198 | "altitude": 1433.783642578125, 199 | "vertical_accuracy": 30, 200 | "friendly_name": "Rod-iPhone-16", 201 | "person_name": "rod", 202 | "entity_picture": "/local/rod-phone.png", 203 | }, 204 | ) 205 | 206 | friendly_name_variables = { 207 | "friendly_name_location": friendly_name_location, 208 | "person_name": target.attributes["person_name"], 209 | "source": sourceObject, 210 | "target": target, 211 | } 212 | _LOGGER.debug(f"friendly_name_variables = {friendly_name_variables}") 213 | 214 | result = await validate_template( 215 | hass, 216 | template_str, 217 | friendly_name_variables, 218 | expected="text", 219 | ) 220 | 221 | return result 222 | -------------------------------------------------------------------------------- /custom_components/person_location/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "menu": { 5 | "title": "Person Location Configuration Menu", 6 | "description": "You can come back to update the configuration later by clicking ` ⋮ → Reconfigure`. If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 7 | "data": { 8 | "menu_selection": "Step through or choose a configuration section:" 9 | } 10 | }, 11 | "geocode": { 12 | "title": "Person Location Geocode Configuration (1 of 2)", 13 | "description": "Reverse geocoding and mapping can be done using one (or more) external services. The key for services that are not used should be entered as `not used`. If you need help with the configuration or obtaining keys, have a look at More Details [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 14 | "data": { 15 | "language": "Google Language", 16 | "region": "Google Country Code", 17 | "google_api_key": "Google API Key", 18 | "mapbox_api_key": "Mapbox Access Token", 19 | "mapquest_api_key": "MapQuest API Key", 20 | "osm_api_key": "OSM API Key (your eMail Address)", 21 | "radar_api_key": "Radar API Key (publishable)" 22 | } 23 | }, 24 | "source": { 25 | "title": "Person Location Geocode Configuration (2 of 2)", 26 | "description": "Driving distance and duration can be obtained using one of the following external services. If you need help with the configuration or obtaining keys, have a look at More Details [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 27 | "data": { 28 | "distance_duration_source": "Source for driving distance and duration" 29 | } 30 | }, 31 | "sensors": { 32 | "title": "Person Location Sensor Configuration", 33 | "description": "We can create individual sensors so that template sensors do not need to be manually configured. Choose from this list: altitude, bread_crumbs, direction, driving_miles, driving_minutes, geocoded, latitude, longitude, meters_from_home, miles_from_home. If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 34 | "data": { 35 | "create_sensors": "Sensors to be created (comma separated)", 36 | "altitude": "altitude", 37 | "bread_crumbs": "bread_crumbs", 38 | "direction": "direction", 39 | "driving_miles": "driving_miles", 40 | "driving_minutes": "driving_minutes", 41 | "geocoded": "geocoded", 42 | "latitude": "latitude", 43 | "longitude": "longitude", 44 | "meters_from_home": "meters_from_home", 45 | "miles_from_home": "miles_from_home", 46 | "platform": "Platform for output sensor (experimental)" 47 | } 48 | }, 49 | "triggers": { 50 | "title": "Person Location Trigger Configuration", 51 | "description": "You can choose to follow updates of all Person entities rather than configuring individual trigger devices. Individual triggers may be added or edited below. If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 52 | "data": { 53 | "follow_person_integration": "Follow Person Integration?", 54 | "new_device_entity": "Trigger Entity (device) to add", 55 | "new_person_name": "Person Name for the new trigger", 56 | "device_choice": "Choose a trigger to edit, or return to menu:" 57 | } 58 | }, 59 | "trigger_edit": { 60 | "title": "Person Location Trigger Edit", 61 | "description": "If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 62 | "data": { 63 | "edit_action": "Action to be taken on {device}:", 64 | "new_device_entity": "Trigger Entity (device)", 65 | "new_person_name": "Person Name for the trigger" 66 | } 67 | }, 68 | "providers": { 69 | "title": "Person Location Map Camera Configuration", 70 | "description": "The integration provides a camera platform that can be used to provide a dynamic map image. Some knowledge of the 3rd-party mapping API is necessary to adjust these maps, so get into it only if you enjoy coding and figuring things out.\n\nMap camera providers may be selected below. If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 71 | "data": { 72 | "provider_choice": "Choose the provider to edit, add a new one, or return to menu:" 73 | } 74 | }, 75 | "provider_add": { 76 | "title": "Person Location Map Camera Add", 77 | "description": "The integration provides a camera platform that can be used to provide a dynamic map image. Some knowledge of the 3rd-party mapping API is necessary to adjust these maps, so get into it only if you enjoy coding and figuring things out.\n\nAdd information for a new Map Camera. You may find it helpful to copy/paste the templates from an editor (like Developer tools > Template). If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters). Templating is explained [**here**](https://www.home-assistant.io/docs/configuration/templating/).", 78 | "data": { 79 | "name": "Map Camera Name", 80 | "state": "Map Camera State Template", 81 | "still_image_url": "Map Camera URL Template" 82 | } 83 | }, 84 | "provider_edit": { 85 | "title": "Person Location Map Camera Edit", 86 | "description": "Some knowledge of the 3rd-party mapping API is necessary to adjust these maps, so get into it only if you enjoy coding and figuring things out.\n\nYou may find it helpful to copy/paste the templates from an editor (like Developer tools > Template). If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters). Templating is explained [**here**](https://www.home-assistant.io/docs/configuration/templating/).", 87 | "data": { 88 | "edit_action": "Action to be taken on map camera {provider}:", 89 | "state": "Map Camera State Template", 90 | "still_image_url": "Map Camera URL Template" 91 | } 92 | }, 93 | "provider_preview": { 94 | "title": "Person Location Map Camera Preview", 95 | "description": "**Map Camera Name:** `camera.{provider_name}`\n\n **Map Camera State Preview:** `{provider_state_preview}` \n{state_missing_entities} \n\n**Map Camera URL Preview:** `{provider_url_preview}` \n{url_missing_entities}", 96 | "data": { 97 | "next_action": "Action to be taken on Submit:", 98 | "state": "Map Camera State Template", 99 | "still_image_url": "Map Camera URL Template" 100 | } 101 | } 102 | }, 103 | "error": { 104 | "sensor_invalid": "Invalid sensor entered.", 105 | "duplicate_device": "Trigger Entity is already used.", 106 | "duplicate_name": "Name is already used", 107 | "missing_person": "Person Name must also be specified.", 108 | "missing_device": "Trigger Entity must also be specified.", 109 | "missing_three": "All three must be specified if any are.", 110 | "invalid_state_template": "Invalid State template. [{state_error}]", 111 | "invalid_url_template": "Invalid URL template. [{url_error}]", 112 | "invalid_key": "API key not accepted.", 113 | "invalid_email": "eMail address is not valid.", 114 | "nothing_was_changed": "No changes were entered for the configuration." 115 | }, 116 | "abort": { 117 | "already_configured": "Already configured and only a single instance is allowed.", 118 | "reconfigure_successful": "The pre-existing entry has been updated." 119 | } 120 | }, 121 | "options": { 122 | "step": { 123 | "general": { 124 | "title": "Person Location Options", 125 | "description": "If you need help with the configuration, have a look [**here**](https://github.com/rodpayne/home-assistant_person_location/blob/master/README.md#configuration-parameters).", 126 | "data": { 127 | "extended_away": "Hours Extended Away", 128 | "just_arrived": "Minutes Just Arrived", 129 | "just_left": "Minutes Just Left", 130 | "show_zone_when_away": "Show zone in state when away?", 131 | "friendly_name_template": "Friendly Name Template", 132 | "_preview_friendly_name": "{friendly_preview}" 133 | } 134 | } 135 | }, 136 | "error": { 137 | "nothing_was_changed": "No changes were entered for the configuration.", 138 | "template_required": "Template required." 139 | }, 140 | "abort": { 141 | "normal_exit": "Configuration finished." 142 | } 143 | 144 | }, 145 | "services": { 146 | "reverse_geocode": { 147 | "name": "Reverse Geocode", 148 | "description": "Service to reverse geocode the location in a person status sensor.", 149 | "fields": { 150 | "entity_id": { 151 | "name": "Entity ID", 152 | "description": "Name of the entitity to examine and update." 153 | }, 154 | "force_update": { 155 | "name": "Force Update", 156 | "description": "Should geocoding be done even if the distance is less than MIN_DISTANCE_TRAVELLED (5 meters) (optional)?" 157 | }, 158 | "friendly_name_template": { 159 | "name": "Friendly Name Template", 160 | "description": "Template for the entitiy's friendly_name (optional)." 161 | } 162 | } 163 | }, 164 | "geocode_api_on": { 165 | "name": "Geocode API On", 166 | "description": "Resume using the Open Street Map API and WazeRouteCalculator." 167 | }, 168 | "geocode_api_off": { 169 | "name": "Geocode API Off", 170 | "description": "Pause using the Open Street Map API. This may be necessary if a malfunction causes an excessive number of calls or if the external site is unreachable. The Nominatim Usage Policy says, 'Apps must make sure that they can switch off the service at our request at any time (in particular, switching should be possible without requiring a software update).' This also pauses the WazeRouteCalculator calls." 171 | }, 172 | "process_trigger": { 173 | "name": "Process Trigger", 174 | "description": "Service to process the changes of a device tracker or sensor.", 175 | "fields": { 176 | "entity_id": { 177 | "name": "Entity ID", 178 | "description": "Name of the device tracker or sensor that has changed." 179 | }, 180 | "from_state": { 181 | "name": "From State", 182 | "description": "The previous state." 183 | }, 184 | "to_state": { 185 | "name": "To State", 186 | "description": "The updated state." 187 | } 188 | } 189 | } 190 | } 191 | } -------------------------------------------------------------------------------- /custom_components/person_location/helpers/api.py: -------------------------------------------------------------------------------- 1 | """API Client Wrapper with retries and exponential backoff.""" 2 | 3 | import asyncio 4 | import logging 5 | import socket 6 | import traceback 7 | 8 | import aiohttp 9 | import async_timeout 10 | 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 13 | 14 | from ..const import ( 15 | CONF_LANGUAGE, 16 | CONF_REGION, 17 | DOMAIN, 18 | PERSON_LOCATION_INTEGRATION, 19 | ) 20 | 21 | _LOGGER: logging.Logger = logging.getLogger(__package__) 22 | 23 | HEADERS = {"Content-type": "application/json; charset=UTF-8"} 24 | RETRIES = 2 # number of retry attempts 25 | TIMEOUT = 10 # seconds 26 | 27 | # ------- Entry point for a generic API call: 28 | 29 | 30 | async def async_person_location_get_api_data( 31 | hass: HomeAssistant, 32 | method: str, 33 | url: str, 34 | data: dict = {}, 35 | headers: dict = HEADERS, 36 | timeout: float = TIMEOUT, 37 | retries: int = RETRIES, 38 | ) -> dict: 39 | """Wrap call to PERSON_LOCATION_CLIENT.async_get_api_data.""" 40 | client = PERSON_LOCATION_CLIENT(hass) 41 | return await client.async_get_api_data(method, url, data, headers, timeout, retries) 42 | 43 | 44 | # ------- Entry points for specific API calls and error checking: 45 | 46 | 47 | async def async_get_google_maps_geocoding( 48 | hass: HomeAssistant, key: str, latitude: str, longitude: str 49 | ) -> dict: 50 | """Call the Google Maps Geocoding API.""" 51 | pli: PERSON_LOCATION_INTEGRATION = hass.data[DOMAIN]["integration"] 52 | url = ( 53 | "https://maps.googleapis.com/maps/api/geocode/json?language=" 54 | + pli.configuration[CONF_LANGUAGE] 55 | + "®ion=" 56 | + pli.configuration[CONF_REGION] 57 | + "&latlng=" 58 | + str(latitude) 59 | + "," 60 | + str(longitude) 61 | + "&key=" 62 | + key 63 | ) 64 | client = PERSON_LOCATION_CLIENT(pli.hass) 65 | resp = await client.async_get_api_data("get", url) 66 | if not resp["ok"]: 67 | return resp 68 | # resp["status"] -> HTTP status code (e.g. 200, 404) 69 | if resp.get("status") == 200 and resp.get("data"): 70 | # resp["data"]["status"] -> Google API status field (e.g. "OK", "ZERO_RESULTS", "REQUEST_DENIED") 71 | api_status = resp["data"].get("status") 72 | if api_status == "OK": 73 | return resp 74 | _LOGGER.debug( 75 | "[async_get_google_maps_geocode] Google API status: %s", api_status 76 | ) 77 | resp["error"] = f"API status: {api_status}" 78 | else: 79 | _LOGGER.debug( 80 | "[async_get_google_maps_geocode] Google HTTP status: %s, data: %s", 81 | resp.get("status"), 82 | resp.get("data").replace(key, "********"), 83 | ) 84 | resp["error"] = f"HTTP status: {resp.get('status')}" 85 | resp["ok"] = False 86 | return resp 87 | 88 | 89 | async def async_get_mapbox_static_image( 90 | hass: HomeAssistant, key: str, latitude: str, longitude: str 91 | ) -> dict: 92 | """Call the Mapbox Static Image API.""" 93 | url = f"https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/{longitude},{latitude},5,0/300x200?access_token={key}" 94 | client = PERSON_LOCATION_CLIENT(hass) 95 | resp = await client.async_get_api_data("get", url) 96 | if not resp["ok"]: 97 | return resp 98 | # resp["status"] -> HTTP status code (e.g. 200, 404) 99 | if resp.get("status") == 200: 100 | return resp 101 | else: 102 | _LOGGER.debug( 103 | "[async_get_mapbox_geocode] Mapbox HTTP status: %s, data: %s", 104 | resp.get("status"), 105 | resp.get("data"), 106 | ) 107 | resp["error"] = f"HTTP status: {resp.get('status')}" 108 | resp["ok"] = False 109 | return resp 110 | 111 | 112 | async def async_get_mapquest_reverse_geocoding( 113 | hass: HomeAssistant, key: str, latitude: str, longitude: str 114 | ) -> dict: 115 | """Call the Mapquest Reverse Geocoding API.""" 116 | url = ( 117 | "https://www.mapquestapi.com/geocoding/v1/reverse" 118 | + "?location=" 119 | + str(latitude) 120 | + "," 121 | + str(longitude) 122 | + "&thumbMaps=false" 123 | + "&key=" 124 | + key 125 | ) 126 | client = PERSON_LOCATION_CLIENT(hass) 127 | resp = await client.async_get_api_data("get", url) 128 | if not resp["ok"]: 129 | return resp 130 | # resp["status"] -> HTTP status code (e.g. 200, 404) 131 | if resp.get("status") == 200: 132 | return resp 133 | else: 134 | _LOGGER.debug( 135 | "[async_get_mapquest_reverse_geocoding] Mapquest HTTP status: %s, data: %s", 136 | resp.get("status"), 137 | resp.get("data"), 138 | ) 139 | resp["error"] = f"HTTP status: {resp.get('status')}" 140 | resp["ok"] = False 141 | return resp 142 | 143 | 144 | async def async_get_open_street_map_reverse_geocoding( 145 | hass: HomeAssistant, key: str, latitude: str, longitude: str 146 | ) -> dict: 147 | """Call the Nominatim Reverse Geocoding (OpenStreetMap) API.""" 148 | if key: 149 | url = ( 150 | "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=" 151 | + str(latitude) 152 | + "&lon=" 153 | + str(longitude) 154 | + "&addressdetails=1&namedetails=1&zoom=18&limit=1&email=" 155 | + key 156 | ) 157 | else: 158 | url = ( 159 | "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=" 160 | + str(latitude) 161 | + "&lon=" 162 | + str(longitude) 163 | + "&addressdetails=1&namedetails=1&zoom=18&limit=1" 164 | ) 165 | 166 | client = PERSON_LOCATION_CLIENT(hass) 167 | resp = await client.async_get_api_data("get", url) 168 | if not resp["ok"]: 169 | return resp 170 | # resp["status"] -> HTTP status code (e.g. 200, 404) 171 | if resp.get("status") == 200: 172 | return resp 173 | else: 174 | _LOGGER.debug( 175 | "[async_get_open_street_map_reverse_geocoding] Open Street Map HTTP status: %s, data: %s", 176 | resp.get("status"), 177 | resp.get("data"), 178 | ) 179 | resp["error"] = f"HTTP status: {resp.get('status')}" 180 | resp["ok"] = False 181 | return resp 182 | 183 | 184 | async def async_get_radar_reverse_geocoding( 185 | hass: HomeAssistant, key: str, latitude: str, longitude: str 186 | ) -> dict: 187 | """Call the Radar Reverse Geocoding API.""" 188 | url = f"https://api.radar.io/v1/geocode/reverse?coordinates={latitude},{longitude}" 189 | headers = {"Authorization": key, "Content-Type": "application/json"} 190 | client = PERSON_LOCATION_CLIENT(hass) 191 | resp = await client.async_get_api_data("get", url, headers=headers) 192 | if not resp["ok"]: 193 | return resp 194 | # resp["status"] -> HTTP status code (e.g. 200, 404) 195 | if resp.get("status") == 200 and resp.get("data"): 196 | # resp["data"]["meta"]["code"] -> Radar API response code (e.g. 200, 400, 401, 403, 404, 429, 500) 197 | api_code = resp["data"].get("meta", {}).get("code") 198 | if api_code == 200: 199 | return resp 200 | api_message = resp["data"].get("meta").get("message") 201 | _LOGGER.debug( 202 | "[async_get_radar_reverse_geocoding] Radar API code: %s, message: %s", 203 | api_code, 204 | api_message, 205 | ) 206 | resp["error"] = f"API status: {api_code} message: {api_message}" 207 | resp["status"] = api_code 208 | else: 209 | _LOGGER.debug( 210 | "[async_get_radar_reverse_geocoding] Radar HTTP status: %s, data: %s", 211 | resp.get("status"), 212 | resp.get("data"), 213 | ) 214 | resp["error"] = f"HTTP status: {resp.get('status')}" 215 | resp["ok"] = False 216 | return resp 217 | 218 | 219 | # ----------------------------------------------------------------------------- 220 | # Normalized API Response Schema 221 | # 222 | # All API calls return a dictionary with a consistent structure: 223 | # 224 | # { 225 | # "data": dict|None, # Parsed JSON payload, or None if parsing failed 226 | # "error": str|None, # Populated only if retries exhausted or unexpected error 227 | # "headers": dict, # Response headers 228 | # "ok": bool, # Convenience flag: True if status < 400 229 | # "status": int, # HTTP status code (e.g. 200, 404, 500) 230 | # "url": str, # Final request URL 231 | # } 232 | # 233 | # This schema ensures downstream code can reliably check both HTTP status 234 | # and parsed payload without guessing the failure mode. 235 | # ----------------------------------------------------------------------------- 236 | 237 | 238 | async def _normalize_response(response: aiohttp.ClientResponse) -> dict: 239 | """Normalize aiohttp response into a consistent schema.""" 240 | try: 241 | payload = await response.json(content_type=None) # allow non-JSON gracefully 242 | except Exception: 243 | payload = None 244 | 245 | return { 246 | "ok": response.status < 400, # quick boolean flag 247 | "status": response.status, # HTTP status code 248 | "url": str(response.url), 249 | "headers": dict(response.headers), 250 | "data": payload, # parsed JSON or None 251 | } 252 | 253 | 254 | # ------- Make the actual API call: 255 | 256 | 257 | class PERSON_LOCATION_CLIENT: 258 | """API Client Wrapper with retries and exponential backoff.""" 259 | 260 | def __init__(self, hass: HomeAssistant) -> None: 261 | """Initialize class PersonLocationClient.""" 262 | # Use HA-managed aiohttp client 263 | self._session: aiohttp.ClientSession = async_get_clientsession(hass) 264 | 265 | async def async_get_api_data( 266 | self, 267 | method: str, 268 | url: str, 269 | data: dict = {}, 270 | headers: dict = HEADERS, 271 | timeout: float = TIMEOUT, 272 | retries: int = RETRIES, 273 | ) -> dict: 274 | """Get data from the API. Placeholder for cache, etc.""" 275 | return await self._api_wrapper(method, url, data, headers, timeout, retries) 276 | 277 | async def _api_wrapper( 278 | self, 279 | method: str, 280 | url: str, 281 | data: dict = {}, 282 | headers: dict = {}, 283 | timeout: float = TIMEOUT, 284 | retries: int = RETRIES, 285 | ) -> dict: 286 | """Get information from the API with retries and exponential backoff.""" 287 | _LOGGER.debug("[_api_wrapper] %s", url.split("?", 1)[0]) 288 | 289 | last_error = None 290 | 291 | for attempt in range(1, RETRIES + 1): 292 | try: 293 | async with async_timeout.timeout(TIMEOUT): 294 | if method == "get": 295 | response = await self._session.get(url, headers=headers) 296 | elif method == "put": 297 | response = await self._session.put( 298 | url, headers=headers, json=data 299 | ) 300 | elif method == "patch": 301 | response = await self._session.patch( 302 | url, headers=headers, json=data 303 | ) 304 | elif method == "post": 305 | response = await self._session.post( 306 | url, headers=headers, json=data 307 | ) 308 | else: 309 | raise ValueError(f"Unsupported method: {method}") 310 | 311 | if response.status >= 400: 312 | last_error = f"HTTP error fetching {url.split('?', 1)[0]} - status: {response.status}" 313 | _LOGGER.debug( 314 | "Attempt %s/%s failed due to: %s", 315 | attempt, 316 | RETRIES, 317 | last_error, 318 | ) 319 | 320 | else: 321 | # ------ Return normal response: 322 | return await _normalize_response(response) 323 | 324 | except TimeoutError as exception: 325 | last_error = ( 326 | f"Timeout error fetching {url.split('?', 1)[0]} - {exception}" 327 | ) 328 | _LOGGER.debug( 329 | "Attempt %s/%s failed: %s", 330 | attempt, 331 | RETRIES, 332 | last_error, 333 | ) 334 | 335 | except (aiohttp.ClientError, socket.gaierror) as exception: 336 | last_error = f"HTTP error fetching {url.split('?', 1)[0]} - {exception}" 337 | _LOGGER.debug( 338 | "Attempt %s/%s failed due to client error: %s", 339 | attempt, 340 | RETRIES, 341 | last_error, 342 | ) 343 | 344 | except Exception as e: # pylint: disable=broad-except 345 | last_error = f"Unexpected error: {type(e).__name__}: {e}" 346 | _LOGGER.debug( 347 | "Attempt %s/%s failed due to unexpected error: %s", 348 | attempt, 349 | RETRIES, 350 | last_error, 351 | ) 352 | _LOGGER.debug(traceback.format_exc()) 353 | 354 | if attempt < RETRIES: 355 | # ------- Pause and then retry error: 356 | delay = 2 ** (attempt - 1) 357 | _LOGGER.debug("Retrying in %s seconds...", delay) 358 | await asyncio.sleep(delay) 359 | 360 | # ------- Return error response: 361 | _LOGGER.debug("All %s attempts failed for %s", RETRIES, url) 362 | return { 363 | "status": None, 364 | "ok": False, 365 | "data": None, 366 | "error": last_error or "Unknown error", 367 | } 368 | -------------------------------------------------------------------------------- /custom_components/person_location/helpers/duration_distance.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import random 5 | import traceback 6 | 7 | import aiohttp 8 | from pywaze.route_calculator import WazeRouteCalculator 9 | 10 | from homeassistant.exceptions import ServiceNotFound 11 | from homeassistant.helpers.httpx_client import get_async_client 12 | 13 | from ..const import ( 14 | ATTR_ATTRIBUTION, 15 | ATTR_DRIVING_MILES, 16 | ATTR_DRIVING_MINUTES, 17 | ATTR_METERS_FROM_HOME, 18 | ATTR_MILES_FROM_HOME, 19 | CONF_DISTANCE_DURATION_SOURCE, 20 | CONF_GOOGLE_API_KEY, 21 | CONF_LANGUAGE, 22 | CONF_MAPBOX_API_KEY, 23 | CONF_MAPQUEST_API_KEY, 24 | CONF_OSM_API_KEY, 25 | CONF_RADAR_API_KEY, 26 | CONF_REGION, 27 | DEFAULT_API_KEY_NOT_SET, 28 | METERS_PER_KM, 29 | METERS_PER_MILE, 30 | PERSON_LOCATION_INTEGRATION, 31 | WAZE_MIN_METERS_FROM_HOME, 32 | get_waze_region, 33 | ) 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | 38 | async def update_driving_miles_and_minutes( 39 | pli: PERSON_LOCATION_INTEGRATION, 40 | target, 41 | new_latitude: str, 42 | new_longitude: str, 43 | waze_country_code: str, 44 | ) -> None: 45 | """ 46 | Update driving duration and distance. 47 | 48 | Input target._attr_extra_state_attributes: 49 | ATTR_METERS_FROM_HOME 50 | ATTR_MILES_FROM_HOME 51 | 52 | Updates target._attr_extra_state_attributes: 53 | ATTR_DRIVING_MILES 54 | ATTR_DRIVING_MINUTES 55 | ATTR_ATTRIBUTION 56 | 57 | May update pli.attributes: 58 | "waze_error_count" 59 | """ 60 | try: 61 | distance_duration_source = pli.configuration[CONF_DISTANCE_DURATION_SOURCE] 62 | entity_id = target.entity_id 63 | 64 | _LOGGER.debug( 65 | "[update_driving_miles_and_minutes] (%s) Source=%s", 66 | entity_id, 67 | distance_duration_source, 68 | ) 69 | 70 | if distance_duration_source == "none": 71 | return 72 | 73 | # If we’re already “home,” skip routing 74 | if ( 75 | target._attr_extra_state_attributes[ATTR_METERS_FROM_HOME] 76 | < WAZE_MIN_METERS_FROM_HOME 77 | ): 78 | target._attr_extra_state_attributes[ATTR_DRIVING_MILES] = ( 79 | target._attr_extra_state_attributes[ATTR_MILES_FROM_HOME] 80 | ) 81 | target._attr_extra_state_attributes[ATTR_DRIVING_MINUTES] = "0" 82 | _LOGGER.debug( 83 | "[update_driving_miles_and_minutes] Too close to home for lookup to matter." 84 | ) 85 | return 86 | 87 | from_location = f"{new_latitude},{new_longitude}" 88 | _LOGGER.debug( 89 | "[update_driving_miles_and_minutes] from_location: " + from_location 90 | ) 91 | 92 | to_location = ( 93 | f"{pli.attributes['home_latitude']},{pli.attributes['home_longitude']}" 94 | ) 95 | _LOGGER.debug("[update_driving_miles_and_minutes] to_location: " + to_location) 96 | 97 | # ------- Waze -------------------------------------------------- 98 | 99 | if distance_duration_source == "waze": 100 | waze_region = get_waze_region(waze_country_code) 101 | _LOGGER.debug( 102 | "[update_driving_miles_and_minutes] waze_region: " + waze_region 103 | ) 104 | 105 | # First attempt: HA-managed service 106 | try: 107 | if not pli.hass.services.has_service( 108 | "waze_travel_time", "get_travel_times" 109 | ): 110 | raise ServiceNotFound("waze_travel_time", "get_travel_times") 111 | 112 | data = await pli.hass.services.async_call( 113 | "waze_travel_time", 114 | "get_travel_times", 115 | { 116 | "origin": from_location, 117 | "destination": to_location, 118 | "region": waze_region, 119 | }, 120 | blocking=True, 121 | return_response=True, 122 | ) 123 | routes = data.get("routes", []) 124 | if not routes: 125 | raise ValueError("No routes from HA service") 126 | 127 | # Pick first or apply your street‐name filter here 128 | best = routes[0] 129 | duration_min = best["duration"] 130 | distance_km = best["distance"] 131 | 132 | _LOGGER.debug( 133 | "[update_driving_miles_and_minutes] (%s) Waze service returned duration (%s), distance_km (%s)", 134 | entity_id, 135 | duration_min, 136 | distance_km, 137 | ) 138 | 139 | except Exception as service_err: 140 | _LOGGER.debug( 141 | "[update_driving_miles_and_minutes] (%s) Waze service failed (%s), falling back to pywaze", 142 | entity_id, 143 | type(service_err).__name__, 144 | ) 145 | # Fallback: direct pywaze call 146 | try: 147 | # pywaze expects an aiohttp client session 148 | client = WazeRouteCalculator( 149 | region=waze_region.upper(), 150 | client=get_async_client(pli.hass), 151 | ) 152 | routes = await client.calc_routes( 153 | from_location, 154 | to_location, 155 | avoid_toll_roads=True, 156 | avoid_subscription_roads=True, 157 | avoid_ferries=True, 158 | ) 159 | if not routes: 160 | raise ValueError("No routes from pywaze") 161 | 162 | route = routes[0] 163 | duration_min = route.duration 164 | distance_km = route.distance 165 | 166 | _LOGGER.debug( 167 | "[update_driving_miles_and_minutes] (%s) pywaze returned duration (%s), distance_km (%s)", 168 | entity_id, 169 | duration_min, 170 | distance_km, 171 | ) 172 | 173 | except Exception as pw_err: 174 | _LOGGER.error( 175 | "[update_driving_miles_and_minutes] (%s) pywaze fallback failed %s: %s", 176 | entity_id, 177 | type(pw_err).__name__, 178 | pw_err, 179 | ) 180 | pli.attributes["waze_error_count"] = ( 181 | pli.attributes.get("waze_error_count", 0) + 1 182 | ) 183 | target._attr_extra_state_attributes[ATTR_DRIVING_MILES] = ( 184 | target._attr_extra_state_attributes[ATTR_MILES_FROM_HOME] 185 | ) 186 | return 187 | # Waze was not used in reverse_geocode, so give attribution if used here 188 | target._attr_extra_state_attributes[ATTR_ATTRIBUTION] += ( 189 | '"Data by Waze App. https://waze.com"; ' 190 | ) 191 | 192 | # ------- Radar ------------------------------------------------- 193 | 194 | elif distance_duration_source == "radar": 195 | async with aiohttp.ClientSession() as session: 196 | data = await radar_calc_distance( 197 | pli, 198 | from_location, 199 | to_location, 200 | modes="car", 201 | units="metric", 202 | session=session, 203 | ) 204 | duration_min, distance_m = extract_duration_distance(data) 205 | distance_km = distance_m / METERS_PER_KM 206 | 207 | # ------- Google -------------------------------------------------- 208 | 209 | elif distance_duration_source == "google_maps": 210 | GOOGLE_DISTANCE_MATRIX_URL = ( 211 | "https://maps.googleapis.com/maps/api/distancematrix/json" 212 | ) 213 | 214 | api_key = pli.configuration.get( 215 | CONF_GOOGLE_API_KEY, DEFAULT_API_KEY_NOT_SET 216 | ) 217 | if not api_key or api_key == DEFAULT_API_KEY_NOT_SET: 218 | _LOGGER.error( 219 | "[update_driving_miles_and_minutes] CONF_GOOGLE_API_KEY not set" 220 | ) 221 | return 222 | 223 | async with aiohttp.ClientSession() as session: 224 | params = { 225 | "origins": from_location, 226 | "destinations": to_location, 227 | "mode": "driving", 228 | "units": "metric", 229 | "key": api_key, 230 | } 231 | async with session.get( 232 | GOOGLE_DISTANCE_MATRIX_URL, params=params 233 | ) as resp: 234 | if resp.status != 200: 235 | text = await resp.text() 236 | raise RuntimeError(f"Google API error {resp.status}: {text}") 237 | data = await resp.json() 238 | rows = data.get("rows", []) 239 | if not rows or not rows[0].get("elements"): 240 | raise ValueError("No routes returned by Google API") 241 | 242 | element = rows[0]["elements"][0] 243 | duration_sec = element["duration"]["value"] # seconds 244 | distance_m = element["distance"]["value"] # meters 245 | 246 | duration_min = duration_sec / 60.0 247 | distance_km = distance_m / 1000.0 248 | 249 | # ------- Mapbox -------------------------------------------------- 250 | 251 | elif distance_duration_source == "mapbox": 252 | async with aiohttp.ClientSession() as session: 253 | minutes, km = await mapbox_calc_distance( 254 | pli, from_location, to_location, session=session 255 | ) 256 | duration_min = minutes 257 | distance_km = km 258 | 259 | # ------- Unknown ------------------------------------------------- 260 | 261 | else: 262 | _LOGGER.debug( 263 | "[update_driving_miles_and_minutes] Source (%s) not handled.", 264 | distance_duration_source, 265 | ) 266 | return 267 | 268 | # ------- Common post‐processing 269 | 270 | miles = distance_km * METERS_PER_KM / METERS_PER_MILE 271 | if miles <= 0: 272 | display_miles = target._attr_extra_state_attributes[ATTR_MILES_FROM_HOME] 273 | elif miles >= 100: 274 | display_miles = round(miles, 0) 275 | elif miles >= 10: 276 | display_miles = round(miles, 1) 277 | else: 278 | display_miles = round(miles, 2) 279 | 280 | target._attr_extra_state_attributes[ATTR_DRIVING_MILES] = str(display_miles) 281 | target._attr_extra_state_attributes[ATTR_DRIVING_MINUTES] = str( 282 | round(duration_min, 1) 283 | ) 284 | 285 | _LOGGER.debug( 286 | "[update_driving_miles_and_minutes] %s returned duration=%.1f minutes, distance=%s miles", 287 | distance_duration_source, 288 | duration_min, 289 | display_miles, 290 | ) 291 | 292 | except Exception as e: 293 | _LOGGER.error( 294 | "[update_driving_miles_and_minutes] Exception %s: %s", 295 | type(e).__name__, 296 | str(e), 297 | ) 298 | _LOGGER.debug(traceback.format_exc()) 299 | pli.attributes["api_error_count"] += 1 300 | 301 | 302 | # ------- Radar ------------------------------------------------- 303 | 304 | 305 | async def radar_calc_distance( 306 | pli: PERSON_LOCATION_INTEGRATION, 307 | origin: str, 308 | destination: str, 309 | modes: str = "car", 310 | units: str = "metric", 311 | session: aiohttp.ClientSession = None, 312 | max_retries: int = 3, 313 | base_backoff: float = 1.0, 314 | ) -> dict: 315 | """ 316 | Async Radar Distance API call with retry/backoff for 429 and network errors. 317 | 318 | Returns JSON with duration and distance for the given mode. 319 | """ 320 | RADAR_BASE_URL = "https://api.radar.io/v1/route/distance" 321 | 322 | api_key = pli.configuration.get(CONF_RADAR_API_KEY, DEFAULT_API_KEY_NOT_SET) 323 | 324 | if not api_key or api_key == DEFAULT_API_KEY_NOT_SET: 325 | raise RuntimeError("CONF_RADAR_API_KEY is not set.") 326 | 327 | params = { 328 | "origin": origin, 329 | "destination": destination, 330 | "modes": modes, 331 | "units": units, 332 | } 333 | headers = {"Authorization": api_key} 334 | 335 | close_session = False 336 | if session is None: 337 | session = aiohttp.ClientSession() 338 | close_session = True 339 | 340 | try: 341 | for attempt in range(max_retries): 342 | try: 343 | async with session.get( 344 | RADAR_BASE_URL, headers=headers, params=params 345 | ) as resp: 346 | if resp.status == 200: 347 | return await resp.json() 348 | elif resp.status == 429: 349 | # Rate limit: exponential backoff with jitter 350 | wait = base_backoff * (2**attempt) + random.uniform(0, 0.5) 351 | print( 352 | f"Radar API rate-limited (429). Retrying in {wait:.1f}s..." 353 | ) 354 | await asyncio.sleep(wait) 355 | else: 356 | text = await resp.text() 357 | raise RuntimeError(f"Radar API error {resp.status}: {text}") 358 | except (TimeoutError, aiohttp.ClientError) as e: 359 | # Network error: retry with backoff 360 | wait = base_backoff * (2**attempt) + random.uniform(0, 0.5) 361 | print(f"Network error: {e}. Retrying in {wait:.1f}s...") 362 | await asyncio.sleep(wait) 363 | raise RuntimeError("Radar API request failed after retries.") 364 | finally: 365 | if close_session: 366 | await session.close() 367 | 368 | 369 | def extract_duration_distance(data: dict, mode: str = "car") -> tuple: 370 | """Extract duration (seconds) and distance (meters) from Radar API response.""" 371 | routes = data.get("routes", {}) 372 | mode_obj = routes.get(mode, {}) 373 | duration = (mode_obj.get("duration") or {}).get("value", 0.0) # minutes 374 | distance = (mode_obj.get("distance") or {}).get("value", 0.0) # meters 375 | return duration, distance 376 | 377 | 378 | # ------- Mapbox -------------------------------------------------- 379 | 380 | 381 | async def mapbox_calc_distance( 382 | pli: PERSON_LOCATION_INTEGRATION, 383 | origin: str, 384 | destination: str, 385 | profile: str = "driving", 386 | session: aiohttp.ClientSession = None, 387 | ) -> tuple: 388 | """ 389 | Query Mapbox Directions API for duration and distance. 390 | 391 | Returns (minutes, kilometers). 392 | """ 393 | MAPBOX_DIRECTIONS_URL = "https://api.mapbox.com/directions/v5/mapbox" 394 | 395 | api_key = pli.configuration.get(CONF_MAPBOX_API_KEY, DEFAULT_API_KEY_NOT_SET) 396 | if not api_key or api_key == DEFAULT_API_KEY_NOT_SET: 397 | _LOGGER.error("[update_driving_miles_and_minutes] Mapbox API key not set") 398 | return 399 | 400 | # Mapbox expects "lon,lat" order 401 | origin_lat, origin_lon = origin.split(",") 402 | dest_lat, dest_lon = destination.split(",") 403 | coords = f"{origin_lon},{origin_lat};{dest_lon},{dest_lat}" 404 | 405 | url = f"{MAPBOX_DIRECTIONS_URL}/{profile}/{coords}" 406 | params = { 407 | "access_token": api_key, 408 | "geometries": "geojson", 409 | "overview": "simplified", 410 | } 411 | 412 | close_session = False 413 | if session is None: 414 | session = aiohttp.ClientSession() 415 | close_session = True 416 | 417 | try: 418 | async with session.get(url, params=params) as resp: 419 | if resp.status != 200: 420 | text = await resp.text() 421 | raise RuntimeError(f"Mapbox API error {resp.status}: {text}") 422 | 423 | data = await resp.json() 424 | routes = data.get("routes", []) 425 | if not routes: 426 | raise ValueError("No routes returned by Mapbox API") 427 | 428 | route = routes[0] 429 | duration_sec = route["duration"] # seconds 430 | distance_m = route["distance"] # meters 431 | 432 | minutes = duration_sec / 60.0 433 | kilometers = distance_m / 1000.0 434 | 435 | _LOGGER.debug( 436 | "[update_driving_miles_and_minutes] Mapbox returned duration=%.1f min, distance=%.2f km", 437 | minutes, 438 | kilometers, 439 | ) 440 | 441 | return minutes, kilometers 442 | finally: 443 | if close_session: 444 | await session.close() 445 | -------------------------------------------------------------------------------- /custom_components/person_location/const.py: -------------------------------------------------------------------------------- 1 | """Constants and Classes for person_location integration.""" 2 | 3 | import asyncio 4 | from datetime import datetime, timedelta 5 | import logging 6 | import threading 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.components.mobile_app.const import ATTR_VERTICAL_ACCURACY 11 | from homeassistant.components.waze_travel_time.const import REGIONS as WAZE_REGIONS 12 | from homeassistant.components.zone.const import DOMAIN as ZONE_DOMAIN 13 | from homeassistant.const import ( 14 | ATTR_ATTRIBUTION, 15 | ATTR_FRIENDLY_NAME, 16 | ATTR_GPS_ACCURACY, 17 | ATTR_ICON, 18 | ATTR_LATITUDE, 19 | ATTR_LONGITUDE, 20 | ATTR_UNIT_OF_MEASUREMENT, 21 | STATE_HOME, 22 | STATE_NOT_HOME, 23 | STATE_ON, 24 | STATE_UNKNOWN, 25 | ) 26 | from homeassistant.core import HomeAssistant 27 | import homeassistant.helpers.config_validation as cv 28 | from homeassistant.util.yaml.objects import ( 29 | NodeListClass, 30 | NodeStrClass, 31 | ) 32 | 33 | # Our info: 34 | 35 | DOMAIN = "person_location" 36 | API_STATE_OBJECT = DOMAIN + "." + DOMAIN + "_integration" 37 | INTEGRATION_NAME = "Person Location" 38 | ISSUE_URL = "https://github.com/rodpayne/home-assistant_person_location/issues" 39 | 40 | VERSION = "2025.12.17" 41 | 42 | # Titles for the config entries: 43 | 44 | # TITLE_IMPORTED_YAML_CONFIG = "Imported YAML Config" 45 | TITLE_IMPORTED_YAML_CONFIG = "Person Location Config" 46 | TITLE_PERSON_LOCATION_CONFIG = "Person Location Config" 47 | 48 | # Constants: 49 | 50 | IC3_STATIONARY_STATE_PREFIX = "StatZon" 51 | IC3_STATIONARY_ZONE_PREFIX = "ic3_stationary_" 52 | METERS_PER_KM = 1000 53 | METERS_PER_MILE = 1609.34 54 | 55 | # Fixed parameters: 56 | 57 | FAR_AWAY_METERS = 400 * METERS_PER_KM 58 | MIN_DISTANCE_TRAVELLED_TO_GEOCODE = 5 # in km? 59 | THROTTLE_INTERVAL = timedelta( 60 | seconds=2 61 | ) # See https://operations.osmfoundation.org/policies/nominatim/ regarding throttling. 62 | WAZE_MIN_METERS_FROM_HOME = 500 63 | 64 | # Parameters that we may want to be configurable in the future: 65 | 66 | DEFAULT_LOCALITY_PRIORITY_OSM = ( 67 | # "neighbourhood", # ---- smallest urban division (e.g. block, named area) 68 | "suburb", # ------------ named area within a city 69 | "hamlet", # ------------ very small rural settlement 70 | "village", # ----------- small rural settlement 71 | "town", # -------------- larger than village, smaller than city 72 | "city_district", # ----- administrative district within a city 73 | "municipality", # ------ local government unit (varies by country) 74 | "city", # -------------- major urban center 75 | "county", # ------------ regional division (e.g. Utah County) 76 | "state_district", # ---- sub-state division (used in some countries) 77 | "state", # ------------- e.g. Utah 78 | "country", # ----------- e.g. United States 79 | ) 80 | 81 | # Attribute names: 82 | 83 | ATTR_ALTITUDE = "altitude" 84 | ATTR_BREAD_CRUMBS = "bread_crumbs" 85 | ATTR_COMPASS_BEARING = "compass_bearing" 86 | ATTR_DIRECTION = "direction" 87 | ATTR_DRIVING_MILES = "driving_miles" 88 | ATTR_DRIVING_MINUTES = "driving_minutes" 89 | ATTR_GEOCODED = "geocoded" 90 | ATTR_LAST_LOCATED = "last_located" 91 | ATTR_LOCATION_TIME = "location_time" 92 | ATTR_METERS_FROM_HOME = "meters_from_home" 93 | ATTR_MILES_FROM_HOME = "miles_from_home" 94 | ATTR_PERSON_NAME = "person_name" 95 | ATTR_REPORTED_STATE = "reported_state" 96 | ATTR_SOURCE = "source" 97 | ATTR_SPEED = "speed" 98 | ATTR_ZONE = "zone" 99 | 100 | ATTR_GOOGLE_MAPS = "Google_Maps" 101 | ATTR_MAPQUEST = "MapQuest" 102 | ATTR_OPEN_STREET_MAP = "Open_Street_Map" 103 | ATTR_RADAR = "Radar" 104 | 105 | # Items under target.this_entity_info: 106 | 107 | INFO_GEOCODE_COUNT = "geocode_count" 108 | INFO_LOCALITY = "locality" 109 | INFO_TRIGGER_COUNT = "trigger_count" 110 | INFO_LOCATION_LATITUDE = "location_latitide" 111 | INFO_LOCATION_LONGITUDE = "location_longitide" 112 | 113 | # -------------------------------------------- 114 | # Data (structural configuration parameters) 115 | # -------------------------------------------- 116 | 117 | CONF_CREATE_SENSORS = "create_sensors" 118 | VALID_CREATE_SENSORS = [ 119 | ATTR_ALTITUDE, 120 | ATTR_BREAD_CRUMBS, 121 | ATTR_DIRECTION, 122 | ATTR_DRIVING_MILES, 123 | ATTR_DRIVING_MINUTES, 124 | ATTR_GEOCODED, 125 | ATTR_LATITUDE, 126 | ATTR_LONGITUDE, 127 | ATTR_METERS_FROM_HOME, 128 | ATTR_MILES_FROM_HOME, 129 | ] 130 | 131 | CONF_FOLLOW_PERSON_INTEGRATION = "follow_person_integration" 132 | CONF_PERSON_NAMES = "person_names" 133 | CONF_DEVICES = "devices" 134 | VALID_ENTITY_DOMAINS = ("binary_sensor", "device_tracker", "person", "sensor") 135 | 136 | CONF_FROM_YAML = "configuration_from_yaml" 137 | CONF_DISTANCE_DURATION_SOURCE = "distance_duration_source" 138 | CONF_USE_WAZE = "use_waze" 139 | CONF_WAZE_REGION = "waze_region" 140 | 141 | CONF_LANGUAGE = "language" 142 | DEFAULT_LANGUAGE = "en" 143 | 144 | CONF_OUTPUT_PLATFORM = "platform" 145 | DEFAULT_OUTPUT_PLATFORM = "sensor" 146 | VALID_OUTPUT_PLATFORM = ["sensor", "device_tracker"] 147 | 148 | CONF_REGION = "region" 149 | DEFAULT_REGION = "US" 150 | 151 | CONF_GOOGLE_API_KEY = "google_api_key" 152 | CONF_MAPBOX_API_KEY = "mapbox_api_key" 153 | CONF_MAPQUEST_API_KEY = "mapquest_api_key" 154 | CONF_OSM_API_KEY = "osm_api_key" 155 | CONF_RADAR_API_KEY = "radar_api_key" 156 | DEFAULT_API_KEY_NOT_SET = "not used" 157 | 158 | # Camera provider fields 159 | 160 | CONF_CONTENT_TYPE = "content_type" 161 | CONF_NAME = "name" 162 | CONF_STATE = "state" 163 | CONF_STILL_IMAGE_URL = "still_image_url" 164 | CONF_VERIFY_SSL = "verify_ssl" 165 | 166 | # Camera provider management (OptionsFlow + config entry) 167 | 168 | CONF_DONE = "done" 169 | CONF_EDIT_PROVIDER = "edit_provider" 170 | CONF_NEW_PROVIDER_NAME = "new_provider_name" 171 | CONF_NEW_PROVIDER_STATE = "new_provider_state" 172 | CONF_NEW_PROVIDER_URL = "new_provider_url" 173 | CONF_PROVIDERS = "providers" 174 | CONF_REMOVE_PROVIDERS = "remove_providers" 175 | 176 | # ----------------------------------------------- 177 | # Options (behavioral configuration parameters) 178 | # ----------------------------------------------- 179 | 180 | CONF_FRIENDLY_NAME_TEMPLATE = "friendly_name_template" 181 | DEFAULT_FRIENDLY_NAME_TEMPLATE = ( 182 | "{{person_name}} ({{source.attributes.friendly_name}}) {{friendly_name_location}}" 183 | ) 184 | 185 | CONF_HOURS_EXTENDED_AWAY = "extended_away" 186 | DEFAULT_HOURS_EXTENDED_AWAY = 48 187 | 188 | CONF_MINUTES_JUST_ARRIVED = "just_arrived" 189 | DEFAULT_MINUTES_JUST_ARRIVED = 3 190 | 191 | CONF_MINUTES_JUST_LEFT = "just_left" 192 | DEFAULT_MINUTES_JUST_LEFT = 3 193 | 194 | CONF_SHOW_ZONE_WHEN_AWAY = "show_zone_when_away" 195 | DEFAULT_SHOW_ZONE_WHEN_AWAY = False 196 | 197 | ALLOWED_OPTIONS_KEYS = { 198 | CONF_FRIENDLY_NAME_TEMPLATE, 199 | CONF_HOURS_EXTENDED_AWAY, 200 | CONF_MINUTES_JUST_ARRIVED, 201 | CONF_MINUTES_JUST_LEFT, 202 | CONF_SHOW_ZONE_WHEN_AWAY, 203 | } 204 | 205 | STARTUP_VERSION = """ 206 | ------------------------------------------------------------------- 207 | {name} 208 | Version: {version} 209 | This is a custom integration 210 | If you have any issues with this you need to open an issue here: 211 | {issue_link} 212 | ------------------------------------------------------------------- 213 | """ 214 | 215 | PERSON_SCHEMA = vol.Schema( 216 | { 217 | vol.Required(CONF_NAME): cv.string, 218 | vol.Optional(CONF_DEVICES, default=[]): vol.All( 219 | cv.ensure_list, cv.entities_domain(VALID_ENTITY_DOMAINS) 220 | ), 221 | } 222 | ) 223 | 224 | CONFIG_SCHEMA = vol.Schema( 225 | { 226 | DOMAIN: vol.Schema( 227 | { 228 | vol.Optional(CONF_CREATE_SENSORS, default=[]): vol.All( 229 | cv.ensure_list, # turn string into list 230 | [vol.In(VALID_CREATE_SENSORS)], 231 | sorted, # ensures deterministic ordering 232 | ), 233 | vol.Optional( 234 | CONF_HOURS_EXTENDED_AWAY, default=DEFAULT_HOURS_EXTENDED_AWAY 235 | ): cv.positive_int, 236 | vol.Optional( 237 | CONF_MINUTES_JUST_ARRIVED, default=DEFAULT_MINUTES_JUST_ARRIVED 238 | ): cv.positive_int, 239 | vol.Optional( 240 | CONF_MINUTES_JUST_LEFT, default=DEFAULT_MINUTES_JUST_LEFT 241 | ): cv.positive_int, 242 | vol.Optional( 243 | CONF_SHOW_ZONE_WHEN_AWAY, default=DEFAULT_SHOW_ZONE_WHEN_AWAY 244 | ): cv.boolean, 245 | vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): cv.string, 246 | vol.Optional( 247 | CONF_OUTPUT_PLATFORM, default=DEFAULT_OUTPUT_PLATFORM 248 | ): cv.string, 249 | vol.Optional(CONF_REGION, default=DEFAULT_REGION): cv.string, 250 | vol.Optional( 251 | CONF_MAPBOX_API_KEY, default=DEFAULT_API_KEY_NOT_SET 252 | ): cv.string, 253 | vol.Optional( 254 | CONF_MAPQUEST_API_KEY, default=DEFAULT_API_KEY_NOT_SET 255 | ): cv.string, 256 | vol.Optional( 257 | CONF_OSM_API_KEY, default=DEFAULT_API_KEY_NOT_SET 258 | ): cv.string, 259 | vol.Optional( 260 | CONF_GOOGLE_API_KEY, default=DEFAULT_API_KEY_NOT_SET 261 | ): cv.string, 262 | vol.Optional( 263 | CONF_RADAR_API_KEY, default=DEFAULT_API_KEY_NOT_SET 264 | ): cv.string, 265 | vol.Optional(CONF_FOLLOW_PERSON_INTEGRATION, default=False): cv.boolean, 266 | vol.Optional(CONF_DISTANCE_DURATION_SOURCE, default="waze"): cv.string, 267 | vol.Optional(CONF_PERSON_NAMES, default=[]): vol.All( 268 | cv.ensure_list, [PERSON_SCHEMA] 269 | ), 270 | }, 271 | extra=vol.ALLOW_EXTRA, 272 | ), 273 | }, 274 | extra=vol.ALLOW_EXTRA, 275 | ) 276 | 277 | # Items under hass.data[DOMAIN]: 278 | 279 | DATA_STATE = "state" 280 | DATA_ATTRIBUTES = "attributes" 281 | DATA_CONFIG_ENTRY = "config_entry" 282 | DATA_CONFIGURATION = "configuration" 283 | DATA_ENTITY_INFO = "entity_info" 284 | DATA_UNDO_STATE_LISTENER = "undo_state_listener" 285 | DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" 286 | DATA_ASYNC_SETUP_ENTRY = "async_setup_entry" 287 | 288 | INTEGRATION_LOCK = threading.Lock() 289 | TARGET_LOCK = threading.Lock() 290 | # Note to future me: If functions where these locks are used are converted to async, 291 | # they will need to be changed to asyncio.Lock and the locations where 292 | # `with TARGET_LOCK:` is used would need to change to `async with TARGET_LOCK:` 293 | INTEGRATION_ASYNCIO_LOCK = asyncio.Lock() 294 | TARGET_ASYNCIO_LOCK = asyncio.Lock() 295 | 296 | _LOGGER = logging.getLogger(__name__) 297 | 298 | 299 | def get_waze_region(country_code: str) -> str: 300 | """Determine Waze region from country code or Waze region setting.""" 301 | country_code = country_code.lower() 302 | if country_code in ("us", "ca", "mx"): 303 | return "us" 304 | if country_code in WAZE_REGIONS: 305 | return country_code 306 | return "eu" 307 | 308 | 309 | class PERSON_LOCATION_INTEGRATION: 310 | """Class to represent the integration itself.""" 311 | 312 | def __init__(self, _entity_id, _hass: HomeAssistant) -> None: 313 | """Initialize the integration instance.""" 314 | # Log startup message: 315 | _LOGGER.info( 316 | STARTUP_VERSION.format(name=DOMAIN, version=VERSION, issue_link=ISSUE_URL) 317 | ) 318 | 319 | self.entity_id = _entity_id 320 | self.hass = _hass 321 | # self.config = _config 322 | self.state = "on" 323 | self.attributes = {} 324 | self.attributes[ATTR_ICON] = "mdi:api" 325 | 326 | self.configuration = {} 327 | self.entity_info = {} 328 | self._target_sensors_restored = [] 329 | 330 | home_zone = "zone.home" 331 | self.attributes[ATTR_FRIENDLY_NAME] = f"{INTEGRATION_NAME} Service" 332 | self.attributes["home_latitude"] = str( 333 | self.hass.states.get(home_zone).attributes.get(ATTR_LATITUDE) 334 | ) 335 | self.attributes["home_longitude"] = str( 336 | self.hass.states.get(home_zone).attributes.get(ATTR_LONGITUDE) 337 | ) 338 | self.attributes["api_last_updated"] = datetime.now() 339 | self.attributes["api_error_count"] = 0 340 | self.attributes["api_calls_requested"] = 0 341 | self.attributes["api_calls_skipped"] = 0 342 | self.attributes["api_calls_throttled"] = 0 343 | self.attributes["startup"] = True 344 | self.attributes["waze_error_count"] = 0 345 | self.attributes[ATTR_ATTRIBUTION] = ( 346 | f"System information for the {INTEGRATION_NAME} integration \ 347 | ({DOMAIN}), version {VERSION}." 348 | ) 349 | 350 | # ❌ self.set_state() 351 | 352 | def set_state(self) -> None: 353 | """Schedule async_set_state safely from a thread or sync context.""" 354 | self.hass.loop.call_soon_threadsafe( 355 | lambda: self.hass.async_create_task(self.async_set_state()) 356 | ) 357 | 358 | async def async_set_state(self) -> None: 359 | """Async-safe state setter.""" 360 | integration_state_data = { 361 | DATA_STATE: self.state, 362 | DATA_ATTRIBUTES: self.attributes, 363 | DATA_CONFIGURATION: self.configuration, 364 | DATA_ENTITY_INFO: self.entity_info, 365 | } 366 | if DOMAIN in self.hass.data: 367 | self.hass.data[DOMAIN].update(integration_state_data) 368 | else: 369 | self.hass.data[DOMAIN] = integration_state_data 370 | 371 | simple_attributes = {ATTR_ICON: self.attributes[ATTR_ICON]} 372 | self.hass.states.async_set(self.entity_id, self.state, simple_attributes) 373 | 374 | _LOGGER.debug( 375 | "[async_set_state] (%s) -state: %s -attributes: %s", 376 | self.entity_id, 377 | self.state, 378 | self.attributes, 379 | ) 380 | 381 | 382 | class PERSON_LOCATION_TRIGGER: 383 | """Class to represent device trackers that trigger us.""" 384 | 385 | def __init__(self, _entity_id, _pli: PERSON_LOCATION_INTEGRATION) -> None: 386 | """Initialize the entity instance.""" 387 | _LOGGER.debug("[PERSON_LOCATION_TRIGGER] (%s) === __init__ ===", _entity_id) 388 | 389 | self.entity_id = _entity_id 390 | self.pli = _pli 391 | self.hass = _pli.hass 392 | 393 | self.configuration = self.hass.data[DOMAIN][DATA_CONFIGURATION] 394 | 395 | targetStateObject = self.hass.states.get(self.entity_id) 396 | if targetStateObject is not None: 397 | self.firstTime = False 398 | if ( 399 | targetStateObject.state.startswith(IC3_STATIONARY_STATE_PREFIX) 400 | or targetStateObject.state == STATE_NOT_HOME 401 | ): 402 | self.state = "Away" 403 | else: 404 | self.state = targetStateObject.state 405 | self.last_changed = targetStateObject.last_changed 406 | self.last_updated = targetStateObject.last_updated 407 | self.attributes = targetStateObject.attributes.copy() 408 | else: 409 | self.firstTime = True 410 | self.state = STATE_UNKNOWN 411 | self.last_changed = datetime(2020, 3, 14, 15, 9, 26, 535897) 412 | self.last_updated = datetime(2020, 3, 14, 15, 9, 26, 535897) 413 | self.attributes = {} 414 | 415 | if "friendly_name" in self.attributes: 416 | self.friendlyName = self.attributes["friendly_name"] 417 | else: 418 | self.friendlyName = "" 419 | _LOGGER.debug("friendly_name attribute is missing") 420 | 421 | if self.state.lower() == STATE_HOME or self.state.lower() == STATE_ON: 422 | self.stateHomeAway = "Home" 423 | self.state = "Home" 424 | else: 425 | self.stateHomeAway = "Away" 426 | if self.state == STATE_NOT_HOME: 427 | self.state = "Away" 428 | 429 | if self.entity_id in self.pli.configuration[CONF_DEVICES]: 430 | self.personName = self.pli.configuration[CONF_DEVICES][ 431 | self.entity_id 432 | ].lower() 433 | elif "person_name" in self.attributes: 434 | self.personName = self.attributes["person_name"] 435 | elif "account_name" in self.attributes: 436 | self.personName = self.attributes["account_name"] 437 | elif "owner_fullname" in self.attributes: 438 | self.personName = self.attributes["owner_fullname"].split()[0].lower() 439 | elif ( 440 | "friendly_name" in self.attributes 441 | and self.entity_id.split(".")[0] == "person" 442 | ): 443 | self.personName = self.attributes["friendly_name"] 444 | else: 445 | self.personName = self.entity_id.split(".")[1].split("_")[0].lower() 446 | if self.firstTime is False: 447 | _LOGGER.debug( 448 | 'The account_name (or person_name) attribute is missing in %s, trying "%s"', 449 | self.entity_id, 450 | self.personName, 451 | ) 452 | # It is tempting to make the output a device_tracker instead of sensor, 453 | # so that it can be input into the Person built-in integration, 454 | # but if you do, be very careful not to trigger a loop. 455 | # The state and other attributes will also need to be adjusted. 456 | 457 | self.targetName = ( 458 | self.configuration[CONF_OUTPUT_PLATFORM] 459 | + "." 460 | + self.personName.lower() 461 | + "_location" 462 | ) 463 | -------------------------------------------------------------------------------- /custom_components/person_location/__init__.py: -------------------------------------------------------------------------------- 1 | """Person Location integration.""" 2 | 3 | from datetime import datetime, timedelta 4 | from functools import partial 5 | import logging 6 | import pprint 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import STATE_OFF, STATE_ON, Platform 12 | from homeassistant.core import Event, EventStateChangedData, HomeAssistant 13 | from homeassistant.helpers import device_registry as dr, entity_registry as er 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers.event import ( 16 | async_track_point_in_time, 17 | async_track_state_change_event, 18 | ) 19 | 20 | from .const import ( 21 | ALLOWED_OPTIONS_KEYS, 22 | API_STATE_OBJECT, 23 | CONF_CREATE_SENSORS, 24 | CONF_DEVICES, 25 | CONF_FOLLOW_PERSON_INTEGRATION, 26 | CONF_FRIENDLY_NAME_TEMPLATE, 27 | CONF_FROM_YAML, 28 | CONF_NAME, 29 | CONF_PERSON_NAMES, 30 | CONF_SHOW_ZONE_WHEN_AWAY, 31 | CONFIG_SCHEMA, 32 | DATA_ASYNC_SETUP_ENTRY, 33 | DATA_CONFIG_ENTRY, 34 | DATA_CONFIGURATION, 35 | DATA_ENTITY_INFO, 36 | DATA_UNDO_STATE_LISTENER, 37 | DATA_UNDO_UPDATE_LISTENER, 38 | DEFAULT_FRIENDLY_NAME_TEMPLATE, 39 | DEFAULT_SHOW_ZONE_WHEN_AWAY, 40 | DOMAIN, 41 | INFO_GEOCODE_COUNT, 42 | INTEGRATION_LOCK, 43 | PERSON_LOCATION_INTEGRATION, 44 | TITLE_PERSON_LOCATION_CONFIG, 45 | ) 46 | from .helpers.entity import prune_orphan_template_entities 47 | from .process_trigger import setup_process_trigger 48 | from .reverse_geocode import setup_reverse_geocode 49 | 50 | _LOGGER = logging.getLogger(__name__) 51 | PLATFORMS: list[Platform] = [Platform.CAMERA, Platform.SENSOR] 52 | 53 | 54 | def merge_entry_data(entry: ConfigEntry, conf: dict) -> tuple[dict, dict]: 55 | """Merge YAML conf into an existing ConfigEntry's data and options. 56 | 57 | ConfigEntry values override YAML: 58 | - Dict keys (e.g. CONF_DEVICES): entry overrides YAML. 59 | - List keys (e.g. CONF_CREATE_SENSORS): merged with duplicates removed. 60 | - Other keys: entry values override YAML. 61 | """ 62 | # Start with YAML (which has data + options), overlay with entry data + options 63 | updated_data_options_and_yaml = {**conf, **entry.data, **entry.options} 64 | 65 | # Then, Merge dict CONF_DEVICES key → entry wins 66 | conf_devices = conf.get(CONF_DEVICES, {}) 67 | entry_devices = entry.data.get(CONF_DEVICES, {}) 68 | updated_data_options_and_yaml[CONF_DEVICES] = {**conf_devices, **entry_devices} 69 | 70 | # Then, merge list CONF_CREATE_SENSORS key → deduped 71 | conf_sensors = conf.get(CONF_CREATE_SENSORS, []) 72 | entry_sensors = entry.data.get(CONF_CREATE_SENSORS, []) 73 | updated_data_options_and_yaml[CONF_CREATE_SENSORS] = list( 74 | dict.fromkeys(entry_sensors + conf_sensors) 75 | ) 76 | 77 | # Preserve conf_with_defaults[CONF_FROM_YAML] because that is the copy that knows 78 | updated_data_options_and_yaml[CONF_FROM_YAML] = conf.get(CONF_FROM_YAML) 79 | 80 | # Pull out keys that should be in data only 81 | updated_data = { 82 | key: value 83 | for key, value in updated_data_options_and_yaml.items() 84 | if key not in ALLOWED_OPTIONS_KEYS 85 | } 86 | _LOGGER.debug("[merge_entry_data] Parsed updated_data: %s", updated_data) 87 | 88 | # Pull out keys that should be in options only 89 | updated_options = { 90 | key: value 91 | for key, value in updated_data_options_and_yaml.items() 92 | if key in ALLOWED_OPTIONS_KEYS 93 | } 94 | _LOGGER.debug("[merge_entry_data] Parsed updated_options: %s", updated_options) 95 | 96 | return updated_data, updated_options 97 | 98 | 99 | async def _setup_services( 100 | pli: PERSON_LOCATION_INTEGRATION, hass: HomeAssistant 101 | ) -> None: 102 | # Services: geocode on/off 103 | async def handle_geocode_api_on(call) -> None: 104 | _LOGGER.debug("[geocode_api_on] === Start ===") 105 | with INTEGRATION_LOCK: 106 | _LOGGER.debug("[geocode_api_on] INTEGRATION_LOCK obtained") 107 | pli.state = STATE_ON 108 | pli.attributes["icon"] = "mdi:api" 109 | pli.async_set_state() 110 | _LOGGER.debug("[geocode_api_on] INTEGRATION_LOCK release...") 111 | _LOGGER.debug("[geocode_api_on] === Return ===") 112 | 113 | async def handle_geocode_api_off(call) -> None: 114 | _LOGGER.debug("[geocode_api_off] === Start ===") 115 | with INTEGRATION_LOCK: 116 | _LOGGER.debug("[geocode_api_off] INTEGRATION_LOCK obtained") 117 | pli.state = STATE_OFF 118 | pli.attributes["icon"] = "mdi:api-off" 119 | pli.async_set_state() 120 | _LOGGER.debug("[geocode_api_off] INTEGRATION_LOCK release...") 121 | _LOGGER.debug("[geocode_api_off] === Return ===") 122 | 123 | if not hass.services.has_service(DOMAIN, "geocode_api_on"): 124 | hass.services.async_register(DOMAIN, "geocode_api_on", handle_geocode_api_on) 125 | if not hass.services.has_service(DOMAIN, "geocode_api_off"): 126 | hass.services.async_register(DOMAIN, "geocode_api_off", handle_geocode_api_off) 127 | 128 | # Services: integration functionality 129 | if not hass.services.has_service(DOMAIN, "reverse_geocode"): 130 | setup_reverse_geocode(pli) 131 | if not hass.services.has_service(DOMAIN, "process_trigger"): 132 | setup_process_trigger(pli) 133 | 134 | 135 | # ------------------------------------------------------------------ 136 | # YAML setup (bridges into config entries) 137 | # ------------------------------------------------------------------ 138 | 139 | 140 | async def async_setup(hass: HomeAssistant, yaml_config: dict) -> bool: 141 | """Set up integration and bridge YAML into config entry.""" 142 | hass.data.setdefault(DOMAIN, {}) 143 | 144 | # Create integration object 145 | pli = PERSON_LOCATION_INTEGRATION(f"{DOMAIN}.integration", hass) 146 | 147 | # Explicit startup flag so logic downstream is predictable 148 | pli.attributes.setdefault("startup", True) 149 | 150 | # Some code references expect hass.data[DOMAIN]["integration"] 151 | hass.data[DOMAIN]["integration"] = pli 152 | 153 | # ------- get configuration from YAML ------- 154 | 155 | default_conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] 156 | # LOGGER.debug("[async_setup] default_conf: %s", default_conf) 157 | if not default_conf.get(CONF_DEVICES, {}): 158 | default_conf[CONF_DEVICES] = {} 159 | 160 | if DOMAIN not in yaml_config: 161 | _LOGGER.debug( 162 | "[async_setup] %s not found in yaml_config. Supplying defaults only.", 163 | DOMAIN, 164 | ) 165 | conf_with_defaults = default_conf 166 | conf_with_defaults[CONF_FROM_YAML] = False 167 | 168 | else: 169 | raw_conf = yaml_config.get(DOMAIN) 170 | _LOGGER.debug("[async_setup] raw_conf: %s", raw_conf) 171 | try: 172 | conf = CONFIG_SCHEMA({DOMAIN: raw_conf})[DOMAIN] 173 | except vol.Invalid as err: 174 | _LOGGER.error("[async_setup] Invalid yaml configuration: %s", err) 175 | return False 176 | conf_with_defaults = {**default_conf, **conf} 177 | conf_with_defaults[CONF_FROM_YAML] = True 178 | 179 | # translate YAML way of specifying 'person_names' into 'devices' format 180 | conf_person_names = conf_with_defaults.pop(CONF_PERSON_NAMES, []) 181 | if conf_person_names: 182 | _LOGGER.debug("[async_setup] conf_person_names: %s", conf_person_names) 183 | conf_devices = { 184 | device: person[CONF_NAME] 185 | for person in conf_person_names 186 | for device in person[CONF_DEVICES] 187 | } 188 | _LOGGER.debug("[async_setup] conf_devices: %s", conf_devices) 189 | conf_with_defaults[CONF_DEVICES] = conf_devices 190 | 191 | # YAML schema allows a couple of different formats for the list 192 | # conf_create_sensors = conf_with_defaults.pop(CONF_CREATE_SENSORS, []) 193 | # conf_with_defaults[CONF_CREATE_SENSORS] = sorted(cv.ensure_list(conf_create_sensors)) 194 | 195 | if not pli.configuration: 196 | pli.DATA_CONFIGURATION = conf_with_defaults 197 | 198 | # ------- register services ------- 199 | 200 | await _setup_services(pli, hass) 201 | 202 | # ------- update existing config entry or request a new one ------- 203 | 204 | existing_entries = hass.config_entries.async_entries(DOMAIN) 205 | if existing_entries: 206 | entry = existing_entries[0] 207 | _LOGGER.debug("[async_setup] Updating existing entry %s", entry.entry_id) 208 | _LOGGER.debug("[async_setup] conf_with_defaults: %s", conf_with_defaults) 209 | _LOGGER.debug("[async_setup] entry.data: %s", entry.data) 210 | _LOGGER.debug("[async_setup] entry.options: %s", entry.options) 211 | 212 | new_data, new_options = merge_entry_data(entry, conf_with_defaults) 213 | _LOGGER.debug("[async_setup] new_data: %s", new_data) 214 | _LOGGER.debug("[async_setup] new_options: %s", new_options) 215 | 216 | hass.config_entries.async_update_entry( 217 | entry, 218 | data=new_data, 219 | options=new_options, 220 | ) 221 | 222 | else: 223 | _LOGGER.debug("[async_setup] Initiating config flow to create entry") 224 | hass.async_create_task( 225 | hass.config_entries.flow.async_init( 226 | DOMAIN, 227 | context={"source": "import"}, 228 | data=conf_with_defaults, 229 | ) 230 | ) 231 | 232 | return True 233 | 234 | 235 | # ------------------------------------------------------------------ 236 | # Options update listener 237 | # ------------------------------------------------------------------ 238 | 239 | 240 | async def async_options_update_listener( 241 | hass: HomeAssistant, entry: ConfigEntry 242 | ) -> bool: 243 | """Handle config_flow options updates by reloading the entry cleanly.""" 244 | _LOGGER.debug( 245 | "[async_options_update_listener] Reloading entry %s after options update", 246 | entry.entry_id, 247 | ) 248 | await hass.config_entries.async_reload(entry.entry_id) 249 | return True 250 | 251 | 252 | # ------------------------------------------------------------------ 253 | # Setup from Config Entry 254 | # ------------------------------------------------------------------ 255 | 256 | 257 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 258 | """Set up integration from a ConfigEntry.""" 259 | _LOGGER.debug("[async_setup_entry] Setting up entry: %s", entry.entry_id) 260 | 261 | hass.data.setdefault(DOMAIN, {}) 262 | hass.data[DOMAIN][DATA_CONFIG_ENTRY] = entry 263 | 264 | # Get integration object 265 | pli = hass.data[DOMAIN]["integration"] 266 | 267 | # Store integration object for entry 268 | hass.data[DOMAIN][entry.entry_id] = pli 269 | 270 | pli.config = entry.data # TODO: is this used anywhere? 271 | pli.async_set_state 272 | 273 | if DATA_UNDO_UPDATE_LISTENER not in hass.data[DOMAIN]: 274 | hass.data[DOMAIN][DATA_UNDO_UPDATE_LISTENER] = entry.add_update_listener( 275 | async_options_update_listener 276 | ) 277 | 278 | # ------------------------------------------------------------------ 279 | # Listeners: device tracker state change 280 | # ------------------------------------------------------------------ 281 | 282 | def _handle_device_tracker_state_change( 283 | event: Event[EventStateChangedData], 284 | ) -> None: 285 | """Handle device tracker state change event.""" 286 | entity_id = event.data["entity_id"] 287 | old_state = event.data["old_state"] 288 | new_state = event.data["new_state"] 289 | 290 | _LOGGER.debug( 291 | "[_handle_device_tracker_state_change] === Start === (%s)", entity_id 292 | ) 293 | 294 | from_state = getattr(old_state, "state", "unknown") 295 | service_data = { 296 | "entity_id": entity_id, 297 | "from_state": from_state, 298 | "to_state": new_state.state, 299 | } 300 | hass.services.call(DOMAIN, "process_trigger", service_data, False) 301 | 302 | _LOGGER.debug("[_handle_device_tracker_state_change] === Return ===") 303 | 304 | # track_state_change_event = threaded_listener_factory(async_track_state_change_event) 305 | 306 | def _listen_for_device_tracker_state_changes(entity_id: str) -> None: 307 | """Register state listener for a device tracker entity.""" 308 | if entity_id not in pli.entity_info: 309 | pli.entity_info[entity_id] = {} 310 | 311 | if DATA_UNDO_STATE_LISTENER not in pli.entity_info[entity_id]: 312 | remove = async_track_state_change_event( 313 | hass, 314 | entity_id, 315 | _handle_device_tracker_state_change, 316 | ) 317 | if remove: 318 | pli.entity_info[entity_id][DATA_UNDO_STATE_LISTENER] = remove 319 | _LOGGER.debug( 320 | "[_listen_for_device_tracker_state_changes] Registered for %s", 321 | entity_id, 322 | ) 323 | 324 | def _listen_for_configured_entities( 325 | hass: HomeAssistant, pli_obj: PERSON_LOCATION_INTEGRATION 326 | ) -> None: 327 | """Register listeners for configured person/device entities.""" 328 | _LOGGER.debug("[_listen_for_configured_entities] === Start ===") 329 | 330 | def _register_person_entities(): 331 | for entity_id in hass.states.entity_ids("person"): 332 | _listen_for_device_tracker_state_changes(entity_id) 333 | 334 | if pli_obj.configuration.get(CONF_FOLLOW_PERSON_INTEGRATION): 335 | # Run the sync call safely in executor 336 | hass.loop.run_in_executor(None, _register_person_entities) 337 | 338 | for device in pli_obj.configuration.get(CONF_DEVICES, {}).keys(): 339 | _listen_for_device_tracker_state_changes(device) 340 | 341 | _LOGGER.debug("[_listen_for_configured_entities] === Return ===") 342 | 343 | # ------------------------------------------------------------------ 344 | # Inner _async_setup_entry (options merge + post-merge actions) 345 | # ------------------------------------------------------------------ 346 | 347 | async def _async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 348 | """Process config_flow configuration and options.""" 349 | _LOGGER.debug( 350 | "[_async_setup_entry] === Start === -data: %s -options: %s", 351 | pprint.pformat(entry.data), 352 | pprint.pformat(entry.options), 353 | ) 354 | 355 | # Register services (for update where async_setup does not get called) 356 | await _setup_services(pli, hass) 357 | 358 | # Determine if friendly name template is being changed 359 | friendly_name_template_changed = ( 360 | (not pli.attributes.get("startup", True)) 361 | and (CONF_FRIENDLY_NAME_TEMPLATE in entry.options) 362 | and (CONF_FRIENDLY_NAME_TEMPLATE in (pli.configuration or {})) 363 | and ( 364 | entry.options[CONF_FRIENDLY_NAME_TEMPLATE] 365 | != pli.configuration[CONF_FRIENDLY_NAME_TEMPLATE] 366 | ) 367 | ) 368 | 369 | # Merge data and options into runtime configuration 370 | pli.configuration = { 371 | **(pli.configuration or {}), 372 | **(entry.data or {}), 373 | **(entry.options or {}), 374 | } 375 | hass.data.setdefault(DOMAIN, {}) 376 | hass.data[DOMAIN][DATA_CONFIGURATION] = pli.configuration 377 | 378 | # Forward setup to platforms 379 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 380 | 381 | # Wire listeners based on updated configuration 382 | _listen_for_configured_entities(hass, pli) 383 | 384 | # Re-apply friendly names if template changed 385 | if friendly_name_template_changed: 386 | try: 387 | entity_info = hass.data[DOMAIN].get(DATA_ENTITY_INFO, {}) 388 | for sensor, info in entity_info.items(): 389 | if info.get(INFO_GEOCODE_COUNT, 0) > 0: 390 | _LOGGER.debug("[_async_setup_entry] updating sensor %s", sensor) 391 | service_data = { 392 | "entity_id": sensor, 393 | "friendly_name_template": pli.configuration[ 394 | CONF_FRIENDLY_NAME_TEMPLATE 395 | ], 396 | "force_update": False, 397 | } 398 | await hass.services.async_call( 399 | DOMAIN, "reverse_geocode", service_data, False 400 | ) 401 | except Exception as e: 402 | _LOGGER.warning( 403 | "Exception updating friendly name after template change - %s", e 404 | ) 405 | 406 | _LOGGER.debug("[_async_setup_entry] === Return ===") 407 | return True 408 | 409 | # Expose entry setup handler for options updates 410 | hass.data[DOMAIN][DATA_ASYNC_SETUP_ENTRY] = _async_setup_entry 411 | await _async_setup_entry(hass, entry) 412 | 413 | # ------------------------------------------------------------------ 414 | # Startup timer (defer wiring until HA finishes starting) 415 | # ------------------------------------------------------------------ 416 | 417 | def _handle_startup_is_done(now) -> None: 418 | """Flip startup flag and rewire listeners when HA has started.""" 419 | _LOGGER.debug("[_handle_startup_is_done] === Start ===") 420 | 421 | # Still starting? Wait another minute 422 | if not hass.is_running: 423 | _LOGGER.debug("[_handle_startup_is_done] === Delay ===") 424 | _set_timer_startup_is_done(1) 425 | return 426 | 427 | pli.attributes["startup"] = False 428 | _listen_for_configured_entities(hass, pli) 429 | 430 | # It should now be safe to expand template sensors for restored target sensors. 431 | if pli._target_sensors_restored: 432 | _LOGGER.debug("[_handle_startup_is_done] Running delayed reverse_geocode.") 433 | while pli._target_sensors_restored: 434 | entity_id = pli._target_sensors_restored.pop() 435 | service_data = { 436 | "entity_id": entity_id, 437 | "friendly_name_template": pli.configuration.get( 438 | CONF_FRIENDLY_NAME_TEMPLATE, 439 | DEFAULT_FRIENDLY_NAME_TEMPLATE, 440 | ), 441 | "force_update": True, 442 | } 443 | pli.hass.services.call(DOMAIN, "reverse_geocode", service_data, False) 444 | 445 | _LOGGER.debug( 446 | "[_handle_startup_is_done] === Return === startup flag is turned off" 447 | ) 448 | 449 | def _set_timer_startup_is_done(minutes: int) -> None: 450 | """Start a timer for 'startup is done'.""" 451 | point_in_time = datetime.now() + timedelta(minutes=minutes) 452 | async_track_point_in_time( 453 | hass, 454 | partial(_handle_startup_is_done), 455 | point_in_time=point_in_time, 456 | ) 457 | 458 | # Initial listener wiring and startup timer 459 | _listen_for_configured_entities(hass, pli) 460 | _set_timer_startup_is_done(1) 461 | 462 | # Async state set (avoid sync set_state during startup) 463 | hass.loop.call_soon_threadsafe( 464 | lambda: hass.async_create_task(pli.async_set_state()) 465 | ) 466 | 467 | _LOGGER.debug("[async_setup_entry] === Return ===") 468 | return True 469 | 470 | 471 | # ------------------------------------------------------------------ 472 | # Unload entry (cleanup symmetry) 473 | # ------------------------------------------------------------------ 474 | 475 | 476 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 477 | """Unload a config entry and clean up orphaned devices/entities.""" 478 | _LOGGER.debug("[async_unload_entry] Unloading entry: %s", entry.entry_id) 479 | 480 | # Get template sensor names that are still valid 481 | create_sensors_list = hass.data[DOMAIN][DATA_CONFIGURATION][CONF_CREATE_SENSORS] 482 | 483 | # Remove template sensors that are no longer needed 484 | removed = await prune_orphan_template_entities( 485 | hass, 486 | platform_domain=DOMAIN, 487 | entity_domain="sensor", 488 | allowed_suffixes=create_sensors_list, 489 | ) 490 | if removed: 491 | _LOGGER.debug( 492 | "[async_unload_entry] Removed orphan template entities: %s", removed 493 | ) 494 | 495 | # Unload platforms 496 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 497 | if not unload_ok: 498 | return False 499 | 500 | # Remove services registered by this integration 501 | if hass.services.has_service(DOMAIN, "geocode_api_on"): 502 | hass.services.async_remove(DOMAIN, "geocode_api_on") 503 | if hass.services.has_service(DOMAIN, "geocode_api_off"): 504 | hass.services.async_remove(DOMAIN, "geocode_api_off") 505 | if hass.services.has_service(DOMAIN, "process_trigger"): 506 | hass.services.async_remove(DOMAIN, "process_trigger") 507 | if hass.services.has_service(DOMAIN, "reverse_geocode"): 508 | hass.services.async_remove(DOMAIN, "reverse_geocode") 509 | 510 | # Remove integration object and undo listeners 511 | pli = hass.data[DOMAIN].pop(entry.entry_id, None) 512 | if pli: 513 | for entity_id, info in list(pli.entity_info.items()): 514 | undo = info.get(DATA_UNDO_STATE_LISTENER) 515 | if undo: 516 | undo() 517 | _LOGGER.debug( 518 | "[async_unload_entry] Removed state listener for %s", entity_id 519 | ) 520 | pli.entity_info.clear() 521 | 522 | # Clean up orphaned devices (no entities left) 523 | dev_reg = dr.async_get(hass) 524 | ent_reg = er.async_get(hass) 525 | 526 | devices = [ 527 | d for d in dev_reg.devices.values() if entry.entry_id in d.config_entries 528 | ] 529 | for device in devices: 530 | entities = [e for e in ent_reg.entities.values() if e.device_id == device.id] 531 | if not entities: 532 | dev_reg.async_remove_device(device.id) 533 | _LOGGER.info("[async_unload_entry] Removed orphaned device %s", device.name) 534 | 535 | # Optional: clear per-domain bookkeeping 536 | hass.data[DOMAIN].pop(DATA_CONFIG_ENTRY, None) 537 | if DATA_UNDO_UPDATE_LISTENER in hass.data[DOMAIN]: 538 | hass.data[DOMAIN][DATA_UNDO_UPDATE_LISTENER]() 539 | hass.data[DOMAIN].pop(DATA_UNDO_UPDATE_LISTENER, None) 540 | 541 | _LOGGER.debug("[async_unload_entry] === Return ===") 542 | return True 543 | 544 | 545 | # ------------------------------------------------------------------ 546 | # Migration 547 | # ------------------------------------------------------------------ 548 | 549 | # Note: Update MIGRATION_SCHEMA_VERSION if integration can't be reverted without restore 550 | MIGRATION_SCHEMA_VERSION = 2 551 | MIGRATION_SCHEMA_MINOR = 1 552 | 553 | 554 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 555 | """Migrate old configuration entry.""" 556 | _LOGGER.debug( 557 | "[async_migrate_entry] Migrating configuration %s from version %s.%s", 558 | config_entry.entry_id, 559 | config_entry.version, 560 | config_entry.minor_version, 561 | ) 562 | 563 | # TODO: add this test when there is a migration that can't be reverted 564 | # if str(config_entry.version) > MIGRATION_SCHEMA_VERSION: 565 | # _LOGGER.error( 566 | # "Component has been downgraded without restoring configuration from backup" 567 | # ) 568 | # return False 569 | 570 | new_data = {**config_entry.data} 571 | new_options = {**config_entry.options} 572 | 573 | if str(config_entry.version) == "1": 574 | if str(config_entry.minor_version) < "2": 575 | if CONF_FRIENDLY_NAME_TEMPLATE not in new_options: 576 | _LOGGER.debug("Adding %s", CONF_FRIENDLY_NAME_TEMPLATE) 577 | new_options[CONF_FRIENDLY_NAME_TEMPLATE] = ( 578 | DEFAULT_FRIENDLY_NAME_TEMPLATE 579 | ) 580 | if CONF_SHOW_ZONE_WHEN_AWAY not in new_options: 581 | _LOGGER.debug("Adding %s", CONF_SHOW_ZONE_WHEN_AWAY) 582 | new_options[CONF_SHOW_ZONE_WHEN_AWAY] = DEFAULT_SHOW_ZONE_WHEN_AWAY 583 | 584 | hass.config_entries.async_update_entry( 585 | config_entry, 586 | data=new_data, 587 | options=new_options, 588 | minor_version=MIGRATION_SCHEMA_MINOR, 589 | version=MIGRATION_SCHEMA_VERSION, 590 | title=TITLE_PERSON_LOCATION_CONFIG, 591 | ) 592 | 593 | _LOGGER.debug( 594 | "[async_migrate_entry] Migration to configuration version %s.%s complete", 595 | config_entry.version, 596 | config_entry.minor_version, 597 | ) 598 | 599 | return True 600 | -------------------------------------------------------------------------------- /custom_components/person_location/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for person_location integration.""" 2 | 3 | from datetime import datetime, timedelta, timezone 4 | from functools import partial 5 | import logging 6 | 7 | from homeassistant.components.mobile_app.const import ATTR_VERTICAL_ACCURACY 8 | from homeassistant.components.sensor import SensorEntity 9 | from homeassistant.const import ( 10 | ATTR_GPS_ACCURACY, 11 | ATTR_ICON, 12 | ATTR_LATITUDE, 13 | ATTR_LONGITUDE, 14 | ATTR_UNIT_OF_MEASUREMENT, 15 | STATE_UNKNOWN, 16 | ) 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers.entity import DeviceInfo 19 | from homeassistant.helpers.event import ( 20 | async_track_point_in_time, 21 | ) 22 | from homeassistant.helpers.restore_state import RestoreEntity 23 | 24 | from .const import ( 25 | ATTR_ALTITUDE, 26 | ATTR_BREAD_CRUMBS, 27 | ATTR_COMPASS_BEARING, 28 | ATTR_DIRECTION, 29 | ATTR_DRIVING_MILES, 30 | ATTR_DRIVING_MINUTES, 31 | ATTR_GEOCODED, 32 | ATTR_GOOGLE_MAPS, 33 | ATTR_MAPQUEST, 34 | ATTR_METERS_FROM_HOME, 35 | ATTR_MILES_FROM_HOME, 36 | ATTR_OPEN_STREET_MAP, 37 | ATTR_PERSON_NAME, 38 | ATTR_RADAR, 39 | ATTR_REPORTED_STATE, 40 | ATTR_SOURCE, 41 | ATTR_ZONE, 42 | CONF_CREATE_SENSORS, 43 | CONF_FOLLOW_PERSON_INTEGRATION, 44 | CONF_GOOGLE_API_KEY, 45 | CONF_HOURS_EXTENDED_AWAY, 46 | CONF_MAPQUEST_API_KEY, 47 | CONF_MINUTES_JUST_ARRIVED, 48 | CONF_MINUTES_JUST_LEFT, 49 | CONF_OSM_API_KEY, 50 | CONF_RADAR_API_KEY, 51 | CONF_SHOW_ZONE_WHEN_AWAY, 52 | DATA_CONFIGURATION, 53 | DEFAULT_API_KEY_NOT_SET, 54 | DOMAIN, 55 | IC3_STATIONARY_ZONE_PREFIX, 56 | INFO_GEOCODE_COUNT, 57 | INFO_LOCALITY, 58 | INFO_TRIGGER_COUNT, 59 | INTEGRATION_NAME, 60 | PERSON_LOCATION_INTEGRATION, 61 | PERSON_LOCATION_TRIGGER, 62 | TARGET_LOCK, 63 | VERSION, 64 | ZONE_DOMAIN, 65 | ) 66 | 67 | _LOGGER = logging.getLogger(__name__) 68 | 69 | 70 | class PERSON_LOCATION_TARGET(SensorEntity, RestoreEntity): 71 | """Main target sensor created by this integration.""" 72 | 73 | def __init__(self, entity_id, pli, person_name) -> None: 74 | self._entity_id = entity_id 75 | self._pli = pli 76 | self._person_name = person_name 77 | self._attr_unique_id = f"{entity_id}_target" 78 | # Note: the following is the new HA specified way to set the name. 79 | # Unfortunately, this causes HA to always prepend the device name to 80 | # the friendly name, so we would lose the ability to completely 81 | # control the friendly name. 82 | # self._attr_has_entity_name = True 83 | # self._attr_name = None # Use the name of the device 84 | self._attr_name = f"{person_name} Location" 85 | # 86 | self._attr_native_value = STATE_UNKNOWN 87 | self._attr_extra_state_attributes = { 88 | ATTR_BREAD_CRUMBS: "", 89 | ATTR_PERSON_NAME: person_name, 90 | ATTR_REPORTED_STATE: STATE_UNKNOWN, 91 | ATTR_SOURCE: STATE_UNKNOWN, 92 | # "version": f"{DOMAIN} {VERSION}", 93 | } 94 | self.this_entity_info = { 95 | INFO_GEOCODE_COUNT: 0, 96 | INFO_LOCALITY: "?", 97 | INFO_TRIGGER_COUNT: 0, 98 | } 99 | self._attr_last_updated = datetime.now(timezone.utc) 100 | self._attr_last_changed = self._attr_last_updated 101 | self._previous_state = self._attr_native_value 102 | if not self._pli._target_sensors_restored: 103 | self._pli._target_sensors_restored = [] 104 | 105 | # Note: async_write_ha_state() in set_state does not have hass until 106 | # after async_add_entities() in async_setup_entry(). 107 | # self.set_state() 108 | 109 | _LOGGER.debug( 110 | "[PERSON_LOCATION_TARGET] (%s) Initialized", 111 | self._entity_id, 112 | ) 113 | 114 | # Old code has target.last_updated 115 | @property 116 | def last_updated(self): 117 | return self._attr_last_updated 118 | 119 | # Old code has target.last_changed 120 | @property 121 | def last_changed(self): 122 | return self._attr_last_changed 123 | 124 | @property 125 | def personName(self) -> str: 126 | return self._person_name 127 | 128 | @property 129 | def device_info(self) -> DeviceInfo: 130 | """Group target sensor under the person's device.""" 131 | return DeviceInfo( 132 | identifiers={(DOMAIN, f"{self._person_name.lower()}_location")}, 133 | name=f"{self._person_name} Location", 134 | manufacturer=INTEGRATION_NAME, 135 | model="Person Tracker", 136 | ) 137 | 138 | def handle_delayed_state_change( 139 | self, now, *, entity_id=None, from_state=None, to_state=None, minutes=3 140 | ) -> bool: 141 | """Handle the delayed state change.""" 142 | _LOGGER.debug( 143 | "[handle_delayed_state_change]" 144 | + " (%s) === Start === from_state = %s; to_state = %s", 145 | entity_id, 146 | from_state, 147 | to_state, 148 | ) 149 | 150 | with TARGET_LOCK: 151 | """Lock while updating the target(entity_id).""" 152 | 153 | _LOGGER.debug("[handle_delayed_state_change]" + " TARGET_LOCK obtained") 154 | 155 | target = get_target_entity(self._pli, entity_id) 156 | if not target: 157 | _LOGGER.warning( 158 | "[handle_delayed_state_change] no target sensor found for %s", 159 | entity_id, 160 | ) 161 | return False 162 | 163 | elapsed_timespan = datetime.now(timezone.utc) - target.last_changed 164 | elapsed_minutes = ( 165 | elapsed_timespan.total_seconds() + 1 166 | ) / 60 # fudge factor of one second 167 | 168 | if target._attr_native_value != from_state: 169 | _LOGGER.debug( 170 | "[handle_delayed_state_change]" 171 | + " Skip update: state %s is no longer %s", 172 | target._attr_native_value, 173 | from_state, 174 | ) 175 | elif elapsed_minutes < minutes: 176 | _LOGGER.debug( 177 | "[handle_delayed_state_change]" 178 | + " Skip update: state change minutes ago %s less than %s", 179 | elapsed_minutes, 180 | minutes, 181 | ) 182 | else: 183 | target._attr_native_value = to_state 184 | 185 | if to_state == "Home": 186 | target._attr_extra_state_attributes[ATTR_BREAD_CRUMBS] = to_state 187 | target._attr_extra_state_attributes[ATTR_COMPASS_BEARING] = 0 188 | target._attr_extra_state_attributes[ATTR_DIRECTION] = "home" 189 | elif to_state == "Away": 190 | if self._pli.configuration.get(CONF_SHOW_ZONE_WHEN_AWAY, False): 191 | reportedZone = target._attr_extra_state_attributes.get( 192 | ATTR_ZONE 193 | ) 194 | zoneStateObject = self._pli.hass.states.get( 195 | ZONE_DOMAIN + "." + reportedZone 196 | ) 197 | if zoneStateObject is None or reportedZone.startswith( 198 | IC3_STATIONARY_ZONE_PREFIX 199 | ): 200 | _LOGGER.debug( 201 | f"Skipping use of zone {reportedZone} for Away state" 202 | ) 203 | # pass 204 | else: 205 | zoneAttributesObject = zoneStateObject.attributes.copy() 206 | if "friendly_name" in zoneAttributesObject: 207 | target._attr_native_value = zoneAttributesObject[ 208 | "friendly_name" 209 | ] 210 | if self._pli.configuration[CONF_HOURS_EXTENDED_AWAY] != 0: 211 | self.change_state_later( 212 | target.entity_id, 213 | target._attr_native_value, 214 | "Extended Away", 215 | (self._pli.configuration[CONF_HOURS_EXTENDED_AWAY] * 60), 216 | ) 217 | # pass 218 | elif to_state == "Extended Away": 219 | pass 220 | 221 | target.set_state() 222 | _LOGGER.debug( 223 | "[handle_delayed_state_change]" + " (%s) === Return ===" % (entity_id) 224 | ) 225 | return True 226 | 227 | def change_state_later(self, entity_id, from_state, to_state, minutes=3) -> None: 228 | """Set timer to handle the delayed state change.""" 229 | _LOGGER.debug("[change_state_later]" + " (%s) === Start ===", entity_id) 230 | point_in_time = datetime.now() + timedelta(minutes=minutes) 231 | remove = async_track_point_in_time( 232 | self._pli.hass, 233 | partial( 234 | self.handle_delayed_state_change, 235 | entity_id=entity_id, 236 | from_state=from_state, 237 | to_state=to_state, 238 | minutes=minutes, 239 | ), 240 | point_in_time=point_in_time, 241 | ) 242 | if remove: 243 | _LOGGER.debug( 244 | "[change_state_later]" 245 | + " (%s) not-so-binary, handle_delayed_state_change(, %s, %s, %d) has been scheduled", 246 | entity_id, 247 | from_state, 248 | to_state, 249 | minutes, 250 | ) 251 | _LOGGER.debug("[change_state_later]" + " (%s) === Return ===", entity_id) 252 | 253 | async def async_added_to_hass(self) -> None: 254 | """Restore state after reboot.""" 255 | old_state = await self.async_get_last_state() 256 | if old_state is not None: 257 | self._attr_native_value = old_state.state 258 | self._attr_extra_state_attributes.update(old_state.attributes) 259 | self._attr_last_updated = old_state.last_updated 260 | self._attr_last_changed = old_state.last_changed 261 | self._previous_state = self._attr_native_value 262 | _LOGGER.debug( 263 | "[async_added_to_hass] Restored target sensor %s with state %s, last_changed %s, last_updated %s", 264 | self._entity_id, 265 | old_state.state, 266 | old_state.last_changed, 267 | old_state.last_updated, 268 | ) 269 | if self._entity_id not in self._pli._target_sensors_restored: 270 | self._pli._target_sensors_restored.append(self._entity_id) 271 | 272 | # Remove geolocation atribute if corresponding key has been removed 273 | 274 | if self._pli.configuration[CONF_GOOGLE_API_KEY] == DEFAULT_API_KEY_NOT_SET: 275 | removed = self._attr_extra_state_attributes.pop(ATTR_GOOGLE_MAPS, None) 276 | if removed: 277 | _LOGGER.debug( 278 | "[async_added_to_hass] Removed attribute %s", ATTR_GOOGLE_MAPS 279 | ) 280 | if ( 281 | self._pli.configuration[CONF_MAPQUEST_API_KEY] 282 | == DEFAULT_API_KEY_NOT_SET 283 | ): 284 | removed = self._attr_extra_state_attributes.pop(ATTR_MAPQUEST, None) 285 | if removed: 286 | _LOGGER.debug( 287 | "[async_added_to_hass] Removed attribute %s", ATTR_MAPQUEST 288 | ) 289 | if self._pli.configuration[CONF_OSM_API_KEY] == DEFAULT_API_KEY_NOT_SET: 290 | removed = self._attr_extra_state_attributes.pop( 291 | ATTR_OPEN_STREET_MAP, None 292 | ) 293 | if removed: 294 | _LOGGER.debug( 295 | "[async_added_to_hass] Removed attribute %s", 296 | ATTR_OPEN_STREET_MAP, 297 | ) 298 | if self._pli.configuration[CONF_RADAR_API_KEY] == DEFAULT_API_KEY_NOT_SET: 299 | removed = self._attr_extra_state_attributes.pop(ATTR_RADAR, None) 300 | if removed: 301 | _LOGGER.debug( 302 | "[async_added_to_hass] Removed attribute %s", ATTR_RADAR 303 | ) 304 | 305 | # Handle timers for delayed state change 306 | 307 | if old_state.state in ["Home", "Extended Away", ""]: 308 | pass 309 | elif old_state.state == "Just Left": 310 | _LOGGER.debug( 311 | "Presence detection not-so-binary, change state later: Away" 312 | ) 313 | self.change_state_later( 314 | self._entity_id, 315 | old_state.state, 316 | "Away", 317 | self._pli.configuration[CONF_MINUTES_JUST_LEFT], 318 | ) 319 | elif old_state.state == "Just Arrived": 320 | _LOGGER.debug( 321 | "Presence detection not-so-binary, change state later: Home" 322 | ) 323 | self.change_state_later( 324 | self._entity_id, 325 | old_state.state, 326 | "Home", 327 | self._pli.configuration[CONF_MINUTES_JUST_ARRIVED], 328 | ) 329 | else: # Otherwise, treat as "Away" 330 | _LOGGER.debug( 331 | "Presence detection not-so-binary, change state later: Extended Away" 332 | ) 333 | self.change_state_later( 334 | self._entity_id, 335 | old_state.state, 336 | "Extended Away", 337 | self._pli.configuration[CONF_HOURS_EXTENDED_AWAY] * 60, 338 | # TODO: Calculate time till Extended Away based on when Away 339 | ) 340 | await self.async_set_state() 341 | 342 | def set_state(self) -> None: 343 | """Push updates when called by synchronous services.""" 344 | _LOGGER.debug( 345 | "[set_state] (%s)", 346 | self._entity_id, 347 | ) 348 | 349 | self._attr_last_updated = datetime.now(timezone.utc) 350 | if self._previous_state != self._attr_native_value: 351 | self._attr_last_changed = self._attr_last_updated 352 | self._previous_state = self._attr_native_value 353 | 354 | # Schedule state write safely 355 | if self.hass: 356 | self.hass.add_job(self.async_write_ha_state) 357 | else: 358 | _LOGGER.debug("[set_state] hass not set for async_write_ha_state.") 359 | 360 | async def async_set_state(self) -> None: 361 | """Push updates when called by async services.""" 362 | _LOGGER.debug( 363 | "[async_set_state] (%s)", 364 | self._entity_id, 365 | ) 366 | self._attr_last_updated = datetime.now(timezone.utc) 367 | if self._previous_state != self._attr_native_value: 368 | self._attr_last_changed = self._attr_last_updated 369 | self._previous_state = self._attr_native_value 370 | 371 | if self.hass: 372 | self.async_write_ha_state() 373 | else: 374 | _LOGGER.debug("[async_set_state] hass not set for async_write_ha_state.") 375 | 376 | def make_template_sensor(self, attributeName, supplementalAttributeArray) -> None: 377 | """Make an additional sensor that will be used instead of making a template sensor.""" 378 | _LOGGER.debug("[make_template_sensor] === Start === %s", attributeName) 379 | 380 | if type(attributeName) is str: 381 | if attributeName in self._attr_extra_state_attributes: 382 | templateSuffix = attributeName 383 | templateState = self._attr_extra_state_attributes[attributeName] 384 | else: 385 | return 386 | elif type(attributeName) is dict: 387 | for templateSuffix in attributeName: 388 | templateState = attributeName[templateSuffix] 389 | 390 | templateAttributes = {} 391 | for supplementalAttribute in supplementalAttributeArray: 392 | if type(supplementalAttribute) is str: 393 | if supplementalAttribute in self._attr_extra_state_attributes: 394 | templateAttributes[supplementalAttribute] = ( 395 | self._attr_extra_state_attributes[supplementalAttribute] 396 | ) 397 | elif type(supplementalAttribute) is dict: 398 | for supplementalAttributeKey in supplementalAttribute: 399 | templateAttributes[supplementalAttributeKey] = ( 400 | supplementalAttribute[supplementalAttributeKey] 401 | ) 402 | else: 403 | _LOGGER.debug( 404 | "supplementalAttribute %s %s", 405 | supplementalAttribute, 406 | type(supplementalAttribute), 407 | ) 408 | templateAttributes[ATTR_PERSON_NAME] = self._person_name 409 | target = self._pli.hass.data[DOMAIN]["entities"].get(self._entity_id) 410 | create_and_register_template_sensor( 411 | self._pli.hass, target, attributeName, templateState, templateAttributes 412 | ) 413 | 414 | _LOGGER.debug("[make_template_sensor] === Return === %s", attributeName) 415 | 416 | def make_template_sensors(self) -> None: 417 | """Make the additional sensors if they are requested.""" 418 | create_sensors_list = ( 419 | self._pli.configuration[CONF_CREATE_SENSORS] 420 | or self._pli.hass.data[DOMAIN][DATA_CONFIGURATION][CONF_CREATE_SENSORS] 421 | ) 422 | _LOGGER.debug( 423 | "[make_template_sensors] === Start === configuration = %s", 424 | create_sensors_list, 425 | ) 426 | 427 | for attributeName in create_sensors_list: 428 | if ( 429 | attributeName == ATTR_ALTITUDE 430 | and ATTR_ALTITUDE in self._attr_extra_state_attributes 431 | and self._attr_extra_state_attributes[ATTR_ALTITUDE] != 0 432 | and ATTR_VERTICAL_ACCURACY in self._attr_extra_state_attributes 433 | and self._attr_extra_state_attributes[ATTR_VERTICAL_ACCURACY] != 0 434 | ): 435 | self.make_template_sensor( 436 | ATTR_ALTITUDE, 437 | [ 438 | ATTR_VERTICAL_ACCURACY, 439 | ATTR_ICON, 440 | {ATTR_UNIT_OF_MEASUREMENT: "m"}, 441 | ], 442 | ) 443 | 444 | elif attributeName == ATTR_BREAD_CRUMBS: 445 | self.make_template_sensor(ATTR_BREAD_CRUMBS, [ATTR_ICON]) 446 | 447 | elif attributeName == ATTR_DIRECTION: 448 | self.make_template_sensor(ATTR_DIRECTION, [ATTR_ICON]) 449 | 450 | elif attributeName == ATTR_DRIVING_MILES: 451 | self.make_template_sensor( 452 | ATTR_DRIVING_MILES, 453 | [ 454 | ATTR_DRIVING_MINUTES, 455 | ATTR_METERS_FROM_HOME, 456 | ATTR_MILES_FROM_HOME, 457 | {ATTR_UNIT_OF_MEASUREMENT: "mi"}, 458 | ATTR_ICON, 459 | ], 460 | ) 461 | 462 | elif attributeName == ATTR_DRIVING_MINUTES: 463 | self.make_template_sensor( 464 | ATTR_DRIVING_MINUTES, 465 | [ 466 | ATTR_DRIVING_MILES, 467 | ATTR_METERS_FROM_HOME, 468 | ATTR_MILES_FROM_HOME, 469 | {ATTR_UNIT_OF_MEASUREMENT: "min"}, 470 | ATTR_ICON, 471 | ], 472 | ) 473 | 474 | elif attributeName == ATTR_GEOCODED: 475 | pass 476 | 477 | elif attributeName == ATTR_LATITUDE: 478 | self.make_template_sensor(ATTR_LATITUDE, [ATTR_GPS_ACCURACY, ATTR_ICON]) 479 | 480 | elif attributeName == ATTR_LONGITUDE: 481 | self.make_template_sensor( 482 | ATTR_LONGITUDE, [ATTR_GPS_ACCURACY, ATTR_ICON] 483 | ) 484 | 485 | elif attributeName == ATTR_METERS_FROM_HOME: 486 | self.make_template_sensor( 487 | ATTR_METERS_FROM_HOME, 488 | [ 489 | ATTR_MILES_FROM_HOME, 490 | ATTR_DRIVING_MILES, 491 | ATTR_DRIVING_MINUTES, 492 | ATTR_ICON, 493 | {ATTR_UNIT_OF_MEASUREMENT: "m"}, 494 | ], 495 | ) 496 | 497 | elif attributeName == ATTR_MILES_FROM_HOME: 498 | self.make_template_sensor( 499 | ATTR_MILES_FROM_HOME, 500 | [ 501 | ATTR_METERS_FROM_HOME, 502 | ATTR_DRIVING_MILES, 503 | ATTR_DRIVING_MINUTES, 504 | {ATTR_UNIT_OF_MEASUREMENT: "mi"}, 505 | ATTR_ICON, 506 | ], 507 | ) 508 | 509 | else: 510 | self.make_template_sensor(attributeName, [ATTR_ICON]) 511 | 512 | _LOGGER.debug("[make_template_sensors] === Return ===") 513 | 514 | 515 | class PersonLocationTemplateSensor(SensorEntity): 516 | """Template sensor (altitude, speed, etc.) tied to a person.""" 517 | 518 | def __init__( 519 | self, parent: PERSON_LOCATION_TARGET, suffix: str, value, attrs 520 | ) -> None: 521 | base_id = getattr(parent, "_entity_id", None) or getattr(parent, "entity_id") 522 | self._entity_id = f"{base_id}_{suffix}" 523 | self._parent = parent 524 | self._suffix = suffix 525 | self._attr_unique_id = f"{base_id}_{suffix}_template" 526 | self._attr_has_entity_name = True 527 | self._attr_name = suffix.replace("_", " ").title() 528 | # self._attr_name = f"{getattr(parent, 'personName', base_id)} {suffix}" 529 | self._attr_native_value = value 530 | self._attr_extra_state_attributes = attrs 531 | self._pending_update = True 532 | self._unsub = None # for cleanup of listeners if needed 533 | 534 | @property 535 | def device_info(self) -> DeviceInfo: 536 | """Group template sensors under the same device as the parent target sensor.""" 537 | return DeviceInfo( 538 | identifiers={(DOMAIN, f"{self._parent._person_name.lower()}_location")}, 539 | name=f"{self._parent._person_name} Location", 540 | manufacturer=INTEGRATION_NAME, 541 | model="Person Tracker", 542 | ) 543 | 544 | async def async_will_remove_from_hass(self) -> None: 545 | """Clean up any listeners or tasks when entity is removed.""" 546 | if self._unsub: 547 | self._unsub() 548 | self._unsub = None 549 | _LOGGER.debug("Template sensor %s removed cleanly", self._attr_unique_id) 550 | 551 | 552 | async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities) -> None: 553 | """Set up person_location sensors from a config entry.""" 554 | _LOGGER.debug("[async_setup_entry] entry: %s", entry.entry_id) 555 | _LOGGER.debug("[async_setup_entry] entry.data: %s", entry.data) 556 | 557 | if DOMAIN not in hass.data: 558 | hass.data[DOMAIN] = {} 559 | 560 | hass.data[DOMAIN]["async_add_entities"] = async_add_entities 561 | hass.data[DOMAIN]["entities"] = {} 562 | 563 | # Create initial target sensors here, based on the person_name of devices 564 | entry_devices = entry.data.get("devices", {}) 565 | _LOGGER.debug("[async_setup_entry] entry.data.devices: %s", entry_devices) 566 | 567 | pli = hass.data[DOMAIN].get("integration") 568 | if pli: 569 | entities = hass.data[DOMAIN].setdefault("entities", {}) 570 | new_entities = [] 571 | seen_entity_ids = set() 572 | 573 | if pli.configuration.get(CONF_FOLLOW_PERSON_INTEGRATION): 574 | entity_ids = await hass.async_add_executor_job( 575 | hass.states.entity_ids, "person" 576 | ) 577 | for trigger_entity_id in entity_ids: 578 | trigger = PERSON_LOCATION_TRIGGER(trigger_entity_id, pli) 579 | entity_id = trigger.targetName 580 | person_name = trigger.personName 581 | 582 | if entity_id in seen_entity_ids or entity_id in entities: 583 | _LOGGER.debug( 584 | "[async_setup_entry] Skipping duplicate entity in entity_ids: %s", 585 | entity_id, 586 | ) 587 | continue 588 | 589 | sensor = PERSON_LOCATION_TARGET(entity_id, pli, person_name) 590 | entities[entity_id] = sensor 591 | new_entities.append(sensor) 592 | seen_entity_ids.add(entity_id) 593 | 594 | _LOGGER.debug( 595 | "[async_setup_entry] Created and preparing to register entity from entity_ids: %s", 596 | entity_id, 597 | ) 598 | 599 | for device_id, person_name in entry_devices.items(): 600 | entity_id = f"sensor.{person_name.lower()}_location" 601 | 602 | if entity_id in seen_entity_ids or entity_id in entities: 603 | _LOGGER.debug( 604 | "[async_setup_entry] Skipping duplicate entity in entry_devices: %s", 605 | entity_id, 606 | ) 607 | continue 608 | 609 | sensor = PERSON_LOCATION_TARGET(entity_id, pli, person_name) 610 | entities[entity_id] = sensor 611 | new_entities.append(sensor) 612 | seen_entity_ids.add(entity_id) 613 | 614 | _LOGGER.debug( 615 | "[async_setup_entry] Created and preparing to register entity from entry_devices: %s", 616 | entity_id, 617 | ) 618 | 619 | if new_entities: 620 | async_add_entities(new_entities) 621 | _LOGGER.debug( 622 | "[async_setup_entry] Registered entities: %s", 623 | [e.entity_id for e in new_entities], 624 | ) 625 | else: 626 | _LOGGER.debug( 627 | "[async_setup_entry] pli is not yet available, hass.data[DOMAIN]: %s", 628 | hass.data[DOMAIN], 629 | ) 630 | 631 | 632 | def create_and_register_template_sensor( 633 | hass, parent, suffix, value, attrs 634 | ) -> PersonLocationTemplateSensor: 635 | """Create or update a PersonLocationTemplateSensor safely.""" 636 | entities = hass.data[DOMAIN]["entities"] 637 | entity_id = f"sensor.{parent.personName.lower()}_location_{suffix.lower()}" 638 | 639 | if entity_id in entities: 640 | # Update existing sensor 641 | sensor = entities[entity_id] 642 | sensor._attr_native_value = value 643 | sensor._attr_extra_state_attributes.update(attrs) 644 | 645 | # Schedule safe state update only if hass is still attached 646 | if sensor.hass: 647 | hass.loop.call_soon_threadsafe(lambda: sensor.async_write_ha_state()) 648 | else: 649 | _LOGGER.warning( 650 | "[create_and_register_template_sensor] Sensor %s hass not set for async_write_ha_state.", 651 | entity_id, 652 | ) 653 | 654 | _LOGGER.debug( 655 | "[create_and_register_template_sensor] Updated existing template sensor %s", 656 | entity_id, 657 | ) 658 | return sensor 659 | 660 | # Create new sensor 661 | sensor = PersonLocationTemplateSensor( 662 | parent=parent, 663 | suffix=suffix, 664 | value=value, 665 | attrs=attrs, 666 | ) 667 | 668 | async_add_entities = hass.data[DOMAIN]["async_add_entities"] 669 | 670 | # Schedule entity addition safely 671 | hass.loop.call_soon_threadsafe(lambda: async_add_entities([sensor])) 672 | 673 | entities[entity_id] = sensor 674 | 675 | _LOGGER.debug( 676 | "[create_and_register_template_sensor] Created new template sensor %s", 677 | entity_id, 678 | ) 679 | return sensor 680 | 681 | 682 | def get_target_entity( 683 | pli: PERSON_LOCATION_INTEGRATION, entity_id: str 684 | ) -> PERSON_LOCATION_TARGET: 685 | """Get the target entity from the entity_id.""" 686 | return pli.hass.data.get(DOMAIN, {}).get("entities", {}).get(entity_id) 687 | -------------------------------------------------------------------------------- /custom_components/person_location/process_trigger.py: -------------------------------------------------------------------------------- 1 | """The person_location integration process_trigger service.""" 2 | 3 | from datetime import datetime, timedelta, timezone 4 | from functools import partial 5 | import logging 6 | import string 7 | 8 | from homeassistant.components.device_tracker import SourceType 9 | from homeassistant.components.device_tracker.const import ( 10 | ATTR_SOURCE_TYPE, 11 | ) 12 | from homeassistant.components.mobile_app.const import ( 13 | ATTR_VERTICAL_ACCURACY, 14 | ) 15 | from homeassistant.const import ( 16 | ATTR_ENTITY_PICTURE, 17 | ATTR_GPS_ACCURACY, 18 | ATTR_LATITUDE, 19 | ATTR_LONGITUDE, 20 | CONF_ENTITY_ID, 21 | STATE_NOT_HOME, 22 | STATE_ON, 23 | STATE_UNAVAILABLE, 24 | STATE_UNKNOWN, 25 | ) 26 | from homeassistant.helpers.event import ( 27 | track_point_in_time, 28 | ) 29 | 30 | from .const import ( 31 | ATTR_ALTITUDE, 32 | ATTR_BREAD_CRUMBS, 33 | ATTR_COMPASS_BEARING, 34 | ATTR_DIRECTION, 35 | ATTR_ICON, 36 | ATTR_LAST_LOCATED, 37 | ATTR_LOCATION_TIME, 38 | ATTR_PERSON_NAME, 39 | ATTR_REPORTED_STATE, 40 | ATTR_SOURCE, 41 | ATTR_SPEED, 42 | ATTR_ZONE, 43 | CONF_FRIENDLY_NAME_TEMPLATE, 44 | CONF_HOURS_EXTENDED_AWAY, 45 | CONF_MINUTES_JUST_ARRIVED, 46 | CONF_MINUTES_JUST_LEFT, 47 | CONF_SHOW_ZONE_WHEN_AWAY, 48 | DEFAULT_FRIENDLY_NAME_TEMPLATE, 49 | DOMAIN, 50 | IC3_STATIONARY_ZONE_PREFIX, 51 | INFO_TRIGGER_COUNT, 52 | PERSON_LOCATION_INTEGRATION, 53 | PERSON_LOCATION_TRIGGER, 54 | TARGET_LOCK, 55 | ZONE_DOMAIN, 56 | ) 57 | from .sensor import get_target_entity 58 | 59 | _LOGGER = logging.getLogger(__name__) 60 | 61 | 62 | # def get_target_entity( 63 | # pli: PERSON_LOCATION_INTEGRATION, entity_id 64 | # ) -> PERSON_LOCATION_TARGET: 65 | # return pli.hass.data.get(DOMAIN, {}).get("entities", {}).get(entity_id) 66 | 67 | 68 | def setup_process_trigger(pli: PERSON_LOCATION_INTEGRATION) -> bool: 69 | """Initialize process_trigger service.""" 70 | 71 | def handle_delayed_state_change( 72 | now, *, entity_id=None, from_state=None, to_state=None, minutes=3 73 | ) -> bool: 74 | """Handle the delayed state change.""" 75 | _LOGGER.debug( 76 | "[handle_delayed_state_change]" 77 | + " (%s) === Start === from_state = %s; to_state = %s", 78 | entity_id, 79 | from_state, 80 | to_state, 81 | ) 82 | 83 | with TARGET_LOCK: 84 | """Lock while updating the target(entity_id).""" 85 | _LOGGER.debug("[handle_delayed_state_change]" + " TARGET_LOCK obtained") 86 | target = get_target_entity(pli, entity_id) 87 | if not target: 88 | _LOGGER.warning( 89 | "[handle_delayed_state_change] no target sensor found for %s", 90 | entity_id, 91 | ) 92 | return False 93 | 94 | elapsed_timespan = datetime.now(timezone.utc) - target.last_changed 95 | elapsed_minutes = ( 96 | elapsed_timespan.total_seconds() + 1 97 | ) / 60 # fudge factor of one second 98 | 99 | if target._attr_native_value != from_state: 100 | _LOGGER.debug( 101 | "[handle_delayed_state_change]" 102 | + " Skip update: state %s is no longer %s", 103 | target._attr_native_value, 104 | from_state, 105 | ) 106 | elif elapsed_minutes < minutes: 107 | _LOGGER.debug( 108 | "[handle_delayed_state_change]" 109 | + " Skip update: state change minutes ago %s less than %s", 110 | elapsed_minutes, 111 | minutes, 112 | ) 113 | else: 114 | target._attr_native_value = to_state 115 | 116 | if to_state == "Home": 117 | target._attr_extra_state_attributes[ATTR_BREAD_CRUMBS] = to_state 118 | target._attr_extra_state_attributes[ATTR_COMPASS_BEARING] = 0 119 | target._attr_extra_state_attributes[ATTR_DIRECTION] = "home" 120 | elif to_state == "Away": 121 | if pli.configuration[CONF_SHOW_ZONE_WHEN_AWAY]: 122 | reportedZone = target._attr_extra_state_attributes[ATTR_ZONE] 123 | zoneStateObject = pli.hass.states.get( 124 | ZONE_DOMAIN + "." + reportedZone 125 | ) 126 | if zoneStateObject is None or reportedZone.startswith( 127 | IC3_STATIONARY_ZONE_PREFIX 128 | ): 129 | _LOGGER.debug( 130 | f"Skipping use of zone {reportedZone} for Away state" 131 | ) 132 | else: 133 | zoneAttributesObject = zoneStateObject.attributes.copy() 134 | if "friendly_name" in zoneAttributesObject: 135 | target._attr_native_value = zoneAttributesObject[ 136 | "friendly_name" 137 | ] 138 | if pli.configuration[CONF_HOURS_EXTENDED_AWAY] != 0: 139 | change_state_later( 140 | target.entity_id, 141 | target._attr_native_value, 142 | "Extended Away", 143 | (pli.configuration[CONF_HOURS_EXTENDED_AWAY] * 60), 144 | ) 145 | elif to_state == "Extended Away": 146 | pass 147 | 148 | target.set_state() 149 | _LOGGER.debug( 150 | "[handle_delayed_state_change]" + " (%s) === Return ===", entity_id 151 | ) 152 | return True 153 | 154 | def change_state_later(entity_id, from_state, to_state, minutes=3) -> None: 155 | """Set timer to handle the delayed state change.""" 156 | _LOGGER.debug("[change_state_later]" + " (%s) === Start ===", entity_id) 157 | point_in_time = datetime.now() + timedelta(minutes=minutes) 158 | remove = track_point_in_time( 159 | pli.hass, 160 | partial( 161 | handle_delayed_state_change, 162 | entity_id=entity_id, 163 | from_state=from_state, 164 | to_state=to_state, 165 | minutes=minutes, 166 | ), 167 | point_in_time=point_in_time, 168 | ) 169 | if remove: 170 | _LOGGER.debug( 171 | "[change_state_later]" 172 | + " (%s) handle_delayed_state_change(, %s, %s, %d) has been scheduled", 173 | entity_id, 174 | from_state, 175 | to_state, 176 | minutes, 177 | ) 178 | _LOGGER.debug("[change_state_later] (%s) === Return ===", entity_id) 179 | 180 | def utc2local_naive(utc_dt: datetime) -> datetime: 181 | """Convert a UTC datetime to a naive local datetime.""" 182 | # Ensure the input is treated as UTC 183 | utc_dt = utc_dt.replace(tzinfo=timezone.utc) 184 | # Convert to local time 185 | local_dt = utc_dt.astimezone() 186 | # Strip tzinfo to make it naive 187 | return local_dt.replace(tzinfo=None) 188 | 189 | def handle_process_trigger(call) -> bool: 190 | """ 191 | Handle changes of triggered device trackers and sensors. 192 | 193 | Input: 194 | - Parameters for the call: 195 | entity_id 196 | from_state 197 | to_state 198 | Output (if update is accepted): 199 | - Updated "sensor._location" with 's location and status: 200 | Attributes: 201 | - selected attributes from the triggered device tracker 202 | - state: "Just Arrived", "Home", "Just Left", "Away", or "Extended Away" 203 | If CONF_SHOW_ZONE_WHEN_AWAY, then the is reported instead of "Away". 204 | - person_name: 205 | - source: entity_id of the device tracker that triggered the automation 206 | - reported_state: the state reported by device tracker = "Home", "Away", or 207 | - bread_crumbs: the series of locations that have been seen 208 | - icon: the icon that corresponds with the current zone 209 | - Call rest_command service to update HomeSeer: 'homeseer__' 210 | """ 211 | entity_id = call.data.get(CONF_ENTITY_ID, "NONE") 212 | triggerFrom = call.data.get("from_state", "NONE") 213 | triggerTo = call.data.get("to_state", "NONE") 214 | 215 | # Validate the input entity: 216 | 217 | if entity_id == "NONE": 218 | { 219 | _LOGGER.warning( 220 | "[handle_process_trigger] %s is required in call of %s.process_trigger service.", 221 | CONF_ENTITY_ID, 222 | DOMAIN, 223 | ) 224 | } 225 | return False 226 | 227 | ha_just_started = pli.attributes["startup"] 228 | if ha_just_started: 229 | _LOGGER.debug("HA just started flag is on") 230 | 231 | trigger = PERSON_LOCATION_TRIGGER(entity_id, pli) 232 | 233 | _LOGGER.debug( 234 | "(%s) === Start === from_state = %s; to_state = %s", 235 | trigger.entity_id, 236 | triggerFrom, 237 | triggerTo, 238 | ) 239 | 240 | if trigger.entity_id == trigger.targetName: 241 | _LOGGER.debug( 242 | "(%s) Decision: skip self update: target = (%s)", 243 | trigger.entity_id, 244 | trigger.targetName, 245 | ) 246 | elif ATTR_GPS_ACCURACY in trigger.attributes and ( 247 | (trigger.attributes[ATTR_GPS_ACCURACY] == 0) 248 | or (trigger.attributes[ATTR_GPS_ACCURACY] >= 100) 249 | ): 250 | _LOGGER.debug( 251 | "(%s) Decision: skip update: gps_accuracy = %s", 252 | trigger.entity_id, 253 | trigger.attributes[ATTR_GPS_ACCURACY], 254 | ) 255 | else: 256 | if ATTR_LAST_LOCATED in trigger.attributes: 257 | last_located = trigger.attributes[ATTR_LAST_LOCATED] 258 | new_location_time = datetime.strptime(last_located, "%Y-%m-%d %H:%M:%S") 259 | else: 260 | new_location_time = utc2local_naive( 261 | trigger.last_updated 262 | ) # HA last_updated is UTC 263 | 264 | if ATTR_SOURCE_TYPE in trigger.attributes: 265 | triggerSourceType = trigger.attributes[ATTR_SOURCE_TYPE] 266 | else: 267 | triggerSourceType = "other" 268 | # Person entities do not indicate the source type, dig deeper: 269 | if ( 270 | "source" in trigger.attributes 271 | and "." in trigger.attributes["source"] 272 | ): 273 | triggerSourceObject = pli.hass.states.get( 274 | trigger.attributes["source"] 275 | ) 276 | if triggerSourceObject is not None: 277 | if ATTR_SOURCE_TYPE in triggerSourceObject.attributes: 278 | triggerSourceType = triggerSourceObject.attributes[ 279 | ATTR_SOURCE_TYPE 280 | ] 281 | 282 | # --------------------------------------------------------- 283 | # Get the current state of the target person location 284 | # sensor and decide if it should be updated with values 285 | # from the triggered device tracker: 286 | saveThisUpdate = False 287 | # --------------------------------------------------------- 288 | 289 | with TARGET_LOCK: 290 | """Lock while updating the target(trigger.targetName).""" 291 | _LOGGER.debug( 292 | "(%s) TARGET_LOCK obtained", 293 | trigger.targetName, 294 | ) 295 | target = get_target_entity(pli, trigger.targetName) 296 | if not target: 297 | _LOGGER.warning("No target sensor found for %s", trigger.targetName) 298 | return False 299 | 300 | target.this_entity_info[INFO_TRIGGER_COUNT] += 1 301 | 302 | if triggerTo in ["NotSet", STATE_UNAVAILABLE, STATE_UNKNOWN]: 303 | _LOGGER.debug( 304 | "(%s) Decision: skip update: triggerTo = %s", 305 | trigger.entity_id, 306 | triggerTo, 307 | ) 308 | if ( 309 | ATTR_SOURCE in target._attr_extra_state_attributes 310 | and target._attr_extra_state_attributes[ATTR_SOURCE] 311 | == trigger.entity_id 312 | ): 313 | _LOGGER.debug( 314 | "(%s) Removing from target's source", 315 | trigger.entity_id, 316 | ) 317 | target._attr_extra_state_attributes.pop(ATTR_SOURCE) 318 | target.set_state() 319 | return True 320 | 321 | if ATTR_LOCATION_TIME in target._attr_extra_state_attributes: 322 | old_location_time = datetime.strptime( 323 | str(target._attr_extra_state_attributes[ATTR_LOCATION_TIME]), 324 | "%Y-%m-%d %H:%M:%S.%f", 325 | ) 326 | else: 327 | old_location_time = utc2local_naive( 328 | target.last_updated 329 | ) # HA last_updated is UTC 330 | 331 | if new_location_time < old_location_time: 332 | _LOGGER.debug( 333 | "(%s) Decision: skip stale update: %s < %s", 334 | trigger.entity_id, 335 | new_location_time, 336 | old_location_time, 337 | ) 338 | # elif target.firstTime: 339 | # saveThisUpdate = True 340 | # _LOGGER.debug( 341 | # "(%s) Decision: target %s does not yet exist (normal at startup)", 342 | # trigger.entity_id, 343 | # target.entity_id, 344 | # ) 345 | # oldTargetState = "none" 346 | else: 347 | oldTargetState = target._attr_native_value.lower() 348 | if oldTargetState == STATE_UNKNOWN: 349 | saveThisUpdate = True 350 | _LOGGER.debug( 351 | "(%s) Decision: accepting the first update of %s", 352 | trigger.entity_id, 353 | target.entity_id, 354 | ) 355 | elif triggerSourceType == SourceType.GPS: # gps device? 356 | if triggerTo != triggerFrom: # did it change zones? 357 | saveThisUpdate = True # gps changing zones is assumed to be new, correct info 358 | _LOGGER.debug( 359 | "(%s) Decision: trigger has changed zones", 360 | trigger.entity_id, 361 | ) 362 | else: 363 | if ( 364 | ATTR_SOURCE not in target._attr_extra_state_attributes 365 | or target._attr_extra_state_attributes[ATTR_SOURCE] 366 | == trigger.entity_id 367 | or ATTR_REPORTED_STATE 368 | not in target._attr_extra_state_attributes 369 | ): # Same entity as we are following, if any? 370 | saveThisUpdate = True 371 | _LOGGER.debug( 372 | "(%s) Decision: continue following trigger", 373 | trigger.entity_id, 374 | ) 375 | elif ( 376 | ATTR_LATITUDE in trigger.attributes 377 | and ATTR_LONGITUDE in trigger.attributes 378 | and ATTR_LATITUDE 379 | not in target._attr_extra_state_attributes 380 | and ATTR_LONGITUDE 381 | not in target._attr_extra_state_attributes 382 | ): 383 | saveThisUpdate = True 384 | _LOGGER.debug( 385 | "(%s) Decision: use source that has coordinates", 386 | trigger.entity_id, 387 | ) 388 | elif ( 389 | trigger.state 390 | == target._attr_extra_state_attributes[ 391 | ATTR_REPORTED_STATE 392 | ] 393 | ): # Same status as the one we are following? 394 | # if ATTR_VERTICAL_ACCURACY in trigger.attributes: 395 | # if ( 396 | # ATTR_VERTICAL_ACCURACY not in target._attr_extra_state_attributes 397 | # ) or ( 398 | # trigger.attributes[ATTR_VERTICAL_ACCURACY] > 0 399 | # and target._attr_extra_state_attributes[ATTR_VERTICAL_ACCURACY] 400 | # == 0 401 | # ): # better choice based on accuracy? 402 | # saveThisUpdate = True 403 | # _LOGGER.debug( 404 | # "(%s) Decision: vertical_accuracy is better than %s", 405 | # trigger.entity_id, 406 | # target._attr_extra_state_attributes[ATTR_SOURCE], 407 | # ) 408 | if ATTR_GPS_ACCURACY in trigger.attributes and ( 409 | ATTR_GPS_ACCURACY 410 | not in target._attr_extra_state_attributes 411 | or trigger.attributes[ATTR_GPS_ACCURACY] 412 | < target._attr_extra_state_attributes[ 413 | ATTR_GPS_ACCURACY 414 | ] 415 | ): # Better choice based on accuracy? 416 | saveThisUpdate = True 417 | _LOGGER.debug( 418 | "(%s) Decision: gps_accuracy is better than %s", 419 | trigger.entity_id, 420 | target._attr_extra_state_attributes[ 421 | ATTR_SOURCE 422 | ], 423 | ) 424 | elif ( 425 | ha_just_started 426 | and ATTR_LATITUDE in trigger.attributes 427 | and ATTR_LONGITUDE in trigger.attributes 428 | ): 429 | saveThisUpdate = True 430 | _LOGGER.debug( 431 | "(%s) Decision: accept gps source that has coordinates during startup", 432 | trigger.entity_id, 433 | ) 434 | else: # source = router or ping 435 | if triggerTo != triggerFrom: # did tracker change state? 436 | if (trigger.stateHomeAway == "Home") != ( 437 | oldTargetState == "home" 438 | ): # reporting Home 439 | saveThisUpdate = True 440 | _LOGGER.debug( 441 | "(%s) Decision: non-GPS trigger has changed state", 442 | trigger.entity_id, 443 | ) 444 | 445 | # ----------------------------------------------------- 446 | 447 | if not saveThisUpdate: 448 | _LOGGER.debug( 449 | "(%s) Decision: ignore update", 450 | trigger.entity_id, 451 | ) 452 | else: 453 | _LOGGER.debug( 454 | "(%s Saving This Update) -state: %s -attributes: %s", 455 | trigger.entity_id, 456 | trigger.state, 457 | trigger.attributes, 458 | ) 459 | 460 | # Carry over selected attributes from trigger to target: 461 | 462 | if ATTR_SOURCE_TYPE in trigger.attributes: 463 | target._attr_extra_state_attributes[ATTR_SOURCE_TYPE] = ( 464 | trigger.attributes[ATTR_SOURCE_TYPE] 465 | ) 466 | else: 467 | if ATTR_SOURCE_TYPE in target._attr_extra_state_attributes: 468 | target._attr_extra_state_attributes.pop(ATTR_SOURCE_TYPE) 469 | 470 | if ( 471 | ATTR_LATITUDE in trigger.attributes 472 | and ATTR_LONGITUDE in trigger.attributes 473 | ): 474 | target._attr_extra_state_attributes[ATTR_LATITUDE] = ( 475 | trigger.attributes[ATTR_LATITUDE] 476 | ) 477 | target._attr_extra_state_attributes[ATTR_LONGITUDE] = ( 478 | trigger.attributes[ATTR_LONGITUDE] 479 | ) 480 | else: 481 | if ATTR_LATITUDE in target._attr_extra_state_attributes: 482 | target._attr_extra_state_attributes.pop(ATTR_LATITUDE) 483 | if ATTR_LONGITUDE in target._attr_extra_state_attributes: 484 | target._attr_extra_state_attributes.pop(ATTR_LONGITUDE) 485 | 486 | if ATTR_GPS_ACCURACY in trigger.attributes: 487 | target._attr_extra_state_attributes[ATTR_GPS_ACCURACY] = ( 488 | trigger.attributes[ATTR_GPS_ACCURACY] 489 | ) 490 | else: 491 | if ATTR_GPS_ACCURACY in target._attr_extra_state_attributes: 492 | target._attr_extra_state_attributes.pop(ATTR_GPS_ACCURACY) 493 | 494 | if ATTR_ALTITUDE in trigger.attributes: 495 | target._attr_extra_state_attributes[ATTR_ALTITUDE] = round( 496 | trigger.attributes[ATTR_ALTITUDE] 497 | ) 498 | else: 499 | if ATTR_ALTITUDE in target._attr_extra_state_attributes: 500 | target._attr_extra_state_attributes.pop(ATTR_ALTITUDE) 501 | 502 | if ATTR_VERTICAL_ACCURACY in trigger.attributes: 503 | target._attr_extra_state_attributes[ATTR_VERTICAL_ACCURACY] = ( 504 | trigger.attributes[ATTR_VERTICAL_ACCURACY] 505 | ) 506 | else: 507 | if ( 508 | ATTR_VERTICAL_ACCURACY 509 | in target._attr_extra_state_attributes 510 | ): 511 | target._attr_extra_state_attributes.pop( 512 | ATTR_VERTICAL_ACCURACY 513 | ) 514 | 515 | if ATTR_ENTITY_PICTURE in trigger.attributes: 516 | target._attr_extra_state_attributes[ATTR_ENTITY_PICTURE] = ( 517 | trigger.attributes[ATTR_ENTITY_PICTURE] 518 | ) 519 | else: 520 | if ATTR_ENTITY_PICTURE in target._attr_extra_state_attributes: 521 | target._attr_extra_state_attributes.pop(ATTR_ENTITY_PICTURE) 522 | 523 | if ATTR_SPEED in trigger.attributes: 524 | target._attr_extra_state_attributes[ATTR_SPEED] = ( 525 | trigger.attributes[ATTR_SPEED] 526 | ) 527 | _LOGGER.debug( 528 | "(%s) speed = %s", 529 | trigger.entity_id, 530 | trigger.attributes[ATTR_SPEED], 531 | ) 532 | else: 533 | if ATTR_SPEED in target._attr_extra_state_attributes: 534 | target._attr_extra_state_attributes.pop(ATTR_SPEED) 535 | 536 | target._attr_extra_state_attributes[ATTR_SOURCE] = trigger.entity_id 537 | target._attr_extra_state_attributes[ATTR_REPORTED_STATE] = ( 538 | trigger.state 539 | ) 540 | target._attr_extra_state_attributes[ATTR_PERSON_NAME] = ( 541 | string.capwords(trigger.personName) 542 | ) 543 | 544 | target._attr_extra_state_attributes[ATTR_LOCATION_TIME] = ( 545 | new_location_time.strftime("%Y-%m-%d %H:%M:%S.%f") 546 | ) 547 | _LOGGER.debug( 548 | "(%s) new_location_time = %s", 549 | target.entity_id, 550 | new_location_time, 551 | ) 552 | 553 | # Determine the zone and the icon to be used: 554 | 555 | if ATTR_ZONE in trigger.attributes: 556 | reportedZone = trigger.attributes[ATTR_ZONE] 557 | else: 558 | reportedZone = ( 559 | trigger.state.lower().replace(" ", "_").replace("'", "_") 560 | ) 561 | zoneStateObject = pli.hass.states.get( 562 | ZONE_DOMAIN + "." + reportedZone 563 | ) 564 | icon = "mdi:help-circle" 565 | if zoneStateObject is not None and not reportedZone.startswith( 566 | IC3_STATIONARY_ZONE_PREFIX 567 | ): 568 | zoneAttributesObject = zoneStateObject.attributes.copy() 569 | if ATTR_ICON in zoneAttributesObject: 570 | icon = zoneAttributesObject[ATTR_ICON] 571 | 572 | target._attr_extra_state_attributes[ATTR_ICON] = icon 573 | target._attr_extra_state_attributes[ATTR_ZONE] = reportedZone 574 | 575 | _LOGGER.debug( 576 | "(%s) zone = %s; icon = %s", 577 | trigger.entity_id, 578 | reportedZone, 579 | target._attr_extra_state_attributes[ATTR_ICON], 580 | ) 581 | 582 | if reportedZone == "home": 583 | target._attr_extra_state_attributes[ATTR_LATITUDE] = ( 584 | pli.attributes["home_latitude"] 585 | ) 586 | target._attr_extra_state_attributes[ATTR_LONGITUDE] = ( 587 | pli.attributes["home_longitude"] 588 | ) 589 | 590 | # Set up something like https://philhawthorne.com/making-home-assistants-presence-detection-not-so-binary/ 591 | # https://github.com/rodpayne/home-assistant_person_location?tab=readme-ov-file#make-presence-detection-not-so-binary 592 | # If Home Assistant just started, just go with Home or Away as the initial state. 593 | 594 | _LOGGER.debug( 595 | f"Presence detection not-so-binary: stateHomeAway = {trigger.stateHomeAway}, oldTargetState = {oldTargetState}" 596 | ) 597 | if trigger.stateHomeAway == "Home": 598 | # State is changing to Home. 599 | if ( 600 | oldTargetState in ["just left", "none"] 601 | or ha_just_started 602 | or (pli.configuration[CONF_MINUTES_JUST_ARRIVED] == 0) 603 | ): 604 | # Initial setting at startup goes straight to Home. 605 | # Just Left also goes straight back to Home. 606 | # Anything else goes straight to Home if Just Arrived is not an option. 607 | newTargetState = "Home" 608 | 609 | target._attr_extra_state_attributes[ATTR_BREAD_CRUMBS] = ( 610 | newTargetState 611 | ) 612 | target._attr_extra_state_attributes[ 613 | ATTR_COMPASS_BEARING 614 | ] = 0 615 | target._attr_extra_state_attributes[ATTR_DIRECTION] = "home" 616 | 617 | elif oldTargetState == "home": 618 | newTargetState = "Home" 619 | elif oldTargetState == "just arrived": 620 | newTargetState = "Just Arrived" 621 | else: 622 | newTargetState = "Just Arrived" 623 | change_state_later( 624 | target.entity_id, 625 | newTargetState, 626 | "Home", 627 | pli.configuration[CONF_MINUTES_JUST_ARRIVED], 628 | ) 629 | else: 630 | # State is changing to not Home. 631 | if oldTargetState != "away" and ( 632 | oldTargetState == "none" 633 | or ha_just_started 634 | or (pli.configuration[CONF_MINUTES_JUST_LEFT] == 0) 635 | ): 636 | # Initial setting at startup goes straight to Away 637 | newTargetState = "Away" 638 | if pli.configuration[CONF_HOURS_EXTENDED_AWAY] != 0: 639 | change_state_later( 640 | target.entity_id, 641 | "Away", 642 | "Extended Away", 643 | (pli.configuration[CONF_HOURS_EXTENDED_AWAY] * 60), 644 | ) 645 | elif oldTargetState in ["just left", "just arrived"]: 646 | newTargetState = "Just Left" 647 | elif oldTargetState == "extended away": 648 | newTargetState = "Extended Away" 649 | elif oldTargetState == "home": 650 | newTargetState = "Just Left" 651 | change_state_later( 652 | target.entity_id, 653 | newTargetState, 654 | "Away", 655 | pli.configuration[CONF_MINUTES_JUST_LEFT], 656 | ) 657 | else: 658 | # The oldTargetState is either "away" or a Zone 659 | newTargetState = "Away" 660 | if ( 661 | newTargetState == "Away" 662 | and pli.configuration[CONF_SHOW_ZONE_WHEN_AWAY] 663 | ): 664 | # Get the state from the zone friendly_name: 665 | if zoneStateObject is None or reportedZone.startswith( 666 | IC3_STATIONARY_ZONE_PREFIX 667 | ): 668 | # Skip stray zone names: 669 | pass 670 | else: 671 | zoneAttributesObject = zoneStateObject.attributes.copy() 672 | if "friendly_name" in zoneAttributesObject: 673 | newTargetState = zoneAttributesObject["friendly_name"] 674 | 675 | target._attr_native_value = newTargetState 676 | 677 | _LOGGER.debug( 678 | f"Presence detection not-so-binary: newTargetState = {newTargetState}" 679 | ) 680 | 681 | if ha_just_started: 682 | target._attr_extra_state_attributes[ATTR_BREAD_CRUMBS] = ( 683 | newTargetState 684 | ) 685 | 686 | target.set_state() 687 | 688 | # Call service to "reverse geocode" the location. 689 | # For devices at Home, this will be forced to run 690 | # just at startup or on arrival. 691 | 692 | force_update = (newTargetState in ["Home", "Just Arrived"]) and ( 693 | oldTargetState in ["away", "extended away", "just left"] 694 | ) 695 | if pli.attributes["startup"]: 696 | force_update = True 697 | 698 | service_data = { 699 | "entity_id": target.entity_id, 700 | "friendly_name_template": pli.configuration.get( 701 | CONF_FRIENDLY_NAME_TEMPLATE, 702 | DEFAULT_FRIENDLY_NAME_TEMPLATE, 703 | ), 704 | "force_update": force_update, 705 | } 706 | pli.hass.services.call( 707 | DOMAIN, "reverse_geocode", service_data, False 708 | ) 709 | 710 | _LOGGER.debug( 711 | "(%s) TARGET_LOCK release...", 712 | trigger.entity_id, 713 | ) 714 | _LOGGER.debug( 715 | "(%s) === Return ===", 716 | trigger.entity_id, 717 | ) 718 | return True 719 | 720 | pli.hass.services.async_register(DOMAIN, "process_trigger", handle_process_trigger) 721 | return True 722 | --------------------------------------------------------------------------------