Skip to content

Commit

Permalink
Service dump_beacons working
Browse files Browse the repository at this point in the history
- created service bermuda.dump_beacons which gives debug
  info of advertisements and calculated distances for each
  device to each beacon.
- Readme update
- linting clean-ups on unused api and sensor decls.
  • Loading branch information
agittins committed Aug 9, 2023
1 parent 348cca3 commit 3687207
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 60 deletions.
42 changes: 36 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
129 changes: 122 additions & 7 deletions custom_components/bermuda/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
83 changes: 44 additions & 39 deletions custom_components/bermuda/api.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -15,6 +17,8 @@


class BermudaApiClient:
"""API Client - not used."""

def __init__(
self, username: str, password: str, session: aiohttp.ClientSession
) -> None:
Expand All @@ -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)
3 changes: 2 additions & 1 deletion custom_components/bermuda/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 11 additions & 4 deletions custom_components/bermuda/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
3 changes: 2 additions & 1 deletion custom_components/bermuda/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions custom_components/bermuda/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dump_beacons:
3 changes: 2 additions & 1 deletion custom_components/bermuda/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 3687207

Please sign in to comment.