diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..d50766a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,36 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**System Log Messages**
+```text
+Past relevant log messages here.
+```
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Versions**
+ - Home Assistant Core: [e.g., 2025.1.0]
+ - Composite: [e.g., 1.0.0]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..df8dd4d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: GitHub Community Support
+ url: https://community.home-assistant.io/t/composite-device-tracker-platform/67345
+ about: Please ask and answer questions here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..11fc491
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
new file mode 100644
index 0000000..e8a997d
--- /dev/null
+++ b/.github/workflows/validate.yml
@@ -0,0 +1,26 @@
+name: Validate
+
+on:
+ pull_request:
+ push:
+
+jobs:
+ validate-hassfest:
+ runs-on: ubuntu-latest
+ name: With hassfest
+ steps:
+ - name: 📥 Checkout the repository
+ uses: actions/checkout@v4
+
+ - name: 🏃 Hassfest validation
+ uses: "home-assistant/actions/hassfest@master"
+
+ validate-hacs:
+ runs-on: ubuntu-latest
+ name: With HACS Action
+ steps:
+ - name: 🏃 HACS validation
+ uses: hacs/action@main
+ with:
+ category: integration
+ ignore: brands
diff --git a/.gitignore b/.gitignore
index 3b242c7..eb2c767 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ __pycache__/
*.py[cod]
*$py.class
+.vscode/
diff --git a/README.md b/README.md
index cab4cae..cba3ba4 100644
--- a/README.md
+++ b/README.md
@@ -1,118 +1,181 @@
-# Composite Device Tracker
+#
Composite Device Tracker
-This platform creates a composite device tracker from one or more other device trackers and/or binary sensors. It will update whenever one of the watched entities updates, taking the last_seen/last_updated (and possibly GPS and battery) data from the changing entity. The result can be a more accurate and up-to-date device tracker if the "input" device tracker's update irregularly.
+This integration creates a composite `device_tracker` entity from one or more other entities. It will update whenever one of the watched entities updates, taking the "last seen" (and possibly GPS and other) data from the changing entity. The result can be a more accurate and up-to-date device tracker if the "input" entities update irregularly.
-Currently device_tracker's with a source_type of bluetooth, bluetooth_le, gps or router are supported, as well as binary_sensor's.
+It will also create a `sensor` entity that indicates the speed of the device.
+
+Currently any entity that has "GPS" attributes (`gps_accuracy` or `acc`, and either `latitude` & `longitude` or `lat` & `lon`), or any `device_tracker` entity with a `source_type` attribute of `bluetooth`, `bluetooth_le`, `gps` or `router`, or any `binary_sensor` entity, can be used as an input entity.
## Installation
-Follow either the HACS or manual installation instructions below.
-Then add the desired configuration. Here is an example of a typical configuration:
+
+With HACS
-```yaml
-device_tracker:
- - platform: composite
- name: me
- time_as: device_or_local
- entity_id:
- - device_tracker.platform1_me
- - device_tracker.platform2_me
-```
+[](https://hacs.xyz/)
+
+You can use HACS to manage the installation and provide update notifications.
-### HACS
+1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/).
+ It should then appear as a new integration. Click on it. If necessary, search for "composite".
-See [HACS](https://github.com/custom-components/hacs).
+ ```text
+ https://github.com/pnbruckner/ha-composite-tracker
+ ```
+ Or use this button:
+
+ [](https://my.home-assistant.io/redirect/hacs_repository/?owner=pnbruckner&repository=ha-composite-tracker&category=integration)
-### Manual
-Alternatively, place a copy of:
+1. Download the integration using the appropriate button.
-[`__init__.py`](custom_components/composite/__init__.py) at `/custom_components/composite/__init__.py`
-[`device_tracker.py`](custom_components/composite/device_tracker.py) at `/custom_components/composite/device_tracker.py`
-[`manifest.json`](custom_components/composite/manifest.json) at `/custom_components/composite/manifest.json`
+
+
+Manual
+
+Place a copy of the files from [`custom_components/composite`](custom_components/composite)
+in `/custom_components/composite`,
where `` is your Home Assistant configuration directory.
->__NOTE__: Do not download the file by using the link above directly. Rather, click on it, then on the page that comes up use the `Raw` button.
+>__NOTE__: When downloading, make sure to use the `Raw` button from each file's page.
-### numpy on Raspberry Pi
+
-To determine time zone from GPS coordinates (see `time_as` configuration variable below) the package [timezonefinderL](https://pypi.org/project/timezonefinderL/) is used. That package requires the package [numpy](https://pypi.org/project/numpy/). These will both be installed automatically by HA. Note, however, that numpy on Pi _usually_ requires libatlas to be installed. (See [this web page](https://www.raspberrypi.org/forums/viewtopic.php?t=207058) for more details.) It can be installed using this command:
-```
-sudo apt install libatlas3-base
+After it has been downloaded you will need to restart Home Assistant.
+
+### Versions
+
+This custom integration supports HomeAssistant versions 2024.11.0 or newer.
+
+## Configuration
+
+Composite entities can be created via the UI on the Integrations page or by YAML entries.
+
+To create a Composite entity via the UI you can use this My Button:
+
+[](https://my.home-assistant.io/redirect/config_flow_start?domain=composite)
+
+Alternatively, go to Settings -> Devices & services and click the **`+ ADD INTEGRATION`** button.
+Find or search for "Composite", click on it, then follow the prompts.
+
+The remainder of this section describes YAML configuration.
+Here is an example YAML configuration:
+
+```yaml
+composite:
+ trackers:
+ - name: Me
+ entity_id:
+ - entity: device_tracker.platform1_me
+ use_picture: true
+ - device_tracker.platform2_me
+ - binary_sensor.i_am_home
```
->Note: This is the same step that would be required if using a standard HA component that uses numpy (such as the [Trend Binary Sensor](https://www.home-assistant.io/components/binary_sensor.trend/)), and is only required if you use `device_or_utc` or `device_or_local` for `time_as`.
-## Configuration variables
+- **default_options** (*Optional*): Defines default values for corresponding options under **trackers**.
+ - **require_movement** (*Optional*): Default is `false`.
+ - **show_unknown_as_0** (*Optional*)): Default is `false`.
+ - **max_speed_age** (*Optional*)
+ - **driving_speed** (*Optional*)
+ - **end_driving_delay** (*Optoinal*)
+
+- **trackers**: The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries).
+
+### Tracker entries
+
+- **entity_id**: Specifies the watched entities. Can be an entity ID, a dictionary (see [Entity Dictionary](#entity-dictionary)), or a list containing any combination of these.
+- **name**: Friendly name of composite device.
+- **id** (*Optional*): Object ID (i.e., part of entity ID after the dot) of composite device. If not supplied, then object ID will be generated from the `name` variable. For example, `My Name` would result in a tracker entity ID of `device_tracker.my_name`. The speed sensor's object ID will be the same as for the device tracker, but with a suffix of "`_speed`" added (e.g., `sensor.my_name_speed`.)
+- **require_movement** (*Optional*): `true` or `false`. If `true`, will skip update from a GPS-based tracker if it has not moved. Specifically, if circle defined by new GPS coordinates and accuracy overlaps circle defined by previous GPS coordinates and accuracy then update will be ignored.
+- **show_unknown_as_0** (*Optional*): `true` or `false`. If `true`, when the speed sensor's state would normally be `unknown` it will be set to `0.0` instead. This can help, e.g., when using the speed sensor as input to the [Statistics](https://www.home-assistant.io/integrations/statistics/) integration.
+- **max_speed_age** (*Optional*): If set, defines the maximum amount of time between speed sensor updates. When this time is exceeded, speed sensor's state will be cleared (i.e., state will become `unknown` and `angle` & `direction` attributes will become null.)
+- **driving_speed** (*Optional*): Defines a driving speed threshold (in MPH or KPH, depending on general unit system setting.) If set, and current speed is at or above this value, and tracker is not in a zone, then the state of the tracker will be set to `driving`.
+- **end_driving_delay** (*Optional*): If set, defines the amount of time to wait before changing state from `driving` (i.e., Driving) back to `not_home` (i.e., Away) after speed falls below set `driving_speed`. This can prevent state changing back and forth when, e.g., slowing for a turn or stopping at a traffic light. If not set, state will change back to `not_home` immediately after speed drops below threshold. May only be used if `driving_speed` is set.
+- **entity_picture** (*Optional*): Specifies image to use for entity. Can be an URL or a file in "/local". Note that /local is used by the frontend to access files in `/www` (which is typically `/config/www`.) You can specify file names with or without the "/local" prefix. If this option is used, then `use_picture` cannot be used.
-- **entity_id**: Entity IDs of watched device tracker devices. Can be a single entity ID, a list of entity IDs, or a string containing multiple entity IDs separated by commas.
-- **name**: Object ID (i.e., part of entity ID after the dot) of composite device. For example, `NAME` would result in an entity ID of `device_tracker.NAME`.
-- **require_movement** (*Optional*): `true` or `false`. Default is `false`. If `true`, will skip update from a GPS-based tracker if it has not moved. Specifically, if circle defined by new GPS coordinates and accuracy overlaps circle defined by previous GPS coordinates and accuracy then update will be ignored.
-- **time_as** (*Optional*): One of `utc`, `local`, `device_or_utc` or `device_or_local`. Default is `utc` which shows time attributes in UTC. `local` shows time attributes per HA's `time_zone` configuration. `device_or_utc` and `device_or_local` attempt to determine the time zone in which the device is located based on its GPS coordinates. The name of the time zone (or `unknown`) will be shown in a new attribute named `time_zone`. If the time zone can be determined, then time attributes will be shown in that time zone. If the time zone cannot be determined, then time attributes will be shown in UTC if `device_or_utc` is selected, or in HA's local time zone if `device_or_local` is selected.
+#### Entity Dictionary
+
+- **entity**: Entity ID of an entity to watch.
+- **all_states** (*Optional*): `true` or `false`. Default is `false`. If `true`, use all states of the entity. If `false`, only use the "Home" state. NOTE: This option is ignored for entities whose `source_type` is `gps` for which all states are always used.
+- **use_picture** (*Optional*): `true` or `false`. Default is `false`. If `true`, use the entity's picture for the composite. Can only be `true` for at most one of the entities. If `entity_picture` is used, then this option cannot be used.
## Watched device notes
+### Used states
+
+For watched non-GPS-based devices, which states are used and whether any GPS data (if present) is used depends on several factors. E.g., if GPS-based devices are in use then the 'not_home'/'off' state of non-GPS-based devices will be ignored (unless `all_states` was specified as `true` for that entity.) If only non-GPS-based devices are in use, then the composite device will be 'home' if any of the watched devices are 'home'/'on', and will be 'not_home' only when _all_ the watched devices are 'not_home'/'off'.
-Watched GPS-based devices must have, at a minimum, the following attributes: `latitude`, `longitude` and `gps_accuracy`. If they don't they will not be used.
+### Last seen
-For watched non-GPS-based devices, which states are used and whether any GPS data (if present) is used depends on several factors. E.g., if GPS-based devices are in use then the 'not_home'/'off' state of non-GPS-based devices will be ignored. If only non-GPS-based devices are in use, then the composite device will be 'home' if any of the watched devices are 'home'/'on', and will be 'not_home' only when _all_ the watched devices are 'not_home'/'off'.
+If a watched device has a "last seen" attribute (i.e. `last_seen` or `last_timestamp`), that will be used in the composite device. If not, then `last_updated` from the entity's [state object](https://www.home-assistant.io/docs/configuration/state_object/) will be used instead.
-If a watched device has a `last_seen` attribute, that will be used in the composite device. If not, then `last_updated` from the entity's state will be used instead.
+The "last seen" attribute can be in any one of these formats:
+
+Python type | description
+-|-
+aware `datetime` | In any time zone
+naive `datetime` | Assumed to be in the system's time zone (Settings -> System -> General)
+`float`, `int`, `str` | A POSIX timestamp (anything accepted by `homeassistant.util.dt.utc_from_timestamp(float(x))`
+`str` | A date & time, aware or naive (anything accepted by `homeassistant.util.dt.parse_datetime`)
-If a watched device has a `battery` or `battery_level` attribute, that will be used to update the composite device's `battery` attribute. If it has a `battery_charging` or `charging` attribute, that will be used to udpate the composite device's `battery_charging` attribute.
+* See [Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects)
-## known_devices.yaml
+Integrations known to provide a supported "last seen" attribute:
-The watched devices, and the composite device, should all have `track` set to `true`.
+- Google Maps (`last_seen`, [built-in](https://www.home-assistant.io/integrations/google_maps/) or [enhanced custom](https://github.com/pnbruckner/ha-google-maps))
+- [Enhanced GPSLogger](https://github.com/pnbruckner/ha-gpslogger) (`last_seen`)
+- [iCould3](https://github.com/gcobb321/icloud3) (`last_timestamp`)
-It's recommended, as well, to set `hide_if_away` to `true` for the watched devices (but leave it set to `false` for the composite device.) This way the map will only show the composite device (of course when it is out of the home zone.) **NOTE:** The downside to hiding the watched devices, though, is that their history (other than when they're home) will not get recorded and hence will not be available in history views. (In history views they will appear to always be home.) Also they are hidden *everywhere* in the UI when not home (not just the map.)
+### Miscellaneous
-Lastly, it is also recommended to _not_ use the native merge feature of the device tracker component (i.e., do not add the MAC address from network-based trackers to a GPS-based tracker. See more details in the [Device Tracker doc page](https://www.home-assistant.io/components/device_tracker/#using-gps-device-trackers-with-local-network-device-trackers).)
+If a watched device has a `battery_level` or `battery` attribute, that will be used to update the composite device's `battery_level` attribute. If it has a `battery_charging` or `charging` attribute, that will be used to udpate the composite device's `battery_charging` attribute.
-## Attributes
+## `device_tracker` Attributes
Attribute | Description
-|-
-battery | Battery level (in percent, if available.)
+battery_level | Battery level (in percent, if available.)
battery_charging | Battery charging status (True/False, if available.)
-entity_id | IDs of entities that have contributed to the state of the composite device.
+entities | IDs of entities that have contributed to the state of the composite device.
+entity_picture | Picture to use for composite (if configured and available.)
gps_accuracy | GPS accuracy radius (in meters, if available.)
last_entity_id | ID of the last entity to update the composite device.
last_seen | Date and time when current location information was last updated.
latitude | Latitude of current location (if available.)
longitude | Longitude of current location (if available.)
source_type | Source of current location information: `binary_sensor`, `bluetooth`, `bluetooth_le`, `gps` or `router`.
-time_zone | The name of the time zone in which the device is located, or `unknown` if it cannot be determined. Only exists if `device_or_utc` or `device_or_local` is chosen for `time_as`.
-## Examples
-### Time zone examples
+## Speed `sensor` Attributes
-This example assumes `time_as` is set to `device_or_utc` or `device_or_local`. It determines the difference between the time zone in which the device is located and the `time_zone` in HA's configuration. A positive value means the device's time zone is ahead of (or later than, or east of) the local time zone.
-```yaml
-sensor:
- - platform: template
- sensors:
- my_tz_offset:
- friendly_name: My time zone offset
- unit_of_measurement: hr
- value_template: >
- {% set state = states.device_tracker.me %}
- {% if state.attributes is defined and
- state.attributes.time_zone is defined and
- state.attributes.time_zone != 'unknown' %}
- {% set n = now() %}
- {{ (n.astimezone(state.attributes.last_seen.tzinfo).utcoffset() -
- n.utcoffset()).total_seconds()/3600 }}
- {% else %}
- unknown
- {% endif %}
-```
-This example converts a time attribute to the local time zone. It works no matter which time zone the attribute is in.
+Attribute | Description
+-|-
+angle | Angle of movement direction (in degrees, if moving.)
+direction | Compass heading of movement direction (if moving.)
+
+## Examples
+### Example Full Config
```yaml
-sensor:
- - platform: template
- sensors:
- my_last_seen_local:
- friendly_name: My last_seen time in local time zone
- value_template: >
- {{ state_attr('device_tracker.me', last_seen').astimezone(now().tzinfo) }}
+composite:
+ default_options:
+ require_movement: true
+ show_unknown_as_0: true
+ max_speed_age:
+ minutes: 5
+ driving_speed: 15
+ end_driving_delay: "00:02:00"
+ trackers:
+ - name: Me
+ driving_speed: 20
+ entity_id:
+ - entity: device_tracker.platform1_me
+ use_picture: true
+ - device_tracker.platform2_me
+ - device_tracker.router_my_device
+ - entity: binary_sensor.i_am_home
+ all_states: true
+ - name: Better Half
+ id: wife
+ require_movement: false
+ end_driving_delay: 30
+ entity_picture: /local/wife.jpg
+ entity_id: device_tracker.platform_wife
```
diff --git a/custom_components/__init__.py b/custom_components/__init__.py
new file mode 100644
index 0000000..118b2de
--- /dev/null
+++ b/custom_components/__init__.py
@@ -0,0 +1,2 @@
+"""Composite Device Tracker."""
+# Exists to satisfy mypy.
diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py
index d7b4788..7c8940a 100644
--- a/custom_components/composite/__init__.py
+++ b/custom_components/composite/__init__.py
@@ -1 +1,80 @@
"""Composite Device Tracker."""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Coroutine
+import logging
+from typing import Any, cast
+
+from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_ID, CONF_NAME, SERVICE_RELOAD, Platform
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers.reload import async_integration_yaml_config
+from homeassistant.helpers.service import async_register_admin_service
+from homeassistant.helpers.typing import ConfigType
+
+from .const import CONF_TRACKERS, DOMAIN
+
+PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up composite integration."""
+
+ async def process_config(config: ConfigType | None) -> None:
+ """Process Composite config."""
+ tracker_configs = cast(
+ list[dict[str, Any]], (config or {}).get(DOMAIN, {}).get(CONF_TRACKERS, [])
+ )
+ tracker_ids = [conf[CONF_ID] for conf in tracker_configs]
+
+ for conf in tracker_configs:
+ # New config entries and changed existing ones can be processed later and do
+ # not need to delay startup.
+ hass.async_create_background_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
+ ),
+ "Import YAML config",
+ )
+
+ tasks: list[Coroutine[Any, Any, Any]] = []
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ if (
+ entry.source != SOURCE_IMPORT
+ or (obj_id := entry.data[CONF_ID]) in tracker_ids
+ ):
+ continue
+ _LOGGER.debug(
+ "Removing %s (%s) because it is no longer in YAML configuration",
+ entry.data[CONF_NAME],
+ f"{DT_DOMAIN}.{obj_id}",
+ )
+ # Removing config entries needs to happen before entries get a chance to be
+ # set up.
+ tasks.append(hass.config_entries.async_remove(entry.entry_id))
+ if tasks:
+ await asyncio.gather(*tasks)
+
+ async def reload_config(_: ServiceCall) -> None:
+ """Reload configuration."""
+ await process_config(await async_integration_yaml_config(hass, DOMAIN))
+
+ await process_config(config)
+ async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config)
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up config entry."""
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py
new file mode 100644
index 0000000..3999999
--- /dev/null
+++ b/custom_components/composite/config.py
@@ -0,0 +1,250 @@
+"""Composite config validation."""
+from __future__ import annotations
+
+from contextlib import suppress
+from datetime import timedelta
+import logging
+from pathlib import Path
+from typing import Any, cast
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ENTITY_ID, CONF_ID, CONF_NAME
+from homeassistant.core import HomeAssistant, async_get_hass
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import slugify
+
+from .const import (
+ CONF_ALL_STATES,
+ CONF_DEFAULT_OPTIONS,
+ CONF_DRIVING_SPEED,
+ CONF_END_DRIVING_DELAY,
+ CONF_ENTITY,
+ CONF_ENTITY_PICTURE,
+ CONF_MAX_SPEED_AGE,
+ CONF_REQ_MOVEMENT,
+ CONF_SHOW_UNKNOWN_AS_0,
+ CONF_TIME_AS,
+ CONF_TRACKERS,
+ CONF_USE_PICTURE,
+ DEF_REQ_MOVEMENT,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+PACKAGE_MERGE_HINT = "dict"
+
+CONF_TZ_FINDER = "tz_finder"
+CONF_TZ_FINDER_CLASS = "tz_finder_class"
+
+
+def _entities(entities: list[str | dict]) -> list[dict]:
+ """Convert entity ID to dict of entity, all_states & use_picture.
+
+ Also ensure no more than one entity has use_picture set to true.
+ """
+ result: list[dict] = []
+ already_using_picture = False
+ for idx, entity in enumerate(entities):
+ if isinstance(entity, dict):
+ if entity[CONF_USE_PICTURE]:
+ if already_using_picture:
+ raise vol.Invalid(
+ f"{CONF_USE_PICTURE} may only be true for one entity per "
+ "composite tracker",
+ path=[idx, CONF_USE_PICTURE],
+ )
+ already_using_picture = True
+ result.append(entity)
+ else:
+ result.append(
+ {CONF_ENTITY: entity, CONF_ALL_STATES: False, CONF_USE_PICTURE: False}
+ )
+ return result
+
+
+def _time_period_to_dict(delay: timedelta) -> dict[str, float]:
+ """Return timedelta as a dict."""
+ result: dict[str, float] = {}
+ if delay.days:
+ result["days"] = delay.days
+ result["hours"] = delay.seconds // 3600
+ result["minutes"] = (delay.seconds // 60) % 60
+ result["seconds"] = delay.seconds % 60
+ return result
+
+
+def _entity_picture(entity_picture: str) -> str:
+ """Validate entity picture.
+
+ Must be run in an executor since it might do file I/O.
+
+ Can be an URL or a file in "/local".
+
+ file can be "/local/file" or just "file"
+
+ Returns URL or "/local/file"
+ """
+ with suppress(vol.Invalid):
+ return cv.url(entity_picture)
+
+ local_dir = Path("/local")
+ local_file = Path(entity_picture)
+ with suppress(ValueError):
+ local_file = local_file.relative_to(local_dir)
+ if not (Path(async_get_hass().config.path("www")) / local_file).is_file():
+ raise vol.Invalid(f"{entity_picture} does not exist")
+ return str(local_dir / local_file)
+
+
+def _trackers(trackers: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Validate tracker entries.
+
+ Determine tracker IDs and ensure they are unique.
+ Also for each tracker, check that no entity has use_picture set if an entity_picture
+ file is specified for tracker.
+ """
+ ids: list[str] = []
+ for t_idx, tracker in enumerate(trackers):
+ if CONF_ID not in tracker:
+ name: str = tracker[CONF_NAME]
+ if name == slugify(name):
+ tracker[CONF_ID] = name
+ tracker[CONF_NAME] = name.replace("_", " ").title()
+ else:
+ tracker[CONF_ID] = cv.slugify(tracker[CONF_NAME])
+ ids.append(cast(str, tracker[CONF_ID]))
+ if tracker.get(CONF_ENTITY_PICTURE):
+ for e_idx, entity in enumerate(tracker[CONF_ENTITY_ID]):
+ if entity[CONF_USE_PICTURE]:
+ raise vol.Invalid(
+ f"{CONF_ENTITY_PICTURE} specified; "
+ f"cannot use {CONF_USE_PICTURE}",
+ path=[t_idx, CONF_ENTITY_ID, e_idx, CONF_USE_PICTURE],
+ )
+ if len(ids) != len(set(ids)):
+ raise vol.Invalid("id's must be unique")
+ return trackers
+
+
+def _defaults(config: dict) -> dict:
+ """Apply default options to trackers.
+
+ Also warn about options no longer supported.
+ """
+ unsupported_cfgs = set()
+ if config.pop(CONF_TZ_FINDER, None):
+ unsupported_cfgs.add(CONF_TZ_FINDER)
+ if config.pop(CONF_TZ_FINDER_CLASS, None):
+ unsupported_cfgs.add(CONF_TZ_FINDER_CLASS)
+ if config[CONF_DEFAULT_OPTIONS].pop(CONF_TIME_AS, None):
+ unsupported_cfgs.add(CONF_TIME_AS)
+
+ def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT]
+ def_shu_az = config[CONF_DEFAULT_OPTIONS].get(CONF_SHOW_UNKNOWN_AS_0)
+ def_max_sa = config[CONF_DEFAULT_OPTIONS].get(CONF_MAX_SPEED_AGE)
+ def_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED)
+ def_end_dd = config[CONF_DEFAULT_OPTIONS].get(CONF_END_DRIVING_DELAY)
+ end_dd_but_no_drv_sp = False
+ for tracker in config[CONF_TRACKERS]:
+ if tracker.pop(CONF_TIME_AS, None):
+ unsupported_cfgs.add(CONF_TIME_AS)
+ tracker[CONF_REQ_MOVEMENT] = tracker.get(CONF_REQ_MOVEMENT, def_req_mv)
+ if CONF_SHOW_UNKNOWN_AS_0 not in tracker and def_shu_az is not None:
+ tracker[CONF_SHOW_UNKNOWN_AS_0] = def_shu_az
+ if CONF_MAX_SPEED_AGE not in tracker and def_max_sa is not None:
+ tracker[CONF_MAX_SPEED_AGE] = def_max_sa
+ if CONF_DRIVING_SPEED not in tracker and def_drv_sp is not None:
+ tracker[CONF_DRIVING_SPEED] = def_drv_sp
+ if CONF_END_DRIVING_DELAY not in tracker and def_end_dd is not None:
+ tracker[CONF_END_DRIVING_DELAY] = def_end_dd
+ if CONF_END_DRIVING_DELAY in tracker and CONF_DRIVING_SPEED not in tracker:
+ end_dd_but_no_drv_sp = True
+
+ if unsupported_cfgs:
+ _LOGGER.warning(
+ "Your %s configuration contains options that are no longer supported: %s; "
+ "Please remove them",
+ DOMAIN,
+ ", ".join(sorted(unsupported_cfgs)),
+ )
+ if end_dd_but_no_drv_sp:
+ raise vol.Invalid(
+ f"using {CONF_END_DRIVING_DELAY}; "
+ f"{CONF_DRIVING_SPEED} must also be specified"
+ )
+
+ del config[CONF_DEFAULT_OPTIONS]
+ return config
+
+
+_ENTITIES = vol.All(
+ cv.ensure_list,
+ [
+ vol.Any(
+ cv.entity_id,
+ vol.Schema(
+ {
+ vol.Required(CONF_ENTITY): cv.entity_id,
+ vol.Optional(CONF_ALL_STATES, default=False): cv.boolean,
+ vol.Optional(CONF_USE_PICTURE, default=False): cv.boolean,
+ }
+ ),
+ )
+ ],
+ vol.Length(1),
+ _entities,
+)
+_POS_TIME_PERIOD = vol.All(cv.positive_time_period, _time_period_to_dict)
+_TRACKER = {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_ID): cv.slugify,
+ vol.Required(CONF_ENTITY_ID): _ENTITIES,
+ vol.Optional(CONF_TIME_AS): cv.string,
+ vol.Optional(CONF_REQ_MOVEMENT): cv.boolean,
+ vol.Optional(CONF_SHOW_UNKNOWN_AS_0): cv.boolean,
+ vol.Optional(CONF_MAX_SPEED_AGE): _POS_TIME_PERIOD,
+ vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
+ vol.Optional(CONF_END_DRIVING_DELAY): _POS_TIME_PERIOD,
+ vol.Optional(CONF_ENTITY_PICTURE): vol.All(cv.string, _entity_picture),
+}
+_CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Optional(DOMAIN): vol.All(
+ vol.Schema(
+ {
+ vol.Optional(CONF_TZ_FINDER): cv.string,
+ vol.Optional(CONF_TZ_FINDER_CLASS): cv.string,
+ vol.Optional(CONF_DEFAULT_OPTIONS, default=dict): vol.Schema(
+ {
+ vol.Optional(CONF_TIME_AS): cv.string,
+ vol.Optional(
+ CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT
+ ): cv.boolean,
+ vol.Optional(CONF_SHOW_UNKNOWN_AS_0): cv.boolean,
+ vol.Optional(CONF_MAX_SPEED_AGE): _POS_TIME_PERIOD,
+ vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
+ vol.Optional(CONF_END_DRIVING_DELAY): _POS_TIME_PERIOD,
+ }
+ ),
+ vol.Required(CONF_TRACKERS, default=list): vol.All(
+ cv.ensure_list, vol.Length(1), [_TRACKER], _trackers
+ ),
+ }
+ ),
+ _defaults,
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+async def async_validate_config(
+ hass: HomeAssistant, config: ConfigType
+) -> ConfigType | None:
+ """Validate configuration."""
+ # Perform _CONFIG_SCHEMA validation in executor since it may indirectly invoke
+ # _entity_picture which must be run in an executor because it might do file I/O.
+ return cast(ConfigType, await hass.async_add_executor_job(_CONFIG_SCHEMA, config))
diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py
new file mode 100644
index 0000000..e05d9e5
--- /dev/null
+++ b/custom_components/composite/config_flow.py
@@ -0,0 +1,618 @@
+"""Config flow for Composite integration."""
+from __future__ import annotations
+
+from abc import abstractmethod
+from functools import cached_property # pylint: disable=hass-deprecated-import
+import logging
+from pathlib import Path
+import shutil
+from typing import Any, cast
+
+import filetype
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN
+from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN
+from homeassistant.components.file_upload import process_uploaded_file
+from homeassistant.config_entries import (
+ SOURCE_IMPORT,
+ ConfigEntry,
+ ConfigEntryBaseFlow,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlowWithConfigEntry,
+)
+from homeassistant.const import (
+ ATTR_GPS_ACCURACY,
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ CONF_ENTITY_ID,
+ CONF_ID,
+ CONF_NAME,
+ UnitOfSpeed,
+)
+from homeassistant.core import State, callback
+from homeassistant.helpers.selector import (
+ BooleanSelector,
+ DurationSelector,
+ DurationSelectorConfig,
+ EntitySelector,
+ EntitySelectorConfig,
+ FileSelector,
+ FileSelectorConfig,
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TextSelector,
+)
+from homeassistant.util.unit_conversion import SpeedConverter
+from homeassistant.util.unit_system import METRIC_SYSTEM
+
+from .const import (
+ ATTR_ACC,
+ ATTR_LAT,
+ ATTR_LON,
+ CONF_ALL_STATES,
+ CONF_DRIVING_SPEED,
+ CONF_END_DRIVING_DELAY,
+ CONF_ENTITY,
+ CONF_ENTITY_PICTURE,
+ CONF_MAX_SPEED_AGE,
+ CONF_REQ_MOVEMENT,
+ CONF_SHOW_UNKNOWN_AS_0,
+ CONF_USE_PICTURE,
+ DOMAIN,
+ MIME_TO_SUFFIX,
+ PICTURE_SUFFIXES,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ """Return pieces of configuration data."""
+ return {
+ kw: {k: v for k, v in conf.items() if k in ks}
+ for kw, ks in (
+ ("data", (CONF_NAME, CONF_ID)),
+ (
+ "options",
+ (
+ CONF_ENTITY_ID,
+ CONF_REQ_MOVEMENT,
+ CONF_MAX_SPEED_AGE,
+ CONF_SHOW_UNKNOWN_AS_0,
+ CONF_DRIVING_SPEED,
+ CONF_END_DRIVING_DELAY,
+ CONF_ENTITY_PICTURE,
+ ),
+ ),
+ )
+ }
+
+
+class CompositeFlow(ConfigEntryBaseFlow):
+ """Composite flow mixin."""
+
+ @cached_property
+ def _entries(self) -> list[ConfigEntry]:
+ """Get existing config entries."""
+ return self.hass.config_entries.async_entries(DOMAIN)
+
+ @cached_property
+ def _local_dir(self) -> Path:
+ """Return real path to "/local" directory."""
+ return Path(self.hass.config.path("www"))
+
+ @cached_property
+ def _uploaded_dir(self) -> Path:
+ """Return real path to "/local/uploaded" directory."""
+ return self._local_dir / "uploaded"
+
+ def _local_files(self) -> list[str]:
+ """Return a list of files in "/local" and subdirectories.
+
+ Must be called in an executor since it does file I/O.
+ """
+ if not (local_dir := self._local_dir).is_dir():
+ _LOGGER.debug("/local directory (%s) does not exist", local_dir)
+ return []
+
+ local_files: list[str] = []
+ for suffix in PICTURE_SUFFIXES:
+ local_files.extend(
+ [
+ str(local_file.relative_to(local_dir))
+ for local_file in local_dir.rglob(f"*.{suffix}")
+ ]
+ )
+ return sorted(local_files)
+
+ @cached_property
+ def _speed_uom(self) -> str:
+ """Return speed unit_of_measurement."""
+ if self.hass.config.units is METRIC_SYSTEM:
+ return UnitOfSpeed.KILOMETERS_PER_HOUR
+ return UnitOfSpeed.MILES_PER_HOUR
+
+ @property
+ @abstractmethod
+ def options(self) -> dict[str, Any]:
+ """Return mutable copy of options."""
+
+ @property
+ def _entity_ids(self) -> list[str]:
+ """Get currently configured entity IDs."""
+ return [cfg[CONF_ENTITY] for cfg in self.options.get(CONF_ENTITY_ID, [])]
+
+ @property
+ def _cur_entity_picture(self) -> tuple[str | None, str | None]:
+ """Return current entity picture source.
+
+ Returns: (entity_id, local_file)
+
+ local_file is relative to "/local".
+ """
+ entity_id = None
+ for cfg in self.options[CONF_ENTITY_ID]:
+ if cfg[CONF_USE_PICTURE]:
+ entity_id = cfg[CONF_ENTITY]
+ break
+ if local_file := cast(str | None, self.options.get(CONF_ENTITY_PICTURE)):
+ local_file = local_file.removeprefix("/local/")
+ return entity_id, local_file
+
+ def _set_entity_picture(
+ self, *, entity_id: str | None = None, local_file: str | None = None
+ ) -> None:
+ """Set composite's entity picture source.
+
+ local_file is relative to "/local".
+ """
+ for cfg in self.options[CONF_ENTITY_ID]:
+ cfg[CONF_USE_PICTURE] = cfg[CONF_ENTITY] == entity_id
+ if local_file:
+ self.options[CONF_ENTITY_PICTURE] = f"/local/{local_file}"
+ elif CONF_ENTITY_PICTURE in self.options:
+ del self.options[CONF_ENTITY_PICTURE]
+
+ def _save_uploaded_file(self, uploaded_file_id: str) -> str:
+ """Save uploaded file.
+
+ Must be called in an executor since it does file I/O.
+
+ Returns name of file relative to "/local".
+ """
+ with process_uploaded_file(self.hass, uploaded_file_id) as uf_path:
+ ud = self._uploaded_dir
+ ud.mkdir(parents=True, exist_ok=True)
+ suffix = MIME_TO_SUFFIX[cast(str, filetype.guess_mime(uf_path))]
+ fn = ud / f"x.{suffix}"
+ idx = 0
+ while (uf := fn.with_stem(f"image{idx:03d}")).exists():
+ idx += 1
+ shutil.move(uf_path, uf)
+ return str(uf.relative_to(self._local_dir))
+
+ async def async_step_options(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Get config options."""
+ errors = {}
+
+ if user_input is not None:
+ self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT]
+ if user_input[CONF_SHOW_UNKNOWN_AS_0]:
+ self.options[CONF_SHOW_UNKNOWN_AS_0] = True
+ elif CONF_SHOW_UNKNOWN_AS_0 in self.options:
+ # For backward compatibility, represent False as the absence of the
+ # option.
+ del self.options[CONF_SHOW_UNKNOWN_AS_0]
+ if CONF_MAX_SPEED_AGE in user_input:
+ self.options[CONF_MAX_SPEED_AGE] = user_input[CONF_MAX_SPEED_AGE]
+ elif CONF_MAX_SPEED_AGE in self.options:
+ del self.options[CONF_MAX_SPEED_AGE]
+ if CONF_DRIVING_SPEED in user_input:
+ self.options[CONF_DRIVING_SPEED] = SpeedConverter.convert(
+ user_input[CONF_DRIVING_SPEED],
+ self._speed_uom,
+ UnitOfSpeed.METERS_PER_SECOND,
+ )
+ else:
+ if CONF_DRIVING_SPEED in self.options:
+ del self.options[CONF_DRIVING_SPEED]
+ if CONF_END_DRIVING_DELAY in self.options:
+ del self.options[CONF_END_DRIVING_DELAY]
+ prv_cfgs = {
+ cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, [])
+ }
+ new_cfgs = [
+ prv_cfgs.get(
+ entity_id,
+ {
+ CONF_ENTITY: entity_id,
+ CONF_USE_PICTURE: False,
+ CONF_ALL_STATES: False,
+ },
+ )
+ for entity_id in user_input[CONF_ENTITY_ID]
+ ]
+ self.options[CONF_ENTITY_ID] = new_cfgs
+ if new_cfgs:
+ if CONF_DRIVING_SPEED in self.options:
+ return await self.async_step_end_driving_delay()
+ return await self.async_step_ep_menu()
+ errors[CONF_ENTITY_ID] = "at_least_one_entity"
+
+ def entity_filter(state: State) -> bool:
+ """Return if entity should be included in input list."""
+ if state.domain in (BS_DOMAIN, DT_DOMAIN):
+ return True
+ attributes = state.attributes
+ if ATTR_GPS_ACCURACY not in attributes and ATTR_ACC not in attributes:
+ return False
+ if ATTR_LATITUDE in attributes and ATTR_LONGITUDE in attributes:
+ return True
+ return ATTR_LAT in attributes and ATTR_LON in attributes
+
+ include_entities = set(self._entity_ids)
+ include_entities |= {
+ state.entity_id
+ for state in filter(entity_filter, self.hass.states.async_all())
+ }
+ data_schema = vol.Schema(
+ {
+ vol.Required(CONF_ENTITY_ID): EntitySelector(
+ EntitySelectorConfig(
+ include_entities=list(include_entities), multiple=True
+ )
+ ),
+ vol.Required(CONF_REQ_MOVEMENT): BooleanSelector(),
+ vol.Required(CONF_SHOW_UNKNOWN_AS_0): BooleanSelector(),
+ vol.Optional(CONF_MAX_SPEED_AGE): DurationSelector(
+ DurationSelectorConfig(
+ enable_day=False, enable_millisecond=False, allow_negative=False
+ )
+ ),
+ vol.Optional(CONF_DRIVING_SPEED): NumberSelector(
+ NumberSelectorConfig(
+ unit_of_measurement=self._speed_uom,
+ mode=NumberSelectorMode.BOX,
+ )
+ ),
+ }
+ )
+ if CONF_ENTITY_ID in self.options:
+ suggested_values = {
+ CONF_ENTITY_ID: self._entity_ids,
+ CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT],
+ CONF_SHOW_UNKNOWN_AS_0: self.options.get(CONF_SHOW_UNKNOWN_AS_0, False),
+ }
+ if CONF_MAX_SPEED_AGE in self.options:
+ suggested_values[CONF_MAX_SPEED_AGE] = self.options[CONF_MAX_SPEED_AGE]
+ if CONF_DRIVING_SPEED in self.options:
+ suggested_values[CONF_DRIVING_SPEED] = SpeedConverter.convert(
+ self.options[CONF_DRIVING_SPEED],
+ UnitOfSpeed.METERS_PER_SECOND,
+ self._speed_uom,
+ )
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, suggested_values
+ )
+ return self.async_show_form(
+ step_id="options", data_schema=data_schema, errors=errors, last_step=False
+ )
+
+ async def async_step_end_driving_delay(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Get end driving delay."""
+ if user_input is not None:
+ if CONF_END_DRIVING_DELAY in user_input:
+ self.options[CONF_END_DRIVING_DELAY] = user_input[
+ CONF_END_DRIVING_DELAY
+ ]
+ elif CONF_END_DRIVING_DELAY in self.options:
+ del self.options[CONF_END_DRIVING_DELAY]
+ return await self.async_step_ep_menu()
+
+ data_schema = vol.Schema(
+ {
+ vol.Optional(CONF_END_DRIVING_DELAY): DurationSelector(
+ DurationSelectorConfig(
+ enable_day=False, enable_millisecond=False, allow_negative=False
+ )
+ ),
+ }
+ )
+ if CONF_END_DRIVING_DELAY in self.options:
+ suggested_values = {
+ CONF_END_DRIVING_DELAY: self.options[CONF_END_DRIVING_DELAY]
+ }
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, suggested_values
+ )
+ return self.async_show_form(
+ step_id="end_driving_delay", data_schema=data_schema, last_step=False
+ )
+
+ async def async_step_ep_menu(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Specify where to get composite's picture from."""
+ entity_id, local_file = self._cur_entity_picture
+ cur_source: Path | str | None
+ if local_file:
+ cur_source = self._local_dir / local_file
+ else:
+ cur_source = entity_id
+
+ menu_options = ["all_states", "ep_upload_file", "ep_input_entity"]
+ if await self.hass.async_add_executor_job(self._local_files):
+ menu_options.insert(1, "ep_local_file")
+ if cur_source:
+ menu_options.append("ep_none")
+
+ return self.async_show_menu(
+ step_id="ep_menu",
+ menu_options=menu_options,
+ description_placeholders={"cur_source": str(cur_source)},
+ )
+
+ async def async_step_ep_input_entity(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Specify which input to get composite's picture from."""
+ if user_input is not None:
+ self._set_entity_picture(entity_id=user_input.get(CONF_ENTITY))
+ return await self.async_step_all_states()
+
+ include_entities = self._entity_ids
+ data_schema = vol.Schema(
+ {
+ vol.Optional(CONF_ENTITY): EntitySelector(
+ EntitySelectorConfig(include_entities=include_entities)
+ )
+ }
+ )
+ picture_entity_id = None
+ for cfg in self.options[CONF_ENTITY_ID]:
+ if cfg[CONF_USE_PICTURE]:
+ picture_entity_id = cfg[CONF_ENTITY]
+ break
+ if picture_entity_id:
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, {CONF_ENTITY: picture_entity_id}
+ )
+ return self.async_show_form(
+ step_id="ep_input_entity", data_schema=data_schema, last_step=False
+ )
+
+ async def async_step_ep_local_file(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Specify a local file for composite's picture."""
+ if user_input is not None:
+ self._set_entity_picture(local_file=user_input.get(CONF_ENTITY_PICTURE))
+ return await self.async_step_all_states()
+
+ local_files = await self.hass.async_add_executor_job(self._local_files)
+ _, local_file = self._cur_entity_picture
+ if local_file and local_file not in local_files:
+ local_files.append(local_file)
+ data_schema = vol.Schema(
+ {
+ vol.Optional(CONF_ENTITY_PICTURE): SelectSelector(
+ SelectSelectorConfig(
+ options=local_files,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ }
+ )
+ if local_file:
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, {CONF_ENTITY_PICTURE: local_file}
+ )
+ return self.async_show_form(
+ step_id="ep_local_file", data_schema=data_schema, last_step=False
+ )
+
+ async def async_step_ep_upload_file(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Upload a file for composite's picture."""
+ if user_input is not None:
+ if (uploaded_file_id := user_input.get(CONF_ENTITY_PICTURE)) is None:
+ self._set_entity_picture()
+ return await self.async_step_all_states()
+
+ def save_uploaded_file() -> tuple[bool, str]:
+ """Save uploaded file.
+
+ Must be called in an executor since it does file I/O.
+
+ Returns if local directory existed beforehand and name of uploaded file.
+ """
+ local_dir_exists = self._local_dir.is_dir()
+ local_file = self._save_uploaded_file(uploaded_file_id)
+ return local_dir_exists, local_file
+
+ local_dir_exists, local_file = await self.hass.async_add_executor_job(
+ save_uploaded_file
+ )
+ self._set_entity_picture(local_file=local_file)
+ if not local_dir_exists:
+ return await self.async_step_ep_warn()
+ return await self.async_step_all_states()
+
+ accept = ", ".join(f".{ext}" for ext in PICTURE_SUFFIXES)
+ data_schema = vol.Schema(
+ {
+ vol.Optional(CONF_ENTITY_PICTURE): FileSelector(
+ FileSelectorConfig(accept=accept)
+ )
+ }
+ )
+ return self.async_show_form(
+ step_id="ep_upload_file", data_schema=data_schema, last_step=False
+ )
+
+ async def async_step_ep_warn(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Warn that since "/local" was created system might need to be restarted."""
+ if user_input is not None:
+ return await self.async_step_all_states()
+
+ return self.async_show_form(
+ step_id="ep_warn",
+ description_placeholders={"local_dir": str(self._local_dir)},
+ last_step=False,
+ )
+
+ async def async_step_ep_none(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Set composite's entity picture to none."""
+ self._set_entity_picture()
+ return await self.async_step_all_states()
+
+ async def async_step_all_states(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Specify if all states should be used for appropriate entities."""
+ if user_input is not None:
+ entity_ids = user_input.get(CONF_ENTITY, [])
+ for cfg in self.options[CONF_ENTITY_ID]:
+ cfg[CONF_ALL_STATES] = cfg[CONF_ENTITY] in entity_ids
+ return await self.async_step_done()
+
+ data_schema = vol.Schema(
+ {
+ vol.Optional(CONF_ENTITY): EntitySelector(
+ EntitySelectorConfig(
+ include_entities=self._entity_ids, multiple=True
+ )
+ )
+ }
+ )
+ all_state_entities = [
+ cfg[CONF_ENTITY]
+ for cfg in self.options[CONF_ENTITY_ID]
+ if cfg[CONF_ALL_STATES]
+ ]
+ if all_state_entities:
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, {CONF_ENTITY: all_state_entities}
+ )
+ return self.async_show_form(step_id="all_states", data_schema=data_schema)
+
+ @abstractmethod
+ async def async_step_done(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Finish the flow."""
+
+
+class CompositeConfigFlow(ConfigFlow, CompositeFlow, domain=DOMAIN):
+ """Composite config flow."""
+
+ VERSION = 1
+
+ _name = ""
+
+ def __init__(self) -> None:
+ """Initialize config flow."""
+ self._options: dict[str, Any] = {}
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry: ConfigEntry) -> CompositeOptionsFlow:
+ """Get the options flow for this handler."""
+ flow = CompositeOptionsFlow(config_entry)
+ flow.init_step = "options"
+ return flow
+
+ @classmethod
+ @callback
+ def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
+ """Return options flow support for this handler."""
+ return config_entry.source != SOURCE_IMPORT
+
+ @property
+ def options(self) -> dict[str, Any]:
+ """Return mutable copy of options."""
+ return self._options
+
+ async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult:
+ """Import config entry from configuration."""
+ if (driving_speed := data.get(CONF_DRIVING_SPEED)) is not None:
+ data[CONF_DRIVING_SPEED] = SpeedConverter.convert(
+ driving_speed, self._speed_uom, UnitOfSpeed.METERS_PER_SECOND
+ )
+ if existing_entry := await self.async_set_unique_id(data[CONF_ID]):
+ self.hass.config_entries.async_update_entry(
+ existing_entry, **split_conf(data) # type: ignore[arg-type]
+ )
+ return self.async_abort(reason="already_configured")
+
+ return self.async_create_entry(
+ title=f"{data[CONF_NAME]} (from configuration)",
+ **split_conf(data), # type: ignore[arg-type]
+ )
+
+ async def async_step_user(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Start user config flow."""
+ return await self.async_step_name()
+
+ def _name_used(self, name: str) -> bool:
+ """Return if name has already been used."""
+ for entry in self._entries:
+ if entry.source == SOURCE_IMPORT:
+ if name == entry.data[CONF_NAME]:
+ return True
+ elif name == entry.title:
+ return True
+ return False
+
+ async def async_step_name(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Get name."""
+ errors = {}
+
+ if user_input is not None:
+ self._name = user_input[CONF_NAME]
+ if not self._name_used(self._name):
+ return await self.async_step_options()
+ errors[CONF_NAME] = "name_used"
+
+ data_schema = vol.Schema({vol.Required(CONF_NAME): TextSelector()})
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, {CONF_NAME: self._name}
+ )
+ return self.async_show_form(
+ step_id="name", data_schema=data_schema, errors=errors, last_step=False
+ )
+
+ async def async_step_done(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Finish the flow."""
+ return self.async_create_entry(title=self._name, data={}, options=self.options)
+
+
+class CompositeOptionsFlow(OptionsFlowWithConfigEntry, CompositeFlow):
+ """Composite integration options flow."""
+
+ async def async_step_done(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Finish the flow."""
+ return self.async_create_entry(title="", data=self.options)
diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py
new file mode 100644
index 0000000..be96f2c
--- /dev/null
+++ b/custom_components/composite/const.py
@@ -0,0 +1,38 @@
+"""Constants for Composite Integration."""
+DOMAIN = "composite"
+
+PICTURE_SUFFIXES = ("bmp", "jpg", "png")
+MIME_TO_SUFFIX = {"image/bmp": "bmp", "image/jpeg": "jpg", "image/png": "png"}
+
+DEF_REQ_MOVEMENT = False
+
+MIN_SPEED_SECONDS = 3
+MIN_ANGLE_SPEED = 1 # meters / second
+
+SIG_COMPOSITE_SPEED = "composite_speed"
+
+CONF_ALL_STATES = "all_states"
+CONF_DEFAULT_OPTIONS = "default_options"
+CONF_DRIVING_SPEED = "driving_speed"
+CONF_END_DRIVING_DELAY = "end_driving_delay"
+CONF_ENTITY = "entity"
+CONF_ENTITY_PICTURE = "entity_picture"
+CONF_MAX_SPEED_AGE = "max_speed_age"
+CONF_REQ_MOVEMENT = "require_movement"
+CONF_SHOW_UNKNOWN_AS_0 = "show_unknown_as_0"
+CONF_TIME_AS = "time_as"
+CONF_TRACKERS = "trackers"
+CONF_USE_PICTURE = "use_picture"
+
+ATTR_ACC = "acc"
+ATTR_ANGLE = "angle"
+ATTR_CHARGING = "charging"
+ATTR_DIRECTION = "direction"
+ATTR_ENTITIES = "entities"
+ATTR_LAST_SEEN = "last_seen"
+ATTR_LAST_TIMESTAMP = "last_timestamp"
+ATTR_LAST_ENTITY_ID = "last_entity_id"
+ATTR_LAT = "lat"
+ATTR_LON = "lon"
+
+STATE_DRIVING = "driving"
diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py
index 0af7ae3..4dcf079 100644
--- a/custom_components/composite/device_tracker.py
+++ b/custom_components/composite/device_tracker.py
@@ -1,323 +1,757 @@
-"""
-A Device Tracker platform that combines one or more device trackers.
-"""
-from datetime import datetime
+"""A Device Tracker platform that combines one or more device trackers."""
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+from contextlib import suppress
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from enum import Enum, auto
import logging
-import threading
+from math import atan2, degrees
+from types import MappingProxyType
+from typing import Any, cast
-import voluptuous as vol
+from propcache.api import cached_property
from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN
from homeassistant.components.device_tracker import (
- ATTR_BATTERY, ATTR_SOURCE_TYPE, PLATFORM_SCHEMA,
- SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS,
- SOURCE_TYPE_ROUTER)
-from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT
-from homeassistant.components.zone import ENTITY_ID_HOME
-from homeassistant.components.zone import async_active_zone
+ ATTR_BATTERY,
+ ATTR_SOURCE_TYPE,
+ DOMAIN as DT_DOMAIN,
+ SourceType,
+)
+from homeassistant.components.device_tracker.config_entry import TrackerEntity
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
- ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL,
- ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
- ATTR_STATE, CONF_ENTITY_ID, CONF_NAME, EVENT_HOMEASSISTANT_START,
- STATE_HOME, STATE_NOT_HOME, STATE_ON, STATE_UNKNOWN)
+ ATTR_BATTERY_CHARGING,
+ ATTR_BATTERY_LEVEL,
+ ATTR_ENTITY_ID,
+ ATTR_ENTITY_PICTURE,
+ ATTR_GPS_ACCURACY,
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ CONF_ENTITY_ID,
+ CONF_ID,
+ CONF_NAME,
+ STATE_HOME,
+ STATE_NOT_HOME,
+ STATE_ON,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+)
+from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State
+from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.event import track_state_change
-from homeassistant.util.async_ import run_callback_threadsafe
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.event import async_call_later, async_track_state_change_event
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.typing import GPSType
import homeassistant.util.dt as dt_util
from homeassistant.util.location import distance
+from .const import (
+ ATTR_ACC,
+ ATTR_CHARGING,
+ ATTR_ENTITIES,
+ ATTR_LAST_ENTITY_ID,
+ ATTR_LAST_SEEN,
+ ATTR_LAST_TIMESTAMP,
+ ATTR_LAT,
+ ATTR_LON,
+ CONF_ALL_STATES,
+ CONF_DRIVING_SPEED,
+ CONF_END_DRIVING_DELAY,
+ CONF_ENTITY,
+ CONF_ENTITY_PICTURE,
+ CONF_MAX_SPEED_AGE,
+ CONF_REQ_MOVEMENT,
+ CONF_USE_PICTURE,
+ MIN_ANGLE_SPEED,
+ MIN_SPEED_SECONDS,
+ SIG_COMPOSITE_SPEED,
+ STATE_DRIVING,
+)
+
_LOGGER = logging.getLogger(__name__)
-__version__ = '1.10.1'
-
-CONF_TIME_AS = 'time_as'
-CONF_REQ_MOVEMENT = 'require_movement'
-
-TZ_UTC = 'utc'
-TZ_LOCAL = 'local'
-TZ_DEVICE_UTC = 'device_or_utc'
-TZ_DEVICE_LOCAL = 'device_or_local'
-# First item in list is default.
-TIME_AS_OPTS = [TZ_UTC, TZ_LOCAL, TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]
-
-ATTR_CHARGING = 'charging'
-ATTR_LAST_SEEN = 'last_seen'
-ATTR_LAST_ENTITY_ID = 'last_entity_id'
-ATTR_TIME_ZONE = 'time_zone'
-
-WARNED = 'warned'
-SEEN = 'seen'
-SOURCE_TYPE = ATTR_SOURCE_TYPE
-DATA = 'data'
-
-SOURCE_TYPE_BINARY_SENSOR = BS_DOMAIN
-STATE_BINARY_SENSOR_HOME = STATE_ON
-
-SOURCE_TYPE_NON_GPS = (
- SOURCE_TYPE_BINARY_SENSOR, SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE,
- SOURCE_TYPE_ROUTER)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_NAME): cv.slugify,
- vol.Required(CONF_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_TIME_AS, default=TIME_AS_OPTS[0]):
- vol.In(TIME_AS_OPTS),
- vol.Optional(CONF_REQ_MOVEMENT, default=False): cv.boolean,
-})
-
-
-def setup_scanner(hass, config, see, discovery_info=None):
- CompositeScanner(hass, config, see)
- return True
-
-
-class CompositeScanner:
- def __init__(self, hass, config, see):
- self._hass = hass
- self._see = see
- entities = config[CONF_ENTITY_ID]
- self._entities = {}
- for entity_id in entities:
- self._entities[entity_id] = {
- WARNED: False,
- SEEN: None,
- SOURCE_TYPE: None,
- DATA: None}
- self._dev_id = config[CONF_NAME]
- self._entity_id = ENTITY_ID_FORMAT.format(self._dev_id)
- self._time_as = config[CONF_TIME_AS]
- if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]:
- from timezonefinderL import TimezoneFinder
- self._tf = TimezoneFinder()
- self._req_movement = config[CONF_REQ_MOVEMENT]
- self._lock = threading.Lock()
- self._prev_seen = None
- self._init_complete = False
+# Cause Semaphore to be created to make async_update, and anything protected by
+# async_request_call, atomic.
+PARALLEL_UPDATES = 1
+
+
+_RESTORE_EXTRA_ATTRS = (
+ ATTR_ENTITY_ID,
+ ATTR_ENTITIES,
+ ATTR_LAST_ENTITY_ID,
+ ATTR_LAST_SEEN,
+ ATTR_BATTERY_CHARGING,
+)
+
+_GPS_ACCURACY_ATTRS = (ATTR_GPS_ACCURACY, ATTR_ACC)
+_BATTERY_ATTRS = (ATTR_BATTERY_LEVEL, ATTR_BATTERY)
+_CHARGING_ATTRS = (ATTR_BATTERY_CHARGING, ATTR_CHARGING)
+_LAST_SEEN_ATTRS = (ATTR_LAST_SEEN, ATTR_LAST_TIMESTAMP)
+
+
+async def async_setup_entry(
+ _hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the device tracker platform."""
+ async_add_entities([CompositeDeviceTracker(entry)])
+
+
+def _nearest_second(time: datetime) -> datetime:
+ """Round time to nearest second."""
+ return time.replace(microsecond=0) + timedelta(
+ seconds=0 if time.microsecond < 500000 else 1
+ )
- self._remove = track_state_change(
- hass, entities, self._update_info)
- for entity_id in entities:
- self._update_info(entity_id, None, hass.states.get(entity_id))
+class EntityStatus(Enum):
+ """Input entity status."""
- def init_complete(event):
- self._init_complete = True
+ NOT_SET = auto()
+ GOOD = auto()
+ BAD = auto()
+ WARNED = auto()
+ SUSPEND = auto()
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, init_complete)
- def _bad_entity(self, entity_id, message):
- msg = '{} {}'.format(entity_id, message)
- # Has there already been a warning for this entity?
- if self._entities[entity_id][WARNED]:
+@dataclass
+class Location:
+ """Location (latitude, longitude & accuracy)."""
+
+ gps: GPSType
+ accuracy: float
+
+
+@dataclass
+class EntityData:
+ """Input entity data."""
+
+ entity_id: str
+ use_all_states: bool
+ use_picture: bool
+ _status: EntityStatus = EntityStatus.NOT_SET
+ seen: datetime | None = None
+ source_type: SourceType = SourceType.GPS
+ data: Location | str | None = None
+
+ @property
+ def is_good(self) -> bool:
+ """Return if last update was good."""
+ return self._status == EntityStatus.GOOD
+
+ def set_params(self, use_all_states: bool, use_picture: bool) -> None:
+ """Set parameters."""
+ self.use_all_states = use_all_states
+ self.use_picture = use_picture
+
+ def good(
+ self, seen: datetime, source_type: SourceType, data: Location | str
+ ) -> None:
+ """Mark entity as good."""
+ self._status = EntityStatus.GOOD
+ self.seen = seen
+ self.source_type = source_type
+ self.data = data
+
+ def bad(self, message: str) -> None:
+ """Mark entity as bad."""
+ if self._status == EntityStatus.SUSPEND:
+ return
+ msg = f"{self.entity_id} {message}"
+ if self._status == EntityStatus.WARNED:
_LOGGER.error(msg)
- self._remove()
- self._entities.pop(entity_id)
- # Are there still any entities to watch?
- if len(self._entities):
- self._remove = track_state_change(
- self._hass, self._entities.keys(), self._update_info)
- # Don't warn during init.
- elif self._init_complete:
+ self._status = EntityStatus.SUSPEND
+ # Only warn if this is not the first state change for the entity.
+ elif self._status != EntityStatus.NOT_SET:
_LOGGER.warning(msg)
- self._entities[entity_id][WARNED] = True
+ self._status = EntityStatus.WARNED
else:
_LOGGER.debug(msg)
+ self._status = EntityStatus.BAD
+
+
+class Attributes:
+ """Flexible attribute retrieval."""
+
+ def __init__(self, attrs: Mapping[str, Any]) -> None:
+ """Initialize."""
+ self._attrs = MappingProxyType(attrs)
+
+ def __getitem__(self, key: str) -> Any:
+ """Implement Attributes_object[key]."""
+ return self._attrs[key]
+
+ def get(self, key: str | Sequence[str], default: Any | None = None) -> Any | None:
+ """Get item for first found key, or default if no key found."""
+ if isinstance(key, str):
+ return self._attrs.get(key, default)
+ for _key in key:
+ if _key in self._attrs:
+ return self._attrs[_key]
+ return default
+
+
+class CompositeDeviceTracker(TrackerEntity, RestoreEntity):
+ """Composite Device Tracker."""
+
+ _attr_translation_key = "tracker"
+ _unrecorded_attributes = frozenset({ATTR_ENTITIES, ATTR_ENTITY_PICTURE})
+
+ # State vars
+ _battery_level: int | None = None
+ _prev_seen: datetime | None = None
+ _prev_speed: float | None = None
+
+ _remove_track_states: Callable[[], None] | None = None
+ _remove_speed_is_stale: Callable[[], None] | None = None
+ _remove_driving_ended: Callable[[], None] | None = None
+ _req_movement: bool
+ _max_speed_age: timedelta | None
+ _driving_speed: float | None # m/s
+ _end_driving_delay: timedelta | None
+ _use_entity_picture: bool
+
+ def __init__(self, entry: ConfigEntry) -> None:
+ """Initialize Composite Device Tracker."""
+ if entry.source == SOURCE_IMPORT:
+ obj_id = entry.data[CONF_ID]
+ self.entity_id = f"{DT_DOMAIN}.{obj_id}"
+ self._attr_name = cast(str, entry.data[CONF_NAME])
+ self._attr_unique_id = obj_id
+ else:
+ self._attr_name = entry.title
+ self._attr_unique_id = entry.entry_id
+ self._attr_extra_state_attributes = {}
+ self._entities: dict[str, EntityData] = {}
+
+ @cached_property
+ def force_update(self) -> bool:
+ """Return True if state updates should be forced."""
+ return False
+
+ @property
+ def battery_level(self) -> int | None:
+ """Return the battery level of the device."""
+ return self._battery_level
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+
+ self.async_on_remove(
+ cast(ConfigEntry, self.platform.config_entry).add_update_listener(
+ self._config_entry_updated
+ )
+ )
+ await self.async_request_call(self._restore_state())
+ await self.async_request_call(self._process_config_options())
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ if self._remove_track_states:
+ self._remove_track_states()
+ self._remove_track_states = None
+ self._cancel_speed_stale_monitor()
+ self._cancel_drive_ending_delay()
+ await super().async_will_remove_from_hass()
+
+ async def _process_config_options(self) -> None:
+ """Process options from config entry."""
+ options = cast(ConfigEntry, self.platform.config_entry).options
+ self._req_movement = options[CONF_REQ_MOVEMENT]
+ if (msa := options.get(CONF_MAX_SPEED_AGE)) is None:
+ self._max_speed_age = None
+ else:
+ self._max_speed_age = cast(timedelta, cv.time_period(msa))
+ self._driving_speed = options.get(CONF_DRIVING_SPEED)
+ if (edd := options.get(CONF_END_DRIVING_DELAY)) is None:
+ self._end_driving_delay = None
+ else:
+ self._end_driving_delay = cast(timedelta, cv.time_period(edd))
+ entity_cfgs = {
+ entity_cfg[CONF_ENTITY]: entity_cfg
+ for entity_cfg in options[CONF_ENTITY_ID]
+ }
+
+ cur_entity_ids = set(self._entities)
+ cfg_entity_ids = set(entity_cfgs)
+
+ del_entity_ids = cur_entity_ids - cfg_entity_ids
+ new_entity_ids = cfg_entity_ids - cur_entity_ids
+ cur_entity_ids &= cfg_entity_ids
+
+ last_entity_id = (
+ self.extra_state_attributes
+ and self.extra_state_attributes[ATTR_LAST_ENTITY_ID]
+ )
+ for entity_id in del_entity_ids:
+ entity = self._entities.pop(entity_id)
+ if entity_id == last_entity_id:
+ self._clear_state()
+ if entity.use_picture:
+ self._attr_entity_picture = None
+
+ for entity_id in cur_entity_ids:
+ entity_cfg = entity_cfgs[entity_id]
+ self._entities[entity_id].set_params(
+ entity_cfg[CONF_ALL_STATES], entity_cfg[CONF_USE_PICTURE]
+ )
+
+ for entity_id in new_entity_ids:
+ entity_cfg = entity_cfgs[entity_id]
+ self._entities[entity_id] = EntityData(
+ entity_id, entity_cfg[CONF_ALL_STATES], entity_cfg[CONF_USE_PICTURE]
+ )
+
+ for entity_id in cfg_entity_ids:
+ await self._entity_updated(entity_id, self.hass.states.get(entity_id))
+
+ self._use_entity_picture = True
+ if entity_picture := options.get(CONF_ENTITY_PICTURE):
+ self._attr_entity_picture = entity_picture
+ elif not any(entity.use_picture for entity in self._entities.values()):
+ self._attr_entity_picture = None
+ self._use_entity_picture = False
+
+ async def state_listener(event: Event[EventStateChangedData]) -> None:
+ """Process input entity state update."""
+ await self.async_request_call(
+ self._entity_updated(event.data["entity_id"], event.data["new_state"])
+ )
+ self.async_write_ha_state()
+
+ if self._remove_track_states:
+ self._remove_track_states()
+ self._remove_track_states = async_track_state_change_event(
+ self.hass, cfg_entity_ids, state_listener
+ )
+
+ async def _config_entry_updated(
+ self, hass: HomeAssistant, entry: ConfigEntry
+ ) -> None:
+ """Run when the config entry has been updated."""
+ if (new_name := entry.title) != self._attr_name:
+ self._attr_name = new_name
+ er.async_get(hass).async_update_entity(
+ self.entity_id, original_name=self.name
+ )
+ await self.async_request_call(self._process_config_options())
+ self.async_write_ha_state()
+
+ async def _restore_state(self) -> None:
+ """Restore state."""
+ if not (last_state := await self.async_get_last_state()):
+ return
- def _good_entity(self, entity_id, seen, source_type, data):
- self._entities[entity_id].update({
- WARNED: False,
- SEEN: seen,
- SOURCE_TYPE: source_type,
- DATA: data})
+ self._attr_entity_picture = last_state.attributes.get(ATTR_ENTITY_PICTURE)
+ self._battery_level = last_state.attributes.get(ATTR_BATTERY_LEVEL)
+ # Prior versions allowed a source_type of binary_sensor. To better conform to
+ # the TrackerEntity base class, inputs that do not directly map to one of the
+ # SourceType options will be represented as SourceType.ROUTER.
+ if (source_type := last_state.attributes[ATTR_SOURCE_TYPE]) in SourceType:
+ self._attr_source_type = source_type
+ else:
+ self._attr_source_type = SourceType.ROUTER
+ self._attr_location_accuracy = last_state.attributes.get(ATTR_GPS_ACCURACY) or 0
+ self._attr_latitude = last_state.attributes.get(ATTR_LATITUDE)
+ self._attr_longitude = last_state.attributes.get(ATTR_LONGITUDE)
+ self._attr_extra_state_attributes = {
+ k: v for k, v in last_state.attributes.items() if k in _RESTORE_EXTRA_ATTRS
+ }
+ # List of seen entity IDs used to be in ATTR_ENTITY_ID.
+ # If present, move it to ATTR_ENTITIES.
+ if ATTR_ENTITY_ID in self._attr_extra_state_attributes:
+ self._attr_extra_state_attributes[
+ ATTR_ENTITIES
+ ] = self._attr_extra_state_attributes.pop(ATTR_ENTITY_ID)
+ with suppress(KeyError):
+ last_seen = dt_util.parse_datetime(
+ self._attr_extra_state_attributes[ATTR_LAST_SEEN]
+ )
+ if last_seen is None:
+ self._attr_extra_state_attributes[ATTR_LAST_SEEN] = None
+ else:
+ self._attr_extra_state_attributes[ATTR_LAST_SEEN] = dt_util.as_local(
+ last_seen
+ )
+ self._prev_seen = dt_util.as_utc(last_seen)
+ if self.source_type != SourceType.GPS and (
+ self.latitude is None or self.longitude is None
+ ):
+ self._attr_location_name = last_state.state
+
+ def _clear_state(self) -> None:
+ """Clear state."""
+ self._battery_level = None
+ self._attr_source_type = SourceType.GPS
+ self._attr_location_accuracy = 0
+ self._attr_location_name = None
+ self._attr_latitude = None
+ self._attr_longitude = None
+ self._attr_extra_state_attributes = {}
+ self._prev_seen = None
+ self._prev_speed = None
+ self._cancel_speed_stale_monitor()
+ self._cancel_drive_ending_delay()
+
+ def _cancel_speed_stale_monitor(self) -> None:
+ """Cancel monitoring of speed sensor staleness."""
+ if self._remove_speed_is_stale:
+ self._remove_speed_is_stale()
+ self._remove_speed_is_stale = None
+
+ def _start_speed_stale_monitor(self) -> None:
+ """Start monitoring speed sensor staleness."""
+ self._cancel_speed_stale_monitor()
+ if self._max_speed_age is None:
+ return
- def _use_non_gps_data(self, state):
- if state == STATE_HOME:
- return True
- entities = self._entities.values()
- if any(entity[SOURCE_TYPE] == SOURCE_TYPE_GPS
- for entity in entities):
- return False
- return all(entity[DATA] != STATE_HOME
- for entity in entities
- if entity[SOURCE_TYPE] in SOURCE_TYPE_NON_GPS)
-
- def _dt_attr_from_utc(self, utc, tz):
- if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL] and tz:
- return utc.astimezone(tz)
- if self._time_as in [TZ_LOCAL, TZ_DEVICE_LOCAL]:
- return dt_util.as_local(utc)
- return utc
-
- def _update_info(self, entity_id, old_state, new_state):
- if new_state is None:
+ async def speed_is_stale(_utcnow: datetime) -> None:
+ """Speed sensor is stale."""
+ self._remove_speed_is_stale = None
+
+ async def clear_speed_sensor_state() -> None:
+ """Clear speed sensor's state."""
+ self._send_speed(None, None)
+
+ await self.async_request_call(clear_speed_sensor_state())
+ self.async_write_ha_state()
+
+ self._remove_speed_is_stale = async_call_later(
+ self.hass, self._max_speed_age, speed_is_stale
+ )
+
+ def _send_speed(self, speed: float | None, angle: int | None) -> None:
+ """Send values to speed sensor."""
+ _LOGGER.debug("%s: Sending speed: %s m/s, angle: %s°", self.name, speed, angle)
+ async_dispatcher_send(
+ self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed, angle
+ )
+
+ def _cancel_drive_ending_delay(self) -> None:
+ """Cancel ending of driving state."""
+ if self._remove_driving_ended:
+ self._remove_driving_ended()
+ self._remove_driving_ended = None
+
+ def _start_drive_ending_delay(self) -> None:
+ """Start delay to end driving state if configured."""
+ self._cancel_drive_ending_delay()
+ if self._end_driving_delay is None:
return
- with self._lock:
- # Get time device was last seen, which is the entity's last_seen
- # attribute, or if that doesn't exist, then last_updated from the
- # new state object. Make sure last_seen is timezone aware in UTC.
- # Note that dt_util.as_utc assumes naive datetime is in local
- # timezone.
- last_seen = new_state.attributes.get(ATTR_LAST_SEEN)
- if isinstance(last_seen, datetime):
- last_seen = dt_util.as_utc(last_seen)
- else:
- try:
- last_seen = dt_util.utc_from_timestamp(float(last_seen))
- except (TypeError, ValueError):
- last_seen = new_state.last_updated
+ async def driving_ended(_utcnow: datetime) -> None:
+ """End driving state."""
+ self._remove_driving_ended = None
- old_last_seen = self._entities[entity_id][SEEN]
- if old_last_seen and last_seen < old_last_seen:
- self._bad_entity(entity_id, 'last_seen went backwards')
- return
+ async def end_driving() -> None:
+ """End driving state."""
+ self._attr_location_name = None
- # Try to get GPS and battery data.
- try:
- gps = (new_state.attributes[ATTR_LATITUDE],
- new_state.attributes[ATTR_LONGITUDE])
- except KeyError:
- gps = None
- gps_accuracy = new_state.attributes.get(ATTR_GPS_ACCURACY)
- battery = new_state.attributes.get(
- ATTR_BATTERY, new_state.attributes.get(ATTR_BATTERY_LEVEL))
- charging = new_state.attributes.get(
- ATTR_BATTERY_CHARGING, new_state.attributes.get(ATTR_CHARGING))
- # Don't use location_name unless we have to.
- location_name = None
-
- # What type of tracker is this?
- if new_state.domain == BS_DOMAIN:
- source_type = SOURCE_TYPE_BINARY_SENSOR
- else:
- source_type = new_state.attributes.get(ATTR_SOURCE_TYPE)
+ await self.async_request_call(end_driving())
+ self.async_write_ha_state()
- state = new_state.state
+ self._remove_driving_ended = async_call_later(
+ self.hass, self._end_driving_delay, driving_ended
+ )
- if source_type == SOURCE_TYPE_GPS:
- # GPS coordinates and accuracy are required.
- if gps is None:
- self._bad_entity(entity_id, 'missing gps attributes')
- return
- if gps_accuracy is None:
- self._bad_entity(entity_id,
- 'missing gps_accuracy attribute')
- return
+ @property
+ def _drive_ending_delayed(self) -> bool:
+ """Return if end of driving state is being delayed."""
+ return self._remove_driving_ended is not None
- new_data = gps, gps_accuracy
- old_data = self._entities[entity_id][DATA]
- if old_data:
- if last_seen == old_last_seen and new_data == old_data:
- return
- old_gps, old_acc = old_data
- self._good_entity(entity_id, last_seen, source_type, new_data)
-
- if (self._req_movement and old_data and
- distance(gps[0], gps[1], old_gps[0], old_gps[1]) <=
- gps_accuracy + old_acc):
- _LOGGER.debug(
- 'For {} skipping update from {}: '
- 'not enough movement'
- .format(self._entity_id, entity_id))
- return
+ async def _entity_updated( # noqa: C901
+ self, entity_id: str, new_state: State | None
+ ) -> None:
+ """Run when an input entity has changed state."""
+ if not new_state or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+ return
- elif source_type in SOURCE_TYPE_NON_GPS:
- # Convert 'on'/'off' state of binary_sensor
- # to 'home'/'not_home'.
- if source_type == SOURCE_TYPE_BINARY_SENSOR:
- if state == STATE_BINARY_SENSOR_HOME:
- state = STATE_HOME
- else:
- state = STATE_NOT_HOME
+ entity = self._entities[entity_id]
+ new_attrs = Attributes(new_state.attributes)
+
+ # Get time device was last seen, which is specified by one of the entity's
+ # attributes defined by _LAST_SEEN_ATTRS, as a datetime.
+
+ def get_last_seen() -> datetime | None:
+ """Get last_seen (in UTC) from one of the possible attributes."""
+ if (raw_last_seen := new_attrs.get(_LAST_SEEN_ATTRS)) is None:
+ return None
+ if isinstance(raw_last_seen, datetime):
+ return dt_util.as_utc(raw_last_seen)
+ with suppress(TypeError, ValueError):
+ return dt_util.utc_from_timestamp(float(raw_last_seen))
+ with suppress(TypeError):
+ if (parsed_last_seen := dt_util.parse_datetime(raw_last_seen)) is None:
+ return None
+ return dt_util.as_utc(parsed_last_seen)
+ return None
+
+ # Use last_updated from the new state object if no valid "last seen" was found.
+ last_seen = get_last_seen() or new_state.last_updated
+
+ old_last_seen = entity.seen
+ if old_last_seen and last_seen < old_last_seen:
+ entity.bad("last_seen went backwards")
+ return
- self._good_entity(entity_id, last_seen, source_type, state)
+ # Try to get GPS and battery data.
+ gps: GPSType | None = None
+ with suppress(KeyError):
+ gps = new_attrs[ATTR_LATITUDE], new_attrs[ATTR_LONGITUDE]
+ if not gps:
+ with suppress(KeyError):
+ gps = new_attrs[ATTR_LAT], new_attrs[ATTR_LON]
+ gps_accuracy = cast(float | None, new_attrs.get(_GPS_ACCURACY_ATTRS))
+ battery = cast(int | None, new_attrs.get(_BATTERY_ATTRS))
+ charging = cast(bool | None, new_attrs.get(_CHARGING_ATTRS))
+
+ # What type of tracker is this?
+ if new_state.domain == BS_DOMAIN:
+ source_type: str | None = SourceType.ROUTER.value
+ else:
+ source_type = new_attrs.get(
+ ATTR_SOURCE_TYPE,
+ SourceType.GPS.value if gps and gps_accuracy is not None else None,
+ )
+
+ if entity.use_picture:
+ self._attr_entity_picture = new_attrs.get(ATTR_ENTITY_PICTURE)
+
+ state = new_state.state
+ # Don't use location_name unless we have to.
+ location_name: str | None = None
+
+ if source_type == SourceType.GPS:
+ # GPS coordinates and accuracy are required.
+ if not gps:
+ entity.bad("missing gps attributes")
+ return
+ if gps_accuracy is None:
+ entity.bad("missing gps_accuracy attribute")
+ return
+
+ new_data = Location(gps, gps_accuracy)
+ old_data = cast(Location | None, entity.data)
+ if last_seen == old_last_seen and new_data == old_data:
+ return
+ entity.good(last_seen, SourceType.GPS, new_data)
- if not self._use_non_gps_data(state):
+ if self._req_movement and old_data:
+ dist = distance(gps[0], gps[1], old_data.gps[0], old_data.gps[1])
+ if dist is not None and dist <= gps_accuracy + old_data.accuracy:
+ _LOGGER.debug(
+ "For %s skipping update from %s: not enough movement",
+ self.entity_id,
+ entity_id,
+ )
return
- # Don't use new GPS data if it's not complete.
- if gps is None or gps_accuracy is None:
- gps = gps_accuracy = None
- # Get current GPS data, if any, and determine if it is in
- # 'zone.home'.
- cur_state = self._hass.states.get(self._entity_id)
- try:
- cur_lat = cur_state.attributes[ATTR_LATITUDE]
- cur_lon = cur_state.attributes[ATTR_LONGITUDE]
- cur_acc = cur_state.attributes[ATTR_GPS_ACCURACY]
- cur_gps_is_home = (
- run_callback_threadsafe(
- self._hass.loop, async_active_zone, self._hass,
- cur_lat, cur_lon, cur_acc
- ).result().entity_id == ENTITY_ID_HOME)
- except (AttributeError, KeyError):
- cur_gps_is_home = False
-
- # It's important, for this composite tracker, to avoid the
- # component level code's "stale processing." This can be done
- # one of two ways: 1) provide GPS data w/ source_type of gps,
- # or 2) provide a location_name (that will be used as the new
- # state.)
-
- # If router entity's state is 'home' and current GPS data from
- # composite entity is available and is in 'zone.home',
- # use it and make source_type gps.
- if state == STATE_HOME and cur_gps_is_home:
- gps = cur_lat, cur_lon
- gps_accuracy = cur_acc
- source_type = SOURCE_TYPE_GPS
- # Otherwise, if new GPS data is valid (which is unlikely if
- # new state is not 'home'),
- # use it and make source_type gps.
- elif gps:
- source_type = SOURCE_TYPE_GPS
- # Otherwise, don't use any GPS data, but set location_name to
- # new state.
+ elif source_type in SourceType:
+ # Convert 'on'/'off' state of binary_sensor
+ # to 'home'/'not_home'.
+ if new_state.domain == BS_DOMAIN:
+ if state == STATE_ON:
+ state = STATE_HOME
else:
- location_name = state
+ state = STATE_NOT_HOME
- else:
- self._bad_entity(
- entity_id,
- 'unsupported source_type: {}'.format(source_type))
- return
+ entity.good(last_seen, SourceType(source_type), state) # type: ignore[arg-type]
- # Is this newer info than last update?
- if self._prev_seen and last_seen <= self._prev_seen:
- _LOGGER.debug(
- 'For {} skipping update from {}: '
- 'last_seen not newer than previous update ({} <= {})'
- .format(self._entity_id, entity_id, last_seen,
- self._prev_seen))
+ if not self._use_non_gps_data(entity_id, state):
return
- _LOGGER.debug('Updating %s from %s', self._entity_id, entity_id)
-
- tz = None
- if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]:
- tzname = None
- if gps:
- # timezone_at will return a string or None.
- tzname = self._tf.timezone_at(lng=gps[1], lat=gps[0])
- # get_time_zone will return a tzinfo or None.
- tz = dt_util.get_time_zone(tzname)
- attrs = {ATTR_TIME_ZONE: tzname or STATE_UNKNOWN}
+ # Don't use new GPS data if it's not complete.
+ if not gps or gps_accuracy is None:
+ gps = gps_accuracy = None
+
+ # Is current state home w/ GPS data?
+ if home_w_gps := self.location_name is None and self.state == STATE_HOME:
+ if self.latitude is None or self.longitude is None:
+ _LOGGER.warning("%s: Unexpectedly home without GPS data", self.name)
+ home_w_gps = False
+
+ # It's important, for this composite tracker, to avoid the
+ # component level code's "stale processing." This can be done
+ # one of two ways: 1) provide GPS data w/ source_type of gps,
+ # or 2) provide a location_name (that will be used as the new
+ # state.)
+
+ # If input entity's state is 'home' and our current state is 'home' w/ GPS
+ # data, use it and make source_type gps.
+ if state == STATE_HOME and home_w_gps:
+ gps = cast(GPSType, (self.latitude, self.longitude))
+ gps_accuracy = self.location_accuracy
+ source_type = SourceType.GPS.value
+ # Otherwise, if new GPS data is valid (which is unlikely if
+ # new state is not 'home'),
+ # use it and make source_type gps.
+ elif gps:
+ source_type = SourceType.GPS.value
+ # Otherwise, if new state is 'home' and old state is not 'home' w/ GPS data
+ # (i.e., not 'home' or no GPS data), then use HA's configured Home location
+ # and make source_type gps.
+ elif state == STATE_HOME:
+ gps = (self.hass.config.latitude, self.hass.config.longitude)
+ gps_accuracy = 0
+ source_type = SourceType.GPS.value
+ # Otherwise, don't use any GPS data, but set location_name to
+ # new state.
else:
- attrs = {}
-
- attrs.update({
- ATTR_ENTITY_ID: tuple(
- entity_id for entity_id, entity in self._entities.items()
- if entity[ATTR_SOURCE_TYPE] is not None),
- ATTR_LAST_ENTITY_ID: entity_id,
- ATTR_LAST_SEEN:
- self._dt_attr_from_utc(last_seen.replace(microsecond=0),
- tz)
- })
- if charging is not None:
- attrs[ATTR_BATTERY_CHARGING] = charging
- self._see(dev_id=self._dev_id, location_name=location_name,
- gps=gps, gps_accuracy=gps_accuracy, battery=battery,
- attributes=attrs, source_type=source_type)
-
- self._prev_seen = last_seen
+ location_name = state
+
+ else:
+ entity.bad(f"unsupported source_type: {source_type}")
+ return
+
+ # Is this newer info than last update?
+ if self._prev_seen and last_seen <= self._prev_seen:
+ _LOGGER.debug(
+ "For %s skipping update from %s: "
+ "last_seen not newer than previous update (%s) <= (%s)",
+ self.entity_id,
+ entity_id,
+ dt_util.as_local(last_seen),
+ dt_util.as_local(self._prev_seen),
+ )
+ return
+
+ _LOGGER.debug("Updating %s from %s", self.entity_id, entity_id)
+
+ attrs = {
+ ATTR_ENTITIES: tuple(
+ entity_id
+ for entity_id, _entity in self._entities.items()
+ if _entity.is_good
+ ),
+ ATTR_LAST_ENTITY_ID: entity_id,
+ ATTR_LAST_SEEN: dt_util.as_local(_nearest_second(last_seen)),
+ }
+ if charging is not None:
+ attrs[ATTR_BATTERY_CHARGING] = charging
+
+ self._set_state(
+ location_name, gps, gps_accuracy, battery, attrs, SourceType(source_type) # type: ignore[arg-type]
+ )
+
+ self._prev_seen = last_seen
+
+ def _set_state(
+ self,
+ location_name: str | None,
+ gps: GPSType | None,
+ gps_accuracy: float | None,
+ battery: int | None,
+ attributes: dict,
+ source_type: SourceType,
+ ) -> None:
+ """Set new state."""
+ # Save previously "seen" values before updating for speed calculations, etc.
+ prev_ent: str | None
+ prev_lat: float | None
+ prev_lon: float | None
+ if self._prev_seen:
+ prev_ent = self._attr_extra_state_attributes[ATTR_LAST_ENTITY_ID]
+ prev_lat = self.latitude
+ prev_lon = self.longitude
+ else:
+ # Don't use restored attributes.
+ prev_ent = prev_lat = prev_lon = None
+ was_driving = (
+ self._prev_speed is not None
+ and self._driving_speed is not None
+ and self._prev_speed >= self._driving_speed
+ )
+
+ self._battery_level = battery
+ self._attr_source_type = source_type
+ self._attr_location_accuracy = gps_accuracy or 0
+ self._attr_location_name = location_name
+ lat: float | None
+ lon: float | None
+ if gps:
+ lat, lon = gps
+ else:
+ lat = lon = None
+ self._attr_latitude = lat
+ self._attr_longitude = lon
+
+ self._attr_extra_state_attributes = attributes
+
+ last_seen = cast(datetime, attributes[ATTR_LAST_SEEN])
+ speed = None
+ angle = None
+ use_new_speed = True
+ if (
+ prev_ent
+ and self._prev_seen
+ and prev_lat is not None
+ and prev_lon is not None
+ and lat is not None
+ and lon is not None
+ ):
+ # It's ok that last_seen is in local tz and self._prev_seen is in UTC.
+ # last_seen's value will automatically be converted to UTC during the
+ # subtraction operation.
+ seconds = (last_seen - self._prev_seen).total_seconds()
+ min_seconds = MIN_SPEED_SECONDS
+ if cast(str, attributes[ATTR_LAST_ENTITY_ID]) != prev_ent:
+ min_seconds *= 3
+ if seconds < min_seconds:
+ _LOGGER.debug(
+ "%s: Not sending speed & angle (time delta %0.1f < %0.1f)",
+ self.name,
+ seconds,
+ min_seconds,
+ )
+ use_new_speed = False
+ else:
+ meters = cast(float, distance(prev_lat, prev_lon, lat, lon))
+ try:
+ speed = round(meters / seconds, 1)
+ except TypeError:
+ _LOGGER.error("%s: distance() returned None", self.name)
+ else:
+ if speed > MIN_ANGLE_SPEED:
+ angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat)))
+ if angle < 0:
+ angle += 360
+
+ if use_new_speed:
+ self._send_speed(speed, angle)
+ self._prev_speed = speed
+ self._start_speed_stale_monitor()
+ else:
+ speed = self._prev_speed
+
+ # Only set state to driving if it's currently "away" (i.e., not in a zone.)
+ if self.state != STATE_NOT_HOME:
+ self._cancel_drive_ending_delay()
+ return
+
+ driving = (
+ speed is not None
+ and self._driving_speed is not None
+ and speed >= self._driving_speed
+ )
+
+ if driving:
+ self._cancel_drive_ending_delay()
+ elif was_driving:
+ self._start_drive_ending_delay()
+
+ if driving or self._drive_ending_delayed:
+ self._attr_location_name = STATE_DRIVING
+
+ def _use_non_gps_data(self, entity_id: str, state: str) -> bool:
+ """Determine if state should be used for non-GPS based entity."""
+ if state == STATE_HOME or self._entities[entity_id].use_all_states:
+ return True
+ good_entities = (entity for entity in self._entities.values() if entity.is_good)
+ if any(entity.source_type == SourceType.GPS for entity in good_entities):
+ return False
+ return all(cast(str, entity.data) != STATE_HOME for entity in good_entities)
diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json
index 845c828..5ee0552 100644
--- a/custom_components/composite/manifest.json
+++ b/custom_components/composite/manifest.json
@@ -1,12 +1,12 @@
{
"domain": "composite",
"name": "Composite",
- "documentation": "https://github.com/pnbruckner/homeassistant-config/blob/master/docs/composite.md",
- "requirements": [
- "timezonefinderL==4.0.2"
- ],
- "dependencies": [],
- "codeowners": [
- "@pnbruckner"
- ]
+ "codeowners": ["@pnbruckner"],
+ "config_flow": true,
+ "dependencies": ["file_upload"],
+ "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/4.1.0/README.md",
+ "iot_class": "local_polling",
+ "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues",
+ "requirements": ["filetype==1.2.0"],
+ "version": "4.1.0"
}
diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py
new file mode 100644
index 0000000..65441a6
--- /dev/null
+++ b/custom_components/composite/sensor.py
@@ -0,0 +1,148 @@
+"""Composite Sensor."""
+from __future__ import annotations
+
+from typing import cast
+
+from homeassistant.components.sensor import (
+ DOMAIN as S_DOMAIN,
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_ID, CONF_NAME, UnitOfSpeed
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import (
+ ATTR_ANGLE,
+ ATTR_DIRECTION,
+ CONF_SHOW_UNKNOWN_AS_0,
+ SIG_COMPOSITE_SPEED,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the sensor platform."""
+ async_add_entities([CompositeSensor(hass, entry)])
+
+
+class CompositeSensor(SensorEntity):
+ """Composite Sensor Entity."""
+
+ _attr_should_poll = False
+ _raw_value: float | None = None
+ _ok_to_write_state = False
+ _show_unknown_as_0: bool
+
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Initialize composite sensor entity."""
+ if entry.source == SOURCE_IMPORT:
+ entity_description = SensorEntityDescription(
+ key="speed",
+ device_class=SensorDeviceClass.SPEED,
+ icon="mdi:car-speed-limiter",
+ name=cast(str, entry.data[CONF_NAME]) + " Speed",
+ translation_key="speed",
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ obj_id = (signal := cast(str, entry.data[CONF_ID])) + "_speed"
+ self.entity_id = f"{S_DOMAIN}.{obj_id}"
+ self._attr_unique_id = obj_id
+ else:
+ entity_description = SensorEntityDescription(
+ key="speed",
+ device_class=SensorDeviceClass.SPEED,
+ icon="mdi:car-speed-limiter",
+ has_entity_name=True,
+ translation_key="speed",
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ self._attr_translation_placeholders = {"name": entry.title}
+ self._attr_unique_id = signal = entry.entry_id
+ self.entity_description = entity_description
+ self._attr_extra_state_attributes = {
+ ATTR_ANGLE: None,
+ ATTR_DIRECTION: None,
+ }
+
+ self.async_on_remove(
+ async_dispatcher_connect(
+ hass, f"{SIG_COMPOSITE_SPEED}-{signal}", self._update
+ )
+ )
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the value reported by the sensor."""
+ if self._raw_value is not None:
+ return self._raw_value
+ if self._show_unknown_as_0:
+ return 0
+ return None
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ cast(ConfigEntry, self.platform.config_entry).add_update_listener(
+ self._config_entry_updated
+ )
+ )
+ await self.async_request_call(self._process_config_options())
+ self._ok_to_write_state = True
+
+ async def _process_config_options(self) -> None:
+ """Process options from config entry."""
+ options = cast(ConfigEntry, self.platform.config_entry).options
+ # For backward compatibility, if the option is not present, interpret that as
+ # the same as False.
+ self._show_unknown_as_0 = options.get(CONF_SHOW_UNKNOWN_AS_0, False)
+
+ async def _config_entry_updated(
+ self, hass: HomeAssistant, entry: ConfigEntry
+ ) -> None:
+ """Run when the config entry has been updated."""
+ if entry.source == SOURCE_IMPORT:
+ return
+ if (new_name := entry.title) != self._attr_translation_placeholders["name"]:
+ # Need to change _attr_translation_placeholders (instead of the dict to
+ # which it refers) to clear the cached_property.
+ self._attr_translation_placeholders = {"name": new_name}
+ er.async_get(hass).async_update_entity(
+ self.entity_id, original_name=self.name
+ )
+ await self.async_request_call(self._process_config_options())
+ self.async_write_ha_state()
+
+ async def _update(self, value: float | None, angle: int | None) -> None:
+ """Update sensor with new value."""
+
+ def direction(angle: int | None) -> str | None:
+ """Determine compass direction."""
+ if angle is None:
+ return None
+ return ("N", "NE", "E", "SE", "S", "SW", "W", "NW", "N")[
+ int((angle + 360 / 16) // (360 / 8))
+ ]
+
+ self._raw_value = value
+ self._attr_extra_state_attributes = {
+ ATTR_ANGLE: angle,
+ ATTR_DIRECTION: direction(angle),
+ }
+ # It's possible for dispatcher signal to arrive, causing this method to execute,
+ # before this sensor entity has been completely "added to hass", meaning
+ # self.hass might not yet have been initialized, causing this call to
+ # async_write_ha_state to fail. We still update our state, so that the call to
+ # async_write_ha_state at the end of the "add to hass" process will see it. Once
+ # added to hass, we can go ahead and write the state here for future updates.
+ if self._ok_to_write_state:
+ self.async_write_ha_state()
diff --git a/custom_components/composite/services.yaml b/custom_components/composite/services.yaml
new file mode 100644
index 0000000..50ece8f
--- /dev/null
+++ b/custom_components/composite/services.yaml
@@ -0,0 +1 @@
+reload: {}
diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json
new file mode 100644
index 0000000..a76793e
--- /dev/null
+++ b/custom_components/composite/translations/en.json
@@ -0,0 +1,174 @@
+{
+ "title": "Composite",
+ "config": {
+ "step": {
+ "all_states": {
+ "title": "Use All States",
+ "description": "Select entities for which all states should be used.\nNote that this only applies to entities whose source type is not GPS.",
+ "data": {
+ "entity": "Entity"
+ }
+ },
+ "end_driving_delay": {
+ "title": "End Driving Delay",
+ "description": "Time to hold Driving state after speed falls below configured Driving speed.\n\nLeave empty to change back to Away immediately. If used, a good starting point is 2 minutes.",
+ "data": {
+ "end_driving_delay": "End driving delay"
+ }
+ },
+ "ep_input_entity": {
+ "title": "Picture Entity",
+ "description": "Choose entity whose picture will be used for the composite.\nIt may be none.",
+ "data": {
+ "entity": "Entity"
+ }
+ },
+ "ep_local_file": {
+ "title": "Local Picture File",
+ "description": "Choose local file to be used for the composite.\nIt may be none.",
+ "data": {
+ "entity_picture": "Local file"
+ }
+ },
+ "ep_menu": {
+ "title": "Composite Entity Picture",
+ "description": "Choose source of composite's entity picture.\n\nCurrently: {cur_source}",
+ "menu_options": {
+ "all_states": "Keep current setting",
+ "ep_local_file": "Select local file",
+ "ep_input_entity": "Use an input entity's picture",
+ "ep_none": "Do not use an entity picture",
+ "ep_upload_file": "Upload a file"
+ }
+ },
+ "ep_upload_file": {
+ "title": "Upload Picture File",
+ "description": "Upload a file to be used for the composite.\nIt may be none.",
+ "data": {
+ "entity_picture": "Picture file"
+ }
+ },
+ "ep_warn": {
+ "title": "File Upload Directory Created",
+ "description": "/local directory ({local_dir}) was created.\nHome Assistant may need to be restarted for uploaded files to be usable."
+ },
+ "name": {
+ "title": "Name",
+ "data": {
+ "name": "Name"
+ }
+ },
+ "options": {
+ "title": "Composite Options",
+ "data": {
+ "driving_speed": "Driving speed",
+ "entity_id": "Input entities",
+ "max_speed_age": "Max time between speed sensor updates",
+ "require_movement": "Require movement",
+ "show_unknown_as_0": "Show unknown speed as 0.0"
+ }
+ }
+ },
+ "error": {
+ "at_least_one_entity": "Must select at least one input entity.",
+ "name_used": "Name has already been used."
+ }
+ },
+ "entity": {
+ "device_tracker": {
+ "tracker": {
+ "state": {
+ "driving": "Driving"
+ },
+ "state_attributes": {
+ "battery_charging": {"name": "Battery charging"},
+ "entities": {"name": "Seen entities"},
+ "last_entity_id": {"name": "Last entity"},
+ "last_seen": {"name": "Last seen"}
+ }
+ }
+ },
+ "sensor": {
+ "speed": {
+ "name": "{name} speed",
+ "state_attributes": {
+ "angle": {"name": "Angle"},
+ "direction": {"name": "Direction"}
+ }
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "all_states": {
+ "title": "Use All States",
+ "description": "Select entities for which all states should be used.\nNote that this only applies to entities whose source type is not GPS.",
+ "data": {
+ "entity": "Entity"
+ }
+ },
+ "end_driving_delay": {
+ "title": "End Driving Delay",
+ "description": "Time to hold Driving state after speed falls below configured Driving speed.\n\nLeave empty to change back to Away immediately. If used, a good starting point is 2 minutes.",
+ "data": {
+ "end_driving_delay": "End driving delay"
+ }
+ },
+ "ep_input_entity": {
+ "title": "Picture Entity",
+ "description": "Choose entity whose picture will be used for the composite.\nIt may be none.",
+ "data": {
+ "entity": "Entity"
+ }
+ },
+ "ep_local_file": {
+ "title": "Local Picture File",
+ "description": "Choose local file to be used for the composite.\nIt may be none.",
+ "data": {
+ "entity_picture": "Local file"
+ }
+ },
+ "ep_menu": {
+ "title": "Composite Entity Picture",
+ "description": "Choose source of composite's entity picture.\n\nCurrently: {cur_source}",
+ "menu_options": {
+ "all_states": "Keep current setting",
+ "ep_local_file": "Select local file",
+ "ep_input_entity": "Use an input entity's picture",
+ "ep_none": "Do not use an entity picture",
+ "ep_upload_file": "Upload a file"
+ }
+ },
+ "ep_upload_file": {
+ "title": "Upload Picture File",
+ "description": "Upload a file to be used for the composite.\nIt may be none.",
+ "data": {
+ "entity_picture": "Picture file"
+ }
+ },
+ "ep_warn": {
+ "title": "File Upload Directory Created",
+ "description": "/local directory ({local_dir}) was created.\nHome Assistant may need to be restarted for uploaded files to be usable."
+ },
+ "options": {
+ "title": "Composite Options",
+ "data": {
+ "driving_speed": "Driving speed",
+ "entity_id": "Input entities",
+ "max_speed_age": "Max time between speed sensor updates",
+ "require_movement": "Require movement",
+ "show_unknown_as_0": "Show unknown speed as 0.0"
+ }
+ }
+ },
+ "error": {
+ "at_least_one_entity": "Must select at least one input entity."
+ }
+ },
+ "services": {
+ "reload": {
+ "name": "Reload",
+ "description": "Reloads Composite from the YAML-configuration."
+ }
+ }
+}
\ No newline at end of file
diff --git a/custom_components/composite/translations/it.json b/custom_components/composite/translations/it.json
new file mode 100644
index 0000000..b42785b
--- /dev/null
+++ b/custom_components/composite/translations/it.json
@@ -0,0 +1,174 @@
+{
+ "title": "Composito",
+ "config": {
+ "step": {
+ "all_states": {
+ "title": "Usa tutti gli stati",
+ "description": "Seleziona le entità per le quali devono essere utilizzati tutti gli stati.\nNota che questo si applica solo alle entità il cui tipo di sorgente non è GPS.",
+ "data": {
+ "entity": "Entità"
+ }
+ },
+ "end_driving_delay": {
+ "title": "Ritardo fine guida",
+ "description": "Tempo per il quale è mantenuto lo stato \"Alla guida\" dopo che la velocità scende al di sotto della velocità di guida configurata.\n\nLascia vuoto per tornare immediatamente a \"Fuori casa\". Se utilizzato, un buon punto di partenza è 2 minuti.",
+ "data": {
+ "end_driving_delay": "Ritardo fine guida"
+ }
+ },
+ "ep_input_entity": {
+ "title": "Entità immagine",
+ "description": "Scegli l'entità la cui immagine verrà utilizzata per il composito.\nPuoi lasciare il campo vuoto.",
+ "data": {
+ "entity": "Entità"
+ }
+ },
+ "ep_local_file": {
+ "title": "File immagine locale",
+ "description": "Scegli il file locale da utilizzare per il composito.\nPuoi lasciare il campo vuoto.",
+ "data": {
+ "entity_picture": "File locale"
+ }
+ },
+ "ep_menu": {
+ "title": "Immagine entità composito",
+ "description": "Scegli la sorgente dell'immagine dell'entità del composito.\n\nAttualmente: {cur_source}",
+ "menu_options": {
+ "all_states": "Mantieni l'impostazione corrente",
+ "ep_local_file": "Seleziona file locale",
+ "ep_input_entity": "Usa l'immagine di un'entità di input",
+ "ep_none": "Non usare un'immagine entità",
+ "ep_upload_file": "Carica un file"
+ }
+ },
+ "ep_upload_file": {
+ "title": "Carica file immagine",
+ "description": "Carica un file da utilizzare per il composito.\nPuoi lasciare il campo vuoto.",
+ "data": {
+ "entity_picture": "File immagine"
+ }
+ },
+ "ep_warn": {
+ "title": "Directory caricamento file creata",
+ "description": "La directory /local ({local_dir}) è stata creata.\nPotrebbe essere necessario riavviare Home Assistant affinché i file caricati siano utilizzabili."
+ },
+ "name": {
+ "title": "Nome",
+ "data": {
+ "name": "Nome"
+ }
+ },
+ "options": {
+ "title": "Opzioni composito",
+ "data": {
+ "driving_speed": "Velocità di guida",
+ "entity_id": "Entità di input",
+ "max_speed_age": "Tempo massimo tra aggiornamenti del sensore di velocità",
+ "require_movement": "Richiedi movimento",
+ "show_unknown_as_0": "Mostra 0,0 quando la velocità è sconosciuta"
+ }
+ }
+ },
+ "error": {
+ "at_least_one_entity": "Devi selezionare almeno un'entità di input.",
+ "name_used": "Il nome è già stato utilizzato."
+ }
+ },
+ "entity": {
+ "device_tracker": {
+ "tracker": {
+ "state": {
+ "driving": "Alla guida"
+ },
+ "state_attributes": {
+ "battery_charging": {"name": "Batteria in carica"},
+ "entities": {"name": "Entità rilevate"},
+ "last_entity_id": {"name": "Ultima entità"},
+ "last_seen": {"name": "Ultima rilevazione"}
+ }
+ }
+ },
+ "sensor": {
+ "speed": {
+ "name": "Velocità {name}",
+ "state_attributes": {
+ "angle": {"name": "Angolo"},
+ "direction": {"name": "Direzione"}
+ }
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "all_states": {
+ "title": "Usa tutti gli stati",
+ "description": "Seleziona le entità per le quali devono essere utilizzati tutti gli stati.\nNota che questo si applica solo alle entità il cui tipo di sorgente non è GPS.",
+ "data": {
+ "entity": "Entità"
+ }
+ },
+ "end_driving_delay": {
+ "title": "Ritardo fine guida",
+ "description": "Tempo per il quale è mantenuto lo stato \"Alla guida\" dopo che la velocità scende al di sotto della velocità di guida configurata.\n\nLascia vuoto per tornare immediatamente a \"Fuori casa\". Se utilizzato, un buon punto di partenza è 2 minuti.",
+ "data": {
+ "end_driving_delay": "Ritardo fine guida"
+ }
+ },
+ "ep_input_entity": {
+ "title": "Entità immagine",
+ "description": "Scegli l'entità la cui immagine verrà utilizzata per il composito.\nPuoi lasciare il campo vuoto.",
+ "data": {
+ "entity": "Entità"
+ }
+ },
+ "ep_local_file": {
+ "title": "File immagine locale",
+ "description": "Scegli il file locale da utilizzare per il composito.\nPuoi lasciare il campo vuoto.",
+ "data": {
+ "entity_picture": "File locale"
+ }
+ },
+ "ep_menu": {
+ "title": "Immagine entità composito",
+ "description": "Scegli la sorgente dell'immagine dell'entità del composito.\n\nAttualmente: {cur_source}",
+ "menu_options": {
+ "all_states": "Mantieni l'impostazione corrente",
+ "ep_local_file": "Seleziona file locale",
+ "ep_input_entity": "Usa l'immagine di un'entità di input",
+ "ep_none": "Non usare un'immagine entità",
+ "ep_upload_file": "Carica un file"
+ }
+ },
+ "ep_upload_file": {
+ "title": "Carica file immagine",
+ "description": "Carica un file da utilizzare per il composito.\nPuoi lasciare il campo vuoto.",
+ "data": {
+ "entity_picture": "File immagine"
+ }
+ },
+ "ep_warn": {
+ "title": "Directory caricamento file creata",
+ "description": "La directory /local ({local_dir}) è stata creata.\nPotrebbe essere necessario riavviare Home Assistant affinché i file caricati siano utilizzabili."
+ },
+ "options": {
+ "title": "Opzioni composito",
+ "data": {
+ "driving_speed": "Velocità di guida",
+ "entity_id": "Entità di input",
+ "max_speed_age": "Tempo massimo tra aggiornamenti del sensore di velocità",
+ "require_movement": "Richiedi movimento",
+ "show_unknown_as_0": "Mostra 0,0 quando la velocità è sconosciuta"
+ }
+ }
+ },
+ "error": {
+ "at_least_one_entity": "Devi selezionare almeno un'entità di input."
+ }
+ },
+ "services": {
+ "reload": {
+ "name": "Ricarica",
+ "description": "Ricarica Composito dalla configurazione YAML."
+ }
+ }
+}
diff --git a/custom_components/composite/translations/nl.json b/custom_components/composite/translations/nl.json
new file mode 100644
index 0000000..41cf657
--- /dev/null
+++ b/custom_components/composite/translations/nl.json
@@ -0,0 +1,174 @@
+{
+ "title": "Composiet",
+ "config": {
+ "step": {
+ "all_states": {
+ "title": "Gebruik Alle Staten",
+ "description": "Selecteer entiteiten waarvoor alle staten moeten worden gebruikt.\nMerk op dat dit alleen geldt voor entiteiten waarvan het brontype niet GPS is.",
+ "data": {
+ "entity": "Entiteit"
+ }
+ },
+ "end_driving_delay": {
+ "title": "Einde Rijden Vertraging",
+ "description": "Tijd om de staat Rijden vast te houden nadat de snelheid onder de ingestelde rijsnelheid komt.\n\nLaat leeg om direct terug te schakelen naar Afwezig. Indien gebruikt, is een goede startwaarde 2 minuten.",
+ "data": {
+ "end_driving_delay": "Vertraging einde rijden"
+ }
+ },
+ "ep_input_entity": {
+ "title": "Afbeeldingsentiteit",
+ "description": "Kies de entiteit waarvan de afbeelding gebruikt zal worden voor de composiet.\nDit mag ook leeg zijn.",
+ "data": {
+ "entity": "Entiteit"
+ }
+ },
+ "ep_local_file": {
+ "title": "Lokaal Afbeeldingsbestand",
+ "description": "Kies een lokaal bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.",
+ "data": {
+ "entity_picture": "Lokaal bestand"
+ }
+ },
+ "ep_menu": {
+ "title": "Composiet Entiteitsafbeelding",
+ "description": "Kies de bron van de entiteitsafbeelding van de composiet.\n\nHuidig: {cur_source}",
+ "menu_options": {
+ "all_states": "Huidige instelling behouden",
+ "ep_local_file": "Selecteer lokaal bestand",
+ "ep_input_entity": "Gebruik de afbeelding van een invoerentiteit",
+ "ep_none": "Geen entiteitsafbeelding gebruiken",
+ "ep_upload_file": "Een bestand uploaden"
+ }
+ },
+ "ep_upload_file": {
+ "title": "Afbeeldingsbestand Uploaden",
+ "description": "Upload een bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.",
+ "data": {
+ "entity_picture": "Afbeeldingsbestand"
+ }
+ },
+ "ep_warn": {
+ "title": "Map voor Bestandsupload Aangemaakt",
+ "description": "/local map ({local_dir}) is aangemaakt.\nHome Assistant moet mogelijk opnieuw worden gestart om geüploade bestanden bruikbaar te maken."
+ },
+ "name": {
+ "title": "Naam",
+ "data": {
+ "name": "Naam"
+ }
+ },
+ "options": {
+ "title": "Composietopties",
+ "data": {
+ "driving_speed": "Rijsnelheid",
+ "entity_id": "Invoerentiteiten",
+ "max_speed_age": "Maximale tijd tussen updates van snelheidsensor",
+ "require_movement": "Beweging vereisen",
+ "show_unknown_as_0": "Onbekende snelheid als 0.0 tonen"
+ }
+ }
+ },
+ "error": {
+ "at_least_one_entity": "Er moet minstens één invoerentiteit worden geselecteerd.",
+ "name_used": "Naam is al in gebruik."
+ }
+ },
+ "entity": {
+ "device_tracker": {
+ "tracker": {
+ "state": {
+ "driving": "Rijden"
+ },
+ "state_attributes": {
+ "battery_charging": {"name": "Batterij wordt opgeladen"},
+ "entities": {"name": "Gezien door entiteiten"},
+ "last_entity_id": {"name": "Laatste entiteit"},
+ "last_seen": {"name": "Laatst gezien"}
+ }
+ }
+ },
+ "sensor": {
+ "speed": {
+ "name": "{name} snelheid",
+ "state_attributes": {
+ "angle": {"name": "Hoek"},
+ "direction": {"name": "Richting"}
+ }
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "all_states": {
+ "title": "Gebruik Alle Staten",
+ "description": "Selecteer entiteiten waarvoor alle staten moeten worden gebruikt.\nMerk op dat dit alleen geldt voor entiteiten waarvan het brontype niet GPS is.",
+ "data": {
+ "entity": "Entiteit"
+ }
+ },
+ "end_driving_delay": {
+ "title": "Einde Rijden Vertraging",
+ "description": "Tijd om de staat Rijden vast te houden nadat de snelheid onder de ingestelde rijsnelheid komt.\n\nLaat leeg om direct terug te schakelen naar Afwezig. Indien gebruikt, is een goede startwaarde 2 minuten.",
+ "data": {
+ "end_driving_delay": "Vertraging einde rijden"
+ }
+ },
+ "ep_input_entity": {
+ "title": "Afbeeldingsentiteit",
+ "description": "Kies de entiteit waarvan de afbeelding gebruikt zal worden voor de composiet.\nDit mag ook leeg zijn.",
+ "data": {
+ "entity": "Entiteit"
+ }
+ },
+ "ep_local_file": {
+ "title": "Lokaal Afbeeldingsbestand",
+ "description": "Kies een lokaal bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.",
+ "data": {
+ "entity_picture": "Lokaal bestand"
+ }
+ },
+ "ep_menu": {
+ "title": "Composiet Entiteitsafbeelding",
+ "description": "Kies de bron van de entiteitsafbeelding van de composiet.\n\nHuidig: {cur_source}",
+ "menu_options": {
+ "all_states": "Huidige instelling behouden",
+ "ep_local_file": "Selecteer lokaal bestand",
+ "ep_input_entity": "Gebruik de afbeelding van een invoerentiteit",
+ "ep_none": "Geen entiteitsafbeelding gebruiken",
+ "ep_upload_file": "Een bestand uploaden"
+ }
+ },
+ "ep_upload_file": {
+ "title": "Afbeeldingsbestand Uploaden",
+ "description": "Upload een bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.",
+ "data": {
+ "entity_picture": "Afbeeldingsbestand"
+ }
+ },
+ "ep_warn": {
+ "title": "Map voor Bestandsupload Aangemaakt",
+ "description": "/local map ({local_dir}) is aangemaakt.\nHome Assistant moet mogelijk opnieuw worden gestart om geüploade bestanden bruikbaar te maken."
+ },
+ "options": {
+ "title": "Composietopties",
+ "data": {
+ "driving_speed": "Rijsnelheid",
+ "entity_id": "Invoerentiteiten",
+ "max_speed_age": "Maximale tijd tussen updates van snelheidsensor",
+ "require_movement": "Beweging vereisen",
+ "show_unknown_as_0": "Onbekende snelheid als 0.0 tonen"
+ }
+ }
+ },
+ "error": {
+ "at_least_one_entity": "Er moet minstens één invoerentiteit worden geselecteerd."
+ }
+ },
+ "services": {
+ "reload": {
+ "name": "Herladen",
+ "description": "Laadt de Composiet-integratie opnieuw vanuit de YAML-configuratie."
+ }
+ }
+}
diff --git a/hacs.json b/hacs.json
new file mode 100644
index 0000000..8814274
--- /dev/null
+++ b/hacs.json
@@ -0,0 +1,4 @@
+{
+ "name": "Composite Device Tracker",
+ "homeassistant": "2024.11.0"
+}
diff --git a/info.md b/info.md
new file mode 100644
index 0000000..d431cae
--- /dev/null
+++ b/info.md
@@ -0,0 +1,7 @@
+#
Composite Device Tracker
+
+This integration creates a composite `device_tracker` entity from one or more other entities. It will update whenever one of the watched entities updates, taking the "last seen" (and possibly GPS and other) data from the changing entity. The result can be a more accurate and up-to-date device tracker if the "input" entities update irregularly.
+
+It will also create a `sensor` entity that indicates the speed of the device.
+
+Currently any entity that has "GPS" attributes (`gps_accuracy` or `acc`, and either `latitude` & `longitude` or `lat` & `lon`), or any `device_tracker` entity with a `source_type` attribute of `bluetooth`, `bluetooth_le`, `gps` or `router`, or any `binary_sensor` entity, can be used as an input entity.