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

Commit 72d2751

Browse files
committed
First stable version. Works with ESP32 v0.0.1
1 parent a1edb0f commit 72d2751

File tree

10 files changed

+449
-0
lines changed

10 files changed

+449
-0
lines changed

__init__.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""The Format BLE Tracker integration."""
2+
from __future__ import annotations
3+
4+
import asyncio
5+
from curses import has_key
6+
import json
7+
import logging
8+
from typing import Any
9+
10+
import voluptuous as vol
11+
12+
from homeassistant.components import mqtt
13+
from homeassistant.config_entries import ConfigEntry
14+
from homeassistant.const import Platform
15+
from homeassistant.core import HomeAssistant, callback
16+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
17+
18+
from .const import (
19+
ALIVE_NODES_TOPIC,
20+
DOMAIN,
21+
MAC,
22+
NAME,
23+
ROOM,
24+
ROOT_TOPIC,
25+
RSSI,
26+
)
27+
28+
PLATFORMS: list[Platform] = [
29+
Platform.DEVICE_TRACKER,
30+
Platform.SENSOR,
31+
Platform.NUMBER
32+
]
33+
_LOGGER = logging.getLogger(__name__)
34+
35+
MQTT_PAYLOAD = vol.Schema(
36+
vol.All(
37+
json.loads,
38+
vol.Schema(
39+
{
40+
vol.Required(RSSI): vol.Coerce(int),
41+
},
42+
extra=vol.ALLOW_EXTRA,
43+
),
44+
)
45+
)
46+
47+
48+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
49+
"""Set up Format BLE Tracker from a config entry."""
50+
51+
hass.data.setdefault(DOMAIN, {})
52+
53+
coordinator = BeaconCoordinator(hass, entry.data)
54+
55+
mac = entry.data[MAC]
56+
state_topic = ROOT_TOPIC + "/" + mac + "/+"
57+
_LOGGER.info("Subscribing to %s", state_topic)
58+
await mqtt.async_subscribe(hass, state_topic, coordinator.message_received, 1)
59+
alive_topic = ALIVE_NODES_TOPIC + "/" + mac
60+
_LOGGER.info("Notifying alive to %s", alive_topic)
61+
await mqtt.async_publish(hass, alive_topic, True, 1, retain=True)
62+
63+
hass.data[DOMAIN][entry.entry_id] = coordinator
64+
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
65+
66+
return True
67+
68+
69+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
70+
"""Unload a config entry."""
71+
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
72+
hass.data[DOMAIN].pop(entry.entry_id)
73+
74+
mac = entry.data[MAC]
75+
alive_topic = ALIVE_NODES_TOPIC + "/" + mac
76+
_LOGGER.info("Notifying alive to %s", alive_topic)
77+
await mqtt.async_publish(hass, alive_topic, "", 1, retain=True)
78+
79+
return unload_ok
80+
81+
82+
class BeaconCoordinator(DataUpdateCoordinator[dict[str, Any]]):
83+
"""Class to arrange interaction with MQTT"""
84+
85+
def __init__(self, hass: HomeAssistant, data) -> None:
86+
self.mac = data[MAC]
87+
self.expiration_time : int
88+
self.default_expiration_time : int = 2
89+
given_name = data[NAME] if data.__contains__(NAME) else self.mac
90+
self.room_data = dict[str, int]()
91+
self.room_expiration_timers = dict[str, asyncio.TimerHandle]()
92+
self.room = None
93+
94+
super().__init__(hass, _LOGGER, name=given_name)
95+
96+
async def _async_update_data(self) -> dict[str, Any]:
97+
"""Update data via library."""
98+
_LOGGER.error("Room data: %s", str(self.room_data))
99+
if len(self.room_data) == 0:
100+
self.room = None
101+
else:
102+
self.room = next(
103+
iter(
104+
dict(
105+
sorted(
106+
self.room_data.items(),
107+
key=lambda item: item[1],
108+
reverse=True,
109+
)
110+
)
111+
)
112+
)
113+
return {**{ROOM: self.room}}
114+
115+
async def subscribe_to_mqtt(self) -> None:
116+
"""Subscribe coordinator to MQTT messages"""
117+
118+
@callback
119+
async def message_received(self, msg):
120+
"""Handle new MQTT messages."""
121+
try:
122+
data = MQTT_PAYLOAD(msg.payload)
123+
except vol.MultipleInvalid as error:
124+
_LOGGER.debug("Skipping update because of malformatted data: %s", error)
125+
return
126+
room_topic = msg.topic.split("/")[2]
127+
128+
await self.schedule_data_expiration(room_topic)
129+
self.room_data[room_topic] = data.get(RSSI)
130+
await self.async_refresh()
131+
132+
async def schedule_data_expiration(self, room):
133+
"""Start timer for data expiration for certain room"""
134+
if room in self.room_expiration_timers:
135+
self.room_expiration_timers[room].cancel()
136+
loop = asyncio.get_event_loop()
137+
timer = loop.call_later(
138+
(self.expiration_time if self.expiration_time else self.default_expiration_time) * 60,
139+
lambda: asyncio.ensure_future(self.expire_data(room)),
140+
)
141+
self.room_expiration_timers[room] = timer
142+
143+
async def expire_data(self, room):
144+
"""Set data for certain room expired"""
145+
del self.room_data[room]
146+
del self.room_expiration_timers[room]
147+
await self.async_refresh()
148+
149+
async def on_expiration_time_changed(self, new_time : int):
150+
"""Respond to expiration time changed by user"""
151+
if new_time is None:
152+
return
153+
self.expiration_time = new_time
154+
for room in self.room_expiration_timers.keys():
155+
await self.schedule_data_expiration(room)

common.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Common values"""
2+
from homeassistant.helpers.device_registry import format_mac
3+
from .const import DOMAIN
4+
from .__init__ import BeaconCoordinator
5+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
6+
7+
class BeaconDeviceEntity(CoordinatorEntity[BeaconCoordinator]):
8+
"""Base device class"""
9+
10+
def __init__(self, coordinator: BeaconCoordinator) -> None:
11+
"""Initialize."""
12+
super().__init__(coordinator)
13+
self.formatted_mac_address = format_mac(coordinator.mac)
14+
15+
@property
16+
def device_info(self):
17+
return {
18+
"identifiers": {
19+
# MAC addresses are unique identifiers within a specific domain
20+
(DOMAIN, self.formatted_mac_address)
21+
},
22+
"name": self.coordinator.name,
23+
}

