From 83714645af68e1003ea11c8f374962e467d7858f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 26 Dec 2019 10:30:14 -0600 Subject: [PATCH 01/96] Use Home location for non-GPS tracker without GPS data (#7) Also round last_seen to nearest second. --- custom_components/composite/device_tracker.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 0af7ae3..14fa991 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -1,7 +1,7 @@ """ A Device Tracker platform that combines one or more device trackers. """ -from datetime import datetime +from datetime import datetime, timedelta import logging import threading @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) -__version__ = '1.10.1' +__version__ = '1.11.0' CONF_TIME_AS = 'time_as' CONF_REQ_MOVEMENT = 'require_movement' @@ -71,6 +71,12 @@ def setup_scanner(hass, config, see, discovery_info=None): return True +def nearest_second(time): + """Round time to nearest second.""" + return (time.replace(microsecond=0) + + timedelta(seconds=0 if time.microsecond < 500000 else 1)) + + class CompositeScanner: def __init__(self, hass, config, see): self._hass = hass @@ -271,6 +277,15 @@ def _update_info(self, entity_id, old_state, new_state): # use it and make source_type gps. elif gps: source_type = SOURCE_TYPE_GPS + # Otherwise, if new state is 'home' and old state is not 'home' + # and no GPS data, then use HA's configured Home location and + # make source_type gps. + elif state == STATE_HOME and cur_state.state != STATE_HOME: + gps = ( + self._hass.config.latitude, + self._hass.config.longitude) + gps_accuracy = 0 + source_type = SOURCE_TYPE_GPS # Otherwise, don't use any GPS data, but set location_name to # new state. else: @@ -311,8 +326,7 @@ def _update_info(self, entity_id, old_state, new_state): 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) + self._dt_attr_from_utc(nearest_second(last_seen), tz) }) if charging is not None: attrs[ATTR_BATTERY_CHARGING] = charging From cd56852575c4e0922aead65a955ec78ad32a8ba7 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 2 Jan 2020 09:28:26 -0600 Subject: [PATCH 02/96] Fix bug in last release (#8) Also clean up some lint issues. --- custom_components/composite/device_tracker.py | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 14fa991..5abd35d 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -1,6 +1,4 @@ -""" -A Device Tracker platform that combines one or more device trackers. -""" +"""A Device Tracker platform that combines one or more device trackers.""" from datetime import datetime, timedelta import logging import threading @@ -16,10 +14,10 @@ from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.components.zone import async_active_zone 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_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID, + CONF_NAME, EVENT_HOMEASSISTANT_START, STATE_HOME, STATE_NOT_HOME, STATE_ON, + STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_state_change from homeassistant.util.async_ import run_callback_threadsafe @@ -67,6 +65,7 @@ def setup_scanner(hass, config, see, discovery_info=None): + """Set up a device scanner.""" CompositeScanner(hass, config, see) return True @@ -78,7 +77,10 @@ def nearest_second(time): class CompositeScanner: + """Composite device scanner.""" + def __init__(self, hass, config, see): + """Initialize CompositeScanner.""" self._hass = hass self._see = see entities = config[CONF_ENTITY_ID] @@ -140,16 +142,15 @@ 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): + 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) + 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) + def _dt_attr_from_utc(self, utc, tzone): + if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL] and tzone: + return utc.astimezone(tzone) if self._time_as in [TZ_LOCAL, TZ_DEVICE_LOCAL]: return dt_util.as_local(utc) return utc @@ -218,9 +219,12 @@ def _update_info(self, entity_id, old_state, new_state): 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): + 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' @@ -280,7 +284,10 @@ def _update_info(self, entity_id, old_state, new_state): # Otherwise, if new state is 'home' and old state is not 'home' # and no GPS data, then use HA's configured Home location and # make source_type gps. - elif state == STATE_HOME and cur_state.state != STATE_HOME: + elif ( + state == STATE_HOME + and (cur_state is None or cur_state.state != STATE_HOME) + ): gps = ( self._hass.config.latitude, self._hass.config.longitude) @@ -303,19 +310,19 @@ def _update_info(self, entity_id, old_state, new_state): 'For {} skipping update from {}: ' 'last_seen not newer than previous update ({} <= {})' .format(self._entity_id, entity_id, last_seen, - self._prev_seen)) + self._prev_seen)) return _LOGGER.debug('Updating %s from %s', self._entity_id, entity_id) - tz = None + tzone = 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) + tzone = dt_util.get_time_zone(tzname) attrs = {ATTR_TIME_ZONE: tzname or STATE_UNKNOWN} else: attrs = {} @@ -326,12 +333,12 @@ def _update_info(self, entity_id, old_state, new_state): if entity[ATTR_SOURCE_TYPE] is not None), ATTR_LAST_ENTITY_ID: entity_id, ATTR_LAST_SEEN: - self._dt_attr_from_utc(nearest_second(last_seen), tz) + self._dt_attr_from_utc(nearest_second(last_seen), tzone) }) 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) + gps=gps, gps_accuracy=gps_accuracy, battery=battery, + attributes=attrs, source_type=source_type) self._prev_seen = last_seen From b3398d299f141199dff171524783e587e24d32e4 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 2 Jan 2020 09:44:15 -0600 Subject: [PATCH 03/96] Fix version number (#9) --- custom_components/composite/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 5abd35d..3489a1f 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -__version__ = '1.11.0' +__version__ = '1.11.2' CONF_TIME_AS = 'time_as' CONF_REQ_MOVEMENT = 'require_movement' From 8793269e5f50ae839d30baeb612d8a3c6eeba534 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 27 Jan 2020 09:29:38 -0600 Subject: [PATCH 04/96] Add composite: tz_finder config option (#11) Bump version to 2.0.0. --- README.md | 25 ++++++++- custom_components/composite/__init__.py | 52 +++++++++++++++++++ custom_components/composite/const.py | 12 +++++ custom_components/composite/device_tracker.py | 19 ++----- custom_components/composite/manifest.json | 8 +-- 5 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 custom_components/composite/const.py diff --git a/README.md b/README.md index cab4cae..93cbc8c 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ See [HACS](https://github.com/custom-components/hacs). Alternatively, place a copy of: [`__init__.py`](custom_components/composite/__init__.py) at `/custom_components/composite/__init__.py` +[`const.py`](custom_components/composite/const.py) at `/custom_components/composite/const.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` @@ -37,13 +38,21 @@ where `` is your Home Assistant configuration directory. ### 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: +To determine time zone from GPS coordinates (see `time_as` configuration variable below) the package [timezonefinderL](https://pypi.org/project/timezonefinderL/) (by default) 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 ``` >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 +### `composite` integration + +- **tz_finder** (*Optional*): Specifies which `timezonefinder` package, and possibly version, to install. Must be formatted as required by `pip`. Default is `timezonefinderL==4.0.2`. Other common values: + +`timezonefinderL==2.0.1` +`timezonefinder` +`timezonefinder==4.2.0` +### `device_tracker` platform - **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`. @@ -84,6 +93,20 @@ source_type | Source of current location information: `binary_sensor`, `bluetoot 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 +### Example Full Config +```yaml +composite: + tz_finder: timezonefinderL==2.0.1 +device_tracker: + - platform: composite + name: me + time_as: device_or_local + require_movement: true + entity_id: + - device_tracker.platform1_me + - device_tracker.platform2_me +``` + ### Time zone examples 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. diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index d7b4788..c6899ce 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -1 +1,53 @@ """Composite Device Tracker.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.requirements import ( + async_process_requirements, RequirementsNotFound) +from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN +from homeassistant.const import CONF_PLATFORM +import homeassistant.helpers.config_validation as cv + +from .const import CONF_TIME_AS, DOMAIN, TZ_DEVICE_LOCAL, TZ_DEVICE_UTC + +__version__ = '2.0.0' + +CONF_TZ_FINDER = 'tz_finder' +DEFAULT_TZ_FINDER = 'timezonefinderL==4.0.2' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default=dict): vol.Schema({ + vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): + cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + if (any(conf[CONF_TIME_AS] in (TZ_DEVICE_UTC, TZ_DEVICE_LOCAL) + for conf in (config.get(DT_DOMAIN) or []) + if conf[CONF_PLATFORM] == DOMAIN)): + pkg = config[DOMAIN][CONF_TZ_FINDER] + try: + asyncio.run_coroutine_threadsafe( + async_process_requirements( + hass, '{}.{}'.format(DOMAIN, DT_DOMAIN), [pkg]), + hass.loop + ).result() + except RequirementsNotFound: + _LOGGER.debug('Process requirements failed: %s', pkg) + return False + else: + _LOGGER.debug('Process requirements suceeded: %s', pkg) + + if pkg.split('==')[0].strip().endswith('L'): + from timezonefinderL import TimezoneFinder + else: + from timezonefinder import TimezoneFinder + hass.data[DOMAIN] = TimezoneFinder() + + return True diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py new file mode 100644 index 0000000..bf42516 --- /dev/null +++ b/custom_components/composite/const.py @@ -0,0 +1,12 @@ +"""Constants for Composite Integration.""" +DOMAIN = 'composite' + +CONF_REQ_MOVEMENT = 'require_movement' +CONF_TIME_AS = 'time_as' + +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] diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 3489a1f..0abc14f 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -24,19 +24,11 @@ import homeassistant.util.dt as dt_util from homeassistant.util.location import distance -_LOGGER = logging.getLogger(__name__) - -__version__ = '1.11.2' +from .const import ( + CONF_REQ_MOVEMENT, CONF_TIME_AS, DOMAIN, TIME_AS_OPTS, TZ_DEVICE_LOCAL, + TZ_DEVICE_UTC, TZ_LOCAL, TZ_UTC) -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] +_LOGGER = logging.getLogger(__name__) ATTR_CHARGING = 'charging' ATTR_LAST_SEEN = 'last_seen' @@ -95,8 +87,7 @@ def __init__(self, hass, config, see): 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._tf = hass.data[DOMAIN] self._req_movement = config[CONF_REQ_MOVEMENT] self._lock = threading.Lock() self._prev_seen = None diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 845c828..379fae9 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -2,11 +2,7 @@ "domain": "composite", "name": "Composite", "documentation": "https://github.com/pnbruckner/homeassistant-config/blob/master/docs/composite.md", - "requirements": [ - "timezonefinderL==4.0.2" - ], + "requirements": [], "dependencies": [], - "codeowners": [ - "@pnbruckner" - ] + "codeowners": ["@pnbruckner"] } From e8d871d7baec48d07cc727d9938995792eb4a51f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 27 Jan 2020 10:29:08 -0600 Subject: [PATCH 05/96] Ignore first update if bad per input entity (#12) Bump version to 2.1.0 --- custom_components/composite/__init__.py | 2 +- custom_components/composite/device_tracker.py | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index c6899ce..ff7ce76 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -12,7 +12,7 @@ from .const import CONF_TIME_AS, DOMAIN, TZ_DEVICE_LOCAL, TZ_DEVICE_UTC -__version__ = '2.0.0' +__version__ = '2.1.0' CONF_TZ_FINDER = 'tz_finder' DEFAULT_TZ_FINDER = 'timezonefinderL==4.0.2' diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 0abc14f..847c1c7 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -35,7 +35,10 @@ ATTR_LAST_ENTITY_ID = 'last_entity_id' ATTR_TIME_ZONE = 'time_zone' +INACTIVE = 'inactive' +ACTIVE = 'active' WARNED = 'warned' +STATUS = 'status' SEEN = 'seen' SOURCE_TYPE = ATTR_SOURCE_TYPE DATA = 'data' @@ -79,7 +82,7 @@ def __init__(self, hass, config, see): self._entities = {} for entity_id in entities: self._entities[entity_id] = { - WARNED: False, + STATUS: INACTIVE, SEEN: None, SOURCE_TYPE: None, DATA: None} @@ -91,7 +94,6 @@ def __init__(self, hass, config, see): self._req_movement = config[CONF_REQ_MOVEMENT] self._lock = threading.Lock() self._prev_seen = None - self._init_complete = False self._remove = track_state_change( hass, entities, self._update_info) @@ -99,15 +101,10 @@ def __init__(self, hass, config, see): for entity_id in entities: self._update_info(entity_id, None, hass.states.get(entity_id)) - def init_complete(event): - self._init_complete = True - - 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]: + if self._entities[entity_id][STATUS] == WARNED: _LOGGER.error(msg) self._remove() self._entities.pop(entity_id) @@ -115,16 +112,17 @@ def _bad_entity(self, entity_id, message): 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: + # Only warn if this is not the first state change for the entity. + elif self._entities[entity_id][STATUS] == ACTIVE: _LOGGER.warning(msg) - self._entities[entity_id][WARNED] = True + self._entities[entity_id][STATUS] = WARNED else: _LOGGER.debug(msg) + self._entities[entity_id][STATUS] = ACTIVE def _good_entity(self, entity_id, seen, source_type, data): self._entities[entity_id].update({ - WARNED: False, + STATUS: ACTIVE, SEEN: seen, SOURCE_TYPE: source_type, DATA: data}) From 943f997616e285f6ac4f8ce7cb56f75075af1f86 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 15 Mar 2020 07:18:37 -0500 Subject: [PATCH 06/96] Fix per changes in device_tracker component (#15) --- README.md | 4 +--- custom_components/composite/device_tracker.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 93cbc8c..e1163ed 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,7 @@ If a watched device has a `battery` or `battery_level` attribute, that will be u The watched devices, and the composite device, should all have `track` set to `true`. -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.) - -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).) +It is 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).) ## Attributes diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 847c1c7..8258b5f 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -10,7 +10,6 @@ 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 from homeassistant.const import ( @@ -87,7 +86,7 @@ def __init__(self, hass, config, see): SOURCE_TYPE: None, DATA: None} self._dev_id = config[CONF_NAME] - self._entity_id = ENTITY_ID_FORMAT.format(self._dev_id) + self._entity_id = f"device_tracker.{self._dev_id}" self._time_as = config[CONF_TIME_AS] if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: self._tf = hass.data[DOMAIN] From 4fa9caee5067032efa866365940828fd380e7f61 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 18 Apr 2020 08:59:35 -0500 Subject: [PATCH 07/96] Fix internal version number, bump to 2.1.2 --- custom_components/composite/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index ff7ce76..5d8d4fa 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -12,7 +12,7 @@ from .const import CONF_TIME_AS, DOMAIN, TZ_DEVICE_LOCAL, TZ_DEVICE_UTC -__version__ = '2.1.0' +__version__ = '2.1.2' CONF_TZ_FINDER = 'tz_finder' DEFAULT_TZ_FINDER = 'timezonefinderL==4.0.2' From 54e967f87dce0098b9d3796c05135ccadaa5f4c9 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 14 May 2020 09:44:07 -0500 Subject: [PATCH 08/96] Update README.md --- README.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e1163ed..32f5ea4 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@ This platform creates a composite device tracker from one or more other device t Currently device_tracker's with a source_type of bluetooth, bluetooth_le, gps or router are supported, as well as binary_sensor's. -## Installation - -Follow either the HACS or manual installation instructions below. +Follow the installation instructions below. Then add the desired configuration. Here is an example of a typical configuration: ```yaml @@ -19,13 +17,9 @@ device_tracker: - device_tracker.platform2_me ``` -### HACS - -See [HACS](https://github.com/custom-components/hacs). - -### Manual +## Installation -Alternatively, place a copy of: +Place a copy of: [`__init__.py`](custom_components/composite/__init__.py) at `/custom_components/composite/__init__.py` [`const.py`](custom_components/composite/const.py) at `/custom_components/composite/const.py` From 01057551a306f594779f1cf9fbbc355284c8c4f2 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 3 Mar 2021 08:43:41 -0600 Subject: [PATCH 09/96] Move version to manifest.json per new integration requirements (#24) Also fix documentation link and add issue tracker link. --- custom_components/composite/__init__.py | 2 -- custom_components/composite/manifest.json | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index 5d8d4fa..abea1b8 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -12,8 +12,6 @@ from .const import CONF_TIME_AS, DOMAIN, TZ_DEVICE_LOCAL, TZ_DEVICE_UTC -__version__ = '2.1.2' - CONF_TZ_FINDER = 'tz_finder' DEFAULT_TZ_FINDER = 'timezonefinderL==4.0.2' diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 379fae9..1ad7e5e 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,9 @@ { "domain": "composite", "name": "Composite", - "documentation": "https://github.com/pnbruckner/homeassistant-config/blob/master/docs/composite.md", + "version": "2.1.3", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", + "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], "codeowners": ["@pnbruckner"] From d7d71b81d125e57c61f5d3a3cbce19f71d39e1bc Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 4 Mar 2021 12:40:17 -0600 Subject: [PATCH 10/96] Format with black (#25) --- custom_components/composite/__init__.py | 40 ++-- custom_components/composite/const.py | 14 +- custom_components/composite/device_tracker.py | 220 +++++++++++------- 3 files changed, 163 insertions(+), 111 deletions(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index abea1b8..d6fe53e 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -4,45 +4,49 @@ import voluptuous as vol -from homeassistant.requirements import ( - async_process_requirements, RequirementsNotFound) +from homeassistant.requirements import async_process_requirements, RequirementsNotFound from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN from homeassistant.const import CONF_PLATFORM import homeassistant.helpers.config_validation as cv from .const import CONF_TIME_AS, DOMAIN, TZ_DEVICE_LOCAL, TZ_DEVICE_UTC -CONF_TZ_FINDER = 'tz_finder' -DEFAULT_TZ_FINDER = 'timezonefinderL==4.0.2' +CONF_TZ_FINDER = "tz_finder" +DEFAULT_TZ_FINDER = "timezonefinderL==4.0.2" -CONFIG_SCHEMA = vol.Schema({ - vol.Optional(DOMAIN, default=dict): vol.Schema({ - vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): - cv.string, - }), -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN, default=dict): vol.Schema( + {vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): cv.string} + ), + }, + extra=vol.ALLOW_EXTRA, +) _LOGGER = logging.getLogger(__name__) def setup(hass, config): - if (any(conf[CONF_TIME_AS] in (TZ_DEVICE_UTC, TZ_DEVICE_LOCAL) - for conf in (config.get(DT_DOMAIN) or []) - if conf[CONF_PLATFORM] == DOMAIN)): + if any( + conf[CONF_TIME_AS] in (TZ_DEVICE_UTC, TZ_DEVICE_LOCAL) + for conf in (config.get(DT_DOMAIN) or []) + if conf[CONF_PLATFORM] == DOMAIN + ): pkg = config[DOMAIN][CONF_TZ_FINDER] try: asyncio.run_coroutine_threadsafe( async_process_requirements( - hass, '{}.{}'.format(DOMAIN, DT_DOMAIN), [pkg]), - hass.loop + hass, "{}.{}".format(DOMAIN, DT_DOMAIN), [pkg] + ), + hass.loop, ).result() except RequirementsNotFound: - _LOGGER.debug('Process requirements failed: %s', pkg) + _LOGGER.debug("Process requirements failed: %s", pkg) return False else: - _LOGGER.debug('Process requirements suceeded: %s', pkg) + _LOGGER.debug("Process requirements suceeded: %s", pkg) - if pkg.split('==')[0].strip().endswith('L'): + if pkg.split("==")[0].strip().endswith("L"): from timezonefinderL import TimezoneFinder else: from timezonefinder import TimezoneFinder diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index bf42516..33f2c2d 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -1,12 +1,12 @@ """Constants for Composite Integration.""" -DOMAIN = 'composite' +DOMAIN = "composite" -CONF_REQ_MOVEMENT = 'require_movement' -CONF_TIME_AS = 'time_as' +CONF_REQ_MOVEMENT = "require_movement" +CONF_TIME_AS = "time_as" -TZ_UTC = 'utc' -TZ_LOCAL = 'local' -TZ_DEVICE_UTC = 'device_or_utc' -TZ_DEVICE_LOCAL = 'device_or_local' +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] diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 8258b5f..668de16 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -7,16 +7,31 @@ 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) + ATTR_BATTERY, + ATTR_SOURCE_TYPE, + PLATFORM_SCHEMA, + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_BLUETOOTH_LE, + SOURCE_TYPE_GPS, + SOURCE_TYPE_ROUTER, +) from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.components.zone import async_active_zone from homeassistant.const import ( - ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, - ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, 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_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_ENTITY_ID, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + STATE_HOME, + STATE_NOT_HOME, + STATE_ON, + STATE_UNKNOWN, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_state_change from homeassistant.util.async_ import run_callback_threadsafe @@ -24,38 +39,49 @@ from homeassistant.util.location import distance from .const import ( - CONF_REQ_MOVEMENT, CONF_TIME_AS, DOMAIN, TIME_AS_OPTS, TZ_DEVICE_LOCAL, - TZ_DEVICE_UTC, TZ_LOCAL, TZ_UTC) + CONF_REQ_MOVEMENT, + CONF_TIME_AS, + DOMAIN, + TIME_AS_OPTS, + TZ_DEVICE_LOCAL, + TZ_DEVICE_UTC, + TZ_LOCAL, + TZ_UTC, +) _LOGGER = logging.getLogger(__name__) -ATTR_CHARGING = 'charging' -ATTR_LAST_SEEN = 'last_seen' -ATTR_LAST_ENTITY_ID = 'last_entity_id' -ATTR_TIME_ZONE = 'time_zone' +ATTR_CHARGING = "charging" +ATTR_LAST_SEEN = "last_seen" +ATTR_LAST_ENTITY_ID = "last_entity_id" +ATTR_TIME_ZONE = "time_zone" -INACTIVE = 'inactive' -ACTIVE = 'active' -WARNED = 'warned' -STATUS = 'status' -SEEN = 'seen' +INACTIVE = "inactive" +ACTIVE = "active" +WARNED = "warned" +STATUS = "status" +SEEN = "seen" SOURCE_TYPE = ATTR_SOURCE_TYPE -DATA = 'data' +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, -}) + 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): @@ -66,8 +92,9 @@ def setup_scanner(hass, config, see, discovery_info=None): def nearest_second(time): """Round time to nearest second.""" - return (time.replace(microsecond=0) + - timedelta(seconds=0 if time.microsecond < 500000 else 1)) + return time.replace(microsecond=0) + timedelta( + seconds=0 if time.microsecond < 500000 else 1 + ) class CompositeScanner: @@ -84,7 +111,8 @@ def __init__(self, hass, config, see): STATUS: INACTIVE, SEEN: None, SOURCE_TYPE: None, - DATA: None} + DATA: None, + } self._dev_id = config[CONF_NAME] self._entity_id = f"device_tracker.{self._dev_id}" self._time_as = config[CONF_TIME_AS] @@ -94,14 +122,13 @@ def __init__(self, hass, config, see): self._lock = threading.Lock() self._prev_seen = None - self._remove = track_state_change( - hass, entities, self._update_info) + 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)) def _bad_entity(self, entity_id, message): - msg = '{} {}'.format(entity_id, message) + msg = "{} {}".format(entity_id, message) # Has there already been a warning for this entity? if self._entities[entity_id][STATUS] == WARNED: _LOGGER.error(msg) @@ -110,7 +137,8 @@ def _bad_entity(self, entity_id, message): # Are there still any entities to watch? if len(self._entities): self._remove = track_state_change( - self._hass, self._entities.keys(), self._update_info) + self._hass, self._entities.keys(), self._update_info + ) # Only warn if this is not the first state change for the entity. elif self._entities[entity_id][STATUS] == ACTIVE: _LOGGER.warning(msg) @@ -120,11 +148,9 @@ def _bad_entity(self, entity_id, message): self._entities[entity_id][STATUS] = ACTIVE def _good_entity(self, entity_id, seen, source_type, data): - self._entities[entity_id].update({ - STATUS: ACTIVE, - SEEN: seen, - SOURCE_TYPE: source_type, - DATA: data}) + self._entities[entity_id].update( + {STATUS: ACTIVE, SEEN: seen, SOURCE_TYPE: source_type, DATA: data} + ) def _use_non_gps_data(self, state): if state == STATE_HOME: @@ -132,9 +158,11 @@ def _use_non_gps_data(self, state): 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) + 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, tzone): if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL] and tzone: @@ -164,20 +192,24 @@ def _update_info(self, entity_id, old_state, new_state): 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') + self._bad_entity(entity_id, "last_seen went backwards") return # Try to get GPS and battery data. try: - gps = (new_state.attributes[ATTR_LATITUDE], - new_state.attributes[ATTR_LONGITUDE]) + 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)) + ATTR_BATTERY, new_state.attributes.get(ATTR_BATTERY_LEVEL) + ) charging = new_state.attributes.get( - ATTR_BATTERY_CHARGING, new_state.attributes.get(ATTR_CHARGING)) + ATTR_BATTERY_CHARGING, new_state.attributes.get(ATTR_CHARGING) + ) # Don't use location_name unless we have to. location_name = None @@ -192,11 +224,10 @@ def _update_info(self, entity_id, old_state, new_state): if source_type == SOURCE_TYPE_GPS: # GPS coordinates and accuracy are required. if gps is None: - self._bad_entity(entity_id, 'missing gps attributes') + self._bad_entity(entity_id, "missing gps attributes") return if gps_accuracy is None: - self._bad_entity(entity_id, - 'missing gps_accuracy attribute') + self._bad_entity(entity_id, "missing gps_accuracy attribute") return new_data = gps, gps_accuracy @@ -208,15 +239,15 @@ def _update_info(self, entity_id, old_state, new_state): 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 + 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)) + "For {} skipping update from {}: " + "not enough movement".format(self._entity_id, entity_id) + ) return elif source_type in SOURCE_TYPE_NON_GPS: @@ -245,9 +276,17 @@ def _update_info(self, entity_id, old_state, new_state): 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) + 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 @@ -272,13 +311,10 @@ def _update_info(self, entity_id, old_state, new_state): # Otherwise, if new state is 'home' and old state is not 'home' # and no GPS data, then use HA's configured Home location and # make source_type gps. - elif ( - state == STATE_HOME - and (cur_state is None or cur_state.state != STATE_HOME) + elif state == STATE_HOME and ( + cur_state is None or cur_state.state != STATE_HOME ): - gps = ( - self._hass.config.latitude, - self._hass.config.longitude) + gps = (self._hass.config.latitude, self._hass.config.longitude) gps_accuracy = 0 source_type = SOURCE_TYPE_GPS # Otherwise, don't use any GPS data, but set location_name to @@ -288,20 +324,21 @@ def _update_info(self, entity_id, old_state, new_state): else: self._bad_entity( - entity_id, - 'unsupported source_type: {}'.format(source_type)) + entity_id, "unsupported source_type: {}".format(source_type) + ) return # 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)) + "For {} skipping update from {}: " + "last_seen not newer than previous update ({} <= {})".format( + self._entity_id, entity_id, last_seen, self._prev_seen + ) + ) return - _LOGGER.debug('Updating %s from %s', self._entity_id, entity_id) + _LOGGER.debug("Updating %s from %s", self._entity_id, entity_id) tzone = None if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: @@ -315,18 +352,29 @@ def _update_info(self, entity_id, old_state, 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(nearest_second(last_seen), tzone) - }) + 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( + nearest_second(last_seen), tzone + ), + } + ) 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._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 From 33cacf24233b1871c2fb3037a0c99ee6cfb7378e Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 5 Mar 2021 08:40:36 -0600 Subject: [PATCH 11/96] Add support for TimezoneFinderL class from timezonefinder (#26) --- README.md | 9 ++++++++- custom_components/composite/__init__.py | 21 ++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 32f5ea4..8f683ad 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,13 @@ sudo apt install libatlas3-base `timezonefinderL==2.0.1` `timezonefinder` +`timezonefinder<6` `timezonefinder==4.2.0` + +- **tz_finder_class** (*Optional*): Specifies which class to use. Only applies when using `timezonefinder` package. Valid options are `TimezoneFinder` and `TimezoneFinderL`. The default is `TimezoneFinder`. + +>Note: Starting with release 4.4.0 the `timezonefinder` package provides two classes to choose from: the original `TimezoneFinder` class, and a new class named `TimezoneFinderL`, which effectively replaces the functionality of the `timezonefinderL` package. + ### `device_tracker` platform - **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. @@ -88,7 +94,8 @@ time_zone | The name of the time zone in which the device is located, or `unknow ### Example Full Config ```yaml composite: - tz_finder: timezonefinderL==2.0.1 + tz_finder: timezonefinder<6 + tz_finder_class: TimezoneFinderL device_tracker: - platform: composite name: me diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index d6fe53e..f5630c5 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -13,11 +13,18 @@ CONF_TZ_FINDER = "tz_finder" DEFAULT_TZ_FINDER = "timezonefinderL==4.0.2" +CONF_TZ_FINDER_CLASS = "tz_finder_class" +TZ_FINDER_CLASS_OPTS = ["TimezoneFinder", "TimezoneFinderL"] CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=dict): vol.Schema( - {vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): cv.string} + { + vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): cv.string, + vol.Optional( + CONF_TZ_FINDER_CLASS, default=TZ_FINDER_CLASS_OPTS[0] + ): vol.In(TZ_FINDER_CLASS_OPTS), + } ), }, extra=vol.ALLOW_EXTRA, @@ -48,8 +55,16 @@ def setup(hass, config): if pkg.split("==")[0].strip().endswith("L"): from timezonefinderL import TimezoneFinder - else: + + tf = TimezoneFinder() + elif config[DOMAIN][CONF_TZ_FINDER_CLASS] == "TimezoneFinder": from timezonefinder import TimezoneFinder - hass.data[DOMAIN] = TimezoneFinder() + + tf = TimezoneFinder() + else: + from timezonefinder import TimezoneFinderL + + tf = TimezoneFinderL() + hass.data[DOMAIN] = tf return True From 8a403e680bf3695ece7120bb2666de19d38382e5 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 5 Mar 2021 08:42:34 -0600 Subject: [PATCH 12/96] Bump version to 2.2.0 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 1ad7e5e..9707893 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,7 @@ { "domain": "composite", "name": "Composite", - "version": "2.1.3", + "version": "2.2.0", "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], From 3e9cc0dee3e58f0fd659257e36ee66631c537b6c Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 6 May 2022 10:59:34 -0500 Subject: [PATCH 13/96] Add note about HACS to readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8f683ad..23a0faf 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ device_tracker: ``` ## Installation +### Manual Place a copy of: @@ -30,6 +31,9 @@ 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. +### With HACS +You can use [HACS](https://hacs.xyz/) to manage installation and updates by adding this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/) and then searching for and installing the "Composite" integration. + ### 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/) (by default) 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: From fc7b92bf4229dcd403d2f2ab6e082ab25a625056 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 2 Aug 2022 09:57:18 -0500 Subject: [PATCH 14/96] Ignore unavailable and unknown states (#31) Some device trackers (especially the newer entity-based variety, like Life360 just became in 2022.7) and binary sensors can temporarily have unavailable or unknown states. If this is true on two consecutive updates for an entity the composite tracker will stop listening to it. This change causes the composite tracker to ignore unavailable and unknown states of the input entities, similar to what the Person integration does. --- custom_components/composite/device_tracker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 668de16..6698530 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -30,6 +30,7 @@ STATE_HOME, STATE_NOT_HOME, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) import homeassistant.helpers.config_validation as cv @@ -172,7 +173,7 @@ def _dt_attr_from_utc(self, utc, tzone): return utc def _update_info(self, entity_id, old_state, new_state): - if new_state is None: + if new_state is None or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): return with self._lock: From ebaff62c5947b0ebfa74941d3cd43c5fe50f1b5c Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 2 Aug 2022 09:58:20 -0500 Subject: [PATCH 15/96] Bump version to 2.2.1 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 9707893..07aa149 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,7 @@ { "domain": "composite", "name": "Composite", - "version": "2.2.0", + "version": "2.2.1", "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], From e3cef9614c7185e77eb76b7a12f0f731fdb1b672 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 13 Oct 2022 10:25:20 -0500 Subject: [PATCH 16/96] Add option to use all states of an input entity (#28) --- .gitignore | 1 + README.md | 8 ++- custom_components/composite/const.py | 2 + custom_components/composite/device_tracker.py | 56 +++++++++++++++---- 4 files changed, 55 insertions(+), 12 deletions(-) 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 23a0faf..89d86be 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ device_tracker: entity_id: - device_tracker.platform1_me - device_tracker.platform2_me + - binary_sensor.i_am_home ``` ## Installation @@ -58,7 +59,7 @@ sudo apt install libatlas3-base ### `device_tracker` platform -- **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. +- **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. Another option is to specify a dictionary with `entity` specifying the entity ID, and `all_states` specifying a boolean value that controls whether or not to use all states of the entity (rather than just the "Home" state, which is the default.) - **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. @@ -67,7 +68,7 @@ sudo apt install libatlas3-base 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. -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'. +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'. 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. @@ -108,6 +109,9 @@ device_tracker: entity_id: - device_tracker.platform1_me - device_tracker.platform2_me + - device_tracker.router_my_device + - entity: binary_sensor.i_am_home + all_states: true ``` ### Time zone examples diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index 33f2c2d..714570c 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -1,6 +1,8 @@ """Constants for Composite Integration.""" DOMAIN = "composite" +CONF_ALL_STATES = "all_states" +CONF_ENTITY = "entity" CONF_REQ_MOVEMENT = "require_movement" CONF_TIME_AS = "time_as" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 6698530..437c92b 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -9,6 +9,7 @@ from homeassistant.components.device_tracker import ( ATTR_BATTERY, ATTR_SOURCE_TYPE, + DOMAIN as DT_DOMAIN, PLATFORM_SCHEMA, SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE, @@ -26,7 +27,6 @@ ATTR_LONGITUDE, CONF_ENTITY_ID, CONF_NAME, - EVENT_HOMEASSISTANT_START, STATE_HOME, STATE_NOT_HOME, STATE_ON, @@ -40,6 +40,8 @@ from homeassistant.util.location import distance from .const import ( + CONF_ALL_STATES, + CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_TIME_AS, DOMAIN, @@ -47,7 +49,6 @@ TZ_DEVICE_LOCAL, TZ_DEVICE_UTC, TZ_LOCAL, - TZ_UTC, ) _LOGGER = logging.getLogger(__name__) @@ -60,6 +61,7 @@ INACTIVE = "inactive" ACTIVE = "active" WARNED = "warned" +USE_ALL_STATES = "use_all_states" STATUS = "status" SEEN = "seen" SOURCE_TYPE = ATTR_SOURCE_TYPE @@ -75,10 +77,40 @@ SOURCE_TYPE_ROUTER, ) + +def _entities(entities): + result = [] + for entity in entities: + if isinstance(entity, dict): + result.append(entity) + else: + result.append( + { + CONF_ENTITY: entity, + CONF_ALL_STATES: False, + } + ) + return result + + +ENTITIES = vol.All( + cv.ensure_list, + [ + vol.Any( + { + vol.Required(CONF_ENTITY): cv.entity_id, + vol.Required(CONF_ALL_STATES): cv.boolean, + }, + cv.entity_id, + msg="Expected an entity ID", + ) + ], + _entities, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_NAME): cv.slugify, - vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ENTITY_ID): ENTITIES, vol.Optional(CONF_TIME_AS, default=TIME_AS_OPTS[0]): vol.In(TIME_AS_OPTS), vol.Optional(CONF_REQ_MOVEMENT, default=False): cv.boolean, } @@ -107,15 +139,19 @@ def __init__(self, hass, config, see): self._see = see entities = config[CONF_ENTITY_ID] self._entities = {} - for entity_id in entities: + entity_ids = [] + for entity in entities: + entity_id = entity[CONF_ENTITY] self._entities[entity_id] = { + USE_ALL_STATES: entity[CONF_ALL_STATES], STATUS: INACTIVE, SEEN: None, SOURCE_TYPE: None, DATA: None, } + entity_ids.append(entity_id) self._dev_id = config[CONF_NAME] - self._entity_id = f"device_tracker.{self._dev_id}" + self._entity_id = f"{DT_DOMAIN}.{self._dev_id}" self._time_as = config[CONF_TIME_AS] if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: self._tf = hass.data[DOMAIN] @@ -123,9 +159,9 @@ def __init__(self, hass, config, see): self._lock = threading.Lock() self._prev_seen = None - self._remove = track_state_change(hass, entities, self._update_info) + self._remove = track_state_change(hass, entity_ids, self._update_info) - for entity_id in entities: + for entity_id in entity_ids: self._update_info(entity_id, None, hass.states.get(entity_id)) def _bad_entity(self, entity_id, message): @@ -153,8 +189,8 @@ def _good_entity(self, entity_id, seen, source_type, data): {STATUS: ACTIVE, SEEN: seen, SOURCE_TYPE: source_type, DATA: data} ) - def _use_non_gps_data(self, state): - if state == STATE_HOME: + def _use_non_gps_data(self, entity_id, state): + if state == STATE_HOME or self._entities[entity_id][USE_ALL_STATES]: return True entities = self._entities.values() if any(entity[SOURCE_TYPE] == SOURCE_TYPE_GPS for entity in entities): @@ -262,7 +298,7 @@ def _update_info(self, entity_id, old_state, new_state): self._good_entity(entity_id, last_seen, source_type, state) - if not self._use_non_gps_data(state): + if not self._use_non_gps_data(entity_id, state): return # Don't use new GPS data if it's not complete. From 9e1892f12b24e1d7e6a07555cc1aa5762863566f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 13 Oct 2022 10:26:33 -0500 Subject: [PATCH 17/96] Bump version to 2.3.0 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 07aa149..c94fc2b 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,7 @@ { "domain": "composite", "name": "Composite", - "version": "2.2.1", + "version": "2.3.0", "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], From 52dc1b89c93f8fc272b963e90cb42bbe702a9728 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 22 Nov 2022 17:02:26 -0600 Subject: [PATCH 18/96] Convert to entity-based trackers (#34) Deprecate support for "legacy" trackers. Create persistent notification to change config from device_tracker -> composite. Support HomeAssistant version 2021.12 or newer & Python 3.9 or newer. Add default_options. Add entity use_picture option. Restore state after HA restart. --- README.md | 104 +++- custom_components/composite/__init__.py | 203 ++++++- custom_components/composite/config_flow.py | 37 ++ custom_components/composite/const.py | 9 + custom_components/composite/device_tracker.py | 554 ++++++++++++++---- custom_components/composite/manifest.json | 4 +- 6 files changed, 750 insertions(+), 161 deletions(-) create mode 100644 custom_components/composite/config_flow.py diff --git a/README.md b/README.md index 89d86be..b93235c 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,50 @@ Follow the installation instructions below. Then add the desired configuration. Here is an example of a typical configuration: ```yaml -device_tracker: - - platform: composite - name: me - time_as: device_or_local - entity_id: - - device_tracker.platform1_me - - device_tracker.platform2_me - - binary_sensor.i_am_home +composite: + trackers: + - name: Me + time_as: device_or_local + entity_id: + - device_tracker.platform1_me + - device_tracker.platform2_me + - binary_sensor.i_am_home ``` +## Legacy vs entity-based implementation + +When this integration was originally created the +[Device Tracker](https://www.home-assistant.io/integrations/device_tracker/) +component worked differently than it does today. +That older implementation is now referred to as the "legacy" implementation, +and is the one that creates and uses the `known_devices.yaml` file in HA's configuration folder. + +Starting with the 2.4.0 release this integration now uses the newer entity-based implementation. +That implementation stores configuration and entity settings in HA's `.storage` folder, +and supports reconfiguring those items via the Integrations and Entities pages in the UI. +The initial configuration, though, is still done via YAML, and is "imported" and will show up +on the Integrations UI page as such. +In the future the integration will likely allow adding & fully reconfiguring composite trackers +via the UI. + +To allow for a smoother transition, the integration currently still supports the older, +legacy implementation as well. If it sees entries under `device_tracker`, it will still create the +entities as before, but it will issue a warning and a persistent notification that the configuration +has changed and suggest how to edit your configuration accordingly. + +At some point (i.e., in an upcoming 3.0.0 release) legacy support will be removed. + ## Installation +### Versions + +This custom integration supports HomeAssistant versions 2021.12 or newer, using Python 3.9 or newer. + ### Manual Place a copy of: [`__init__.py`](custom_components/composite/__init__.py) at `/custom_components/composite/__init__.py` +[`config_flow.py`](custom_components/composite/config_flow.py) at `/custom_components/composite/config_flow.py` [`const.py`](custom_components/composite/const.py) at `/custom_components/composite/const.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` @@ -33,7 +61,7 @@ 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. ### With HACS -You can use [HACS](https://hacs.xyz/) to manage installation and updates by adding this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/) and then searching for and installing the "Composite" integration. +You can use [HACS](https://hacs.xyz/) to manage installation and updates by adding this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/). ### numpy on Raspberry Pi @@ -44,7 +72,13 @@ sudo apt install libatlas3-base >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 -### `composite` integration + +- **default_options** (*Optional*): Defines default values for corresponding options under **trackers**. + - **require_movement** (*Optional*): Default is `false`. + - **time_as** (*Optional*): Default is `utc`. + +- **trackers** (*Optional*): The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). +NOTE: Once legacy support is removed, this variable, with at least one entry, will become required. - **tz_finder** (*Optional*): Specifies which `timezonefinder` package, and possibly version, to install. Must be formatted as required by `pip`. Default is `timezonefinderL==4.0.2`. Other common values: @@ -57,12 +91,19 @@ sudo apt install libatlas3-base >Note: Starting with release 4.4.0 the `timezonefinder` package provides two classes to choose from: the original `TimezoneFinder` class, and a new class named `TimezoneFinderL`, which effectively replaces the functionality of the `timezonefinderL` package. -### `device_tracker` platform +### 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 an entity ID of `device_tracker.my_name`. +- **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. +- **time_as** (*Optional*): One of `utc`, `local`, `device_or_utc` or `device_or_local`. `utc` 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_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. Another option is to specify a dictionary with `entity` specifying the entity ID, and `all_states` specifying a boolean value that controls whether or not to use all states of the entity (rather than just the "Home" state, which is the default.) -- **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. ## Watched device notes @@ -76,17 +117,18 @@ If a watched device has a `battery` or `battery_level` attribute, that will be u ## known_devices.yaml -The watched devices, and the composite device, should all have `track` set to `true`. +NOTE: This only applies to "legacy" tracker devices. -It is 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).) +The watched devices, and the composite device, should all have `track` set to `true`. ## 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. +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. @@ -101,17 +143,25 @@ time_zone | The name of the time zone in which the device is located, or `unknow composite: tz_finder: timezonefinder<6 tz_finder_class: TimezoneFinderL -device_tracker: - - platform: composite - name: me + default_options: time_as: device_or_local require_movement: true - entity_id: - - device_tracker.platform1_me - - device_tracker.platform2_me - - device_tracker.router_my_device - - entity: binary_sensor.i_am_home - all_states: true + trackers: + - name: Me + time_as: local + 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 + entity_id: + entity: device_tracker.platform_wife + use_picture: true ``` ### Time zone examples diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index f5630c5..de91ef4 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -1,31 +1,108 @@ """Composite Device Tracker.""" -import asyncio +from __future__ import annotations + import logging +from typing import Any, cast import voluptuous as vol +from homeassistant.config import load_yaml_config_file +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.requirements import async_process_requirements, RequirementsNotFound from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN -from homeassistant.const import CONF_PLATFORM +from homeassistant.components.device_tracker.legacy import YAML_DEVICES +from homeassistant.components.persistent_notification import ( + async_create as pn_async_create, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import slugify -from .const import CONF_TIME_AS, DOMAIN, TZ_DEVICE_LOCAL, TZ_DEVICE_UTC +from .const import ( + CONF_DEFAULT_OPTIONS, + CONF_REQ_MOVEMENT, + CONF_TIME_AS, + CONF_TRACKERS, + DATA_LEGACY_WARNED, + DATA_TF, + DEF_REQ_MOVEMENT, + DEF_TIME_AS, + DOMAIN, + TIME_AS_OPTS, + TZ_DEVICE_LOCAL, + TZ_DEVICE_UTC, +) +from .config_flow import split_conf +from .device_tracker import COMPOSITE_TRACKER CONF_TZ_FINDER = "tz_finder" DEFAULT_TZ_FINDER = "timezonefinderL==4.0.2" CONF_TZ_FINDER_CLASS = "tz_finder_class" +PLATFORMS = [Platform.DEVICE_TRACKER] TZ_FINDER_CLASS_OPTS = ["TimezoneFinder", "TimezoneFinderL"] +TRACKER = COMPOSITE_TRACKER.copy() +TRACKER.update({vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ID): cv.slugify}) + + +def _tracker_ids( + value: list[dict[vol.Required | vol.Optional, Any]] +) -> list[dict[vol.Required | vol.Optional, Any]]: + """Determine tracker ID.""" + ids: list[str] = [] + for conf in value: + if CONF_ID not in conf: + name: str = conf[CONF_NAME] + if name == slugify(name): + conf[CONF_ID] = name + conf[CONF_NAME] = name.replace("_", " ").title() + else: + conf[CONF_ID] = cv.slugify(conf[CONF_NAME]) + ids.append(cast(str, conf[CONF_ID])) + if len(ids) != len(set(ids)): + raise vol.Invalid("id's must be unique") + return value + + +def _defaults(config: dict) -> dict: + """Apply default options to trackers.""" + def_time_as = config[CONF_DEFAULT_OPTIONS][CONF_TIME_AS] + def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT] + for tracker in config[CONF_TRACKERS]: + tracker[CONF_TIME_AS] = tracker.get(CONF_TIME_AS, def_time_as) + tracker[CONF_REQ_MOVEMENT] = tracker.get(CONF_REQ_MOVEMENT, def_req_mv) + return config + CONFIG_SCHEMA = vol.Schema( { - vol.Optional(DOMAIN, default=dict): vol.Schema( - { - vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): cv.string, - vol.Optional( - CONF_TZ_FINDER_CLASS, default=TZ_FINDER_CLASS_OPTS[0] - ): vol.In(TZ_FINDER_CLASS_OPTS), - } - ), + vol.Optional(DOMAIN, default=dict): vol.All( + vol.Schema( + { + vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): cv.string, + vol.Optional( + CONF_TZ_FINDER_CLASS, default=TZ_FINDER_CLASS_OPTS[0] + ): vol.In(TZ_FINDER_CLASS_OPTS), + vol.Optional(CONF_DEFAULT_OPTIONS, default=dict): vol.Schema( + { + vol.Optional(CONF_TIME_AS, default=DEF_TIME_AS): vol.In( + TIME_AS_OPTS + ), + vol.Optional( + CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT + ): cv.boolean, + } + ), + vol.Optional(CONF_TRACKERS, default=list): vol.All( + cv.ensure_list, [TRACKER], _tracker_ids + ), + } + ), + _defaults, + ) }, extra=vol.ALLOW_EXTRA, ) @@ -33,20 +110,85 @@ _LOGGER = logging.getLogger(__name__) -def setup(hass, config): - if any( - conf[CONF_TIME_AS] in (TZ_DEVICE_UTC, TZ_DEVICE_LOCAL) - for conf in (config.get(DT_DOMAIN) or []) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Setup composite integration.""" + hass.data[DOMAIN] = {DATA_LEGACY_WARNED: False} + + # Get a list of all the object IDs in known_devices.yaml to see if any were created + # when this integration was a legacy device tracker, or would otherwise conflict + # with IDs in our config. + try: + legacy_devices: dict[str, dict] = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (HomeAssistantError, FileNotFoundError): + legacy_devices = {} + try: + legacy_ids = [ + cv.slugify(id) + for id, dev in legacy_devices.items() + if cv.boolean(dev.get("track", False)) + ] + except vol.Invalid: + legacy_ids = [] + + # Get all existing composite config entries. + cfg_entries = { + cast(str, entry.data[CONF_ID]): entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + # For each tracker config, see if it conflicts with a known_devices.yaml entry. + # If not, update the config entry if one already exists for it in case the config + # has changed, or create a new config entry if one did not already exist. + tracker_configs: list[dict[str, Any]] = config[DOMAIN][CONF_TRACKERS] + conflict_ids: list[str] = [] + for conf in tracker_configs: + id: str = conf[CONF_ID] + + if id in legacy_ids: + conflict_ids.append(id) + elif id in cfg_entries: + hass.config_entries.async_update_entry( + cfg_entries[id], **split_conf(conf) # type: ignore[arg-type] + ) + else: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + if conflict_ids: + _LOGGER.warning("%s in %s: skipping", ", ".join(conflict_ids), YAML_DEVICES) + if len(conflict_ids) == 1: + msg1 = "ID was" + msg2 = "conflicts" + else: + msg1 = "IDs were" + msg2 = "conflict" + pn_async_create( + hass, + title="Conflicting IDs", + message=f"The following {msg1} found in {YAML_DEVICES}" + f" which {msg2} with the configuration of the {DOMAIN} integration." + " Please remove from one or the other." + f"\n\n{', '.join(conflict_ids)}", + ) + + legacy_configs = [ + conf + for conf in cast(list[dict[str, Any]], config.get(DT_DOMAIN) or []) if conf[CONF_PLATFORM] == DOMAIN + ] + # Note that CONF_TIME_AS may not be in legacy configs. + if any( + conf.get(CONF_TIME_AS, DEF_TIME_AS) in (TZ_DEVICE_UTC, TZ_DEVICE_LOCAL) + for conf in tracker_configs + legacy_configs ): - pkg = config[DOMAIN][CONF_TZ_FINDER] + pkg: str = config[DOMAIN][CONF_TZ_FINDER] try: - asyncio.run_coroutine_threadsafe( - async_process_requirements( - hass, "{}.{}".format(DOMAIN, DT_DOMAIN), [pkg] - ), - hass.loop, - ).result() + await async_process_requirements(hass, f"{DOMAIN}.{DT_DOMAIN}", [pkg]) except RequirementsNotFound: _LOGGER.debug("Process requirements failed: %s", pkg) return False @@ -65,6 +207,21 @@ def setup(hass, config): from timezonefinder import TimezoneFinderL tf = TimezoneFinderL() - hass.data[DOMAIN] = tf + hass.data[DOMAIN][DATA_TF] = tf return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up config entry.""" + # async_forward_entry_setups was new in 2022.8 + try: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + except AttributeError: + hass.config_entries.async_setup_platforms(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_flow.py b/custom_components/composite/config_flow.py new file mode 100644 index 0000000..08048b0 --- /dev/null +++ b/custom_components/composite/config_flow.py @@ -0,0 +1,37 @@ +"""Config flow for Composite integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult +from homeassistant.const import CONF_ENTITY_ID, CONF_ID, CONF_NAME + +from .const import CONF_REQ_MOVEMENT, CONF_TIME_AS, DOMAIN + + +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_TIME_AS)), + ) + } + + +class CompositeConfigFlow(ConfigFlow, domain=DOMAIN): + """Composite config flow.""" + + VERSION = 1 + + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Import config entry from configuration.""" + await self.async_set_unique_id(data[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{data[CONF_NAME]} (from configuration)", + **split_conf(data), # type: ignore[arg-type] + ) diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index 714570c..5e6b19d 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -1,10 +1,16 @@ """Constants for Composite Integration.""" DOMAIN = "composite" +DATA_LEGACY_WARNED = "legacy_warned" +DATA_TF = "tf" + CONF_ALL_STATES = "all_states" +CONF_DEFAULT_OPTIONS = "default_options" CONF_ENTITY = "entity" CONF_REQ_MOVEMENT = "require_movement" CONF_TIME_AS = "time_as" +CONF_TRACKERS = "trackers" +CONF_USE_PICTURE = "use_picture" TZ_UTC = "utc" TZ_LOCAL = "local" @@ -12,3 +18,6 @@ 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] + +DEF_TIME_AS = TIME_AS_OPTS[0] +DEF_REQ_MOVEMENT = False diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 437c92b..ec4f0da 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -1,7 +1,13 @@ """A Device Tracker platform that combines one or more device trackers.""" -from datetime import datetime, timedelta +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Iterable, MutableMapping +from datetime import datetime, timedelta, tzinfo +from functools import partial import logging import threading +from typing import Any, cast import voluptuous as vol @@ -10,31 +16,63 @@ ATTR_BATTERY, ATTR_SOURCE_TYPE, DOMAIN as DT_DOMAIN, - PLATFORM_SCHEMA, - SOURCE_TYPE_BLUETOOTH, - SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS, - SOURCE_TYPE_ROUTER, + PLATFORM_SCHEMA as DT_PLATFORM_SCHEMA, +) + +# SourceType was new in 2022.9 +try: + from homeassistant.components.device_tracker import SourceType + + source_type_type = SourceType + source_type_bluetooth = SourceType.BLUETOOTH + source_type_bluetooth_le = SourceType.BLUETOOTH_LE + source_type_gps = SourceType.GPS + source_type_router = SourceType.ROUTER +except ImportError: + from homeassistant.components.device_tracker import ( + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_BLUETOOTH_LE, + SOURCE_TYPE_GPS, + SOURCE_TYPE_ROUTER, + ) + + source_type_type = str # type: ignore[assignment, misc] + source_type_bluetooth = SOURCE_TYPE_BLUETOOTH # type: ignore[assignment] + source_type_bluetooth_le = SOURCE_TYPE_BLUETOOTH_LE # type: ignore[assignment] + source_type_gps = SOURCE_TYPE_GPS # type: ignore[assignment] + source_type_router = SOURCE_TYPE_ROUTER # type: ignore[assignment] + +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.components.persistent_notification import ( + async_create as pn_async_create, ) from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.components.zone import async_active_zone +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( 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, + CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import GPSType, UNDEFINED, UndefinedType from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.location import distance @@ -44,6 +82,12 @@ CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_TIME_AS, + CONF_TRACKERS, + CONF_USE_PICTURE, + DATA_LEGACY_WARNED, + DATA_TF, + DEF_TIME_AS, + DEF_REQ_MOVEMENT, DOMAIN, TIME_AS_OPTS, TZ_DEVICE_LOCAL, @@ -58,10 +102,19 @@ ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_TIME_ZONE = "time_zone" +RESTORE_EXTRA_ATTRS = ( + ATTR_TIME_ZONE, + ATTR_ENTITY_ID, + ATTR_LAST_ENTITY_ID, + ATTR_LAST_SEEN, + ATTR_BATTERY_CHARGING, +) + INACTIVE = "inactive" ACTIVE = "active" WARNED = "warned" USE_ALL_STATES = "use_all_states" +USE_PICTURE = "use_picture" STATUS = "status" SEEN = "seen" SOURCE_TYPE = ATTR_SOURCE_TYPE @@ -72,23 +125,31 @@ SOURCE_TYPE_NON_GPS = ( SOURCE_TYPE_BINARY_SENSOR, - SOURCE_TYPE_BLUETOOTH, - SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_ROUTER, + source_type_bluetooth, + source_type_bluetooth_le, + source_type_router, ) -def _entities(entities): - result = [] - for entity in entities: +def _entities(entities: list[str | dict]) -> list[dict]: + """Convert entity ID to dict of entity & all_states.""" + 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], + ) + else: + already_using_picture = True result.append(entity) else: result.append( - { - CONF_ENTITY: entity, - CONF_ALL_STATES: False, - } + {CONF_ENTITY: entity, CONF_ALL_STATES: False, CONF_USE_PICTURE: False} ) return result @@ -97,85 +158,341 @@ def _entities(entities): cv.ensure_list, [ vol.Any( - { - vol.Required(CONF_ENTITY): cv.entity_id, - vol.Required(CONF_ALL_STATES): cv.boolean, - }, cv.entity_id, - msg="Expected an 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, ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.slugify, - vol.Required(CONF_ENTITY_ID): ENTITIES, - 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): +COMPOSITE_TRACKER = { + vol.Required(CONF_NAME): cv.slugify, + vol.Required(CONF_ENTITY_ID): ENTITIES, + vol.Optional(CONF_TIME_AS): vol.In(TIME_AS_OPTS), + vol.Optional(CONF_REQ_MOVEMENT): cv.boolean, +} +PLATFORM_SCHEMA = DT_PLATFORM_SCHEMA.extend(COMPOSITE_TRACKER) + + +def setup_scanner( + hass: HomeAssistant, + config: dict, + see: Callable[..., None], + discovery_info: dict[str, Any] | None = None, +) -> bool: """Set up a device scanner.""" CompositeScanner(hass, config, see) + if not hass.data[DOMAIN][DATA_LEGACY_WARNED]: + _LOGGER.warning( + '"%s: %s" under %s is deprecated. Move to "%s: %s"', + CONF_PLATFORM, + DOMAIN, + DT_DOMAIN, + DOMAIN, + CONF_TRACKERS, + ) + pn_async_create( + hass, + title="Composite configuration has changed", + message="```text\n" + f"{DT_DOMAIN}:\n" + f"- platform: {DOMAIN}\n" + " \n\n" + "```\n" + "is deprecated. Move to:\n\n" + "```text\n" + f"{DOMAIN}:\n" + f" {CONF_TRACKERS}:\n" + " - \n" + "```\n\n" + "Also remove entries from known_devices.yaml.", + ) + hass.data[DOMAIN][DATA_LEGACY_WARNED] = True return True -def nearest_second(time): +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 ) +def _config_from_entry(entry: ConfigEntry) -> dict | None: + """Get CompositeScanner config from config entry.""" + if not entry.options: + return None + scanner_config = {CONF_NAME: entry.data[CONF_ID]} + scanner_config.update(entry.options) + return scanner_config + + +class CompositeDeviceTracker(TrackerEntity, RestoreEntity): + """Composite Device Tracker.""" + + _attr_extra_state_attributes: MutableMapping[ + str, Any + ] | None = None # type: ignore[assignment] + _battery_level: int | None = None + _source_type: str | None = None + _location_accuracy = 0 + _location_name: str | None = None + _latitude: float | None = None + _longitude: float | None = None + _scanner: CompositeScanner | None = None + _see_called = False + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize Composite Device Tracker.""" + self._attr_name: str = entry.data[CONF_NAME] + id: str = entry.data[CONF_ID] + self._attr_unique_id = id + self.entity_id = f"{DT_DOMAIN}.{id}" + self._scanner_config: dict | None = _config_from_entry(entry) + self._lock = asyncio.Lock() + + self.async_on_remove( + entry.add_update_listener(self._async_config_entry_updated) + ) + + @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 + + @property + def source_type(self) -> str | None: # type: ignore[override] + """Return the source type of the device.""" + return self._source_type + + @property + def location_accuracy(self) -> int: + """Return the location accuracy of the device.""" + return self._location_accuracy + + @property + def location_name(self) -> str | None: + """Return a location name for the current location of the device.""" + return self._location_name + + @property + def latitude(self) -> float | None: + """Return the latitude value of the device.""" + return self._latitude + + @property + def longitude(self) -> float | None: + """Rerturn the longitude value of the device.""" + return self._longitude + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + async with self._lock: + await self._setup_scanner() + + if self._see_called and self._attr_entity_picture: + return + state = await self.async_get_last_state() + if not state: + return + + if not self._attr_entity_picture: + self._attr_entity_picture = state.attributes.get(ATTR_ENTITY_PICTURE) + + if self._see_called: + return + self._battery_level = state.attributes.get(ATTR_BATTERY_LEVEL) + self._source_type = state.attributes[ATTR_SOURCE_TYPE] + self._location_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) or 0 + self._latitude = state.attributes.get(ATTR_LATITUDE) + self._longitude = state.attributes.get(ATTR_LONGITUDE) + self._attr_extra_state_attributes = { + k: v for k, v in state.attributes.items() if k in RESTORE_EXTRA_ATTRS + } + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + async with self._lock: + await self._shutdown_scanner() + await super().async_will_remove_from_hass() + + async def _setup_scanner(self) -> None: + """Set up device scanner.""" + if not self._scanner_config or self._scanner: + return + + def setup_scanner() -> None: + """Set up device scanner.""" + self._scanner = CompositeScanner( + self.hass, cast(dict, self._scanner_config), self._see + ) + + await self.hass.async_add_executor_job(setup_scanner) + + async def _shutdown_scanner(self) -> None: + """Shutdown device scanner.""" + if not self._scanner: + return + + def shutdown_scanner() -> None: + """Shutdown device scanner.""" + cast(CompositeScanner, self._scanner).shutdown() + self._scanner = None + + await self.hass.async_add_executor_job(shutdown_scanner) + + async def _async_config_entry_updated( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Run when the config entry has been updated.""" + new_scanner_config = _config_from_entry(entry) + if new_scanner_config == self._scanner_config: + return + async with self._lock: + await self._shutdown_scanner() + self._scanner_config = new_scanner_config + await self._setup_scanner() + + def _see( + self, + *, + dev_id: str | None = None, + location_name: str | None = None, + gps: GPSType | None = None, + gps_accuracy: int | None = None, + battery: int | None = None, + attributes: dict | None = None, + source_type: str | None = source_type_gps, + picture: str | None | UndefinedType = UNDEFINED, + ) -> None: + """Process update from CompositeScanner.""" + self.hass.add_job( + partial( + self._async_see, + dev_id=dev_id, + location_name=location_name, + gps=gps, + gps_accuracy=gps_accuracy, + battery=battery, + attributes=attributes, + source_type=source_type, + picture=picture, + ) + ) + + @callback + def _async_see( + self, + *, + dev_id: str | None = None, + location_name: str | None = None, + gps: GPSType | None = None, + gps_accuracy: int | None = None, + battery: int | None = None, + attributes: dict | None = None, + source_type: str | None = source_type_gps, + picture: str | None | UndefinedType = UNDEFINED, + ) -> None: + """Process update from CompositeScanner.""" + self._see_called = True + self._battery_level = battery + self._source_type = source_type + self._location_accuracy = gps_accuracy or 0 + self._location_name = location_name + if gps: + self._latitude = gps[0] + self._longitude = gps[1] + else: + self._latitude = self._longitude = None + self._attr_extra_state_attributes = attributes + if picture is not UNDEFINED: + self._attr_entity_picture = picture + self.async_write_ha_state() + + class CompositeScanner: """Composite device scanner.""" - def __init__(self, hass, config, see): + _prev_seen: datetime | None = None + _remove: CALLBACK_TYPE | None = None + + def __init__( + self, hass: HomeAssistant, config: dict, see: Callable[..., None] + ) -> None: """Initialize CompositeScanner.""" self._hass = hass self._see = see - entities = config[CONF_ENTITY_ID] - self._entities = {} - entity_ids = [] + entities: list[dict[str, Any]] = config[CONF_ENTITY_ID] + self._entities: dict[str, dict[str, Any]] = {} + entity_ids: list[str] = [] for entity in entities: - entity_id = entity[CONF_ENTITY] + entity_id: str = entity[CONF_ENTITY] self._entities[entity_id] = { USE_ALL_STATES: entity[CONF_ALL_STATES], + USE_PICTURE: entity[CONF_USE_PICTURE], STATUS: INACTIVE, SEEN: None, SOURCE_TYPE: None, DATA: None, } entity_ids.append(entity_id) - self._dev_id = config[CONF_NAME] + self._dev_id: str = config[CONF_NAME] self._entity_id = f"{DT_DOMAIN}.{self._dev_id}" - self._time_as = config[CONF_TIME_AS] + self._time_as: str = config.get(CONF_TIME_AS, DEF_TIME_AS) if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: - self._tf = hass.data[DOMAIN] - self._req_movement = config[CONF_REQ_MOVEMENT] + self._tf = hass.data[DOMAIN][DATA_TF] + self._req_movement: bool = config.get(CONF_REQ_MOVEMENT, DEF_REQ_MOVEMENT) self._lock = threading.Lock() - self._prev_seen = None - self._remove = track_state_change(hass, entity_ids, self._update_info) + self._startup(entity_ids) for entity_id in entity_ids: self._update_info(entity_id, None, hass.states.get(entity_id)) - def _bad_entity(self, entity_id, message): - msg = "{} {}".format(entity_id, message) + def _startup(self, entity_ids: str | Iterable[str]) -> None: + """Start updating.""" + self._remove = track_state_change(self._hass, entity_ids, self._update_info) + + def shutdown(self) -> None: + """Stop updating.""" + if self._remove: + self._remove() + self._remove = None + # In case an update started just prior to call to self._remove above, wait + # for it to complete so that our caller will know, when we return, this + # CompositeScanner instance is completely stopped. + with self._lock: + pass + + def _bad_entity(self, entity_id: str, message: str) -> None: + """Mark entity ID as bad.""" + msg = f"{entity_id} {message}" # Has there already been a warning for this entity? if self._entities[entity_id][STATUS] == WARNED: _LOGGER.error(msg) - self._remove() + self.shutdown() 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 - ) + self._startup(self._entities.keys()) # Only warn if this is not the first state change for the entity. elif self._entities[entity_id][STATUS] == ACTIVE: _LOGGER.warning(msg) @@ -184,16 +501,20 @@ def _bad_entity(self, entity_id, message): _LOGGER.debug(msg) self._entities[entity_id][STATUS] = ACTIVE - def _good_entity(self, entity_id, seen, source_type, data): + def _good_entity( + self, entity_id: str, seen: datetime, source_type: str, data: Any + ) -> None: + """Mark entity ID as good.""" self._entities[entity_id].update( {STATUS: ACTIVE, SEEN: seen, SOURCE_TYPE: source_type, DATA: data} ) - def _use_non_gps_data(self, entity_id, state): + 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 entities = self._entities.values() - if any(entity[SOURCE_TYPE] == SOURCE_TYPE_GPS for entity in entities): + if any(entity[SOURCE_TYPE] == source_type_gps for entity in entities): return False return all( entity[DATA] != STATE_HOME @@ -201,14 +522,18 @@ def _use_non_gps_data(self, entity_id, state): if entity[SOURCE_TYPE] in SOURCE_TYPE_NON_GPS ) - def _dt_attr_from_utc(self, utc, tzone): + def _dt_attr_from_utc(self, utc: datetime, tzone: tzinfo | None) -> datetime: + """Determine state attribute value from datetime & timezone.""" if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL] and tzone: return utc.astimezone(tzone) 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): + def _update_info( + self, entity_id: str, old_state: State | None, new_state: State | None + ) -> None: + """Update composite tracker from input entity state change.""" if new_state is None or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): return @@ -218,47 +543,52 @@ def _update_info(self, entity_id, old_state, new_state): # 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) + last_seen: datetime | str | None = 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)) + last_seen = dt_util.utc_from_timestamp( + float(last_seen) # type: ignore[arg-type] + ) except (TypeError, ValueError): last_seen = new_state.last_updated - old_last_seen = self._entities[entity_id][SEEN] + old_last_seen: datetime | None = 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 # Try to get GPS and battery data. try: - gps = ( - new_state.attributes[ATTR_LATITUDE], - new_state.attributes[ATTR_LONGITUDE], + gps: GPSType | None = cast( + GPSType, + ( + 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( + gps_accuracy: int | None = new_state.attributes.get(ATTR_GPS_ACCURACY) + battery: int | None = new_state.attributes.get( ATTR_BATTERY, new_state.attributes.get(ATTR_BATTERY_LEVEL) ) - charging = new_state.attributes.get( + charging: bool | None = 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 + location_name: str | None = None # What type of tracker is this? if new_state.domain == BS_DOMAIN: - source_type = SOURCE_TYPE_BINARY_SENSOR + source_type: str | None = SOURCE_TYPE_BINARY_SENSOR else: source_type = new_state.attributes.get(ATTR_SOURCE_TYPE) state = new_state.state - if source_type == SOURCE_TYPE_GPS: + if source_type == source_type_gps: # GPS coordinates and accuracy are required. if gps is None: self._bad_entity(entity_id, "missing gps attributes") @@ -268,7 +598,7 @@ def _update_info(self, entity_id, old_state, new_state): return new_data = gps, gps_accuracy - old_data = self._entities[entity_id][DATA] + old_data: tuple[GPSType, int] | None = self._entities[entity_id][DATA] if old_data: if last_seen == old_last_seen and new_data == old_data: return @@ -278,12 +608,13 @@ def _update_info(self, entity_id, old_state, new_state): if ( self._req_movement and old_data - and distance(gps[0], gps[1], old_gps[0], old_gps[1]) + and cast(float, 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) + "For %s skipping update from %s: not enough movement", + self._entity_id, + entity_id, ) return @@ -308,20 +639,21 @@ def _update_info(self, entity_id, old_state, new_state): # '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_lat: float = cast(State, cur_state).attributes[ATTR_LATITUDE] + cur_lon: float = cast(State, cur_state).attributes[ATTR_LONGITUDE] + cur_acc: int = cast(State, 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 + cast( + State, + 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): @@ -339,53 +671,53 @@ def _update_info(self, entity_id, old_state, new_state): if state == STATE_HOME and cur_gps_is_home: gps = cur_lat, cur_lon gps_accuracy = cur_acc - source_type = SOURCE_TYPE_GPS + 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 + source_type = source_type_gps # Otherwise, if new state is 'home' and old state is not 'home' # and no GPS data, then use HA's configured Home location and # make source_type gps. elif state == STATE_HOME and ( cur_state is None or cur_state.state != STATE_HOME ): - gps = (self._hass.config.latitude, self._hass.config.longitude) + gps = self._hass.config.latitude, self._hass.config.longitude gps_accuracy = 0 - source_type = SOURCE_TYPE_GPS + source_type = source_type_gps # Otherwise, don't use any GPS data, but set location_name to # new state. else: location_name = state else: - self._bad_entity( - entity_id, "unsupported source_type: {}".format(source_type) - ) + self._bad_entity(entity_id, 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 {} skipping update from {}: " - "last_seen not newer than previous update ({} <= {})".format( - self._entity_id, entity_id, last_seen, self._prev_seen - ) + "For %s skipping update from %s: " + "last_seen not newer than previous update (%s) <= (%s)", + self._entity_id, + entity_id, + last_seen, + self._prev_seen, ) return _LOGGER.debug("Updating %s from %s", self._entity_id, entity_id) - tzone = None + tzone: tzinfo | None = None if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: - tzname = None + tzname: str | None = 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. - tzone = dt_util.get_time_zone(tzname) - attrs = {ATTR_TIME_ZONE: tzname or STATE_UNKNOWN} + tzone = dt_util.get_time_zone(tzname) if tzname else None + attrs: dict[str, Any] = {ATTR_TIME_ZONE: tzname or STATE_UNKNOWN} else: attrs = {} @@ -404,14 +736,18 @@ def _update_info(self, entity_id, old_state, new_state): ) 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, - ) + + kwargs = { + "dev_id": self._dev_id, + "location_name": location_name, + "gps": gps, + "gps_accuracy": gps_accuracy, + "battery": battery, + "attributes": attrs, + "source_type": source_type, + } + if self._entities[entity_id][USE_PICTURE]: + kwargs["picture"] = new_state.attributes.get(ATTR_ENTITY_PICTURE) + self._see(**kwargs) self._prev_seen = last_seen diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index c94fc2b..feb6f76 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.3.0", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", + "version": "2.4.0b7", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/entity-based/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], From 14bce1e6b220cc24b00ef14f853918c0ef783008 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 22 Nov 2022 17:05:08 -0600 Subject: [PATCH 19/96] Bump version to 2.4.0 Point documentation back to master branch. --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index feb6f76..2f542b8 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.4.0b7", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/entity-based/README.md", + "version": "2.4.0", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], From f3aa2e6cfd26018774df1a8c8ebe77505981f4b0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 15 Dec 2022 14:15:08 -0600 Subject: [PATCH 20/96] Improve HACS support (#35) --- README.md | 26 ++++++++++++++++------- custom_components/composite/manifest.json | 4 ++-- hacs.json | 4 ++++ info.md | 7 ++++++ 4 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 hacs.json create mode 100644 info.md diff --git a/README.md b/README.md index b93235c..8a5e1f1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # 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 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" 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. +Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. -Follow the installation instructions below. -Then add the desired configuration. Here is an example of a typical configuration: +Follow the [installation](#installation) instructions below. +Then, after restarting Home Assistant, add the desired configuration and restart Home Assistant once more. Here is an example of a typical configuration: ```yaml composite: @@ -42,9 +42,18 @@ has changed and suggest how to edit your configuration accordingly. At some point (i.e., in an upcoming 3.0.0 release) legacy support will be removed. ## Installation -### Versions +### With HACS +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/) -This custom integration supports HomeAssistant versions 2021.12 or newer, using Python 3.9 or newer. +You can use HACS to manage the installation and provide update notifications. + +1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/): + +```text +https://github.com/pnbruckner/ha-composite-tracker +``` + +2. Install the integration using the appropriate button on the HACS Integrations page. Search for "composite". ### Manual @@ -60,8 +69,9 @@ 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. -### With HACS -You can use [HACS](https://hacs.xyz/) to manage installation and updates by adding this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/). +### Versions + +This custom integration supports HomeAssistant versions 2021.12 or newer, using Python 3.9 or newer. ### numpy on Raspberry Pi diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 2f542b8..7ccfeb8 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.4.0", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", + "version": "2.5.0b2", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/hacs/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..afeed8b --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Composite Device Tracker", + "homeassistant": "2021.12.0b0" +} diff --git a/info.md b/info.md new file mode 100644 index 0000000..9fdbea3 --- /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 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" entities update irregularly. + +Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. + +For now configuration is done strictly in YAML and will be imported into the Integrations and Entities pages in the UI. \ No newline at end of file From 03d6b365602a2316e4ebab348752f7b056e2c07e Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 15 Dec 2022 14:18:59 -0600 Subject: [PATCH 21/96] Bump version to 2.5.0 Point docs back to master branch. --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 7ccfeb8..b604406 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.5.0b2", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/hacs/README.md", + "version": "2.5.0", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], From 68b86b72531dbebe179a94e5d43b027c539300b6 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 15 Dec 2022 14:54:29 -0600 Subject: [PATCH 22/96] Create validate.yml --- .github/workflows/validate.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/validate.yml diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..25cc220 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,25 @@ +name: Validate + +on: + pull_request: + push: + +jobs: + validate-hassfest: + runs-on: ubuntu-latest + name: With hassfest + steps: + - name: 📥 Checkout the repository + uses: actions/checkout@v3 + + - 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 From bb0acd6c3a1aff6c06ddc903c787ee17fbf6713a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 15 Dec 2022 15:02:04 -0600 Subject: [PATCH 23/96] Update manifest.json --- custom_components/composite/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index b604406..4693a7a 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -6,5 +6,6 @@ "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], + "iot_class": "local_polling", "codeowners": ["@pnbruckner"] } From a8175a41c6ccadc1fad7cf4888a6de7ee0181ffb Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 15 Dec 2022 15:18:41 -0600 Subject: [PATCH 24/96] Ignore brands check --- .github/workflows/validate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 25cc220..c3069e1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,3 +23,4 @@ jobs: uses: hacs/action@main with: category: integration + ignore: brands From 45efaed952b4119f733f2b4820dad434731c6ce9 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 21 Dec 2022 10:24:55 -0600 Subject: [PATCH 25/96] Add speed sensor (#37) Convert last_seen attribute to datetime when restored. --- README.md | 9 +- custom_components/composite/__init__.py | 2 +- custom_components/composite/const.py | 4 + custom_components/composite/device_tracker.py | 47 ++++++- custom_components/composite/sensor.py | 125 ++++++++++++++++++ info.md | 4 +- 6 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 custom_components/composite/sensor.py diff --git a/README.md b/README.md index 8a5e1f1..146769d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This integration creates a composite `device_tracker` entity from one or more ot Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. +It will also create a `sensor` entity that indicates the speed of the device. + Follow the [installation](#installation) instructions below. Then, after restarting Home Assistant, add the desired configuration and restart Home Assistant once more. Here is an example of a typical configuration: @@ -13,7 +15,8 @@ composite: - name: Me time_as: device_or_local entity_id: - - device_tracker.platform1_me + - entity: device_tracker.platform1_me + use_picture: true - device_tracker.platform2_me - binary_sensor.i_am_home ``` @@ -105,7 +108,7 @@ NOTE: Once legacy support is removed, this variable, with at least one entry, wi - **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 an entity ID of `device_tracker.my_name`. +- **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. - **time_as** (*Optional*): One of `utc`, `local`, `device_or_utc` or `device_or_local`. `utc` 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. @@ -131,7 +134,7 @@ NOTE: This only applies to "legacy" tracker devices. The watched devices, and the composite device, should all have `track` set to `true`. -## Attributes +## `device_tracker` Attributes Attribute | Description -|- diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index de91ef4..534bfc4 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -42,7 +42,7 @@ CONF_TZ_FINDER = "tz_finder" DEFAULT_TZ_FINDER = "timezonefinderL==4.0.2" CONF_TZ_FINDER_CLASS = "tz_finder_class" -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] TZ_FINDER_CLASS_OPTS = ["TimezoneFinder", "TimezoneFinderL"] TRACKER = COMPOSITE_TRACKER.copy() TRACKER.update({vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ID): cv.slugify}) diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index 5e6b19d..ff91ee6 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -4,6 +4,8 @@ DATA_LEGACY_WARNED = "legacy_warned" DATA_TF = "tf" +SIG_COMPOSITE_SPEED = "composite_speed" + CONF_ALL_STATES = "all_states" CONF_DEFAULT_OPTIONS = "default_options" CONF_ENTITY = "entity" @@ -21,3 +23,5 @@ DEF_TIME_AS = TIME_AS_OPTS[0] DEF_REQ_MOVEMENT = False + +MIN_SPEED_SECONDS = 1 diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index ec4f0da..7bc2fd8 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable, Iterable, MutableMapping +from contextlib import suppress from datetime import datetime, timedelta, tzinfo from functools import partial import logging @@ -69,6 +70,7 @@ ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_state_change from homeassistant.helpers.restore_state import RestoreEntity @@ -89,6 +91,8 @@ DEF_TIME_AS, DEF_REQ_MOVEMENT, DOMAIN, + MIN_SPEED_SECONDS, + SIG_COMPOSITE_SPEED, TIME_AS_OPTS, TZ_DEVICE_LOCAL, TZ_DEVICE_UTC, @@ -328,6 +332,12 @@ async def async_added_to_hass(self) -> None: self._attr_extra_state_attributes = { k: v for k, v in state.attributes.items() if k in RESTORE_EXTRA_ATTRS } + with suppress(KeyError): + self._attr_extra_state_attributes[ + ATTR_LAST_SEEN + ] = dt_util.parse_datetime( + self._attr_extra_state_attributes[ATTR_LAST_SEEN] + ) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" @@ -413,21 +423,50 @@ def _async_see( picture: str | None | UndefinedType = UNDEFINED, ) -> None: """Process update from CompositeScanner.""" - self._see_called = True + # Save previously "seen" values before updating for speed calculations below. + if self._see_called: + prev_seen = (self._attr_extra_state_attributes or {}).get(ATTR_LAST_SEEN) + prev_lat = self.latitude + prev_lon = self.longitude + else: + # Don't use restored attributes. + prev_seen = prev_lat = prev_lon = None + self._see_called = True + self._battery_level = battery self._source_type = source_type self._location_accuracy = gps_accuracy or 0 self._location_name = location_name if gps: - self._latitude = gps[0] - self._longitude = gps[1] + lat, lon = gps else: - self._latitude = self._longitude = None + lat = lon = None + self._latitude = lat + self._longitude = lon + self._attr_extra_state_attributes = attributes if picture is not UNDEFINED: self._attr_entity_picture = picture + self.async_write_ha_state() + speed = None + if prev_seen and prev_lat and prev_lon and gps: + last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) + seconds = (last_seen - cast(datetime, prev_seen)).total_seconds() + if seconds < MIN_SPEED_SECONDS: + _LOGGER.debug( + "%s: Not sending speed (time delta %0.1f < %0.1f", seconds, MIN_SPEED_SECONDS + ) + return + 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) + _LOGGER.debug("%s: Sending speed: %s m/s", self.name, speed) + async_dispatcher_send(self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed) + class CompositeScanner: """Composite device scanner.""" diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py new file mode 100644 index 0000000..f0a9c3c --- /dev/null +++ b/custom_components/composite/sensor.py @@ -0,0 +1,125 @@ +"""Composite Sensor.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from homeassistant.components.sensor import ( + DOMAIN as S_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) + +# SensorDeviceClass.SPEED was new in 2022.10 +try: + from homeassistant.components.sensor import SensorDeviceClass + + speed_sensor_device_class = SensorDeviceClass.SPEED +except AttributeError: + speed_sensor_device_class = None + from homeassistant.const import ( + EVENT_CORE_CONFIG_UPDATE, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + ) + from homeassistant.util.distance import convert + from homeassistant.util.unit_system import METRIC_SYSTEM + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_NAME + +# UnitOfSpeed was new in 2022.11 +try: + from homeassistant.const import UnitOfSpeed + + meters_per_second = UnitOfSpeed.METERS_PER_SECOND +except ImportError: + from homeassistant.const import SPEED_METERS_PER_SECOND + + meters_per_second = SPEED_METERS_PER_SECOND + +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import SIG_COMPOSITE_SPEED + + +@dataclass +class CompositeSensorEntityDescription(SensorEntityDescription): + """Composite sensor entity description.""" + + id: str = None + signal: str = None + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + entity_description = CompositeSensorEntityDescription( + "speed", + icon="mdi:car-speed-limiter", + name=cast(str, entry.data[CONF_NAME]) + " Speed", + state_class=SensorStateClass.MEASUREMENT, + id=cast(str, entry.data[CONF_ID]) + "_speed", + signal=f"{SIG_COMPOSITE_SPEED}-{entry.data[CONF_ID]}", + ) + if speed_sensor_device_class: + entity_description.device_class = speed_sensor_device_class + entity_description.native_unit_of_measurement = meters_per_second + async_add_entities([CompositeSensor(hass, entity_description)]) + + +class CompositeSensor(SensorEntity): + """Composite Sensor Entity.""" + + _attr_should_poll = False + _to_unit: str | None = None + + def __init__( + self, hass: HomeAssistant, entity_description: CompositeSensorEntityDescription + ) -> None: + """Initialize composite sensor entity.""" + assert entity_description.key == "speed" + + self.entity_description = entity_description + + @callback + def set_unit_of_measurement(event: Event = None) -> None: + """Set unit of measurement based on HA config.""" + if hass.config.units is METRIC_SYSTEM: + uom = SPEED_KILOMETERS_PER_HOUR + self._to_unit = LENGTH_KILOMETERS + else: + uom = SPEED_MILES_PER_HOUR + self._to_unit = LENGTH_MILES + self.entity_description.native_unit_of_measurement = uom + + if not entity_description.device_class: + set_unit_of_measurement() + self.async_on_remove( + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, set_unit_of_measurement) + ) + + self._attr_unique_id = entity_description.id + self.entity_id = f"{S_DOMAIN}.{entity_description.id}" + + self.async_on_remove( + async_dispatcher_connect(hass, entity_description.signal, self._update) + ) + + async def _update(self, value: float) -> None: + """Update sensor with new value.""" + if value and self._to_unit: + value = f"{convert(value, LENGTH_METERS, self._to_unit) * (60 * 60):0.1f}" + self._attr_native_value = value + self.entity_description.force_update = bool(value) + self.async_write_ha_state() diff --git a/info.md b/info.md index 9fdbea3..7c401af 100644 --- a/info.md +++ b/info.md @@ -4,4 +4,6 @@ This integration creates a composite `device_tracker` entity from one or more ot Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. -For now configuration is done strictly in YAML and will be imported into the Integrations and Entities pages in the UI. \ No newline at end of file +It will also create a `sensor` entity that indicates the speed of the device. + +For now configuration is done strictly in YAML and will be imported into the Integrations and Entities pages in the UI. From 0c01675177a05483fb629a638760e4ecfc7fb87b Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 21 Dec 2022 10:29:40 -0600 Subject: [PATCH 26/96] Bump version to 2.6.0 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 4693a7a..1ee4d00 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,7 @@ { "domain": "composite", "name": "Composite", - "version": "2.5.0", + "version": "2.6.0", "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], From a4b6e1b1e226bea486537346ac2ffa2ead6875aa Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 9 Jan 2023 12:57:58 -0600 Subject: [PATCH 27/96] Add angle & direction attributes to speed sensor (#38) Better filter out speed value outliers, especially when a pair of location updates comes from different input entities. --- README.md | 7 ++++ custom_components/composite/const.py | 6 ++- custom_components/composite/device_tracker.py | 38 +++++++++++++++---- custom_components/composite/manifest.json | 4 +- custom_components/composite/sensor.py | 21 +++++++++- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 146769d..814361a 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,13 @@ 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`. +## Speed `sensor` Attributes + +Attribute | Description +-|- +angle | Angle of movement direction (in degrees, if moving.) +direction | Compass heading of movement direction (if moving.) + ## Examples ### Example Full Config ```yaml diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index ff91ee6..7092255 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -24,4 +24,8 @@ DEF_TIME_AS = TIME_AS_OPTS[0] DEF_REQ_MOVEMENT = False -MIN_SPEED_SECONDS = 1 +MIN_SPEED_SECONDS = 3 +MIN_ANGLE_SPEED = 1 # meters / second + +ATTR_ANGLE = "angle" +ATTR_DIRECTION = "direction" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 7bc2fd8..745bb40 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, tzinfo from functools import partial import logging +from math import atan2, degrees import threading from typing import Any, cast @@ -91,6 +92,7 @@ DEF_TIME_AS, DEF_REQ_MOVEMENT, DOMAIN, + MIN_ANGLE_SPEED, MIN_SPEED_SECONDS, SIG_COMPOSITE_SPEED, TIME_AS_OPTS, @@ -425,12 +427,17 @@ def _async_see( """Process update from CompositeScanner.""" # Save previously "seen" values before updating for speed calculations below. if self._see_called: - prev_seen = (self._attr_extra_state_attributes or {}).get(ATTR_LAST_SEEN) + prev_ent: str | None = self._attr_extra_state_attributes[ + ATTR_LAST_ENTITY_ID + ] + prev_seen: datetime | None = self._attr_extra_state_attributes[ + ATTR_LAST_SEEN + ] prev_lat = self.latitude prev_lon = self.longitude else: # Don't use restored attributes. - prev_seen = prev_lat = prev_lon = None + prev_ent = prev_seen = prev_lat = prev_lon = None self._see_called = True self._battery_level = battery @@ -451,12 +458,20 @@ def _async_see( self.async_write_ha_state() speed = None - if prev_seen and prev_lat and prev_lon and gps: + angle = None + if prev_ent and prev_seen and prev_lat and prev_lon and gps: + assert attributes + last_ent = cast(str, attributes[ATTR_LAST_ENTITY_ID]) last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) - seconds = (last_seen - cast(datetime, prev_seen)).total_seconds() - if seconds < MIN_SPEED_SECONDS: + seconds = (last_seen - prev_seen).total_seconds() + min_seconds = MIN_SPEED_SECONDS + if last_ent != prev_ent: + min_seconds *= 3 + if seconds < min_seconds: _LOGGER.debug( - "%s: Not sending speed (time delta %0.1f < %0.1f", seconds, MIN_SPEED_SECONDS + "%s: Not sending speed & angle (time delta %0.1f < %0.1f", + seconds, + min_seconds, ) return meters = cast(float, distance(prev_lat, prev_lon, lat, lon)) @@ -464,8 +479,15 @@ def _async_see( speed = round(meters / seconds, 1) except TypeError: _LOGGER.error("%s: distance() returned None", self.name) - _LOGGER.debug("%s: Sending speed: %s m/s", self.name, speed) - async_dispatcher_send(self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed) + else: + if speed > MIN_ANGLE_SPEED: + angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat))) + if angle < 0: + angle += 360 + _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 + ) class CompositeScanner: diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 1ee4d00..e9075b1 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.6.0", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", + "version": "2.7.0b0", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/sensor-direction/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index f0a9c3c..c3d749f 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -47,7 +47,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import SIG_COMPOSITE_SPEED +from .const import ATTR_ANGLE, ATTR_DIRECTION, SIG_COMPOSITE_SPEED @dataclass @@ -110,16 +110,33 @@ def set_unit_of_measurement(event: Event = None) -> None: ) self._attr_unique_id = entity_description.id + self._attr_extra_state_attributes = { + ATTR_ANGLE: None, + ATTR_DIRECTION: None, + } self.entity_id = f"{S_DOMAIN}.{entity_description.id}" self.async_on_remove( async_dispatcher_connect(hass, entity_description.signal, self._update) ) - async def _update(self, value: float) -> None: + 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)) + ] + if value and self._to_unit: value = f"{convert(value, LENGTH_METERS, self._to_unit) * (60 * 60):0.1f}" self._attr_native_value = value self.entity_description.force_update = bool(value) + self._attr_extra_state_attributes = { + ATTR_ANGLE: angle, + ATTR_DIRECTION: direction(angle), + } self.async_write_ha_state() From 2290e5f199634d46ab563f86ce9091d885b141ac Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 9 Jan 2023 12:58:49 -0600 Subject: [PATCH 28/96] Bump version to 2.7.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index e9075b1..fd9af60 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.7.0b0", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/sensor-direction/README.md", + "version": "2.7.0", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], From ca86e341aa7d25cc77f4d2ac4320ebfdadc8263b Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 12 Jan 2023 16:10:23 -0600 Subject: [PATCH 29/96] Fix bug in log statement --- custom_components/composite/device_tracker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 745bb40..4876ff4 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -470,6 +470,7 @@ def _async_see( if seconds < min_seconds: _LOGGER.debug( "%s: Not sending speed & angle (time delta %0.1f < %0.1f", + self.name, seconds, min_seconds, ) From 5db1b3a7549e72e2d8eb73d949705ccbb983a5c8 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 12 Jan 2023 16:43:41 -0600 Subject: [PATCH 30/96] Bump version to 2.7.1 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index fd9af60..e362300 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,7 @@ { "domain": "composite", "name": "Composite", - "version": "2.7.0", + "version": "2.7.1", "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], From 9d589f88b307cd50cfc7646973784c327e21e592 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 19 Jan 2023 09:33:46 -0600 Subject: [PATCH 31/96] Add support for last_timestamp attribute (#40) Also fix some typing. --- README.md | 4 +-- custom_components/composite/device_tracker.py | 31 +++++++++++++------ custom_components/composite/manifest.json | 4 +-- custom_components/composite/sensor.py | 12 ++++--- info.md | 2 +- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 814361a..17bfe15 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Composite Device Tracker -This integration creates a composite `device_tracker` entity 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" entities update irregularly. +This integration creates a composite `device_tracker` entity 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_timestamp` or`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" entities update irregularly. Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. @@ -124,7 +124,7 @@ Watched GPS-based devices must have, at a minimum, the following attributes: `la 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'. -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. +If a watched device has a `last_seen` or `last_timestamp` attribute, that will be used in the composite device. If not, then `last_updated` from the entity's state will be used instead. 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. diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 4876ff4..8f5771c 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -105,6 +105,7 @@ ATTR_CHARGING = "charging" ATTR_LAST_SEEN = "last_seen" +ATTR_LAST_TIMESTAMP = "last_timestamp" ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_TIME_ZONE = "time_zone" @@ -426,13 +427,17 @@ def _async_see( ) -> None: """Process update from CompositeScanner.""" # Save previously "seen" values before updating for speed calculations below. + prev_ent: str | None + prev_seen: datetime | None + prev_lat: float | None + prev_lon: float | None if self._see_called: - prev_ent: str | None = self._attr_extra_state_attributes[ - ATTR_LAST_ENTITY_ID - ] - prev_seen: datetime | None = self._attr_extra_state_attributes[ - ATTR_LAST_SEEN - ] + prev_ent = cast( + MutableMapping[str, Any], self._attr_extra_state_attributes + )[ATTR_LAST_ENTITY_ID] + prev_seen = cast( + MutableMapping[str, Any], self._attr_extra_state_attributes + )[ATTR_LAST_SEEN] prev_lat = self.latitude prev_lon = self.longitude else: @@ -444,6 +449,8 @@ def _async_see( self._source_type = source_type self._location_accuracy = gps_accuracy or 0 self._location_name = location_name + lat: float | None + lon: float | None if gps: lat, lon = gps else: @@ -460,6 +467,8 @@ def _async_see( speed = None angle = None if prev_ent and prev_seen and prev_lat and prev_lon and gps: + assert lat + assert lon assert attributes last_ent = cast(str, attributes[ATTR_LAST_ENTITY_ID]) last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) @@ -600,12 +609,14 @@ def _update_info( 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. + # Get time device was last seen, which is the entity's last_seen or + # last_timestamp 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: datetime | str | None = new_state.attributes.get(ATTR_LAST_SEEN) + last_seen: datetime | str | None = new_state.attributes.get( + ATTR_LAST_SEEN + ) or new_state.attributes.get(ATTR_LAST_TIMESTAMP) if isinstance(last_seen, datetime): last_seen = dt_util.as_utc(last_seen) else: diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index e362300..d6e792d 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.7.1", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", + "version": "2.8.0b0", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/last_timestamp/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index c3d749f..86f8b58 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -13,6 +13,7 @@ ) # SensorDeviceClass.SPEED was new in 2022.10 +speed_sensor_device_class: str | None try: from homeassistant.components.sensor import SensorDeviceClass @@ -34,6 +35,7 @@ from homeassistant.const import CONF_ID, CONF_NAME # UnitOfSpeed was new in 2022.11 +meters_per_second: str try: from homeassistant.const import UnitOfSpeed @@ -54,8 +56,8 @@ class CompositeSensorEntityDescription(SensorEntityDescription): """Composite sensor entity description.""" - id: str = None - signal: str = None + id: str = None # type: ignore[assignment] + signal: str = None # type: ignore[assignment] async def async_setup_entry( @@ -73,7 +75,7 @@ async def async_setup_entry( signal=f"{SIG_COMPOSITE_SPEED}-{entry.data[CONF_ID]}", ) if speed_sensor_device_class: - entity_description.device_class = speed_sensor_device_class + entity_description.device_class = speed_sensor_device_class # type: ignore[assignment] entity_description.native_unit_of_measurement = meters_per_second async_add_entities([CompositeSensor(hass, entity_description)]) @@ -93,7 +95,7 @@ def __init__( self.entity_description = entity_description @callback - def set_unit_of_measurement(event: Event = None) -> None: + def set_unit_of_measurement(event: Event | None = None) -> None: """Set unit of measurement based on HA config.""" if hass.config.units is METRIC_SYSTEM: uom = SPEED_KILOMETERS_PER_HOUR @@ -132,7 +134,7 @@ def direction(angle: int | None) -> str | None: ] if value and self._to_unit: - value = f"{convert(value, LENGTH_METERS, self._to_unit) * (60 * 60):0.1f}" + value = f"{convert(value, LENGTH_METERS, self._to_unit) * (60 * 60):0.1f}" # type: ignore[assignment] self._attr_native_value = value self.entity_description.force_update = bool(value) self._attr_extra_state_attributes = { diff --git a/info.md b/info.md index 7c401af..a5b68c5 100644 --- a/info.md +++ b/info.md @@ -1,6 +1,6 @@ # Composite Device Tracker -This integration creates a composite `device_tracker` entity 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" entities update irregularly. +This integration creates a composite `device_tracker` entity 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_timestamp` or `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" entities update irregularly. Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. From 61475f1101943588753cc30685a41f29f99c884e Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 19 Jan 2023 09:34:35 -0600 Subject: [PATCH 32/96] Bump version to 2.8.0 Point docs back to master branch. --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index d6e792d..78bc4c2 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,8 +1,8 @@ { "domain": "composite", "name": "Composite", - "version": "2.8.0b0", - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/last_timestamp/README.md", + "version": "2.8.0", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], "dependencies": [], From 0f553f9ee205fee1ef445810f2ba90791da05b88 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 19 Jan 2023 14:23:25 -0600 Subject: [PATCH 33/96] Improve typing and use of dataclass --- custom_components/composite/device_tracker.py | 222 +++++++++++------- 1 file changed, 132 insertions(+), 90 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 8f5771c..a759e7d 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -2,14 +2,17 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable, MutableMapping +from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from contextlib import suppress +from dataclasses import dataclass from datetime import datetime, timedelta, tzinfo +from enum import Enum, auto from functools import partial import logging from math import atan2, degrees import threading -from typing import Any, cast +from types import MappingProxyType +from typing import Any, Optional, cast import voluptuous as vol @@ -25,11 +28,10 @@ try: from homeassistant.components.device_tracker import SourceType - source_type_type = SourceType - source_type_bluetooth = SourceType.BLUETOOTH - source_type_bluetooth_le = SourceType.BLUETOOTH_LE - source_type_gps = SourceType.GPS - source_type_router = SourceType.ROUTER + source_type_bluetooth: SourceType | str = SourceType.BLUETOOTH + source_type_bluetooth_le: SourceType | str = SourceType.BLUETOOTH_LE + source_type_gps: SourceType | str = SourceType.GPS + source_type_router: SourceType | str = SourceType.ROUTER except ImportError: from homeassistant.components.device_tracker import ( SOURCE_TYPE_BLUETOOTH, @@ -38,11 +40,10 @@ SOURCE_TYPE_ROUTER, ) - source_type_type = str # type: ignore[assignment, misc] - source_type_bluetooth = SOURCE_TYPE_BLUETOOTH # type: ignore[assignment] - source_type_bluetooth_le = SOURCE_TYPE_BLUETOOTH_LE # type: ignore[assignment] - source_type_gps = SOURCE_TYPE_GPS # type: ignore[assignment] - source_type_router = SOURCE_TYPE_ROUTER # type: ignore[assignment] + source_type_bluetooth = SOURCE_TYPE_BLUETOOTH + source_type_bluetooth_le = SOURCE_TYPE_BLUETOOTH_LE + source_type_gps = SOURCE_TYPE_GPS + source_type_router = SOURCE_TYPE_ROUTER from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.components.persistent_notification import ( @@ -117,16 +118,6 @@ ATTR_BATTERY_CHARGING, ) -INACTIVE = "inactive" -ACTIVE = "active" -WARNED = "warned" -USE_ALL_STATES = "use_all_states" -USE_PICTURE = "use_picture" -STATUS = "status" -SEEN = "seen" -SOURCE_TYPE = ATTR_SOURCE_TYPE -DATA = "data" - SOURCE_TYPE_BINARY_SENSOR = BS_DOMAIN STATE_BINARY_SENSOR_HOME = STATE_ON @@ -137,6 +128,10 @@ source_type_router, ) +LAST_SEEN_ATTRS = (ATTR_LAST_SEEN, ATTR_LAST_TIMESTAMP) +BATTERY_ATTRS = (ATTR_BATTERY, ATTR_BATTERY_LEVEL) +CHARGING_ATTRS = (ATTR_BATTERY_CHARGING, ATTR_CHARGING) + def _entities(entities: list[str | dict]) -> list[dict]: """Convert entity ID to dict of entity & all_states.""" @@ -500,6 +495,68 @@ def _async_see( ) +class EntityStatus(Enum): + """Input entity status.""" + + INACTIVE = auto() + ACTIVE = auto() + WARNED = auto() + + +@dataclass +class Location: + """Location (latitude, longitude & accuracy).""" + + gps: GPSType + accuracy: int + + +@dataclass +class EntityData: + """Input entity data.""" + + use_all_states: bool + use_picture: bool + status: EntityStatus + seen: datetime | None = None + source_type: str | None = None + data: Location | str | None = None + + def update( + self, + status: EntityStatus, + seen: datetime, + source_type: str, + data: Location | str, + ) -> None: + """Update entity data.""" + self.status = status + self.seen = seen + self.source_type = source_type + self.data = data + + +class Attributes: + """Flexible attribute retrieval.""" + + def __init__(self, attrs: Mapping[str, Any]) -> None: + """Initialize.""" + self._attrs = MappingProxyType(attrs) + + def __getitem__(self, key: str) -> Any: + """Get item.""" + 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[key] + for _key in key: + if _key in self._attrs: + return self._attrs[_key] + return default + + class CompositeScanner: """Composite device scanner.""" @@ -513,18 +570,13 @@ def __init__( self._hass = hass self._see = see entities: list[dict[str, Any]] = config[CONF_ENTITY_ID] - self._entities: dict[str, dict[str, Any]] = {} + self._entities: dict[str, EntityData] = {} entity_ids: list[str] = [] for entity in entities: entity_id: str = entity[CONF_ENTITY] - self._entities[entity_id] = { - USE_ALL_STATES: entity[CONF_ALL_STATES], - USE_PICTURE: entity[CONF_USE_PICTURE], - STATUS: INACTIVE, - SEEN: None, - SOURCE_TYPE: None, - DATA: None, - } + self._entities[entity_id] = EntityData( + entity[CONF_ALL_STATES], entity[CONF_USE_PICTURE], EntityStatus.INACTIVE + ) entity_ids.append(entity_id) self._dev_id: str = config[CONF_NAME] self._entity_id = f"{DT_DOMAIN}.{self._dev_id}" @@ -558,39 +610,37 @@ def _bad_entity(self, entity_id: str, message: str) -> None: """Mark entity ID as bad.""" msg = f"{entity_id} {message}" # Has there already been a warning for this entity? - if self._entities[entity_id][STATUS] == WARNED: + if self._entities[entity_id].status == EntityStatus.WARNED: _LOGGER.error(msg) self.shutdown() self._entities.pop(entity_id) # Are there still any entities to watch? self._startup(self._entities.keys()) # Only warn if this is not the first state change for the entity. - elif self._entities[entity_id][STATUS] == ACTIVE: + elif self._entities[entity_id].status == EntityStatus.ACTIVE: _LOGGER.warning(msg) - self._entities[entity_id][STATUS] = WARNED + self._entities[entity_id].status = EntityStatus.WARNED else: _LOGGER.debug(msg) - self._entities[entity_id][STATUS] = ACTIVE + self._entities[entity_id].status = EntityStatus.ACTIVE def _good_entity( - self, entity_id: str, seen: datetime, source_type: str, data: Any + self, entity_id: str, seen: datetime, source_type: str, data: Location | str ) -> None: """Mark entity ID as good.""" - self._entities[entity_id].update( - {STATUS: ACTIVE, SEEN: seen, SOURCE_TYPE: source_type, DATA: data} - ) + self._entities[entity_id].update(EntityStatus.ACTIVE, seen, source_type, data) 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]: + if state == STATE_HOME or self._entities[entity_id].use_all_states: return True entities = self._entities.values() - if any(entity[SOURCE_TYPE] == source_type_gps for entity in entities): + if any(entity.source_type == source_type_gps for entity in entities): return False return all( - entity[DATA] != STATE_HOME + cast(str, entity.data) != STATE_HOME for entity in entities - if entity[SOURCE_TYPE] in SOURCE_TYPE_NON_GPS + if entity.source_type in SOURCE_TYPE_NON_GPS ) def _dt_attr_from_utc(self, utc: datetime, tzone: tzinfo | None) -> datetime: @@ -605,18 +655,19 @@ def _update_info( self, entity_id: str, old_state: State | None, new_state: State | None ) -> None: """Update composite tracker from input entity state change.""" - if new_state is None or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + if not new_state or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): return + new_attrs = Attributes(new_state.attributes) + with self._lock: - # Get time device was last seen, which is the entity's last_seen or - # last_timestamp attribute, or if that doesn't exist, then last_updated from - # the new state object. Make sure last_seen is timezone aware in UTC. + # Get time device was last seen, which is specified by one the entity's + # attributes defined by LAST_SEEN_ATTRS, 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: datetime | str | None = new_state.attributes.get( - ATTR_LAST_SEEN - ) or new_state.attributes.get(ATTR_LAST_TIMESTAMP) + last_seen: datetime | str | None = new_attrs.get(LAST_SEEN_ATTRS) if isinstance(last_seen, datetime): last_seen = dt_util.as_utc(last_seen) else: @@ -627,7 +678,7 @@ def _update_info( except (TypeError, ValueError): last_seen = new_state.last_updated - old_last_seen: datetime | None = self._entities[entity_id][SEEN] + 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 @@ -636,20 +687,13 @@ def _update_info( try: gps: GPSType | None = cast( GPSType, - ( - new_state.attributes[ATTR_LATITUDE], - new_state.attributes[ATTR_LONGITUDE], - ), + (new_attrs[ATTR_LATITUDE], new_attrs[ATTR_LONGITUDE]), ) except KeyError: gps = None - gps_accuracy: int | None = new_state.attributes.get(ATTR_GPS_ACCURACY) - battery: int | None = new_state.attributes.get( - ATTR_BATTERY, new_state.attributes.get(ATTR_BATTERY_LEVEL) - ) - charging: bool | None = new_state.attributes.get( - ATTR_BATTERY_CHARGING, new_state.attributes.get(ATTR_CHARGING) - ) + gps_accuracy = cast(Optional[int], new_attrs.get(ATTR_GPS_ACCURACY)) + battery = cast(Optional[int], new_attrs.get(BATTERY_ATTRS)) + charging = cast(Optional[bool], new_attrs.get(CHARGING_ATTRS)) # Don't use location_name unless we have to. location_name: str | None = None @@ -657,39 +701,34 @@ def _update_info( if new_state.domain == BS_DOMAIN: source_type: str | None = SOURCE_TYPE_BINARY_SENSOR else: - source_type = new_state.attributes.get(ATTR_SOURCE_TYPE) + source_type = new_attrs.get(ATTR_SOURCE_TYPE) state = new_state.state if source_type == source_type_gps: # GPS coordinates and accuracy are required. - if gps is None: + if not gps: self._bad_entity(entity_id, "missing gps attributes") return if gps_accuracy is None: self._bad_entity(entity_id, "missing gps_accuracy attribute") return - new_data = gps, gps_accuracy - old_data: tuple[GPSType, int] | None = 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 + new_data = Location(gps, gps_accuracy) + old_data = cast(Optional[Location], self._entities[entity_id].data) + if last_seen == old_last_seen and new_data == old_data: + return self._good_entity(entity_id, last_seen, source_type, new_data) - if ( - self._req_movement - and old_data - and cast(float, distance(gps[0], gps[1], old_gps[0], old_gps[1])) - <= gps_accuracy + old_acc - ): - _LOGGER.debug( - "For %s skipping update from %s: not enough movement", - self._entity_id, - entity_id, - ) - return + 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 elif source_type in SOURCE_TYPE_NON_GPS: # Convert 'on'/'off' state of binary_sensor @@ -706,7 +745,7 @@ def _update_info( return # Don't use new GPS data if it's not complete. - if gps is None or gps_accuracy is None: + if not gps or gps_accuracy is None: gps = gps_accuracy = None # Get current GPS data, if any, and determine if it is in # 'zone.home'. @@ -742,7 +781,7 @@ def _update_info( # 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 = cast(GPSType, (cur_lat, cur_lon)) gps_accuracy = cur_acc source_type = source_type_gps # Otherwise, if new GPS data is valid (which is unlikely if @@ -753,10 +792,13 @@ def _update_info( # Otherwise, if new state is 'home' and old state is not 'home' # and no GPS data, then use HA's configured Home location and # make source_type gps. - elif state == STATE_HOME and ( - cur_state is None or cur_state.state != STATE_HOME + elif state == STATE_HOME and not ( + cur_state and cur_state.state == STATE_HOME ): - gps = self._hass.config.latitude, self._hass.config.longitude + gps = cast( + GPSType, + (self._hass.config.latitude, self._hass.config.longitude), + ) gps_accuracy = 0 source_type = source_type_gps # Otherwise, don't use any GPS data, but set location_name to @@ -799,7 +841,7 @@ def _update_info( ATTR_ENTITY_ID: tuple( entity_id for entity_id, entity in self._entities.items() - if entity[ATTR_SOURCE_TYPE] is not None + if entity.source_type ), ATTR_LAST_ENTITY_ID: entity_id, ATTR_LAST_SEEN: self._dt_attr_from_utc( @@ -819,8 +861,8 @@ def _update_info( "attributes": attrs, "source_type": source_type, } - if self._entities[entity_id][USE_PICTURE]: - kwargs["picture"] = new_state.attributes.get(ATTR_ENTITY_PICTURE) + if self._entities[entity_id].use_picture: + kwargs["picture"] = new_attrs.get(ATTR_ENTITY_PICTURE) self._see(**kwargs) self._prev_seen = last_seen From 0e43e86685cd7a6e64a1c016d37a154913382369 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 22 Jan 2023 07:46:27 -0600 Subject: [PATCH 34/96] Fix bug in new Attributes class --- custom_components/composite/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index a759e7d..c31eefa 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -550,7 +550,7 @@ def __getitem__(self, key: str) -> Any: 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[key] + return self._attrs.get(key) for _key in key: if _key in self._attrs: return self._attrs[_key] From 150e55f4d6d70f5f6ca91d6f72e5fadf22ff77c8 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 3 Feb 2023 08:46:55 -0600 Subject: [PATCH 35/96] Fix: Exception in _update when dispatching 'composite_speed-xxx' (#42) --- custom_components/composite/sensor.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index 86f8b58..40ac061 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -85,6 +85,7 @@ class CompositeSensor(SensorEntity): _attr_should_poll = False _to_unit: str | None = None + _first_state_written = False def __init__( self, hass: HomeAssistant, entity_description: CompositeSensorEntityDescription @@ -122,6 +123,12 @@ def set_unit_of_measurement(event: Event | None = None) -> None: async_dispatcher_connect(hass, entity_description.signal, self._update) ) + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + super().async_write_ha_state() + self._first_state_written = True + async def _update(self, value: float | None, angle: int | None) -> None: """Update sensor with new value.""" @@ -141,4 +148,12 @@ def direction(angle: int | None) -> str | None: ATTR_ANGLE: angle, ATTR_DIRECTION: direction(angle), } - self.async_write_ha_state() + # 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 + # we know that call has completed, we can go ahead and write the state here for + # future updates. + if self._first_state_written: + self.async_write_ha_state() From 8cbbc82cb9635940163d8bb5d3cebe6f58749c73 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 3 Feb 2023 08:54:14 -0600 Subject: [PATCH 36/96] Bump version to 2.8.1 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 78bc4c2..ddd7077 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,7 @@ { "domain": "composite", "name": "Composite", - "version": "2.8.0", + "version": "2.8.1", "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], From 16cab01f4025db571c5c25157300fa8b8b81892e Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 7 Feb 2023 12:56:43 -0600 Subject: [PATCH 37/96] Fix timezonefinder doing file I/O in async_setup (#43) Catch timezonefinder timezone_at exception. --- custom_components/composite/__init__.py | 28 ++++++++++++------- custom_components/composite/device_tracker.py | 12 +++++--- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index 534bfc4..8150921 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -195,19 +195,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: _LOGGER.debug("Process requirements suceeded: %s", pkg) - if pkg.split("==")[0].strip().endswith("L"): - from timezonefinderL import TimezoneFinder + def create_timefinder() -> None: + """Create timefinder object.""" - tf = TimezoneFinder() - elif config[DOMAIN][CONF_TZ_FINDER_CLASS] == "TimezoneFinder": - from timezonefinder import TimezoneFinder + # This must be done in an executor since the timefinder constructor + # does file I/O. - tf = TimezoneFinder() - else: - from timezonefinder import TimezoneFinderL + if pkg.split("==")[0].strip().endswith("L"): + from timezonefinderL import TimezoneFinder + + tf = TimezoneFinder() + elif config[DOMAIN][CONF_TZ_FINDER_CLASS] == "TimezoneFinder": + from timezonefinder import TimezoneFinder + + tf = TimezoneFinder() + else: + from timezonefinder import TimezoneFinderL + + tf = TimezoneFinderL() + hass.data[DOMAIN][DATA_TF] = tf - tf = TimezoneFinderL() - hass.data[DOMAIN][DATA_TF] = tf + await hass.async_add_executor_job(create_timefinder) return True diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index c31eefa..492b7f9 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -828,10 +828,14 @@ def _update_info( if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: tzname: str | None = 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. - tzone = dt_util.get_time_zone(tzname) if tzname else None + try: + # timezone_at will return a string or None. + tzname = self._tf.timezone_at(lng=gps[1], lat=gps[0]) + except Exception as exc: + _LOGGER.warning("Error while finding time zone: %s", exc) + else: + # get_time_zone will return a tzinfo or None. + tzone = dt_util.get_time_zone(tzname) if tzname else None attrs: dict[str, Any] = {ATTR_TIME_ZONE: tzname or STATE_UNKNOWN} else: attrs = {} From 26ff93fc47597941cbe58c903a28244d98ec4f73 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 7 Feb 2023 12:57:21 -0600 Subject: [PATCH 38/96] Bump version to 2.8.2 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index ddd7077..b8f5877 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,7 +1,7 @@ { "domain": "composite", "name": "Composite", - "version": "2.8.1", + "version": "2.8.2", "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], From cea52fc4af9c63b364e2b15fe53a1843cb88fcad Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 27 Feb 2023 09:45:27 -0600 Subject: [PATCH 39/96] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 17bfe15..0247ad1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Composite Device Tracker +# Composite Device Tracker This integration creates a composite `device_tracker` entity 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_timestamp` or`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" entities update irregularly. From 32573726ae6f087d04bce7dedfa46aa5fefca094 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 27 Feb 2023 11:13:36 -0600 Subject: [PATCH 40/96] Add icon image --- info.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/info.md b/info.md index a5b68c5..41fb3a7 100644 --- a/info.md +++ b/info.md @@ -1,4 +1,4 @@ -# Composite Device Tracker +# Composite Device Tracker This integration creates a composite `device_tracker` entity 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_timestamp` or `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" entities update irregularly. From 46b0ded9b61ce5dda81ac17deb2a6b2390e0f937 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Mar 2023 08:44:35 -0600 Subject: [PATCH 41/96] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0247ad1..94254f0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Composite Device Tracker +# Composite Device Tracker Platform Composite Device Tracker This integration creates a composite `device_tracker` entity 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_timestamp` or`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" entities update irregularly. From 8b86b08d3ca850f9ab1969b2c62a531124c9ec76 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Mar 2023 08:44:58 -0600 Subject: [PATCH 42/96] Update info.md --- info.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/info.md b/info.md index 41fb3a7..a350731 100644 --- a/info.md +++ b/info.md @@ -1,4 +1,4 @@ -# Composite Device Tracker +# Composite Device Tracker Platform Composite Device Tracker This integration creates a composite `device_tracker` entity 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_timestamp` or `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" entities update irregularly. From aca469faa4cc54bbadcd711b3ec25f325a09292b Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 17 Mar 2023 11:15:49 -0500 Subject: [PATCH 43/96] Update manifest.json --- custom_components/composite/manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index b8f5877..6bb49ba 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -1,11 +1,11 @@ { "domain": "composite", "name": "Composite", - "version": "2.8.2", + "codeowners": ["@pnbruckner"], + "dependencies": [], "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", + "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "dependencies": [], - "iot_class": "local_polling", - "codeowners": ["@pnbruckner"] + "version": "2.8.2" } From 38a3211bb275bad1a456538ffc606a2a8e7bb114 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 17 Jul 2023 12:14:51 -0500 Subject: [PATCH 44/96] Change timezonefinder defaults (#49) Package: timezonefinder==6.2.0 Class: TimezoneFinderL --- README.md | 12 +++++++----- custom_components/composite/__init__.py | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 94254f0..99cb579 100644 --- a/README.md +++ b/README.md @@ -93,14 +93,16 @@ sudo apt install libatlas3-base - **trackers** (*Optional*): The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). NOTE: Once legacy support is removed, this variable, with at least one entry, will become required. -- **tz_finder** (*Optional*): Specifies which `timezonefinder` package, and possibly version, to install. Must be formatted as required by `pip`. Default is `timezonefinderL==4.0.2`. Other common values: +- **tz_finder** (*Optional*): Specifies which `timezonefinder` package, and possibly version, to install. Must be formatted as required by `pip`. Default is `timezonefinder==6.2.0`. Other common values: -`timezonefinderL==2.0.1` `timezonefinder` -`timezonefinder<6` -`timezonefinder==4.2.0` +`timezonefinder<7` +`timezonefinder==4.2.0` +`timezonefinderL` +`timezonefinderL==4.0.2` +`timezonefinderL==2.0.1` -- **tz_finder_class** (*Optional*): Specifies which class to use. Only applies when using `timezonefinder` package. Valid options are `TimezoneFinder` and `TimezoneFinderL`. The default is `TimezoneFinder`. +- **tz_finder_class** (*Optional*): Specifies which class to use. Only applies when using `timezonefinder` package. Valid options are `TimezoneFinder` and `TimezoneFinderL`. The default is `TimezoneFinderL`. >Note: Starting with release 4.4.0 the `timezonefinder` package provides two classes to choose from: the original `TimezoneFinder` class, and a new class named `TimezoneFinderL`, which effectively replaces the functionality of the `timezonefinderL` package. diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index 8150921..aa6e346 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -40,7 +40,7 @@ from .device_tracker import COMPOSITE_TRACKER CONF_TZ_FINDER = "tz_finder" -DEFAULT_TZ_FINDER = "timezonefinderL==4.0.2" +DEFAULT_TZ_FINDER = "timezonefinder==6.2.0" CONF_TZ_FINDER_CLASS = "tz_finder_class" PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] TZ_FINDER_CLASS_OPTS = ["TimezoneFinder", "TimezoneFinderL"] @@ -84,7 +84,7 @@ def _defaults(config: dict) -> dict: { vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): cv.string, vol.Optional( - CONF_TZ_FINDER_CLASS, default=TZ_FINDER_CLASS_OPTS[0] + CONF_TZ_FINDER_CLASS, default=TZ_FINDER_CLASS_OPTS[1] ): vol.In(TZ_FINDER_CLASS_OPTS), vol.Optional(CONF_DEFAULT_OPTIONS, default=dict): vol.Schema( { From 2a3f0bf1eb58cad1ee15e136f8aa56221008d736 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 17 Jul 2023 12:28:20 -0500 Subject: [PATCH 45/96] Bump version to 2.8.3 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 6bb49ba..6e872e7 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "2.8.2" + "version": "2.8.3" } From 7cd336ce23e36d027bb0f11cd4668aff41fff5fc Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 20 Jul 2023 11:17:32 -0500 Subject: [PATCH 46/96] Change default timezonefinder version to 5.2.0 timezonefinder>=6 changed its build infrastructure and started using the h3 package, which seems to break on ARM targets. Going back to version 5.2.0 seems to avoid those problems. --- custom_components/composite/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index aa6e346..064190a 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -40,7 +40,7 @@ from .device_tracker import COMPOSITE_TRACKER CONF_TZ_FINDER = "tz_finder" -DEFAULT_TZ_FINDER = "timezonefinder==6.2.0" +DEFAULT_TZ_FINDER = "timezonefinder==5.2.0" CONF_TZ_FINDER_CLASS = "tz_finder_class" PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] TZ_FINDER_CLASS_OPTS = ["TimezoneFinder", "TimezoneFinderL"] From 1349a5e874cec3b050ca118d227cb95e996ec1f3 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 20 Jul 2023 11:17:59 -0500 Subject: [PATCH 47/96] Bump version to 2.8.4 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 6e872e7..551efe2 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "2.8.3" + "version": "2.8.4" } From b1c25e42d6798af0ebe4d1769f36e9fa82621707 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 20 Jul 2023 14:06:10 -0500 Subject: [PATCH 48/96] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 99cb579..a9ac935 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,10 @@ sudo apt install libatlas3-base - **trackers** (*Optional*): The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). NOTE: Once legacy support is removed, this variable, with at least one entry, will become required. -- **tz_finder** (*Optional*): Specifies which `timezonefinder` package, and possibly version, to install. Must be formatted as required by `pip`. Default is `timezonefinder==6.2.0`. Other common values: +- **tz_finder** (*Optional*): Specifies which `timezonefinder` package, and possibly version, to install. Must be formatted as required by `pip`. Default is `timezonefinder==5.2.0`. Other common values: `timezonefinder` -`timezonefinder<7` +`timezonefinder<5` `timezonefinder==4.2.0` `timezonefinderL` `timezonefinderL==4.0.2` From c225d788e9948bffe360acd87a470084068f0eb5 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 30 Dec 2023 21:53:15 -0600 Subject: [PATCH 49/96] Prepare for frozen EntityDescription in 2024.1 (#53) Only support HA version 2022.11 or later. ruff, pylint, mypy & black. --- README.md | 2 +- custom_components/composite/__init__.py | 46 ++++++------ custom_components/composite/config_flow.py | 2 +- custom_components/composite/device_tracker.py | 67 ++++++----------- custom_components/composite/sensor.py | 74 +++---------------- hacs.json | 2 +- 6 files changed, 58 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index a9ac935..7af9e76 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ where `` is your Home Assistant configuration directory. ### Versions -This custom integration supports HomeAssistant versions 2021.12 or newer, using Python 3.9 or newer. +This custom integration supports HomeAssistant versions 2022.11 or newer. ### numpy on Raspberry Pi diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index 064190a..8dd8b83 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -6,22 +6,22 @@ import voluptuous as vol -from homeassistant.config import load_yaml_config_file -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.requirements import async_process_requirements, RequirementsNotFound from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN from homeassistant.components.device_tracker.legacy import YAML_DEVICES from homeassistant.components.persistent_notification import ( async_create as pn_async_create, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config import load_yaml_config_file +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.requirements import RequirementsNotFound, async_process_requirements from homeassistant.util import slugify +from .config_flow import split_conf from .const import ( CONF_DEFAULT_OPTIONS, CONF_REQ_MOVEMENT, @@ -36,7 +36,6 @@ TZ_DEVICE_LOCAL, TZ_DEVICE_UTC, ) -from .config_flow import split_conf from .device_tracker import COMPOSITE_TRACKER CONF_TZ_FINDER = "tz_finder" @@ -111,7 +110,7 @@ def _defaults(config: dict) -> dict: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Setup composite integration.""" + """Set up composite integration.""" hass.data[DOMAIN] = {DATA_LEGACY_WARNED: False} # Get a list of all the object IDs in known_devices.yaml to see if any were created @@ -125,8 +124,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: legacy_devices = {} try: legacy_ids = [ - cv.slugify(id) - for id, dev in legacy_devices.items() + cv.slugify(obj_id) + for obj_id, dev in legacy_devices.items() if cv.boolean(dev.get("track", False)) ] except vol.Invalid: @@ -144,13 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: tracker_configs: list[dict[str, Any]] = config[DOMAIN][CONF_TRACKERS] conflict_ids: list[str] = [] for conf in tracker_configs: - id: str = conf[CONF_ID] + obj_id: str = conf[CONF_ID] - if id in legacy_ids: - conflict_ids.append(id) - elif id in cfg_entries: + if obj_id in legacy_ids: + conflict_ids.append(obj_id) + elif obj_id in cfg_entries: hass.config_entries.async_update_entry( - cfg_entries[id], **split_conf(conf) # type: ignore[arg-type] + cfg_entries[obj_id], **split_conf(conf) # type: ignore[arg-type] ) else: hass.async_create_task( @@ -192,8 +191,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: except RequirementsNotFound: _LOGGER.debug("Process requirements failed: %s", pkg) return False - else: - _LOGGER.debug("Process requirements suceeded: %s", pkg) + _LOGGER.debug("Process requirements suceeded: %s", pkg) def create_timefinder() -> None: """Create timefinder object.""" @@ -202,15 +200,21 @@ def create_timefinder() -> None: # does file I/O. if pkg.split("==")[0].strip().endswith("L"): - from timezonefinderL import TimezoneFinder + from timezonefinderL import ( # pylint: disable=import-outside-toplevel + TimezoneFinder, + ) tf = TimezoneFinder() elif config[DOMAIN][CONF_TZ_FINDER_CLASS] == "TimezoneFinder": - from timezonefinder import TimezoneFinder + from timezonefinder import ( # pylint: disable=import-outside-toplevel + TimezoneFinder, + ) tf = TimezoneFinder() else: - from timezonefinder import TimezoneFinderL + from timezonefinder import ( # pylint: disable=import-outside-toplevel + TimezoneFinderL, + ) tf = TimezoneFinderL() hass.data[DOMAIN][DATA_TF] = tf @@ -222,11 +226,7 @@ def create_timefinder() -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" - # async_forward_entry_setups was new in 2022.8 - try: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - except AttributeError: - hass.config_entries.async_setup_platforms(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index 08048b0..f00d103 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -4,8 +4,8 @@ from typing import Any from homeassistant.config_entries import ConfigFlow -from homeassistant.data_entry_flow import FlowResult from homeassistant.const import CONF_ENTITY_ID, CONF_ID, CONF_NAME +from homeassistant.data_entry_flow import FlowResult from .const import CONF_REQ_MOVEMENT, CONF_TIME_AS, DOMAIN diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 492b7f9..f638acf 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -22,35 +22,13 @@ ATTR_SOURCE_TYPE, DOMAIN as DT_DOMAIN, PLATFORM_SCHEMA as DT_PLATFORM_SCHEMA, + SourceType, ) - -# SourceType was new in 2022.9 -try: - from homeassistant.components.device_tracker import SourceType - - source_type_bluetooth: SourceType | str = SourceType.BLUETOOTH - source_type_bluetooth_le: SourceType | str = SourceType.BLUETOOTH_LE - source_type_gps: SourceType | str = SourceType.GPS - source_type_router: SourceType | str = SourceType.ROUTER -except ImportError: - from homeassistant.components.device_tracker import ( - SOURCE_TYPE_BLUETOOTH, - SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS, - SOURCE_TYPE_ROUTER, - ) - - source_type_bluetooth = SOURCE_TYPE_BLUETOOTH - source_type_bluetooth_le = SOURCE_TYPE_BLUETOOTH_LE - source_type_gps = SOURCE_TYPE_GPS - source_type_router = SOURCE_TYPE_ROUTER - from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.components.persistent_notification import ( async_create as pn_async_create, ) -from homeassistant.components.zone import ENTITY_ID_HOME -from homeassistant.components.zone import async_active_zone +from homeassistant.components.zone import ENTITY_ID_HOME, async_active_zone from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -76,7 +54,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_state_change from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import GPSType, UNDEFINED, UndefinedType +from homeassistant.helpers.typing import UNDEFINED, GPSType, UndefinedType from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.location import distance @@ -90,8 +68,8 @@ CONF_USE_PICTURE, DATA_LEGACY_WARNED, DATA_TF, - DEF_TIME_AS, DEF_REQ_MOVEMENT, + DEF_TIME_AS, DOMAIN, MIN_ANGLE_SPEED, MIN_SPEED_SECONDS, @@ -123,9 +101,9 @@ SOURCE_TYPE_NON_GPS = ( SOURCE_TYPE_BINARY_SENSOR, - source_type_bluetooth, - source_type_bluetooth_le, - source_type_router, + SourceType.BLUETOOTH, + SourceType.BLUETOOTH_LE, + SourceType.ROUTER, ) LAST_SEEN_ATTRS = (ATTR_LAST_SEEN, ATTR_LAST_TIMESTAMP) @@ -146,8 +124,7 @@ def _entities(entities: list[str | dict]) -> list[dict]: "composite tracker", path=[idx, CONF_USE_PICTURE], ) - else: - already_using_picture = True + already_using_picture = True result.append(entity) else: result.append( @@ -260,9 +237,9 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize Composite Device Tracker.""" self._attr_name: str = entry.data[CONF_NAME] - id: str = entry.data[CONF_ID] - self._attr_unique_id = id - self.entity_id = f"{DT_DOMAIN}.{id}" + obj_id: str = entry.data[CONF_ID] + self._attr_unique_id = obj_id + self.entity_id = f"{DT_DOMAIN}.{obj_id}" self._scanner_config: dict | None = _config_from_entry(entry) self._lock = asyncio.Lock() @@ -348,13 +325,13 @@ async def _setup_scanner(self) -> None: if not self._scanner_config or self._scanner: return - def setup_scanner() -> None: + def setup_comp_scanner() -> None: """Set up device scanner.""" self._scanner = CompositeScanner( self.hass, cast(dict, self._scanner_config), self._see ) - await self.hass.async_add_executor_job(setup_scanner) + await self.hass.async_add_executor_job(setup_comp_scanner) async def _shutdown_scanner(self) -> None: """Shutdown device scanner.""" @@ -389,7 +366,7 @@ def _see( gps_accuracy: int | None = None, battery: int | None = None, attributes: dict | None = None, - source_type: str | None = source_type_gps, + source_type: SourceType | str | None = SourceType.GPS, picture: str | None | UndefinedType = UNDEFINED, ) -> None: """Process update from CompositeScanner.""" @@ -417,7 +394,7 @@ def _async_see( gps_accuracy: int | None = None, battery: int | None = None, attributes: dict | None = None, - source_type: str | None = source_type_gps, + source_type: SourceType | str | None = SourceType.GPS, picture: str | None | UndefinedType = UNDEFINED, ) -> None: """Process update from CompositeScanner.""" @@ -635,7 +612,7 @@ def _use_non_gps_data(self, entity_id: str, state: str) -> bool: if state == STATE_HOME or self._entities[entity_id].use_all_states: return True entities = self._entities.values() - if any(entity.source_type == source_type_gps for entity in entities): + if any(entity.source_type == SourceType.GPS for entity in entities): return False return all( cast(str, entity.data) != STATE_HOME @@ -651,7 +628,7 @@ def _dt_attr_from_utc(self, utc: datetime, tzone: tzinfo | None) -> datetime: return dt_util.as_local(utc) return utc - def _update_info( + def _update_info( # noqa: C901 self, entity_id: str, old_state: State | None, new_state: State | None ) -> None: """Update composite tracker from input entity state change.""" @@ -705,7 +682,7 @@ def _update_info( state = new_state.state - if source_type == source_type_gps: + if source_type == SourceType.GPS: # GPS coordinates and accuracy are required. if not gps: self._bad_entity(entity_id, "missing gps attributes") @@ -783,12 +760,12 @@ def _update_info( if state == STATE_HOME and cur_gps_is_home: gps = cast(GPSType, (cur_lat, cur_lon)) gps_accuracy = cur_acc - source_type = source_type_gps + source_type = SourceType.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 + source_type = SourceType.GPS # Otherwise, if new state is 'home' and old state is not 'home' # and no GPS data, then use HA's configured Home location and # make source_type gps. @@ -800,7 +777,7 @@ def _update_info( (self._hass.config.latitude, self._hass.config.longitude), ) gps_accuracy = 0 - source_type = source_type_gps + source_type = SourceType.GPS # Otherwise, don't use any GPS data, but set location_name to # new state. else: @@ -831,7 +808,7 @@ def _update_info( try: # timezone_at will return a string or None. tzname = self._tf.timezone_at(lng=gps[1], lat=gps[0]) - except Exception as exc: + except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("Error while finding time zone: %s", exc) else: # get_time_zone will return a tzinfo or None. diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index 40ac061..f9b915f 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -11,41 +11,9 @@ SensorEntityDescription, SensorStateClass, ) - -# SensorDeviceClass.SPEED was new in 2022.10 -speed_sensor_device_class: str | None -try: - from homeassistant.components.sensor import SensorDeviceClass - - speed_sensor_device_class = SensorDeviceClass.SPEED -except AttributeError: - speed_sensor_device_class = None - from homeassistant.const import ( - EVENT_CORE_CONFIG_UPDATE, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, - ) - from homeassistant.util.distance import convert - from homeassistant.util.unit_system import METRIC_SYSTEM - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID, CONF_NAME - -# UnitOfSpeed was new in 2022.11 -meters_per_second: str -try: - from homeassistant.const import UnitOfSpeed - - meters_per_second = UnitOfSpeed.METERS_PER_SECOND -except ImportError: - from homeassistant.const import SPEED_METERS_PER_SECOND - - meters_per_second = SPEED_METERS_PER_SECOND - -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.const import CONF_ID, CONF_NAME, UnitOfSpeed +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,7 +24,7 @@ class CompositeSensorEntityDescription(SensorEntityDescription): """Composite sensor entity description.""" - id: str = None # type: ignore[assignment] + obj_id: str = None # type: ignore[assignment] signal: str = None # type: ignore[assignment] @@ -67,16 +35,15 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" entity_description = CompositeSensorEntityDescription( - "speed", + key="speed", icon="mdi:car-speed-limiter", name=cast(str, entry.data[CONF_NAME]) + " Speed", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, - id=cast(str, entry.data[CONF_ID]) + "_speed", + obj_id=cast(str, entry.data[CONF_ID]) + "_speed", signal=f"{SIG_COMPOSITE_SPEED}-{entry.data[CONF_ID]}", ) - if speed_sensor_device_class: - entity_description.device_class = speed_sensor_device_class # type: ignore[assignment] - entity_description.native_unit_of_measurement = meters_per_second async_add_entities([CompositeSensor(hass, entity_description)]) @@ -84,7 +51,6 @@ class CompositeSensor(SensorEntity): """Composite Sensor Entity.""" _attr_should_poll = False - _to_unit: str | None = None _first_state_written = False def __init__( @@ -94,30 +60,12 @@ def __init__( assert entity_description.key == "speed" self.entity_description = entity_description - - @callback - def set_unit_of_measurement(event: Event | None = None) -> None: - """Set unit of measurement based on HA config.""" - if hass.config.units is METRIC_SYSTEM: - uom = SPEED_KILOMETERS_PER_HOUR - self._to_unit = LENGTH_KILOMETERS - else: - uom = SPEED_MILES_PER_HOUR - self._to_unit = LENGTH_MILES - self.entity_description.native_unit_of_measurement = uom - - if not entity_description.device_class: - set_unit_of_measurement() - self.async_on_remove( - hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, set_unit_of_measurement) - ) - - self._attr_unique_id = entity_description.id + self._attr_unique_id = entity_description.obj_id self._attr_extra_state_attributes = { ATTR_ANGLE: None, ATTR_DIRECTION: None, } - self.entity_id = f"{S_DOMAIN}.{entity_description.id}" + self.entity_id = f"{S_DOMAIN}.{entity_description.obj_id}" self.async_on_remove( async_dispatcher_connect(hass, entity_description.signal, self._update) @@ -140,10 +88,8 @@ def direction(angle: int | None) -> str | None: int((angle + 360 / 16) // (360 / 8)) ] - if value and self._to_unit: - value = f"{convert(value, LENGTH_METERS, self._to_unit) * (60 * 60):0.1f}" # type: ignore[assignment] self._attr_native_value = value - self.entity_description.force_update = bool(value) + self._attr_force_update = bool(value) self._attr_extra_state_attributes = { ATTR_ANGLE: angle, ATTR_DIRECTION: direction(angle), diff --git a/hacs.json b/hacs.json index afeed8b..e902306 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Composite Device Tracker", - "homeassistant": "2021.12.0b0" + "homeassistant": "2022.11" } From 4b1f498af8bb06c751c31ff2c774529d7c4d1567 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 16 Jan 2024 11:57:05 -0600 Subject: [PATCH 50/96] Add UI config flow, remove time zone features & legacy tracker support (#54) Add UI config flow. Add YAML reload service. Add translations. Add restored attribute to restored states. Allow any entity that has GPS attributes. Change entity_id attribute to entities. YAML config must have at least one tracker entry. Remove time zone related features. Remove legacy tracker support. Remove config entry when tracker removed from YAML. Reorganize code. Handle frozen EntityDescription. --- README.md | 146 +-- custom_components/composite/__init__.py | 259 ++-- custom_components/composite/config_flow.py | 275 ++++- custom_components/composite/const.py | 15 +- custom_components/composite/device_tracker.py | 1057 +++++++---------- custom_components/composite/manifest.json | 1 + custom_components/composite/sensor.py | 103 +- custom_components/composite/services.yaml | 1 + .../composite/translations/en.json | 93 ++ .../composite/translations/nl.json | 93 ++ hacs.json | 2 +- info.md | 6 +- 12 files changed, 1110 insertions(+), 941 deletions(-) create mode 100644 custom_components/composite/services.yaml create mode 100644 custom_components/composite/translations/en.json create mode 100644 custom_components/composite/translations/nl.json diff --git a/README.md b/README.md index 7af9e76..16039d5 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,16 @@ # Composite Device Tracker Platform Composite Device Tracker -This integration creates a composite `device_tracker` entity 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_timestamp` or`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" entities update irregularly. - -Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. +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`, `last_timestamp` or`last_updated` (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. -Follow the [installation](#installation) instructions below. -Then, after restarting Home Assistant, add the desired configuration and restart Home Assistant once more. Here is an example of a typical configuration: - -```yaml -composite: - trackers: - - name: Me - time_as: device_or_local - entity_id: - - entity: device_tracker.platform1_me - use_picture: true - - device_tracker.platform2_me - - binary_sensor.i_am_home -``` +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. -## Legacy vs entity-based implementation +## Breaking Change -When this integration was originally created the -[Device Tracker](https://www.home-assistant.io/integrations/device_tracker/) -component worked differently than it does today. -That older implementation is now referred to as the "legacy" implementation, -and is the one that creates and uses the `known_devices.yaml` file in HA's configuration folder. - -Starting with the 2.4.0 release this integration now uses the newer entity-based implementation. -That implementation stores configuration and entity settings in HA's `.storage` folder, -and supports reconfiguring those items via the Integrations and Entities pages in the UI. -The initial configuration, though, is still done via YAML, and is "imported" and will show up -on the Integrations UI page as such. -In the future the integration will likely allow adding & fully reconfiguring composite trackers -via the UI. - -To allow for a smoother transition, the integration currently still supports the older, -legacy implementation as well. If it sees entries under `device_tracker`, it will still create the -entities as before, but it will issue a warning and a persistent notification that the configuration -has changed and suggest how to edit your configuration accordingly. - -At some point (i.e., in an upcoming 3.0.0 release) legacy support will be removed. +- All time zone related features have been removed. See https://github.com/pnbruckner/ha-entity-tz for an integration that replaces those features, and more. +- Any tracker entry removed from YAML configuration will be removed from the system. +- The `entity_id` attribute has been changed to `entities`. `entity_id` did not show up in the attribute list in the UI. ## Installation ### With HACS @@ -60,51 +28,36 @@ https://github.com/pnbruckner/ha-composite-tracker ### Manual -Place a copy of: - -[`__init__.py`](custom_components/composite/__init__.py) at `/custom_components/composite/__init__.py` -[`config_flow.py`](custom_components/composite/config_flow.py) at `/custom_components/composite/config_flow.py` -[`const.py`](custom_components/composite/const.py) at `/custom_components/composite/const.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` - +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. ### Versions -This custom integration supports HomeAssistant versions 2022.11 or newer. +This custom integration supports HomeAssistant versions 2023.7 or newer. -### numpy on Raspberry Pi +## Configuration variables -To determine time zone from GPS coordinates (see `time_as` configuration variable below) the package [timezonefinderL](https://pypi.org/project/timezonefinderL/) (by default) 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 -``` ->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`. +Composite entities can be created via the UI on the Integrations page or by YAML entries. This section describes the latter. +Here is an example YAML configuration: -## Configuration variables +```yaml +composite: + trackers: + - name: Me + entity_id: + - entity: device_tracker.platform1_me + use_picture: true + - device_tracker.platform2_me + - binary_sensor.i_am_home +``` - **default_options** (*Optional*): Defines default values for corresponding options under **trackers**. - **require_movement** (*Optional*): Default is `false`. - - **time_as** (*Optional*): Default is `utc`. - -- **trackers** (*Optional*): The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). -NOTE: Once legacy support is removed, this variable, with at least one entry, will become required. - -- **tz_finder** (*Optional*): Specifies which `timezonefinder` package, and possibly version, to install. Must be formatted as required by `pip`. Default is `timezonefinder==5.2.0`. Other common values: - -`timezonefinder` -`timezonefinder<5` -`timezonefinder==4.2.0` -`timezonefinderL` -`timezonefinderL==4.0.2` -`timezonefinderL==2.0.1` -- **tz_finder_class** (*Optional*): Specifies which class to use. Only applies when using `timezonefinder` package. Valid options are `TimezoneFinder` and `TimezoneFinderL`. The default is `TimezoneFinderL`. - ->Note: Starting with release 4.4.0 the `timezonefinder` package provides two classes to choose from: the original `TimezoneFinder` class, and a new class named `TimezoneFinderL`, which effectively replaces the functionality of the `timezonefinderL` package. +- **trackers**: The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). ### Tracker entries @@ -112,7 +65,6 @@ NOTE: Once legacy support is removed, this variable, with at least one entry, wi - **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. -- **time_as** (*Optional*): One of `utc`, `local`, `device_or_utc` or `device_or_local`. `utc` 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 @@ -122,19 +74,11 @@ NOTE: Once legacy support is removed, this variable, with at least one entry, wi ## Watched device notes -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. - 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'. If a watched device has a `last_seen` or `last_timestamp` attribute, that will be used in the composite device. If not, then `last_updated` from the entity's state will be used instead. -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. - -## known_devices.yaml - -NOTE: This only applies to "legacy" tracker devices. - -The watched devices, and the composite device, should all have `track` set to `true`. +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. ## `device_tracker` Attributes @@ -142,7 +86,7 @@ Attribute | Description -|- 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. @@ -150,7 +94,6 @@ 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`. ## Speed `sensor` Attributes @@ -163,14 +106,10 @@ direction | Compass heading of movement direction (if moving.) ### Example Full Config ```yaml composite: - tz_finder: timezonefinder<6 - tz_finder_class: TimezoneFinderL default_options: - time_as: device_or_local require_movement: true trackers: - name: Me - time_as: local entity_id: - entity: device_tracker.platform1_me use_picture: true @@ -185,36 +124,3 @@ composite: entity: device_tracker.platform_wife use_picture: true ``` - -### Time zone examples - -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. -```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) }} -``` diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index 8dd8b83..b00baaa 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -1,56 +1,78 @@ """Composite Device Tracker.""" from __future__ import annotations +import asyncio +from collections.abc import Coroutine import logging from typing import Any, cast import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN -from homeassistant.components.device_tracker.legacy import YAML_DEVICES -from homeassistant.components.persistent_notification import ( - async_create as pn_async_create, -) -from homeassistant.config import load_yaml_config_file from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ID, CONF_NAME, CONF_PLATFORM, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ( + CONF_ENTITY_ID, + CONF_ID, + CONF_NAME, + SERVICE_RELOAD, + Platform, +) +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv +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 homeassistant.requirements import RequirementsNotFound, async_process_requirements from homeassistant.util import slugify -from .config_flow import split_conf from .const import ( + CONF_ALL_STATES, CONF_DEFAULT_OPTIONS, + CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_TIME_AS, CONF_TRACKERS, - DATA_LEGACY_WARNED, - DATA_TF, + CONF_USE_PICTURE, DEF_REQ_MOVEMENT, - DEF_TIME_AS, DOMAIN, - TIME_AS_OPTS, - TZ_DEVICE_LOCAL, - TZ_DEVICE_UTC, ) -from .device_tracker import COMPOSITE_TRACKER CONF_TZ_FINDER = "tz_finder" -DEFAULT_TZ_FINDER = "timezonefinder==5.2.0" CONF_TZ_FINDER_CLASS = "tz_finder_class" PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] -TZ_FINDER_CLASS_OPTS = ["TimezoneFinder", "TimezoneFinderL"] -TRACKER = COMPOSITE_TRACKER.copy() -TRACKER.update({vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ID): cv.slugify}) + + +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 _tracker_ids( value: list[dict[vol.Required | vol.Optional, Any]] ) -> list[dict[vol.Required | vol.Optional, Any]]: - """Determine tracker ID.""" + """Determine tracker ID. + + Also ensure IDs are unique. + """ ids: list[str] = [] for conf in value: if CONF_ID not in conf: @@ -67,36 +89,76 @@ def _tracker_ids( def _defaults(config: dict) -> dict: - """Apply default options to trackers.""" - def_time_as = config[CONF_DEFAULT_OPTIONS][CONF_TIME_AS] + """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] for tracker in config[CONF_TRACKERS]: - tracker[CONF_TIME_AS] = tracker.get(CONF_TIME_AS, def_time_as) + 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 unsupported_cfgs: + _LOGGER.warning( + "Your %s configuration contains options that are no longer supported: %s; " + "Please remove them", + DOMAIN, + ", ".join(sorted(unsupported_cfgs)), + ) + 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, +) +_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, +} CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=dict): vol.All( vol.Schema( { - vol.Optional(CONF_TZ_FINDER, default=DEFAULT_TZ_FINDER): cv.string, - vol.Optional( - CONF_TZ_FINDER_CLASS, default=TZ_FINDER_CLASS_OPTS[1] - ): vol.In(TZ_FINDER_CLASS_OPTS), + 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, default=DEF_TIME_AS): vol.In( - TIME_AS_OPTS - ), + vol.Optional(CONF_TIME_AS): cv.string, vol.Optional( CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT ): cv.boolean, } ), - vol.Optional(CONF_TRACKERS, default=list): vol.All( - cv.ensure_list, [TRACKER], _tracker_ids + vol.Required(CONF_TRACKERS, default=list): vol.All( + cv.ensure_list, vol.Length(1), [_TRACKER], _tracker_ids ), } ), @@ -111,115 +173,46 @@ def _defaults(config: dict) -> dict: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up composite integration.""" - hass.data[DOMAIN] = {DATA_LEGACY_WARNED: False} - - # Get a list of all the object IDs in known_devices.yaml to see if any were created - # when this integration was a legacy device tracker, or would otherwise conflict - # with IDs in our config. - try: - legacy_devices: dict[str, dict] = await hass.async_add_executor_job( - load_yaml_config_file, hass.config.path(YAML_DEVICES) + + 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, []) ) - except (HomeAssistantError, FileNotFoundError): - legacy_devices = {} - try: - legacy_ids = [ - cv.slugify(obj_id) - for obj_id, dev in legacy_devices.items() - if cv.boolean(dev.get("track", False)) - ] - except vol.Invalid: - legacy_ids = [] - - # Get all existing composite config entries. - cfg_entries = { - cast(str, entry.data[CONF_ID]): entry - for entry in hass.config_entries.async_entries(DOMAIN) - } - - # For each tracker config, see if it conflicts with a known_devices.yaml entry. - # If not, update the config entry if one already exists for it in case the config - # has changed, or create a new config entry if one did not already exist. - tracker_configs: list[dict[str, Any]] = config[DOMAIN][CONF_TRACKERS] - conflict_ids: list[str] = [] - for conf in tracker_configs: - obj_id: str = conf[CONF_ID] - - if obj_id in legacy_ids: - conflict_ids.append(obj_id) - elif obj_id in cfg_entries: - hass.config_entries.async_update_entry( - cfg_entries[obj_id], **split_conf(conf) # type: ignore[arg-type] + tracker_ids = [conf[CONF_ID] for conf in tracker_configs] + 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}", ) - else: - hass.async_create_task( + tasks.append(hass.config_entries.async_remove(entry.entry_id)) + + for conf in tracker_configs: + tasks.append( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=conf ) ) - if conflict_ids: - _LOGGER.warning("%s in %s: skipping", ", ".join(conflict_ids), YAML_DEVICES) - if len(conflict_ids) == 1: - msg1 = "ID was" - msg2 = "conflicts" - else: - msg1 = "IDs were" - msg2 = "conflict" - pn_async_create( - hass, - title="Conflicting IDs", - message=f"The following {msg1} found in {YAML_DEVICES}" - f" which {msg2} with the configuration of the {DOMAIN} integration." - " Please remove from one or the other." - f"\n\n{', '.join(conflict_ids)}", - ) - - legacy_configs = [ - conf - for conf in cast(list[dict[str, Any]], config.get(DT_DOMAIN) or []) - if conf[CONF_PLATFORM] == DOMAIN - ] - # Note that CONF_TIME_AS may not be in legacy configs. - if any( - conf.get(CONF_TIME_AS, DEF_TIME_AS) in (TZ_DEVICE_UTC, TZ_DEVICE_LOCAL) - for conf in tracker_configs + legacy_configs - ): - pkg: str = config[DOMAIN][CONF_TZ_FINDER] - try: - await async_process_requirements(hass, f"{DOMAIN}.{DT_DOMAIN}", [pkg]) - except RequirementsNotFound: - _LOGGER.debug("Process requirements failed: %s", pkg) - return False - _LOGGER.debug("Process requirements suceeded: %s", pkg) - - def create_timefinder() -> None: - """Create timefinder object.""" - - # This must be done in an executor since the timefinder constructor - # does file I/O. - - if pkg.split("==")[0].strip().endswith("L"): - from timezonefinderL import ( # pylint: disable=import-outside-toplevel - TimezoneFinder, - ) + if not tasks: + return - tf = TimezoneFinder() - elif config[DOMAIN][CONF_TZ_FINDER_CLASS] == "TimezoneFinder": - from timezonefinder import ( # pylint: disable=import-outside-toplevel - TimezoneFinder, - ) - - tf = TimezoneFinder() - else: - from timezonefinder import ( # pylint: disable=import-outside-toplevel - TimezoneFinderL, - ) + await asyncio.gather(*tasks) - tf = TimezoneFinderL() - hass.data[DOMAIN][DATA_TF] = tf + async def reload_config(_: ServiceCall) -> None: + """Reload configuration.""" + await process_config(await async_integration_yaml_config(hass, DOMAIN)) - await hass.async_add_executor_job(create_timefinder) + await process_config(config) + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config) return True diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index f00d103..f8fa035 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -1,13 +1,47 @@ """Config flow for Composite integration.""" from __future__ import annotations +from abc import abstractmethod from typing import Any -from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ENTITY_ID, CONF_ID, CONF_NAME -from homeassistant.data_entry_flow import FlowResult +import voluptuous as vol -from .const import CONF_REQ_MOVEMENT, CONF_TIME_AS, DOMAIN +from homeassistant.backports.functools import cached_property +from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN +from homeassistant.config_entries import ( + SOURCE_IMPORT, + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import ( + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_ENTITY_ID, + CONF_ID, + CONF_NAME, +) +from homeassistant.core import State, callback +from homeassistant.data_entry_flow import FlowHandler, FlowResult +from homeassistant.helpers.selector import ( + BooleanSelector, + EntitySelector, + EntitySelectorConfig, + TextSelector, +) + +from .const import ( + ATTR_ACC, + ATTR_LAT, + ATTR_LON, + CONF_ALL_STATES, + CONF_ENTITY, + CONF_REQ_MOVEMENT, + CONF_USE_PICTURE, + DOMAIN, +) def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: @@ -16,22 +50,247 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: 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_TIME_AS)), + ("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT)), ) } -class CompositeConfigFlow(ConfigFlow, domain=DOMAIN): +class CompositeFlow(FlowHandler): + """Composite flow mixin.""" + + @cached_property + def _entries(self) -> list[ConfigEntry]: + """Get existing config entries.""" + return self.hass.config_entries.async_entries(DOMAIN) + + @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, [])] + + async def async_step_options( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Get config options.""" + errors = {} + + if user_input is not None: + self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT] + prv_cfgs = { + cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, []) + } + new_cfgs: list[dict[str, Any]] = [] + for entity_id in user_input[CONF_ENTITY_ID]: + new_cfgs.append( + prv_cfgs.get( + entity_id, + { + CONF_ENTITY: entity_id, + CONF_USE_PICTURE: False, + CONF_ALL_STATES: False, + }, + ) + ) + self.options[CONF_ENTITY_ID] = new_cfgs + if new_cfgs: + return await self.async_step_use_picture() + 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(), + } + ) + if CONF_ENTITY_ID in self.options: + data_schema = self.add_suggested_values_to_schema( + data_schema, + { + CONF_ENTITY_ID: self._entity_ids, + CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT], + }, + ) + return self.async_show_form( + step_id="options", data_schema=data_schema, errors=errors, last_step=False + ) + + async def async_step_use_picture( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Specify which input to get composite's picture from.""" + if user_input is not None: + entity_id = user_input.get(CONF_ENTITY) + for cfg in self.options[CONF_ENTITY_ID]: + cfg[CONF_USE_PICTURE] = cfg[CONF_ENTITY] == entity_id + return await self.async_step_all_states() + + data_schema = vol.Schema( + { + vol.Optional(CONF_ENTITY): EntitySelector( + EntitySelectorConfig(include_entities=self._entity_ids) + ) + } + ) + 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="use_picture", data_schema=data_schema, last_step=False + ) + + async def async_step_all_states( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """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) -> FlowResult: + """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.""" + if config_entry.source == SOURCE_IMPORT: + return False + return True + + @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]) -> FlowResult: """Import config entry from configuration.""" - await self.async_set_unique_id(data[CONF_ID]) - self._abort_if_unique_id_configured() + 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) -> FlowResult: + """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 + ) -> FlowResult: + """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) -> FlowResult: + """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) -> FlowResult: + """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 index 7092255..9e10dba 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -1,9 +1,6 @@ """Constants for Composite Integration.""" DOMAIN = "composite" -DATA_LEGACY_WARNED = "legacy_warned" -DATA_TF = "tf" - SIG_COMPOSITE_SPEED = "composite_speed" CONF_ALL_STATES = "all_states" @@ -14,18 +11,14 @@ CONF_TRACKERS = "trackers" CONF_USE_PICTURE = "use_picture" -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] - -DEF_TIME_AS = TIME_AS_OPTS[0] DEF_REQ_MOVEMENT = False MIN_SPEED_SECONDS = 3 MIN_ANGLE_SPEED = 1 # meters / second +ATTR_ACC = "acc" ATTR_ANGLE = "angle" ATTR_DIRECTION = "direction" +ATTR_ENTITIES = "entities" +ATTR_LAT = "lat" +ATTR_LON = "lon" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index f638acf..668dadf 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -1,35 +1,26 @@ """A Device Tracker platform that combines one or more device trackers.""" from __future__ import annotations -import asyncio -from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence +from collections.abc import Callable, Mapping, Sequence from contextlib import suppress from dataclasses import dataclass -from datetime import datetime, timedelta, tzinfo +from datetime import datetime, timedelta from enum import Enum, auto -from functools import partial import logging from math import atan2, degrees -import threading from types import MappingProxyType from typing import Any, Optional, cast -import voluptuous as vol - from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.components.device_tracker import ( ATTR_BATTERY, ATTR_SOURCE_TYPE, DOMAIN as DT_DOMAIN, - PLATFORM_SCHEMA as DT_PLATFORM_SCHEMA, SourceType, ) from homeassistant.components.device_tracker.config_entry import TrackerEntity -from homeassistant.components.persistent_notification import ( - async_create as pn_async_create, -) from homeassistant.components.zone import ENTITY_ID_HOME, async_active_zone -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -38,214 +29,197 @@ ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_RESTORED, CONF_ENTITY_ID, CONF_ID, CONF_NAME, - CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.core import Event, HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import UNDEFINED, GPSType, UndefinedType -from homeassistant.util.async_ import run_callback_threadsafe +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_ENTITIES, + ATTR_LAT, + ATTR_LON, CONF_ALL_STATES, CONF_ENTITY, CONF_REQ_MOVEMENT, - CONF_TIME_AS, - CONF_TRACKERS, CONF_USE_PICTURE, - DATA_LEGACY_WARNED, - DATA_TF, - DEF_REQ_MOVEMENT, - DEF_TIME_AS, - DOMAIN, MIN_ANGLE_SPEED, MIN_SPEED_SECONDS, SIG_COMPOSITE_SPEED, - TIME_AS_OPTS, - TZ_DEVICE_LOCAL, - TZ_DEVICE_UTC, - TZ_LOCAL, ) _LOGGER = logging.getLogger(__name__) +# Cause Semaphore to be created to make async_update, and anything protected by +# async_request_call, atomic. +PARALLEL_UPDATES = 1 + ATTR_CHARGING = "charging" ATTR_LAST_SEEN = "last_seen" ATTR_LAST_TIMESTAMP = "last_timestamp" ATTR_LAST_ENTITY_ID = "last_entity_id" -ATTR_TIME_ZONE = "time_zone" -RESTORE_EXTRA_ATTRS = ( - ATTR_TIME_ZONE, +_RESTORE_EXTRA_ATTRS = ( ATTR_ENTITY_ID, + ATTR_ENTITIES, ATTR_LAST_ENTITY_ID, ATTR_LAST_SEEN, ATTR_BATTERY_CHARGING, ) -SOURCE_TYPE_BINARY_SENSOR = BS_DOMAIN -STATE_BINARY_SENSOR_HOME = STATE_ON +_SOURCE_TYPE_BINARY_SENSOR = BS_DOMAIN +_STATE_BINARY_SENSOR_HOME = STATE_ON -SOURCE_TYPE_NON_GPS = ( - SOURCE_TYPE_BINARY_SENSOR, +_SOURCE_TYPE_NON_GPS = ( + _SOURCE_TYPE_BINARY_SENSOR, SourceType.BLUETOOTH, SourceType.BLUETOOTH_LE, SourceType.ROUTER, ) -LAST_SEEN_ATTRS = (ATTR_LAST_SEEN, ATTR_LAST_TIMESTAMP) -BATTERY_ATTRS = (ATTR_BATTERY, ATTR_BATTERY_LEVEL) -CHARGING_ATTRS = (ATTR_BATTERY_CHARGING, ATTR_CHARGING) - - -def _entities(entities: list[str | dict]) -> list[dict]: - """Convert entity ID to dict of entity & all_states.""" - 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 - - -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, -) -COMPOSITE_TRACKER = { - vol.Required(CONF_NAME): cv.slugify, - vol.Required(CONF_ENTITY_ID): ENTITIES, - vol.Optional(CONF_TIME_AS): vol.In(TIME_AS_OPTS), - vol.Optional(CONF_REQ_MOVEMENT): cv.boolean, -} -PLATFORM_SCHEMA = DT_PLATFORM_SCHEMA.extend(COMPOSITE_TRACKER) - - -def setup_scanner( - hass: HomeAssistant, - config: dict, - see: Callable[..., None], - discovery_info: dict[str, Any] | None = None, -) -> bool: - """Set up a device scanner.""" - CompositeScanner(hass, config, see) - if not hass.data[DOMAIN][DATA_LEGACY_WARNED]: - _LOGGER.warning( - '"%s: %s" under %s is deprecated. Move to "%s: %s"', - CONF_PLATFORM, - DOMAIN, - DT_DOMAIN, - DOMAIN, - CONF_TRACKERS, - ) - pn_async_create( - hass, - title="Composite configuration has changed", - message="```text\n" - f"{DT_DOMAIN}:\n" - f"- platform: {DOMAIN}\n" - " \n\n" - "```\n" - "is deprecated. Move to:\n\n" - "```text\n" - f"{DOMAIN}:\n" - f" {CONF_TRACKERS}:\n" - " - \n" - "```\n\n" - "Also remove entries from known_devices.yaml.", - ) - hass.data[DOMAIN][DATA_LEGACY_WARNED] = True - return True +_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 + _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: +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 ) -def _config_from_entry(entry: ConfigEntry) -> dict | None: - """Get CompositeScanner config from config entry.""" - if not entry.options: - return None - scanner_config = {CONF_NAME: entry.data[CONF_ID]} - scanner_config.update(entry.options) - return scanner_config +class EntityStatus(Enum): + """Input entity status.""" + + INACTIVE = auto() + ACTIVE = auto() + WARNED = auto() + SUSPEND = auto() + + +@dataclass +class Location: + """Location (latitude, longitude & accuracy).""" + + gps: GPSType + accuracy: int + + +@dataclass +class EntityData: + """Input entity data.""" + + entity_id: str + use_all_states: bool + use_picture: bool + status: EntityStatus = EntityStatus.INACTIVE + seen: datetime | None = None + source_type: str | None = None + data: Location | str | None = None + + 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: str, data: Location | str) -> None: + """Mark entity as good.""" + self.status = EntityStatus.ACTIVE + 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.status = EntityStatus.SUSPEND + # Only warn if this is not the first state change for the entity. + elif self.status == EntityStatus.ACTIVE: + _LOGGER.warning(msg) + self.status = EntityStatus.WARNED + else: + _LOGGER.debug(msg) + self.status = EntityStatus.ACTIVE + + +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_extra_state_attributes: MutableMapping[ - str, Any - ] | None = None # type: ignore[assignment] + _attr_translation_key = "tracker" + + # State vars _battery_level: int | None = None _source_type: str | None = None _location_accuracy = 0 _location_name: str | None = None _latitude: float | None = None _longitude: float | None = None - _scanner: CompositeScanner | None = None - _see_called = False + + _prev_seen: datetime | None = None + _remove_track_states: Callable[[], None] | None = None + _req_movement: bool def __init__(self, entry: ConfigEntry) -> None: """Initialize Composite Device Tracker.""" - self._attr_name: str = entry.data[CONF_NAME] - obj_id: str = entry.data[CONF_ID] - self._attr_unique_id = obj_id - self.entity_id = f"{DT_DOMAIN}.{obj_id}" - self._scanner_config: dict | None = _config_from_entry(entry) - self._lock = asyncio.Lock() - - self.async_on_remove( - entry.add_update_listener(self._async_config_entry_updated) - ) + 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] = {} @property def force_update(self) -> bool: @@ -285,137 +259,344 @@ def longitude(self) -> float | None: async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() - async with self._lock: - await self._setup_scanner() - - if self._see_called and self._attr_entity_picture: - return - state = await self.async_get_last_state() - if not state: - return - if not self._attr_entity_picture: - self._attr_entity_picture = state.attributes.get(ATTR_ENTITY_PICTURE) - - if self._see_called: - return - self._battery_level = state.attributes.get(ATTR_BATTERY_LEVEL) - self._source_type = state.attributes[ATTR_SOURCE_TYPE] - self._location_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) or 0 - self._latitude = state.attributes.get(ATTR_LATITUDE) - self._longitude = state.attributes.get(ATTR_LONGITUDE) - self._attr_extra_state_attributes = { - k: v for k, v in state.attributes.items() if k in RESTORE_EXTRA_ATTRS - } - with suppress(KeyError): - self._attr_extra_state_attributes[ - ATTR_LAST_SEEN - ] = dt_util.parse_datetime( - self._attr_extra_state_attributes[ATTR_LAST_SEEN] - ) + 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()) + await self.async_request_call(self._restore_state()) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" - async with self._lock: - await self._shutdown_scanner() + if self._remove_track_states: + self._remove_track_states() + self._remove_track_states = None await super().async_will_remove_from_hass() - async def _setup_scanner(self) -> None: - """Set up device scanner.""" - if not self._scanner_config or self._scanner: - return - - def setup_comp_scanner() -> None: - """Set up device scanner.""" - self._scanner = CompositeScanner( - self.hass, cast(dict, self._scanner_config), self._see + 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] + 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] ) - await self.hass.async_add_executor_job(setup_comp_scanner) + 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] + ) - async def _shutdown_scanner(self) -> None: - """Shutdown device scanner.""" - if not self._scanner: - return + for entity_id in cfg_entity_ids: + await self._entity_updated(entity_id, self.hass.states.get(entity_id)) - def shutdown_scanner() -> None: - """Shutdown device scanner.""" - cast(CompositeScanner, self._scanner).shutdown() - self._scanner = None + async def state_listener(event: Event) -> 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() - await self.hass.async_add_executor_job(shutdown_scanner) + 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 _async_config_entry_updated( + async def _config_entry_updated( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: """Run when the config entry has been updated.""" - new_scanner_config = _config_from_entry(entry) - if new_scanner_config == self._scanner_config: + 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.""" + + # Do we need to restore from saved state and, if so, is there one? + if self._prev_seen and self.entity_picture: + return + if not (last_state := await self.async_get_last_state()): return - async with self._lock: - await self._shutdown_scanner() - self._scanner_config = new_scanner_config - await self._setup_scanner() - def _see( - self, - *, - dev_id: str | None = None, - location_name: str | None = None, - gps: GPSType | None = None, - gps_accuracy: int | None = None, - battery: int | None = None, - attributes: dict | None = None, - source_type: SourceType | str | None = SourceType.GPS, - picture: str | None | UndefinedType = UNDEFINED, - ) -> None: - """Process update from CompositeScanner.""" - self.hass.add_job( - partial( - self._async_see, - dev_id=dev_id, - location_name=location_name, - gps=gps, - gps_accuracy=gps_accuracy, - battery=battery, - attributes=attributes, - source_type=source_type, - picture=picture, + # Even if we don't need to restore most of the state (i.e., if we've been + # updated by at least one new state), we may need to restore entity picture, if + # we had one but the entities we've been updated from so far do not. + if not self.entity_picture: + self._attr_entity_picture = last_state.attributes.get(ATTR_ENTITY_PICTURE) + + if self._prev_seen: + return + + self._battery_level = last_state.attributes.get(ATTR_BATTERY_LEVEL) + self._source_type = last_state.attributes[ATTR_SOURCE_TYPE] + self._location_accuracy = last_state.attributes.get(ATTR_GPS_ACCURACY) or 0 + self._latitude = last_state.attributes.get(ATTR_LATITUDE) + self._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): + self._attr_extra_state_attributes[ATTR_LAST_SEEN] = dt_util.parse_datetime( + self._attr_extra_state_attributes[ATTR_LAST_SEEN] ) - ) + if self.source_type in _SOURCE_TYPE_NON_GPS and ( + self.latitude is None or self.longitude is None + ): + self._location_name = last_state.state + self._attr_extra_state_attributes[ATTR_RESTORED] = True + + def _clear_state(self) -> None: + """Clear state.""" + self._battery_level = None + self._source_type = None + self._location_accuracy = 0 + self._location_name = None + self._latitude = None + self._longitude = None + self._attr_extra_state_attributes = {} + self._prev_seen = None + + async def _entity_updated(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 + + 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, or if that doesn't exist, then + # last_updated from the new state object. + # Make sure last_seen is timezone aware in local timezone. + # Note that dt_util.as_local assumes naive datetime is in local timezone. + last_seen: datetime | str | None = new_attrs.get(_LAST_SEEN_ATTRS) + if not isinstance(last_seen, datetime): + try: + last_seen = dt_util.utc_from_timestamp( + float(last_seen) # type: ignore[arg-type] + ) + except (TypeError, ValueError): + last_seen = new_state.last_updated + last_seen = dt_util.as_local(last_seen) + + old_last_seen = entity.seen + if old_last_seen and last_seen < old_last_seen: + entity.bad("last_seen went backwards") + return + + # 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(Optional[int], new_attrs.get(_GPS_ACCURACY_ATTRS)) + battery = cast(Optional[int], new_attrs.get(_BATTERY_ATTRS)) + charging = cast(Optional[bool], new_attrs.get(_CHARGING_ATTRS)) + + # What type of tracker is this? + if new_state.domain == BS_DOMAIN: + source_type: str | None = _SOURCE_TYPE_BINARY_SENSOR + else: + source_type = new_attrs.get( + ATTR_SOURCE_TYPE, SourceType.GPS if gps and gps_accuracy 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(Optional[Location], entity.data) + if last_seen == old_last_seen and new_data == old_data: + return + entity.good(last_seen, source_type, new_data) + + 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 + + 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 - @callback - def _async_see( + entity.good(last_seen, source_type, state) + + if not self._use_non_gps_data(entity_id, state): + return + + # Don't use new GPS data if it's not complete. + if not gps 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: float = cast(State, cur_state).attributes[ATTR_LATITUDE] + cur_lon: float = cast(State, cur_state).attributes[ATTR_LONGITUDE] + cur_acc: int = cast(State, cur_state).attributes[ATTR_GPS_ACCURACY] + cur_gps_is_home = ( + async_active_zone( + self.hass, cur_lat, cur_lon, cur_acc + ).entity_id # type: ignore[union-attr] + == 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 = cast(GPSType, (cur_lat, cur_lon)) + gps_accuracy = cur_acc + source_type = SourceType.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 = SourceType.GPS + # Otherwise, if new state is 'home' and old state is not 'home' + # and no GPS data, then use HA's configured Home location and + # make source_type gps. + elif state == STATE_HOME and not ( + cur_state and cur_state.state == STATE_HOME + ): + gps = cast( + GPSType, + (self.hass.config.latitude, self.hass.config.longitude), + ) + gps_accuracy = 0 + source_type = SourceType.GPS + # Otherwise, don't use any GPS data, but set location_name to + # new state. + else: + 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, + last_seen, + 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.source_type + ), + ATTR_LAST_ENTITY_ID: entity_id, + ATTR_LAST_SEEN: _nearest_second(last_seen), + } + if charging is not None: + attrs[ATTR_BATTERY_CHARGING] = charging + + self._set_state(location_name, gps, gps_accuracy, battery, attrs, source_type) + + self._prev_seen = last_seen + + def _set_state( self, - *, - dev_id: str | None = None, - location_name: str | None = None, - gps: GPSType | None = None, - gps_accuracy: int | None = None, - battery: int | None = None, - attributes: dict | None = None, - source_type: SourceType | str | None = SourceType.GPS, - picture: str | None | UndefinedType = UNDEFINED, + location_name: str | None, + gps: GPSType | None, + gps_accuracy: int | None, + battery: int | None, + attributes: dict, + source_type: SourceType | str | None, ) -> None: - """Process update from CompositeScanner.""" + """Set new state.""" # Save previously "seen" values before updating for speed calculations below. prev_ent: str | None - prev_seen: datetime | None prev_lat: float | None prev_lon: float | None - if self._see_called: - prev_ent = cast( - MutableMapping[str, Any], self._attr_extra_state_attributes - )[ATTR_LAST_ENTITY_ID] - prev_seen = cast( - MutableMapping[str, Any], self._attr_extra_state_attributes - )[ATTR_LAST_SEEN] + 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_seen = prev_lat = prev_lon = None - self._see_called = True + prev_ent = prev_lat = prev_lon = None self._battery_level = battery self._source_type = source_type @@ -431,26 +612,22 @@ def _async_see( self._longitude = lon self._attr_extra_state_attributes = attributes - if picture is not UNDEFINED: - self._attr_entity_picture = picture - - self.async_write_ha_state() speed = None angle = None - if prev_ent and prev_seen and prev_lat and prev_lon and gps: + if prev_ent and self._prev_seen and prev_lat and prev_lon and gps: assert lat assert lon assert attributes last_ent = cast(str, attributes[ATTR_LAST_ENTITY_ID]) last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) - seconds = (last_seen - prev_seen).total_seconds() + seconds = (last_seen - self._prev_seen).total_seconds() min_seconds = MIN_SPEED_SECONDS if last_ent != prev_ent: min_seconds *= 3 if seconds < min_seconds: _LOGGER.debug( - "%s: Not sending speed & angle (time delta %0.1f < %0.1f", + "%s: Not sending speed & angle (time delta %0.1f < %0.1f)", self.name, seconds, min_seconds, @@ -471,142 +648,6 @@ def _async_see( self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed, angle ) - -class EntityStatus(Enum): - """Input entity status.""" - - INACTIVE = auto() - ACTIVE = auto() - WARNED = auto() - - -@dataclass -class Location: - """Location (latitude, longitude & accuracy).""" - - gps: GPSType - accuracy: int - - -@dataclass -class EntityData: - """Input entity data.""" - - use_all_states: bool - use_picture: bool - status: EntityStatus - seen: datetime | None = None - source_type: str | None = None - data: Location | str | None = None - - def update( - self, - status: EntityStatus, - seen: datetime, - source_type: str, - data: Location | str, - ) -> None: - """Update entity data.""" - self.status = status - self.seen = seen - self.source_type = source_type - self.data = data - - -class Attributes: - """Flexible attribute retrieval.""" - - def __init__(self, attrs: Mapping[str, Any]) -> None: - """Initialize.""" - self._attrs = MappingProxyType(attrs) - - def __getitem__(self, key: str) -> Any: - """Get item.""" - 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) - for _key in key: - if _key in self._attrs: - return self._attrs[_key] - return default - - -class CompositeScanner: - """Composite device scanner.""" - - _prev_seen: datetime | None = None - _remove: CALLBACK_TYPE | None = None - - def __init__( - self, hass: HomeAssistant, config: dict, see: Callable[..., None] - ) -> None: - """Initialize CompositeScanner.""" - self._hass = hass - self._see = see - entities: list[dict[str, Any]] = config[CONF_ENTITY_ID] - self._entities: dict[str, EntityData] = {} - entity_ids: list[str] = [] - for entity in entities: - entity_id: str = entity[CONF_ENTITY] - self._entities[entity_id] = EntityData( - entity[CONF_ALL_STATES], entity[CONF_USE_PICTURE], EntityStatus.INACTIVE - ) - entity_ids.append(entity_id) - self._dev_id: str = config[CONF_NAME] - self._entity_id = f"{DT_DOMAIN}.{self._dev_id}" - self._time_as: str = config.get(CONF_TIME_AS, DEF_TIME_AS) - if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: - self._tf = hass.data[DOMAIN][DATA_TF] - self._req_movement: bool = config.get(CONF_REQ_MOVEMENT, DEF_REQ_MOVEMENT) - self._lock = threading.Lock() - - self._startup(entity_ids) - - for entity_id in entity_ids: - self._update_info(entity_id, None, hass.states.get(entity_id)) - - def _startup(self, entity_ids: str | Iterable[str]) -> None: - """Start updating.""" - self._remove = track_state_change(self._hass, entity_ids, self._update_info) - - def shutdown(self) -> None: - """Stop updating.""" - if self._remove: - self._remove() - self._remove = None - # In case an update started just prior to call to self._remove above, wait - # for it to complete so that our caller will know, when we return, this - # CompositeScanner instance is completely stopped. - with self._lock: - pass - - def _bad_entity(self, entity_id: str, message: str) -> None: - """Mark entity ID as bad.""" - msg = f"{entity_id} {message}" - # Has there already been a warning for this entity? - if self._entities[entity_id].status == EntityStatus.WARNED: - _LOGGER.error(msg) - self.shutdown() - self._entities.pop(entity_id) - # Are there still any entities to watch? - self._startup(self._entities.keys()) - # Only warn if this is not the first state change for the entity. - elif self._entities[entity_id].status == EntityStatus.ACTIVE: - _LOGGER.warning(msg) - self._entities[entity_id].status = EntityStatus.WARNED - else: - _LOGGER.debug(msg) - self._entities[entity_id].status = EntityStatus.ACTIVE - - def _good_entity( - self, entity_id: str, seen: datetime, source_type: str, data: Location | str - ) -> None: - """Mark entity ID as good.""" - self._entities[entity_id].update(EntityStatus.ACTIVE, seen, source_type, data) - 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: @@ -617,233 +658,5 @@ def _use_non_gps_data(self, entity_id: str, state: str) -> bool: return all( cast(str, entity.data) != STATE_HOME for entity in entities - if entity.source_type in SOURCE_TYPE_NON_GPS + if entity.source_type in _SOURCE_TYPE_NON_GPS ) - - def _dt_attr_from_utc(self, utc: datetime, tzone: tzinfo | None) -> datetime: - """Determine state attribute value from datetime & timezone.""" - if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL] and tzone: - return utc.astimezone(tzone) - if self._time_as in [TZ_LOCAL, TZ_DEVICE_LOCAL]: - return dt_util.as_local(utc) - return utc - - def _update_info( # noqa: C901 - self, entity_id: str, old_state: State | None, new_state: State | None - ) -> None: - """Update composite tracker from input entity state change.""" - if not new_state or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - return - - new_attrs = Attributes(new_state.attributes) - - with self._lock: - # Get time device was last seen, which is specified by one the entity's - # attributes defined by LAST_SEEN_ATTRS, 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: datetime | str | None = new_attrs.get(LAST_SEEN_ATTRS) - 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) # type: ignore[arg-type] - ) - except (TypeError, ValueError): - last_seen = new_state.last_updated - - 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 - - # Try to get GPS and battery data. - try: - gps: GPSType | None = cast( - GPSType, - (new_attrs[ATTR_LATITUDE], new_attrs[ATTR_LONGITUDE]), - ) - except KeyError: - gps = None - gps_accuracy = cast(Optional[int], new_attrs.get(ATTR_GPS_ACCURACY)) - battery = cast(Optional[int], new_attrs.get(BATTERY_ATTRS)) - charging = cast(Optional[bool], new_attrs.get(CHARGING_ATTRS)) - # Don't use location_name unless we have to. - location_name: str | None = None - - # What type of tracker is this? - if new_state.domain == BS_DOMAIN: - source_type: str | None = SOURCE_TYPE_BINARY_SENSOR - else: - source_type = new_attrs.get(ATTR_SOURCE_TYPE) - - state = new_state.state - - if source_type == SourceType.GPS: - # GPS coordinates and accuracy are required. - if not gps: - self._bad_entity(entity_id, "missing gps attributes") - return - if gps_accuracy is None: - self._bad_entity(entity_id, "missing gps_accuracy attribute") - return - - new_data = Location(gps, gps_accuracy) - old_data = cast(Optional[Location], self._entities[entity_id].data) - if last_seen == old_last_seen and new_data == old_data: - return - self._good_entity(entity_id, last_seen, source_type, new_data) - - 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 - - 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 - - self._good_entity(entity_id, last_seen, source_type, state) - - if not self._use_non_gps_data(entity_id, state): - return - - # Don't use new GPS data if it's not complete. - if not gps 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: float = cast(State, cur_state).attributes[ATTR_LATITUDE] - cur_lon: float = cast(State, cur_state).attributes[ATTR_LONGITUDE] - cur_acc: int = cast(State, cur_state).attributes[ATTR_GPS_ACCURACY] - cur_gps_is_home = ( - cast( - State, - 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 = cast(GPSType, (cur_lat, cur_lon)) - gps_accuracy = cur_acc - source_type = SourceType.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 = SourceType.GPS - # Otherwise, if new state is 'home' and old state is not 'home' - # and no GPS data, then use HA's configured Home location and - # make source_type gps. - elif state == STATE_HOME and not ( - cur_state and cur_state.state == STATE_HOME - ): - gps = cast( - GPSType, - (self._hass.config.latitude, self._hass.config.longitude), - ) - gps_accuracy = 0 - source_type = SourceType.GPS - # Otherwise, don't use any GPS data, but set location_name to - # new state. - else: - location_name = state - - else: - self._bad_entity(entity_id, 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, - last_seen, - self._prev_seen, - ) - return - - _LOGGER.debug("Updating %s from %s", self._entity_id, entity_id) - - tzone: tzinfo | None = None - if self._time_as in [TZ_DEVICE_UTC, TZ_DEVICE_LOCAL]: - tzname: str | None = None - if gps: - try: - # timezone_at will return a string or None. - tzname = self._tf.timezone_at(lng=gps[1], lat=gps[0]) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.warning("Error while finding time zone: %s", exc) - else: - # get_time_zone will return a tzinfo or None. - tzone = dt_util.get_time_zone(tzname) if tzname else None - attrs: dict[str, Any] = {ATTR_TIME_ZONE: tzname or STATE_UNKNOWN} - else: - attrs = {} - - attrs.update( - { - ATTR_ENTITY_ID: tuple( - entity_id - for entity_id, entity in self._entities.items() - if entity.source_type - ), - ATTR_LAST_ENTITY_ID: entity_id, - ATTR_LAST_SEEN: self._dt_attr_from_utc( - nearest_second(last_seen), tzone - ), - } - ) - if charging is not None: - attrs[ATTR_BATTERY_CHARGING] = charging - - kwargs = { - "dev_id": self._dev_id, - "location_name": location_name, - "gps": gps, - "gps_accuracy": gps_accuracy, - "battery": battery, - "attributes": attrs, - "source_type": source_type, - } - if self._entities[entity_id].use_picture: - kwargs["picture"] = new_attrs.get(ATTR_ENTITY_PICTURE) - self._see(**kwargs) - - self._prev_seen = last_seen diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 551efe2..756d82d 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -2,6 +2,7 @@ "domain": "composite", "name": "Composite", "codeowners": ["@pnbruckner"], + "config_flow": true, "dependencies": [], "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", "iot_class": "local_polling", diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index f9b915f..33b777a 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -1,7 +1,6 @@ """Composite Sensor.""" from __future__ import annotations -from dataclasses import dataclass from typing import cast from homeassistant.components.sensor import ( @@ -11,71 +10,77 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, UnitOfSpeed -from homeassistant.core import HomeAssistant, callback +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, SIG_COMPOSITE_SPEED -@dataclass -class CompositeSensorEntityDescription(SensorEntityDescription): - """Composite sensor entity description.""" - - obj_id: str = None # type: ignore[assignment] - signal: str = None # type: ignore[assignment] - - async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the sensor platform.""" - entity_description = CompositeSensorEntityDescription( - key="speed", - icon="mdi:car-speed-limiter", - name=cast(str, entry.data[CONF_NAME]) + " Speed", - device_class=SensorDeviceClass.SPEED, - native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - obj_id=cast(str, entry.data[CONF_ID]) + "_speed", - signal=f"{SIG_COMPOSITE_SPEED}-{entry.data[CONF_ID]}", - ) - async_add_entities([CompositeSensor(hass, entity_description)]) + async_add_entities([CompositeSensor(hass, entry)]) class CompositeSensor(SensorEntity): """Composite Sensor Entity.""" _attr_should_poll = False - _first_state_written = False + _ok_to_write_state = False - def __init__( - self, hass: HomeAssistant, entity_description: CompositeSensorEntityDescription - ) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize composite sensor entity.""" - assert entity_description.key == "speed" - + 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_unique_id = entity_description.obj_id self._attr_extra_state_attributes = { ATTR_ANGLE: None, ATTR_DIRECTION: None, } - self.entity_id = f"{S_DOMAIN}.{entity_description.obj_id}" self.async_on_remove( - async_dispatcher_connect(hass, entity_description.signal, self._update) + async_dispatcher_connect( + hass, f"{SIG_COMPOSITE_SPEED}-{signal}", self._update + ) ) - @callback - def async_write_ha_state(self) -> None: - """Write the state to the state machine.""" - super().async_write_ha_state() - self._first_state_written = True + 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 + ) + ) + self._ok_to_write_state = True async def _update(self, value: float | None, angle: int | None) -> None: """Update sensor with new value.""" @@ -99,7 +104,21 @@ def direction(angle: int | None) -> str | None: # 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 - # we know that call has completed, we can go ahead and write the state here for - # future updates. - if self._first_state_written: + # 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() + + 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_translation_placeholders["name"]: + # Need to change _attr_translation_placeholders (instead of the dict to + # which it refers) to clear the cached_property in new HA versions. + self._attr_translation_placeholders = {"name": new_name} + # Clear cached_property in new HA versions. + self._attr_name = "" + del self._attr_name + er.async_get(hass).async_update_entity( + self.entity_id, original_name=self.name + ) 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..7ad5ecb --- /dev/null +++ b/custom_components/composite/translations/en.json @@ -0,0 +1,93 @@ +{ + "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" + } + }, + "name": { + "title": "Name", + "data": { + "name": "Name" + } + }, + "options": { + "title": "Composite Options", + "data": { + "entity_id": "Input entities", + "require_movement": "Require movement" + } + }, + "use_picture": { + "title": "Picture Entity", + "description": "Choose entity whose picture will be used for the composite.\nIt may be none.", + "data": { + "entity": "Entity" + } + } + }, + "error": { + "at_least_one_entity": "Must select at least one input entity.", + "name_used": "Name has already been used." + } + }, + "entity": { + "device_tracker": { + "tracker": { + "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" + } + }, + "options": { + "title": "Composite Options", + "data": { + "entity_id": "Input entities", + "require_movement": "Require movement" + } + }, + "use_picture": { + "title": "Picture Entity", + "description": "Choose entity whose picture will be used for the composite.\nIt may be none.", + "data": { + "entity": "Entity" + } + } + }, + "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/nl.json b/custom_components/composite/translations/nl.json new file mode 100644 index 0000000..cb02fe3 --- /dev/null +++ b/custom_components/composite/translations/nl.json @@ -0,0 +1,93 @@ +{ + "title": "Composiet", + "config": { + "step": { + "all_states": { + "title": "Gebruik alle staten", + "description": "Selecteer entiteiten waarvoor alle staten moeten worden gebruikt.\nHoud er rekening mee dat dit alleen van toepassing is op entiteiten waarvan het brontype niet GPS is.", + "data": { + "entity": "Entiteit" + } + }, + "name": { + "title": "Naam", + "data": { + "name": "Naam" + } + }, + "options": { + "title": "Samengestelde opties", + "data": { + "entity_id": "Voer entiteiten in", + "require_movement": "Vereist beweging" + } + }, + "use_picture": { + "title": "Afbeeldingsentiteit", + "description": "Kies de entiteit waarvan de afbeelding voor de composiet zal worden gebruikt.\nHet is misschien geen.", + "data": { + "entity": "Entiteit" + } + } + }, + "error": { + "at_least_one_entity": "Moet ten minste één invoerentiteit selecteren.", + "name_used": "Naam is al gebruikt." + } + }, + "entity": { + "device_tracker": { + "tracker": { + "state_attributes": { + "battery_charging": {"name": "Batterij opladen"}, + "entities": {"name": "Entiteiten gezien"}, + "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.\nHoud er rekening mee dat dit alleen van toepassing is op entiteiten waarvan het brontype niet GPS is.", + "data": { + "entity": "Entiteit" + } + }, + "options": { + "title": "Samengestelde opties", + "data": { + "entity_id": "Voer entiteiten in", + "require_movement": "Vereist beweging" + } + }, + "use_picture": { + "title": "Afbeeldingsentiteit", + "description": "Kies de entiteit waarvan de afbeelding voor de composiet zal worden gebruikt.\nHet is misschien geen.", + "data": { + "entity": "Entiteit" + } + } + }, + "error": { + "at_least_one_entity": "Must select at least one input entity." + } + }, + "services": { + "reload": { + "name": "Herladen", + "description": "Herlaadt Composiet vanuit de YAML-configuratie." + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json index e902306..04d055c 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Composite Device Tracker", - "homeassistant": "2022.11" + "homeassistant": "2023.7" } diff --git a/info.md b/info.md index a350731..bc0204d 100644 --- a/info.md +++ b/info.md @@ -1,9 +1,7 @@ # Composite Device Tracker Platform Composite Device Tracker -This integration creates a composite `device_tracker` entity 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_timestamp` or `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" entities update irregularly. - -Currently `device_tracker` entities with a `source_type` of `bluetooth`, `bluetooth_le`, `gps` or `router` are supported, as well as `binary_sensor` entities. +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`, `last_timestamp` or`last_updated` (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. -For now configuration is done strictly in YAML and will be imported into the Integrations and Entities pages in the UI. +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. From 4177051fdf105f71ab3eddb3b48906902470c9e6 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 16 Jan 2024 12:01:01 -0600 Subject: [PATCH 51/96] Bump version to 3.0.0b0 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 756d82d..6491e6d 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "2.8.4" + "version": "3.0.0b0" } From 8548220b23723b871acd3138bec78676fb8007fd Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 16 Jan 2024 13:58:50 -0600 Subject: [PATCH 52/96] Process YAML config in background so it does not delay startup --- custom_components/composite/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index b00baaa..1396593 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -211,7 +211,9 @@ async def reload_config(_: ServiceCall) -> None: """Reload configuration.""" await process_config(await async_integration_yaml_config(hass, DOMAIN)) - await process_config(config) + hass.async_create_background_task( + process_config(config), f"Proccess {DOMAIN} YAML configuration" + ) async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config) return True From aef58fca3bc5ed75116121510fef7020c511c2d1 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 16 Jan 2024 14:00:38 -0600 Subject: [PATCH 53/96] Bump version to 3.0.0b1 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 6491e6d..d399d00 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0b0" + "version": "3.0.0b1" } From 8b57722e172b8256766540549477005eeb6f2d91 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 17 Jan 2024 13:06:37 -0600 Subject: [PATCH 54/96] Fix startup issue if composite in a package --- custom_components/composite/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index 1396593..44e576d 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -144,7 +144,7 @@ def _defaults(config: dict) -> dict: } CONFIG_SCHEMA = vol.Schema( { - vol.Optional(DOMAIN, default=dict): vol.All( + vol.Optional(DOMAIN): vol.All( vol.Schema( { vol.Optional(CONF_TZ_FINDER): cv.string, From a5036c9cf5048f17f32d4d0a6b6f567dc8d53eee Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 17 Jan 2024 13:10:21 -0600 Subject: [PATCH 55/96] Bump version to 3.0.0b2 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index d399d00..ef19297 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0b1" + "version": "3.0.0b2" } From 0019d210eaa6e25f73c7d8cde3672c2c70c69dfb Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 17 Jan 2024 15:46:17 -0600 Subject: [PATCH 56/96] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 16039d5..f081e57 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Currently any entity that has "GPS" attributes (`gps_accuracy` or `acc`, and eit - All time zone related features have been removed. See https://github.com/pnbruckner/ha-entity-tz for an integration that replaces those features, and more. - Any tracker entry removed from YAML configuration will be removed from the system. +- `trackers` in YAML configuration must have at least one entry. - The `entity_id` attribute has been changed to `entities`. `entity_id` did not show up in the attribute list in the UI. ## Installation From 90022b1244e59baa6c40d64811f8878c7a2d196f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 17 Jan 2024 18:38:47 -0600 Subject: [PATCH 57/96] Fix race condition when removing a YAML config --- custom_components/composite/__init__.py | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index 44e576d..b0cb6d2 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -180,8 +180,18 @@ async def process_config(config: ConfigType | None) -> None: list[dict[str, Any]], (config or {}).get(DOMAIN, {}).get(CONF_TRACKERS, []) ) tracker_ids = [conf[CONF_ID] for conf in tracker_configs] - tasks: list[Coroutine[Any, Any, Any]] = [] + 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 @@ -193,27 +203,17 @@ async def process_config(config: ConfigType | None) -> None: 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)) - - for conf in tracker_configs: - tasks.append( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - - if not tasks: - return - - await asyncio.gather(*tasks) + if tasks: + await asyncio.gather(*tasks) async def reload_config(_: ServiceCall) -> None: """Reload configuration.""" await process_config(await async_integration_yaml_config(hass, DOMAIN)) - hass.async_create_background_task( - process_config(config), f"Proccess {DOMAIN} YAML configuration" - ) + await process_config(config) async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config) return True From 7661df72f9a74f965b5e852a93a249ca72466b6a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 17 Jan 2024 18:40:38 -0600 Subject: [PATCH 58/96] Bump version to 3.0.0b3 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index ef19297..bde1fb9 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0b2" + "version": "3.0.0b3" } From f5f8e2914c77dacacbc696397366483ad1a23114 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 20 Jan 2024 13:26:07 -0600 Subject: [PATCH 59/96] Do not add restored attribute when restored The restored attribute has undesired side effects. --- custom_components/composite/device_tracker.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 668dadf..37239ec 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -29,7 +29,6 @@ ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_RESTORED, CONF_ENTITY_ID, CONF_ID, CONF_NAME, @@ -382,7 +381,6 @@ async def _restore_state(self) -> None: self.latitude is None or self.longitude is None ): self._location_name = last_state.state - self._attr_extra_state_attributes[ATTR_RESTORED] = True def _clear_state(self) -> None: """Clear state.""" From 78af6536c4fc31a48fbdb1b29ae3c33548caf224 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 20 Jan 2024 13:29:08 -0600 Subject: [PATCH 60/96] Bump version to 3.0.0b4 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index bde1fb9..686c0d0 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0b3" + "version": "3.0.0b4" } From f2579d04e3d7ad667f18d7700cf988f4f1ba7b73 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 26 Jan 2024 21:29:43 -0600 Subject: [PATCH 61/96] Fix speed sensor name Was using a feature that will be new in 2024.2. --- custom_components/composite/sensor.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index 33b777a..0ae9e16 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -49,16 +49,20 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.entity_id = f"{S_DOMAIN}.{obj_id}" self._attr_unique_id = obj_id else: + # translation_placeholders was new in 2024.2 + self._use_name_translation = hasattr(self, "translation_placeholders") entity_description = SensorEntityDescription( key="speed", device_class=SensorDeviceClass.SPEED, icon="mdi:car-speed-limiter", - has_entity_name=True, + has_entity_name=self._use_name_translation, translation_key="speed", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, ) self._attr_translation_placeholders = {"name": entry.title} + if not self._use_name_translation: + self._attr_name = f"{entry.title} speed" self._attr_unique_id = signal = entry.entry_id self.entity_description = entity_description self._attr_extra_state_attributes = { @@ -116,9 +120,11 @@ async def _config_entry_updated( # Need to change _attr_translation_placeholders (instead of the dict to # which it refers) to clear the cached_property in new HA versions. self._attr_translation_placeholders = {"name": new_name} - # Clear cached_property in new HA versions. - self._attr_name = "" - del self._attr_name + self._attr_name = f"{new_name} speed" + if self._use_name_translation: + # Delete _attr_name so translated name is used. + # This also clears the cached_property in new HA versions. + del self._attr_name er.async_get(hass).async_update_entity( self.entity_id, original_name=self.name ) From dbacfe53aaff211d99837712d1a292945c4152a2 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 26 Jan 2024 21:31:46 -0600 Subject: [PATCH 62/96] Bump actions/checkout to v4 --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c3069e1..e8a997d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -10,7 +10,7 @@ jobs: name: With hassfest steps: - name: 📥 Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 🏃 Hassfest validation uses: "home-assistant/actions/hassfest@master" From 4ff214a0bc98173fb0c512b92bf4f4d18fb99884 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 26 Jan 2024 21:32:14 -0600 Subject: [PATCH 63/96] Bump version to 3.0.0b5 --- custom_components/composite/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 686c0d0..1f9843e 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0b4" + "version": "3.0.0b5" } From fec63ae599576e6b70b349569b27db78f6291305 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 13 Feb 2024 20:50:29 -0600 Subject: [PATCH 64/96] Bump version to 3.0.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 1f9843e..3ab4a66 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/master/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.0.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0b5" + "version": "3.0.0" } From bb9fcc6511f16e601425dd46a096f7b76c06119f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 29 Feb 2024 15:55:12 -0600 Subject: [PATCH 65/96] Add optional driving state (#61) --- README.md | 38 +++++++----- custom_components/composite/__init__.py | 7 +++ custom_components/composite/config_flow.py | 50 ++++++++++++++-- custom_components/composite/const.py | 3 + custom_components/composite/device_tracker.py | 58 +++++++++---------- custom_components/composite/manifest.json | 4 +- .../composite/translations/en.json | 5 ++ .../composite/translations/nl.json | 5 ++ 8 files changed, 120 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index f081e57..456952a 100644 --- a/README.md +++ b/README.md @@ -6,28 +6,32 @@ 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. -## Breaking Change +## Installation -- All time zone related features have been removed. See https://github.com/pnbruckner/ha-entity-tz for an integration that replaces those features, and more. -- Any tracker entry removed from YAML configuration will be removed from the system. -- `trackers` in YAML configuration must have at least one entry. -- The `entity_id` attribute has been changed to `entities`. `entity_id` did not show up in the attribute list in the UI. +
+With HACS -## Installation -### With HACS [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/) You can use HACS to manage the installation and provide update notifications. -1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/): +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". + + ```text + https://github.com/pnbruckner/ha-composite-tracker + ``` + Or use this button: + + [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=pnbruckner&repository=ha-composite-tracker&category=integration) -```text -https://github.com/pnbruckner/ha-composite-tracker -``` -2. Install the integration using the appropriate button on the HACS Integrations page. Search for "composite". +1. Download the integration using the appropriate button. -### Manual +
+ +
+Manual Place a copy of the files from [`custom_components/composite`](custom_components/composite) in `/custom_components/composite`, @@ -35,6 +39,10 @@ where `` is your Home Assistant configuration directory. >__NOTE__: When downloading, make sure to use the `Raw` button from each file's page. +
+ +After it has been downloaded you will need to restart Home Assistant. + ### Versions This custom integration supports HomeAssistant versions 2023.7 or newer. @@ -57,6 +65,7 @@ composite: - **default_options** (*Optional*): Defines default values for corresponding options under **trackers**. - **require_movement** (*Optional*): Default is `false`. + - **driving_speed** (*Optional*) - **trackers**: The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). @@ -66,6 +75,7 @@ composite: - **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. +- **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`. #### Entity Dictionary @@ -109,8 +119,10 @@ direction | Compass heading of movement direction (if moving.) composite: default_options: require_movement: true + driving_speed: 15 trackers: - name: Me + driving_speed: 20 entity_id: - entity: device_tracker.platform1_me use_picture: true diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index b0cb6d2..b11b24a 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -27,6 +27,7 @@ from .const import ( CONF_ALL_STATES, CONF_DEFAULT_OPTIONS, + CONF_DRIVING_SPEED, CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_TIME_AS, @@ -102,10 +103,13 @@ def _defaults(config: dict) -> dict: unsupported_cfgs.add(CONF_TIME_AS) def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT] + def_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED) 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_DRIVING_SPEED not in tracker and def_drv_sp is not None: + tracker[CONF_DRIVING_SPEED] = def_drv_sp if unsupported_cfgs: _LOGGER.warning( @@ -115,6 +119,7 @@ def _defaults(config: dict) -> dict: ", ".join(sorted(unsupported_cfgs)), ) + del config[CONF_DEFAULT_OPTIONS] return config @@ -141,6 +146,7 @@ def _defaults(config: dict) -> dict: vol.Required(CONF_ENTITY_ID): _ENTITIES, vol.Optional(CONF_TIME_AS): cv.string, vol.Optional(CONF_REQ_MOVEMENT): cv.boolean, + vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), } CONFIG_SCHEMA = vol.Schema( { @@ -155,6 +161,7 @@ def _defaults(config: dict) -> dict: vol.Optional( CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT ): cv.boolean, + vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), } ), vol.Required(CONF_TRACKERS, default=list): vol.All( diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index f8fa035..9a75e15 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -22,6 +22,7 @@ CONF_ENTITY_ID, CONF_ID, CONF_NAME, + UnitOfSpeed, ) from homeassistant.core import State, callback from homeassistant.data_entry_flow import FlowHandler, FlowResult @@ -29,14 +30,20 @@ BooleanSelector, EntitySelector, EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, 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_ENTITY, CONF_REQ_MOVEMENT, CONF_USE_PICTURE, @@ -50,7 +57,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: 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)), + ("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT, CONF_DRIVING_SPEED)), ) } @@ -63,6 +70,13 @@ def _entries(self) -> list[ConfigEntry]: """Get existing config entries.""" return self.hass.config_entries.async_entries(DOMAIN) + @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]: @@ -81,6 +95,14 @@ async def async_step_options( if user_input is not None: self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT] + 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, + ) + elif CONF_DRIVING_SPEED in self.options: + del self.options[CONF_DRIVING_SPEED] prv_cfgs = { cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, []) } @@ -125,15 +147,27 @@ def entity_filter(state: State) -> bool: ) ), vol.Required(CONF_REQ_MOVEMENT): BooleanSelector(), + 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], + } + 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, - { - CONF_ENTITY_ID: self._entity_ids, - CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT], - }, + data_schema, suggested_values ) return self.async_show_form( step_id="options", data_schema=data_schema, errors=errors, last_step=False @@ -238,6 +272,10 @@ def options(self) -> dict[str, Any]: async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """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] diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index 9e10dba..35e4114 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -5,6 +5,7 @@ CONF_ALL_STATES = "all_states" CONF_DEFAULT_OPTIONS = "default_options" +CONF_DRIVING_SPEED = "driving_speed" CONF_ENTITY = "entity" CONF_REQ_MOVEMENT = "require_movement" CONF_TIME_AS = "time_as" @@ -22,3 +23,5 @@ ATTR_ENTITIES = "entities" 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 37239ec..b9eb7c4 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -19,7 +19,6 @@ SourceType, ) from homeassistant.components.device_tracker.config_entry import TrackerEntity -from homeassistant.components.zone import ENTITY_ID_HOME, async_active_zone from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -54,12 +53,14 @@ ATTR_LAT, ATTR_LON, CONF_ALL_STATES, + CONF_DRIVING_SPEED, CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_USE_PICTURE, MIN_ANGLE_SPEED, MIN_SPEED_SECONDS, SIG_COMPOSITE_SPEED, + STATE_DRIVING, ) _LOGGER = logging.getLogger(__name__) @@ -206,6 +207,7 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): _prev_seen: datetime | None = None _remove_track_states: Callable[[], None] | None = None _req_movement: bool + _driving_speed: float | None # m/s def __init__(self, entry: ConfigEntry) -> None: """Initialize Composite Device Tracker.""" @@ -278,6 +280,7 @@ 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] + self._driving_speed = options.get(CONF_DRIVING_SPEED) entity_cfgs = { entity_cfg[CONF_ENTITY]: entity_cfg for entity_cfg in options[CONF_ENTITY_ID] @@ -393,7 +396,9 @@ def _clear_state(self) -> None: self._attr_extra_state_attributes = {} self._prev_seen = None - async def _entity_updated(self, entity_id: str, new_state: State | None) -> None: + 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 @@ -489,21 +494,12 @@ async def _entity_updated(self, entity_id: str, new_state: State | None) -> None # Don't use new GPS data if it's not complete. if not gps 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: float = cast(State, cur_state).attributes[ATTR_LATITUDE] - cur_lon: float = cast(State, cur_state).attributes[ATTR_LONGITUDE] - cur_acc: int = cast(State, cur_state).attributes[ATTR_GPS_ACCURACY] - cur_gps_is_home = ( - async_active_zone( - self.hass, cur_lat, cur_lon, cur_acc - ).entity_id # type: ignore[union-attr] - == ENTITY_ID_HOME - ) - except (AttributeError, KeyError): - cur_gps_is_home = False + + # 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 @@ -511,24 +507,21 @@ async def _entity_updated(self, entity_id: str, new_state: State | None) -> None # 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 = cast(GPSType, (cur_lat, cur_lon)) - gps_accuracy = cur_acc + # If router 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 # 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 - # Otherwise, if new state is 'home' and old state is not 'home' - # and no GPS data, then use HA's configured Home location and - # make source_type gps. - elif state == STATE_HOME and not ( - cur_state and cur_state.state == STATE_HOME - ): + # 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 = cast( GPSType, (self.hass.config.latitude, self.hass.config.longitude), @@ -641,6 +634,13 @@ def _set_state( angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat))) if angle < 0: angle += 360 + if ( + speed is not None + and self._driving_speed is not None + and speed >= self._driving_speed + and self.state == STATE_NOT_HOME + ): + self._location_name = STATE_DRIVING _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 diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 3ab4a66..c9f82fc 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.0.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.1.0b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0" + "version": "3.1.0b0" } diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index 7ad5ecb..f0ce422 100644 --- a/custom_components/composite/translations/en.json +++ b/custom_components/composite/translations/en.json @@ -18,6 +18,7 @@ "options": { "title": "Composite Options", "data": { + "driving_speed": "Driving speed", "entity_id": "Input entities", "require_movement": "Require movement" } @@ -38,6 +39,9 @@ "entity": { "device_tracker": { "tracker": { + "state": { + "driving": "Driving" + }, "state_attributes": { "battery_charging": {"name": "Battery charging"}, "entities": {"name": "Seen entities"}, @@ -68,6 +72,7 @@ "options": { "title": "Composite Options", "data": { + "driving_speed": "Driving speed", "entity_id": "Input entities", "require_movement": "Require movement" } diff --git a/custom_components/composite/translations/nl.json b/custom_components/composite/translations/nl.json index cb02fe3..e1f6884 100644 --- a/custom_components/composite/translations/nl.json +++ b/custom_components/composite/translations/nl.json @@ -18,6 +18,7 @@ "options": { "title": "Samengestelde opties", "data": { + "driving_speed": "Rijsnelheid", "entity_id": "Voer entiteiten in", "require_movement": "Vereist beweging" } @@ -38,6 +39,9 @@ "entity": { "device_tracker": { "tracker": { + "state": { + "driving": "Rijden" + }, "state_attributes": { "battery_charging": {"name": "Batterij opladen"}, "entities": {"name": "Entiteiten gezien"}, @@ -68,6 +72,7 @@ "options": { "title": "Samengestelde opties", "data": { + "driving_speed": "Rijsnelheid", "entity_id": "Voer entiteiten in", "require_movement": "Vereist beweging" } From f9c76ad25f2335185119afd7b3b5632ef68a3d38 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 29 Feb 2024 15:58:18 -0600 Subject: [PATCH 66/96] Bump version to 3.1.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index c9f82fc..4718019 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.1.0b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.1.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.1.0b0" + "version": "3.1.0" } From 8c31a5887f38fd243abeaf575edbb7374de33a88 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 29 Feb 2024 16:37:01 -0600 Subject: [PATCH 67/96] Do not record entities & entity_picture attributes in database --- custom_components/composite/device_tracker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index b9eb7c4..8e394f4 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -195,6 +195,7 @@ 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 From 96a2681e93d717f35e867ed512b3f6e9f81792bc Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 29 Feb 2024 16:39:43 -0600 Subject: [PATCH 68/96] Bump version to 3.2.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 4718019..da75197 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.1.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.2.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.1.0" + "version": "3.2.0" } From 36d07203f78c5323d24f32c075780dfc08accc66 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 1 Mar 2024 19:24:57 -0600 Subject: [PATCH 69/96] Fix bug in speed sensor when imported config is changed (cherry picked from commit 03319f6f83fa47c176a91c0c98e5ff5b5c7e4b02) --- custom_components/composite/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index 0ae9e16..38ad9f4 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -116,6 +116,8 @@ 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 in new HA versions. From daf5874296ef89766a5833c61d450021c8630877 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 1 Mar 2024 21:38:16 -0600 Subject: [PATCH 70/96] Bump version to 3.2.1 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index da75197..1b94f0f 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.2.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.2.1/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.2.0" + "version": "3.2.1" } From 23684475fcc66f8a01e02de693518c24c825ba84 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 14 Mar 2024 14:03:08 -0500 Subject: [PATCH 71/96] Add local file option for entity picture (#65) --- README.md | 21 +- custom_components/composite/__init__.py | 63 +++-- custom_components/composite/config_flow.py | 215 +++++++++++++++++- custom_components/composite/const.py | 18 +- custom_components/composite/device_tracker.py | 19 +- custom_components/composite/manifest.json | 8 +- .../composite/translations/en.json | 86 +++++-- 7 files changed, 373 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 456952a..6ef349a 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,18 @@ After it has been downloaded you will need to restart Home Assistant. This custom integration supports HomeAssistant versions 2023.7 or newer. -## Configuration variables +## Configuration -Composite entities can be created via the UI on the Integrations page or by YAML entries. This section describes the latter. +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: + +[![add integration](https://my.home-assistant.io/badges/config_flow_start.svg)](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 @@ -76,12 +85,13 @@ composite: - **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. - **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`. +- **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 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. +- **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 @@ -133,7 +143,6 @@ composite: - name: Better Half id: wife require_movement: false - entity_id: - entity: device_tracker.platform_wife - use_picture: true + entity_picture: /local/wife.jpg + entity_id: device_tracker.platform_wife ``` diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index b11b24a..6347644 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -3,7 +3,9 @@ import asyncio from collections.abc import Coroutine +from contextlib import suppress import logging +from pathlib import Path from typing import Any, cast import voluptuous as vol @@ -17,7 +19,7 @@ SERVICE_RELOAD, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service @@ -29,6 +31,7 @@ CONF_DEFAULT_OPTIONS, CONF_DRIVING_SPEED, CONF_ENTITY, + CONF_ENTITY_PICTURE, CONF_REQ_MOVEMENT, CONF_TIME_AS, CONF_TRACKERS, @@ -67,26 +70,57 @@ def _entities(entities: list[str | dict]) -> list[dict]: return result -def _tracker_ids( - value: list[dict[vol.Required | vol.Optional, Any]] +def _entity_picture(entity_picture: str) -> str: + """Validate entity picture. + + 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[vol.Required | vol.Optional, Any]] ) -> list[dict[vol.Required | vol.Optional, Any]]: - """Determine tracker ID. + """Validate tracker entries. - Also ensure IDs are unique. + 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 conf in value: - if CONF_ID not in conf: - name: str = conf[CONF_NAME] + for t_idx, tracker in enumerate(trackers): + if CONF_ID not in tracker: + name: str = tracker[CONF_NAME] if name == slugify(name): - conf[CONF_ID] = name - conf[CONF_NAME] = name.replace("_", " ").title() + tracker[CONF_ID] = name + tracker[CONF_NAME] = name.replace("_", " ").title() else: - conf[CONF_ID] = cv.slugify(conf[CONF_NAME]) - ids.append(cast(str, conf[CONF_ID])) + 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 value + return trackers def _defaults(config: dict) -> dict: @@ -147,6 +181,7 @@ def _defaults(config: dict) -> dict: vol.Optional(CONF_TIME_AS): cv.string, vol.Optional(CONF_REQ_MOVEMENT): cv.boolean, vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), + vol.Optional(CONF_ENTITY_PICTURE): vol.All(cv.string, _entity_picture), } CONFIG_SCHEMA = vol.Schema( { @@ -165,7 +200,7 @@ def _defaults(config: dict) -> dict: } ), vol.Required(CONF_TRACKERS, default=list): vol.All( - cv.ensure_list, vol.Length(1), [_TRACKER], _tracker_ids + cv.ensure_list, vol.Length(1), [_TRACKER], _trackers ), } ), diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index 9a75e15..97a72a8 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -2,13 +2,18 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any +import logging +from pathlib import Path +import shutil +from typing import Any, cast +import filetype import voluptuous as vol from homeassistant.backports.functools import cached_property 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, @@ -30,9 +35,14 @@ BooleanSelector, EntitySelector, EntitySelectorConfig, + FileSelector, + FileSelectorConfig, NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, TextSelector, ) from homeassistant.util.unit_conversion import SpeedConverter @@ -45,11 +55,16 @@ CONF_ALL_STATES, CONF_DRIVING_SPEED, CONF_ENTITY, + CONF_ENTITY_PICTURE, CONF_REQ_MOVEMENT, 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.""" @@ -57,7 +72,15 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: 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_DRIVING_SPEED)), + ( + "options", + ( + CONF_ENTITY_ID, + CONF_REQ_MOVEMENT, + CONF_DRIVING_SPEED, + CONF_ENTITY_PICTURE, + ), + ), ) } @@ -70,6 +93,33 @@ 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" + + @cached_property + def _local_files(self) -> list[str]: + """Return a list of files in "/local" and subdirectories.""" + 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.""" @@ -87,6 +137,55 @@ 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. + + 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[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 ) -> FlowResult: @@ -120,7 +219,7 @@ async def async_step_options( ) self.options[CONF_ENTITY_ID] = new_cfgs if new_cfgs: - return await self.async_step_use_picture() + return await self.async_step_ep_menu() errors[CONF_ENTITY_ID] = "at_least_one_entity" def entity_filter(state: State) -> bool: @@ -173,20 +272,40 @@ def entity_filter(state: State) -> bool: step_id="options", data_schema=data_schema, errors=errors, last_step=False ) - async def async_step_use_picture( + async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResult: + """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 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 ) -> FlowResult: """Specify which input to get composite's picture from.""" if user_input is not None: - entity_id = user_input.get(CONF_ENTITY) - for cfg in self.options[CONF_ENTITY_ID]: - cfg[CONF_USE_PICTURE] = cfg[CONF_ENTITY] == entity_id + 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=self._entity_ids) + EntitySelectorConfig(include_entities=include_entities) ) } ) @@ -200,9 +319,87 @@ async def async_step_use_picture( data_schema, {CONF_ENTITY: picture_entity_id} ) return self.async_show_form( - step_id="use_picture", data_schema=data_schema, last_step=False + 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 + ) -> FlowResult: + """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 = 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 + ) -> FlowResult: + """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() + + local_dir_exists = self._local_dir.is_dir() + local_file = await self.hass.async_add_executor_job( + self._save_uploaded_file, uploaded_file_id + ) + self._set_entity_picture(local_file=local_file) + if local_dir_exists: + return await self.async_step_all_states() + return await self.async_step_ep_warn() + + 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 + ) -> FlowResult: + """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) -> FlowResult: + """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 ) -> FlowResult: diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index 35e4114..c515a59 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -1,26 +1,34 @@ """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_ENTITY = "entity" +CONF_ENTITY_PICTURE = "entity_picture" CONF_REQ_MOVEMENT = "require_movement" CONF_TIME_AS = "time_as" CONF_TRACKERS = "trackers" CONF_USE_PICTURE = "use_picture" -DEF_REQ_MOVEMENT = False - -MIN_SPEED_SECONDS = 3 -MIN_ANGLE_SPEED = 1 # meters / second - 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" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 8e394f4..81737f2 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -49,12 +49,17 @@ 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_ENTITY, + CONF_ENTITY_PICTURE, CONF_REQ_MOVEMENT, CONF_USE_PICTURE, MIN_ANGLE_SPEED, @@ -69,10 +74,6 @@ # async_request_call, atomic. PARALLEL_UPDATES = 1 -ATTR_CHARGING = "charging" -ATTR_LAST_SEEN = "last_seen" -ATTR_LAST_TIMESTAMP = "last_timestamp" -ATTR_LAST_ENTITY_ID = "last_entity_id" _RESTORE_EXTRA_ATTRS = ( ATTR_ENTITY_ID, @@ -209,6 +210,7 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): _remove_track_states: Callable[[], None] | None = None _req_movement: bool _driving_speed: float | None # m/s + _use_entity_picture: bool def __init__(self, entry: ConfigEntry) -> None: """Initialize Composite Device Tracker.""" @@ -320,6 +322,13 @@ async def _process_config_options(self) -> None: 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) -> None: """Process input entity state update.""" await self.async_request_call( @@ -357,7 +366,7 @@ async def _restore_state(self) -> None: # Even if we don't need to restore most of the state (i.e., if we've been # updated by at least one new state), we may need to restore entity picture, if # we had one but the entities we've been updated from so far do not. - if not self.entity_picture: + if not self.entity_picture and self._use_entity_picture: self._attr_entity_picture = last_state.attributes.get(ATTR_ENTITY_PICTURE) if self._prev_seen: diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 1b94f0f..5cc14fc 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -3,10 +3,10 @@ "name": "Composite", "codeowners": ["@pnbruckner"], "config_flow": true, - "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.2.1/README.md", + "dependencies": ["file_upload"], + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.3.0b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", - "requirements": [], - "version": "3.2.1" + "requirements": ["filetype==1.2.0"], + "version": "3.3.0b0" } diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index f0ce422..6e77347 100644 --- a/custom_components/composite/translations/en.json +++ b/custom_components/composite/translations/en.json @@ -9,6 +9,42 @@ "entity": "Entity" } }, + "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": { @@ -22,13 +58,6 @@ "entity_id": "Input entities", "require_movement": "Require movement" } - }, - "use_picture": { - "title": "Picture Entity", - "description": "Choose entity whose picture will be used for the composite.\nIt may be none.", - "data": { - "entity": "Entity" - } } }, "error": { @@ -69,6 +98,42 @@ "entity": "Entity" } }, + "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": { @@ -76,13 +141,6 @@ "entity_id": "Input entities", "require_movement": "Require movement" } - }, - "use_picture": { - "title": "Picture Entity", - "description": "Choose entity whose picture will be used for the composite.\nIt may be none.", - "data": { - "entity": "Entity" - } } }, "error": { From a78fece5e24dae7ae529dca85d6979008de43887 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 14 Mar 2024 14:05:11 -0500 Subject: [PATCH 72/96] Bump version to 3.3.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 5cc14fc..96157c0 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.3.0b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.3.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.3.0b0" + "version": "3.3.0" } From 915aeb6416613f299c3e46bf50db8a05db5174d5 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 3 Apr 2024 11:56:39 -0500 Subject: [PATCH 73/96] Accept various formats for last_seen/last_timestamp attribute (#67) - naive Python datetime - aware Python datetime - dt_util.utc_from_timestamp(float(x)) - dt_util.parse_datetiem(x) --- README.md | 26 ++++++++++++++++-- custom_components/composite/device_tracker.py | 27 +++++++++++-------- custom_components/composite/manifest.json | 4 +-- info.md | 2 +- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6ef349a..e62b3b2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Composite Device Tracker Platform 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`, `last_timestamp` or`last_updated` (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. +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. @@ -94,10 +94,32 @@ composite: - **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'. -If a watched device has a `last_seen` or `last_timestamp` attribute, that will be used in the composite device. If not, then `last_updated` from the entity's state will be used instead. +### Last seen + +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. + +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`) + +* See [Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) + +Integrations known to provide a supported "last seen" attribute: + +- 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`) + +### Miscellaneous 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. diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 81737f2..590047d 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -417,19 +417,24 @@ async def _entity_updated( # noqa: C901 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, or if that doesn't exist, then - # last_updated from the new state object. + # attributes defined by _LAST_SEEN_ATTRS, as a datetime. + + def get_last_seen() -> datetime | None: + """Get last_seen 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 raw_last_seen + with suppress(TypeError, ValueError): + return dt_util.utc_from_timestamp(float(raw_last_seen)) + with suppress(TypeError): + return dt_util.parse_datetime(raw_last_seen) + return None + # Make sure last_seen is timezone aware in local timezone. # Note that dt_util.as_local assumes naive datetime is in local timezone. - last_seen: datetime | str | None = new_attrs.get(_LAST_SEEN_ATTRS) - if not isinstance(last_seen, datetime): - try: - last_seen = dt_util.utc_from_timestamp( - float(last_seen) # type: ignore[arg-type] - ) - except (TypeError, ValueError): - last_seen = new_state.last_updated - last_seen = dt_util.as_local(last_seen) + # Use last_updated from the new state object if no valid "last seen" was found. + last_seen = dt_util.as_local(get_last_seen() or new_state.last_updated) old_last_seen = entity.seen if old_last_seen and last_seen < old_last_seen: diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 96157c0..d319693 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.3.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.0b1/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.3.0" + "version": "3.4.0b1" } diff --git a/info.md b/info.md index bc0204d..d431cae 100644 --- a/info.md +++ b/info.md @@ -1,6 +1,6 @@ # Composite Device Tracker Platform 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`, `last_timestamp` or`last_updated` (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. +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. From c894fd52fb54e4085bcc8406621ef70b0bb0e1de Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 3 Apr 2024 12:10:06 -0500 Subject: [PATCH 74/96] Bump version to 3.4.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index d319693..dd148fc 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.0b1/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.0b1" + "version": "3.4.0" } From d285873718e8811cb7cedc1dada5f7cd40586482 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 30 Apr 2024 10:08:40 -0500 Subject: [PATCH 75/96] Import cached_property from functools instead of backports (#73) --- custom_components/composite/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index 97a72a8..2a53ebf 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import abstractmethod +from functools import cached_property import logging from pathlib import Path import shutil @@ -10,7 +11,6 @@ import filetype import voluptuous as vol -from homeassistant.backports.functools import cached_property 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 6f41f6159e147bc2eae6537aaa93a630d13e2511 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 30 Apr 2024 10:10:07 -0500 Subject: [PATCH 76/96] Bump version to 3.4.1 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index dd148fc..3edf305 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.1/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.0" + "version": "3.4.1" } From 54caacf75307af66fc590a1a3d3f348e161ba230 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 23 May 2024 09:22:16 -0500 Subject: [PATCH 77/96] Restore before checking input entities at startup (#76) --- custom_components/composite/device_tracker.py | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 590047d..f4c7f2b 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -269,8 +269,8 @@ async def async_added_to_hass(self) -> None: self._config_entry_updated ) ) - await self.async_request_call(self._process_config_options()) 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.""" @@ -356,22 +356,10 @@ async def _config_entry_updated( async def _restore_state(self) -> None: """Restore state.""" - - # Do we need to restore from saved state and, if so, is there one? - if self._prev_seen and self.entity_picture: - return if not (last_state := await self.async_get_last_state()): return - # Even if we don't need to restore most of the state (i.e., if we've been - # updated by at least one new state), we may need to restore entity picture, if - # we had one but the entities we've been updated from so far do not. - if not self.entity_picture and self._use_entity_picture: - self._attr_entity_picture = last_state.attributes.get(ATTR_ENTITY_PICTURE) - - if self._prev_seen: - return - + self._attr_entity_picture = last_state.attributes.get(ATTR_ENTITY_PICTURE) self._battery_level = last_state.attributes.get(ATTR_BATTERY_LEVEL) self._source_type = last_state.attributes[ATTR_SOURCE_TYPE] self._location_accuracy = last_state.attributes.get(ATTR_GPS_ACCURACY) or 0 @@ -387,9 +375,11 @@ async def _restore_state(self) -> None: ATTR_ENTITIES ] = self._attr_extra_state_attributes.pop(ATTR_ENTITY_ID) with suppress(KeyError): - self._attr_extra_state_attributes[ATTR_LAST_SEEN] = dt_util.parse_datetime( + last_seen = dt_util.parse_datetime( self._attr_extra_state_attributes[ATTR_LAST_SEEN] ) + self._attr_extra_state_attributes[ATTR_LAST_SEEN] = last_seen + self._prev_seen = last_seen if self.source_type in _SOURCE_TYPE_NON_GPS and ( self.latitude is None or self.longitude is None ): From 7a82aaf3b9427b951512a8dbb9ceec06772af1f0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 23 May 2024 09:37:49 -0500 Subject: [PATCH 78/96] Bump version to 3.4.2 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 3edf305..9cd1954 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.1/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.2/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.1" + "version": "3.4.2" } From 060458f1649833de6e8b7eb2112469340baccbe4 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 13 Aug 2024 08:46:21 -0500 Subject: [PATCH 79/96] Fix new error w/ HA 2024.8 (#80) Move YAML config processing to config.py. A couple other minor fixes/improvements. --- custom_components/__init__.py | 2 + custom_components/composite/__init__.py | 198 +---------------- custom_components/composite/config.py | 207 ++++++++++++++++++ custom_components/composite/config_flow.py | 21 +- custom_components/composite/device_tracker.py | 10 +- custom_components/composite/manifest.json | 4 +- 6 files changed, 229 insertions(+), 213 deletions(-) create mode 100644 custom_components/__init__.py create mode 100644 custom_components/composite/config.py 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 6347644..7c8940a 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -3,213 +3,21 @@ import asyncio from collections.abc import Coroutine -from contextlib import suppress import logging -from pathlib import Path from typing import Any, cast -import voluptuous as vol - from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_ENTITY_ID, - CONF_ID, - CONF_NAME, - SERVICE_RELOAD, - Platform, -) -from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass -import homeassistant.helpers.config_validation as cv +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 homeassistant.util import slugify -from .const import ( - CONF_ALL_STATES, - CONF_DEFAULT_OPTIONS, - CONF_DRIVING_SPEED, - CONF_ENTITY, - CONF_ENTITY_PICTURE, - CONF_REQ_MOVEMENT, - CONF_TIME_AS, - CONF_TRACKERS, - CONF_USE_PICTURE, - DEF_REQ_MOVEMENT, - DOMAIN, -) +from .const import CONF_TRACKERS, DOMAIN -CONF_TZ_FINDER = "tz_finder" -CONF_TZ_FINDER_CLASS = "tz_finder_class" PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] - -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 _entity_picture(entity_picture: str) -> str: - """Validate entity picture. - - 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[vol.Required | vol.Optional, Any]] -) -> list[dict[vol.Required | vol.Optional, 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_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED) - 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_DRIVING_SPEED not in tracker and def_drv_sp is not None: - tracker[CONF_DRIVING_SPEED] = def_drv_sp - - if unsupported_cfgs: - _LOGGER.warning( - "Your %s configuration contains options that are no longer supported: %s; " - "Please remove them", - DOMAIN, - ", ".join(sorted(unsupported_cfgs)), - ) - - 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, -) -_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_DRIVING_SPEED): vol.Coerce(float), - 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_DRIVING_SPEED): vol.Coerce(float), - } - ), - vol.Required(CONF_TRACKERS, default=list): vol.All( - cv.ensure_list, vol.Length(1), [_TRACKER], _trackers - ), - } - ), - _defaults, - ) - }, - extra=vol.ALLOW_EXTRA, -) - _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py new file mode 100644 index 0000000..5ebcb3d --- /dev/null +++ b/custom_components/composite/config.py @@ -0,0 +1,207 @@ +"""Composite config validation.""" +from __future__ import annotations + +from contextlib import suppress +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_ENTITY, + CONF_ENTITY_PICTURE, + CONF_REQ_MOVEMENT, + CONF_TIME_AS, + CONF_TRACKERS, + CONF_USE_PICTURE, + DEF_REQ_MOVEMENT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +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 _entity_picture(entity_picture: str) -> str: + """Validate entity picture. + + 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[vol.Required | vol.Optional, Any]] +) -> list[dict[vol.Required | vol.Optional, 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_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED) + 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_DRIVING_SPEED not in tracker and def_drv_sp is not None: + tracker[CONF_DRIVING_SPEED] = def_drv_sp + + if unsupported_cfgs: + _LOGGER.warning( + "Your %s configuration contains options that are no longer supported: %s; " + "Please remove them", + DOMAIN, + ", ".join(sorted(unsupported_cfgs)), + ) + + 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, +) +_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_DRIVING_SPEED): vol.Coerce(float), + 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_DRIVING_SPEED): vol.Coerce(float), + } + ), + 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.""" + return cast(ConfigType, _CONFIG_SCHEMA(config)) diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index 2a53ebf..2dc1b94 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -205,18 +205,17 @@ async def async_step_options( prv_cfgs = { cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, []) } - new_cfgs: list[dict[str, Any]] = [] - for entity_id in user_input[CONF_ENTITY_ID]: - new_cfgs.append( - prv_cfgs.get( - entity_id, - { - CONF_ENTITY: entity_id, - CONF_USE_PICTURE: False, - CONF_ALL_STATES: False, - }, - ) + 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: return await self.async_step_ep_menu() diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index f4c7f2b..16d6e7c 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -9,7 +9,7 @@ import logging from math import atan2, degrees from types import MappingProxyType -from typing import Any, Optional, cast +from typing import Any, cast from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.components.device_tracker import ( @@ -438,9 +438,9 @@ def get_last_seen() -> datetime | None: if not gps: with suppress(KeyError): gps = new_attrs[ATTR_LAT], new_attrs[ATTR_LON] - gps_accuracy = cast(Optional[int], new_attrs.get(_GPS_ACCURACY_ATTRS)) - battery = cast(Optional[int], new_attrs.get(_BATTERY_ATTRS)) - charging = cast(Optional[bool], new_attrs.get(_CHARGING_ATTRS)) + gps_accuracy = cast(int | 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: @@ -467,7 +467,7 @@ def get_last_seen() -> datetime | None: return new_data = Location(gps, gps_accuracy) - old_data = cast(Optional[Location], entity.data) + old_data = cast(Location | None, entity.data) if last_seen == old_last_seen and new_data == old_data: return entity.good(last_seen, source_type, new_data) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 9cd1954..27900c9 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.2/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.3b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.2" + "version": "3.4.3b0" } From 9ff67749ec14305ba9a29cda1e684535b00cddd4 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 13 Aug 2024 08:47:56 -0500 Subject: [PATCH 80/96] Bump version to 3.4.3 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 27900c9..b95c788 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.3b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.3/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.3b0" + "version": "3.4.3" } From 26d3a7007e78350e9e391a6da1289fed6d42e242 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 19 Aug 2024 08:19:32 -0500 Subject: [PATCH 81/96] Add missing PACKAGE_MERGE_HINT to config.py --- custom_components/composite/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py index 5ebcb3d..04372aa 100644 --- a/custom_components/composite/config.py +++ b/custom_components/composite/config.py @@ -30,6 +30,8 @@ _LOGGER = logging.getLogger(__name__) +PACKAGE_MERGE_HINT = "dict" + CONF_TZ_FINDER = "tz_finder" CONF_TZ_FINDER_CLASS = "tz_finder_class" From aa45575263bac89ec80cb335dee8de52d61a37af Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 19 Aug 2024 08:21:51 -0500 Subject: [PATCH 82/96] Bump version to 3.4.4 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index b95c788..4fc77e5 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.3/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.4/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.3" + "version": "3.4.4" } From 372ea19d39a6f6227f8a271f8ec8afd7d1163f50 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 20 Nov 2024 09:17:29 -0600 Subject: [PATCH 83/96] Use UTC datetimes internally (#84) Python datetime object comparisons and math don't work as expected when the objects are aware and have the same tzinfo attribute. Basically, the fold attribute is ignored in this case, which can lead to wrong results. Avoid this problem by using aware times in UTC internally. Only use local time zone for user visible attributes. --- custom_components/composite/device_tracker.py | 30 ++++++++++++------- custom_components/composite/manifest.json | 4 +-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 16d6e7c..c53c792 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -378,8 +378,13 @@ async def _restore_state(self) -> None: last_seen = dt_util.parse_datetime( self._attr_extra_state_attributes[ATTR_LAST_SEEN] ) - self._attr_extra_state_attributes[ATTR_LAST_SEEN] = last_seen - self._prev_seen = 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 in _SOURCE_TYPE_NON_GPS and ( self.latitude is None or self.longitude is None ): @@ -410,21 +415,21 @@ async def _entity_updated( # noqa: C901 # attributes defined by _LAST_SEEN_ATTRS, as a datetime. def get_last_seen() -> datetime | None: - """Get last_seen from one of the possible attributes.""" + """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 raw_last_seen + 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): - return dt_util.parse_datetime(raw_last_seen) + 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 - # Make sure last_seen is timezone aware in local timezone. - # Note that dt_util.as_local assumes naive datetime is in local timezone. # Use last_updated from the new state object if no valid "last seen" was found. - last_seen = dt_util.as_local(get_last_seen() or new_state.last_updated) + 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: @@ -549,8 +554,8 @@ def get_last_seen() -> datetime | None: "last_seen not newer than previous update (%s) <= (%s)", self.entity_id, entity_id, - last_seen, - self._prev_seen, + dt_util.as_local(last_seen), + dt_util.as_local(self._prev_seen), ) return @@ -563,7 +568,7 @@ def get_last_seen() -> datetime | None: if _entity.source_type ), ATTR_LAST_ENTITY_ID: entity_id, - ATTR_LAST_SEEN: _nearest_second(last_seen), + ATTR_LAST_SEEN: dt_util.as_local(_nearest_second(last_seen)), } if charging is not None: attrs[ATTR_BATTERY_CHARGING] = charging @@ -617,6 +622,9 @@ def _set_state( assert attributes last_ent = cast(str, attributes[ATTR_LAST_ENTITY_ID]) last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) + # 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 last_ent != prev_ent: diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 4fc77e5..7c9b3ba 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.4/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.5b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.4" + "version": "3.4.5b0" } From 3429c56a624ca9b03c2f195cbc3cbea70659f709 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 20 Nov 2024 09:19:17 -0600 Subject: [PATCH 84/96] Bump version to 3.4.5 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 7c9b3ba..64bf124 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.5b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.5/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.5b0" + "version": "3.4.5" } From d7d4c8c5608f3dd727b2d94ff00794ba4871dfe0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 30 Sep 2025 12:49:36 -0500 Subject: [PATCH 85/96] Support Home Assistant 2024.8.3 or newer (#91) Drop support for HA versions before 2024.8.3. Move file I/O to executor. Don't force sensor updates. --- README.md | 2 +- custom_components/composite/config.py | 10 ++- custom_components/composite/config_flow.py | 87 ++++++++++++------- custom_components/composite/device_tracker.py | 4 +- custom_components/composite/manifest.json | 4 +- custom_components/composite/sensor.py | 1 - hacs.json | 2 +- 7 files changed, 67 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index e62b3b2..832aa96 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ After it has been downloaded you will need to restart Home Assistant. ### Versions -This custom integration supports HomeAssistant versions 2023.7 or newer. +This custom integration supports HomeAssistant versions 2024.8.3 or newer. ## Configuration diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py index 04372aa..99ceda8 100644 --- a/custom_components/composite/config.py +++ b/custom_components/composite/config.py @@ -64,6 +64,8 @@ def _entities(entities: list[str | dict]) -> list[dict]: 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" @@ -82,9 +84,7 @@ def _entity_picture(entity_picture: str) -> str: return str(local_dir / local_file) -def _trackers( - trackers: list[dict[vol.Required | vol.Optional, Any]] -) -> list[dict[vol.Required | vol.Optional, Any]]: +def _trackers(trackers: list[dict[str, Any]]) -> list[dict[str, Any]]: """Validate tracker entries. Determine tracker IDs and ensure they are unique. @@ -206,4 +206,6 @@ async def async_validate_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Validate configuration.""" - return cast(ConfigType, _CONFIG_SCHEMA(config)) + # 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 index 2dc1b94..cd144a1 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from functools import cached_property +from functools import cached_property # pylint: disable=hass-deprecated-import import logging from pathlib import Path import shutil @@ -17,7 +17,9 @@ from homeassistant.config_entries import ( SOURCE_IMPORT, ConfigEntry, + ConfigEntryBaseFlow, ConfigFlow, + ConfigFlowResult, OptionsFlowWithConfigEntry, ) from homeassistant.const import ( @@ -30,7 +32,6 @@ UnitOfSpeed, ) from homeassistant.core import State, callback -from homeassistant.data_entry_flow import FlowHandler, FlowResult from homeassistant.helpers.selector import ( BooleanSelector, EntitySelector, @@ -85,7 +86,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: } -class CompositeFlow(FlowHandler): +class CompositeFlow(ConfigEntryBaseFlow): """Composite flow mixin.""" @cached_property @@ -103,9 +104,11 @@ def _uploaded_dir(self) -> Path: """Return real path to "/local/uploaded" directory.""" return self._local_dir / "uploaded" - @cached_property def _local_files(self) -> list[str]: - """Return a list of files in "/local" and subdirectories.""" + """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 [] @@ -171,14 +174,14 @@ def _set_entity_picture( def _save_uploaded_file(self, uploaded_file_id: str) -> str: """Save uploaded file. - Must be called in an executor. + 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[filetype.guess_mime(uf_path)] + 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(): @@ -188,7 +191,7 @@ def _save_uploaded_file(self, uploaded_file_id: str) -> str: async def async_step_options( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Get config options.""" errors = {} @@ -271,7 +274,9 @@ def entity_filter(state: State) -> bool: step_id="options", data_schema=data_schema, errors=errors, last_step=False ) - async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResult: + 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 @@ -281,7 +286,7 @@ async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResul cur_source = entity_id menu_options = ["all_states", "ep_upload_file", "ep_input_entity"] - if self._local_files: + 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") @@ -294,7 +299,7 @@ async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResul async def async_step_ep_input_entity( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> 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)) @@ -323,13 +328,13 @@ async def async_step_ep_input_entity( async def async_step_ep_local_file( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> 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 = self._local_files + 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) @@ -353,21 +358,31 @@ async def async_step_ep_local_file( async def async_step_ep_upload_file( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> 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() - local_dir_exists = self._local_dir.is_dir() - local_file = await self.hass.async_add_executor_job( - self._save_uploaded_file, uploaded_file_id + 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 local_dir_exists: - return await self.async_step_all_states() - return await self.async_step_ep_warn() + 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( @@ -383,7 +398,7 @@ async def async_step_ep_upload_file( async def async_step_ep_warn( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> 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() @@ -394,14 +409,16 @@ async def async_step_ep_warn( last_step=False, ) - async def async_step_ep_none(self, _: dict[str, Any] | None = None) -> FlowResult: + 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 - ) -> FlowResult: + ) -> ConfigFlowResult: """Specify if all states should be used for appropriate entities.""" if user_input is not None: entity_ids = user_input.get(CONF_ENTITY, []) @@ -430,7 +447,9 @@ async def async_step_all_states( 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) -> FlowResult: + async def async_step_done( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Finish the flow.""" @@ -457,16 +476,14 @@ def async_get_options_flow(config_entry: ConfigEntry) -> CompositeOptionsFlow: @callback def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" - if config_entry.source == SOURCE_IMPORT: - return False - return True + 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]) -> FlowResult: + 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( @@ -483,7 +500,9 @@ async def async_step_import(self, data: dict[str, Any]) -> FlowResult: **split_conf(data), # type: ignore[arg-type] ) - async def async_step_user(self, _: dict[str, Any] | None = None) -> FlowResult: + async def async_step_user( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Start user config flow.""" return await self.async_step_name() @@ -499,7 +518,7 @@ def _name_used(self, name: str) -> bool: async def async_step_name( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Get name.""" errors = {} @@ -517,7 +536,9 @@ async def async_step_name( step_id="name", data_schema=data_schema, errors=errors, last_step=False ) - async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + 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) @@ -525,6 +546,8 @@ async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: class CompositeOptionsFlow(OptionsFlowWithConfigEntry, CompositeFlow): """Composite integration options flow.""" - async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + 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/device_tracker.py b/custom_components/composite/device_tracker.py index c53c792..2a8b029 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -37,7 +37,7 @@ STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -329,7 +329,7 @@ async def _process_config_options(self) -> None: self._attr_entity_picture = None self._use_entity_picture = False - async def state_listener(event: Event) -> None: + 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"]) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 64bf124..7644c74 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.5/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.5" + "version": "3.5.0b0" } diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index 38ad9f4..1bcaa36 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -98,7 +98,6 @@ def direction(angle: int | None) -> str | None: ] self._attr_native_value = value - self._attr_force_update = bool(value) self._attr_extra_state_attributes = { ATTR_ANGLE: angle, ATTR_DIRECTION: direction(angle), diff --git a/hacs.json b/hacs.json index 04d055c..6259499 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Composite Device Tracker", - "homeassistant": "2023.7" + "homeassistant": "2024.8.3" } From 7d30f7e1137d99d74412071dadfc7d0757af5a69 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 30 Sep 2025 12:50:51 -0500 Subject: [PATCH 86/96] Bump version to 3.5.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 7644c74..364dcc1 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.5.0b0" + "version": "3.5.0" } From 7bbdf379ec9ccd5756d5fa8be55d19167173d8d0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Oct 2025 14:10:15 -0500 Subject: [PATCH 87/96] Improve handling of speed and corresponding driving state (#99) * Maintain driving state even when not updating speed sensor When updates come too close together, speed sensor is not updated. However, if speed was previously above set driving speed threshold, still need to keep device_tracker state as driving, which was previously not the case. This update fixes that bug. * Add time to hold driving state after speed falls below configured driving speed Can help avoid undesired state changes when, e.g., slowing to go around a corner or when stopping at an intersection. * Add max time between speed sensor updates If set, will clear speed sensor state when exceeded. --- README.md | 8 + custom_components/composite/config.py | 33 ++++ custom_components/composite/config_flow.py | 57 +++++- custom_components/composite/const.py | 2 + custom_components/composite/device_tracker.py | 163 +++++++++++++++--- custom_components/composite/manifest.json | 4 +- .../composite/translations/en.json | 16 ++ 7 files changed, 252 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 832aa96..9e2c741 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ composite: - **default_options** (*Optional*): Defines default values for corresponding options under **trackers**. - **require_movement** (*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). @@ -84,7 +86,9 @@ composite: - **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. +- **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 Dictionary @@ -151,7 +155,10 @@ direction | Compass heading of movement direction (if moving.) composite: default_options: require_movement: true + max_speed_age: + minutes: 5 driving_speed: 15 + end_driving_delay: "00:02:00" trackers: - name: Me driving_speed: 20 @@ -165,6 +172,7 @@ composite: - 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/composite/config.py b/custom_components/composite/config.py index 99ceda8..87b834b 100644 --- a/custom_components/composite/config.py +++ b/custom_components/composite/config.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextlib import suppress +from datetime import timedelta import logging from pathlib import Path from typing import Any, cast @@ -18,8 +19,10 @@ 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_TIME_AS, CONF_TRACKERS, @@ -61,6 +64,17 @@ def _entities(entities: list[str | dict]) -> list[dict]: 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. @@ -128,13 +142,22 @@ def _defaults(config: dict) -> dict: unsupported_cfgs.add(CONF_TIME_AS) def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT] + 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_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( @@ -143,6 +166,11 @@ def _defaults(config: dict) -> dict: 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 @@ -165,13 +193,16 @@ def _defaults(config: dict) -> dict: 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_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( @@ -187,7 +218,9 @@ def _defaults(config: dict) -> dict: vol.Optional( CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT ): 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( diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index cd144a1..6f6809e 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -34,6 +34,8 @@ from homeassistant.core import State, callback from homeassistant.helpers.selector import ( BooleanSelector, + DurationSelector, + DurationSelectorConfig, EntitySelector, EntitySelectorConfig, FileSelector, @@ -55,8 +57,10 @@ 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, DOMAIN, @@ -78,7 +82,9 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: ( CONF_ENTITY_ID, CONF_REQ_MOVEMENT, + CONF_MAX_SPEED_AGE, CONF_DRIVING_SPEED, + CONF_END_DRIVING_DELAY, CONF_ENTITY_PICTURE, ), ), @@ -197,14 +203,21 @@ async def async_step_options( if user_input is not None: self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT] + 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, ) - elif CONF_DRIVING_SPEED in self.options: - del self.options[CONF_DRIVING_SPEED] + 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, []) } @@ -221,6 +234,8 @@ async def async_step_options( ] 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" @@ -248,6 +263,11 @@ def entity_filter(state: State) -> bool: ) ), vol.Required(CONF_REQ_MOVEMENT): 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, @@ -261,6 +281,8 @@ def entity_filter(state: State) -> bool: CONF_ENTITY_ID: self._entity_ids, CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT], } + 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], @@ -274,6 +296,37 @@ def entity_filter(state: State) -> bool: 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: diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index c515a59..25d587b 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -14,6 +14,8 @@ CONF_ALL_STATES = "all_states" CONF_DEFAULT_OPTIONS = "default_options" CONF_DRIVING_SPEED = "driving_speed" +CONF_END_DRIVING_DELAY = "end_driving_delay" +CONF_MAX_SPEED_AGE = "max_speed_age" CONF_ENTITY = "entity" CONF_ENTITY_PICTURE = "entity_picture" CONF_REQ_MOVEMENT = "require_movement" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 2a8b029..1f33d97 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -39,9 +39,10 @@ ) 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.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event +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 @@ -58,8 +59,10 @@ 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, @@ -205,11 +208,16 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): _location_name: str | None = None _latitude: float | None = None _longitude: float | 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: @@ -277,13 +285,23 @@ async def async_will_remove_from_hass(self) -> None: 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] @@ -400,6 +418,70 @@ def _clear_state(self) -> None: self._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 + + 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.""" + async_dispatcher_send( + self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", 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 _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 + + async def driving_ended(_utcnow: datetime) -> None: + """End driving state.""" + self._remove_driving_ended = None + + async def end_driving() -> None: + """End driving state.""" + self._location_name = None + + await self.async_request_call(end_driving()) + self.async_write_ha_state() + + self._remove_driving_ended = async_call_later( + self.hass, self._end_driving_delay, driving_ended + ) + + @property + def _drive_ending_delayed(self) -> bool: + """Return if end of driving state is being delayed.""" + return self._remove_driving_ended is not None async def _entity_updated( # noqa: C901 self, entity_id: str, new_state: State | None @@ -587,7 +669,7 @@ def _set_state( source_type: SourceType | str | None, ) -> None: """Set new state.""" - # Save previously "seen" values before updating for speed calculations below. + # Save previously "seen" values before updating for speed calculations, etc. prev_ent: str | None prev_lat: float | None prev_lon: float | None @@ -598,6 +680,11 @@ def _set_state( 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._source_type = source_type @@ -614,20 +701,21 @@ def _set_state( self._attr_extra_state_attributes = attributes + last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) speed = None angle = None - if prev_ent and self._prev_seen and prev_lat and prev_lon and gps: - assert lat - assert lon - assert attributes - last_ent = cast(str, attributes[ATTR_LAST_ENTITY_ID]) - last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) + 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 last_ent != prev_ent: + if cast(str, attributes[ATTR_LAST_ENTITY_ID]) != prev_ent: min_seconds *= 3 if seconds < min_seconds: _LOGGER.debug( @@ -636,29 +724,50 @@ def _set_state( seconds, min_seconds, ) - return - 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) + use_new_speed = False else: - if speed > MIN_ANGLE_SPEED: - angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat))) - if angle < 0: - angle += 360 - if ( + 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: + _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 + ) + 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 - and self.state == STATE_NOT_HOME - ): - self._location_name = STATE_DRIVING - _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 ) + if driving: + self._cancel_drive_ending_delay() + elif was_driving: + self._start_drive_ending_delay() + + if driving or self._drive_ending_delayed: + self._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: diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 364dcc1..9ac4623 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.6.0b3/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.5.0" + "version": "3.6.0b3" } diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index 6e77347..783d822 100644 --- a/custom_components/composite/translations/en.json +++ b/custom_components/composite/translations/en.json @@ -9,6 +9,13 @@ "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.", @@ -56,6 +63,7 @@ "data": { "driving_speed": "Driving speed", "entity_id": "Input entities", + "max_speed_age": "Max time between speed sensor updates", "require_movement": "Require movement" } } @@ -98,6 +106,13 @@ "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.", @@ -139,6 +154,7 @@ "data": { "driving_speed": "Driving speed", "entity_id": "Input entities", + "max_speed_age": "Max time between speed sensor updates", "require_movement": "Require movement" } } From 6260f4d0632d98e2472806d60e95c6ce609c3c7e Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Oct 2025 14:34:10 -0500 Subject: [PATCH 88/96] Bump version to 3.6.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 9ac4623..33247f3 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.6.0b3/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.6.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.6.0b3" + "version": "3.6.0" } From b8e1023d3b5becc7989c9497f98dcd14905c9a9f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 9 Dec 2025 08:32:36 -0600 Subject: [PATCH 89/96] Support Home Assistant 2024.11 or newer (#100) Support Home Assistant 2024.11 or newer. Add option to show unknown speed sensor value as 0.0. This can help when using speed sensor as input to statistics integration, etc. Breaking change: Only use supported values for source_type. Binary sensors will now be represented as router. --- README.md | 5 +- custom_components/composite/config.py | 6 + custom_components/composite/config_flow.py | 16 +- custom_components/composite/const.py | 3 +- custom_components/composite/device_tracker.py | 197 ++++++++---------- custom_components/composite/manifest.json | 4 +- custom_components/composite/sensor.py | 69 +++--- .../composite/translations/en.json | 6 +- .../composite/translations/nl.json | 6 +- hacs.json | 2 +- 10 files changed, 166 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 9e2c741..cba3ba4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ After it has been downloaded you will need to restart Home Assistant. ### Versions -This custom integration supports HomeAssistant versions 2024.8.3 or newer. +This custom integration supports HomeAssistant versions 2024.11.0 or newer. ## Configuration @@ -74,6 +74,7 @@ composite: - **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*) @@ -86,6 +87,7 @@ composite: - **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. @@ -155,6 +157,7 @@ direction | Compass heading of movement direction (if moving.) composite: default_options: require_movement: true + show_unknown_as_0: true max_speed_age: minutes: 5 driving_speed: 15 diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py index 87b834b..3999999 100644 --- a/custom_components/composite/config.py +++ b/custom_components/composite/config.py @@ -24,6 +24,7 @@ CONF_ENTITY_PICTURE, CONF_MAX_SPEED_AGE, CONF_REQ_MOVEMENT, + CONF_SHOW_UNKNOWN_AS_0, CONF_TIME_AS, CONF_TRACKERS, CONF_USE_PICTURE, @@ -142,6 +143,7 @@ def _defaults(config: dict) -> dict: 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) @@ -150,6 +152,8 @@ def _defaults(config: dict) -> dict: 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: @@ -200,6 +204,7 @@ def _defaults(config: dict) -> dict: 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, @@ -218,6 +223,7 @@ def _defaults(config: dict) -> dict: 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, diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index 6f6809e..e05d9e5 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -62,6 +62,7 @@ CONF_ENTITY_PICTURE, CONF_MAX_SPEED_AGE, CONF_REQ_MOVEMENT, + CONF_SHOW_UNKNOWN_AS_0, CONF_USE_PICTURE, DOMAIN, MIME_TO_SUFFIX, @@ -83,6 +84,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: 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, @@ -203,6 +205,12 @@ async def async_step_options( 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: @@ -263,6 +271,7 @@ def entity_filter(state: State) -> bool: ) ), 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 @@ -280,6 +289,7 @@ def entity_filter(state: State) -> bool: 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] @@ -302,7 +312,9 @@ async def async_step_end_driving_delay( """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] + 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() @@ -398,7 +410,7 @@ async def async_step_ep_local_file( options=local_files, mode=SelectSelectorMode.DROPDOWN, ) - ) + ), } ) if local_file: diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index 25d587b..be96f2c 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -15,10 +15,11 @@ CONF_DEFAULT_OPTIONS = "default_options" CONF_DRIVING_SPEED = "driving_speed" CONF_END_DRIVING_DELAY = "end_driving_delay" -CONF_MAX_SPEED_AGE = "max_speed_age" 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" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 1f33d97..4dcf079 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -11,6 +11,8 @@ from types import MappingProxyType from typing import Any, cast +from propcache.api import cached_property + from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.components.device_tracker import ( ATTR_BATTERY, @@ -86,16 +88,6 @@ ATTR_BATTERY_CHARGING, ) -_SOURCE_TYPE_BINARY_SENSOR = BS_DOMAIN -_STATE_BINARY_SENSOR_HOME = STATE_ON - -_SOURCE_TYPE_NON_GPS = ( - _SOURCE_TYPE_BINARY_SENSOR, - SourceType.BLUETOOTH, - SourceType.BLUETOOTH_LE, - SourceType.ROUTER, -) - _GPS_ACCURACY_ATTRS = (ATTR_GPS_ACCURACY, ATTR_ACC) _BATTERY_ATTRS = (ATTR_BATTERY_LEVEL, ATTR_BATTERY) _CHARGING_ATTRS = (ATTR_BATTERY_CHARGING, ATTR_CHARGING) @@ -119,8 +111,9 @@ def _nearest_second(time: datetime) -> datetime: class EntityStatus(Enum): """Input entity status.""" - INACTIVE = auto() - ACTIVE = auto() + NOT_SET = auto() + GOOD = auto() + BAD = auto() WARNED = auto() SUSPEND = auto() @@ -130,7 +123,7 @@ class Location: """Location (latitude, longitude & accuracy).""" gps: GPSType - accuracy: int + accuracy: float @dataclass @@ -140,38 +133,45 @@ class EntityData: entity_id: str use_all_states: bool use_picture: bool - status: EntityStatus = EntityStatus.INACTIVE + _status: EntityStatus = EntityStatus.NOT_SET seen: datetime | None = None - source_type: str | 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: str, data: Location | str) -> None: + def good( + self, seen: datetime, source_type: SourceType, data: Location | str + ) -> None: """Mark entity as good.""" - self.status = EntityStatus.ACTIVE + 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: + if self._status == EntityStatus.SUSPEND: return msg = f"{self.entity_id} {message}" - if self.status == EntityStatus.WARNED: + if self._status == EntityStatus.WARNED: _LOGGER.error(msg) - self.status = EntityStatus.SUSPEND + self._status = EntityStatus.SUSPEND # Only warn if this is not the first state change for the entity. - elif self.status == EntityStatus.ACTIVE: + elif self._status != EntityStatus.NOT_SET: _LOGGER.warning(msg) - self.status = EntityStatus.WARNED + self._status = EntityStatus.WARNED else: _LOGGER.debug(msg) - self.status = EntityStatus.ACTIVE + self._status = EntityStatus.BAD class Attributes: @@ -203,11 +203,6 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): # State vars _battery_level: int | None = None - _source_type: str | None = None - _location_accuracy = 0 - _location_name: str | None = None - _latitude: float | None = None - _longitude: float | None = None _prev_seen: datetime | None = None _prev_speed: float | None = None @@ -233,7 +228,7 @@ def __init__(self, entry: ConfigEntry) -> None: self._attr_extra_state_attributes = {} self._entities: dict[str, EntityData] = {} - @property + @cached_property def force_update(self) -> bool: """Return True if state updates should be forced.""" return False @@ -243,31 +238,6 @@ def battery_level(self) -> int | None: """Return the battery level of the device.""" return self._battery_level - @property - def source_type(self) -> str | None: # type: ignore[override] - """Return the source type of the device.""" - return self._source_type - - @property - def location_accuracy(self) -> int: - """Return the location accuracy of the device.""" - return self._location_accuracy - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - return self._location_name - - @property - def latitude(self) -> float | None: - """Return the latitude value of the device.""" - return self._latitude - - @property - def longitude(self) -> float | None: - """Rerturn the longitude value of the device.""" - return self._longitude - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -379,10 +349,16 @@ async def _restore_state(self) -> None: self._attr_entity_picture = last_state.attributes.get(ATTR_ENTITY_PICTURE) self._battery_level = last_state.attributes.get(ATTR_BATTERY_LEVEL) - self._source_type = last_state.attributes[ATTR_SOURCE_TYPE] - self._location_accuracy = last_state.attributes.get(ATTR_GPS_ACCURACY) or 0 - self._latitude = last_state.attributes.get(ATTR_LATITUDE) - self._longitude = last_state.attributes.get(ATTR_LONGITUDE) + # 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 } @@ -403,19 +379,19 @@ async def _restore_state(self) -> None: last_seen ) self._prev_seen = dt_util.as_utc(last_seen) - if self.source_type in _SOURCE_TYPE_NON_GPS and ( + if self.source_type != SourceType.GPS and ( self.latitude is None or self.longitude is None ): - self._location_name = last_state.state + self._attr_location_name = last_state.state def _clear_state(self) -> None: """Clear state.""" self._battery_level = None - self._source_type = None - self._location_accuracy = 0 - self._location_name = None - self._latitude = None - self._longitude = 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 @@ -440,9 +416,7 @@ async def speed_is_stale(_utcnow: datetime) -> None: async def clear_speed_sensor_state() -> None: """Clear speed sensor's state.""" - async_dispatcher_send( - self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", None, None - ) + self._send_speed(None, None) await self.async_request_call(clear_speed_sensor_state()) self.async_write_ha_state() @@ -451,6 +425,13 @@ async def clear_speed_sensor_state() -> None: 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: @@ -469,7 +450,7 @@ async def driving_ended(_utcnow: datetime) -> None: async def end_driving() -> None: """End driving state.""" - self._location_name = None + self._attr_location_name = None await self.async_request_call(end_driving()) self.async_write_ha_state() @@ -525,16 +506,17 @@ def get_last_seen() -> datetime | None: if not gps: with suppress(KeyError): gps = new_attrs[ATTR_LAT], new_attrs[ATTR_LON] - gps_accuracy = cast(int | None, new_attrs.get(_GPS_ACCURACY_ATTRS)) + 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 = _SOURCE_TYPE_BINARY_SENSOR + source_type: str | None = SourceType.ROUTER.value else: source_type = new_attrs.get( - ATTR_SOURCE_TYPE, SourceType.GPS if gps and gps_accuracy else None + ATTR_SOURCE_TYPE, + SourceType.GPS.value if gps and gps_accuracy is not None else None, ) if entity.use_picture: @@ -557,7 +539,7 @@ def get_last_seen() -> datetime | None: old_data = cast(Location | None, entity.data) if last_seen == old_last_seen and new_data == old_data: return - entity.good(last_seen, source_type, new_data) + entity.good(last_seen, SourceType.GPS, new_data) if self._req_movement and old_data: dist = distance(gps[0], gps[1], old_data.gps[0], old_data.gps[1]) @@ -569,16 +551,16 @@ def get_last_seen() -> datetime | None: ) return - elif source_type in _SOURCE_TYPE_NON_GPS: + elif source_type in SourceType: # Convert 'on'/'off' state of binary_sensor # to 'home'/'not_home'. - if source_type == _SOURCE_TYPE_BINARY_SENSOR: - if state == _STATE_BINARY_SENSOR_HOME: + if new_state.domain == BS_DOMAIN: + if state == STATE_ON: state = STATE_HOME else: state = STATE_NOT_HOME - entity.good(last_seen, source_type, state) + entity.good(last_seen, SourceType(source_type), state) # type: ignore[arg-type] if not self._use_non_gps_data(entity_id, state): return @@ -599,27 +581,24 @@ def get_last_seen() -> datetime | None: # or 2) provide a location_name (that will be used as the new # state.) - # If router entity's state is 'home' and our current state is 'home' w/ GPS + # 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 + 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 + 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 = cast( - GPSType, - (self.hass.config.latitude, self.hass.config.longitude), - ) + gps = (self.hass.config.latitude, self.hass.config.longitude) gps_accuracy = 0 - source_type = SourceType.GPS + source_type = SourceType.GPS.value # Otherwise, don't use any GPS data, but set location_name to # new state. else: @@ -647,7 +626,7 @@ def get_last_seen() -> datetime | None: ATTR_ENTITIES: tuple( entity_id for entity_id, _entity in self._entities.items() - if _entity.source_type + if _entity.is_good ), ATTR_LAST_ENTITY_ID: entity_id, ATTR_LAST_SEEN: dt_util.as_local(_nearest_second(last_seen)), @@ -655,7 +634,9 @@ def get_last_seen() -> datetime | None: if charging is not None: attrs[ATTR_BATTERY_CHARGING] = charging - self._set_state(location_name, gps, gps_accuracy, battery, attrs, source_type) + self._set_state( + location_name, gps, gps_accuracy, battery, attrs, SourceType(source_type) # type: ignore[arg-type] + ) self._prev_seen = last_seen @@ -663,10 +644,10 @@ def _set_state( self, location_name: str | None, gps: GPSType | None, - gps_accuracy: int | None, + gps_accuracy: float | None, battery: int | None, attributes: dict, - source_type: SourceType | str | None, + source_type: SourceType, ) -> None: """Set new state.""" # Save previously "seen" values before updating for speed calculations, etc. @@ -687,17 +668,17 @@ def _set_state( ) self._battery_level = battery - self._source_type = source_type - self._location_accuracy = gps_accuracy or 0 - self._location_name = location_name + 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._latitude = lat - self._longitude = lon + self._attr_latitude = lat + self._attr_longitude = lon self._attr_extra_state_attributes = attributes @@ -706,9 +687,12 @@ def _set_state( 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 + 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 @@ -738,12 +722,7 @@ def _set_state( angle += 360 if use_new_speed: - _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 - ) + self._send_speed(speed, angle) self._prev_speed = speed self._start_speed_stale_monitor() else: @@ -766,17 +745,13 @@ def _set_state( self._start_drive_ending_delay() if driving or self._drive_ending_delayed: - self._location_name = STATE_DRIVING + 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 - entities = self._entities.values() - if any(entity.source_type == SourceType.GPS for entity in entities): + 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 entities - if entity.source_type in _SOURCE_TYPE_NON_GPS - ) + 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 33247f3..156062e 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.6.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/4.0.0b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.6.0" + "version": "4.0.0b0" } diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index 1bcaa36..65441a6 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -17,7 +17,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_ANGLE, ATTR_DIRECTION, SIG_COMPOSITE_SPEED +from .const import ( + ATTR_ANGLE, + ATTR_DIRECTION, + CONF_SHOW_UNKNOWN_AS_0, + SIG_COMPOSITE_SPEED, +) async def async_setup_entry( @@ -31,7 +36,9 @@ 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.""" @@ -49,20 +56,16 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.entity_id = f"{S_DOMAIN}.{obj_id}" self._attr_unique_id = obj_id else: - # translation_placeholders was new in 2024.2 - self._use_name_translation = hasattr(self, "translation_placeholders") entity_description = SensorEntityDescription( key="speed", device_class=SensorDeviceClass.SPEED, icon="mdi:car-speed-limiter", - has_entity_name=self._use_name_translation, + 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} - if not self._use_name_translation: - self._attr_name = f"{entry.title} speed" self._attr_unique_id = signal = entry.entry_id self.entity_description = entity_description self._attr_extra_state_attributes = { @@ -76,6 +79,15 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: ) ) + @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() @@ -84,8 +96,32 @@ async def async_added_to_hass(self) -> None: 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.""" @@ -97,7 +133,7 @@ def direction(angle: int | None) -> str | None: int((angle + 360 / 16) // (360 / 8)) ] - self._attr_native_value = value + self._raw_value = value self._attr_extra_state_attributes = { ATTR_ANGLE: angle, ATTR_DIRECTION: direction(angle), @@ -110,22 +146,3 @@ def direction(angle: int | None) -> str | None: # 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() - - 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 in new HA versions. - self._attr_translation_placeholders = {"name": new_name} - self._attr_name = f"{new_name} speed" - if self._use_name_translation: - # Delete _attr_name so translated name is used. - # This also clears the cached_property in new HA versions. - del self._attr_name - er.async_get(hass).async_update_entity( - self.entity_id, original_name=self.name - ) diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index 783d822..a76793e 100644 --- a/custom_components/composite/translations/en.json +++ b/custom_components/composite/translations/en.json @@ -64,7 +64,8 @@ "driving_speed": "Driving speed", "entity_id": "Input entities", "max_speed_age": "Max time between speed sensor updates", - "require_movement": "Require movement" + "require_movement": "Require movement", + "show_unknown_as_0": "Show unknown speed as 0.0" } } }, @@ -155,7 +156,8 @@ "driving_speed": "Driving speed", "entity_id": "Input entities", "max_speed_age": "Max time between speed sensor updates", - "require_movement": "Require movement" + "require_movement": "Require movement", + "show_unknown_as_0": "Show unknown speed as 0.0" } } }, diff --git a/custom_components/composite/translations/nl.json b/custom_components/composite/translations/nl.json index e1f6884..332284f 100644 --- a/custom_components/composite/translations/nl.json +++ b/custom_components/composite/translations/nl.json @@ -20,7 +20,8 @@ "data": { "driving_speed": "Rijsnelheid", "entity_id": "Voer entiteiten in", - "require_movement": "Vereist beweging" + "require_movement": "Vereist beweging", + "show_unknown_as_0": "Toon onbekende snelheid als 0,0" } }, "use_picture": { @@ -74,7 +75,8 @@ "data": { "driving_speed": "Rijsnelheid", "entity_id": "Voer entiteiten in", - "require_movement": "Vereist beweging" + "require_movement": "Vereist beweging", + "show_unknown_as_0": "Toon onbekende snelheid als 0,0" } }, "use_picture": { diff --git a/hacs.json b/hacs.json index 6259499..8814274 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Composite Device Tracker", - "homeassistant": "2024.8.3" + "homeassistant": "2024.11.0" } From 13c41e900ded074a8748a318ee3d15b1b5db4693 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 9 Dec 2025 08:41:31 -0600 Subject: [PATCH 90/96] Bump version to 4.0.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 156062e..c45e91b 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/4.0.0b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/4.0.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "4.0.0b0" + "version": "4.0.0" } From c6c0e318471dda4d26d19534e1a8b6450eee34b8 Mon Sep 17 00:00:00 2001 From: "Simone A." <6689226+sibest19@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:03:07 +0100 Subject: [PATCH 91/96] Add Italian translations for composite component (#101) --- .../composite/translations/it.json | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 custom_components/composite/translations/it.json diff --git a/custom_components/composite/translations/it.json b/custom_components/composite/translations/it.json new file mode 100644 index 0000000..780f6f5 --- /dev/null +++ b/custom_components/composite/translations/it.json @@ -0,0 +1,172 @@ +{ + "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" + } + } + }, + "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" + } + } + }, + "error": { + "at_least_one_entity": "Devi selezionare almeno un'entità di input." + } + }, + "services": { + "reload": { + "name": "Ricarica", + "description": "Ricarica Composito dalla configurazione YAML." + } + } +} From 35506bfc471e2b6e343b8543f010fd8a7e311db4 Mon Sep 17 00:00:00 2001 From: Marius <33354141+Mariusthvdb@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:25:44 +0100 Subject: [PATCH 92/96] Update Dutch translations (#103) --- .../composite/translations/nl.json | 134 ++++++++++++++---- 1 file changed, 104 insertions(+), 30 deletions(-) diff --git a/custom_components/composite/translations/nl.json b/custom_components/composite/translations/nl.json index 332284f..41cf657 100644 --- a/custom_components/composite/translations/nl.json +++ b/custom_components/composite/translations/nl.json @@ -3,12 +3,55 @@ "config": { "step": { "all_states": { - "title": "Gebruik alle staten", - "description": "Selecteer entiteiten waarvoor alle staten moeten worden gebruikt.\nHoud er rekening mee dat dit alleen van toepassing is op entiteiten waarvan het brontype niet GPS is.", + "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": { @@ -16,25 +59,19 @@ } }, "options": { - "title": "Samengestelde opties", + "title": "Composietopties", "data": { "driving_speed": "Rijsnelheid", - "entity_id": "Voer entiteiten in", - "require_movement": "Vereist beweging", - "show_unknown_as_0": "Toon onbekende snelheid als 0,0" - } - }, - "use_picture": { - "title": "Afbeeldingsentiteit", - "description": "Kies de entiteit waarvan de afbeelding voor de composiet zal worden gebruikt.\nHet is misschien geen.", - "data": { - "entity": "Entiteit" + "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": "Moet ten minste één invoerentiteit selecteren.", - "name_used": "Naam is al gebruikt." + "at_least_one_entity": "Er moet minstens één invoerentiteit worden geselecteerd.", + "name_used": "Naam is al in gebruik." } }, "entity": { @@ -44,8 +81,8 @@ "driving": "Rijden" }, "state_attributes": { - "battery_charging": {"name": "Batterij opladen"}, - "entities": {"name": "Entiteiten gezien"}, + "battery_charging": {"name": "Batterij wordt opgeladen"}, + "entities": {"name": "Gezien door entiteiten"}, "last_entity_id": {"name": "Laatste entiteit"}, "last_seen": {"name": "Laatst gezien"} } @@ -64,37 +101,74 @@ "options": { "step": { "all_states": { - "title": "Gebruik alle staten", - "description": "Selecteer entiteiten waarvoor alle staten moeten worden gebruikt.\nHoud er rekening mee dat dit alleen van toepassing is op entiteiten waarvan het brontype niet GPS is.", + "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" } }, - "options": { - "title": "Samengestelde opties", + "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": { - "driving_speed": "Rijsnelheid", - "entity_id": "Voer entiteiten in", - "require_movement": "Vereist beweging", - "show_unknown_as_0": "Toon onbekende snelheid als 0,0" + "end_driving_delay": "Vertraging einde rijden" } }, - "use_picture": { + "ep_input_entity": { "title": "Afbeeldingsentiteit", - "description": "Kies de entiteit waarvan de afbeelding voor de composiet zal worden gebruikt.\nHet is misschien geen.", + "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": "Must select at least one input entity." + "at_least_one_entity": "Er moet minstens één invoerentiteit worden geselecteerd." } }, "services": { "reload": { "name": "Herladen", - "description": "Herlaadt Composiet vanuit de YAML-configuratie." + "description": "Laadt de Composiet-integratie opnieuw vanuit de YAML-configuratie." } } -} \ No newline at end of file +} From 86919e08ba665dec29a6dc01dfe70549716df881 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 10 Dec 2025 08:38:31 -0600 Subject: [PATCH 93/96] Bump version to 4.1.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index c45e91b..5ee0552 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/4.0.0/README.md", + "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.0.0" + "version": "4.1.0" } From 4bae8bfead49e779c2596efb639a5560309ac175 Mon Sep 17 00:00:00 2001 From: "Simone A." <6689226+sibest19@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:01:55 +0100 Subject: [PATCH 94/96] Add 'show_unknown_as_0' translation to Italian JSON (#104) --- custom_components/composite/translations/it.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/translations/it.json b/custom_components/composite/translations/it.json index 780f6f5..b42785b 100644 --- a/custom_components/composite/translations/it.json +++ b/custom_components/composite/translations/it.json @@ -64,7 +64,8 @@ "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" + "require_movement": "Richiedi movimento", + "show_unknown_as_0": "Mostra 0,0 quando la velocità è sconosciuta" } } }, @@ -155,7 +156,8 @@ "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" + "require_movement": "Richiedi movimento", + "show_unknown_as_0": "Mostra 0,0 quando la velocità è sconosciuta" } } }, From 1d71c2bfa5e1f1cd8b57ba2f75ed092097d8786c Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 23 Jan 2026 13:39:35 -0600 Subject: [PATCH 95/96] Add issue templates --- ISSUE_TEMPLATE/bug_report.md | 36 +++++++++++++++++++++++++++++++ ISSUE_TEMPLATE/config.yml | 5 +++++ ISSUE_TEMPLATE/feature_request.md | 20 +++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 ISSUE_TEMPLATE/bug_report.md create mode 100644 ISSUE_TEMPLATE/config.yml create mode 100644 ISSUE_TEMPLATE/feature_request.md diff --git a/ISSUE_TEMPLATE/bug_report.md b/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d50766a --- /dev/null +++ b/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/ISSUE_TEMPLATE/config.yml b/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..df8dd4d --- /dev/null +++ b/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/ISSUE_TEMPLATE/feature_request.md b/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/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. From ea3b6e0239cb5931031778a6e4eeff40c0879dcd Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 23 Jan 2026 13:41:44 -0600 Subject: [PATCH 96/96] ISSUE_TEMPLATE in wrong directory --- {ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/bug_report.md | 0 {ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/config.yml | 0 {ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/feature_request.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/bug_report.md (100%) rename {ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/config.yml (100%) rename {ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/feature_request.md (100%) diff --git a/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 100% rename from ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/bug_report.md diff --git a/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml similarity index 100% rename from ISSUE_TEMPLATE/config.yml rename to .github/ISSUE_TEMPLATE/config.yml diff --git a/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md similarity index 100% rename from ISSUE_TEMPLATE/feature_request.md rename to .github/ISSUE_TEMPLATE/feature_request.md