Skip to content

Commit

Permalink
Calibration improvements
Browse files Browse the repository at this point in the history
- Tidy of diagnostic tables in options_flow
- Add model to device entries to help identify un-named devices
- Label and text tweaks
- Switch to a linear options_flow, looping back to menu caused processing to break.
- Handle where selected device has not been see by selected scanner
- Translate scanner names back to addresses for saving in options
- Flattened ObjectSelector yaml block for scanner offsets
- Cleaned some BermudaDeviceScanner stuff
  • Loading branch information
agittins committed Aug 17, 2024
1 parent 55103b6 commit 5008a15
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 67 deletions.
3 changes: 1 addition & 2 deletions custom_components/bermuda/bermuda_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,14 @@ def update_scanner(self, scanner_device: BermudaDevice, discoveryinfo: Bluetooth
self.address,
discoveryinfo, # the entire BluetoothScannerDevice struct
scanner_device.area_id or "area_not_defined",
self.options,
)
else:
self.scanners[format_mac(scanner_device.address)] = BermudaDeviceScanner(
self.address,
discoveryinfo, # the entire BluetoothScannerDevice struct
scanner_device.area_id or "area_not_defined",
self.options,
scanner_device.name,
scanner_device,
)
device_scanner = self.scanners[format_mac(scanner_device.address)]
# Let's see if we should update our last_seen based on this...
Expand Down
33 changes: 17 additions & 16 deletions custom_components/bermuda/bermuda_device_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from __future__ import annotations

from typing import TYPE_CHECKING

from homeassistant.components.bluetooth import MONOTONIC_TIME, BluetoothScannerDevice

from .const import (
Expand All @@ -27,6 +29,9 @@
# from .const import _LOGGER_SPAM_LESS
from .util import rssi_to_metres

if TYPE_CHECKING:
from .bermuda_device import BermudaDevice


class BermudaDeviceScanner(dict):
"""
Expand All @@ -45,15 +50,18 @@ def __init__(
scandata: BluetoothScannerDevice,
area_id: str,
options,
scanner_device_name: str,
scanner_device: BermudaDevice,
) -> None:
# I am declaring these just to control their order in the dump,
# which is a bit silly, I suspect.
self.name: str = scandata.scanner.name
self.scanner_device_name = scanner_device_name
self.scanner_device_name = scanner_device.name
self.adapter: str = scandata.scanner.adapter
self.address = scanner_device.address
self.source: str = scandata.scanner.source
self.area_id: str = area_id
self.parent_device = device_address

self.options = options
self.stamp: float | None = 0
self.new_stamp: float | None = None # Set when a new advert is loaded from update
self.hist_stamp = []
Expand All @@ -70,15 +78,9 @@ def __init__(
self.adverts: dict[str, bytes] = {}

# Just pass the rest on to update...
self.update_advertisement(device_address, scandata, area_id, options)
self.update_advertisement(device_address, scandata, area_id)

def update_advertisement(
self,
device_address: str,
scandata: BluetoothScannerDevice,
area_id: str,
options,
):
def update_advertisement(self, device_address: str, scandata: BluetoothScannerDevice, area_id: str):
"""
Update gets called every time we see a new packet or
every time we do a polled update.
Expand Down Expand Up @@ -143,9 +145,9 @@ def update_advertisement(
self.rssi = scandata.advertisement.rssi
self.hist_rssi.insert(0, self.rssi)
self.rssi_distance_raw = rssi_to_metres(
self.rssi + options.get(CONF_RSSI_OFFSETS, {}).get(self.scanner_device_name, 0),
options.get(CONF_REF_POWER),
options.get(CONF_ATTENUATION),
self.rssi + self.options.get(CONF_RSSI_OFFSETS, {}).get(self.address, 0),
self.options.get(CONF_REF_POWER),
self.options.get(CONF_ATTENUATION),
)
self.hist_distance.insert(0, self.rssi_distance_raw)

Expand Down Expand Up @@ -184,7 +186,6 @@ def update_advertisement(
self.tx_power = scandata.advertisement.tx_power
for ad_str, ad_bytes in scandata.advertisement.service_data.items():
self.adverts[ad_str] = ad_bytes
self.options = options

self.new_stamp = new_stamp

Expand Down Expand Up @@ -248,7 +249,7 @@ def calculate_data(self):
self.rssi_distance = None
# Clear the smoothing history
if len(self.hist_distance_by_interval) > 0:
self.hist_distance_by_interval = []
self.hist_distance_by_interval.clear()

else:
# Add the current reading (whether new or old) to
Expand Down
89 changes: 65 additions & 24 deletions custom_components/bermuda/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,10 @@ async def async_step_init(self, user_input=None): # pylint: disable=unused-argu
"You will need to solve this before Bermuda can be of much help."
)
else:
messages["status"] = "Life looks good."
messages["status"] = "You have at least some active devices, this is good."

# Build a markdown table of scanners so the user can see what's up.
scanner_table = "\n|Scanner|Address|Last advertisement|\n|---|---|---:|\n"
scanner_table = "\nStatus of scanners:\n\n|Scanner|Address|Last advertisement|\n|---|---|---:|\n"
# Use emoji to indicate if age is "good"
for scanner in self.coordinator.get_active_scanner_summary():
age = int(scanner.get("last_stamp_age", 999))
Expand All @@ -183,7 +183,7 @@ async def async_step_init(self, user_input=None): # pylint: disable=unused-argu
"globalopts": "Global Options",
"selectdevices": "Select Devices",
"calibration1_global": "Calibration 1: Global",
"calibration2_scanners": "Calibration 2: Scanners",
"calibration2_scanners": "Calibration 2: Scanner RSSI Offsets",
},
description_placeholders=messages,
)
Expand Down Expand Up @@ -332,17 +332,30 @@ async def async_step_calibration1_global(self, user_input=None):

