Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,43 @@ It will also create a `sensor` entity that indicates the speed of the device.

Currently any entity that has "GPS" attributes (`gps_accuracy` or `acc`, and either `latitude` & `longitude` or `lat` & `lon`), or any `device_tracker` entity with a `source_type` attribute of `bluetooth`, `bluetooth_le`, `gps` or `router`, or any `binary_sensor` entity, can be used as an input entity.

## Breaking Change
## Installation

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

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

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

1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/):
1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/).
It should then appear as a new integration. Click on it. If necessary, search for "composite".

```text
https://github.com/pnbruckner/ha-composite-tracker
```
Or use this button:

[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=pnbruckner&repository=ha-composite-tracker&category=integration)

```text
https://github.com/pnbruckner/ha-composite-tracker
```

2. Install the integration using the appropriate button on the HACS Integrations page. Search for "composite".
1. Download the integration using the appropriate button.

### Manual
</details>

<details>
<summary>Manual</summary>

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

>__NOTE__: When downloading, make sure to use the `Raw` button from each file's page.

</details>

After it has been downloaded you will need to restart Home Assistant.

### Versions

This custom integration supports HomeAssistant versions 2023.7 or newer.
Expand All @@ -57,6 +65,7 @@ composite:

- **default_options** (*Optional*): Defines default values for corresponding options under **trackers**.
- **require_movement** (*Optional*): Default is `false`.
- **driving_speed** (*Optional*)

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

Expand All @@ -66,6 +75,7 @@ composite:
- **name**: Friendly name of composite device.
- **id** (*Optional*): Object ID (i.e., part of entity ID after the dot) of composite device. If not supplied, then object ID will be generated from the `name` variable. For example, `My Name` would result in a tracker entity ID of `device_tracker.my_name`. The speed sensor's object ID will be the same as for the device tracker, but with a suffix of "`_speed`" added (e.g., `sensor.my_name_speed`.)
- **require_movement** (*Optional*): `true` or `false`. If `true`, will skip update from a GPS-based tracker if it has not moved. Specifically, if circle defined by new GPS coordinates and accuracy overlaps circle defined by previous GPS coordinates and accuracy then update will be ignored.
- **driving_speed** (*Optional*): Defines a driving speed threshold (in MPH or KPH, depending on general unit system setting.) If set, and current speed is at or above this value, and tracker is not in a zone, then the state of the tracker will be set to `driving`.

#### Entity Dictionary

Expand Down Expand Up @@ -109,8 +119,10 @@ direction | Compass heading of movement direction (if moving.)
composite:
default_options:
require_movement: true
driving_speed: 15
trackers:
- name: Me
driving_speed: 20
entity_id:
- entity: device_tracker.platform1_me
use_picture: true
Expand Down
7 changes: 7 additions & 0 deletions custom_components/composite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .const import (
CONF_ALL_STATES,
CONF_DEFAULT_OPTIONS,
CONF_DRIVING_SPEED,
CONF_ENTITY,
CONF_REQ_MOVEMENT,
CONF_TIME_AS,
Expand Down Expand Up @@ -102,10 +103,13 @@ def _defaults(config: dict) -> dict:
unsupported_cfgs.add(CONF_TIME_AS)

def_req_mv = config[CONF_DEFAULT_OPTIONS][CONF_REQ_MOVEMENT]
def_drv_sp = config[CONF_DEFAULT_OPTIONS].get(CONF_DRIVING_SPEED)
for tracker in config[CONF_TRACKERS]:
if tracker.pop(CONF_TIME_AS, None):
unsupported_cfgs.add(CONF_TIME_AS)
tracker[CONF_REQ_MOVEMENT] = tracker.get(CONF_REQ_MOVEMENT, def_req_mv)
if CONF_DRIVING_SPEED not in tracker and def_drv_sp is not None:
tracker[CONF_DRIVING_SPEED] = def_drv_sp

if unsupported_cfgs:
_LOGGER.warning(
Expand All @@ -115,6 +119,7 @@ def _defaults(config: dict) -> dict:
", ".join(sorted(unsupported_cfgs)),
)

del config[CONF_DEFAULT_OPTIONS]
return config


Expand All @@ -141,6 +146,7 @@ def _defaults(config: dict) -> dict:
vol.Required(CONF_ENTITY_ID): _ENTITIES,
vol.Optional(CONF_TIME_AS): cv.string,
vol.Optional(CONF_REQ_MOVEMENT): cv.boolean,
vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
}
CONFIG_SCHEMA = vol.Schema(
{
Expand All @@ -155,6 +161,7 @@ def _defaults(config: dict) -> dict:
vol.Optional(
CONF_REQ_MOVEMENT, default=DEF_REQ_MOVEMENT
): cv.boolean,
vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
}
),
vol.Required(CONF_TRACKERS, default=list): vol.All(
Expand Down
50 changes: 44 additions & 6 deletions custom_components/composite/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,28 @@
CONF_ENTITY_ID,
CONF_ID,
CONF_NAME,
UnitOfSpeed,
)
from homeassistant.core import State, callback
from homeassistant.data_entry_flow import FlowHandler, FlowResult
from homeassistant.helpers.selector import (
BooleanSelector,
EntitySelector,
EntitySelectorConfig,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
TextSelector,
)
from homeassistant.util.unit_conversion import SpeedConverter
from homeassistant.util.unit_system import METRIC_SYSTEM