config_flow.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Config flow for Format BLE Tracker integration."""
2+
from __future__ import annotations
3+
4+
import re
5+
from typing import Any
6+
7+
import voluptuous as vol
8+
9+
from homeassistant import config_entries
10+
from homeassistant.data_entry_flow import FlowResult
11+
12+
from .const import DOMAIN, MAC, MAC_REGEX, UUID_REGEX, NAME
13+
14+
15+
STEP_USER_DATA_SCHEMA = vol.Schema(
16+
{
17+
vol.Required(MAC): str,
18+
vol.Optional(NAME): str,
19+
}
20+
)
21+
22+
23+
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
24+
"""Handle a config flow for Format BLE Tracker."""
25+
26+
VERSION = 1
27+
28+
async def async_step_user(
29+
self, user_input: dict[str, Any] | None = None
30+
) -> FlowResult:
31+
"""Handle the initial step."""
32+
if user_input is None:
33+
return self.async_show_form(
34+
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
35+
)
36+
mac = user_input[MAC].strip().upper()
37+
if not re.match(MAC_REGEX, mac) and not re.match(UUID_REGEX, mac):
38+
return self.async_abort(reason="not_id")
39+
await self.async_set_unique_id(mac)
40+
self._abort_if_unique_id_configured()
41+
42+
given_name = user_input[NAME] if NAME in user_input else mac
43+
44+
return self.async_create_entry(
45+
title=given_name, data={MAC: mac, NAME: given_name}
46+
)

const.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Constants for the Format BLE Tracker integration."""
2+
3+
DOMAIN = "format_ble_tracker"
4+
5+
MAC = "mac"
6+
NAME = "name"
7+
SIXTEENTH_REGEX = "[0-9A-F]"
8+
MAC_REGEX = "^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$"
9+
UUID_REGEX = "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"
10+
11+
ROOM = "room"
12+
ROOT_TOPIC = "format_ble_tracker"
13+
ALIVE_NODES_TOPIC = ROOT_TOPIC + "/alive"
14+
RSSI = "rssi"

