Skip to content

Commit bb9fcc6

Browse files
authored
Add optional driving state (pnbruckner#61)
1 parent fec63ae commit bb9fcc6

File tree

8 files changed

+120
-50
lines changed

8 files changed

+120
-50
lines changed

README.md

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,43 @@ It will also create a `sensor` entity that indicates the speed of the device.
66

77
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.
88

9-
## Breaking Change
9+
## Installation
1010

11-
- All time zone related features have been removed. See https://github.com/pnbruckner/ha-entity-tz for an integration that replaces those features, and more.
12-
- Any tracker entry removed from YAML configuration will be removed from the system.
13-
- `trackers` in YAML configuration must have at least one entry.
14-
- The `entity_id` attribute has been changed to `entities`. `entity_id` did not show up in the attribute list in the UI.
11+
<details>
12+
<summary>With HACS</summary>
1513

16-
## Installation
17-
### With HACS
1814
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/)
1915

2016
You can use HACS to manage the installation and provide update notifications.
2117

22-
1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/):
18+
1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/).
19+
It should then appear as a new integration. Click on it. If necessary, search for "composite".
20+
21+
```text
22+
https://github.com/pnbruckner/ha-composite-tracker
23+
```
24+
Or use this button:
25+
26+
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=pnbruckner&repository=ha-composite-tracker&category=integration)
2327

24-
```text
25-
https://github.com/pnbruckner/ha-composite-tracker
26-
```
2728

28-
2. Install the integration using the appropriate button on the HACS Integrations page. Search for "composite".
29+
1. Download the integration using the appropriate button.
2930

30-
### Manual
31+
</details>
32+
33+
<details>
34+
<summary>Manual</summary>
3135

3236
Place a copy of the files from [`custom_components/composite`](custom_components/composite)
3337
in `<config>/custom_components/composite`,
3438
where `<config>` is your Home Assistant configuration directory.
3539

3640
>__NOTE__: When downloading, make sure to use the `Raw` button from each file's page.
3741
42+
</details>
43+
44+
After it has been downloaded you will need to restart Home Assistant.
45+
3846
### Versions
3947

4048
This custom integration supports HomeAssistant versions 2023.7 or newer.
@@ -57,6 +65,7 @@ composite:
5765
5866
- **default_options** (*Optional*): Defines default values for corresponding options under **trackers**.
5967
- **require_movement** (*Optional*): Default is `false`.
68+
- **driving_speed** (*Optional*)
6069

6170
- **trackers**: The list of composite trackers to create. For each entry see [Tracker entries](#tracker-entries).
6271

@@ -66,6 +75,7 @@ composite:
6675
- **name**: Friendly name of composite device.
6776
- **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`.)
6877
- **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.
78+
- **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`.
6979

7080
#### Entity Dictionary
7181

@@ -109,8 +119,10 @@ direction | Compass heading of movement direction (if moving.)
109119
composite:
110120
default_options:
111121
require_movement: true
122+
driving_speed: 15
112123
trackers:
113124
- name: Me
125+
driving_speed: 20
114126
entity_id:
115127
- entity: device_tracker.platform1_me
116128
use_picture: true

custom_components/composite/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .const import (
2828
CONF_ALL_STATES,
2929
CONF_DEFAULT_OPTIONS,
30+
CONF_DRIVING_SPEED,
3031
CONF_ENTITY,
3132
CONF_REQ_MOVEMENT,
3233
CONF_TIME_AS,
@@ -102,10 +103,13 @@ def _defaults(config: dict) -> dict:
102103
unsupported_cfgs.add(CONF_TIME_AS)
103104

104105
def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT]
106+
def_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED)
105107
for tracker in config[CONF_TRACKERS]:
106108
if tracker.pop(CONF_TIME_AS, None):
107109
unsupported_cfgs.add(CONF_TIME_AS)
108110
tracker[CONF_REQ_MOVEMENT] = tracker.get(CONF_REQ_MOVEMENT, def_req_mv)
111+
if CONF_DRIVING_SPEED not in tracker and def_drv_sp is not None:
112+
tracker[CONF_DRIVING_SPEED] = def_drv_sp
109113