from .const import (
ATTR_ACC,
ATTR_LAT,
ATTR_LON,
CONF_ALL_STATES,
CONF_DRIVING_SPEED,
CONF_ENTITY,
CONF_REQ_MOVEMENT,
CONF_USE_PICTURE,
Expand All @@ -50,7 +57,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]:
kw: {k: v for k, v in conf.items() if k in ks}
for kw, ks in (
("data", (CONF_NAME, CONF_ID)),
("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT)),
("options", (CONF_ENTITY_ID, CONF_REQ_MOVEMENT, CONF_DRIVING_SPEED)),
)
}

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

@cached_property
def _speed_uom(self) -> str:
"""Return speed unit_of_measurement."""
if self.hass.config.units is METRIC_SYSTEM:
return UnitOfSpeed.KILOMETERS_PER_HOUR
return UnitOfSpeed.MILES_PER_HOUR

@property
@abstractmethod
def options(self) -> dict[str, Any]:
Expand All @@ -81,6 +95,14 @@ async def async_step_options(

if user_input is not None:
self.options[CONF_REQ_MOVEMENT] = user_input[CONF_REQ_MOVEMENT]
if CONF_DRIVING_SPEED in user_input:
self.options[CONF_DRIVING_SPEED] = SpeedConverter.convert(
user_input[CONF_DRIVING_SPEED],
self._speed_uom,
UnitOfSpeed.METERS_PER_SECOND,
)
elif CONF_DRIVING_SPEED in self.options:
del self.options[CONF_DRIVING_SPEED]
prv_cfgs = {
cfg[CONF_ENTITY]: cfg for cfg in self.options.get(CONF_ENTITY_ID, [])
}
Expand Down Expand Up @@ -125,15 +147,27 @@ def entity_filter(state: State) -> bool:
)
),
vol.Required(CONF_REQ_MOVEMENT): BooleanSelector(),
vol.Optional(CONF_DRIVING_SPEED): NumberSelector(
NumberSelectorConfig(
unit_of_measurement=self._speed_uom,
mode=NumberSelectorMode.BOX,
)
),
}
)
if CONF_ENTITY_ID in self.options:
suggested_values = {
CONF_ENTITY_ID: self._entity_ids,
CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT],
}
if CONF_DRIVING_SPEED in self.options:
suggested_values[CONF_DRIVING_SPEED] = SpeedConverter.convert(
self.options[CONF_DRIVING_SPEED],
UnitOfSpeed.METERS_PER_SECOND,
self._speed_uom,
)
data_schema = self.add_suggested_values_to_schema(
data_schema,
{
CONF_ENTITY_ID: self._entity_ids,
CONF_REQ_MOVEMENT: self.options[CONF_REQ_MOVEMENT],
},
data_schema, suggested_values
)
return self.async_show_form(
step_id="options", data_schema=data_schema, errors=errors, last_step=False
Expand Down Expand Up @@ -238,6 +272,10 @@ def options(self) -> dict[str, Any]:

async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
"""Import config entry from configuration."""
if (driving_speed := data.get(CONF_DRIVING_SPEED)) is not None:
data[CONF_DRIVING_SPEED] = SpeedConverter.convert(
driving_speed, self._speed_uom, UnitOfSpeed.METERS_PER_SECOND
)
if existing_entry := await self.async_set_unique_id(data[CONF_ID]):
self.hass.config_entries.async_update_entry(
existing_entry, **split_conf(data) # type: ignore[arg-type]
Expand Down
3 changes: 3 additions & 0 deletions custom_components/composite/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

CONF_ALL_STATES = "all_states"
CONF_DEFAULT_OPTIONS = "default_options"
CONF_DRIVING_SPEED = "driving_speed"
CONF_ENTITY = "entity"
CONF_REQ_MOVEMENT = "require_movement"
CONF_TIME_AS = "time_as"
Expand All @@ -22,3 +23,5 @@
ATTR_ENTITIES = "entities"
ATTR_LAT = "lat"
ATTR_LON = "lon"

STATE_DRIVING = "driving"
58 changes: 29 additions & 29 deletions custom_components/composite/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
SourceType,
)
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.components.zone import ENTITY_ID_HOME, async_active_zone
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_CHARGING,
Expand Down Expand Up @@ -54,12 +53,14 @@
ATTR_LAT,
ATTR_LON,
CONF_ALL_STATES,
CONF_DRIVING_SPEED,
CONF_ENTITY,
CONF_REQ_MOVEMENT,
CONF_USE_PICTURE,
MIN_ANGLE_SPEED,
MIN_SPEED_SECONDS,
SIG_COMPOSITE_SPEED,
STATE_DRIVING,
)

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

