diff --git a/README.md b/README.md index 5e156f7..5f01710 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Format-BLE-Tracker -Custom integration foor tracking BLE devices (bluetoooth tags, smartwatches, phones with Home Assistant Companion app) in Home Assistant +Custom integration for tracking BLE devices (bluetooth tags, smartwatches, phones with Home Assistant Companion app) in Home Assistant Requires ESP32 tracking nodes installation: see https://github.com/formatBCE/ESP32_BLE_presense. # Prerequisites: @@ -11,8 +11,8 @@ Requires ESP32 tracking nodes installation: see https://github.com/formatBCE/ESP 2. Make sure you have 2.4 GHz WiFi network available in all places, where you plan to place your tracking nodes. 3. Prepare and place ESP32 device(s) according to instructions at https://github.com/formatBCE/ESP32_BLE_presense. 4. Write down MAC addresses or Proximity UUIDs for all devices that you want to track: - - for dumb beacons (e.g. Tile) you can find MAC address in official app, or use amy Bluetooth tracker app on your phone too scrap one; - - for smart beacons (e.g. Android phone with Home Assistant Companion application and BLE tracker enabled), you will need to copy Proximity UUID, because device MAC address is not exposed to public. + - for dumb beacons (e.g. Tile) you can find MAC address in official app, or use any Bluetooth tracker app on your phone to scrap one; + - for smart beacons (e.g. Android phone with Home Assistant Companion application and BLE tracker enabled), you will need to copy Proximity UUID, because device MAC address is not exposed to public. # Installation: @@ -22,19 +22,26 @@ Requires ESP32 tracking nodes installation: see https://github.com/formatBCE/ESP 2. Manual: Copy the format_ble_tracker folder and all of its contents into your Home Assistant's custom_components folder. This is often located inside of your /config folder. If you are running Hass.io, use SAMBA to copy the folder over. If you are running Home Assistant Supervised, the custom_components folder might be located at /usr/share/hassio/homeassistant. It is possible that your custom_components folder does not exist. If that is the case, create the folder in the proper location, and then copy the format_ble_tracker folder and all of its contents inside the newly created custom_components folder. - After it's done, restart HomeAssistant and relooad browser page forcefully (Ctrl+F5). + After it's done, restart HomeAssistant and reload browser page forcefully (Ctrl+F5). # Usage: In Home Assistant, go to "Devices and Services" -> "Add Integration". Search for "Format BLE Tracker" and click on it. -In the configuratioon dialog, insert MAC address or UUID of tag, and (optionally) enter friendly name for this device. +## Adding new beacon device +In the configuration dialog, insert MAC address or UUID of tag, and (optionally) enter friendly name for this device. +## Creating combined tracker +(Will be useful, if you need to customize behavior of device trackers working together. E.g. i have tags on my key chain and in my wallet - and i want Home Assistant to show myself away, if either of this device trackers is not_home. +Currently it's impossible without relying on custom sensors (which are NOT device_tracker) or MQTT device_tracker with help of NodeRED or automation. Combined tracker will use your logic to show itself home/not_home accordingly.) +Choose name for new virtual tracker, logic (all home or either home) and pick trackers, that will be combined in new entity. All communication between tracker nodes and created device are automatic. -Integration will create device with three entities: +Integration will create device with three entities for beacon: 1. Device Tracker entity for device. Will show Home status for this tag, if tag is visible for at least one of tracking nodes, or Away status. 2. Sensor with current closest node name for this device (basically, current room name). 3. Input slider for tuning data expiration period (from 1 minute to 10 minutes). This will affect the time from last visibility event till setting up Away mode. Use greater values, if you experience often changes Home to Away and back. By default set to 2 minutes. +For combined tracker, new Device Tracker entity will be created. + Additionally, for each tracker node (ESP32 device) there will be device with current IP sensor created. It can be useful to determine availability of node. Also, you can visit device config web page right from Home Assistant device page. diff --git a/custom_components/format_ble_tracker/__init__.py b/custom_components/format_ble_tracker/__init__.py index 1825b53..f4dce4a 100644 --- a/custom_components/format_ble_tracker/__init__.py +++ b/custom_components/format_ble_tracker/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from curses import has_key +from asyncio import events import json import time import logging @@ -25,6 +25,7 @@ ROOT_TOPIC, RSSI, TIMESTAMP, + MERGE_IDS, ) PLATFORMS: list[Platform] = [ @@ -53,30 +54,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - coordinator = BeaconCoordinator(hass, entry.data) - - mac = entry.data[MAC] - state_topic = ROOT_TOPIC + "/" + mac + "/+" - _LOGGER.info("Subscribing to %s", state_topic) - await mqtt.async_subscribe(hass, state_topic, coordinator.message_received, 1) - alive_topic = ALIVE_NODES_TOPIC + "/" + mac - _LOGGER.info("Notifying alive to %s", alive_topic) - await mqtt.async_publish(hass, alive_topic, True, 1, retain=True) + if MAC in entry.data: + mac = entry.data[MAC] + coordinator = BeaconCoordinator(hass, entry.data) + state_topic = ROOT_TOPIC + "/" + mac + "/+" + _LOGGER.info("Subscribing to %s", state_topic) + await mqtt.async_subscribe(hass, state_topic, coordinator.message_received, 1) + alive_topic = ALIVE_NODES_TOPIC + "/" + mac + _LOGGER.info("Notifying alive to %s", alive_topic) + await mqtt.async_publish(hass, alive_topic, True, 1, retain=True) + hass.data[DOMAIN][entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + elif MERGE_IDS in entry.data: + hass.config_entries.async_setup_platforms(entry, [Platform.DEVICE_TRACKER]) - hass.data[DOMAIN][entry.entry_id] = coordinator - hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + + if entry.entry_id in hass.data[DOMAIN]: + platforms = PLATFORMS + 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]: hass.data[DOMAIN].pop(entry.entry_id) + if MAC in entry.data: mac = entry.data[MAC] alive_topic = ALIVE_NODES_TOPIC + "/" + mac - _LOGGER.info("Notifying alive to %s", alive_topic) + _LOGGER.info("Notifying dead to %s", alive_topic) await mqtt.async_publish(hass, alive_topic, "", 1, retain=True) return unload_ok diff --git a/custom_components/format_ble_tracker/config_flow.py b/custom_components/format_ble_tracker/config_flow.py index 1c9ef45..67d796a 100644 --- a/custom_components/format_ble_tracker/config_flow.py +++ b/custom_components/format_ble_tracker/config_flow.py @@ -1,15 +1,18 @@ """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 .const import DOMAIN, MAC, MAC_REGEX, UUID_REGEX, NAME +from .const import DOMAIN, MAC, MAC_REGEX, UUID_REGEX, NAME, MERGE_IDS, MERGE_LOGIC, HOME_WHEN_AND, HOME_WHEN_OR STEP_USER_DATA_SCHEMA = vol.Schema( @@ -19,6 +22,37 @@ } ) +CONF_ACTION = "conf_action" +CONF_ADD_DEVICE = "add_device" +CONF_MERGE_DEVICES = "merge_devices" +CONF_ENTITIES = "conf_entities" + +CONF_ACTIONS = { + CONF_ADD_DEVICE: "Add new beacon", + CONF_MERGE_DEVICES: "Combine trackers" +} + +CHOOSE_DATA_SCHEMA = vol.Schema( + { + 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" +} + +MERGE_SCHEMA = vol.Schema( + { + vol.Required(NAME): str, + vol.Required(MERGE_LOGIC, default=HOME_WHEN_AND): vol.In(CONF_MERGE_LOGIC), + vol.Required(CONF_ENTITIES): selector.EntitySelector( + selector.EntitySelectorConfig(integration="format_ble_tracker", domain="device_tracker", multiple=True), + ) + } +) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Format BLE Tracker.""" @@ -29,9 +63,24 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - if user_input is None: + if user_input is None or CONF_ACTION not in user_input: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", data_schema=CHOOSE_DATA_SCHEMA + ) + + 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""" + 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 ) mac = user_input[MAC].strip().upper() if not re.match(MAC_REGEX, mac) and not re.match(UUID_REGEX, mac): @@ -41,6 +90,19 @@ async def async_step_user( 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""" + 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 + ) + entities = user_input[CONF_ENTITIES] + 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}) diff --git a/custom_components/format_ble_tracker/const.py b/custom_components/format_ble_tracker/const.py index 8719245..9be6041 100644 --- a/custom_components/format_ble_tracker/const.py +++ b/custom_components/format_ble_tracker/const.py @@ -13,3 +13,9 @@ ALIVE_NODES_TOPIC = ROOT_TOPIC + "/alive" RSSI = "rssi" TIMESTAMP = "timestamp" +MERGE_IDS = "merge_ids" +ENTITY_ID = "entity_id" +NEW_STATE = "new_state" +MERGE_LOGIC = "merge_logic" +HOME_WHEN_AND = "home_when_and" +HOME_WHEN_OR = "home_when_or" diff --git a/custom_components/format_ble_tracker/device_tracker.py b/custom_components/format_ble_tracker/device_tracker.py index 148686e..66f5e4c 100644 --- a/custom_components/format_ble_tracker/device_tracker.py +++ b/custom_components/format_ble_tracker/device_tracker.py @@ -3,12 +3,17 @@ 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, callback +from homeassistant.core import HomeAssistant, Event, 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 +from .const import DOMAIN, NAME, MERGE_IDS, ENTITY_ID, NEW_STATE, MERGE_LOGIC, HOME_WHEN_AND, HOME_WHEN_OR + +import logging + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -16,8 +21,11 @@ async def async_setup_entry( ) -> None: """Add device tracker entities from a config_entry.""" - coordinator: BeaconCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([BleDeviceTracker(coordinator)], True) + if entry.entry_id in hass.data[DOMAIN]: + 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) class BleDeviceTracker(BeaconDeviceEntity, BaseTrackerEntity): @@ -49,7 +57,84 @@ def _handle_coordinator_update(self) -> None: """Handle data update.""" self.async_write_ha_state() +class MergedDeviceTracker(BaseTrackerEntity): + """Define an device tracker entity.""" + + _attr_should_poll = False + + def __init__(self, entry_id, name, merge_logic, merge_ids) -> None: + """Initialize.""" + super().__init__() + self._attr_name = name + self._attr_unique_id = entry_id + self.entity_id = f"{device_tracker.DOMAIN}.combined_{self._attr_unique_id}" + self.logic = merge_logic + self.ids = merge_ids + self.states = {key: None for key in merge_ids} + self.merged_state = None + + @property + def source_type(self) -> str: + """Return the source type, eg gps or router, of the device.""" + return "bluetooth_le" + + @property + def state(self) -> str: + """Return the state of the device.""" + return self.merged_state + async def async_added_to_hass(self) -> None: - """Subscribe to MQTT events.""" - # await self.coordinator.async_on_entity_added_to_ha() - return await super().async_added_to_hass() + """Register callbacks.""" + + for ent_id in self.ids: + state_obj = self.hass.states.get(ent_id) + if state_obj is None: + state = None + else: + state = state_obj.state + self.on_state_changed(ent_id, state) + + @callback + 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.async_on_remove( + async_track_state_change_event( + self.hass, self.ids, _async_state_changed_listener + ) + ) + + def on_state_changed(self, entity_id, new_state): + """Calculate new state""" + self.states[entity_id] = new_state + states = self.states.values() + if None in states: + self.merged_state = None + else: + if self.logic == HOME_WHEN_AND: + if STATE_NOT_HOME in states: + self.merged_state = STATE_NOT_HOME + else: + self.merged_state = STATE_HOME + elif self.logic == HOME_WHEN_OR: + if STATE_HOME in states: + self.merged_state = STATE_HOME + else: + self.merged_state = STATE_NOT_HOME + + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + if len(self.ids) == 0: + return None + attr = {} + attr["included_trackers"] = self.ids + if self.logic == HOME_WHEN_AND: + logic = "All are home" + else: + logic = "Any is home" + attr["show_home_when"] = logic + return attr \ No newline at end of file diff --git a/custom_components/format_ble_tracker/manifest.json b/custom_components/format_ble_tracker/manifest.json index 0ea309a..ff34fad 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.4", + "version": "0.0.5", "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/sensor.py b/custom_components/format_ble_tracker/sensor.py index b3dbf1d..14fc5c1 100644 --- a/custom_components/format_ble_tracker/sensor.py +++ b/custom_components/format_ble_tracker/sensor.py @@ -40,7 +40,7 @@ def _handle_coordinator_update(self) -> None: @property def extra_state_attributes(self): - """Return the state attributes of the humidifier.""" + """Return the state attributes of the sensor.""" if len(self.coordinator.room_data) == 0: return None attr = {} diff --git a/custom_components/format_ble_tracker/strings.json b/custom_components/format_ble_tracker/strings.json index a5b974f..c135ce9 100644 --- a/custom_components/format_ble_tracker/strings.json +++ b/custom_components/format_ble_tracker/strings.json @@ -2,18 +2,28 @@ "config": { "abort": { "already_configured": "Device is already configured", - "not_id": "Entered string does not correspond to MAC address or UUID" + "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" + } } } } diff --git a/custom_components/format_ble_tracker/translations/en.json b/custom_components/format_ble_tracker/translations/en.json index a5b974f..c135ce9 100644 --- a/custom_components/format_ble_tracker/translations/en.json +++ b/custom_components/format_ble_tracker/translations/en.json @@ -2,18 +2,28 @@ "config": { "abort": { "already_configured": "Device is already configured", - "not_id": "Entered string does not correspond to MAC address or UUID" + "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" + } } } }