Skip to content

Commit

Permalink
Support BLE Trackers as source of presence (#467)
Browse files Browse the repository at this point in the history
* config flow and schema for BLE tracker option

* BLE Tracker sensor tracks and is counted as an occupancy sensor

* adding tests

* linting
  • Loading branch information
jseidl authored Jan 3, 2025
1 parent b0a5042 commit 043f300
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 14 deletions.
6 changes: 6 additions & 0 deletions custom_components/magic_areas/base/magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
AREA_TYPE_META,
CONF_ENABLED_FEATURES,
CONF_EXCLUDE_ENTITIES,
CONF_FEATURE_BLE_TRACKERS,
CONF_FEATURE_PRESENCE_HOLD,
CONF_INCLUDE_ENTITIES,
CONF_PRESENCE_DEVICE_PLATFORMS,
Expand Down Expand Up @@ -365,6 +366,11 @@ def get_presence_sensors(self) -> list[str]:
)
sensors.append(presence_hold_switch_id)

# Append BLE Tracker monitor as a presence_sensor
if self.has_feature(CONF_FEATURE_BLE_TRACKERS):
ble_tracker_sensor_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_ble_trackers_{self.slug}_ble_tracker_monitor"
sensors.append(ble_tracker_sensor_id)

return sensors

async def initialize(self, _=None) -> None:
Expand Down
9 changes: 4 additions & 5 deletions custom_components/magic_areas/base/presence.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ def __init__(self, area: MagicArea) -> None:
_LOGGER.debug("%s: presence tracker initialized", self.area.name)

def _setup_tracking_listeners(self) -> None:

