From fe6692b1e027ec54583d2e7936ffdafd7c9c8731 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 1 Oct 2025 12:02:29 -0500 Subject: [PATCH 01/14] 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. --- custom_components/composite/device_tracker.py | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 2a8b029..696e504 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -207,6 +207,7 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): _longitude: float | None = None _prev_seen: datetime | None = None + _prev_speed: float | None = None _remove_track_states: Callable[[], None] | None = None _req_movement: bool _driving_speed: float | None # m/s @@ -616,10 +617,12 @@ def _set_state( 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 + 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 + ): 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. @@ -636,17 +639,28 @@ 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 + 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 + else: + speed = self._prev_speed if ( speed is not None and self._driving_speed is not None @@ -654,10 +668,6 @@ def _set_state( 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 - ) def _use_non_gps_data(self, entity_id: str, state: str) -> bool: """Determine if state should be used for non-GPS based entity.""" From db853beb17fd41a053788d875aa6948d23c18517 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 1 Oct 2025 12:56:12 -0500 Subject: [PATCH 02/14] Clear saved speed when clearing state --- 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 696e504..018e311 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -401,6 +401,7 @@ def _clear_state(self) -> None: self._longitude = None self._attr_extra_state_attributes = {} self._prev_seen = None + self._prev_speed = None async def _entity_updated( # noqa: C901 self, entity_id: str, new_state: State | None From 90c40acf8e4998ae294f9f5a833a9d715756924a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 1 Oct 2025 14:01:38 -0500 Subject: [PATCH 03/14] Bump version to 3.6.0b0 --- 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 364dcc1..efa2834 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.0b0/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.0b0" } From 4e56acefbdae410e5827476ad163b3910022f5ec Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 2 Oct 2025 11:58:52 -0500 Subject: [PATCH 04/14] Add option to delay ending of driving state --- custom_components/composite/config.py | 15 +++++ custom_components/composite/config_flow.py | 44 ++++++++++++- custom_components/composite/const.py | 1 + custom_components/composite/device_tracker.py | 61 ++++++++++++++++--- .../composite/translations/en.json | 14 +++++ 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py index 99ceda8..d332eb3 100644 --- a/custom_components/composite/config.py +++ b/custom_components/composite/config.py @@ -18,6 +18,7 @@ CONF_ALL_STATES, CONF_DEFAULT_OPTIONS, CONF_DRIVING_SPEED, + CONF_END_DRIVING_DELAY, CONF_ENTITY, CONF_ENTITY_PICTURE, CONF_REQ_MOVEMENT, @@ -129,12 +130,18 @@ def _defaults(config: dict) -> dict: def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT] 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_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 +150,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 @@ -172,6 +184,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_END_DRIVING_DELAY): cv.positive_time_period, vol.Optional(CONF_ENTITY_PICTURE): vol.All(cv.string, _entity_picture), } _CONFIG_SCHEMA = vol.Schema( @@ -188,6 +201,8 @@ def _defaults(config: dict) -> dict: CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT ): cv.boolean, vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), + vol.Optional(CONF_END_DRIVING_DELAY): + cv.positive_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..459e023 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,6 +57,7 @@ ATTR_LON, CONF_ALL_STATES, CONF_DRIVING_SPEED, + CONF_END_DRIVING_DELAY, CONF_ENTITY, CONF_ENTITY_PICTURE, CONF_REQ_MOVEMENT, @@ -79,6 +82,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: CONF_ENTITY_ID, CONF_REQ_MOVEMENT, CONF_DRIVING_SPEED, + CONF_END_DRIVING_DELAY, CONF_ENTITY_PICTURE, ), ), @@ -203,8 +207,11 @@ async def async_step_options( 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 +228,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" @@ -274,6 +283,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..facc8b1 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -14,6 +14,7 @@ CONF_ALL_STATES = "all_states" CONF_DEFAULT_OPTIONS = "default_options" CONF_DRIVING_SPEED = "driving_speed" +CONF_END_DRIVING_DELAY = "end_driving_delay" CONF_ENTITY = "entity" CONF_ENTITY_PICTURE = "entity_picture" CONF_REQ_MOVEMENT = "require_movement" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 018e311..d939819 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -41,7 +41,7 @@ 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 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,6 +58,7 @@ ATTR_LON, CONF_ALL_STATES, CONF_DRIVING_SPEED, + CONF_END_DRIVING_DELAY, CONF_ENTITY, CONF_ENTITY_PICTURE, CONF_REQ_MOVEMENT, @@ -205,12 +206,14 @@ 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_driving_ended: Callable[[], None] | None = None _req_movement: bool _driving_speed: float | None # m/s + _end_driving_delay: timedelta | None _use_entity_picture: bool def __init__(self, entry: ConfigEntry) -> None: @@ -278,6 +281,7 @@ 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_drive_ending_delay() await super().async_will_remove_from_hass() async def _process_config_options(self) -> None: @@ -285,6 +289,7 @@ async def _process_config_options(self) -> None: options = cast(ConfigEntry, self.platform.config_entry).options self._req_movement = options[CONF_REQ_MOVEMENT] self._driving_speed = options.get(CONF_DRIVING_SPEED) + self._end_driving_delay = options.get(CONF_END_DRIVING_DELAY) entity_cfgs = { entity_cfg[CONF_ENTITY]: entity_cfg for entity_cfg in options[CONF_ENTITY_ID] @@ -402,6 +407,13 @@ def _clear_state(self) -> None: self._attr_extra_state_attributes = {} self._prev_seen = None self._prev_speed = None + self._cancel_drive_ending_delay() + + 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 async def _entity_updated( # noqa: C901 self, entity_id: str, new_state: State | None @@ -589,7 +601,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 @@ -600,6 +612,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 @@ -616,6 +633,7 @@ def _set_state( self._attr_extra_state_attributes = attributes + last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) speed = None angle = None use_new_speed = True @@ -624,14 +642,12 @@ def _set_state( and prev_lat is not None and prev_lon is not None and lat is not None and lon is not None ): - 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: + if cast(str, attributes[ATTR_LAST_ENTITY_ID]) != prev_ent: min_seconds *= 3 if seconds < min_seconds: _LOGGER.debug( @@ -652,6 +668,7 @@ def _set_state( 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 @@ -662,12 +679,38 @@ def _set_state( self._prev_speed = speed else: speed = self._prev_speed - if ( + + # 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 - ): + ) + + 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() + + if driving: + self._cancel_drive_ending_delay() + elif was_driving: + if self._end_driving_delay is not None: + self._remove_driving_ended = async_call_later( + self.hass, self._end_driving_delay, driving_ended + ) + + if driving or self._remove_driving_ended is not None: self._location_name = STATE_DRIVING def _use_non_gps_data(self, entity_id: str, state: str) -> bool: diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index 6e77347..f0aedf1 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.", + "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.", @@ -98,6 +105,13 @@ "entity": "Entity" } }, + "end_driving_delay": { + "title": "End Driving Delay", + "description": "Time to hold driving state after speed falls below configured driving speed.", + "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.", From 9d2e04f5e2bf1ef08d2e16d653381f41501326b5 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 2 Oct 2025 12:09:06 -0500 Subject: [PATCH 05/14] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 832aa96..c40f335 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ composite: - **default_options** (*Optional*): Defines default values for corresponding options under **trackers**. - **require_movement** (*Optional*): Default is `false`. - **driving_speed** (*Optional*) + - **end_driving_delay** (*Optoinal*) - **trackers**: The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). @@ -85,6 +86,7 @@ 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`. +- **end_driving_delay** (*Optional*): Amount of time to wait before changing state back to `not_home` (i.e., Away) when speed falls below set `driving_speed`. This can prevent state changing back and forth to `driving` (i.e., Driving) when, e.g., slowing for a turn or stopping at a traffic light. - **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 @@ -152,6 +154,7 @@ composite: default_options: require_movement: true driving_speed: 15 + end_driving_delay: "00:02:00" trackers: - name: Me driving_speed: 20 @@ -165,6 +168,8 @@ composite: - name: Better Half id: wife require_movement: false + end_driving_delay: + seconds: 30 entity_picture: /local/wife.jpg entity_id: device_tracker.platform_wife ``` From cfd40d0c14dc6a7e5cc0fe733ccc97b98b5b5628 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 2 Oct 2025 12:18:41 -0500 Subject: [PATCH 06/14] Bump version to 3.6.0b1 --- 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 efa2834..8865a62 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.0b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.6.0b1/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.6.0b0" + "version": "3.6.0b1" } From 50a70ca46bca7b12281e6650dfbff02e8ef149fd Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 2 Oct 2025 15:02:29 -0500 Subject: [PATCH 07/14] Fix bug with handling of end_driving_delay config --- custom_components/composite/config.py | 18 +++++++++++++++--- custom_components/composite/device_tracker.py | 6 +++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py index d332eb3..c7434f6 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 @@ -62,6 +63,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. @@ -177,6 +189,7 @@ def _defaults(config: dict) -> dict: vol.Length(1), _entities, ) +_DELAY = vol.All(cv.positive_time_period, _time_period_to_dict) _TRACKER = { vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ID): cv.slugify, @@ -184,7 +197,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_END_DRIVING_DELAY): cv.positive_time_period, + vol.Optional(CONF_END_DRIVING_DELAY): _DELAY, vol.Optional(CONF_ENTITY_PICTURE): vol.All(cv.string, _entity_picture), } _CONFIG_SCHEMA = vol.Schema( @@ -201,8 +214,7 @@ def _defaults(config: dict) -> dict: CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT ): cv.boolean, vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), - vol.Optional(CONF_END_DRIVING_DELAY): - cv.positive_time_period, + vol.Optional(CONF_END_DRIVING_DELAY): _DELAY, } ), vol.Required(CONF_TRACKERS, default=list): vol.All( diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index d939819..7e240b9 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -39,6 +39,7 @@ ) 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_call_later, async_track_state_change_event @@ -289,7 +290,10 @@ async def _process_config_options(self) -> None: options = cast(ConfigEntry, self.platform.config_entry).options self._req_movement = options[CONF_REQ_MOVEMENT] self._driving_speed = options.get(CONF_DRIVING_SPEED) - self._end_driving_delay = options.get(CONF_END_DRIVING_DELAY) + 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] From 4a0f6d1dbfbf48c8693a322f3979eaa18f856e1f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 2 Oct 2025 15:03:12 -0500 Subject: [PATCH 08/14] Bump version to 3.6.0b2 --- 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 8865a62..72c6bb7 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.0b1/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.6.0b2/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.6.0b1" + "version": "3.6.0b2" } From 403c684d6c77103d14ecf32fa33a24ba4dfe3eb6 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 8 Oct 2025 09:33:46 -0500 Subject: [PATCH 09/14] Update README.md Improve description of end_driving_delay --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c40f335..a8b1ba3 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ 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`. -- **end_driving_delay** (*Optional*): Amount of time to wait before changing state back to `not_home` (i.e., Away) when speed falls below set `driving_speed`. This can prevent state changing back and forth to `driving` (i.e., Driving) when, e.g., slowing for a turn or stopping at a traffic light. +- **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 From 3447cebf1d4195474e1a4a7785d7e0db57a62cc0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 8 Oct 2025 09:36:23 -0500 Subject: [PATCH 10/14] Improve description of end_driving_delay --- custom_components/composite/translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index f0aedf1..4db7d48 100644 --- a/custom_components/composite/translations/en.json +++ b/custom_components/composite/translations/en.json @@ -11,7 +11,7 @@ }, "end_driving_delay": { "title": "End Driving Delay", - "description": "Time to hold driving state after speed falls below configured driving speed.", + "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" } @@ -107,7 +107,7 @@ }, "end_driving_delay": { "title": "End Driving Delay", - "description": "Time to hold driving state after speed falls below configured driving speed.", + "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" } From e10c82453d884ed11c66272ebfffeba0e4407d0e Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 8 Oct 2025 10:11:14 -0500 Subject: [PATCH 11/14] Abstract end of driving state delay handling better --- custom_components/composite/device_tracker.py | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 7e240b9..b865d45 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -419,6 +419,31 @@ def _cancel_drive_ending_delay(self) -> None: self._remove_driving_ended() self._remove_driving_ended = None + def _start_drive_ending_delay(self) -> None: + """Start delay to end driving state if configured.""" + 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 ) -> None: @@ -695,26 +720,12 @@ def _set_state( and speed >= self._driving_speed ) - 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() - if driving: self._cancel_drive_ending_delay() elif was_driving: - if self._end_driving_delay is not None: - self._remove_driving_ended = async_call_later( - self.hass, self._end_driving_delay, driving_ended - ) + self._start_drive_ending_delay() - if driving or self._remove_driving_ended is not None: + if driving or self._drive_ending_delayed: self._location_name = STATE_DRIVING def _use_non_gps_data(self, entity_id: str, state: str) -> bool: From ce9765b784c5b27e1d0c906270b13c7ecfbd7443 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 8 Oct 2025 11:36:43 -0500 Subject: [PATCH 12/14] Add max speed age --- custom_components/composite/config.py | 12 ++++-- custom_components/composite/config_flow.py | 13 ++++++ custom_components/composite/const.py | 1 + custom_components/composite/device_tracker.py | 40 +++++++++++++++++++ .../composite/translations/en.json | 2 + 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py index c7434f6..87b834b 100644 --- a/custom_components/composite/config.py +++ b/custom_components/composite/config.py @@ -22,6 +22,7 @@ CONF_END_DRIVING_DELAY, CONF_ENTITY, CONF_ENTITY_PICTURE, + CONF_MAX_SPEED_AGE, CONF_REQ_MOVEMENT, CONF_TIME_AS, CONF_TRACKERS, @@ -141,6 +142,7 @@ 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 @@ -148,6 +150,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_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: @@ -189,15 +193,16 @@ def _defaults(config: dict) -> dict: vol.Length(1), _entities, ) -_DELAY = vol.All(cv.positive_time_period, _time_period_to_dict) +_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): _DELAY, + vol.Optional(CONF_END_DRIVING_DELAY): _POS_TIME_PERIOD, vol.Optional(CONF_ENTITY_PICTURE): vol.All(cv.string, _entity_picture), } _CONFIG_SCHEMA = vol.Schema( @@ -213,8 +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): _DELAY, + 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 459e023..6f6809e 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -60,6 +60,7 @@ CONF_END_DRIVING_DELAY, CONF_ENTITY, CONF_ENTITY_PICTURE, + CONF_MAX_SPEED_AGE, CONF_REQ_MOVEMENT, CONF_USE_PICTURE, DOMAIN, @@ -81,6 +82,7 @@ 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, @@ -201,6 +203,10 @@ 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], @@ -257,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, @@ -270,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], diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index facc8b1..25d587b 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -15,6 +15,7 @@ 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 b865d45..1f33d97 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -62,6 +62,7 @@ CONF_END_DRIVING_DELAY, CONF_ENTITY, CONF_ENTITY_PICTURE, + CONF_MAX_SPEED_AGE, CONF_REQ_MOVEMENT, CONF_USE_PICTURE, MIN_ANGLE_SPEED, @@ -211,8 +212,10 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): _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 @@ -282,6 +285,7 @@ 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() @@ -289,6 +293,10 @@ 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 @@ -411,8 +419,38 @@ def _clear_state(self) -> 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: @@ -421,6 +459,7 @@ def _cancel_drive_ending_delay(self) -> 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 @@ -706,6 +745,7 @@ def _set_state( 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 diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index 4db7d48..783d822 100644 --- a/custom_components/composite/translations/en.json +++ b/custom_components/composite/translations/en.json @@ -63,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" } } @@ -153,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 9e73c1a37ab837df81c170ab6e2374f2cc614ae5 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 8 Oct 2025 11:44:30 -0500 Subject: [PATCH 13/14] Update README.md Add max_speed_age --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8b1ba3..9e2c741 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ 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*) @@ -85,6 +86,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. +- **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. @@ -153,6 +155,8 @@ 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: @@ -168,8 +172,7 @@ composite: - name: Better Half id: wife require_movement: false - end_driving_delay: - seconds: 30 + end_driving_delay: 30 entity_picture: /local/wife.jpg entity_id: device_tracker.platform_wife ``` From bf4e53934b0d0c25c1b0f60ab81e7055a3870677 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 8 Oct 2025 11:46:59 -0500 Subject: [PATCH 14/14] Bump version to 3.6.0b3 --- 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 72c6bb7..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.6.0b2/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.6.0b2" + "version": "3.6.0b3" }