def __init__(self, entry: ConfigEntry) -> None:
"""Initialize Composite Device Tracker."""
Expand Down Expand Up @@ -278,6 +280,7 @@ async def _process_config_options(self) -> None:
"""Process options from config entry."""
options = cast(ConfigEntry, self.platform.config_entry).options
self._req_movement = options[CONF_REQ_MOVEMENT]
self._driving_speed = options.get(CONF_DRIVING_SPEED)
entity_cfgs = {
entity_cfg[CONF_ENTITY]: entity_cfg
for entity_cfg in options[CONF_ENTITY_ID]
Expand Down Expand Up @@ -393,7 +396,9 @@ def _clear_state(self) -> None:
self._attr_extra_state_attributes = {}
self._prev_seen = None

async def _entity_updated(self, entity_id: str, new_state: State | None) -> None:
async def _entity_updated( # noqa: C901
self, entity_id: str, new_state: State | None
) -> None:
"""Run when an input entity has changed state."""
if not new_state or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
return
Expand Down Expand Up @@ -489,46 +494,34 @@ async def _entity_updated(self, entity_id: str, new_state: State | None) -> None
# Don't use new GPS data if it's not complete.
if not gps or gps_accuracy is None:
gps = gps_accuracy = None
# Get current GPS data, if any, and determine if it is in
# 'zone.home'.
cur_state = self.hass.states.get(self.entity_id)
try:
cur_lat: float = cast(State, cur_state).attributes[ATTR_LATITUDE]
cur_lon: float = cast(State, cur_state).attributes[ATTR_LONGITUDE]
cur_acc: int = cast(State, cur_state).attributes[ATTR_GPS_ACCURACY]
cur_gps_is_home = (
async_active_zone(
self.hass, cur_lat, cur_lon, cur_acc
).entity_id # type: ignore[union-attr]
== ENTITY_ID_HOME
)
except (AttributeError, KeyError):
cur_gps_is_home = False

# Is current state home w/ GPS data?
if home_w_gps := self.location_name is None and self.state == STATE_HOME:
if self.latitude is None or self.longitude is None:
_LOGGER.warning("%s: Unexpectedly home without GPS data", self.name)
home_w_gps = False

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

# If router entity's state is 'home' and current GPS data from
# composite entity is available and is in 'zone.home',
# use it and make source_type gps.
if state == STATE_HOME and cur_gps_is_home:
gps = cast(GPSType, (cur_lat, cur_lon))
gps_accuracy = cur_acc
# If router entity's state is 'home' and our current state is 'home' w/ GPS
# data, use it and make source_type gps.
if state == STATE_HOME and home_w_gps:
gps = cast(GPSType, (self.latitude, self.longitude))
gps_accuracy = self.location_accuracy
source_type = SourceType.GPS
# Otherwise, if new GPS data is valid (which is unlikely if
# new state is not 'home'),
# use it and make source_type gps.
elif gps:
source_type = SourceType.GPS
# Otherwise, if new state is 'home' and old state is not 'home'
# and no GPS data, then use HA's configured Home location and
# make source_type gps.
elif state == STATE_HOME and not (
cur_state and cur_state.state == STATE_HOME
):
# Otherwise, if new state is 'home' and old state is not 'home' w/ GPS data
# (i.e., not 'home' or no GPS data), then use HA's configured Home location
# and make source_type gps.
elif state == STATE_HOME:
gps = cast(
GPSType,
(self.hass.config.latitude, self.hass.config.longitude),
Expand Down Expand Up @@ -641,6 +634,13 @@ def _set_state(
angle = round(degrees(atan2(lon - prev_lon, lat - prev_lat)))
if angle < 0:
angle += 360
if (
speed is not None
and self._driving_speed is not None
and speed >= self._driving_speed
and self.state == STATE_NOT_HOME
):
self._location_name = STATE_DRIVING
_LOGGER.debug("%s: Sending speed: %s m/s, angle: %s°", self.name, speed, angle)
async_dispatcher_send(
self.hass, f"{SIG_COMPOSITE_SPEED}-{self.unique_id}", speed, angle
Expand Down
4 changes: 2 additions & 2 deletions custom_components/composite/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"codeowners": ["@pnbruckner"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.0.0/README.md",
"documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.1.0b0/README.md",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues",
"requirements": [],
"version": "3.0.0"
"version": "3.1.0b0"
}
Loading