├── custom_components ├── composite │ ├── services.yaml │ ├── manifest.json │ ├── const.py │ ├── __init__.py │ ├── sensor.py │ ├── translations │ │ ├── en.json │ │ ├── it.json │ │ └── nl.json │ ├── config.py │ ├── config_flow.py │ └── device_tracker.py └── __init__.py ├── hacs.json ├── .gitignore ├── .github └── workflows │ └── validate.yml ├── info.md ├── LICENSE └── README.md /custom_components/composite/services.yaml: -------------------------------------------------------------------------------- 1 | reload: {} 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Composite Device Tracker.""" 2 | # Exists to satisfy mypy. 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Composite Device Tracker", 3 | "homeassistant": "2024.11.0" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .vscode/ 7 | -------------------------------------------------------------------------------- /custom_components/composite/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "composite", 3 | "name": "Composite", 4 | "codeowners": ["@pnbruckner"], 5 | "config_flow": true, 6 | "dependencies": ["file_upload"], 7 | "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/4.1.0/README.md", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", 10 | "requirements": ["filetype==1.2.0"], 11 | "version": "4.1.0" 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | validate-hassfest: 9 | runs-on: ubuntu-latest 10 | name: With hassfest 11 | steps: 12 | - name: 📥 Checkout the repository 13 | uses: actions/checkout@v4 14 | 15 | - name: 🏃 Hassfest validation 16 | uses: "home-assistant/actions/hassfest@master" 17 | 18 | validate-hacs: 19 | runs-on: ubuntu-latest 20 | name: With HACS Action 21 | steps: 22 | - name: 🏃 HACS validation 23 | uses: hacs/action@main 24 | with: 25 | category: integration 26 | ignore: brands 27 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Composite Device Tracker Platform Composite Device Tracker 2 | 3 | This integration creates a composite `device_tracker` entity from one or more other entities. It will update whenever one of the watched entities updates, taking the "last seen" (and possibly GPS and other) data from the changing entity. The result can be a more accurate and up-to-date device tracker if the "input" entities update irregularly. 4 | 5 | It will also create a `sensor` entity that indicates the speed of the device. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /custom_components/composite/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Composite Integration.""" 2 | DOMAIN = "composite" 3 | 4 | PICTURE_SUFFIXES = ("bmp", "jpg", "png") 5 | MIME_TO_SUFFIX = {"image/bmp": "bmp", "image/jpeg": "jpg", "image/png": "png"} 6 | 7 | DEF_REQ_MOVEMENT = False 8 | 9 | MIN_SPEED_SECONDS = 3 10 | MIN_ANGLE_SPEED = 1 # meters / second 11 | 12 | SIG_COMPOSITE_SPEED = "composite_speed" 13 | 14 | CONF_ALL_STATES = "all_states" 15 | CONF_DEFAULT_OPTIONS = "default_options" 16 | CONF_DRIVING_SPEED = "driving_speed" 17 | CONF_END_DRIVING_DELAY = "end_driving_delay" 18 | CONF_ENTITY = "entity" 19 | CONF_ENTITY_PICTURE = "entity_picture" 20 | CONF_MAX_SPEED_AGE = "max_speed_age" 21 | CONF_REQ_MOVEMENT = "require_movement" 22 | CONF_SHOW_UNKNOWN_AS_0 = "show_unknown_as_0" 23 | CONF_TIME_AS = "time_as" 24 | CONF_TRACKERS = "trackers" 25 | CONF_USE_PICTURE = "use_picture" 26 | 27 | ATTR_ACC = "acc" 28 | ATTR_ANGLE = "angle" 29 | ATTR_CHARGING = "charging" 30 | ATTR_DIRECTION = "direction" 31 | ATTR_ENTITIES = "entities" 32 | ATTR_LAST_SEEN = "last_seen" 33 | ATTR_LAST_TIMESTAMP = "last_timestamp" 34 | ATTR_LAST_ENTITY_ID = "last_entity_id" 35 | ATTR_LAT = "lat" 36 | ATTR_LON = "lon" 37 | 38 | STATE_DRIVING = "driving" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /custom_components/composite/__init__.py: -------------------------------------------------------------------------------- 1 | """Composite Device Tracker.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | from collections.abc import Coroutine 6 | import logging 7 | from typing import Any, cast 8 | 9 | from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN 10 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 11 | from homeassistant.const import CONF_ID, CONF_NAME, SERVICE_RELOAD, Platform 12 | from homeassistant.core import HomeAssistant, ServiceCall 13 | from homeassistant.helpers.reload import async_integration_yaml_config 14 | from homeassistant.helpers.service import async_register_admin_service 15 | from homeassistant.helpers.typing import ConfigType 16 | 17 | from .const import CONF_TRACKERS, DOMAIN 18 | 19 | PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 25 | """Set up composite integration.""" 26 | 27 | async def process_config(config: ConfigType | None) -> None: 28 | """Process Composite config.""" 29 | tracker_configs = cast( 30 | list[dict[str, Any]], (config or {}).get(DOMAIN, {}).get(CONF_TRACKERS, []) 31 | ) 32 | tracker_ids = [conf[CONF_ID] for conf in tracker_configs] 33 | 34 | for conf in tracker_configs: 35 | # New config entries and changed existing ones can be processed later and do 36 | # not need to delay startup. 37 | hass.async_create_background_task( 38 | hass.config_entries.flow.async_init( 39 | DOMAIN, context={"source": SOURCE_IMPORT}, data=conf 40 | ), 41 | "Import YAML config", 42 | ) 43 | 44 | tasks: list[Coroutine[Any, Any, Any]] = [] 45 | for entry in hass.config_entries.async_entries(DOMAIN): 46 | if ( 47 | entry.source != SOURCE_IMPORT 48 | or (obj_id := entry.data[CONF_ID]) in tracker_ids 49 | ): 50 | continue 51 | _LOGGER.debug( 52 | "Removing %s (%s) because it is no longer in YAML configuration", 53 | entry.data[CONF_NAME], 54 | f"{DT_DOMAIN}.{obj_id}", 55 | ) 56 | # Removing config entries needs to happen before entries get a chance to be 57 | # set up. 58 | tasks.append(hass.config_entries.async_remove(entry.entry_id)) 59 | if tasks: 60 | await asyncio.gather(*tasks) 61 | 62 | async def reload_config(_: ServiceCall) -> None: 63 | """Reload configuration.""" 64 | await process_config(await async_integration_yaml_config(hass, DOMAIN)) 65 | 66 | await process_config(config) 67 | async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, reload_config) 68 | 69 | return True 70 | 71 | 72 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 73 | """Set up config entry.""" 74 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 75 | return True 76 | 77 | 78 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 79 | """Unload a config entry.""" 80 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 81 | -------------------------------------------------------------------------------- /custom_components/composite/sensor.py: -------------------------------------------------------------------------------- 1 | """Composite Sensor.""" 2 | from __future__ import annotations 3 | 4 | from typing import cast 5 | 6 | from homeassistant.components.sensor import ( 7 | DOMAIN as S_DOMAIN, 8 | SensorDeviceClass, 9 | SensorEntity, 10 | SensorEntityDescription, 11 | SensorStateClass, 12 | ) 13 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 14 | from homeassistant.const import CONF_ID, CONF_NAME, UnitOfSpeed 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers import entity_registry as er 17 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | 20 | from .const import ( 21 | ATTR_ANGLE, 22 | ATTR_DIRECTION, 23 | CONF_SHOW_UNKNOWN_AS_0, 24 | SIG_COMPOSITE_SPEED, 25 | ) 26 | 27 | 28 | async def async_setup_entry( 29 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 30 | ) -> None: 31 | """Set up the sensor platform.""" 32 | async_add_entities([CompositeSensor(hass, entry)]) 33 | 34 | 35 | class CompositeSensor(SensorEntity): 36 | """Composite Sensor Entity.""" 37 | 38 | _attr_should_poll = False 39 | _raw_value: float | None = None 40 | _ok_to_write_state = False 41 | _show_unknown_as_0: bool 42 | 43 | def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 44 | """Initialize composite sensor entity.""" 45 | if entry.source == SOURCE_IMPORT: 46 | entity_description = SensorEntityDescription( 47 | key="speed", 48 | device_class=SensorDeviceClass.SPEED, 49 | icon="mdi:car-speed-limiter", 50 | name=cast(str, entry.data[CONF_NAME]) + " Speed", 51 | translation_key="speed", 52 | native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, 53 | state_class=SensorStateClass.MEASUREMENT, 54 | ) 55 | obj_id = (signal := cast(str, entry.data[CONF_ID])) + "_speed" 56 | self.entity_id = f"{S_DOMAIN}.{obj_id}" 57 | self._attr_unique_id = obj_id 58 | else: 59 | entity_description = SensorEntityDescription( 60 | key="speed", 61 | device_class=SensorDeviceClass.SPEED, 62 | icon="mdi:car-speed-limiter", 63 | has_entity_name=True, 64 | translation_key="speed", 65 | native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, 66 | state_class=SensorStateClass.MEASUREMENT, 67 | ) 68 | self._attr_translation_placeholders = {"name": entry.title} 69 | self._attr_unique_id = signal = entry.entry_id 70 | self.entity_description = entity_description 71 | self._attr_extra_state_attributes = { 72 | ATTR_ANGLE: None, 73 | ATTR_DIRECTION: None, 74 | } 75 | 76 | self.async_on_remove( 77 | async_dispatcher_connect( 78 | hass, f"{SIG_COMPOSITE_SPEED}-{signal}", self._update 79 | ) 80 | ) 81 | 82 | @property 83 | def native_value(self) -> float | None: 84 | """Return the value reported by the sensor.""" 85 | if self._raw_value is not None: 86 | return self._raw_value 87 | if self._show_unknown_as_0: 88 | return 0 89 | return None 90 | 91 | async def async_added_to_hass(self) -> None: 92 | """Run when entity about to be added to hass.""" 93 | await super().async_added_to_hass() 94 | self.async_on_remove( 95 | cast(ConfigEntry, self.platform.config_entry).add_update_listener( 96 | self._config_entry_updated 97 | ) 98 | ) 99 | await self.async_request_call(self._process_config_options()) 100 | self._ok_to_write_state = True 101 | 102 | async def _process_config_options(self) -> None: 103 | """Process options from config entry.""" 104 | options = cast(ConfigEntry, self.platform.config_entry).options 105 | # For backward compatibility, if the option is not present, interpret that as 106 | # the same as False. 107 | self._show_unknown_as_0 = options.get(CONF_SHOW_UNKNOWN_AS_0, False) 108 | 109 | async def _config_entry_updated( 110 | self, hass: HomeAssistant, entry: ConfigEntry 111 | ) -> None: 112 | """Run when the config entry has been updated.""" 113 | if entry.source == SOURCE_IMPORT: 114 | return 115 | if (new_name := entry.title) != self._attr_translation_placeholders["name"]: 116 | # Need to change _attr_translation_placeholders (instead of the dict to 117 | # which it refers) to clear the cached_property. 118 | self._attr_translation_placeholders = {"name": new_name} 119 | er.async_get(hass).async_update_entity( 120 | self.entity_id, original_name=self.name 121 | ) 122 | await self.async_request_call(self._process_config_options()) 123 | self.async_write_ha_state() 124 | 125 | async def _update(self, value: float | None, angle: int | None) -> None: 126 | """Update sensor with new value.""" 127 | 128 | def direction(angle: int | None) -> str | None: 129 | """Determine compass direction.""" 130 | if angle is None: 131 | return None 132 | return ("N", "NE", "E", "SE", "S", "SW", "W", "NW", "N")[ 133 | int((angle + 360 / 16) // (360 / 8)) 134 | ] 135 | 136 | self._raw_value = value 137 | self._attr_extra_state_attributes = { 138 | ATTR_ANGLE: angle, 139 | ATTR_DIRECTION: direction(angle), 140 | } 141 | # It's possible for dispatcher signal to arrive, causing this method to execute, 142 | # before this sensor entity has been completely "added to hass", meaning 143 | # self.hass might not yet have been initialized, causing this call to 144 | # async_write_ha_state to fail. We still update our state, so that the call to 145 | # async_write_ha_state at the end of the "add to hass" process will see it. Once 146 | # added to hass, we can go ahead and write the state here for future updates. 147 | if self._ok_to_write_state: 148 | self.async_write_ha_state() 149 | -------------------------------------------------------------------------------- /custom_components/composite/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Composite", 3 | "config": { 4 | "step": { 5 | "all_states": { 6 | "title": "Use All States", 7 | "description": "Select entities for which all states should be used.\nNote that this only applies to entities whose source type is not GPS.", 8 | "data": { 9 | "entity": "Entity" 10 | } 11 | }, 12 | "end_driving_delay": { 13 | "title": "End Driving Delay", 14 | "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.", 15 | "data": { 16 | "end_driving_delay": "End driving delay" 17 | } 18 | }, 19 | "ep_input_entity": { 20 | "title": "Picture Entity", 21 | "description": "Choose entity whose picture will be used for the composite.\nIt may be none.", 22 | "data": { 23 | "entity": "Entity" 24 | } 25 | }, 26 | "ep_local_file": { 27 | "title": "Local Picture File", 28 | "description": "Choose local file to be used for the composite.\nIt may be none.", 29 | "data": { 30 | "entity_picture": "Local file" 31 | } 32 | }, 33 | "ep_menu": { 34 | "title": "Composite Entity Picture", 35 | "description": "Choose source of composite's entity picture.\n\nCurrently: {cur_source}", 36 | "menu_options": { 37 | "all_states": "Keep current setting", 38 | "ep_local_file": "Select local file", 39 | "ep_input_entity": "Use an input entity's picture", 40 | "ep_none": "Do not use an entity picture", 41 | "ep_upload_file": "Upload a file" 42 | } 43 | }, 44 | "ep_upload_file": { 45 | "title": "Upload Picture File", 46 | "description": "Upload a file to be used for the composite.\nIt may be none.", 47 | "data": { 48 | "entity_picture": "Picture file" 49 | } 50 | }, 51 | "ep_warn": { 52 | "title": "File Upload Directory Created", 53 | "description": "/local directory ({local_dir}) was created.\nHome Assistant may need to be restarted for uploaded files to be usable." 54 | }, 55 | "name": { 56 | "title": "Name", 57 | "data": { 58 | "name": "Name" 59 | } 60 | }, 61 | "options": { 62 | "title": "Composite Options", 63 | "data": { 64 | "driving_speed": "Driving speed", 65 | "entity_id": "Input entities", 66 | "max_speed_age": "Max time between speed sensor updates", 67 | "require_movement": "Require movement", 68 | "show_unknown_as_0": "Show unknown speed as 0.0" 69 | } 70 | } 71 | }, 72 | "error": { 73 | "at_least_one_entity": "Must select at least one input entity.", 74 | "name_used": "Name has already been used." 75 | } 76 | }, 77 | "entity": { 78 | "device_tracker": { 79 | "tracker": { 80 | "state": { 81 | "driving": "Driving" 82 | }, 83 | "state_attributes": { 84 | "battery_charging": {"name": "Battery charging"}, 85 | "entities": {"name": "Seen entities"}, 86 | "last_entity_id": {"name": "Last entity"}, 87 | "last_seen": {"name": "Last seen"} 88 | } 89 | } 90 | }, 91 | "sensor": { 92 | "speed": { 93 | "name": "{name} speed", 94 | "state_attributes": { 95 | "angle": {"name": "Angle"}, 96 | "direction": {"name": "Direction"} 97 | } 98 | } 99 | } 100 | }, 101 | "options": { 102 | "step": { 103 | "all_states": { 104 | "title": "Use All States", 105 | "description": "Select entities for which all states should be used.\nNote that this only applies to entities whose source type is not GPS.", 106 | "data": { 107 | "entity": "Entity" 108 | } 109 | }, 110 | "end_driving_delay": { 111 | "title": "End Driving Delay", 112 | "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.", 113 | "data": { 114 | "end_driving_delay": "End driving delay" 115 | } 116 | }, 117 | "ep_input_entity": { 118 | "title": "Picture Entity", 119 | "description": "Choose entity whose picture will be used for the composite.\nIt may be none.", 120 | "data": { 121 | "entity": "Entity" 122 | } 123 | }, 124 | "ep_local_file": { 125 | "title": "Local Picture File", 126 | "description": "Choose local file to be used for the composite.\nIt may be none.", 127 | "data": { 128 | "entity_picture": "Local file" 129 | } 130 | }, 131 | "ep_menu": { 132 | "title": "Composite Entity Picture", 133 | "description": "Choose source of composite's entity picture.\n\nCurrently: {cur_source}", 134 | "menu_options": { 135 | "all_states": "Keep current setting", 136 | "ep_local_file": "Select local file", 137 | "ep_input_entity": "Use an input entity's picture", 138 | "ep_none": "Do not use an entity picture", 139 | "ep_upload_file": "Upload a file" 140 | } 141 | }, 142 | "ep_upload_file": { 143 | "title": "Upload Picture File", 144 | "description": "Upload a file to be used for the composite.\nIt may be none.", 145 | "data": { 146 | "entity_picture": "Picture file" 147 | } 148 | }, 149 | "ep_warn": { 150 | "title": "File Upload Directory Created", 151 | "description": "/local directory ({local_dir}) was created.\nHome Assistant may need to be restarted for uploaded files to be usable." 152 | }, 153 | "options": { 154 | "title": "Composite Options", 155 | "data": { 156 | "driving_speed": "Driving speed", 157 | "entity_id": "Input entities", 158 | "max_speed_age": "Max time between speed sensor updates", 159 | "require_movement": "Require movement", 160 | "show_unknown_as_0": "Show unknown speed as 0.0" 161 | } 162 | } 163 | }, 164 | "error": { 165 | "at_least_one_entity": "Must select at least one input entity." 166 | } 167 | }, 168 | "services": { 169 | "reload": { 170 | "name": "Reload", 171 | "description": "Reloads Composite from the YAML-configuration." 172 | } 173 | } 174 | } -------------------------------------------------------------------------------- /custom_components/composite/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Composito", 3 | "config": { 4 | "step": { 5 | "all_states": { 6 | "title": "Usa tutti gli stati", 7 | "description": "Seleziona le entità per le quali devono essere utilizzati tutti gli stati.\nNota che questo si applica solo alle entità il cui tipo di sorgente non è GPS.", 8 | "data": { 9 | "entity": "Entità" 10 | } 11 | }, 12 | "end_driving_delay": { 13 | "title": "Ritardo fine guida", 14 | "description": "Tempo per il quale è mantenuto lo stato \"Alla guida\" dopo che la velocità scende al di sotto della velocità di guida configurata.\n\nLascia vuoto per tornare immediatamente a \"Fuori casa\". Se utilizzato, un buon punto di partenza è 2 minuti.", 15 | "data": { 16 | "end_driving_delay": "Ritardo fine guida" 17 | } 18 | }, 19 | "ep_input_entity": { 20 | "title": "Entità immagine", 21 | "description": "Scegli l'entità la cui immagine verrà utilizzata per il composito.\nPuoi lasciare il campo vuoto.", 22 | "data": { 23 | "entity": "Entità" 24 | } 25 | }, 26 | "ep_local_file": { 27 | "title": "File immagine locale", 28 | "description": "Scegli il file locale da utilizzare per il composito.\nPuoi lasciare il campo vuoto.", 29 | "data": { 30 | "entity_picture": "File locale" 31 | } 32 | }, 33 | "ep_menu": { 34 | "title": "Immagine entità composito", 35 | "description": "Scegli la sorgente dell'immagine dell'entità del composito.\n\nAttualmente: {cur_source}", 36 | "menu_options": { 37 | "all_states": "Mantieni l'impostazione corrente", 38 | "ep_local_file": "Seleziona file locale", 39 | "ep_input_entity": "Usa l'immagine di un'entità di input", 40 | "ep_none": "Non usare un'immagine entità", 41 | "ep_upload_file": "Carica un file" 42 | } 43 | }, 44 | "ep_upload_file": { 45 | "title": "Carica file immagine", 46 | "description": "Carica un file da utilizzare per il composito.\nPuoi lasciare il campo vuoto.", 47 | "data": { 48 | "entity_picture": "File immagine" 49 | } 50 | }, 51 | "ep_warn": { 52 | "title": "Directory caricamento file creata", 53 | "description": "La directory /local ({local_dir}) è stata creata.\nPotrebbe essere necessario riavviare Home Assistant affinché i file caricati siano utilizzabili." 54 | }, 55 | "name": { 56 | "title": "Nome", 57 | "data": { 58 | "name": "Nome" 59 | } 60 | }, 61 | "options": { 62 | "title": "Opzioni composito", 63 | "data": { 64 | "driving_speed": "Velocità di guida", 65 | "entity_id": "Entità di input", 66 | "max_speed_age": "Tempo massimo tra aggiornamenti del sensore di velocità", 67 | "require_movement": "Richiedi movimento", 68 | "show_unknown_as_0": "Mostra 0,0 quando la velocità è sconosciuta" 69 | } 70 | } 71 | }, 72 | "error": { 73 | "at_least_one_entity": "Devi selezionare almeno un'entità di input.", 74 | "name_used": "Il nome è già stato utilizzato." 75 | } 76 | }, 77 | "entity": { 78 | "device_tracker": { 79 | "tracker": { 80 | "state": { 81 | "driving": "Alla guida" 82 | }, 83 | "state_attributes": { 84 | "battery_charging": {"name": "Batteria in carica"}, 85 | "entities": {"name": "Entità rilevate"}, 86 | "last_entity_id": {"name": "Ultima entità"}, 87 | "last_seen": {"name": "Ultima rilevazione"} 88 | } 89 | } 90 | }, 91 | "sensor": { 92 | "speed": { 93 | "name": "Velocità {name}", 94 | "state_attributes": { 95 | "angle": {"name": "Angolo"}, 96 | "direction": {"name": "Direzione"} 97 | } 98 | } 99 | } 100 | }, 101 | "options": { 102 | "step": { 103 | "all_states": { 104 | "title": "Usa tutti gli stati", 105 | "description": "Seleziona le entità per le quali devono essere utilizzati tutti gli stati.\nNota che questo si applica solo alle entità il cui tipo di sorgente non è GPS.", 106 | "data": { 107 | "entity": "Entità" 108 | } 109 | }, 110 | "end_driving_delay": { 111 | "title": "Ritardo fine guida", 112 | "description": "Tempo per il quale è mantenuto lo stato \"Alla guida\" dopo che la velocità scende al di sotto della velocità di guida configurata.\n\nLascia vuoto per tornare immediatamente a \"Fuori casa\". Se utilizzato, un buon punto di partenza è 2 minuti.", 113 | "data": { 114 | "end_driving_delay": "Ritardo fine guida" 115 | } 116 | }, 117 | "ep_input_entity": { 118 | "title": "Entità immagine", 119 | "description": "Scegli l'entità la cui immagine verrà utilizzata per il composito.\nPuoi lasciare il campo vuoto.", 120 | "data": { 121 | "entity": "Entità" 122 | } 123 | }, 124 | "ep_local_file": { 125 | "title": "File immagine locale", 126 | "description": "Scegli il file locale da utilizzare per il composito.\nPuoi lasciare il campo vuoto.", 127 | "data": { 128 | "entity_picture": "File locale" 129 | } 130 | }, 131 | "ep_menu": { 132 | "title": "Immagine entità composito", 133 | "description": "Scegli la sorgente dell'immagine dell'entità del composito.\n\nAttualmente: {cur_source}", 134 | "menu_options": { 135 | "all_states": "Mantieni l'impostazione corrente", 136 | "ep_local_file": "Seleziona file locale", 137 | "ep_input_entity": "Usa l'immagine di un'entità di input", 138 | "ep_none": "Non usare un'immagine entità", 139 | "ep_upload_file": "Carica un file" 140 | } 141 | }, 142 | "ep_upload_file": { 143 | "title": "Carica file immagine", 144 | "description": "Carica un file da utilizzare per il composito.\nPuoi lasciare il campo vuoto.", 145 | "data": { 146 | "entity_picture": "File immagine" 147 | } 148 | }, 149 | "ep_warn": { 150 | "title": "Directory caricamento file creata", 151 | "description": "La directory /local ({local_dir}) è stata creata.\nPotrebbe essere necessario riavviare Home Assistant affinché i file caricati siano utilizzabili." 152 | }, 153 | "options": { 154 | "title": "Opzioni composito", 155 | "data": { 156 | "driving_speed": "Velocità di guida", 157 | "entity_id": "Entità di input", 158 | "max_speed_age": "Tempo massimo tra aggiornamenti del sensore di velocità", 159 | "require_movement": "Richiedi movimento", 160 | "show_unknown_as_0": "Mostra 0,0 quando la velocità è sconosciuta" 161 | } 162 | } 163 | }, 164 | "error": { 165 | "at_least_one_entity": "Devi selezionare almeno un'entità di input." 166 | } 167 | }, 168 | "services": { 169 | "reload": { 170 | "name": "Ricarica", 171 | "description": "Ricarica Composito dalla configurazione YAML." 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /custom_components/composite/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Composiet", 3 | "config": { 4 | "step": { 5 | "all_states": { 6 | "title": "Gebruik Alle Staten", 7 | "description": "Selecteer entiteiten waarvoor alle staten moeten worden gebruikt.\nMerk op dat dit alleen geldt voor entiteiten waarvan het brontype niet GPS is.", 8 | "data": { 9 | "entity": "Entiteit" 10 | } 11 | }, 12 | "end_driving_delay": { 13 | "title": "Einde Rijden Vertraging", 14 | "description": "Tijd om de staat Rijden vast te houden nadat de snelheid onder de ingestelde rijsnelheid komt.\n\nLaat leeg om direct terug te schakelen naar Afwezig. Indien gebruikt, is een goede startwaarde 2 minuten.", 15 | "data": { 16 | "end_driving_delay": "Vertraging einde rijden" 17 | } 18 | }, 19 | "ep_input_entity": { 20 | "title": "Afbeeldingsentiteit", 21 | "description": "Kies de entiteit waarvan de afbeelding gebruikt zal worden voor de composiet.\nDit mag ook leeg zijn.", 22 | "data": { 23 | "entity": "Entiteit" 24 | } 25 | }, 26 | "ep_local_file": { 27 | "title": "Lokaal Afbeeldingsbestand", 28 | "description": "Kies een lokaal bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.", 29 | "data": { 30 | "entity_picture": "Lokaal bestand" 31 | } 32 | }, 33 | "ep_menu": { 34 | "title": "Composiet Entiteitsafbeelding", 35 | "description": "Kies de bron van de entiteitsafbeelding van de composiet.\n\nHuidig: {cur_source}", 36 | "menu_options": { 37 | "all_states": "Huidige instelling behouden", 38 | "ep_local_file": "Selecteer lokaal bestand", 39 | "ep_input_entity": "Gebruik de afbeelding van een invoerentiteit", 40 | "ep_none": "Geen entiteitsafbeelding gebruiken", 41 | "ep_upload_file": "Een bestand uploaden" 42 | } 43 | }, 44 | "ep_upload_file": { 45 | "title": "Afbeeldingsbestand Uploaden", 46 | "description": "Upload een bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.", 47 | "data": { 48 | "entity_picture": "Afbeeldingsbestand" 49 | } 50 | }, 51 | "ep_warn": { 52 | "title": "Map voor Bestandsupload Aangemaakt", 53 | "description": "/local map ({local_dir}) is aangemaakt.\nHome Assistant moet mogelijk opnieuw worden gestart om geüploade bestanden bruikbaar te maken." 54 | }, 55 | "name": { 56 | "title": "Naam", 57 | "data": { 58 | "name": "Naam" 59 | } 60 | }, 61 | "options": { 62 | "title": "Composietopties", 63 | "data": { 64 | "driving_speed": "Rijsnelheid", 65 | "entity_id": "Invoerentiteiten", 66 | "max_speed_age": "Maximale tijd tussen updates van snelheidsensor", 67 | "require_movement": "Beweging vereisen", 68 | "show_unknown_as_0": "Onbekende snelheid als 0.0 tonen" 69 | } 70 | } 71 | }, 72 | "error": { 73 | "at_least_one_entity": "Er moet minstens één invoerentiteit worden geselecteerd.", 74 | "name_used": "Naam is al in gebruik." 75 | } 76 | }, 77 | "entity": { 78 | "device_tracker": { 79 | "tracker": { 80 | "state": { 81 | "driving": "Rijden" 82 | }, 83 | "state_attributes": { 84 | "battery_charging": {"name": "Batterij wordt opgeladen"}, 85 | "entities": {"name": "Gezien door entiteiten"}, 86 | "last_entity_id": {"name": "Laatste entiteit"}, 87 | "last_seen": {"name": "Laatst gezien"} 88 | } 89 | } 90 | }, 91 | "sensor": { 92 | "speed": { 93 | "name": "{name} snelheid", 94 | "state_attributes": { 95 | "angle": {"name": "Hoek"}, 96 | "direction": {"name": "Richting"} 97 | } 98 | } 99 | } 100 | }, 101 | "options": { 102 | "step": { 103 | "all_states": { 104 | "title": "Gebruik Alle Staten", 105 | "description": "Selecteer entiteiten waarvoor alle staten moeten worden gebruikt.\nMerk op dat dit alleen geldt voor entiteiten waarvan het brontype niet GPS is.", 106 | "data": { 107 | "entity": "Entiteit" 108 | } 109 | }, 110 | "end_driving_delay": { 111 | "title": "Einde Rijden Vertraging", 112 | "description": "Tijd om de staat Rijden vast te houden nadat de snelheid onder de ingestelde rijsnelheid komt.\n\nLaat leeg om direct terug te schakelen naar Afwezig. Indien gebruikt, is een goede startwaarde 2 minuten.", 113 | "data": { 114 | "end_driving_delay": "Vertraging einde rijden" 115 | } 116 | }, 117 | "ep_input_entity": { 118 | "title": "Afbeeldingsentiteit", 119 | "description": "Kies de entiteit waarvan de afbeelding gebruikt zal worden voor de composiet.\nDit mag ook leeg zijn.", 120 | "data": { 121 | "entity": "Entiteit" 122 | } 123 | }, 124 | "ep_local_file": { 125 | "title": "Lokaal Afbeeldingsbestand", 126 | "description": "Kies een lokaal bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.", 127 | "data": { 128 | "entity_picture": "Lokaal bestand" 129 | } 130 | }, 131 | "ep_menu": { 132 | "title": "Composiet Entiteitsafbeelding", 133 | "description": "Kies de bron van de entiteitsafbeelding van de composiet.\n\nHuidig: {cur_source}", 134 | "menu_options": { 135 | "all_states": "Huidige instelling behouden", 136 | "ep_local_file": "Selecteer lokaal bestand", 137 | "ep_input_entity": "Gebruik de afbeelding van een invoerentiteit", 138 | "ep_none": "Geen entiteitsafbeelding gebruiken", 139 | "ep_upload_file": "Een bestand uploaden" 140 | } 141 | }, 142 | "ep_upload_file": { 143 | "title": "Afbeeldingsbestand Uploaden", 144 | "description": "Upload een bestand dat zal worden gebruikt voor de composiet.\nDit mag ook leeg zijn.", 145 | "data": { 146 | "entity_picture": "Afbeeldingsbestand" 147 | } 148 | }, 149 | "ep_warn": { 150 | "title": "Map voor Bestandsupload Aangemaakt", 151 | "description": "/local map ({local_dir}) is aangemaakt.\nHome Assistant moet mogelijk opnieuw worden gestart om geüploade bestanden bruikbaar te maken." 152 | }, 153 | "options": { 154 | "title": "Composietopties", 155 | "data": { 156 | "driving_speed": "Rijsnelheid", 157 | "entity_id": "Invoerentiteiten", 158 | "max_speed_age": "Maximale tijd tussen updates van snelheidsensor", 159 | "require_movement": "Beweging vereisen", 160 | "show_unknown_as_0": "Onbekende snelheid als 0.0 tonen" 161 | } 162 | } 163 | }, 164 | "error": { 165 | "at_least_one_entity": "Er moet minstens één invoerentiteit worden geselecteerd." 166 | } 167 | }, 168 | "services": { 169 | "reload": { 170 | "name": "Herladen", 171 | "description": "Laadt de Composiet-integratie opnieuw vanuit de YAML-configuratie." 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /custom_components/composite/config.py: -------------------------------------------------------------------------------- 1 | """Composite config validation.""" 2 | from __future__ import annotations 3 | 4 | from contextlib import suppress 5 | from datetime import timedelta 6 | import logging 7 | from pathlib import Path 8 | from typing import Any, cast 9 | 10 | import voluptuous as vol 11 | 12 | from homeassistant.const import CONF_ENTITY_ID, CONF_ID, CONF_NAME 13 | from homeassistant.core import HomeAssistant, async_get_hass 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers.typing import ConfigType 16 | from homeassistant.util import slugify 17 | 18 | from .const import ( 19 | CONF_ALL_STATES, 20 | CONF_DEFAULT_OPTIONS, 21 | CONF_DRIVING_SPEED, 22 | CONF_END_DRIVING_DELAY, 23 | CONF_ENTITY, 24 | CONF_ENTITY_PICTURE, 25 | CONF_MAX_SPEED_AGE, 26 | CONF_REQ_MOVEMENT, 27 | CONF_SHOW_UNKNOWN_AS_0, 28 | CONF_TIME_AS, 29 | CONF_TRACKERS, 30 | CONF_USE_PICTURE, 31 | DEF_REQ_MOVEMENT, 32 | DOMAIN, 33 | ) 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | PACKAGE_MERGE_HINT = "dict" 38 | 39 | CONF_TZ_FINDER = "tz_finder" 40 | CONF_TZ_FINDER_CLASS = "tz_finder_class" 41 | 42 | 43 | def _entities(entities: list[str | dict]) -> list[dict]: 44 | """Convert entity ID to dict of entity, all_states & use_picture. 45 | 46 | Also ensure no more than one entity has use_picture set to true. 47 | """ 48 | result: list[dict] = [] 49 | already_using_picture = False 50 | for idx, entity in enumerate(entities): 51 | if isinstance(entity, dict): 52 | if entity[CONF_USE_PICTURE]: 53 | if already_using_picture: 54 | raise vol.Invalid( 55 | f"{CONF_USE_PICTURE} may only be true for one entity per " 56 | "composite tracker", 57 | path=[idx, CONF_USE_PICTURE], 58 | ) 59 | already_using_picture = True 60 | result.append(entity) 61 | else: 62 | result.append( 63 | {CONF_ENTITY: entity, CONF_ALL_STATES: False, CONF_USE_PICTURE: False} 64 | ) 65 | return result 66 | 67 | 68 | def _time_period_to_dict(delay: timedelta) -> dict[str, float]: 69 | """Return timedelta as a dict.""" 70 | result: dict[str, float] = {} 71 | if delay.days: 72 | result["days"] = delay.days 73 | result["hours"] = delay.seconds // 3600 74 | result["minutes"] = (delay.seconds // 60) % 60 75 | result["seconds"] = delay.seconds % 60 76 | return result 77 | 78 | 79 | def _entity_picture(entity_picture: str) -> str: 80 | """Validate entity picture. 81 | 82 | Must be run in an executor since it might do file I/O. 83 | 84 | Can be an URL or a file in "/local". 85 | 86 | file can be "/local/file" or just "file" 87 | 88 | Returns URL or "/local/file" 89 | """ 90 | with suppress(vol.Invalid): 91 | return cv.url(entity_picture) 92 | 93 | local_dir = Path("/local") 94 | local_file = Path(entity_picture) 95 | with suppress(ValueError): 96 | local_file = local_file.relative_to(local_dir) 97 | if not (Path(async_get_hass().config.path("www")) / local_file).is_file(): 98 | raise vol.Invalid(f"{entity_picture} does not exist") 99 | return str(local_dir / local_file) 100 | 101 | 102 | def _trackers(trackers: list[dict[str, Any]]) -> list[dict[str, Any]]: 103 | """Validate tracker entries. 104 | 105 | Determine tracker IDs and ensure they are unique. 106 | Also for each tracker, check that no entity has use_picture set if an entity_picture 107 | file is specified for tracker. 108 | """ 109 | ids: list[str] = [] 110 | for t_idx, tracker in enumerate(trackers): 111 | if CONF_ID not in tracker: 112 | name: str = tracker[CONF_NAME] 113 | if name == slugify(name): 114 | tracker[CONF_ID] = name 115 | tracker[CONF_NAME] = name.replace("_", " ").title() 116 | else: 117 | tracker[CONF_ID] = cv.slugify(tracker[CONF_NAME]) 118 | ids.append(cast(str, tracker[CONF_ID])) 119 | if tracker.get(CONF_ENTITY_PICTURE): 120 | for e_idx, entity in enumerate(tracker[CONF_ENTITY_ID]): 121 | if entity[CONF_USE_PICTURE]: 122 | raise vol.Invalid( 123 | f"{CONF_ENTITY_PICTURE} specified; " 124 | f"cannot use {CONF_USE_PICTURE}", 125 | path=[t_idx, CONF_ENTITY_ID, e_idx, CONF_USE_PICTURE], 126 | ) 127 | if len(ids) != len(set(ids)): 128 | raise vol.Invalid("id's must be unique") 129 | return trackers 130 | 131 | 132 | def _defaults(config: dict) -> dict: 133 | """Apply default options to trackers. 134 | 135 | Also warn about options no longer supported. 136 | """ 137 | unsupported_cfgs = set() 138 | if config.pop(CONF_TZ_FINDER, None): 139 | unsupported_cfgs.add(CONF_TZ_FINDER) 140 | if config.pop(CONF_TZ_FINDER_CLASS, None): 141 | unsupported_cfgs.add(CONF_TZ_FINDER_CLASS) 142 | if config[CONF_DEFAULT_OPTIONS].pop(CONF_TIME_AS, None): 143 | unsupported_cfgs.add(CONF_TIME_AS) 144 | 145 | def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT] 146 | def_shu_az = config[CONF_DEFAULT_OPTIONS].get(CONF_SHOW_UNKNOWN_AS_0) 147 | def_max_sa = config[CONF_DEFAULT_OPTIONS].get(CONF_MAX_SPEED_AGE) 148 | def_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED) 149 | def_end_dd = config[CONF_DEFAULT_OPTIONS].get(CONF_END_DRIVING_DELAY) 150 | end_dd_but_no_drv_sp = False 151 | for tracker in config[CONF_TRACKERS]: 152 | if tracker.pop(CONF_TIME_AS, None): 153 | unsupported_cfgs.add(CONF_TIME_AS) 154 | tracker[CONF_REQ_MOVEMENT] = tracker.get(CONF_REQ_MOVEMENT, def_req_mv) 155 | if CONF_SHOW_UNKNOWN_AS_0 not in tracker and def_shu_az is not None: 156 | tracker[CONF_SHOW_UNKNOWN_AS_0] = def_shu_az 157 | if CONF_MAX_SPEED_AGE not in tracker and def_max_sa is not None: 158 | tracker[CONF_MAX_SPEED_AGE] = def_max_sa 159 | if CONF_DRIVING_SPEED not in tracker and def_drv_sp is not None: 160 | tracker[CONF_DRIVING_SPEED] = def_drv_sp 161 | if CONF_END_DRIVING_DELAY not in tracker and def_end_dd is not None: 162 | tracker[CONF_END_DRIVING_DELAY] = def_end_dd 163 | if CONF_END_DRIVING_DELAY in tracker and CONF_DRIVING_SPEED not in tracker: 164 | end_dd_but_no_drv_sp = True 165 | 166 | if unsupported_cfgs: 167 | _LOGGER.warning( 168 | "Your %s configuration contains options that are no longer supported: %s; " 169 | "Please remove them", 170 | DOMAIN, 171 | ", ".join(sorted(unsupported_cfgs)), 172 | ) 173 | if end_dd_but_no_drv_sp: 174 | raise vol.Invalid( 175 | f"using {CONF_END_DRIVING_DELAY}; " 176 | f"{CONF_DRIVING_SPEED} must also be specified" 177 | ) 178 | 179 | del config[CONF_DEFAULT_OPTIONS] 180 | return config 181 | 182 | 183 | _ENTITIES = vol.All( 184 | cv.ensure_list, 185 | [ 186 | vol.Any( 187 | cv.entity_id, 188 | vol.Schema( 189 | { 190 | vol.Required(CONF_ENTITY): cv.entity_id, 191 | vol.Optional(CONF_ALL_STATES, default=False): cv.boolean, 192 | vol.Optional(CONF_USE_PICTURE, default=False): cv.boolean, 193 | } 194 | ), 195 | ) 196 | ], 197 | vol.Length(1), 198 | _entities, 199 | ) 200 | _POS_TIME_PERIOD = vol.All(cv.positive_time_period, _time_period_to_dict) 201 | _TRACKER = { 202 | vol.Required(CONF_NAME): cv.string, 203 | vol.Optional(CONF_ID): cv.slugify, 204 | vol.Required(CONF_ENTITY_ID): _ENTITIES, 205 | vol.Optional(CONF_TIME_AS): cv.string, 206 | vol.Optional(CONF_REQ_MOVEMENT): cv.boolean, 207 | vol.Optional(CONF_SHOW_UNKNOWN_AS_0): cv.boolean, 208 | vol.Optional(CONF_MAX_SPEED_AGE): _POS_TIME_PERIOD, 209 | vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), 210 | vol.Optional(CONF_END_DRIVING_DELAY): _POS_TIME_PERIOD, 211 | vol.Optional(CONF_ENTITY_PICTURE): vol.All(cv.string, _entity_picture), 212 | } 213 | _CONFIG_SCHEMA = vol.Schema( 214 | { 215 | vol.Optional(DOMAIN): vol.All( 216 | vol.Schema( 217 | { 218 | vol.Optional(CONF_TZ_FINDER): cv.string, 219 | vol.Optional(CONF_TZ_FINDER_CLASS): cv.string, 220 | vol.Optional(CONF_DEFAULT_OPTIONS, default=dict): vol.Schema( 221 | { 222 | vol.Optional(CONF_TIME_AS): cv.string, 223 | vol.Optional( 224 | CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT 225 | ): cv.boolean, 226 | vol.Optional(CONF_SHOW_UNKNOWN_AS_0): cv.boolean, 227 | vol.Optional(CONF_MAX_SPEED_AGE): _POS_TIME_PERIOD, 228 | vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), 229 | vol.Optional(CONF_END_DRIVING_DELAY): _POS_TIME_PERIOD, 230 | } 231 | ), 232 | vol.Required(CONF_TRACKERS, default=list): vol.All( 233 | cv.ensure_list, vol.Length(1), [_TRACKER], _trackers 234 | ), 235 | } 236 | ), 237 | _defaults, 238 | ) 239 | }, 240 | extra=vol.ALLOW_EXTRA, 241 | ) 242 | 243 | 244 | async def async_validate_config( 245 | hass: HomeAssistant, config: ConfigType 246 | ) -> ConfigType | None: 247 | """Validate configuration.""" 248 | # Perform _CONFIG_SCHEMA validation in executor since it may indirectly invoke 249 | # _entity_picture which must be run in an executor because it might do file I/O. 250 | return cast(ConfigType, await hass.async_add_executor_job(_CONFIG_SCHEMA, config)) 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Composite Device Tracker Platform Composite Device Tracker 2 | 3 | This integration creates a composite `device_tracker` entity from one or more other entities. It will update whenever one of the watched entities updates, taking the "last seen" (and possibly GPS and other) data from the changing entity. The result can be a more accurate and up-to-date device tracker if the "input" entities update irregularly. 4 | 5 | It will also create a `sensor` entity that indicates the speed of the device. 6 | 7 | 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. 8 | 9 | ## Installation 10 | 11 |
12 | With HACS 13 | 14 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/) 15 | 16 | You can use HACS to manage the installation and provide update notifications. 17 | 18 | 1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/). 19 | It should then appear as a new integration. Click on it. If necessary, search for "composite". 20 | 21 | ```text 22 | https://github.com/pnbruckner/ha-composite-tracker 23 | ``` 24 | Or use this button: 25 | 26 | [![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) 27 | 28 | 29 | 1. Download the integration using the appropriate button. 30 | 31 |
32 | 33 |
34 | Manual 35 | 36 | Place a copy of the files from [`custom_components/composite`](custom_components/composite) 37 | in `/custom_components/composite`, 38 | where `` is your Home Assistant configuration directory. 39 | 40 | >__NOTE__: When downloading, make sure to use the `Raw` button from each file's page. 41 | 42 |
43 | 44 | After it has been downloaded you will need to restart Home Assistant. 45 | 46 | ### Versions 47 | 48 | This custom integration supports HomeAssistant versions 2024.11.0 or newer. 49 | 50 | ## Configuration 51 | 52 | Composite entities can be created via the UI on the Integrations page or by YAML entries. 53 | 54 | To create a Composite entity via the UI you can use this My Button: 55 | 56 | [![add integration](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start?domain=composite) 57 | 58 | Alternatively, go to Settings -> Devices & services and click the **`+ ADD INTEGRATION`** button. 59 | Find or search for "Composite", click on it, then follow the prompts. 60 | 61 | The remainder of this section describes YAML configuration. 62 | Here is an example YAML configuration: 63 | 64 | ```yaml 65 | composite: 66 | trackers: 67 | - name: Me 68 | entity_id: 69 | - entity: device_tracker.platform1_me 70 | use_picture: true 71 | - device_tracker.platform2_me 72 | - binary_sensor.i_am_home 73 | ``` 74 | 75 | - **default_options** (*Optional*): Defines default values for corresponding options under **trackers**. 76 | - **require_movement** (*Optional*): Default is `false`. 77 | - **show_unknown_as_0** (*Optional*)): Default is `false`. 78 | - **max_speed_age** (*Optional*) 79 | - **driving_speed** (*Optional*) 80 | - **end_driving_delay** (*Optoinal*) 81 | 82 | - **trackers**: The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries). 83 | 84 | ### Tracker entries 85 | 86 | - **entity_id**: Specifies the watched entities. Can be an entity ID, a dictionary (see [Entity Dictionary](#entity-dictionary)), or a list containing any combination of these. 87 | - **name**: Friendly name of composite device. 88 | - **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`.) 89 | - **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. 90 | - **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. 91 | - **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.) 92 | - **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`. 93 | - **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. 94 | - **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. 95 | 96 | #### Entity Dictionary 97 | 98 | - **entity**: Entity ID of an entity to watch. 99 | - **all_states** (*Optional*): `true` or `false`. Default is `false`. If `true`, use all states of the entity. If `false`, only use the "Home" state. NOTE: This option is ignored for entities whose `source_type` is `gps` for which all states are always used. 100 | - **use_picture** (*Optional*): `true` or `false`. Default is `false`. If `true`, use the entity's picture for the composite. Can only be `true` for at most one of the entities. If `entity_picture` is used, then this option cannot be used. 101 | 102 | ## Watched device notes 103 | ### Used states 104 | 105 | For watched non-GPS-based devices, which states are used and whether any GPS data (if present) is used depends on several factors. E.g., if GPS-based devices are in use then the 'not_home'/'off' state of non-GPS-based devices will be ignored (unless `all_states` was specified as `true` for that entity.) If only non-GPS-based devices are in use, then the composite device will be 'home' if any of the watched devices are 'home'/'on', and will be 'not_home' only when _all_ the watched devices are 'not_home'/'off'. 106 | 107 | ### Last seen 108 | 109 | If a watched device has a "last seen" attribute (i.e. `last_seen` or `last_timestamp`), that will be used in the composite device. If not, then `last_updated` from the entity's [state object](https://www.home-assistant.io/docs/configuration/state_object/) will be used instead. 110 | 111 | The "last seen" attribute can be in any one of these formats: 112 | 113 | Python type | description 114 | -|- 115 | aware `datetime` | In any time zone 116 | naive `datetime` | Assumed to be in the system's time zone (Settings -> System -> General) 117 | `float`, `int`, `str` | A POSIX timestamp (anything accepted by `homeassistant.util.dt.utc_from_timestamp(float(x))` 118 | `str` | A date & time, aware or naive (anything accepted by `homeassistant.util.dt.parse_datetime`) 119 | 120 | * See [Aware and Naive Objects](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects) 121 | 122 | Integrations known to provide a supported "last seen" attribute: 123 | 124 | - Google Maps (`last_seen`, [built-in](https://www.home-assistant.io/integrations/google_maps/) or [enhanced custom](https://github.com/pnbruckner/ha-google-maps)) 125 | - [Enhanced GPSLogger](https://github.com/pnbruckner/ha-gpslogger) (`last_seen`) 126 | - [iCould3](https://github.com/gcobb321/icloud3) (`last_timestamp`) 127 | 128 | ### Miscellaneous 129 | 130 | If a watched device has a `battery_level` or `battery` attribute, that will be used to update the composite device's `battery_level` attribute. If it has a `battery_charging` or `charging` attribute, that will be used to udpate the composite device's `battery_charging` attribute. 131 | 132 | ## `device_tracker` Attributes 133 | 134 | Attribute | Description 135 | -|- 136 | battery_level | Battery level (in percent, if available.) 137 | battery_charging | Battery charging status (True/False, if available.) 138 | entities | IDs of entities that have contributed to the state of the composite device. 139 | entity_picture | Picture to use for composite (if configured and available.) 140 | gps_accuracy | GPS accuracy radius (in meters, if available.) 141 | last_entity_id | ID of the last entity to update the composite device. 142 | last_seen | Date and time when current location information was last updated. 143 | latitude | Latitude of current location (if available.) 144 | longitude | Longitude of current location (if available.) 145 | source_type | Source of current location information: `binary_sensor`, `bluetooth`, `bluetooth_le`, `gps` or `router`. 146 | 147 | ## Speed `sensor` Attributes 148 | 149 | Attribute | Description 150 | -|- 151 | angle | Angle of movement direction (in degrees, if moving.) 152 | direction | Compass heading of movement direction (if moving.) 153 | 154 | ## Examples 155 | ### Example Full Config 156 | ```yaml 157 | composite: 158 | default_options: 159 | require_movement: true 160 | show_unknown_as_0: true 161 | max_speed_age: 162 | minutes: 5 163 | driving_speed: 15 164 | end_driving_delay: "00:02:00" 165 | trackers: 166 | - name: Me 167 | driving_speed: 20 168 | entity_id: 169 | - entity: device_tracker.platform1_me 170 | use_picture: true 171 | - device_tracker.platform2_me 172 | - device_tracker.router_my_device 173 | - entity: binary_sensor.i_am_home 174 | all_states: true 175 | - name: Better Half 176 | id: wife 177 | require_movement: false 178 | end_driving_delay: 30 179 | entity_picture: /local/wife.jpg 180 | entity_id: device_tracker.platform_wife 181 | ``` 182 | -------------------------------------------------------------------------------- /custom_components/composite/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Composite integration.""" 2 | from __future__ import annotations 3 | 4 | from abc import abstractmethod 5 | from functools import cached_property # pylint: disable=hass-deprecated-import 6 | import logging 7 | from pathlib import Path 8 | import shutil 9 | from typing import Any, cast 10 | 11 | import filetype 12 | import voluptuous as vol 13 | 14 | from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN 15 | from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN 16 | from homeassistant.components.file_upload import process_uploaded_file 17 | from homeassistant.config_entries import ( 18 | SOURCE_IMPORT, 19 | ConfigEntry, 20 | ConfigEntryBaseFlow, 21 | ConfigFlow, 22 | ConfigFlowResult, 23 | OptionsFlowWithConfigEntry, 24 | ) 25 | from homeassistant.const import ( 26 | ATTR_GPS_ACCURACY, 27 | ATTR_LATITUDE, 28 | ATTR_LONGITUDE, 29 | CONF_ENTITY_ID, 30 | CONF_ID, 31 | CONF_NAME, 32 | UnitOfSpeed, 33 | ) 34 | from homeassistant.core import State, callback 35 | from homeassistant.helpers.selector import ( 36 | BooleanSelector, 37 | DurationSelector, 38 | DurationSelectorConfig, 39 | EntitySelector, 40 | EntitySelectorConfig, 41 | FileSelector, 42 | FileSelectorConfig, 43 | NumberSelector, 44 | NumberSelectorConfig, 45 | NumberSelectorMode, 46 | SelectSelector, 47 | SelectSelectorConfig, 48 | SelectSelectorMode, 49 | TextSelector, 50 | ) 51 | from homeassistant.util.unit_conversion import SpeedConverter 52 | from homeassistant.util.unit_system import METRIC_SYSTEM 53 | 54 | from .const import ( 55 | ATTR_ACC, 56 | ATTR_LAT, 57 | ATTR_LON, 58 | CONF_ALL_STATES, 59 | CONF_DRIVING_SPEED, 60 | CONF_END_DRIVING_DELAY, 61 | CONF_ENTITY, 62 | CONF_ENTITY_PICTURE, 63 | CONF_MAX_SPEED_AGE, 64 | CONF_REQ_MOVEMENT, 65 | CONF_SHOW_UNKNOWN_AS_0, 66 | CONF_USE_PICTURE, 67 | DOMAIN, 68 | MIME_TO_SUFFIX, 69 | PICTURE_SUFFIXES, 70 | ) 71 | 72 | _LOGGER = logging.getLogger(__name__) 73 | 74 | 75 | def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: 76 | """Return pieces of configuration data.""" 77 | return { 78 | kw: {k: v for k, v in conf.items() if k in ks} 79 | for kw, ks in ( 80 | ("data", (CONF_NAME, CONF_ID)), 81 | ( 82 | "options", 83 | ( 84 | CONF_ENTITY_ID, 85 | CONF_REQ_MOVEMENT, 86 | CONF_MAX_SPEED_AGE, 87 | CONF_SHOW_UNKNOWN_AS_0, 88 | CONF_DRIVING_SPEED, 89 | CONF_END_DRIVING_DELAY, 90 | CONF_ENTITY_PICTURE, 91 | ), 92 | ), 93 | ) 94 | } 95 | 96 | 97 | class CompositeFlow(ConfigEntryBaseFlow): 98 | """Composite flow mixin.""" 99 | 100 | @cached_property 101 | def _entries(self) -> list[ConfigEntry]: 102 | """Get existing config entries.""" 103 | return self.hass.config_entries.async_entries(DOMAIN) 104 | 105 | @cached_property 106 | def _local_dir(self) -> Path: 107 | """Return real path to "/local" directory.""" 108 | return Path(self.hass.config.path("www")) 109 | 110 | @cached_property 111 | def _uploaded_dir(self) -> Path: 112 | """Return real path to "/local/uploaded" directory.""" 113 | return self._local_dir / "uploaded" 114 | 115 | def _local_files(self) -> list[str]: 116 | """Return a list of files in "/local" and subdirectories. 117 | 118 | Must be called in an executor since it does file I/O. 119 | """ 120 | if not (local_dir := self._local_dir).is_dir(): 121 | _LOGGER.debug("/local directory (%s) does not exist", local_dir) 122 | return [] 123 | 124 | local_files: list[str] = [] 125 | for suffix in PICTURE_SUFFIXES: 126 | local_files.extend( 127 | [ 128 | str(local_file.relative_to(local_dir)) 129 | for local_file in local_dir.rglob(f"*.{suffix}") 130 | ] 131 | ) 132 | return sorted(local_files) 133 | 134 | @cached_property 135 | def _speed_uom(self) -> str: 136 | """Return speed unit_of_measurement.""" 137 | if self.hass.config.units is METRIC_SYSTEM: 138 | return UnitOfSpeed.KILOMETERS_PER_HOUR 139 | return UnitOfSpeed.MILES_PER_HOUR 140 | 141 | @property 142 | @abstractmethod 143 | def options(self) -> dict[str, Any]: 144 | """Return mutable copy of options.""" 145 | 146 | @property 147 | def _entity_ids(self) -> list[str]: 148 | """Get currently configured entity IDs.""" 149 | return [cfg[CONF_ENTITY] for cfg in self.options.get(CONF_ENTITY_ID, [])] 150 | 151 | @property 152 | def _cur_entity_picture(self) -> tuple[str | None, str | None]: 153 | """Return current entity picture source. 154 | 155 | Returns: (entity_id, local_file) 156 | 157 | local_file is relative to "/local". 158 | """ 159 | entity_id = None 160 | for cfg in self.options[CONF_ENTITY_ID]: 161 | if cfg[CONF_USE_PICTURE]: 162 | entity_id = cfg[CONF_ENTITY] 163 | break 164 | if local_file := cast(str | None, self.options.get(CONF_ENTITY_PICTURE)): 165 | local_file = local_file.removeprefix("/local/") 166 | return entity_id, local_file 167 | 168 | def _set_entity_picture( 169 | self, *, entity_id: str | None = None, local_file: str | None = None 170 | ) -> None: 171 | """Set composite's entity picture source. 172 | 173 | local_file is relative to "/local". 174 | """ 175 | for cfg in self.options[CONF_ENTITY_ID]: 176 | cfg[CONF_USE_PICTURE] = cfg[CONF_ENTITY] == entity_id 177 | if local_file: 178 | self.options[CONF_ENTITY_PICTURE] = f"/local/{local_file}" 179 | elif CONF_ENTITY_PICTURE in self.options: 180 | del self.options[CONF_ENTITY_PICTURE] 181 | 182 | def _save_uploaded_file(self, uploaded_file_id: str) -> str: 183 | """Save uploaded file. 184 | 185 | Must be called in an executor since it does file I/O. 186 | 187 | Returns name of file relative to "/local". 188 | """ 189 | with process_uploaded_file(self.hass, uploaded_file_id) as uf_path: 190 | ud = self._uploaded_dir 191 | ud.mkdir(parents=True, exist_ok=True) 192 | suffix = MIME_TO_SUFFIX[cast(str, filetype.guess_mime(uf_path))] 193 | fn = ud / f"x.{suffix}" 194 | idx = 0 195 | while (uf := fn.with_stem(f"image{idx:03d}")).exists(): 196 | idx += 1 197 | shutil.move(uf_path, uf) 198 | return str(uf.relative_to(self._local_dir)) 199 | 200 | async def async_step_options( 201 | self, user_input: dict[str, Any] | None = None 202 | ) -> ConfigFlowResult: 203 | """Get config options.""" 204 | errors = {} 205 | 206 | if user_input is not None: 207 | self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT] 208 | if user_input[CONF_SHOW_UNKNOWN_AS_0]: 209 | self.options[CONF_SHOW_UNKNOWN_AS_0] = True 210 | elif CONF_SHOW_UNKNOWN_AS_0 in self.options: 211 | # For backward compatibility, represent False as the absence of the 212 | # option. 213 | del self.options[CONF_SHOW_UNKNOWN_AS_0] 214 | if CONF_MAX_SPEED_AGE in user_input: 215 | self.options[CONF_MAX_SPEED_AGE] = user_input[CONF_MAX_SPEED_AGE] 216 | elif CONF_MAX_SPEED_AGE in self.options: 217 | del self.options[CONF_MAX_SPEED_AGE] 218 | if CONF_DRIVING_SPEED in user_input: 219 | self.options[CONF_DRIVING_SPEED] = SpeedConverter.convert( 220 | user_input[CONF_DRIVING_SPEED], 221 | self._speed_uom, 222 | UnitOfSpeed.METERS_PER_SECOND, 223 | ) 224 | else: 225 | if CONF_DRIVING_SPEED in self.options: 226 | del self.options[CONF_DRIVING_SPEED] 227 | if CONF_END_DRIVING_DELAY in self.options: 228 | del self.options[CONF_END_DRIVING_DELAY] 229 | prv_cfgs = { 230 | cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, []) 231 | } 232 | new_cfgs = [ 233 | prv_cfgs.get( 234 | entity_id, 235 | { 236 | CONF_ENTITY: entity_id, 237 | CONF_USE_PICTURE: False, 238 | CONF_ALL_STATES: False, 239 | }, 240 | ) 241 | for entity_id in user_input[CONF_ENTITY_ID] 242 | ] 243 | self.options[CONF_ENTITY_ID] = new_cfgs 244 | if new_cfgs: 245 | if CONF_DRIVING_SPEED in self.options: 246 | return await self.async_step_end_driving_delay() 247 | return await self.async_step_ep_menu() 248 | errors[CONF_ENTITY_ID] = "at_least_one_entity" 249 | 250 | def entity_filter(state: State) -> bool: 251 | """Return if entity should be included in input list.""" 252 | if state.domain in (BS_DOMAIN, DT_DOMAIN): 253 | return True 254 | attributes = state.attributes 255 | if ATTR_GPS_ACCURACY not in attributes and ATTR_ACC not in attributes: 256 | return False 257 | if ATTR_LATITUDE in attributes and ATTR_LONGITUDE in attributes: 258 | return True 259 | return ATTR_LAT in attributes and ATTR_LON in attributes 260 | 261 | include_entities = set(self._entity_ids) 262 | include_entities |= { 263 | state.entity_id 264 | for state in filter(entity_filter, self.hass.states.async_all()) 265 | } 266 | data_schema = vol.Schema( 267 | { 268 | vol.Required(CONF_ENTITY_ID): EntitySelector( 269 | EntitySelectorConfig( 270 | include_entities=list(include_entities), multiple=True 271 | ) 272 | ), 273 | vol.Required(CONF_REQ_MOVEMENT): BooleanSelector(), 274 | vol.Required(CONF_SHOW_UNKNOWN_AS_0): BooleanSelector(), 275 | vol.Optional(CONF_MAX_SPEED_AGE): DurationSelector( 276 | DurationSelectorConfig( 277 | enable_day=False, enable_millisecond=False, allow_negative=False 278 | ) 279 | ), 280 | vol.Optional(CONF_DRIVING_SPEED): NumberSelector( 281 | NumberSelectorConfig( 282 | unit_of_measurement=self._speed_uom, 283 | mode=NumberSelectorMode.BOX, 284 | ) 285 | ), 286 | } 287 | ) 288 | if CONF_ENTITY_ID in self.options: 289 | suggested_values = { 290 | CONF_ENTITY_ID: self._entity_ids, 291 | CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT], 292 | CONF_SHOW_UNKNOWN_AS_0: self.options.get(CONF_SHOW_UNKNOWN_AS_0, False), 293 | } 294 | if CONF_MAX_SPEED_AGE in self.options: 295 | suggested_values[CONF_MAX_SPEED_AGE] = self.options[CONF_MAX_SPEED_AGE] 296 | if CONF_DRIVING_SPEED in self.options: 297 | suggested_values[CONF_DRIVING_SPEED] = SpeedConverter.convert( 298 | self.options[CONF_DRIVING_SPEED], 299 | UnitOfSpeed.METERS_PER_SECOND, 300 | self._speed_uom, 301 | ) 302 | data_schema = self.add_suggested_values_to_schema( 303 | data_schema, suggested_values 304 | ) 305 | return self.async_show_form( 306 | step_id="options", data_schema=data_schema, errors=errors, last_step=False 307 | ) 308 | 309 | async def async_step_end_driving_delay( 310 | self, user_input: dict[str, Any] | None = None 311 | ) -> ConfigFlowResult: 312 | """Get end driving delay.""" 313 | if user_input is not None: 314 | if CONF_END_DRIVING_DELAY in user_input: 315 | self.options[CONF_END_DRIVING_DELAY] = user_input[ 316 | CONF_END_DRIVING_DELAY 317 | ] 318 | elif CONF_END_DRIVING_DELAY in self.options: 319 | del self.options[CONF_END_DRIVING_DELAY] 320 | return await self.async_step_ep_menu() 321 | 322 | data_schema = vol.Schema( 323 | { 324 | vol.Optional(CONF_END_DRIVING_DELAY): DurationSelector( 325 | DurationSelectorConfig( 326 | enable_day=False, enable_millisecond=False, allow_negative=False 327 | ) 328 | ), 329 | } 330 | ) 331 | if CONF_END_DRIVING_DELAY in self.options: 332 | suggested_values = { 333 | CONF_END_DRIVING_DELAY: self.options[CONF_END_DRIVING_DELAY] 334 | } 335 | data_schema = self.add_suggested_values_to_schema( 336 | data_schema, suggested_values 337 | ) 338 | return self.async_show_form( 339 | step_id="end_driving_delay", data_schema=data_schema, last_step=False 340 | ) 341 | 342 | async def async_step_ep_menu( 343 | self, _: dict[str, Any] | None = None 344 | ) -> ConfigFlowResult: 345 | """Specify where to get composite's picture from.""" 346 | entity_id, local_file = self._cur_entity_picture 347 | cur_source: Path | str | None 348 | if local_file: 349 | cur_source = self._local_dir / local_file 350 | else: 351 | cur_source = entity_id 352 | 353 | menu_options = ["all_states", "ep_upload_file", "ep_input_entity"] 354 | if await self.hass.async_add_executor_job(self._local_files): 355 | menu_options.insert(1, "ep_local_file") 356 | if cur_source: 357 | menu_options.append("ep_none") 358 | 359 | return self.async_show_menu( 360 | step_id="ep_menu", 361 | menu_options=menu_options, 362 | description_placeholders={"cur_source": str(cur_source)}, 363 | ) 364 | 365 | async def async_step_ep_input_entity( 366 | self, user_input: dict[str, Any] | None = None 367 | ) -> ConfigFlowResult: 368 | """Specify which input to get composite's picture from.""" 369 | if user_input is not None: 370 | self._set_entity_picture(entity_id=user_input.get(CONF_ENTITY)) 371 | return await self.async_step_all_states() 372 | 373 | include_entities = self._entity_ids 374 | data_schema = vol.Schema( 375 | { 376 | vol.Optional(CONF_ENTITY): EntitySelector( 377 | EntitySelectorConfig(include_entities=include_entities) 378 | ) 379 | } 380 | ) 381 | picture_entity_id = None 382 | for cfg in self.options[CONF_ENTITY_ID]: 383 | if cfg[CONF_USE_PICTURE]: 384 | picture_entity_id = cfg[CONF_ENTITY] 385 | break 386 | if picture_entity_id: 387 | data_schema = self.add_suggested_values_to_schema( 388 | data_schema, {CONF_ENTITY: picture_entity_id} 389 | ) 390 | return self.async_show_form( 391 | step_id="ep_input_entity", data_schema=data_schema, last_step=False 392 | ) 393 | 394 | async def async_step_ep_local_file( 395 | self, user_input: dict[str, Any] | None = None 396 | ) -> ConfigFlowResult: 397 | """Specify a local file for composite's picture.""" 398 | if user_input is not None: 399 | self._set_entity_picture(local_file=user_input.get(CONF_ENTITY_PICTURE)) 400 | return await self.async_step_all_states() 401 | 402 | local_files = await self.hass.async_add_executor_job(self._local_files) 403 | _, local_file = self._cur_entity_picture 404 | if local_file and local_file not in local_files: 405 | local_files.append(local_file) 406 | data_schema = vol.Schema( 407 | { 408 | vol.Optional(CONF_ENTITY_PICTURE): SelectSelector( 409 | SelectSelectorConfig( 410 | options=local_files, 411 | mode=SelectSelectorMode.DROPDOWN, 412 | ) 413 | ), 414 | } 415 | ) 416 | if local_file: 417 | data_schema = self.add_suggested_values_to_schema( 418 | data_schema, {CONF_ENTITY_PICTURE: local_file} 419 | ) 420 | return self.async_show_form( 421 | step_id="ep_local_file", data_schema=data_schema, last_step=False 422 | ) 423 | 424 | async def async_step_ep_upload_file( 425 | self, user_input: dict[str, Any] | None = None 426 | ) -> ConfigFlowResult: 427 | """Upload a file for composite's picture.""" 428 | if user_input is not None: 429 | if (uploaded_file_id := user_input.get(CONF_ENTITY_PICTURE)) is None: 430 | self._set_entity_picture() 431 | return await self.async_step_all_states() 432 | 433 | def save_uploaded_file() -> tuple[bool, str]: 434 | """Save uploaded file. 435 | 436 | Must be called in an executor since it does file I/O. 437 | 438 | Returns if local directory existed beforehand and name of uploaded file. 439 | """ 440 | local_dir_exists = self._local_dir.is_dir() 441 | local_file = self._save_uploaded_file(uploaded_file_id) 442 | return local_dir_exists, local_file 443 | 444 | local_dir_exists, local_file = await self.hass.async_add_executor_job( 445 | save_uploaded_file 446 | ) 447 | self._set_entity_picture(local_file=local_file) 448 | if not local_dir_exists: 449 | return await self.async_step_ep_warn() 450 | return await self.async_step_all_states() 451 | 452 | accept = ", ".join(f".{ext}" for ext in PICTURE_SUFFIXES) 453 | data_schema = vol.Schema( 454 | { 455 | vol.Optional(CONF_ENTITY_PICTURE): FileSelector( 456 | FileSelectorConfig(accept=accept) 457 | ) 458 | } 459 | ) 460 | return self.async_show_form( 461 | step_id="ep_upload_file", data_schema=data_schema, last_step=False 462 | ) 463 | 464 | async def async_step_ep_warn( 465 | self, user_input: dict[str, Any] | None = None 466 | ) -> ConfigFlowResult: 467 | """Warn that since "/local" was created system might need to be restarted.""" 468 | if user_input is not None: 469 | return await self.async_step_all_states() 470 | 471 | return self.async_show_form( 472 | step_id="ep_warn", 473 | description_placeholders={"local_dir": str(self._local_dir)}, 474 | last_step=False, 475 | ) 476 | 477 | async def async_step_ep_none( 478 | self, _: dict[str, Any] | None = None 479 | ) -> ConfigFlowResult: 480 | """Set composite's entity picture to none.""" 481 | self._set_entity_picture() 482 | return await self.async_step_all_states() 483 | 484 | async def async_step_all_states( 485 | self, user_input: dict[str, Any] | None = None 486 | ) -> ConfigFlowResult: 487 | """Specify if all states should be used for appropriate entities.""" 488 | if user_input is not None: 489 | entity_ids = user_input.get(CONF_ENTITY, []) 490 | for cfg in self.options[CONF_ENTITY_ID]: 491 | cfg[CONF_ALL_STATES] = cfg[CONF_ENTITY] in entity_ids 492 | return await self.async_step_done() 493 | 494 | data_schema = vol.Schema( 495 | { 496 | vol.Optional(CONF_ENTITY): EntitySelector( 497 | EntitySelectorConfig( 498 | include_entities=self._entity_ids, multiple=True 499 | ) 500 | ) 501 | } 502 | ) 503 | all_state_entities = [ 504 | cfg[CONF_ENTITY] 505 | for cfg in self.options[CONF_ENTITY_ID] 506 | if cfg[CONF_ALL_STATES] 507 | ] 508 | if all_state_entities: 509 | data_schema = self.add_suggested_values_to_schema( 510 | data_schema, {CONF_ENTITY: all_state_entities} 511 | ) 512 | return self.async_show_form(step_id="all_states", data_schema=data_schema) 513 | 514 | @abstractmethod 515 | async def async_step_done( 516 | self, _: dict[str, Any] | None = None 517 | ) -> ConfigFlowResult: 518 | """Finish the flow.""" 519 | 520 | 521 | class CompositeConfigFlow(ConfigFlow, CompositeFlow, domain=DOMAIN): 522 | """Composite config flow.""" 523 | 524 | VERSION = 1 525 | 526 | _name = "" 527 | 528 | def __init__(self) -> None: 529 | """Initialize config flow.""" 530 | self._options: dict[str, Any] = {} 531 | 532 | @staticmethod 533 | @callback 534 | def async_get_options_flow(config_entry: ConfigEntry) -> CompositeOptionsFlow: 535 | """Get the options flow for this handler.""" 536 | flow = CompositeOptionsFlow(config_entry) 537 | flow.init_step = "options" 538 | return flow 539 | 540 | @classmethod 541 | @callback 542 | def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: 543 | """Return options flow support for this handler.""" 544 | return config_entry.source != SOURCE_IMPORT 545 | 546 | @property 547 | def options(self) -> dict[str, Any]: 548 | """Return mutable copy of options.""" 549 | return self._options 550 | 551 | async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult: 552 | """Import config entry from configuration.""" 553 | if (driving_speed := data.get(CONF_DRIVING_SPEED)) is not None: 554 | data[CONF_DRIVING_SPEED] = SpeedConverter.convert( 555 | driving_speed, self._speed_uom, UnitOfSpeed.METERS_PER_SECOND 556 | ) 557 | if existing_entry := await self.async_set_unique_id(data[CONF_ID]): 558 | self.hass.config_entries.async_update_entry( 559 | existing_entry, **split_conf(data) # type: ignore[arg-type] 560 | ) 561 | return self.async_abort(reason="already_configured") 562 | 563 | return self.async_create_entry( 564 | title=f"{data[CONF_NAME]} (from configuration)", 565 | **split_conf(data), # type: ignore[arg-type] 566 | ) 567 | 568 | async def async_step_user( 569 | self, _: dict[str, Any] | None = None 570 | ) -> ConfigFlowResult: 571 | """Start user config flow.""" 572 | return await self.async_step_name() 573 | 574 | def _name_used(self, name: str) -> bool: 575 | """Return if name has already been used.""" 576 | for entry in self._entries: 577 | if entry.source == SOURCE_IMPORT: 578 | if name == entry.data[CONF_NAME]: 579 | return True 580 | elif name == entry.title: 581 | return True 582 | return False 583 | 584 | async def async_step_name( 585 | self, user_input: dict[str, Any] | None = None 586 | ) -> ConfigFlowResult: 587 | """Get name.""" 588 | errors = {} 589 | 590 | if user_input is not None: 591 | self._name = user_input[CONF_NAME] 592 | if not self._name_used(self._name): 593 | return await self.async_step_options() 594 | errors[CONF_NAME] = "name_used" 595 | 596 | data_schema = vol.Schema({vol.Required(CONF_NAME): TextSelector()}) 597 | data_schema = self.add_suggested_values_to_schema( 598 | data_schema, {CONF_NAME: self._name} 599 | ) 600 | return self.async_show_form( 601 | step_id="name", data_schema=data_schema, errors=errors, last_step=False 602 | ) 603 | 604 | async def async_step_done( 605 | self, _: dict[str, Any] | None = None 606 | ) -> ConfigFlowResult: 607 | """Finish the flow.""" 608 | return self.async_create_entry(title=self._name, data={}, options=self.options) 609 | 610 | 611 | class CompositeOptionsFlow(OptionsFlowWithConfigEntry, CompositeFlow): 612 | """Composite integration options flow.""" 613 | 614 | async def async_step_done( 615 | self, _: dict[str, Any] | None = None 616 | ) -> ConfigFlowResult: 617 | """Finish the flow.""" 618 | return self.async_create_entry(title="", data=self.options) 619 | -------------------------------------------------------------------------------- /custom_components/composite/device_tracker.py: -------------------------------------------------------------------------------- 1 | """A Device Tracker platform that combines one or more device trackers.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Callable, Mapping, Sequence 5 | from contextlib import suppress 6 | from dataclasses import dataclass 7 | from datetime import datetime, timedelta 8 | from enum import Enum, auto 9 | import logging 10 | from math import atan2, degrees 11 | from types import MappingProxyType 12 | from typing import Any, cast 13 | 14 | from propcache.api import cached_property 15 | 16 | from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN 17 | from homeassistant.components.device_tracker import ( 18 | ATTR_BATTERY, 19 | ATTR_SOURCE_TYPE, 20 | DOMAIN as DT_DOMAIN, 21 | SourceType, 22 | ) 23 | from homeassistant.components.device_tracker.config_entry import TrackerEntity 24 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 25 | from homeassistant.const import ( 26 | ATTR_BATTERY_CHARGING, 27 | ATTR_BATTERY_LEVEL, 28 | ATTR_ENTITY_ID, 29 | ATTR_ENTITY_PICTURE, 30 | ATTR_GPS_ACCURACY, 31 | ATTR_LATITUDE, 32 | ATTR_LONGITUDE, 33 | CONF_ENTITY_ID, 34 | CONF_ID, 35 | CONF_NAME, 36 | STATE_HOME, 37 | STATE_NOT_HOME, 38 | STATE_ON, 39 | STATE_UNAVAILABLE, 40 | STATE_UNKNOWN, 41 | ) 42 | from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State 43 | from homeassistant.helpers import entity_registry as er 44 | import homeassistant.helpers.config_validation as cv 45 | from homeassistant.helpers.dispatcher import async_dispatcher_send 46 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 47 | from homeassistant.helpers.event import async_call_later, async_track_state_change_event 48 | from homeassistant.helpers.restore_state import RestoreEntity 49 | from homeassistant.helpers.typing import GPSType 50 | import homeassistant.util.dt as dt_util 51 | from homeassistant.util.location import distance 52 | 53 | from .const import ( 54 | ATTR_ACC, 55 | ATTR_CHARGING, 56 | ATTR_ENTITIES, 57 | ATTR_LAST_ENTITY_ID, 58 | ATTR_LAST_SEEN, 59 | ATTR_LAST_TIMESTAMP, 60 | ATTR_LAT, 61 | ATTR_LON, 62 | CONF_ALL_STATES, 63 | CONF_DRIVING_SPEED, 64 | CONF_END_DRIVING_DELAY, 65 | CONF_ENTITY, 66 | CONF_ENTITY_PICTURE, 67 | CONF_MAX_SPEED_AGE, 68 | CONF_REQ_MOVEMENT, 69 | CONF_USE_PICTURE, 70 | MIN_ANGLE_SPEED, 71 | MIN_SPEED_SECONDS, 72 | SIG_COMPOSITE_SPEED, 73 | STATE_DRIVING, 74 | ) 75 | 76 | _LOGGER = logging.getLogger(__name__) 77 | 78 | # Cause Semaphore to be created to make async_update, and anything protected by 79 | # async_request_call, atomic. 80 | PARALLEL_UPDATES = 1 81 | 82 | 83 | _RESTORE_EXTRA_ATTRS = ( 84 | ATTR_ENTITY_ID, 85 | ATTR_ENTITIES, 86 | ATTR_LAST_ENTITY_ID, 87 | ATTR_LAST_SEEN, 88 | ATTR_BATTERY_CHARGING, 89 | ) 90 | 91 | _GPS_ACCURACY_ATTRS = (ATTR_GPS_ACCURACY, ATTR_ACC) 92 | _BATTERY_ATTRS = (ATTR_BATTERY_LEVEL, ATTR_BATTERY) 93 | _CHARGING_ATTRS = (ATTR_BATTERY_CHARGING, ATTR_CHARGING) 94 | _LAST_SEEN_ATTRS = (ATTR_LAST_SEEN, ATTR_LAST_TIMESTAMP) 95 | 96 | 97 | async def async_setup_entry( 98 | _hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 99 | ) -> None: 100 | """Set up the device tracker platform.""" 101 | async_add_entities([CompositeDeviceTracker(entry)]) 102 | 103 | 104 | def _nearest_second(time: datetime) -> datetime: 105 | """Round time to nearest second.""" 106 | return time.replace(microsecond=0) + timedelta( 107 | seconds=0 if time.microsecond < 500000 else 1 108 | ) 109 | 110 | 111 | class EntityStatus(Enum): 112 | """Input entity status.""" 113 | 114 | NOT_SET = auto() 115 | GOOD = auto() 116 | BAD = auto() 117 | WARNED = auto() 118 | SUSPEND = auto() 119 | 120 | 121 | @dataclass 122 | class Location: 123 | """Location (latitude, longitude & accuracy).""" 124 | 125 | gps: GPSType 126 | accuracy: float 127 | 128 | 129 | @dataclass 130 | class EntityData: 131 | """Input entity data.""" 132 | 133 | entity_id: str 134 | use_all_states: bool 135 | use_picture: bool 136 | _status: EntityStatus = EntityStatus.NOT_SET 137 | seen: datetime | None = None 138 | source_type: SourceType = SourceType.GPS 139 | data: Location | str | None = None 140 | 141 | @property 142 | def is_good(self) -> bool: 143 | """Return if last update was good.""" 144 | return self._status == EntityStatus.GOOD 145 | 146 | def set_params(self, use_all_states: bool, use_picture: bool) -> None: 147 | """Set parameters.""" 148 | self.use_all_states = use_all_states 149 | self.use_picture = use_picture 150 | 151 | def good( 152 | self, seen: datetime, source_type: SourceType, data: Location | str 153 | ) -> None: 154 | """Mark entity as good.""" 155 | self._status = EntityStatus.GOOD 156 | self.seen = seen 157 | self.source_type = source_type 158 | self.data = data 159 | 160 | def bad(self, message: str) -> None: 161 | """Mark entity as bad.""" 162 | if self._status == EntityStatus.SUSPEND: 163 | return 164 | msg = f"{self.entity_id} {message}" 165 | if self._status == EntityStatus.WARNED: 166 | _LOGGER.error(msg) 167 | self._status = EntityStatus.SUSPEND 168 | # Only warn if this is not the first state change for the entity. 169 | elif self._status != EntityStatus.NOT_SET: 170 | _LOGGER.warning(msg) 171 | self._status = EntityStatus.WARNED 172 | else: 173 | _LOGGER.debug(msg) 174 | self._status = EntityStatus.BAD 175 | 176 | 177 | class Attributes: 178 | """Flexible attribute retrieval.""" 179 | 180 | def __init__(self, attrs: Mapping[str, Any]) -> None: 181 | """Initialize.""" 182 | self._attrs = MappingProxyType(attrs) 183 | 184 | def __getitem__(self, key: str) -> Any: 185 | """Implement Attributes_object[key].""" 186 | return self._attrs[key] 187 | 188 | def get(self, key: str | Sequence[str], default: Any | None = None) -> Any | None: 189 | """Get item for first found key, or default if no key found.""" 190 | if isinstance(key, str): 191 | return self._attrs.get(key, default) 192 | for _key in key: 193 | if _key in self._attrs: 194 | return self._attrs[_key] 195 | return default 196 | 197 | 198 | class CompositeDeviceTracker(TrackerEntity, RestoreEntity): 199 | """Composite Device Tracker.""" 200 | 201 | _attr_translation_key = "tracker" 202 | _unrecorded_attributes = frozenset({ATTR_ENTITIES, ATTR_ENTITY_PICTURE}) 203 | 204 | # State vars 205 | _battery_level: int | None = None 206 | _prev_seen: datetime | None = None 207 | _prev_speed: float | None = None 208 | 209 | _remove_track_states: Callable[[], None] | None = None 210 | _remove_speed_is_stale: Callable[[], None] | None = None 211 | _remove_driving_ended: Callable[[], None] | None = None 212 | _req_movement: bool 213 | _max_speed_age: timedelta | None 214 | _driving_speed: float | None # m/s 215 | _end_driving_delay: timedelta | None 216 | _use_entity_picture: bool 217 | 218 | def __init__(self, entry: ConfigEntry) -> None: 219 | """Initialize Composite Device Tracker.""" 220 | if entry.source == SOURCE_IMPORT: 221 | obj_id = entry.data[CONF_ID] 222 | self.entity_id = f"{DT_DOMAIN}.{obj_id}" 223 | self._attr_name = cast(str, entry.data[CONF_NAME]) 224 | self._attr_unique_id = obj_id 225 | else: 226 | self._attr_name = entry.title 227 | self._attr_unique_id = entry.entry_id 228 | self._attr_extra_state_attributes = {} 229 | self._entities: dict[str, EntityData] = {} 230 | 231 | @cached_property 232 | def force_update(self) -> bool: 233 | """Return True if state updates should be forced.""" 234 | return False 235 | 236 | @property 237 | def battery_level(self) -> int | None: 238 | """Return the battery level of the device.""" 239 | return self._battery_level 240 | 241 | async def async_added_to_hass(self) -> None: 242 | """Run when entity about to be added to hass.""" 243 | await super().async_added_to_hass() 244 | 245 | self.async_on_remove( 246 | cast(ConfigEntry, self.platform.config_entry).add_update_listener( 247 | self._config_entry_updated 248 | ) 249 | ) 250 | await self.async_request_call(self._restore_state()) 251 | await self.async_request_call(self._process_config_options()) 252 | 253 | async def async_will_remove_from_hass(self) -> None: 254 | """Run when entity will be removed from hass.""" 255 | if self._remove_track_states: 256 | self._remove_track_states() 257 | self._remove_track_states = None 258 | self._cancel_speed_stale_monitor() 259 | self._cancel_drive_ending_delay() 260 | await super().async_will_remove_from_hass() 261 | 262 | async def _process_config_options(self) -> None: 263 | """Process options from config entry.""" 264 | options = cast(ConfigEntry, self.platform.config_entry).options 265 | self._req_movement = options[CONF_REQ_MOVEMENT] 266 | if (msa := options.get(CONF_MAX_SPEED_AGE)) is None: 267 | self._max_speed_age = None 268 | else: 269 | self._max_speed_age = cast(timedelta, cv.time_period(msa)) 270 | self._driving_speed = options.get(CONF_DRIVING_SPEED) 271 | if (edd := options.get(CONF_END_DRIVING_DELAY)) is None: 272 | self._end_driving_delay = None 273 | else: 274 | self._end_driving_delay = cast(timedelta, cv.time_period(edd)) 275 | entity_cfgs = { 276 | entity_cfg[CONF_ENTITY]: entity_cfg 277 | for entity_cfg in options[CONF_ENTITY_ID] 278 | } 279 | 280 | cur_entity_ids = set(self._entities) 281 | cfg_entity_ids = set(entity_cfgs) 282 | 283 | del_entity_ids = cur_entity_ids - cfg_entity_ids 284 | new_entity_ids = cfg_entity_ids - cur_entity_ids 285 | cur_entity_ids &= cfg_entity_ids 286 | 287 | last_entity_id = ( 288 | self.extra_state_attributes 289 | and self.extra_state_attributes[ATTR_LAST_ENTITY_ID] 290 | ) 291 | for entity_id in del_entity_ids: 292 | entity = self._entities.pop(entity_id) 293 | if entity_id == last_entity_id: 294 | self._clear_state() 295 | if entity.use_picture: 296 | self._attr_entity_picture = None 297 | 298 | for entity_id in cur_entity_ids: 299 | entity_cfg = entity_cfgs[entity_id] 300 | self._entities[entity_id].set_params( 301 | entity_cfg[CONF_ALL_STATES], entity_cfg[CONF_USE_PICTURE] 302 | ) 303 | 304 | for entity_id in new_entity_ids: 305 | entity_cfg = entity_cfgs[entity_id] 306 | self._entities[entity_id] = EntityData( 307 | entity_id, entity_cfg[CONF_ALL_STATES], entity_cfg[CONF_USE_PICTURE] 308 | ) 309 | 310 | for entity_id in cfg_entity_ids: 311 | await self._entity_updated(entity_id, self.hass.states.get(entity_id)) 312 | 313 | self._use_entity_picture = True 314 | if entity_picture := options.get(CONF_ENTITY_PICTURE): 315 | self._attr_entity_picture = entity_picture 316 | elif not any(entity.use_picture for entity in self._entities.values()): 317 | self._attr_entity_picture = None 318 | self._use_entity_picture = False 319 | 320 | async def state_listener(event: Event[EventStateChangedData]) -> None: 321 | """Process input entity state update.""" 322 | await self.async_request_call( 323 | self._entity_updated(event.data["entity_id"], event.data["new_state"]) 324 | ) 325 | self.async_write_ha_state() 326 | 327 | if self._remove_track_states: 328 | self._remove_track_states() 329 | self._remove_track_states = async_track_state_change_event( 330 | self.hass, cfg_entity_ids, state_listener 331 | ) 332 | 333 | async def _config_entry_updated( 334 | self, hass: HomeAssistant, entry: ConfigEntry 335 | ) -> None: 336 | """Run when the config entry has been updated.""" 337 | if (new_name := entry.title) != self._attr_name: 338 | self._attr_name = new_name 339 | er.async_get(hass).async_update_entity( 340 | self.entity_id, original_name=self.name 341 | ) 342 | await self.async_request_call(self._process_config_options()) 343 | self.async_write_ha_state() 344 | 345 | async def _restore_state(self) -> None: 346 | """Restore state.""" 347 | if not (last_state := await self.async_get_last_state()): 348 | return 349 | 350 | self._attr_entity_picture = last_state.attributes.get(ATTR_ENTITY_PICTURE) 351 | self._battery_level = last_state.attributes.get(ATTR_BATTERY_LEVEL) 352 | # Prior versions allowed a source_type of binary_sensor. To better conform to 353 | # the TrackerEntity base class, inputs that do not directly map to one of the 354 | # SourceType options will be represented as SourceType.ROUTER. 355 | if (source_type := last_state.attributes[ATTR_SOURCE_TYPE]) in SourceType: 356 | self._attr_source_type = source_type 357 | else: 358 | self._attr_source_type = SourceType.ROUTER 359 | self._attr_location_accuracy = last_state.attributes.get(ATTR_GPS_ACCURACY) or 0 360 | self._attr_latitude = last_state.attributes.get(ATTR_LATITUDE) 361 | self._attr_longitude = last_state.attributes.get(ATTR_LONGITUDE) 362 | self._attr_extra_state_attributes = { 363 | k: v for k, v in last_state.attributes.items() if k in _RESTORE_EXTRA_ATTRS 364 | } 365 | # List of seen entity IDs used to be in ATTR_ENTITY_ID. 366 | # If present, move it to ATTR_ENTITIES. 367 | if ATTR_ENTITY_ID in self._attr_extra_state_attributes: 368 | self._attr_extra_state_attributes[ 369 | ATTR_ENTITIES 370 | ] = self._attr_extra_state_attributes.pop(ATTR_ENTITY_ID) 371 | with suppress(KeyError): 372 | last_seen = dt_util.parse_datetime( 373 | self._attr_extra_state_attributes[ATTR_LAST_SEEN] 374 | ) 375 | if last_seen is None: 376 | self._attr_extra_state_attributes[ATTR_LAST_SEEN] = None 377 | else: 378 | self._attr_extra_state_attributes[ATTR_LAST_SEEN] = dt_util.as_local( 379 | last_seen 380 | ) 381 | self._prev_seen = dt_util.as_utc(last_seen) 382 | if self.source_type != SourceType.GPS and ( 383 | self.latitude is None or self.longitude is None 384 | ): 385 | self._attr_location_name = last_state.state 386 | 387 | def _clear_state(self) -> None: 388 | """Clear state.""" 389 | self._battery_level = None 390 | self._attr_source_type = SourceType.GPS 391 | self._attr_location_accuracy = 0 392 | self._attr_location_name = None 393 | self._attr_latitude = None 394 | self._attr_longitude = None 395 | self._attr_extra_state_attributes = {} 396 | self._prev_seen = None 397 | self._prev_speed = None 398 | self._cancel_speed_stale_monitor() 399 | self._cancel_drive_ending_delay() 400 | 401 | def _cancel_speed_stale_monitor(self) -> None: 402 | """Cancel monitoring of speed sensor staleness.""" 403 | if self._remove_speed_is_stale: 404 | self._remove_speed_is_stale() 405 | self._remove_speed_is_stale = None 406 | 407 | def _start_speed_stale_monitor(self) -> None: 408 | """Start monitoring speed sensor staleness.""" 409 | self._cancel_speed_stale_monitor() 410 | if self._max_speed_age is None: 411 | return 412 | 413 | async def speed_is_stale(_utcnow: datetime) -> None: 414 | """Speed sensor is stale.""" 415 | self._remove_speed_is_stale = None 416 | 417 | async def clear_speed_sensor_state() -> None: 418 | """Clear speed sensor's state.""" 419 | self._send_speed(None, None) 420 | 421 | await self.async_request_call(clear_speed_sensor_state()) 422 | self.async_write_ha_state() 423 | 424 | self._remove_speed_is_stale = async_call_later( 425 | self.hass, self._max_speed_age, speed_is_stale 426 | ) 427 | 428 | def _send_speed(self, speed: float | None, angle: int | None) -> None: 429 | """Send values to speed sensor.""" 430 | _LOGGER.debug("%s: Sending speed: %s m/s, angle: %s°", self.name, speed, angle) 431 | async_dispatcher_send( 432 | self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed, angle 433 | ) 434 | 435 | def _cancel_drive_ending_delay(self) -> None: 436 | """Cancel ending of driving state.""" 437 | if self._remove_driving_ended: 438 | self._remove_driving_ended() 439 | self._remove_driving_ended = None 440 | 441 | def _start_drive_ending_delay(self) -> None: 442 | """Start delay to end driving state if configured.""" 443 | self._cancel_drive_ending_delay() 444 | if self._end_driving_delay is None: 445 | return 446 | 447 | async def driving_ended(_utcnow: datetime) -> None: 448 | """End driving state.""" 449 | self._remove_driving_ended = None 450 | 451 | async def end_driving() -> None: 452 | """End driving state.""" 453 | self._attr_location_name = None 454 | 455 | await self.async_request_call(end_driving()) 456 | self.async_write_ha_state() 457 | 458 | self._remove_driving_ended = async_call_later( 459 | self.hass, self._end_driving_delay, driving_ended 460 | ) 461 | 462 | @property 463 | def _drive_ending_delayed(self) -> bool: 464 | """Return if end of driving state is being delayed.""" 465 | return self._remove_driving_ended is not None 466 | 467 | async def _entity_updated( # noqa: C901 468 | self, entity_id: str, new_state: State | None 469 | ) -> None: 470 | """Run when an input entity has changed state.""" 471 | if not new_state or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): 472 | return 473 | 474 | entity = self._entities[entity_id] 475 | new_attrs = Attributes(new_state.attributes) 476 | 477 | # Get time device was last seen, which is specified by one of the entity's 478 | # attributes defined by _LAST_SEEN_ATTRS, as a datetime. 479 | 480 | def get_last_seen() -> datetime | None: 481 | """Get last_seen (in UTC) from one of the possible attributes.""" 482 | if (raw_last_seen := new_attrs.get(_LAST_SEEN_ATTRS)) is None: 483 | return None 484 | if isinstance(raw_last_seen, datetime): 485 | return dt_util.as_utc(raw_last_seen) 486 | with suppress(TypeError, ValueError): 487 | return dt_util.utc_from_timestamp(float(raw_last_seen)) 488 | with suppress(TypeError): 489 | if (parsed_last_seen := dt_util.parse_datetime(raw_last_seen)) is None: 490 | return None 491 | return dt_util.as_utc(parsed_last_seen) 492 | return None 493 | 494 | # Use last_updated from the new state object if no valid "last seen" was found. 495 | last_seen = get_last_seen() or new_state.last_updated 496 | 497 | old_last_seen = entity.seen 498 | if old_last_seen and last_seen < old_last_seen: 499 | entity.bad("last_seen went backwards") 500 | return 501 | 502 | # Try to get GPS and battery data. 503 | gps: GPSType | None = None 504 | with suppress(KeyError): 505 | gps = new_attrs[ATTR_LATITUDE], new_attrs[ATTR_LONGITUDE] 506 | if not gps: 507 | with suppress(KeyError): 508 | gps = new_attrs[ATTR_LAT], new_attrs[ATTR_LON] 509 | gps_accuracy = cast(float | None, new_attrs.get(_GPS_ACCURACY_ATTRS)) 510 | battery = cast(int | None, new_attrs.get(_BATTERY_ATTRS)) 511 | charging = cast(bool | None, new_attrs.get(_CHARGING_ATTRS)) 512 | 513 | # What type of tracker is this? 514 | if new_state.domain == BS_DOMAIN: 515 | source_type: str | None = SourceType.ROUTER.value 516 | else: 517 | source_type = new_attrs.get( 518 | ATTR_SOURCE_TYPE, 519 | SourceType.GPS.value if gps and gps_accuracy is not None else None, 520 | ) 521 | 522 | if entity.use_picture: 523 | self._attr_entity_picture = new_attrs.get(ATTR_ENTITY_PICTURE) 524 | 525 | state = new_state.state 526 | # Don't use location_name unless we have to. 527 | location_name: str | None = None 528 | 529 | if source_type == SourceType.GPS: 530 | # GPS coordinates and accuracy are required. 531 | if not gps: 532 | entity.bad("missing gps attributes") 533 | return 534 | if gps_accuracy is None: 535 | entity.bad("missing gps_accuracy attribute") 536 | return 537 | 538 | new_data = Location(gps, gps_accuracy) 539 | old_data = cast(Location | None, entity.data) 540 | if last_seen == old_last_seen and new_data == old_data: 541 | return 542 | entity.good(last_seen, SourceType.GPS, new_data) 543 | 544 | if self._req_movement and old_data: 545 | dist = distance(gps[0], gps[1], old_data.gps[0], old_data.gps[1]) 546 | if dist is not None and dist <= gps_accuracy + old_data.accuracy: 547 | _LOGGER.debug( 548 | "For %s skipping update from %s: not enough movement", 549 | self.entity_id, 550 | entity_id, 551 | ) 552 | return 553 | 554 | elif source_type in SourceType: 555 | # Convert 'on'/'off' state of binary_sensor 556 | # to 'home'/'not_home'. 557 | if new_state.domain == BS_DOMAIN: 558 | if state == STATE_ON: 559 | state = STATE_HOME 560 | else: 561 | state = STATE_NOT_HOME 562 | 563 | entity.good(last_seen, SourceType(source_type), state) # type: ignore[arg-type] 564 | 565 | if not self._use_non_gps_data(entity_id, state): 566 | return 567 | 568 | # Don't use new GPS data if it's not complete. 569 | if not gps or gps_accuracy is None: 570 | gps = gps_accuracy = None 571 | 572 | # Is current state home w/ GPS data? 573 | if home_w_gps := self.location_name is None and self.state == STATE_HOME: 574 | if self.latitude is None or self.longitude is None: 575 | _LOGGER.warning("%s: Unexpectedly home without GPS data", self.name) 576 | home_w_gps = False 577 | 578 | # It's important, for this composite tracker, to avoid the 579 | # component level code's "stale processing." This can be done 580 | # one of two ways: 1) provide GPS data w/ source_type of gps, 581 | # or 2) provide a location_name (that will be used as the new 582 | # state.) 583 | 584 | # If input entity's state is 'home' and our current state is 'home' w/ GPS 585 | # data, use it and make source_type gps. 586 | if state == STATE_HOME and home_w_gps: 587 | gps = cast(GPSType, (self.latitude, self.longitude)) 588 | gps_accuracy = self.location_accuracy 589 | source_type = SourceType.GPS.value 590 | # Otherwise, if new GPS data is valid (which is unlikely if 591 | # new state is not 'home'), 592 | # use it and make source_type gps. 593 | elif gps: 594 | source_type = SourceType.GPS.value 595 | # Otherwise, if new state is 'home' and old state is not 'home' w/ GPS data 596 | # (i.e., not 'home' or no GPS data), then use HA's configured Home location 597 | # and make source_type gps. 598 | elif state == STATE_HOME: 599 | gps = (self.hass.config.latitude, self.hass.config.longitude) 600 | gps_accuracy = 0 601 | source_type = SourceType.GPS.value 602 | # Otherwise, don't use any GPS data, but set location_name to 603 | # new state. 604 | else: 605 | location_name = state 606 | 607 | else: 608 | entity.bad(f"unsupported source_type: {source_type}") 609 | return 610 | 611 | # Is this newer info than last update? 612 | if self._prev_seen and last_seen <= self._prev_seen: 613 | _LOGGER.debug( 614 | "For %s skipping update from %s: " 615 | "last_seen not newer than previous update (%s) <= (%s)", 616 | self.entity_id, 617 | entity_id, 618 | dt_util.as_local(last_seen), 619 | dt_util.as_local(self._prev_seen), 620 | ) 621 | return 622 | 623 | _LOGGER.debug("Updating %s from %s", self.entity_id, entity_id) 624 | 625 | attrs = { 626 | ATTR_ENTITIES: tuple( 627 | entity_id 628 | for entity_id, _entity in self._entities.items() 629 | if _entity.is_good 630 | ), 631 | ATTR_LAST_ENTITY_ID: entity_id, 632 | ATTR_LAST_SEEN: dt_util.as_local(_nearest_second(last_seen)), 633 | } 634 | if charging is not None: 635 | attrs[ATTR_BATTERY_CHARGING] = charging 636 | 637 | self._set_state( 638 | location_name, gps, gps_accuracy, battery, attrs, SourceType(source_type) # type: ignore[arg-type] 639 | ) 640 | 641 | self._prev_seen = last_seen 642 | 643 | def _set_state( 644 | self, 645 | location_name: str | None, 646 | gps: GPSType | None, 647 | gps_accuracy: float | None, 648 | battery: int | None, 649 | attributes: dict, 650 | source_type: SourceType, 651 | ) -> None: 652 | """Set new state.""" 653 | # Save previously "seen" values before updating for speed calculations, etc. 654 | prev_ent: str | None 655 | prev_lat: float | None 656 | prev_lon: float | None 657 | if self._prev_seen: 658 | prev_ent = self._attr_extra_state_attributes[ATTR_LAST_ENTITY_ID] 659 | prev_lat = self.latitude 660 | prev_lon = self.longitude 661 | else: 662 | # Don't use restored attributes. 663 | prev_ent = prev_lat = prev_lon = None 664 | was_driving = ( 665 | self._prev_speed is not None 666 | and self._driving_speed is not None 667 | and self._prev_speed >= self._driving_speed 668 | ) 669 | 670 | self._battery_level = battery 671 | self._attr_source_type = source_type 672 | self._attr_location_accuracy = gps_accuracy or 0 673 | self._attr_location_name = location_name 674 | lat: float | None 675 | lon: float | None 676 | if gps: 677 | lat, lon = gps 678 | else: 679 | lat = lon = None 680 | self._attr_latitude = lat 681 | self._attr_longitude = lon 682 | 683 | self._attr_extra_state_attributes = attributes 684 | 685 | last_seen = cast(datetime, attributes[ATTR_LAST_SEEN]) 686 | speed = None 687 | angle = None 688 | use_new_speed = True 689 | if ( 690 | prev_ent 691 | and self._prev_seen 692 | and prev_lat is not None 693 | and prev_lon is not None 694 | and lat is not None 695 | and lon is not None 696 | ): 697 | # It's ok that last_seen is in local tz and self._prev_seen is in UTC. 698 | # last_seen's value will automatically be converted to UTC during the 699 | # subtraction operation. 700 | seconds = (last_seen - self._prev_seen).total_seconds() 701 | min_seconds = MIN_SPEED_SECONDS 702 | if cast(str, attributes[ATTR_LAST_ENTITY_ID]) != prev_ent: 703 | min_seconds *= 3 704 | if seconds < min_seconds: 705 | _LOGGER.debug( 706 | "%s: Not sending speed & angle (time delta %0.1f < %0.1f)", 707 | self.name, 708 | seconds, 709 | min_seconds, 710 | ) 711 | use_new_speed = False 712 | else: 713 | meters = cast(float, distance(prev_lat, prev_lon, lat, lon)) 714 | try: 715 | speed = round(meters / seconds, 1) 716 | except TypeError: 717 | _LOGGER.error("%s: distance() returned None", self.name) 718 | else: 719 | if speed > MIN_ANGLE_SPEED: 720 | angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat))) 721 | if angle < 0: 722 | angle += 360 723 | 724 | if use_new_speed: 725 | self._send_speed(speed, angle) 726 | self._prev_speed = speed 727 | self._start_speed_stale_monitor() 728 | else: 729 | speed = self._prev_speed 730 | 731 | # Only set state to driving if it's currently "away" (i.e., not in a zone.) 732 | if self.state != STATE_NOT_HOME: 733 | self._cancel_drive_ending_delay() 734 | return 735 | 736 | driving = ( 737 | speed is not None 738 | and self._driving_speed is not None 739 | and speed >= self._driving_speed 740 | ) 741 | 742 | if driving: 743 | self._cancel_drive_ending_delay() 744 | elif was_driving: 745 | self._start_drive_ending_delay() 746 | 747 | if driving or self._drive_ending_delayed: 748 | self._attr_location_name = STATE_DRIVING 749 | 750 | def _use_non_gps_data(self, entity_id: str, state: str) -> bool: 751 | """Determine if state should be used for non-GPS based entity.""" 752 | if state == STATE_HOME or self._entities[entity_id].use_all_states: 753 | return True 754 | good_entities = (entity for entity in self._entities.values() if entity.is_good) 755 | if any(entity.source_type == SourceType.GPS for entity in good_entities): 756 | return False 757 | return all(cast(str, entity.data) != STATE_HOME for entity in good_entities) 758 | --------------------------------------------------------------------------------