if user_input is not None:
if user_input[CONF_SAVE_AND_CLOSE]:
# Update the running options (this propagates to coordinator etc)
self.options.update(
{
CONF_ATTENUATION: user_input[CONF_ATTENUATION],
CONF_REF_POWER: user_input[CONF_REF_POWER],
}
)
# Let's update the options - but we don't want to call create entry as that will close the flow.
self.hass.config_entries.async_update_entry(self.config_entry, options=self.options)
# Ideally, we'd like to just save out the config entry and return to the main menu.
# Unfortunately, doing so seems to break the chosen device (for at least 15 seconds or so)
# until it gets re-invigorated. My guess is that the link between coordinator and the
# sensor entity might be getting broken, but not entirely sure.
# For now disabling the return-to-menu and instead we finish out the flow.

# Previous block for returning to menu:
# # Let's update the options - but we don't want to call create entry as that will close the flow.
# # This will save out the config entry:
# self.hass.config_entries.async_update_entry(self.config_entry, options=self.options)
# Reset last device so that the next step doesn't think it exists.
self._last_device = None
return await self.async_step_init()
# self._last_device = None
# return await self.async_step_init()

# Current block for finishing the flow:
return await self._update_options()

self._last_ref_power = user_input[CONF_REF_POWER]
self._last_attenuation = user_input[CONF_ATTENUATION]
self._last_device = user_input[CONF_DEVICES]
Expand Down Expand Up @@ -393,7 +406,17 @@ async def async_step_calibration1_global(self, user_input=None):
| {"suffix": "After you click Submit, the new distances will be shown here."},
)
device = self._get_bermuda_device_from_registry(user_input[CONF_DEVICES])
scanner = device.scanners[user_input[CONF_SCANNERS]]

if user_input[CONF_SCANNERS] in device.scanners:
scanner = device.scanners[user_input[CONF_SCANNERS]]
else:
return self.async_show_form(
step_id="calibration_global",
errors={"err_scanner_no_record": "The selected scanner hasn't (yet) seen this device."},
data_schema=vol.Schema(data_schema),
description_placeholders=_ugly_token_hack
| {"suffix": "After you click Submit, the new distances will be shown here."},
)