110114
if unsupported_cfgs:
111115
_LOGGER.warning(
@@ -115,6 +119,7 @@ def _defaults(config: dict) -> dict:
115119
", ".join(sorted(unsupported_cfgs)),
116120
)
117121

122+
del config[CONF_DEFAULT_OPTIONS]
118123
return config
119124

120125

@@ -141,6 +146,7 @@ def _defaults(config: dict) -> dict:
141146
vol.Required(CONF_ENTITY_ID): _ENTITIES,
142147
vol.Optional(CONF_TIME_AS): cv.string,
143148
vol.Optional(CONF_REQ_MOVEMENT): cv.boolean,
149+
vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
144150
}
145151
CONFIG_SCHEMA = vol.Schema(
146152
{
@@ -155,6 +161,7 @@ def _defaults(config: dict) -> dict:
155161
vol.Optional(
156162
CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT
157163
): cv.boolean,
164+
vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
158165
}
159166
),
160167
vol.Required(CONF_TRACKERS, default=list): vol.All(

custom_components/composite/config_flow.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,28 @@
2222
CONF_ENTITY_ID,
2323
CONF_ID,
2424
CONF_NAME,
25+
UnitOfSpeed,
2526
)
2627
from homeassistant.core import State, callback
2728
from homeassistant.data_entry_flow import FlowHandler, FlowResult
2829
from homeassistant.helpers.selector import (
2930
BooleanSelector,
3031
EntitySelector,
3132
EntitySelectorConfig,
33+
NumberSelector,
34+
NumberSelectorConfig,
35+
NumberSelectorMode,
3236
TextSelector,
3337
)
38+
from homeassistant.util.unit_conversion import SpeedConverter
39+
from homeassistant.util.unit_system import METRIC_SYSTEM
3440

