diff --git a/README.md b/README.md index f081e57..456952a 100644 --- a/README.md +++ b/README.md @@ -6,28 +6,32 @@ It will also create a `sensor` entity that indicates the speed of the device. Currently any entity that has "GPS" attributes (`gps_accuracy` or `acc`, and either `latitude` & `longitude` or `lat` & `lon`), or any `device_tracker` entity with a `source_type` attribute of `bluetooth`, `bluetooth_le`, `gps` or `router`, or any `binary_sensor` entity, can be used as an input entity. -## Breaking Change +## Installation -- All time zone related features have been removed. See https://github.com/pnbruckner/ha-entity-tz for an integration that replaces those features, and more. -- Any tracker entry removed from YAML configuration will be removed from the system. -- `trackers` in YAML configuration must have at least one entry. -- The `entity_id` attribute has been changed to `entities`. `entity_id` did not show up in the attribute list in the UI. +
+With HACS -## Installation -### With HACS [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/) You can use HACS to manage the installation and provide update notifications. -1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/): +1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/). + It should then appear as a new integration. Click on it. If necessary, search for "composite". + + ```text + https://github.com/pnbruckner/ha-composite-tracker + ``` + Or use this button: + + [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=pnbruckner&repository=ha-composite-tracker&category=integration) -```text -https://github.com/pnbruckner/ha-composite-tracker -``` -2. Install the integration using the appropriate button on the HACS Integrations page. Search for "composite". +1. Download the integration using the appropriate button. -### Manual +
+ +
+Manual Place a copy of the files from [`custom_components/composite`](custom_components/composite) in `/custom_components/composite`, @@ -35,6 +39,10 @@ where `` is your Home Assistant configuration directory. >__NOTE__: When downloading, make sure to use the `Raw` button from each file's page. +
+ +After it has been downloaded you will need to restart Home Assistant. + ### Versions This custom integration supports HomeAssistant versions 2023.7 or newer. @@ -57,6 +65,7 @@ composite: - **default_options** (*Optional*): Defines default values for corresponding options under **trackers**. - **require_movement** (*Optional*): Default is `false`. + - **driving_speed** (*Optional*) - **trackers**: The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). @@ -66,6 +75,7 @@ composite: - **name**: Friendly name of composite device. - **id** (*Optional*): Object ID (i.e., part of entity ID after the dot) of composite device. If not supplied, then object ID will be generated from the `name` variable. For example, `My Name` would result in a tracker entity ID of `device_tracker.my_name`. The speed sensor's object ID will be the same as for the device tracker, but with a suffix of "`_speed`" added (e.g., `sensor.my_name_speed`.) - **require_movement** (*Optional*): `true` or `false`. If `true`, will skip update from a GPS-based tracker if it has not moved. Specifically, if circle defined by new GPS coordinates and accuracy overlaps circle defined by previous GPS coordinates and accuracy then update will be ignored. +- **driving_speed** (*Optional*): Defines a driving speed threshold (in MPH or KPH, depending on general unit system setting.) If set, and current speed is at or above this value, and tracker is not in a zone, then the state of the tracker will be set to `driving`. #### Entity Dictionary @@ -109,8 +119,10 @@ direction | Compass heading of movement direction (if moving.) composite: default_options: require_movement: true + driving_speed: 15 trackers: - name: Me + driving_speed: 20 entity_id: - entity: device_tracker.platform1_me use_picture: true diff --git a/custom_components/composite/__init__.py b/custom_components/composite/__init__.py index b0cb6d2..b11b24a 100644 --- a/custom_components/composite/__init__.py +++ b/custom_components/composite/__init__.py @@ -27,6 +27,7 @@ from .const import ( CONF_ALL_STATES, CONF_DEFAULT_OPTIONS, + CONF_DRIVING_SPEED, CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_TIME_AS, @@ -102,10 +103,13 @@ def _defaults(config: dict) -> dict: unsupported_cfgs.add(CONF_TIME_AS) def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT] + def_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED) for tracker in config[CONF_TRACKERS]: if tracker.pop(CONF_TIME_AS, None): unsupported_cfgs.add(CONF_TIME_AS) tracker[CONF_REQ_MOVEMENT] = tracker.get(CONF_REQ_MOVEMENT, def_req_mv) + if CONF_DRIVING_SPEED not in tracker and def_drv_sp is not None: + tracker[CONF_DRIVING_SPEED] = def_drv_sp if unsupported_cfgs: _LOGGER.warning( @@ -115,6 +119,7 @@ def _defaults(config: dict) -> dict: ", ".join(sorted(unsupported_cfgs)), ) + del config[CONF_DEFAULT_OPTIONS] return config @@ -141,6 +146,7 @@ def _defaults(config: dict) -> dict: vol.Required(CONF_ENTITY_ID): _ENTITIES, vol.Optional(CONF_TIME_AS): cv.string, vol.Optional(CONF_REQ_MOVEMENT): cv.boolean, + vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), } CONFIG_SCHEMA = vol.Schema( { @@ -155,6 +161,7 @@ def _defaults(config: dict) -> dict: vol.Optional( CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT ): cv.boolean, + vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), } ), vol.Required(CONF_TRACKERS, default=list): vol.All( diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index f8fa035..9a75e15 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -22,6 +22,7 @@ CONF_ENTITY_ID, CONF_ID, CONF_NAME, + UnitOfSpeed, ) from homeassistant.core import State, callback from homeassistant.data_entry_flow import FlowHandler, FlowResult @@ -29,14 +30,20 @@ BooleanSelector, EntitySelector, EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, TextSelector, ) +from homeassistant.util.unit_conversion import SpeedConverter +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( ATTR_ACC, ATTR_LAT, ATTR_LON, CONF_ALL_STATES, + CONF_DRIVING_SPEED, CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_USE_PICTURE, @@ -50,7 +57,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: kw: {k: v for k, v in conf.items() if k in ks} for kw, ks in ( ("data", (CONF_NAME, CONF_ID)), - ("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT)), + ("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT, CONF_DRIVING_SPEED)), ) } @@ -63,6 +70,13 @@ def _entries(self) -> list[ConfigEntry]: """Get existing config entries.""" return self.hass.config_entries.async_entries(DOMAIN) + @cached_property + def _speed_uom(self) -> str: + """Return speed unit_of_measurement.""" + if self.hass.config.units is METRIC_SYSTEM: + return UnitOfSpeed.KILOMETERS_PER_HOUR + return UnitOfSpeed.MILES_PER_HOUR + @property @abstractmethod def options(self) -> dict[str, Any]: @@ -81,6 +95,14 @@ async def async_step_options( if user_input is not None: self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT] + if CONF_DRIVING_SPEED in user_input: + self.options[CONF_DRIVING_SPEED] = SpeedConverter.convert( + user_input[CONF_DRIVING_SPEED], + self._speed_uom, + UnitOfSpeed.METERS_PER_SECOND, + ) + elif CONF_DRIVING_SPEED in self.options: + del self.options[CONF_DRIVING_SPEED] prv_cfgs = { cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, []) } @@ -125,15 +147,27 @@ def entity_filter(state: State) -> bool: ) ), vol.Required(CONF_REQ_MOVEMENT): BooleanSelector(), + vol.Optional(CONF_DRIVING_SPEED): NumberSelector( + NumberSelectorConfig( + unit_of_measurement=self._speed_uom, + mode=NumberSelectorMode.BOX, + ) + ), } ) if CONF_ENTITY_ID in self.options: + suggested_values = { + CONF_ENTITY_ID: self._entity_ids, + CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT], + } + if CONF_DRIVING_SPEED in self.options: + suggested_values[CONF_DRIVING_SPEED] = SpeedConverter.convert( + self.options[CONF_DRIVING_SPEED], + UnitOfSpeed.METERS_PER_SECOND, + self._speed_uom, + ) data_schema = self.add_suggested_values_to_schema( - data_schema, - { - CONF_ENTITY_ID: self._entity_ids, - CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT], - }, + data_schema, suggested_values ) return self.async_show_form( step_id="options", data_schema=data_schema, errors=errors, last_step=False @@ -238,6 +272,10 @@ def options(self) -> dict[str, Any]: async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Import config entry from configuration.""" + if (driving_speed := data.get(CONF_DRIVING_SPEED)) is not None: + data[CONF_DRIVING_SPEED] = SpeedConverter.convert( + driving_speed, self._speed_uom, UnitOfSpeed.METERS_PER_SECOND + ) if existing_entry := await self.async_set_unique_id(data[CONF_ID]): self.hass.config_entries.async_update_entry( existing_entry, **split_conf(data) # type: ignore[arg-type] diff --git a/custom_components/composite/const.py b/custom_components/composite/const.py index 9e10dba..35e4114 100644 --- a/custom_components/composite/const.py +++ b/custom_components/composite/const.py @@ -5,6 +5,7 @@ CONF_ALL_STATES = "all_states" CONF_DEFAULT_OPTIONS = "default_options" +CONF_DRIVING_SPEED = "driving_speed" CONF_ENTITY = "entity" CONF_REQ_MOVEMENT = "require_movement" CONF_TIME_AS = "time_as" @@ -22,3 +23,5 @@ ATTR_ENTITIES = "entities" ATTR_LAT = "lat" ATTR_LON = "lon" + +STATE_DRIVING = "driving" diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index 37239ec..b9eb7c4 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -19,7 +19,6 @@ SourceType, ) from homeassistant.components.device_tracker.config_entry import TrackerEntity -from homeassistant.components.zone import ENTITY_ID_HOME, async_active_zone from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -54,12 +53,14 @@ ATTR_LAT, ATTR_LON, CONF_ALL_STATES, + CONF_DRIVING_SPEED, CONF_ENTITY, CONF_REQ_MOVEMENT, CONF_USE_PICTURE, MIN_ANGLE_SPEED, MIN_SPEED_SECONDS, SIG_COMPOSITE_SPEED, + STATE_DRIVING, ) _LOGGER = logging.getLogger(__name__) @@ -206,6 +207,7 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity): _prev_seen: datetime | None = None _remove_track_states: Callable[[], None] | None = None _req_movement: bool + _driving_speed: float | None # m/s def __init__(self, entry: ConfigEntry) -> None: """Initialize Composite Device Tracker.""" @@ -278,6 +280,7 @@ async def _process_config_options(self) -> None: """Process options from config entry.""" options = cast(ConfigEntry, self.platform.config_entry).options self._req_movement = options[CONF_REQ_MOVEMENT] + self._driving_speed = options.get(CONF_DRIVING_SPEED) entity_cfgs = { entity_cfg[CONF_ENTITY]: entity_cfg for entity_cfg in options[CONF_ENTITY_ID] @@ -393,7 +396,9 @@ def _clear_state(self) -> None: self._attr_extra_state_attributes = {} self._prev_seen = None - async def _entity_updated(self, entity_id: str, new_state: State | None) -> None: + async def _entity_updated( # noqa: C901 + self, entity_id: str, new_state: State | None + ) -> None: """Run when an input entity has changed state.""" if not new_state or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): return @@ -489,21 +494,12 @@ async def _entity_updated(self, entity_id: str, new_state: State | None) -> None # Don't use new GPS data if it's not complete. if not gps or gps_accuracy is None: gps = gps_accuracy = None - # Get current GPS data, if any, and determine if it is in - # 'zone.home'. - cur_state = self.hass.states.get(self.entity_id) - try: - cur_lat: float = cast(State, cur_state).attributes[ATTR_LATITUDE] - cur_lon: float = cast(State, cur_state).attributes[ATTR_LONGITUDE] - cur_acc: int = cast(State, cur_state).attributes[ATTR_GPS_ACCURACY] - cur_gps_is_home = ( - async_active_zone( - self.hass, cur_lat, cur_lon, cur_acc - ).entity_id # type: ignore[union-attr] - == ENTITY_ID_HOME - ) - except (AttributeError, KeyError): - cur_gps_is_home = False + + # Is current state home w/ GPS data? + if home_w_gps := self.location_name is None and self.state == STATE_HOME: + if self.latitude is None or self.longitude is None: + _LOGGER.warning("%s: Unexpectedly home without GPS data", self.name) + home_w_gps = False # It's important, for this composite tracker, to avoid the # component level code's "stale processing." This can be done @@ -511,24 +507,21 @@ async def _entity_updated(self, entity_id: str, new_state: State | None) -> None # or 2) provide a location_name (that will be used as the new # state.) - # If router entity's state is 'home' and current GPS data from - # composite entity is available and is in 'zone.home', - # use it and make source_type gps. - if state == STATE_HOME and cur_gps_is_home: - gps = cast(GPSType, (cur_lat, cur_lon)) - gps_accuracy = cur_acc + # If router entity's state is 'home' and our current state is 'home' w/ GPS + # data, use it and make source_type gps. + if state == STATE_HOME and home_w_gps: + gps = cast(GPSType, (self.latitude, self.longitude)) + gps_accuracy = self.location_accuracy source_type = SourceType.GPS # Otherwise, if new GPS data is valid (which is unlikely if # new state is not 'home'), # use it and make source_type gps. elif gps: source_type = SourceType.GPS - # Otherwise, if new state is 'home' and old state is not 'home' - # and no GPS data, then use HA's configured Home location and - # make source_type gps. - elif state == STATE_HOME and not ( - cur_state and cur_state.state == STATE_HOME - ): + # Otherwise, if new state is 'home' and old state is not 'home' w/ GPS data + # (i.e., not 'home' or no GPS data), then use HA's configured Home location + # and make source_type gps. + elif state == STATE_HOME: gps = cast( GPSType, (self.hass.config.latitude, self.hass.config.longitude), @@ -641,6 +634,13 @@ def _set_state( angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat))) if angle < 0: angle += 360 + if ( + speed is not None + and self._driving_speed is not None + and speed >= self._driving_speed + and self.state == STATE_NOT_HOME + ): + self._location_name = STATE_DRIVING _LOGGER.debug("%s: Sending speed: %s m/s, angle: %s°", self.name, speed, angle) async_dispatcher_send( self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed, angle diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 3ab4a66..c9f82fc 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.0.0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.1.0b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": [], - "version": "3.0.0" + "version": "3.1.0b0" } diff --git a/custom_components/composite/translations/en.json b/custom_components/composite/translations/en.json index 7ad5ecb..f0ce422 100644 --- a/custom_components/composite/translations/en.json +++ b/custom_components/composite/translations/en.json @@ -18,6 +18,7 @@ "options": { "title": "Composite Options", "data": { + "driving_speed": "Driving speed", "entity_id": "Input entities", "require_movement": "Require movement" } @@ -38,6 +39,9 @@ "entity": { "device_tracker": { "tracker": { + "state": { + "driving": "Driving" + }, "state_attributes": { "battery_charging": {"name": "Battery charging"}, "entities": {"name": "Seen entities"}, @@ -68,6 +72,7 @@ "options": { "title": "Composite Options", "data": { + "driving_speed": "Driving speed", "entity_id": "Input entities", "require_movement": "Require movement" } diff --git a/custom_components/composite/translations/nl.json b/custom_components/composite/translations/nl.json index cb02fe3..e1f6884 100644 --- a/custom_components/composite/translations/nl.json +++ b/custom_components/composite/translations/nl.json @@ -18,6 +18,7 @@ "options": { "title": "Samengestelde opties", "data": { + "driving_speed": "Rijsnelheid", "entity_id": "Voer entiteiten in", "require_movement": "Vereist beweging" } @@ -38,6 +39,9 @@ "entity": { "device_tracker": { "tracker": { + "state": { + "driving": "Rijden" + }, "state_attributes": { "battery_charging": {"name": "Batterij opladen"}, "entities": {"name": "Entiteiten gezien"}, @@ -68,6 +72,7 @@ "options": { "title": "Samengestelde opties", "data": { + "driving_speed": "Rijsnelheid", "entity_id": "Voer entiteiten in", "require_movement": "Vereist beweging" }