diff --git a/README.md b/README.md index c93a568..b699e2e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Triangulate your lost objects using ESPHome bluetooth proxies! [![Discord][discord-shield]][discord] [![Community Forum][forum-shield]][forum] -**STATUS: Pre-alpha! Nothing works yet, this is just an idea so far. +**STATUS: Pre-alpha! Only a basic service dumping info works right now. This integration uses the advertisement data gathered by your esphome bluetooth-proxy deployments to track or triangulate the relative @@ -74,13 +74,43 @@ After enabling the integration, you should start to see results for any bluetoot devices in your home that are sending broadcasts. The implemented results are: (important to note here that NONE of these boxes are ticked yet!) -[] A raw listing of values returned when you call the `bermuda_get_rssi` service +[x] A raw listing of values returned when you call the `bermuda.dump_beacons` service [] An interface to choose which devices should have sensors created for them [] Sensors created for selected devices, showing their estimated location [] A mud-map showing relative locations between proxies and detected devices [] An interface to "pin" the proxies on a map to establish a sort of coordinate system [] An interface to define Areas in relation to the pinned proxies +## TODO / Ideas + +[x] Basic `bermuda.dump_beacons` service that responds with measurements. +[] Switch to performing updates on receipt of advertisements, instead of periodic polling +[] Realtime approximation of inter-proxy distances using Triangle Inequality +[] Resolve x/y co-ordinates of all scanners and proxies (!) +[] Some sort of map, just pick two proxies as an x-axis vector and go +[] Config setting to define absolute locations of two proxies +[] Support some way to "pin" more than two proxies/tags, and have it not break. +[] Create entities (use `device_tracker`? or create own?) for each detected beacon +[] Experiment with some of + [these algo's](https://mdpi-res.com/d_attachment/applsci/applsci-10-02003/article_deploy/applsci-10-02003.pdf?version=1584265508) + for improving accuracy (too much math for me!). Particularly weighting shorter + distances higher and perhaps the cosine similarity fingerprinting, possibly against + fixed beacons as well to smooth environmental rssi fluctuations. + + +## Hacking tips + +Wanna improve this? Awesome! Here's some tips on how it works inside and +what direction I'm hoping to go. Bear in mind this is my first ever HA +integration, and I'm much more greybeard sysadmin than programmer, so if +I'm doing stupid things I really would welcome some improvements! + +At this stage I'm using the service `bermuda.dump_beacons` to examine the +internal state while I gather the basic info and make initial efforts at +calculating locations. It's defined in `__init__.py`. + +(right now that's about all that exists!) + ## Prior Art The `bluetooth_tracker` and `ble_tracker` integrations are only built to give a "home/not home" @@ -97,7 +127,7 @@ it doesn't leverage the bluetooth proxy features now in HA. ## Under the bonnet -The bluetooth integration doesn't really expose the advertisements that it receives, +The `bluetooth` integration doesn't really expose the advertisements that it receives, expecting instead integrations to do specific tasks by device type. Even so, the data available by the normal APIs only expose the view from one proxy - the one that received the strongest signal (rssi) for that advertisement. We want to see the *relative* rssi @@ -112,9 +142,9 @@ it stores the recent adverts received by each proxy, along with the raw data and | Platform | Description | | --------------- | ------------------------------------------------------------------------- | -| `binary_sensor` | Show something `True` or `False`. | -| `sensor` | Show info from Bermuda BLE Triangulation API. | -| `switch` | Switch something `True` or `False`. | +| `binary_sensor` | Nothing yet. | +| `sensor` | Nor here, yet. | +| `switch` | Nope. | ## Installation diff --git a/custom_components/bermuda/__init__.py b/custom_components/bermuda/__init__.py index 5c9a422..3790354 100644 --- a/custom_components/bermuda/__init__.py +++ b/custom_components/bermuda/__init__.py @@ -4,13 +4,17 @@ For more details about this integration, please refer to https://github.com/agittins/bermuda """ +from __future__ import annotations + import asyncio import logging from datetime import timedelta +from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config from homeassistant.core import HomeAssistant +from homeassistant.core import SupportsResponse from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -28,7 +32,9 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) -async def async_setup(hass: HomeAssistant, config: Config): +async def async_setup( + hass: HomeAssistant, config: Config +): # pylint: disable=unused-argument; """Set up this integration using YAML is not supported.""" return True @@ -65,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): class BermudaDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the API.""" + """Class to manage fetching data from the Bluetooth component.""" def __init__( self, @@ -75,15 +81,124 @@ def __init__( """Initialize.""" self.api = client self.platforms = [] + self.devices = [] + + hass.services.async_register( + DOMAIN, + "dump_beacons", + self.service_dump_beacons, + None, + SupportsResponse.ONLY, + ) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + """Some algorithms to keep in mind: + + https://en.wikipedia.org/wiki/Triangle_inequality + - with distance to two rx nodes, we can apply min and max bounds + on the distance between them (less than the sum, more than the + difference). This could allow us to iterively approximate toward + the rx layout, esp as devices move between (and right up to) rx. + - bear in mind that rssi errors are typically attenuation-only. + This means that we should favour *minimum* distances as being + more accurate, both when weighting measurements from distant + receivers, and when whittling down a max distance between + receivers (but beware of the min since that uses differences) + + https://mdpi-res.com/d_attachment/applsci/applsci-10-02003/article_deploy/applsci-10-02003.pdf?version=1584265508 + - lots of good info and ideas. + + TODO / IDEAS: + - when we get to establishing a fix, we can apply a path-loss factor to + a calculated vector based on previously measured losses on that path. + We could perhaps also fine-tune that with real-time measurements from + fixed beacons to compensate for environmental factors. + - An "obstruction map" or "radio map" could provide field strength estimates + at given locations, and/or hint at attenuation by counting "wall crossings" + for a given vector/path. + + """ + + async def _rssi_to_metres(self, rssi): + """Convert instant rssi value to a distance in metres + + Based on the information from + https://mdpi-res.com/d_attachment/applsci/applsci-10-02003/article_deploy/applsci-10-02003.pdf?version=1584265508 + + attenuation: a factor representing environmental attenuation + along the path. Will vary by humidity, terrain etc. + ref_power: db. measured rssi when at 1m distance from rx. The will + be affected by both receiver sensitivity and transmitter + calibration, antenna design and orientation etc. + + TODO: the ref_power and attenuation figures can/should probably be mapped + against each receiver and transmitter for variances. We could also fine- + tune the attenuation in real time based on changing values coming from + known-fixed beacons (eg thermometers, window sensors etc) + """ + attenuation = 3.0 # Will range depending on environmental factors + ref_power = -55.0 # db reference measured at 1.0m + + distance = 10 ** ((ref_power - rssi) / (10 * attenuation)) + return distance + async def _async_update_data(self): - """Update data via library.""" - try: - return await self.api.async_get_data() - except Exception as exception: - raise UpdateFailed() from exception + """Update data on known devices.""" + beacon_details = [] + # Fixme/todo: We re-create the device list from scratch. This means + # we lose devices as they are expunged from the discovery lists. + # Instead we might want to update our list incrementally to keep + # more history and/or not over-write extra info we've calculated! + + # We only trawl through this as there's no API I could see for + # acessing the scanner devices except by address. One probably + # could access the data structs directly but that would be rude. + for service_info in bluetooth.async_discovered_service_info(self.hass, False): + # Not all discovered service info entries have corresponding + # scanner entries, which seems a little odd. + # redict.append(service_info.address) + device = { + "address": service_info.address, + "name": service_info.device.name, + "local_name": service_info.advertisement.local_name, + "connectable": service_info.connectable, + } + device["scanners"] = [] + + for discovered in bluetooth.async_scanner_devices_by_address( + self.hass, service_info.address, False + ): + adverts = [] + for sd in discovered.advertisement.service_data: + adverts.append( + { + "advert": sd, + "bytes": discovered.advertisement.service_data[sd].hex(), + } + ) + + device["scanners"].append( + { + "scanner_name": discovered.scanner.name, + "scanner_address": discovered.scanner.adapter, + "rssi": discovered.advertisement.rssi, + "rssi_distance": await self._rssi_to_metres( + discovered.advertisement.rssi + ), + "adverts": adverts, + } + ) + beacon_details.append(device) + self.devices = beacon_details + # try: + # return await self.api.async_get_data() + # except Exception as exception: + # raise UpdateFailed() from exception + + async def service_dump_beacons(self, call): # pylint: disable=unused-argument; + """Return a dump of beacon advertisements by receiver""" + return {"items": self.devices} async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/bermuda/api.py b/custom_components/bermuda/api.py index 816bfa7..1259051 100644 --- a/custom_components/bermuda/api.py +++ b/custom_components/bermuda/api.py @@ -1,10 +1,12 @@ """Sample API Client.""" -import asyncio +# import asyncio import logging -import socket + +# import socket import aiohttp -import async_timeout + +# import async_timeout TIMEOUT = 10 @@ -15,6 +17,8 @@ class BermudaApiClient: + """API Client - not used.""" + def __init__( self, username: str, password: str, session: aiohttp.ClientSession ) -> None: @@ -33,43 +37,44 @@ async def async_set_title(self, value: str) -> None: url = "https://jsonplaceholder.typicode.com/posts/1" await self.api_wrapper("patch", url, data={"title": value}, headers=HEADERS) - async def api_wrapper( + async def api_wrapper( # pylint: disable=dangerous-default-value; self, method: str, url: str, data: dict = {}, headers: dict = {} ) -> dict: """Get information from the API.""" - try: - async with async_timeout.timeout(TIMEOUT, loop=asyncio.get_event_loop()): - if method == "get": - response = await self._session.get(url, headers=headers) - return await response.json() - - elif method == "put": - await self._session.put(url, headers=headers, json=data) - - elif method == "patch": - await self._session.patch(url, headers=headers, json=data) - - elif method == "post": - await self._session.post(url, headers=headers, json=data) - - except asyncio.TimeoutError as exception: - _LOGGER.error( - "Timeout error fetching information from %s - %s", - url, - exception, - ) - except (KeyError, TypeError) as exception: - _LOGGER.error( - "Error parsing information from %s - %s", - url, - exception, - ) - except (aiohttp.ClientError, socket.gaierror) as exception: - _LOGGER.error( - "Error fetching information from %s - %s", - url, - exception, - ) - except Exception as exception: # pylint: disable=broad-except - _LOGGER.error("Something really wrong happened! - %s", exception) + # try: + # async with async_timeout.timeout(TIMEOUT, loop=asyncio.get_event_loop()): + # if method == "get": + # response = await self._session.get(url, headers=headers) + # return await response.json() + # + # elif method == "put": + # await self._session.put(url, headers=headers, json=data) + # + # elif method == "patch": + # await self._session.patch(url, headers=headers, json=data) + # + # elif method == "post": + # await self._session.post(url, headers=headers, json=data) + # + # except asyncio.TimeoutError as exception: + # _LOGGER.error( + # "Timeout error fetching information from %s - %s", + # url, + # exception, + # ) + # + # except (KeyError, TypeError) as exception: + # _LOGGER.error( + # "Error parsing information from %s - %s", + # url, + # exception, + # ) + # except (aiohttp.ClientError, socket.gaierror) as exception: + # _LOGGER.error( + # "Error fetching information from %s - %s", + # url, + # exception, + # ) + # except Exception as exception: # pylint: disable=broad-except + # _LOGGER.error("Something really wrong happened! - %s", exception) diff --git a/custom_components/bermuda/binary_sensor.py b/custom_components/bermuda/binary_sensor.py index f4d14ce..1d13ae0 100644 --- a/custom_components/bermuda/binary_sensor.py +++ b/custom_components/bermuda/binary_sensor.py @@ -30,4 +30,5 @@ def device_class(self): @property def is_on(self): """Return true if the binary_sensor is on.""" - return self.coordinator.data.get("title", "") == "foo" + # return self.coordinator.data.get("title", "") == "foo" + return True diff --git a/custom_components/bermuda/manifest.json b/custom_components/bermuda/manifest.json index 360f4d4..d18021b 100644 --- a/custom_components/bermuda/manifest.json +++ b/custom_components/bermuda/manifest.json @@ -3,8 +3,15 @@ "name": "Bermuda BLE Triangulation", "documentation": "https://github.com/agittins/bermuda", "issue_tracker": "https://github.com/agittins/bermuda/issues", - "dependencies": [], + "dependencies": [ + "bluetooth_adapters" + ], "config_flow": true, - "codeowners": ["@agittins"], - "requirements": [] -} + "iot_class": "local_polling", + "codeowners": [ + "@agittins" + ], + "requirements": [ + ], + "version": "0.0.1" +} \ No newline at end of file diff --git a/custom_components/bermuda/sensor.py b/custom_components/bermuda/sensor.py index 2bead20..6822cb6 100644 --- a/custom_components/bermuda/sensor.py +++ b/custom_components/bermuda/sensor.py @@ -23,7 +23,8 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - return self.coordinator.data.get("body") + # return self.coordinator.data.get("body") + return "Looks good, eh." @property def icon(self): diff --git a/custom_components/bermuda/services.yaml b/custom_components/bermuda/services.yaml new file mode 100644 index 0000000..46d9df1 --- /dev/null +++ b/custom_components/bermuda/services.yaml @@ -0,0 +1 @@ +dump_beacons: \ No newline at end of file diff --git a/custom_components/bermuda/switch.py b/custom_components/bermuda/switch.py index 7eb0872..450cadd 100644 --- a/custom_components/bermuda/switch.py +++ b/custom_components/bermuda/switch.py @@ -40,4 +40,5 @@ def icon(self): @property def is_on(self): """Return true if the switch is on.""" - return self.coordinator.data.get("title", "") == "foo" + # return self.coordinator.data.get("title", "") == "foo" + return True diff --git a/custom_components/bermuda/translations/en.json b/custom_components/bermuda/translations/en.json index 66a32cb..f95a46d 100644 --- a/custom_components/bermuda/translations/en.json +++ b/custom_components/bermuda/translations/en.json @@ -27,5 +27,11 @@ } } } + }, + "services": { + "dump_beacons": { + "name": "Dump Beacons", + "description": "Returns all detected advertisements and their rssi for each scanner" + } } -} +} \ No newline at end of file