Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
v 0.0.5 - combined trackers
Browse files Browse the repository at this point in the history
  • Loading branch information
formatBCE committed Aug 10, 2022
1 parent 6361f0a commit 3f32b64
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 37 deletions.
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:

Expand All @@ -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.

38 changes: 24 additions & 14 deletions custom_components/format_ble_tracker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +25,7 @@
ROOT_TOPIC,
RSSI,
TIMESTAMP,
MERGE_IDS,
)

PLATFORMS: list[Platform] = [
Expand Down Expand Up @@ -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
Expand Down
74 changes: 68 additions & 6 deletions custom_components/format_ble_tracker/config_flow.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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."""
Expand All @@ -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):
Expand All @@ -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})
6 changes: 6 additions & 0 deletions custom_components/format_ble_tracker/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
99 changes: 92 additions & 7 deletions custom_components/format_ble_tracker/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,29 @@
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(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> 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):
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion custom_components/format_ble_tracker/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/format_ble_tracker/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
Loading

0 comments on commit 3f32b64

Please sign in to comment.