diff --git a/custom_components/format_ble_tracker/__init__.py b/custom_components/format_ble_tracker/__init__.py index f4dce4a..eb12238 100644 --- a/custom_components/format_ble_tracker/__init__.py +++ b/custom_components/format_ble_tracker/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio -from asyncio import events import json -import time import logging +import time from typing import Any +#import numpy as np +import math import voluptuous as vol @@ -20,19 +21,15 @@ ALIVE_NODES_TOPIC, DOMAIN, MAC, + MERGE_IDS, NAME, ROOM, ROOT_TOPIC, RSSI, TIMESTAMP, - MERGE_IDS, ) -PLATFORMS: list[Platform] = [ - Platform.DEVICE_TRACKER, - Platform.SENSOR, - Platform.NUMBER -] +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.NUMBER] _LOGGER = logging.getLogger(__name__) MQTT_PAYLOAD = vol.Schema( @@ -41,7 +38,7 @@ vol.Schema( { vol.Required(RSSI): vol.Coerce(int), - vol.Optional(TIMESTAMP): vol.Coerce(int) + vol.Optional(TIMESTAMP): vol.Coerce(int), }, extra=vol.ALLOW_EXTRA, ), @@ -68,7 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: elif MERGE_IDS in entry.data: hass.config_entries.async_setup_platforms(entry, [Platform.DEVICE_TRACKER]) - return True @@ -80,7 +76,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: platforms = [Platform.DEVICE_TRACKER] - if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms) and entry.entry_id in hass.data[DOMAIN]: + if ( + unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms) + and entry.entry_id in hass.data[DOMAIN] + ): hass.data[DOMAIN].pop(entry.entry_id) if MAC in entry.data: @@ -93,29 +92,34 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class BeaconCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Class to arrange interaction with MQTT""" + """Class to arrange interaction with MQTT.""" def __init__(self, hass: HomeAssistant, data) -> None: + """Initialise coordinator.""" self.mac = data[MAC] - self.expiration_time : int - self.default_expiration_time : int = 2 + self.expiration_time: int + self.default_expiration_time: int = 2 given_name = data[NAME] if data.__contains__(NAME) else self.mac self.room_data = dict[str, int]() + self.filtered_room_data = dict[str, int]() + self.room_filters = dict[str, KalmanFilter]() self.room_expiration_timers = dict[str, asyncio.TimerHandle]() - self.room = None + self.room: str | None = None + self.last_received_adv_time = None super().__init__(hass, _LOGGER, name=given_name) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - if len(self.room_data) == 0: + if len(self.filtered_room_data) == 0: self.room = None + self.last_received_adv_time = None else: self.room = next( iter( dict( sorted( - self.room_data.items(), + self.filtered_room_data.items(), key=lambda item: item[1], reverse=True, ) @@ -125,7 +129,7 @@ async def _async_update_data(self) -> dict[str, Any]: return {**{ROOM: self.room}} async def subscribe_to_mqtt(self) -> None: - """Subscribe coordinator to MQTT messages""" + """Subscribe coordinator to MQTT messages.""" @callback async def message_received(self, msg): @@ -136,20 +140,27 @@ async def message_received(self, msg): _LOGGER.debug("Skipping update because of malformatted data: %s", error) return msg_time = data.get(TIMESTAMP) - if (msg_time is not None): + if msg_time is not None: current_time = int(time.time()) - if (current_time - msg_time >= self.get_expiration_time()): + if current_time - msg_time >= self.get_expiration_time(): _LOGGER.info("Received message with old timestamp, skipping") return + self.time_from_previous = None if self.last_received_adv_time is None else (current_time - self.last_received_adv_time) + self.last_received_adv_time = current_time + room_topic = msg.topic.split("/")[2] await self.schedule_data_expiration(room_topic) - self.room_data[room_topic] = data.get(RSSI) + + rssi = data.get(RSSI) + self.room_data[room_topic] = rssi + self.filtered_room_data[room_topic] = self.get_filtered_value(room_topic, rssi) + await self.async_refresh() async def schedule_data_expiration(self, room): - """Start timer for data expiration for certain room""" + """Start timer for data expiration for certain room.""" if room in self.room_expiration_timers: self.room_expiration_timers[room].cancel() loop = asyncio.get_event_loop() @@ -159,20 +170,95 @@ async def schedule_data_expiration(self, room): ) self.room_expiration_timers[room] = timer + def get_filtered_value(self, room, value) -> int: + """Apply Kalman filter""" + k_filter: KalmanFilter + if room in self.room_filters: + k_filter = self.room_filters[room] + else: + k_filter = KalmanFilter(0.01, 5) + self.room_filters[room] = k_filter + return int(k_filter.filter(value)) + def get_expiration_time(self): - """Calculate current expiration delay""" + """Calculate current expiration delay.""" return getattr(self, "expiration_time", self.default_expiration_time) * 60 async def expire_data(self, room): - """Set data for certain room expired""" + """Set data for certain room expired.""" del self.room_data[room] + del self.filtered_room_data[room] + del self.room_filters[room] del self.room_expiration_timers[room] await self.async_refresh() - async def on_expiration_time_changed(self, new_time : int): - """Respond to expiration time changed by user""" + async def on_expiration_time_changed(self, new_time: int): + """Respond to expiration time changed by user.""" if new_time is None: return self.expiration_time = new_time for room in self.room_expiration_timers.keys(): await self.schedule_data_expiration(room) + +class KalmanFilter: + """Filtering RSSI data.""" + + cov = float('nan') + x = float('nan') + + def __init__(self, R, Q): + """ + Constructor + :param R: Process Noise + :param Q: Measurement Noise + """ + self.A = 1 + self.B = 0 + self.C = 1 + + self.R = R + self.Q = Q + + def filter(self, measurement): + """ + Filters a measurement + :param measurement: The measurement value to be filtered + :return: The filtered value + """ + u = 0 + if math.isnan(self.x): + self.x = (1 / self.C) * measurement + self.cov = (1 / self.C) * self.Q * (1 / self.C) + else: + pred_x = (self.A * self.x) + (self.B * u) + pred_cov = ((self.A * self.cov) * self.A) + self.R + + # Kalman Gain + K = pred_cov * self.C * (1 / ((self.C * pred_cov * self.C) + self.Q)); + + # Correction + self.x = pred_x + K * (measurement - (self.C * pred_x)); + self.cov = pred_cov - (K * self.C * pred_cov); + + return self.x + + def last_measurement(self): + """ + Returns the last measurement fed into the filter + :return: The last measurement fed into the filter + """ + return self.x + + def set_measurement_noise(self, noise): + """ + Sets measurement noise + :param noise: The new measurement noise + """ + self.Q = noise + + def set_process_noise(self, noise): + """ + Sets process noise + :param noise: The new process noise + """ + self.R = noise \ No newline at end of file diff --git a/custom_components/format_ble_tracker/common.py b/custom_components/format_ble_tracker/common.py index b2d5a55..e5123f0 100644 --- a/custom_components/format_ble_tracker/common.py +++ b/custom_components/format_ble_tracker/common.py @@ -1,11 +1,13 @@ -"""Common values""" +"""Common values.""" from homeassistant.helpers.device_registry import format_mac -from .const import DOMAIN -from .__init__ import BeaconCoordinator from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .__init__ import BeaconCoordinator +from .const import DOMAIN + + class BeaconDeviceEntity(CoordinatorEntity[BeaconCoordinator]): - """Base device class""" + """Base device class.""" def __init__(self, coordinator: BeaconCoordinator) -> None: """Initialize.""" @@ -14,10 +16,11 @@ def __init__(self, coordinator: BeaconCoordinator) -> None: @property def device_info(self): + """Device info creation.""" return { "identifiers": { # MAC addresses are unique identifiers within a specific domain (DOMAIN, self.formatted_mac_address) }, "name": self.coordinator.name, - } \ No newline at end of file + } diff --git a/custom_components/format_ble_tracker/config_flow.py b/custom_components/format_ble_tracker/config_flow.py index 67d796a..c70c4c2 100644 --- a/custom_components/format_ble_tracker/config_flow.py +++ b/custom_components/format_ble_tracker/config_flow.py @@ -1,19 +1,26 @@ """Config flow for Format BLE Tracker integration.""" from __future__ import annotations -from html import entities -from operator import mul import re from typing import Any import voluptuous as vol -from homeassistant.helpers import selector from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector -from .const import DOMAIN, MAC, MAC_REGEX, UUID_REGEX, NAME, MERGE_IDS, MERGE_LOGIC, HOME_WHEN_AND, HOME_WHEN_OR - +from .const import ( + DOMAIN, + AWAY_WHEN_OR, + AWAY_WHEN_AND, + MAC, + MAC_REGEX, + MERGE_IDS, + MERGE_LOGIC, + NAME, + UUID_REGEX, +) STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -29,27 +36,29 @@ CONF_ACTIONS = { CONF_ADD_DEVICE: "Add new beacon", - CONF_MERGE_DEVICES: "Combine trackers" + CONF_MERGE_DEVICES: "Combine trackers", } CHOOSE_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS), + vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS), } ) CONF_MERGE_LOGIC = { - HOME_WHEN_AND: "Show as home, when ALL trackers are home", - HOME_WHEN_OR: "Show as home, when ANY tracker is home" + AWAY_WHEN_OR: "Show as away, when ANY tracker is away", + AWAY_WHEN_AND: "Show as away, when ALL trackers are away" } MERGE_SCHEMA = vol.Schema( { vol.Required(NAME): str, - vol.Required(MERGE_LOGIC, default=HOME_WHEN_AND): vol.In(CONF_MERGE_LOGIC), + vol.Required(MERGE_LOGIC, default=AWAY_WHEN_OR): vol.In(CONF_MERGE_LOGIC), vol.Required(CONF_ENTITIES): selector.EntitySelector( - selector.EntitySelectorConfig(integration="format_ble_tracker", domain="device_tracker", multiple=True), - ) + selector.EntitySelectorConfig( + integration="format_ble_tracker", domain="device_tracker", multiple=True + ), + ), } ) @@ -64,20 +73,17 @@ async def async_step_user( ) -> FlowResult: """Handle the initial step.""" if user_input is None or CONF_ACTION not in user_input: - return self.async_show_form( - step_id="user", data_schema=CHOOSE_DATA_SCHEMA - ) + return self.async_show_form(step_id="user", data_schema=CHOOSE_DATA_SCHEMA) - if (user_input[CONF_ACTION] == CONF_ADD_DEVICE): + if user_input[CONF_ACTION] == CONF_ADD_DEVICE: return await self.async_step_add_device(user_input) return await self.async_step_combine_devices(user_input) - - - - async def async_step_add_device(self, user_input: dict[str, Any] | None = None) -> FlowResult: - """Add new beacon device""" + async def async_step_add_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add new beacon device.""" if user_input is None or MAC not in user_input: return self.async_show_form( step_id="add_device", data_schema=STEP_USER_DATA_SCHEMA @@ -90,13 +96,14 @@ async def async_step_add_device(self, user_input: dict[str, Any] | None = None) given_name = user_input[NAME] if NAME in user_input else mac - return self.async_create_entry(title=given_name, data={MAC: mac, NAME: given_name}) - - - + return self.async_create_entry( + title=given_name, data={MAC: mac, NAME: given_name} + ) - async def async_step_combine_devices(self, user_input: dict[str, Any] | None = None) -> FlowResult: - """Add new combined tracker""" + async def async_step_combine_devices( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add new combined tracker.""" if user_input is None or CONF_ENTITIES not in user_input: return self.async_show_form( step_id="combine_devices", data_schema=MERGE_SCHEMA @@ -105,4 +112,11 @@ async def async_step_combine_devices(self, user_input: dict[str, Any] | None = N if len(entities) < 2: return self.async_abort(reason="less_than_two_children") given_name = user_input[NAME] - return self.async_create_entry(title=given_name, data={NAME: given_name, MERGE_LOGIC: user_input[MERGE_LOGIC], MERGE_IDS: entities}) + return self.async_create_entry( + title=given_name, + data={ + NAME: given_name, + MERGE_LOGIC: user_input[MERGE_LOGIC], + MERGE_IDS: entities, + }, + ) diff --git a/custom_components/format_ble_tracker/const.py b/custom_components/format_ble_tracker/const.py index 9be6041..b2b33fd 100644 --- a/custom_components/format_ble_tracker/const.py +++ b/custom_components/format_ble_tracker/const.py @@ -17,5 +17,5 @@ ENTITY_ID = "entity_id" NEW_STATE = "new_state" MERGE_LOGIC = "merge_logic" -HOME_WHEN_AND = "home_when_and" -HOME_WHEN_OR = "home_when_or" +AWAY_WHEN_OR = "away_when_or" +AWAY_WHEN_AND = "away_when_and" diff --git a/custom_components/format_ble_tracker/device_tracker.py b/custom_components/format_ble_tracker/device_tracker.py index 66f5e4c..dd53060 100644 --- a/custom_components/format_ble_tracker/device_tracker.py +++ b/custom_components/format_ble_tracker/device_tracker.py @@ -1,17 +1,26 @@ -"""Device tracker implementation""" +"""Device tracker implementation.""" +import logging + from homeassistant.components import device_tracker from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant, Event, callback +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from .common import BeaconDeviceEntity from .__init__ import BeaconCoordinator -from .const import DOMAIN, NAME, MERGE_IDS, ENTITY_ID, NEW_STATE, MERGE_LOGIC, HOME_WHEN_AND, HOME_WHEN_OR - -import logging +from .common import BeaconDeviceEntity +from .const import ( + DOMAIN, + ENTITY_ID, + AWAY_WHEN_OR, + AWAY_WHEN_AND, + MERGE_IDS, + MERGE_LOGIC, + NAME, + NEW_STATE, +) _LOGGER = logging.getLogger(__name__) @@ -25,7 +34,17 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([BleDeviceTracker(coordinator)], True) elif MERGE_IDS in entry.data: - async_add_entities([MergedDeviceTracker(entry.entry_id, entry.data[NAME], entry.data[MERGE_LOGIC], entry.data[MERGE_IDS])], True) + async_add_entities( + [ + MergedDeviceTracker( + entry.entry_id, + entry.data[NAME], + entry.data[MERGE_LOGIC], + entry.data[MERGE_IDS], + ) + ], + True, + ) class BleDeviceTracker(BeaconDeviceEntity, BaseTrackerEntity): @@ -57,6 +76,7 @@ def _handle_coordinator_update(self) -> None: """Handle data update.""" self.async_write_ha_state() + class MergedDeviceTracker(BaseTrackerEntity): """Define an device tracker entity.""" @@ -71,7 +91,7 @@ def __init__(self, entry_id, name, merge_logic, merge_ids) -> None: self.logic = merge_logic self.ids = merge_ids self.states = {key: None for key in merge_ids} - self.merged_state = None + self.merged_state = STATE_UNKNOWN @property def source_type(self) -> str: @@ -98,8 +118,9 @@ async def async_added_to_hass(self) -> None: def _async_state_changed_listener(event: Event) -> None: """Handle updates.""" if ENTITY_ID in event.data and NEW_STATE in event.data: - self.on_state_changed(event.data[ENTITY_ID], event.data[NEW_STATE].state) - self.async_write_ha_state() + self.on_state_changed( + event.data[ENTITY_ID], event.data[NEW_STATE].state + ) self.async_on_remove( async_track_state_change_event( @@ -108,22 +129,24 @@ def _async_state_changed_listener(event: Event) -> None: ) def on_state_changed(self, entity_id, new_state): - """Calculate new state""" + """Calculate new state.""" self.states[entity_id] = new_state states = self.states.values() - if None in states: - self.merged_state = None + if STATE_HOME not in states and STATE_NOT_HOME not in states: + self.merged_state = STATE_UNKNOWN else: - if self.logic == HOME_WHEN_AND: + if self.logic == AWAY_WHEN_OR: if STATE_NOT_HOME in states: self.merged_state = STATE_NOT_HOME else: self.merged_state = STATE_HOME - elif self.logic == HOME_WHEN_OR: + elif self.logic == AWAY_WHEN_AND: if STATE_HOME in states: self.merged_state = STATE_HOME else: self.merged_state = STATE_NOT_HOME + self.async_write_ha_state() + @property def extra_state_attributes(self): @@ -132,9 +155,11 @@ def extra_state_attributes(self): return None attr = {} attr["included_trackers"] = self.ids - if self.logic == HOME_WHEN_AND: - logic = "All are home" + if self.logic == AWAY_WHEN_OR: + logic = "Home when all are home" + elif self.logic == AWAY_WHEN_AND: + logic = "Home when any is home" else: - logic = "Any is home" + logic = None attr["show_home_when"] = logic - return attr \ No newline at end of file + return attr diff --git a/custom_components/format_ble_tracker/manifest.json b/custom_components/format_ble_tracker/manifest.json index ff34fad..143a16f 100644 --- a/custom_components/format_ble_tracker/manifest.json +++ b/custom_components/format_ble_tracker/manifest.json @@ -1,7 +1,7 @@ { "domain": "format_ble_tracker", "name": "Format BLE Tracker", - "version": "0.0.5", + "version": "0.0.6", "config_flow": true, "documentation": "https://github.com/formatBCE/Format-BLE-Tracker/blob/main/README.md", "issue_tracker": "https://github.com/formatBCE/Format-BLE-Tracker/issues", diff --git a/custom_components/format_ble_tracker/number.py b/custom_components/format_ble_tracker/number.py index 641a1f1..5204130 100644 --- a/custom_components/format_ble_tracker/number.py +++ b/custom_components/format_ble_tracker/number.py @@ -1,12 +1,12 @@ -"""Expiration setter implementation""" +"""Expiration setter implementation.""" from homeassistant.components import input_number from homeassistant.components.number import NumberEntity, NumberMode, RestoreNumber from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import BeaconDeviceEntity from .__init__ import BeaconCoordinator +from .common import BeaconDeviceEntity from .const import DOMAIN @@ -37,7 +37,7 @@ def __init__(self, coordinator: BeaconCoordinator) -> None: self.entity_id = f"{input_number.DOMAIN}.{self._attr_unique_id}" async def async_added_to_hass(self): - """Entity has been added to hass, restoring state""" + """Entity has been added to hass, restoring state.""" restored = await self.async_get_last_number_data() native_value = 2 if restored is None else restored.native_value await self.update_value(native_value) @@ -47,10 +47,8 @@ async def async_set_native_value(self, value: float) -> None: val = min(10, max(1, int(value))) await self.update_value(val) - async def update_value(self, value: int): - """Set value to HA and coordinator""" + """Set value to HA and coordinator.""" self._attr_native_value = value await self.coordinator.on_expiration_time_changed(value) self.async_write_ha_state() - diff --git a/custom_components/format_ble_tracker/sensor.py b/custom_components/format_ble_tracker/sensor.py index 14fc5c1..9302bef 100644 --- a/custom_components/format_ble_tracker/sensor.py +++ b/custom_components/format_ble_tracker/sensor.py @@ -1,12 +1,12 @@ -"""Room sensor implementation""" +"""Room sensor implementation.""" from homeassistant.components import sensor from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import BeaconDeviceEntity from .__init__ import BeaconCoordinator +from .common import BeaconDeviceEntity from .const import DOMAIN @@ -45,6 +45,6 @@ def extra_state_attributes(self): return None attr = {} attr["current_rooms"] = {} - for key, value in self.coordinator.room_data.items(): + for key, value in self.coordinator.filtered_room_data.items(): attr["current_rooms"][key] = f"{value} dBm" return attr diff --git a/custom_components/format_ble_tracker/strings.json b/custom_components/format_ble_tracker/strings.json index c135ce9..1cad50b 100644 --- a/custom_components/format_ble_tracker/strings.json +++ b/custom_components/format_ble_tracker/strings.json @@ -1,30 +1,30 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured", - "not_id": "Entered string does not correspond to MAC address or UUID", - "less_than_two_children": "At least two entities required to combine" - }, - "error": { - "unknown": "Unexpected error" - }, - "step": { - "user": { - "title": "Choose what you want to do" - }, - "add_device": { - "title": "Fill in tracker device data", - "data": { - "mac": "Device MAC address (e.g. 12:34:56:78:90:ab) or beacon UUID (e.g. abcdef12-3456-7890-abcd-ef1234567890)", - "name": "Friendly name for device" - } - }, - "combine_devices": { - "title": "Select trackers to combine", - "data": { - "name": "Name for tracker entity" - } - } + "config": { + "abort": { + "already_configured": "Device is already configured", + "not_id": "Entered string does not correspond to MAC address or UUID", + "less_than_two_children": "At least two entities required to combine" + }, + "error": { + "unknown": "Unexpected error" + }, + "step": { + "user": { + "title": "Choose what you want to do" + }, + "add_device": { + "title": "Fill in tracker device data", + "data": { + "mac": "Device MAC address (e.g. 12:34:56:78:90:ab) or beacon UUID (e.g. abcdef12-3456-7890-abcd-ef1234567890)", + "name": "Friendly name for device" } + }, + "combine_devices": { + "title": "Select trackers to combine", + "data": { + "name": "Name for tracker entity" + } + } } -} \ No newline at end of file + } +}