device_tracker.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Device tracker implementation"""
2+
from homeassistant.components import device_tracker
3+
from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity
4+
from homeassistant.config_entries import ConfigEntry
5+
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
6+
from homeassistant.core import HomeAssistant, callback
7+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
8+
9+
from .common import BeaconDeviceEntity
10+
from .__init__ import BeaconCoordinator
11+
from .const import DOMAIN
12+
13+
14+
async def async_setup_entry(
15+
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
16+
) -> None:
17+
"""Add device tracker entities from a config_entry."""
18+
19+
coordinator: BeaconCoordinator = hass.data[DOMAIN][entry.entry_id]
20+
async_add_entities([BleDeviceTracker(coordinator)], True)
21+
22+
23+
class BleDeviceTracker(BeaconDeviceEntity, BaseTrackerEntity):
24+
"""Define an device tracker entity."""
25+
26+
_attr_should_poll = False
27+
28+
def __init__(self, coordinator: BeaconCoordinator) -> None:
29+
"""Initialize."""
30+
super().__init__(coordinator)
31+
self._attr_name = coordinator.name + " tracker"
32+
self._attr_unique_id = self.formatted_mac_address + "_tracker"
33+
self.entity_id = f"{device_tracker.DOMAIN}.{self._attr_unique_id}"
34+
35+
@property
36+
def source_type(self) -> str:
37+
"""Return the source type, eg gps or router, of the device."""
38+
return "bluetooth_le"
39+
40+
@property
41+
def state(self) -> str:
42+
"""Return the state of the device."""
43+
if self.coordinator.room is None:
44+
return STATE_NOT_HOME
45+
return STATE_HOME
46+
47+
@callback
48+
def _handle_coordinator_update(self) -> None:
49+
"""Handle data update."""
50+
self.async_write_ha_state()
51+
52+
async def async_added_to_hass(self) -> None:
53+
"""Subscribe to MQTT events."""
54+
# await self.coordinator.async_on_entity_added_to_ha()
55+
return await super().async_added_to_hass()

manifest.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"domain": "format_ble_tracker",
3+
"name": "Format BLE Tracker",
4+
"version": "0.0.1",
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/format_ble_tracker",
7+
"requirements": [],
8+
"ssdp": [],
9+
"zeroconf": [],
10+
"homekit": {},
11+
"dependencies": ["mqtt"],
12+
"codeowners": ["@formatBCE"],
13+
"iot_class": "local_push"
14+
}

number.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Expiration setter implementation"""
2+
from homeassistant.components import input_number
3+
from homeassistant.components.number import NumberEntity, NumberMode, RestoreNumber
4+
from homeassistant.config_entries import ConfigEntry
5+
from homeassistant.core import HomeAssistant, callback
6+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
7+
8+
from .common import BeaconDeviceEntity
9+
from .__init__ import BeaconCoordinator
10+
from .const import DOMAIN
11+
12+
13+
async def async_setup_entry(
14+
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
15+
) -> None:
16+
"""Add sensor entities from a config_entry."""
17+
18+
coordinator: BeaconCoordinator = hass.data[DOMAIN][entry.entry_id]
19+
async_add_entities([BleDataExpirationNumber(coordinator)], True)
20+
21+
22+
class BleDataExpirationNumber(BeaconDeviceEntity, RestoreNumber, NumberEntity):
23+
"""Define an room sensor entity."""
24+
25+
_attr_should_poll = False
26+
27+
def __init__(self, coordinator: BeaconCoordinator) -> None:
28+
"""Initialize."""
29+
super().__init__(coordinator)
30+
self._attr_name = coordinator.name + " expiration delay"
31+
self._attr_mode = NumberMode.SLIDER
32+
self._attr_native_unit_of_measurement = "min"
33+
self._attr_native_max_value = 10
34+
self._attr_native_min_value = 1
35+
self._attr_native_step = 1
36+
self._attr_unique_id = self.formatted_mac_address + "_expiration"
37+
self.entity_id = f"{input_number.DOMAIN}.{self._attr_unique_id}"
38+
39+
async def async_added_to_hass(self):
40+
"""Entity has been added to hass, restoring state"""
41+
restored = await self.async_get_last_number_data()
42+
native_value = 2 if restored is None else restored.native_value
43+
self._attr_native_value = native_value
44+
await self.coordinator.on_expiration_time_changed(native_value)
45+
self.async_write_ha_state()
46+
47+
async def async_set_native_value(self, value: float) -> None:
48+
"""Update the current value."""
49+
val = min(10, max(1, int(value)))
50+
self._attr_native_value = val
51+
await self.coordinator.on_expiration_time_changed(val)
52+

0 commit comments

Comments
 (0)