From 8a6d9432f4ee4773f5153825d0ef830e4bb96195 Mon Sep 17 00:00:00 2001 From: myhomeiot Date: Sat, 4 May 2024 13:20:11 +0300 Subject: [PATCH 1/5] Allow proxy HCI packets to Home Assistant Bluetooth stack --- custom_components/ble_monitor/__init__.py | 132 ++++++++++++++++++++-- custom_components/ble_monitor/const.py | 1 + 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/custom_components/ble_monitor/__init__.py b/custom_components/ble_monitor/__init__.py index f29fd432..75ad4c89 100644 --- a/custom_components/ble_monitor/__init__.py +++ b/custom_components/ble_monitor/__init__.py @@ -3,6 +3,7 @@ import copy import json import logging +import struct from threading import Thread import aioblescan as aiobs @@ -11,11 +12,16 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import (CONF_DEVICES, CONF_DISCOVERY, CONF_MAC, CONF_NAME, CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_STOP) -from homeassistant.core import HomeAssistant + CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_STOP, + ATTR_DEVICE_ID) +from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.util import dt +try: + from homeassistant.components import bluetooth +except ImportError: + bluetooth = None from .ble_parser import BleParser from .bt_helpers import (BT_INTERFACES, BT_MULTI_SELECT, DEFAULT_BT_INTERFACE, @@ -28,7 +34,7 @@ CONF_DEVICE_RESET_TIMER, CONF_DEVICE_RESTORE_STATE, CONF_DEVICE_TRACK, CONF_DEVICE_TRACKER_CONSIDER_HOME, CONF_DEVICE_TRACKER_SCAN_INTERVAL, CONF_DEVICE_USE_MEDIAN, - CONF_GATEWAY_ID, CONF_HCI_INTERFACE, CONF_LOG_SPIKES, + CONF_GATEWAY_ID, CONF_PROXY, CONF_HCI_INTERFACE, CONF_LOG_SPIKES, CONF_PACKET, CONF_PERIOD, CONF_REPORT_UNKNOWN, CONF_RESTORE_STATE, CONF_USE_MEDIAN, CONF_UUID, CONFIG_IS_FLOW, DEFAULT_ACTIVE_SCAN, DEFAULT_BATT_ENTITIES, @@ -130,7 +136,9 @@ SERVICE_PARSE_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_PACKET): cv.string, - vol.Optional(CONF_GATEWAY_ID): cv.string + vol.Optional(ATTR_DEVICE_ID, default=None): cv.string, + vol.Optional(CONF_GATEWAY_ID, default=DOMAIN): cv.string, + vol.Optional(CONF_PROXY, default=False): cv.boolean } ) @@ -423,8 +431,10 @@ async def async_parse_data_service(hass: HomeAssistant, service_data): blemonitor: BLEmonitor = hass.data[DOMAIN]["blemonitor"] if blemonitor: blemonitor.dumpthread.process_hci_events( - bytes.fromhex(service_data["packet"]), - service_data[CONF_GATEWAY_ID] if CONF_GATEWAY_ID in service_data else DOMAIN + data=bytes.fromhex(service_data["packet"]), + device_id=service_data[ATTR_DEVICE_ID], + gateway_id=service_data[CONF_GATEWAY_ID], + proxy=service_data[CONF_PROXY] ) @@ -506,6 +516,7 @@ def __init__(self, config, dataqueue): self.report_unknown = False self.report_unknown_whitelist = [] self.last_bt_reset = dt.now() + self.scanners = {} if self.config[CONF_REPORT_UNKNOWN]: if self.config[CONF_REPORT_UNKNOWN] != "Off": self.report_unknown = self.config[CONF_REPORT_UNKNOWN] @@ -578,11 +589,118 @@ def __init__(self, config, dataqueue): aeskeys=self.aeskeys, ) - def process_hci_events(self, data, gateway_id=DOMAIN): + @staticmethod + def hci_packet_on_advertisement(scanner, packet): + def _format_uuid(uuid: bytes) -> str: + if len(uuid) == 2 or len(uuid) == 4: + return "{:08x}-0000-1000-8000-00805f9b34fb".format( + struct.unpack(' packet_size: + raise Exception(f"Wrong payload start index {payload_start}") + payload_size = packet[payload_start - 1] + payload_packet_size = payload_start + payload_size + (0 if is_ext_packet else 1) + if packet_size != payload_packet_size: + raise Exception(f"Wrong packet size {packet_size}, expected {payload_packet_size}") + + tx_power = None + rssi = packet[18 if is_ext_packet else packet_size - 1] + if rssi < 128: + raise Exception(f"Positive RSSI {rssi}") + rssi -= 256 + + address_index = 8 if is_ext_packet else 7 + address_type = packet[address_index - 1] + address = ':'.join(f'{i:02X}' for i in packet[address_index:address_index + 6][::-1]) + local_name = None + service_uuids = [] + service_data = {} + manufacturer_data = {} + + while payload_size > 1: + record_size = packet[payload_start] + 1 + if 1 < record_size <= payload_size: + record = packet[payload_start:payload_start + record_size] + if record[0] != record_size - 1: + raise Exception(f"Wrong record size {record[0]}, expected {record_size - 1}") + record_type = record[1] + record = record[2:] + # Incomplete/Complete List of 16/32/128-bit Service Class UUIDs + if record_type in [0x02, 0x03, 0x04, 0x05, 0x06, 0x07]: + service_uuids.append(_format_uuid(record)) + # Shortened/Complete local name + elif record_type in [0x08, 0x09]: + name = record.decode("utf-8", errors="replace") + if local_name is None or len(name) > len(local_name): + local_name = name + # TX Power + elif record_type == 0x0A: + tx_power = record[0] + # Service Data of 16/32/128-bit UUID + elif record_type in [0x16, 0x20, 0x21]: + record_type_sizes = {0x16: 2, 0x20: 4, 0x21: 16} + uuid_size = record_type_sizes[record_type] + if len(record) < uuid_size: + raise Exception("Wrong service data 0x{:02X} size {}, expected {}".format( + record_type, len(record), record_type_sizes[record_type])) + service_data[_format_uuid(record[:uuid_size])] = record[uuid_size:] + # Manufacturer Specific Data + elif record_type == 0xFF: + manufacturer_data[(record[1] << 8) | record[0]] = record[2:] + payload_size -= record_size + payload_start += record_size + + scanner._async_on_advertisement( + address=address, + rssi=rssi, + local_name=local_name, + service_uuids=service_uuids, + service_data=service_data, + manufacturer_data=manufacturer_data, + tx_power=tx_power, + details={"address_type": address_type}, + advertisement_monotonic_time=bluetooth.MONOTONIC_TIME(), + ) + + def process_hci_events(self, data, device_id=None, gateway_id=DOMAIN, proxy=False): """Parse HCI events.""" self.evt_cnt += 1 if len(data) < 12: return + if bluetooth is not None and proxy: + try: + scanner_name = device_id or gateway_id + scanner = self.scanners.get(scanner_name) + if not scanner: + hass = async_get_hass() + device = hass.data["device_registry"].devices.get(device_id) if device_id \ + else next((entry for entry in hass.data["device_registry"].devices.data.values() + if entry.name.lower() == gateway_id.lower()), None) + source = next((connection[1] for connection in device.connections if + connection[0] in ["mac", "bluetooth"]), gateway_id) if device else gateway_id + scanner = bluetooth.BaseHaRemoteScanner(source, gateway_id, None, False) + bluetooth.async_register_scanner(hass, scanner) + self.scanners[scanner_name] = scanner + self.hci_packet_on_advertisement(scanner, data) + except Exception as e: + _LOGGER.error("%s: %s: %s", gateway_id, e, data.hex().upper()) sensor_msg, tracker_msg = self.ble_parser.parse_raw_data(data) if sensor_msg: measurements = list(sensor_msg.keys()) diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index cf504126..2b4a9f01 100755 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -47,6 +47,7 @@ CONF_DEVICE_DELETE_DEVICE = "delete device" CONF_PACKET = "packet" CONF_GATEWAY_ID = "gateway_id" +CONF_PROXY = "proxy" CONF_UUID = "uuid" CONFIG_IS_FLOW = "is_flow" From 78397fe6054f9bab193dcb6419861591b12aa756 Mon Sep 17 00:00:00 2001 From: myhomeiot Date: Sat, 4 May 2024 14:11:48 +0300 Subject: [PATCH 2/5] Fix pre-commit --- custom_components/ble_monitor/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/ble_monitor/__init__.py b/custom_components/ble_monitor/__init__.py index 75ad4c89..8bd01922 100644 --- a/custom_components/ble_monitor/__init__.py +++ b/custom_components/ble_monitor/__init__.py @@ -10,10 +10,9 @@ import janus import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import (CONF_DEVICES, CONF_DISCOVERY, CONF_MAC, - CONF_NAME, CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_STOP, - ATTR_DEVICE_ID) +from homeassistant.const import (ATTR_DEVICE_ID, CONF_DEVICES, CONF_DISCOVERY, + CONF_MAC, CONF_NAME, CONF_TEMPERATURE_UNIT, + CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_registry import async_entries_for_device @@ -34,8 +33,8 @@ CONF_DEVICE_RESET_TIMER, CONF_DEVICE_RESTORE_STATE, CONF_DEVICE_TRACK, CONF_DEVICE_TRACKER_CONSIDER_HOME, CONF_DEVICE_TRACKER_SCAN_INTERVAL, CONF_DEVICE_USE_MEDIAN, - CONF_GATEWAY_ID, CONF_PROXY, CONF_HCI_INTERFACE, CONF_LOG_SPIKES, - CONF_PACKET, CONF_PERIOD, CONF_REPORT_UNKNOWN, + CONF_GATEWAY_ID, CONF_HCI_INTERFACE, CONF_LOG_SPIKES, + CONF_PACKET, CONF_PERIOD, CONF_PROXY, CONF_REPORT_UNKNOWN, CONF_RESTORE_STATE, CONF_USE_MEDIAN, CONF_UUID, CONFIG_IS_FLOW, DEFAULT_ACTIVE_SCAN, DEFAULT_BATT_ENTITIES, DEFAULT_BT_AUTO_RESTART, DEFAULT_DEVICE_REPORT_UNKNOWN, From a658ce5e64d6920ca9dd75cfb428cf8502d41ac6 Mon Sep 17 00:00:00 2001 From: myhomeiot Date: Tue, 7 May 2024 15:24:02 +0300 Subject: [PATCH 3/5] Fix HACS and Hassfest validation --- custom_components/ble_monitor/__init__.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/custom_components/ble_monitor/__init__.py b/custom_components/ble_monitor/__init__.py index 3bce9390..e5dd575f 100644 --- a/custom_components/ble_monitor/__init__.py +++ b/custom_components/ble_monitor/__init__.py @@ -4,6 +4,7 @@ import json import logging import struct +import importlib from threading import Thread import aioblescan as aiobs @@ -17,10 +18,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import device_registry, entity_registry from homeassistant.util import dt -try: - from homeassistant.components import bluetooth -except ImportError: - bluetooth = None from .ble_parser import BleParser from .bt_helpers import (BT_INTERFACES, BT_MULTI_SELECT, DEFAULT_BT_INTERFACE, @@ -516,6 +513,10 @@ def __init__(self, config, dataqueue): self.report_unknown_whitelist = [] self.last_bt_reset = dt.now() self.scanners = {} + try: + self.bluetooth = importlib.import_module("homeassistant.components.bluetooth") + except ImportError: + self.bluetooth = None if self.config[CONF_REPORT_UNKNOWN]: if self.config[CONF_REPORT_UNKNOWN] != "Off": self.report_unknown = self.config[CONF_REPORT_UNKNOWN] @@ -588,8 +589,7 @@ def __init__(self, config, dataqueue): aeskeys=self.aeskeys, ) - @staticmethod - def hci_packet_on_advertisement(scanner, packet): + def hci_packet_on_advertisement(self, scanner, packet): def _format_uuid(uuid: bytes) -> str: if len(uuid) == 2 or len(uuid) == 4: return "{:08x}-0000-1000-8000-00805f9b34fb".format( @@ -675,7 +675,7 @@ def _format_uuid(uuid: bytes) -> str: manufacturer_data=manufacturer_data, tx_power=tx_power, details={"address_type": address_type}, - advertisement_monotonic_time=bluetooth.MONOTONIC_TIME(), + advertisement_monotonic_time=self.bluetooth.MONOTONIC_TIME(), ) def process_hci_events(self, data, device_id=None, gateway_id=DOMAIN, proxy=False): @@ -683,7 +683,7 @@ def process_hci_events(self, data, device_id=None, gateway_id=DOMAIN, proxy=Fals self.evt_cnt += 1 if len(data) < 12: return - if bluetooth is not None and proxy: + if self.bluetooth is not None and proxy: try: scanner_name = device_id or gateway_id scanner = self.scanners.get(scanner_name) @@ -694,8 +694,8 @@ def process_hci_events(self, data, device_id=None, gateway_id=DOMAIN, proxy=Fals if entry.name.lower() == gateway_id.lower()), None) source = next((connection[1] for connection in device.connections if connection[0] in ["mac", "bluetooth"]), gateway_id) if device else gateway_id - scanner = bluetooth.BaseHaRemoteScanner(source, gateway_id, None, False) - bluetooth.async_register_scanner(hass, scanner) + scanner = self.bluetooth.BaseHaRemoteScanner(source, gateway_id, None, False) + self.bluetooth.async_register_scanner(hass, scanner) self.scanners[scanner_name] = scanner self.hci_packet_on_advertisement(scanner, data) except Exception as e: From a2d446e389de88c58afa870174d71039ff167a1b Mon Sep 17 00:00:00 2001 From: myhomeiot Date: Tue, 7 May 2024 15:26:06 +0300 Subject: [PATCH 4/5] Fix pre-commit --- custom_components/ble_monitor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/ble_monitor/__init__.py b/custom_components/ble_monitor/__init__.py index e5dd575f..cc8ceee9 100644 --- a/custom_components/ble_monitor/__init__.py +++ b/custom_components/ble_monitor/__init__.py @@ -1,10 +1,10 @@ """Passive BLE monitor integration.""" import asyncio import copy +import importlib import json import logging import struct -import importlib from threading import Thread import aioblescan as aiobs From 4b25b25e0ce4285c8ebc93708590a2d0ca4c8fe4 Mon Sep 17 00:00:00 2001 From: myhomeiot Date: Sat, 11 May 2024 12:17:42 +0300 Subject: [PATCH 5/5] Fix scanners initialization and destroying --- custom_components/ble_monitor/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/custom_components/ble_monitor/__init__.py b/custom_components/ble_monitor/__init__.py index cc8ceee9..fa2c0e22 100644 --- a/custom_components/ble_monitor/__init__.py +++ b/custom_components/ble_monitor/__init__.py @@ -686,7 +686,7 @@ def process_hci_events(self, data, device_id=None, gateway_id=DOMAIN, proxy=Fals if self.bluetooth is not None and proxy: try: scanner_name = device_id or gateway_id - scanner = self.scanners.get(scanner_name) + scanner, _ = self.scanners.get(scanner_name, (None, None)) if not scanner: hass = async_get_hass() device = hass.data["device_registry"].devices.get(device_id) if device_id \ @@ -695,8 +695,9 @@ def process_hci_events(self, data, device_id=None, gateway_id=DOMAIN, proxy=Fals source = next((connection[1] for connection in device.connections if connection[0] in ["mac", "bluetooth"]), gateway_id) if device else gateway_id scanner = self.bluetooth.BaseHaRemoteScanner(source, gateway_id, None, False) - self.bluetooth.async_register_scanner(hass, scanner) - self.scanners[scanner_name] = scanner + self.scanners[scanner_name] = (scanner, [ + self.bluetooth.async_register_scanner(hass, scanner), + scanner.async_setup()]) self.hci_packet_on_advertisement(scanner, data) except Exception as e: _LOGGER.error("%s: %s: %s", gateway_id, e, data.hex().upper()) @@ -853,6 +854,9 @@ def run(self): _LOGGER.debug("%i HCI events processed for previous period", self.evt_cnt) self.evt_cnt = 0 self._event_loop.close() + for _, unload_callbacks in self.scanners.values(): + for callback in unload_callbacks: + callback() _LOGGER.debug("HCIdump thread: Run finished") def join(self, timeout=10):