From b8e1023d3b5becc7989c9497f98dcd14905c9a9f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 9 Dec 2025 08:32:36 -0600 Subject: [PATCH 1/2] 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 2/2] 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" }