diff --git a/.gitignore b/.gitignore index f33f815..2f4cb75 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ config/home-assistant* config/blueprint* config/.HA_VERSION +config/configuration.yaml +config/known_devices.yaml diff --git a/README.md b/README.md index b699e2e..0b71a13 100644 --- a/README.md +++ b/README.md @@ -16,43 +16,73 @@ Triangulate your lost objects using ESPHome bluetooth proxies! [![Discord][discord-shield]][discord] [![Community Forum][forum-shield]][forum] -**STATUS: Pre-alpha! Only a basic service dumping info works right now. +**STATUS: Early days! +- Can replace bluetooth_ble_tracker by creating entities for home/not_home + for selected BLE devices, which can be used for Person home/away sensing. + This is the "Zone" element of homeassistant localisation, where "home" is + one Zone, and "work" or other large geographic areas might be others. + +- Provides a json/yaml dump of devices and their distances from each bluetooth + receiver. This is via the `bermuda.dump_devices` service. + +- (soon) Provides sensors to indicate which Room ("Area", in HA terms) a device + is "in". This is based on the measured RF power (rssi - received signal strength + indicator) which can give a (varyingly inaccurate) measure of distance to the + closest BLE Proxy. If you have a bluetooth receiver (ESPHome with `bluetooth_proxy` + or a Shelley device) in each room you want tracking for, this will do the job. + +- (soon) Provide a mud-map of your entire home, in bluetooth signal strength terms. + +This integration uses the advertisement data gathered by your esphome or +Shelley bluetooth-proxy deployments to track or triangulate (more correctly, +trilaterate) the relative positions of any BLE devices +observed around your home. + +Note that this is more properly called "Tri*lateration*", as we are not +measuring the angles, but instead measuring distances. The bottom line +is that triangulation is more likely to hit people's search terms. + +This integration gives you two forms of presence tracking. +- Simple Home/Away detection using the device_tracker integration. This is + not much different to the already working bluetooth_le_tracker integration + in that regard, but was an easy step along the way to... +- Room-based ("Area"s in homeassistant parlance) localisation for bluetooth + devices. For example, "which human/pet is at home and in what room are they?" + and "where's my phone/toothbrush?" + +## FAQ +Isn't mmWave better? +: mmWave is definitely *faster*, but it will only tell you "someone" has entered +a space, while Bermuda can tell you *who* is in a space. +What about PIR / Infrared? +: It's also likely faster than bluetooth, but again it only tells you that +someone / something is present, but doesn't tell you who/what. + +So how does that help? +: If the home knows who is in a given room, it can set the thermostat to their +personal preferences, or perhaps their lighting settings. This might be +particularly useful for testing automations for yourself before unleashing them +on to your housemates, so they don't get annoyed while you iron out the bugs :-) +: If you have BLE tags on your pets you can have automations specifically for them, +and/or you can exclude certain automations, for example don't trigger a light from +an IR sensor if it knows it's just your cat, say. + +How quickly does it react? +: That will mainly depend on how often your beacon transmits advertisements, however +right now the integration only re-calculates on a timed basis. This should be changed +to a realtime recalculation based on incoming advertisements soon. -This integration uses the advertisement data gathered by your esphome -bluetooth-proxy deployments to track or triangulate the relative -positions of any BLE or classic-bluetooth devices around your home -that are observed. - -This can be used for prescence detection (ie which human/pet is at home -and in what room are they?), and device location (where's my -phone/toothbrush?) - -It's unlikely to give you *fast* detection, but it might be handy to -supplement other sensors that can't distinguish between people. For -example, the mmWave sensor might turn on the lights, but a few -seconds later when Bermuda realises it's Alice, set her preferred -colour temperature. Or something. - -If the tracking is any good (it might not be) it may even be possible -to calculate a vector for the person based on the last several seconds, -and *predict* which room they're heading for. I'm not smart enough to do -that so hopefully you're better at math than I am.... - -## Expectations - -It's hard to say, but I wouldn't be expecting terribly -accurate locating, I think we'd be doing well to get down to room-level -granularity. It might only be possible to really get an idea of "very close -to this one esphome proxy" vs "somewhere between these three", but hopefully some -people smarter than me can contribute some algorithmic goodness that makes -it more useful. ## What you need - HomeAssistant, with the `bluetooth` integration enabled - Multiple (ideally) ESPHome devices, acting as `bluetooth_proxy` devices. I like the D1-Mini32 boards because they're cheap and easy to deploy. + The Shelly bluetooth proxy devices should also work but I don't have any + so can't test them myself. Issue reports with debug info welcome. - Some bluetooth things you want to locate (phones, beacons/tags etc) +- That's it! No mqtt, or devices dedicated to bluetooth (the esphome devices + can also provide other sensors etc, within reason) ## How it works @@ -65,25 +95,31 @@ compares the rssi value for a given advertisement across the different bluetooth proxies, and from that tries to make some guesses about how far (in relative terms) the device was from each proxy. -From there we hope to get a rough idea of the transmitting device's location, -and perhaps even manage to map the device to a specific "Area" in homeassistant. +The plan is to experiment with multiple algorithms to find the best ways to +establish a device's location. In the first instace the methods are: +- If a device is close (within a few metres) to a receiver, consider it to be in + the same Area as that receiver. (Working) +- Attempt to "solve" a 2D map for all beacons and receivers based on the triangles + created between them to derive all the required distances. (WIP) ## What you'll see After enabling the integration, you should start to see results for any bluetooth devices in your home that are sending broadcasts. The implemented results are: -(important to note here that NONE of these boxes are ticked yet!) +(important to note here that VERY FEW of these boxes are ticked yet!) -[x] A raw listing of values returned when you call the `bermuda.dump_beacons` service +[x] A raw listing of values returned when you call the `bermuda.dump_devices` service + [x] `area` if a device is within a max distance of a receiver [] An interface to choose which devices should have sensors created for them -[] Sensors created for selected devices, showing their estimated location +[x] Sensors created for selected devices, showing their estimated location +[] Algo to "solve" the 2D layout of devices [] 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. +[x] Basic `bermuda.dump_devices` 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 (!) @@ -102,10 +138,10 @@ devices in your home that are sending broadcasts. The implemented results are: 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 +integration, and I'm much more greybeard sysadmin than programmer, so ~~if~~where 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 +At this stage I'm using the service `bermuda.dump_devices` to examine the internal state while I gather the basic info and make initial efforts at calculating locations. It's defined in `__init__.py`. @@ -115,28 +151,17 @@ calculating locations. It's defined in `__init__.py`. The `bluetooth_tracker` and `ble_tracker` integrations are only built to give a "home/not home" determination, and don't do "Area" based location. (nb: "Zones" are places outside the -home, while "Areas" are rooms/areas inside the home). They feel rather "legacy" to me, -and they don't seem to be a popular target for innovation. +home, while "Areas" are rooms/areas inside the home). I wanted to be free to experiement with +this in ways that might not suit core, but hopefully at least some of this could find +a home in the core codebase one day. The "monitor" script uses standalone Pi's to gather bluetooth data and then pumps it into MQTT. It doesn't use the `bluetooth_proxy` capabilities which I feel are the future of home bluetooth networking (well, it is for my home, anyway!). ESPrescence looks cool, but I don't want to dedicate my nodes to non-esphome use, and again -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, -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 -strengths for all the proxies, so we can then have a ham-fisted go at estimating their -position within the home. - -To do this we need to directly access the bluetooth integration's data structures, where -it stores the recent adverts received by each proxy, along with the raw data and rssi. - +it doesn't leverage the bluetooth proxy features now in HA. I am probably reinventing +a fair amount of ESPrescense's wheel. **This component will set up the following platforms.** @@ -149,8 +174,15 @@ it stores the recent adverts received by each proxy, along with the raw data and ## Installation -I'd strongly suggest installing via the HACS user interface, but no idea if that reliably works -yet :-) The instructions below are the generic notes from the template: +Definitely use the HACS interface! Once you have HACS installed, go to `Integrations`, click the +meatballs menu in the top right, and choose `Custom Repositories`. Paste `agittins/bermuda` into +the `Repository` field, and choose `Integration` for the `Category`. Click `Add`. + +You should now be able to add the `Bermuda BLE Triangulation` integration. Once you have done that, +you need to restart Homeassistant, then in `Settings`, `Devices & Services` choose `Add Integration` +and search for `Bermuda BLE Triangulation`. + +The instructions below are the generic notes from the template: 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 2. If you do not have a `custom_components` directory (folder) there, you need to create it. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..32ad563 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# TODO Items + +[x] DeviceTracker sensors +[x] New Area sensors +[] Config Flow to define device_trackers + (because dt uses hostname as key which is evil) +[] Config flow for exposed Area sensors +[] Work out how to automate version numbering in manifest, const etc. +[] Make first release +[x] Move scanners into devices dict +[] Solve for all distances based on least-tri for adjacent beacons +[] Config flow for "datum" scanners, set origin, axis. +[] Button/service to refresh scanner areas diff --git a/custom_components/bermuda/__init__.py b/custom_components/bermuda/__init__.py index 3790354..6de01e8 100644 --- a/custom_components/bermuda/__init__.py +++ b/custom_components/bermuda/__init__.py @@ -9,25 +9,32 @@ import asyncio import logging from datetime import timedelta +from typing import TYPE_CHECKING, Final from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothScannerDevice 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 import area_registry +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.util import slugify +from homeassistant.util.dt import monotonic_time_coarse, now -from .api import BermudaApiClient from .const import CONF_PASSWORD from .const import CONF_USERNAME from .const import DOMAIN from .const import PLATFORMS from .const import STARTUP_MESSAGE -SCAN_INTERVAL = timedelta(seconds=30) +from .entity import BermudaEntity + +SCAN_INTERVAL = timedelta(seconds=10) + +MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -35,7 +42,7 @@ async def async_setup( hass: HomeAssistant, config: Config ): # pylint: disable=unused-argument; - """Set up this integration using YAML is not supported.""" + """Setting up this integration using YAML is not supported.""" return True @@ -48,10 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): username = entry.data.get(CONF_USERNAME) password = entry.data.get(CONF_PASSWORD) - session = async_get_clientsession(hass) - client = BermudaApiClient(username, password, session) - - coordinator = BermudaDataUpdateCoordinator(hass, client=client) + coordinator = BermudaDataUpdateCoordinator(hass) await coordinator.async_refresh() if not coordinator.last_update_success: @@ -69,31 +73,116 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): entry.add_update_listener(async_reload_entry) return True +def rssi_to_metres(rssi): + """Convert instant rssi value to a distance in metres -class BermudaDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the Bluetooth component.""" + 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 + +class BermudaDeviceScanner(dict): + """Represents details from a scanner relevant to a specific device + + A BermudaDevice will contain 0 or more of these depending on whether + it has been "seen" by that scanner. + + Note that details on a scanner itself are BermudaDevice instances + in their own right. + """ def __init__( self, - hass: HomeAssistant, - client: BermudaApiClient, - ) -> None: - """Initialize.""" - self.api = client - self.platforms = [] - self.devices = [] + device_address: str, + scandata: BluetoothScannerDevice, + area_id: str + ): + self.name = scandata.scanner.name + self.area_id = area_id + self.adapter = scandata.scanner.adapter + self.source = scandata.scanner.source + self.stamp = scandata.scanner._discovered_device_timestamps[device_address] + self.rssi = scandata.advertisement.rssi + self.rssi_distance = rssi_to_metres(self.rssi) + self.adverts = scandata.advertisement.service_data.items() + + def to_dict(self): + out = {} + for ( var, val) in vars(self).items(): + if var == 'adverts': + val = {} + for ad, thebytes in self.adverts: + val[ad] = thebytes.hex() + out[var] = val + return out + + + +class BermudaDevice(dict): + """This class is to represent a single bluetooth "device" tracked by Bermuda. + + "device" in this context means a bluetooth receiver like an ESPHome + running bluetooth_proxy or a bluetooth transmitter such as a beacon, + a thermometer, watch or phone etc. + + We're not storing this as an Entity because we don't want all devices to + become entities in homeassistant, since there might be a _lot_ of them. + """ + def __init__(self): + """Initial (empty) data""" + self.address = None + self.unique_id = None # mac address formatted. + self.name = None + self.local_name = None + self.prefname = None # "preferred" name - ideally local_name + self.area_id = None + self.area_name = None + self.area_distance = None # how far this dev is from that area + self.location = None # home or not_home + self.manufacturer = None + self.connectable = False + self.is_scanner = False + self.entry_id = None # used for scanner devices + self.send_tracker_see = False # Create/update device_tracker entity + self.create_sensor = False # Create/update a sensor for this device + self.last_seen = 0 # stamp from most recent scanner spotting + self.scanners: dict[str, BermudaDeviceScanner] = {} + + def to_dict(self): + out = {} + for ( var, val) in vars(self).items(): + if var == 'scanners': + scanout = {} + for address, scanner in self.scanners.items(): + scanout[address] = scanner.to_dict() + val = scanout + out[var] = val + return out - hass.services.async_register( - DOMAIN, - "dump_beacons", - self.service_dump_beacons, - None, - SupportsResponse.ONLY, - ) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) +class BermudaDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the Bluetooth component. + + Since we are not actually using an external API and only computing local + data already gathered by the bluetooth integration, the update process is + very cheap, and the processing process (currently) rather cheap. - """Some algorithms to keep in mind: + 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 @@ -120,86 +209,225 @@ def __init__( """ - async def _rssi_to_metres(self, rssi): - """Convert instant rssi value to a distance in metres + def __init__( + self, + hass: HomeAssistant, + + ) -> None: + """Initialize.""" + self.platforms = [] + self.devices: dict[str, BermudaDevice] = {} + self.created_entities: set[BermudaEntity] = set() - Based on the information from - https://mdpi-res.com/d_attachment/applsci/applsci-10-02003/article_deploy/applsci-10-02003.pdf?version=1584265508 + self.ar = area_registry.async_get(hass) - 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: These settings are to be moved into the config flow + self.max_area_radius = 3.0 # maximum distance to consider "in the area" + self.timeout_not_home = 60 # seconds to wait before declaring "not_home" - 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 + + hass.services.async_register( + DOMAIN, + "dump_devices", + self.service_dump_devices, + None, + SupportsResponse.ONLY, + ) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + async def _async_update_data(self): - """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"] = [] + """Update data on known devices. + + This works only with local data, so should be cheap to run + (no network requests made etc). + + """ + for service_info in bluetooth.async_discovered_service_info(self.hass, False): + # Note that some of these entries are restored from storage, + # so we won't necessarily find (immediately, or perhaps ever) + # scanner entries for any given device. + + # Get/Create a device entry + if service_info.address not in self.devices: + #Initialise an empty device + self.devices[service_info.address] = BermudaDevice() + device: BermudaDevice = self.devices[service_info.address] + + # We probably don't need to do all of this every time, but we + # want to catch any changes, eg when the system learns the local + # name etc. + device.address = service_info.address + device.unique_id = format_mac(service_info.address) + device.name = service_info.device.name + device.local_name = service_info.advertisement.local_name + device.manufacturer = service_info.manufacturer + device.connectable = service_info.connectable + + # Try to make a nice name for prefname. + # TODO: Add support for user-defined name, especially since the + # device_tracker entry can only be renamed using the editor. + if service_info.advertisement.local_name is not None: + device.prefname = service_info.advertisement.local_name + elif service_info.device.name is not None: + device.prefname = service_info.device.name + else: + # we tried. Fall back to boring... + device.prefname = 'bermuda_' + slugify(service_info.address) + + # Work through the scanner entries... 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, - } + + if discovered.scanner.source not in self.devices: + self._refresh_scanners() + + #FIXME: Find a method or request one be added for this + stamps = discovered.scanner._discovered_device_timestamps # pylint: disable=protected-access + scanner_stamp = stamps[service_info.address] + if device.last_seen < scanner_stamp: + device.last_seen = scanner_stamp + + # Just replace the scanner entries outright... + device.scanners[discovered.scanner.source] = BermudaDeviceScanner( + device.address, + discovered, + self.devices[discovered.scanner.source].area_id ) - 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} + #FIXME: This should be configurable... + if device.address in [ + "EE:E8:37:9F:6B:54", # infinitime, main watch + "C7:B8:C6:B0:27:11", # pinetime, devwatch + "A4:C1:38:C8:58:91", # bthome thermo, with reed switch + ]: + device.send_tracker_see = True + device.create_sensor = True + + if device.send_tracker_see: + # Send a "see" notification to device_tracker + await self._send_device_tracker_see(device) + + self._refresh_areas_by_min_distance() + + # end of async update + + # async def _create_or_update_sensor(self, device): + # if self.async_sensor_add_entities is not None: + # #await self.async_sensor_add_entities([BermudaSensor, self.config_entry]) + # NotImplemented + + async def _send_device_tracker_see(self, device): + """Send "see" event to the legacy device_tracker integration. + + If the device is not yet in known_devices.yaml it will get added. + Note that device_tracker can *only* support [home|not_home]. + It does support Zones (not via the service though?), but Zones + are only EXTERNAL to the home, not the same as "Area"s. + + I'm not implementing device_tracker proper because I don't grok it + well enough yet. And to be honest this is probably all we need + since it doesn't support Areas anyway. + + TODO: Allow user to configure what name to use for the device_tracker. + """ + + # Check if the device has been seen recently + now = MONOTONIC_TIME() + if now - device.last_seen > self.timeout_not_home: + location_name = 'not_home' + else: + location_name = 'home' + + # If mac is set, dt will: + # slugify the hostname (if set) or mac, and use that as the dev_id. + # Else: + # will slugify dev_id + # So, we will not set mac, but use bermuda_[mac] as dev_id and prefname + # for host_name. + await self.hass.services.async_call( + domain='device_tracker', + service='see', + service_data={ + 'dev_id': 'bermuda_' + slugify(device.address), + #'mac': device.address, + 'host_name': device.prefname, + 'location_name': location_name, + } + ) + + def dt_mono_to_datetime(self, stamp): + """Given a monotonic timestamp, convert to datetime object""" + age = MONOTONIC_TIME() - stamp + return now() - timedelta(seconds=age) + + + + def _refresh_areas_by_min_distance(self): + """Set area for ALL devices based on closest beacon""" + for device in self.devices.values(): + if device.is_scanner is not True: + self._refresh_area_by_min_distance(device) + + def _refresh_area_by_min_distance(self, device: BermudaDevice): + """Very basic Area setting by finding closest beacon to a given device""" + assert device.is_scanner is not True + closest_scanner: BermudaDeviceScanner = None + + for scanner in device.scanners.values(): + # whittle down to the closest beacon inside max range + if scanner.rssi_distance < self.max_area_radius: # potential... + if closest_scanner is None \ + or scanner.rssi_distance < closest_scanner.rssi_distance: + closest_scanner = scanner + if closest_scanner is not None: + # We found a winner + device.area_id = closest_scanner.area_id + areas = self.ar.async_get_area(device.area_id).name # which is actually a list. + if len(areas) == 1: + device.area_name = areas[0] + else: + device.area_name = areas + + device.area_distance = closest_scanner.rssi_distance + else: + # Not close to any scanners! + device.area_id = None + device.area_name = None + device.area_distance = None + + + def _refresh_scanners(self, address = None): + """Refresh our local list of scanners (BLE Proxies)""" + #FIXME: Really? This can't possibly be a sensible nesting of loops. + for dev_entry in self.hass.data['device_registry'].devices.data.values(): + if len(dev_entry.connections) > 0: + for dev_connection in dev_entry.connections: + if dev_connection[0] == 'mac': + if address is None or address == dev_connection[1]: + found_address = dev_connection[1] + self.devices[found_address] = BermudaDevice() + scandev = self.devices[found_address] + scandev.address = found_address + scandev.area_id = dev_entry.area_id + scandev.entry_id = dev_entry.id + if dev_entry.name_by_user is not None: + scandev.name = dev_entry.name_by_user + else: + scandev.name = dev_entry.name + scandev.is_scanner = True + + + async def service_dump_devices(self, call): # pylint: disable=unused-argument; + """Return a dump of beacon advertisements by receiver""" + out = {} + for address, device in self.devices.items(): + out[address] = device.to_dict() + return out async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" diff --git a/custom_components/bermuda/api.py b/custom_components/bermuda/api.py deleted file mode 100644 index 1259051..0000000 --- a/custom_components/bermuda/api.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Sample API Client.""" -# import asyncio -import logging - -# import socket - -import aiohttp - -# import async_timeout - -TIMEOUT = 10 - - -_LOGGER: logging.Logger = logging.getLogger(__package__) - -HEADERS = {"Content-type": "application/json; charset=UTF-8"} - - -class BermudaApiClient: - """API Client - not used.""" - - def __init__( - self, username: str, password: str, session: aiohttp.ClientSession - ) -> None: - """Sample API Client.""" - self._username = username - self._passeword = password - self._session = session - - async def async_get_data(self) -> dict: - """Get data from the API.""" - url = "https://jsonplaceholder.typicode.com/posts/1" - return await self.api_wrapper("get", url) - - async def async_set_title(self, value: str) -> None: - """Get data from the API.""" - url = "https://jsonplaceholder.typicode.com/posts/1" - await self.api_wrapper("patch", url, data={"title": value}, headers=HEADERS) - - 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) diff --git a/custom_components/bermuda/binary_sensor.py b/custom_components/bermuda/binary_sensor.py index 1d13ae0..afa84b2 100644 --- a/custom_components/bermuda/binary_sensor.py +++ b/custom_components/bermuda/binary_sensor.py @@ -11,7 +11,7 @@ async def async_setup_entry(hass, entry, async_add_devices): """Setup binary_sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([BermudaBinarySensor(coordinator, entry)]) + #AJG async_add_devices([BermudaBinarySensor(coordinator, entry)]) class BermudaBinarySensor(BermudaEntity, BinarySensorEntity): diff --git a/custom_components/bermuda/config_flow.py b/custom_components/bermuda/config_flow.py index 51fcba6..13d3155 100644 --- a/custom_components/bermuda/config_flow.py +++ b/custom_components/bermuda/config_flow.py @@ -4,7 +4,6 @@ from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .api import BermudaApiClient from .const import CONF_PASSWORD from .const import CONF_USERNAME from .const import DOMAIN @@ -63,8 +62,8 @@ async def _test_credentials(self, username, password): """Return true if credentials is valid.""" try: session = async_create_clientsession(self.hass) - client = BermudaApiClient(username, password, session) - await client.async_get_data() + #client = BermudaApiClient(username, password, session) + #await client.async_get_data() return True except Exception: # pylint: disable=broad-except pass diff --git a/custom_components/bermuda/const.py b/custom_components/bermuda/const.py index 96e5f2a..cdaf9af 100644 --- a/custom_components/bermuda/const.py +++ b/custom_components/bermuda/const.py @@ -18,13 +18,14 @@ BINARY_SENSOR = "binary_sensor" SENSOR = "sensor" SWITCH = "switch" -PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] - +#PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] +PLATFORMS = [ SENSOR ] # Configuration and options CONF_ENABLED = "enabled" CONF_USERNAME = "username" CONF_PASSWORD = "password" +CONF_DEVTRACKERS = "device_trackers" # Defaults DEFAULT_NAME = DOMAIN diff --git a/custom_components/bermuda/entity.py b/custom_components/bermuda/entity.py index 59631e6..624aa67 100644 --- a/custom_components/bermuda/entity.py +++ b/custom_components/bermuda/entity.py @@ -1,27 +1,48 @@ """BermudaEntity class""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers import area_registry from .const import ATTRIBUTION from .const import DOMAIN from .const import NAME from .const import VERSION +if TYPE_CHECKING: + from . import BermudaDataUpdateCoordinator + from . import BermudaDevice class BermudaEntity(CoordinatorEntity): - def __init__(self, coordinator, config_entry): + """Co-ordinator for Bermuda data. + + Gathers the device infor for receivers and transmitters, calculates + distances etc. + """ + def __init__( + self, + coordinator: BermudaDataUpdateCoordinator, + config_entry, + address: str + ): super().__init__(coordinator) + self.coordinator = coordinator self.config_entry = config_entry - + self._device = coordinator.devices[address] + self.ar = area_registry.async_get(coordinator.hass) @property def unique_id(self): """Return a unique ID to use for this entity.""" - return self.config_entry.entry_id + return self._device.unique_id @property def device_info(self): + """Implementing this creates an entry in the device registry.""" return { "identifiers": {(DOMAIN, self.unique_id)}, - "name": NAME, + "name": self._device.prefname, "model": VERSION, "manufacturer": NAME, } diff --git a/custom_components/bermuda/sensor.py b/custom_components/bermuda/sensor.py index 6822cb6..61acf0f 100644 --- a/custom_components/bermuda/sensor.py +++ b/custom_components/bermuda/sensor.py @@ -1,37 +1,69 @@ """Sensor platform for Bermuda BLE Triangulation.""" -from .const import DEFAULT_NAME + +from collections.abc import Mapping +from typing import Any +from homeassistant import config_entries + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +#from .const import DEFAULT_NAME from .const import DOMAIN -from .const import ICON -from .const import SENSOR +#from .const import ICON +#from .const import SENSOR from .entity import BermudaEntity +from . import BermudaDataUpdateCoordinator -async def async_setup_entry(hass, entry, async_add_devices): +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_devices: AddEntitiesCallback +) -> None: """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([BermudaSensor(coordinator, entry)]) + coordinator: BermudaDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + # We simply trawl the devices to set up sensors. + entities = [] + for device in coordinator.devices.values(): + if device.create_sensor: + entities.append(BermudaSensor(coordinator, entry, device.address)) + #async_add_devices([BermudaSensor(coordinator, entry)]) + async_add_devices(entities, True) class BermudaSensor(BermudaEntity): """bermuda Sensor class.""" + @property + def has_entity_name(self) -> bool: + return True + @property def name(self): """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{SENSOR}" + return "Area" @property def state(self): """Return the state of the sensor.""" # return self.coordinator.data.get("body") - return "Looks good, eh." + return self._device.area_name - @property - def icon(self): - """Return the icon of the sensor.""" - return ICON + #@property + #def icon(self): + # """Return the icon of the sensor.""" + # return ICON @property def device_class(self): """Return de device class of the sensor.""" return "bermuda__custom_device_class" + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + return { + 'last_seen': self.coordinator.dt_mono_to_datetime(self._device.last_seen), + 'area_id': self._device.area_id, + 'area_name': self._device.area_name, + 'area_distance': self._device.area_distance, + } diff --git a/custom_components/bermuda/services.yaml b/custom_components/bermuda/services.yaml index 46d9df1..abfd9ee 100644 --- a/custom_components/bermuda/services.yaml +++ b/custom_components/bermuda/services.yaml @@ -1 +1,3 @@ -dump_beacons: \ No newline at end of file +dump_devices: + name: Dump devices + description: Returns the internal structure of devices, useful for debugging or building templates etc. \ No newline at end of file diff --git a/custom_components/bermuda/switch.py b/custom_components/bermuda/switch.py index 450cadd..f94fcf0 100644 --- a/custom_components/bermuda/switch.py +++ b/custom_components/bermuda/switch.py @@ -11,7 +11,7 @@ async def async_setup_entry(hass, entry, async_add_devices): """Setup sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([BermudaBinarySwitch(coordinator, entry)]) + #AJG async_add_devices([BermudaBinarySwitch(coordinator, entry)]) class BermudaBinarySwitch(BermudaEntity, SwitchEntity): @@ -19,13 +19,13 @@ class BermudaBinarySwitch(BermudaEntity, SwitchEntity): async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument """Turn on the switch.""" - await self.coordinator.api.async_set_title("bar") - await self.coordinator.async_request_refresh() + #await self.coordinator.api.async_set_title("bar") + #await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument """Turn off the switch.""" - await self.coordinator.api.async_set_title("foo") - await self.coordinator.async_request_refresh() + #await self.coordinator.api.async_set_title("foo") + #await self.coordinator.async_request_refresh() @property def name(self):