distances = [
rssi_to_metres(historical_rssi, self._last_ref_power, self._last_attenuation)
Expand Down Expand Up @@ -444,30 +467,43 @@ async def async_step_calibration2_scanners(self, user_input=None):
"""
if user_input is not None:
if user_input[CONF_SAVE_AND_CLOSE]:
self.options.update(user_input[CONF_SCANNER_INFO])
# Let's update the options - but we don't want to call create entry as that will close the flow.
self.hass.config_entries.async_update_entry(self.config_entry, options=self.options)
# Reset last device so that the next step doesn't think it exists.
self._last_device = None
self._last_scanner_info = None
return await self.async_step_init()
# Convert the name-based dict to use MAC addresses
rssi_offset_by_address = {}
for address in self.coordinator.scanner_list:
scanner_name = self.coordinator.devices[address].name
rssi_offset_by_address[address] = user_input[CONF_SCANNER_INFO][scanner_name]

self.options.update({CONF_RSSI_OFFSETS: rssi_offset_by_address})
# Per previous step, returning elsewhere in the flow after updating the entry doesn't
# seem to work, so we'll just save and close the flow.
# # Let's update the options - but we don't want to call create entry as that will close the flow.
# self.hass.config_entries.async_update_entry(self.config_entry, options=self.options)
# # Reset last device so that the next step doesn't think it exists.
# self._last_device = None
# self._last_scanner_info = None
# return await self.async_step_init()

# Save the config entry and close the flow.
return await self._update_options()

# It's a refresh, basically...
self._last_scanner_info = user_input[CONF_SCANNER_INFO]
self._last_device = user_input[CONF_DEVICES]
existing_rssi_offsets = self.options.get(CONF_RSSI_OFFSETS, {})

saved_rssi_offsets = self.options.get(CONF_RSSI_OFFSETS, {})
rssi_offset_dict = {}

for scanner in self.coordinator.scanner_list:
scanner_name = self.coordinator.devices[scanner].name
rssi_offset_dict[scanner_name] = existing_rssi_offsets.get(scanner, 0)
rssi_offset_dict[scanner_name] = saved_rssi_offsets.get(scanner, 0)
data_schema = {
vol.Required(
CONF_DEVICES,
default=self._last_device if self._last_device is not None else vol.UNDEFINED,
): DeviceSelector(DeviceSelectorConfig(integration=DOMAIN)),
vol.Required(
CONF_SCANNER_INFO,
default={CONF_RSSI_OFFSETS: rssi_offset_dict}
if not self._last_scanner_info
else self._last_scanner_info,
default=rssi_offset_dict if not self._last_scanner_info else self._last_scanner_info,
): ObjectSelector(),
vol.Optional(CONF_SAVE_AND_CLOSE, default=False): vol.Coerce(bool),
}
Expand All @@ -482,7 +518,7 @@ async def async_step_calibration2_scanners(self, user_input=None):
# Gather new estimates for distances using rssi hist and the new offset.
for scanner in self.coordinator.scanner_list:
scanner_name = self.coordinator.devices[scanner].name
cur_offset = self._last_scanner_info[CONF_RSSI_OFFSETS].get(scanner_name, 0)
cur_offset = self._last_scanner_info.get(scanner_name, 0)
if scanner in device.scanners:
results[scanner_name] = [
rssi_to_metres(
Expand All @@ -495,11 +531,16 @@ async def async_step_calibration2_scanners(self, user_input=None):
# Format the results for display (HA has full markdown support!)
results_str = "| Scanner | Measurements (new...old)|\n|---|---|"
for scanner_name, distances in results.items():
results_str += f"\n|{scanner_name}|"
results_str += f"\n|{scanner_name}|<pre>"
i = 0
for distance in distances:
# limit how many columns we'll dump
i += 1
if i > 5:
continue
# We round to 2 places (1cm) and pad to fit nn.nn
results_str += f" `{distance:>5.2f}`"
results_str += "|"
results_str += f" {distance:>6.2f}"
results_str += "</pre>|"

return self.async_show_form(
step_id="calibration2_scanners",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/bermuda/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@

CONF_SAVE_AND_CLOSE = "save_and_close"
CONF_SCANNER_INFO = "scanner_info"
CONF_RSSI_OFFSETS = "rssi_offset"
CONF_RSSI_OFFSETS = "rssi_offsets"

CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL = "update_interval", 10
DOCS[CONF_UPDATE_INTERVAL] = (
Expand Down
20 changes: 4 additions & 16 deletions custom_components/bermuda/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import MONOTONIC_TIME, BluetoothChange, BluetoothScannerDevice
from homeassistant.components.bluetooth.api import _get_manager
from homeassistant.const import EVENT_STATE_CHANGED
from homeassistant.core import (
Event,
Expand Down Expand Up @@ -75,6 +76,7 @@

if TYPE_CHECKING:
from habluetooth import BluetoothServiceInfoBleak
from homeassistant.components.bluetooth import HomeAssistantBluetoothManager
from homeassistant.config_entries import ConfigEntry

from .bermuda_device_scanner import BermudaDeviceScanner
Expand All @@ -90,22 +92,6 @@ class BermudaDataUpdateCoordinator(DataUpdateCoordinator):
data already gathered by the bluetooth integration, the update process is
very cheap, and the processing process (currently) rather cheap.
Future work / algo's etc 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.
Expand Down Expand Up @@ -146,6 +132,8 @@ def __init__(
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)

self._manager: HomeAssistantBluetoothManager = _get_manager(hass)

# Track the list of Private BLE devices, noting their entity id
# and current "last address".
self.pb_state_sources: dict[str, str | None] = {}
Expand Down
4 changes: 1 addition & 3 deletions custom_components/bermuda/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from typing import TYPE_CHECKING, Any

from homeassistant.components.bluetooth.api import _get_manager
from homeassistant.core import HomeAssistant, ServiceCall

from .const import DOMAIN
Expand All @@ -22,8 +21,7 @@ async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: ConfigE
# We can call this with our own config_entry because the diags step doesn't
# actually use it.

bt_manager = _get_manager(hass)
bt_diags = await bt_manager.async_diagnostics()
bt_diags = await coordinator._manager.async_diagnostics() # noqa

# Param structure for service call
call = ServiceCall(DOMAIN, "dump_devices", {"redact": True})
Expand Down
13 changes: 12 additions & 1 deletion custom_components/bermuda/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,22 @@ def device_info(self):
# seem to be stored uppercased.
# existing_device_id = None
domain_name = DOMAIN
model = None

if self._device.is_scanner:
connection = {(dr.CONNECTION_NETWORK_MAC, self._device.address.lower())}
elif self._device.address_type == ADDR_TYPE_IBEACON:
# ibeacon doesn't (yet) actually set a "connection", but
# this "matches" what it stores for identifier.
connection = {("ibeacon", self._device.address.lower())}
model = f"iBeacon: {self._device.address.lower()}"
elif self._device.address_type == ADDR_TYPE_PRIVATE_BLE_DEVICE:
# Private BLE Device integration doesn't specify "connection" tuples,
# so we use what it defines for the "identifier" instead.
connection = {("private_ble_device", self._device.address.lower())}
# We don't set the model since the Private BLE integration should have
# already named it nicely.
# model = f"IRK: {self._device.address.lower()[:4]}"
# We look up and use the device from the registry so we get
# the private_ble_device device congealment!
# The "connection" is actually being used as the "identifiers" tuple
Expand All @@ -125,15 +130,21 @@ def device_info(self):
domain_name = DOMAIN_PRIVATE_BLE_DEVICE
else:
connection = {(dr.CONNECTION_BLUETOOTH, self._device.address.upper())}
# No need to set model, since MAC address will be shown via connection.
# model = f"Bermuda: {self._device.address.lower()}"

return {
device_info = {
"identifiers": {(domain_name, self._device.unique_id)},
"connections": connection,
"name": self._device.prefname,
}
if model is not None:
device_info["model"] = model
# if existing_device_id is not None:
# device_info['id'] = existing_device_id

return device_info

@property
def device_state_attributes(self):
"""Return the state attributes."""
Expand Down
Loading

0 comments on commit 5008a15

Please sign in to comment.