3541
from .const import (
3642
ATTR_ACC,
3743
ATTR_LAT,
3844
ATTR_LON,
3945
CONF_ALL_STATES,
46+
CONF_DRIVING_SPEED,
4047
CONF_ENTITY,
4148
CONF_REQ_MOVEMENT,
4249
CONF_USE_PICTURE,
@@ -50,7 +57,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]:
5057
kw: {k: v for k, v in conf.items() if k in ks}
5158
for kw, ks in (
5259
("data", (CONF_NAME, CONF_ID)),
53-
("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT)),
60+
("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT, CONF_DRIVING_SPEED)),
5461
)
5562
}
5663

@@ -63,6 +70,13 @@ def _entries(self) -> list[ConfigEntry]:
6370
"""Get existing config entries."""
6471
return self.hass.config_entries.async_entries(DOMAIN)
6572

73+
@cached_property
74+
def _speed_uom(self) -> str:
75+
"""Return speed unit_of_measurement."""
76+
if self.hass.config.units is METRIC_SYSTEM:
77+
return UnitOfSpeed.KILOMETERS_PER_HOUR
78+
return UnitOfSpeed.MILES_PER_HOUR
79+
6680
@property
6781
@abstractmethod
6882
def options(self) -> dict[str, Any]:
@@ -81,6 +95,14 @@ async def async_step_options(
8195

8296
if user_input is not None:
8397
self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT]
98+
if CONF_DRIVING_SPEED in user_input:
99+
self.options[CONF_DRIVING_SPEED] = SpeedConverter.convert(
100+
user_input[CONF_DRIVING_SPEED],
101+
self._speed_uom,
102+
UnitOfSpeed.METERS_PER_SECOND,
103+
)
104+
elif CONF_DRIVING_SPEED in self.options:
105+
del self.options[CONF_DRIVING_SPEED]
84106
prv_cfgs = {
85107
cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, [])
86108
}
@@ -125,15 +147,27 @@ def entity_filter(state: State) -> bool:
125147
)
126148
),
127149
vol.Required(CONF_REQ_MOVEMENT): BooleanSelector(),
150+
vol.Optional(CONF_DRIVING_SPEED): NumberSelector(
151+
NumberSelectorConfig(
152+
unit_of_measurement=self._speed_uom,
153+
mode=NumberSelectorMode.BOX,
154+
)
155+
),
128156
}
129157
)
130158
if CONF_ENTITY_ID in self.options:
159+
suggested_values = {
160+
CONF_ENTITY_ID: self._entity_ids,
161+
CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT],
162+
}
163+
if CONF_DRIVING_SPEED in self.options:
164+
suggested_values[CONF_DRIVING_SPEED] = SpeedConverter.convert(
165+
self.options[CONF_DRIVING_SPEED],
166+
UnitOfSpeed.METERS_PER_SECOND,
167+
self._speed_uom,
168+
)
131169
data_schema = self.add_suggested_values_to_schema(
132-
data_schema,
133-
{
134-
CONF_ENTITY_ID: self._entity_ids,
135-
CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT],
136-
},
170+
data_schema, suggested_values
137171
)
138172
return self.async_show_form(
139173
step_id="options", data_schema=data_schema, errors=errors, last_step=False
@@ -238,6 +272,10 @@ def options(self) -> dict[str, Any]:
238272

239273
async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
240274
"""Import config entry from configuration."""
275+
if (driving_speed := data.get(CONF_DRIVING_SPEED)) is not None:
276+
data[CONF_DRIVING_SPEED] = SpeedConverter.convert(
277+
driving_speed, self._speed_uom, UnitOfSpeed.METERS_PER_SECOND
278+
)
241279
if existing_entry := await self.async_set_unique_id(data[CONF_ID]):
242280
self.hass.config_entries.async_update_entry(
243281
existing_entry, **split_conf(data) # type: ignore[arg-type]

custom_components/composite/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
CONF_ALL_STATES = "all_states"
77
CONF_DEFAULT_OPTIONS = "default_options"
8+
CONF_DRIVING_SPEED = "driving_speed"
89
CONF_ENTITY = "entity"
910
CONF_REQ_MOVEMENT = "require_movement"
1011
CONF_TIME_AS = "time_as"
@@ -22,3 +23,5 @@
2223
ATTR_ENTITIES = "entities"
2324
ATTR_LAT = "lat"
2425
ATTR_LON = "lon"
26+
27+
STATE_DRIVING = "driving"

custom_components/composite/device_tracker.py

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
SourceType,
2020
)
2121
from homeassistant.components.device_tracker.config_entry import TrackerEntity
22-
from homeassistant.components.zone import ENTITY_ID_HOME, async_active_zone
2322
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
2423
from homeassistant.const import (
2524
ATTR_BATTERY_CHARGING,
@@ -54,12 +53,14 @@
5453
ATTR_LAT,
5554
ATTR_LON,
5655
CONF_ALL_STATES,
56+
CONF_DRIVING_SPEED,
5757
CONF_ENTITY,
5858
CONF_REQ_MOVEMENT,
5959
CONF_USE_PICTURE,
6060
MIN_ANGLE_SPEED,
6161
MIN_SPEED_SECONDS,
6262
SIG_COMPOSITE_SPEED,
63+
STATE_DRIVING,
6364
)
6465

6566
_LOGGER = logging.getLogger(__name__)
@@ -206,6 +207,7 @@ class CompositeDeviceTracker(TrackerEntity, RestoreEntity):
206207
_prev_seen: datetime | None = None
207208
_remove_track_states: Callable[[], None] | None = None
208209
_req_movement: bool
210+
_driving_speed: float | None # m/s
209211

210212
def __init__(self, entry: ConfigEntry) -> None:
211213
"""Initialize Composite Device Tracker."""
@@ -278,6 +280,7 @@ async def _process_config_options(self) -> None:
278280
"""Process options from config entry."""
279281
options = cast(ConfigEntry, self.platform.config_entry).options
280282
self._req_movement = options[CONF_REQ_MOVEMENT]
283+
self._driving_speed = options.get(CONF_DRIVING_SPEED)
281284
entity_cfgs = {
282285
entity_cfg[CONF_ENTITY]: entity_cfg
283286
for entity_cfg in options[CONF_ENTITY_ID]
@@ -393,7 +396,9 @@ def _clear_state(self) -> None:
393396
self._attr_extra_state_attributes = {}
394397
self._prev_seen = None
395398

396-
async def _entity_updated(self, entity_id: str, new_state: State | None) -> None:
399+
async def _entity_updated( # noqa: C901
400+
self, entity_id: str, new_state: State | None
401+
) -> None:
397402
"""Run when an input entity has changed state."""
398403
if not new_state or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
399404
return
@@ -489,46 +494,34 @@ async def _entity_updated(self, entity_id: str, new_state: State | None) -> None
489494
# Don't use new GPS data if it's not complete.
490495
if not gps or gps_accuracy is None:
491496
gps = gps_accuracy = None
492-
# Get current GPS data, if any, and determine if it is in
493-
# 'zone.home'.
494-
cur_state = self.hass.states.get(self.entity_id)
495-
try:
496-
cur_lat: float = cast(State, cur_state).attributes[ATTR_LATITUDE]
497-
cur_lon: float = cast(State, cur_state).attributes[ATTR_LONGITUDE]
498-
cur_acc: int = cast(State, cur_state).attributes[ATTR_GPS_ACCURACY]
499-
cur_gps_is_home = (
500-
async_active_zone(
501-
self.hass, cur_lat, cur_lon, cur_acc
502-
).entity_id # type: ignore[union-attr]
503-
== ENTITY_ID_HOME
504-
)
505-
except (AttributeError, KeyError):
506-
cur_gps_is_home = False
497+
498+
# Is current state home w/ GPS data?
499+
if home_w_gps := self.location_name is None and self.state == STATE_HOME:
500+
if self.latitude is None or self.longitude is None:
501+
_LOGGER.warning("%s: Unexpectedly home without GPS data", self.name)
502+
home_w_gps = False
507503

508504
# It's important, for this composite tracker, to avoid the
509505
# component level code's "stale processing." This can be done
510506
# one of two ways: 1) provide GPS data w/ source_type of gps,
511507
# or 2) provide a location_name (that will be used as the new
512508
# state.)
513509

514-
# If router entity's state is 'home' and current GPS data from
515-
# composite entity is available and is in 'zone.home',
516-
# use it and make source_type gps.
517-
if state == STATE_HOME and cur_gps_is_home:
518-
gps = cast(GPSType, (cur_lat, cur_lon))
519-
gps_accuracy = cur_acc
510+
# If router entity's state is 'home' and our current state is 'home' w/ GPS
511+
# data, use it and make source_type gps.
512+
if state == STATE_HOME and home_w_gps:
513+
gps = cast(GPSType, (self.latitude, self.longitude))
514+
gps_accuracy = self.location_accuracy
520515
source_type = SourceType.GPS
521516
# Otherwise, if new GPS data is valid (which is unlikely if
522517
# new state is not 'home'),
523518
# use it and make source_type gps.
524519
elif gps:
525520
source_type = SourceType.GPS
526-
# Otherwise, if new state is 'home' and old state is not 'home'
527-
# and no GPS data, then use HA's configured Home location and
528-
# make source_type gps.
529-
elif state == STATE_HOME and not (
530-
cur_state and cur_state.state == STATE_HOME
531-
):
521+
# Otherwise, if new state is 'home' and old state is not 'home' w/ GPS data
522+
# (i.e., not 'home' or no GPS data), then use HA's configured Home location
523+
# and make source_type gps.
524+
elif state == STATE_HOME:
532525
gps = cast(
533526
GPSType,
534527
(self.hass.config.latitude, self.hass.config.longitude),
@@ -641,6 +634,13 @@ def _set_state(
641634
angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat)))
642635
if angle < 0:
643636
angle += 360
637+
if (
638+
speed is not None
639+
and self._driving_speed is not None
640+
and speed >= self._driving_speed
641+
and self.state == STATE_NOT_HOME
642+
):
643+
self._location_name = STATE_DRIVING
644644
_LOGGER.debug("%s: Sending speed: %s m/s, angle: %s°", self.name, speed, angle)
645645
async_dispatcher_send(
646646
self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed, angle

custom_components/composite/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
"codeowners": ["@pnbruckner"],
55
"config_flow": true,
66
"dependencies": [],
7-
"documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.0.0/README.md",
7+
"documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.1.0b0/README.md",
88
"iot_class": "local_polling",
99
"issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues",
1010
"requirements": [],
11-
"version": "3.0.0"
11+
"version": "3.1.0b0"
1212
}

0 commit comments

Comments
 (0)