# Track presence sensor
self.async_on_remove(
async_track_state_change_event(
Expand All @@ -92,7 +91,6 @@ def _setup_tracking_listeners(self) -> None:
configurable_states = self._get_configured_secondary_states()

for configurable_state in configurable_states:

configurable_state_entity = CONFIGURABLE_AREA_STATE_MAP[configurable_state]
tracked_entity = self.area.config.get(CONF_SECONDARY_STATES, {}).get(
configurable_state_entity, None
Expand Down Expand Up @@ -163,7 +161,6 @@ def _get_configured_secondary_states(self) -> list[str]:
configurable_state,
configurable_state_entity,
) in CONFIGURABLE_AREA_STATE_MAP.items():

secondary_state_entity = self.area.config.get(
CONF_SECONDARY_STATES, {}
).get(configurable_state_entity, None)
Expand Down Expand Up @@ -601,7 +598,6 @@ async def async_added_to_hass(self) -> None:
_LOGGER.debug("%s: area presence binary sensor initialized", self.area.name)

async def _setup_listeners(self) -> None:

# Setup state chagne listener
async_dispatcher_connect(
self.hass, MagicAreasEvents.AREA_STATE_CHANGED, self._area_state_changed
Expand Down Expand Up @@ -632,7 +628,10 @@ async def _restore_state(self) -> None:
@property
def icon(self):
"""Return the icon to be used for this entity."""
return self.area.icon
default_icon = None
if self.feature_info:
self.feature_info.icons.get(BINARY_SENSOR_DOMAIN, None)
return self.area.icon or default_icon

# Helpers

Expand Down
101 changes: 100 additions & 1 deletion custom_components/magic_areas/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,35 @@
DEVICE_CLASSES,
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.components.group.binary_sensor import BinarySensorGroup
from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.core import Event, EventStateChangedData, HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event

from custom_components.magic_areas.base.entities import MagicEntity
from custom_components.magic_areas.base.magic import MagicArea
from custom_components.magic_areas.base.presence import AreaStateBinarySensor
from custom_components.magic_areas.const import (
AGGREGATE_MODE_ALL,
ATTR_ACTIVE_SENSORS,
CONF_AGGREGATES_BINARY_SENSOR_DEVICE_CLASSES,
CONF_AGGREGATES_MIN_ENTITIES,
CONF_BLE_TRACKER_ENTITIES,
CONF_FEATURE_AGGREGATION,
CONF_FEATURE_BLE_TRACKERS,
CONF_FEATURE_HEALTH,
CONF_HEALTH_SENSOR_DEVICE_CLASSES,
DEFAULT_AGGREGATES_BINARY_SENSOR_DEVICE_CLASSES,
DEFAULT_HEALTH_SENSOR_DEVICE_CLASSES,
EMPTY_STRING,
MagicAreasFeatureInfoAggregates,
MagicAreasFeatureInfoBLETrackers,
MagicAreasFeatureInfoHealth,
)
from custom_components.magic_areas.helpers.area import get_area_from_config_entry
Expand Down Expand Up @@ -80,6 +87,80 @@ class AreaHealthBinarySensor(AreaSensorGroupBinarySensor):
feature_info = MagicAreasFeatureInfoHealth()


class AreaBLETrackerBinarySensor(MagicEntity, BinarySensorEntity):
"""BLE Tracker monitoring sensor for the area."""

feature_info = MagicAreasFeatureInfoBLETrackers()
_sensors: list[str]

def __init__(self, area: MagicArea) -> None:
"""Initialize the area presence binary sensor."""

MagicEntity.__init__(self, area, domain=BINARY_SENSOR_DOMAIN)
BinarySensorEntity.__init__(self)

self._sensors = self.area.feature_config(CONF_FEATURE_BLE_TRACKERS).get(
CONF_BLE_TRACKER_ENTITIES, []
)

self._attr_device_class = BinarySensorDeviceClass.OCCUPANCY
self._attr_extra_state_attributes = {
ATTR_ENTITY_ID: self._sensors,
ATTR_ACTIVE_SENSORS: [],
}
self._attr_is_on: bool = False

async def async_added_to_hass(self) -> None:
"""Call to add the system to hass."""
await super().async_added_to_hass()

# Setup the listeners
await self._setup_listeners()

_LOGGER.debug("%s: area presence binary sensor initialized", self.area.name)

async def _setup_listeners(self) -> None:
"""Attach state chagne listeners."""
self.async_on_remove(
async_track_state_change_event(
self.hass, self._sensors, self._sensor_state_change
)
)

def _sensor_state_change(self, event: Event[EventStateChangedData]) -> None:
"""Calculate state based off BLE tracker sensors."""

calculated_state: bool = False
active_sensors: list[str] = []

for sensor in self._sensors:
sensor_state = self.hass.states.get(sensor)

if not sensor_state:
continue

normalized_state = sensor_state.state.lower()

if (
normalized_state == self.area.slug
or normalized_state == self.area.id
or normalized_state == self.area.name.lower()
):
calculated_state = True
active_sensors.append(sensor)

_LOGGER.debug(
"%s: BLE Tracker monitor sensor state change: %s -> %s",
self.area.name,
self._attr_is_on,
calculated_state,
)

self._attr_is_on = calculated_state
self._attr_extra_state_attributes[ATTR_ACTIVE_SENSORS] = active_sensors
self.schedule_update_ha_state()


# Setup


Expand Down Expand Up @@ -108,6 +189,9 @@ async def async_setup_entry(
if area.has_feature(CONF_FEATURE_HEALTH):
entities.extend(create_health_sensors(area))

if area.has_feature(CONF_FEATURE_BLE_TRACKERS):
entities.extend(create_ble_tracker_sensor(area))

# Add all entities
async_add_entities(entities)

Expand All @@ -118,6 +202,21 @@ async def async_setup_entry(
)


def create_ble_tracker_sensor(area: MagicArea) -> list[AreaBLETrackerBinarySensor]:
"""Add the BLE tracker sensor for the area."""
if not area.has_feature(CONF_FEATURE_BLE_TRACKERS):
return []

if SENSOR_DOMAIN not in area.entities:
return []

return [
AreaBLETrackerBinarySensor(
area,
)
]


def create_health_sensors(area: MagicArea) -> list[AreaHealthBinarySensor]:
"""Add the health sensors for the area."""
if not area.has_feature(CONF_FEATURE_HEALTH):
Expand Down
31 changes: 31 additions & 0 deletions custom_components/magic_areas/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import ATTR_DEVICE_CLASS, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.area_registry import async_get as areareg_async_get
Expand Down Expand Up @@ -47,6 +48,7 @@
CONF_AGGREGATES_ILLUMINANCE_THRESHOLD_HYSTERESIS,
CONF_AGGREGATES_MIN_ENTITIES,
CONF_AGGREGATES_SENSOR_DEVICE_CLASSES,
CONF_BLE_TRACKER_ENTITIES,
CONF_CLEAR_TIMEOUT,
CONF_CLIMATE_GROUPS_TURN_ON_STATE,
CONF_DARK_ENTITY,
Expand All @@ -56,6 +58,7 @@
CONF_EXTENDED_TIMEOUT,
CONF_FEATURE_AGGREGATION,
CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER,
CONF_FEATURE_BLE_TRACKERS,
CONF_FEATURE_CLIMATE_GROUPS,
CONF_FEATURE_HEALTH,
CONF_FEATURE_LIGHT_GROUPS,
Expand Down Expand Up @@ -95,6 +98,7 @@
DISTRESS_SENSOR_CLASSES,
DOMAIN,
LIGHT_GROUP_ACT_ON_OPTIONS,
MAGICAREAS_UNIQUEID_PREFIX,
META_AREA_BASIC_OPTIONS_SCHEMA,
META_AREA_GLOBAL,
META_AREA_PRESENCE_TRACKING_OPTIONS_SCHEMA,
Expand All @@ -105,6 +109,7 @@
OPTIONS_AREA,
OPTIONS_AREA_AWARE_MEDIA_PLAYER,
OPTIONS_AREA_META,
OPTIONS_BLE_TRACKERS,
OPTIONS_CLIMATE_GROUP,
OPTIONS_CLIMATE_GROUP_META,
OPTIONS_HEALTH_SENSOR,
Expand Down Expand Up @@ -1037,6 +1042,32 @@ async def async_step_feature_conf_presence_hold(self, user_input=None):
user_input=user_input,
)

async def async_step_feature_conf_ble_trackers(self, user_input=None):
"""Configure the sensor BLE trackers feature."""

selectors = {
CONF_BLE_TRACKER_ENTITIES: self._build_selector_entity_simple(
[
entity_id
for entity_id in self.all_entities
if (
entity_id.split(".")[0] == SENSOR_DOMAIN
and not entity_id.split(".")[1].startswith(
MAGICAREAS_UNIQUEID_PREFIX
)
)
],
multiple=True,
),
}

return await self.do_feature_config(
name=CONF_FEATURE_BLE_TRACKERS,
options=OPTIONS_BLE_TRACKERS,
selectors=selectors,
user_input=user_input,
)

async def do_feature_config(
self, *, name, options, dynamic_validators=None, selectors=None, user_input=None
):
Expand Down
30 changes: 30 additions & 0 deletions custom_components/magic_areas/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ class MagicAreasFeatureInfoPresenceTracking(MagicAreasFeatureInfo):

id = "presence_tracking"
translation_keys = {BINARY_SENSOR_DOMAIN: "area_state"}
icons = {BINARY_SENSOR_DOMAIN: "mdi:texture-box"}


class MagicAreasFeatureInfoPresenceHold(MagicAreasFeatureInfo):
Expand All @@ -189,6 +190,14 @@ class MagicAreasFeatureInfoPresenceHold(MagicAreasFeatureInfo):
icons = {SWITCH_DOMAIN: "mdi:car-brake-hold"}


class MagicAreasFeatureInfoBLETrackers(MagicAreasFeatureInfo):
"""Feature information for feature: BLE Trackers."""

id = "ble_trackers"
translation_keys = {BINARY_SENSOR_DOMAIN: "ble_tracker_monitor"}
icons = {BINARY_SENSOR_DOMAIN: "mdi:bluetooth"}


class MagicAreasFeatureInfoAggregates(MagicAreasFeatureInfo):
"""Feature information for feature: Aggregates."""

Expand Down Expand Up @@ -512,6 +521,11 @@ class MetaAreaType(StrEnum):
CONF_EXTENDED_TIME, DEFAULT_EXTENDED_TIME = "extended_time", 5 # cv.positive_int
CONF_EXTENDED_TIMEOUT, DEFAULT_EXTENDED_TIMEOUT = "extended_timeout", 10 # int

CONF_BLE_TRACKER_ENTITIES, DEFAULT_BLE_TRACKER_ENTITIES = (
"ble_tracker_entities",
[],
) # cv.entity_ids

CONFIGURABLE_AREA_STATE_MAP = {
AREA_STATE_SLEEP: CONF_SLEEP_ENTITY,
AREA_STATE_DARK: CONF_DARK_ENTITY,
Expand All @@ -528,6 +542,7 @@ class MetaAreaType(StrEnum):
CONF_FEATURE_AGGREGATION = "aggregates"
CONF_FEATURE_HEALTH = "health"
CONF_FEATURE_PRESENCE_HOLD = "presence_hold"
CONF_FEATURE_BLE_TRACKERS = "ble_trackers"

CONF_FEATURE_LIST_META = [
CONF_FEATURE_MEDIA_PLAYER_GROUPS,
Expand All @@ -541,6 +556,7 @@ class MetaAreaType(StrEnum):
CONF_FEATURE_LIST = CONF_FEATURE_LIST_META + [
CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER,
CONF_FEATURE_PRESENCE_HOLD,
CONF_FEATURE_BLE_TRACKERS,
]

CONF_FEATURE_LIST_GLOBAL = CONF_FEATURE_LIST_META
Expand Down Expand Up @@ -604,6 +620,15 @@ class MetaAreaType(StrEnum):
extra=vol.REMOVE_EXTRA,
)

BLE_TRACKER_FEATURE_SCHEMA = vol.Schema(
{
vol.Optional(
CONF_BLE_TRACKER_ENTITIES, default=DEFAULT_BLE_TRACKER_ENTITIES
): cv.entity_ids,
},
extra=vol.REMOVE_EXTRA,
)

CLIMATE_GROUP_FEATURE_SCHEMA = vol.Schema(
{
vol.Optional(
Expand Down Expand Up @@ -659,6 +684,7 @@ class MetaAreaType(StrEnum):
CONF_FEATURE_HEALTH: HEALTH_FEATURE_SCHEMA,
CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER: AREA_AWARE_MEDIA_PLAYER_FEATURE_SCHEMA,
CONF_FEATURE_PRESENCE_HOLD: PRESENCE_HOLD_FEATURE_SCHEMA,
CONF_FEATURE_BLE_TRACKERS: BLE_TRACKER_FEATURE_SCHEMA,
}

NON_CONFIGURABLE_FEATURES_META = [
Expand Down Expand Up @@ -908,6 +934,10 @@ class MetaAreaType(StrEnum):
(CONF_PRESENCE_HOLD_TIMEOUT, DEFAULT_PRESENCE_HOLD_TIMEOUT, int),
]

OPTIONS_BLE_TRACKERS = [
(CONF_BLE_TRACKER_ENTITIES, DEFAULT_BLE_TRACKER_ENTITIES, cv.entity_ids),
]

OPTIONS_CLIMATE_GROUP = [
(CONF_CLIMATE_GROUPS_TURN_ON_STATE, DEFAULT_CLIMATE_GROUPS_TURN_ON_STATE, str),
]
Expand Down
9 changes: 3 additions & 6 deletions custom_components/magic_areas/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@

import logging

from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SwitchDeviceClass,
SwitchEntity,
)
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
Expand Down Expand Up @@ -41,7 +38,7 @@ async def async_setup_entry(
):
"""Set up the area switch config entry."""

area: MagicArea = get_area_from_config_entry(hass, config_entry)
area: MagicArea | None = get_area_from_config_entry(hass, config_entry)
assert area is not None

switch_entities = []
Expand Down
Loading

0 comments on commit 043f300

Please sign in to comment.