├── 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
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
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 | [](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 | [](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 | [](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 |
--------------------------------------------------------------------------------