From 7bbdf379ec9ccd5756d5fa8be55d19167173d8d0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 9 Oct 2025 14:10:15 -0500 Subject: [PATCH 1/2] 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 2/2] 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" }