diff --git a/custom_components/ble_monitor/__init__.py b/custom_components/ble_monitor/__init__.py index 8e989b1d..07b141eb 100644 --- a/custom_components/ble_monitor/__init__.py +++ b/custom_components/ble_monitor/__init__.py @@ -3,6 +3,8 @@ import copy import json import logging +import time +from functools import partial from threading import Thread import aioblescan as aiobs @@ -16,7 +18,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.util import dt from .ble_parser import BleParser from .bt_helpers import (BT_INTERFACES, BT_MULTI_SELECT, DEFAULT_BT_INTERFACE, @@ -25,22 +26,28 @@ AUTO_BINARY_SENSOR_LIST, AUTO_MANUFACTURER_DICT, AUTO_SENSOR_LIST, CONF_ACTIVE_SCAN, CONF_BATT_ENTITIES, CONF_BT_AUTO_RESTART, CONF_BT_INTERFACE, - CONF_DEVICE_ENCRYPTION_KEY, CONF_DEVICE_REPORT_UNKNOWN, - CONF_DEVICE_RESET_TIMER, CONF_DEVICE_RESTORE_STATE, - CONF_DEVICE_TRACK, CONF_DEVICE_TRACKER_CONSIDER_HOME, + CONF_DEVICE_ENCRYPTION_KEY, CONF_DEVICE_MEASUREMENT_UPDATE, + CONF_DEVICE_REPORT_UNKNOWN, 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_PACKET, CONF_PERIOD, 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, - DEFAULT_DEVICE_RESET_TIMER, DEFAULT_DEVICE_RESTORE_STATE, - DEFAULT_DEVICE_TRACK, DEFAULT_DEVICE_TRACKER_CONSIDER_HOME, + CONF_GATEWAY_ID, CONF_HCI_INACTIVITY_TIMEOUT, + CONF_HCI_INTERFACE, CONF_LOG_SPIKES, + CONF_MEASUREMENT_UPDATE, CONF_PACKET, CONF_PERIOD, + 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_MEASUREMENT_UPDATE, + DEFAULT_DEVICE_REPORT_UNKNOWN, DEFAULT_DEVICE_RESET_TIMER, + DEFAULT_DEVICE_RESTORE_STATE, DEFAULT_DEVICE_TRACK, + DEFAULT_DEVICE_TRACKER_CONSIDER_HOME, DEFAULT_DEVICE_TRACKER_SCAN_INTERVAL, DEFAULT_DEVICE_USE_MEDIAN, DEFAULT_DISCOVERY, - DEFAULT_LOG_SPIKES, DEFAULT_PERIOD, DEFAULT_REPORT_UNKNOWN, - DEFAULT_RESTORE_STATE, DEFAULT_USE_MEDIAN, DOMAIN, - MAC_REGEX, MANUFACTURER_DICT, MEASUREMENT_DICT, PLATFORMS, + DEFAULT_HCI_INACTIVITY_TIMEOUT, DEFAULT_LOG_SPIKES, + DEFAULT_MEASUREMENT_UPDATE, DEFAULT_PERIOD, + DEFAULT_REPORT_UNKNOWN, DEFAULT_RESTORE_STATE, + DEFAULT_USE_MEDIAN, DOMAIN, MAC_REGEX, MANUFACTURER_DICT, + MEASUREMENT_DICT, MEASUREMENT_UPDATE_LIST, PLATFORMS, REPORT_UNKNOWN_LIST, SERVICE_CLEANUP_ENTRIES, SERVICE_PARSE_DATA) from .helper import (config_validation_uuid, dict_get_or, dict_get_or_clean, @@ -63,6 +70,10 @@ vol.Optional(CONF_DEVICE_USE_MEDIAN, default=DEFAULT_DEVICE_USE_MEDIAN): vol.In( [DEFAULT_DEVICE_USE_MEDIAN, True, False] ), + vol.Optional( + CONF_DEVICE_MEASUREMENT_UPDATE, + default=DEFAULT_DEVICE_MEASUREMENT_UPDATE, + ): vol.In([DEFAULT_DEVICE_MEASUREMENT_UPDATE] + MEASUREMENT_UPDATE_LIST), vol.Optional( CONF_DEVICE_RESTORE_STATE, default=DEFAULT_DEVICE_RESTORE_STATE ): vol.In([DEFAULT_DEVICE_RESTORE_STATE, True, False]), @@ -97,6 +108,10 @@ vol.Optional( CONF_BT_AUTO_RESTART, default=DEFAULT_BT_AUTO_RESTART ): cv.boolean, + vol.Optional( + CONF_HCI_INACTIVITY_TIMEOUT, + default=DEFAULT_HCI_INACTIVITY_TIMEOUT, + ): cv.positive_int, vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, vol.Optional( CONF_LOG_SPIKES, default=DEFAULT_LOG_SPIKES @@ -104,6 +119,9 @@ vol.Optional( CONF_USE_MEDIAN, default=DEFAULT_USE_MEDIAN ): cv.boolean, + vol.Optional( + CONF_MEASUREMENT_UPDATE, default=DEFAULT_MEASUREMENT_UPDATE + ): vol.In(MEASUREMENT_UPDATE_LIST), vol.Optional( CONF_ACTIVE_SCAN, default=DEFAULT_ACTIVE_SCAN ): cv.boolean, @@ -224,6 +242,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): if CONF_DEVICES not in config: config[CONF_DEVICES] = [] + config.setdefault( + CONF_HCI_INACTIVITY_TIMEOUT, DEFAULT_HCI_INACTIVITY_TIMEOUT + ) + config.setdefault(CONF_MEASUREMENT_UPDATE, DEFAULT_MEASUREMENT_UPDATE) if config[CONFIG_IS_FLOW]: # Configuration in UI @@ -497,6 +519,9 @@ def restart(self): class HCIdump(Thread): """Mimic deprecated hcidump tool.""" + SCAN_RETRY_INTERVAL = 60 + WATCHDOG_CHECK_INTERVAL = 1 + def __init__(self, config, dataqueue): """Initiate HCIdump thread.""" Thread.__init__(self) @@ -506,10 +531,15 @@ def __init__(self, config, dataqueue): self.dataqueue_tracker = dataqueue["tracker"] self._event_loop = None self._joining = False - self.evt_cnt = 0 + self.evt_cnt = {} self.config = config self._interfaces = list(set(config[CONF_HCI_INTERFACE])) self._active = int(config[CONF_ACTIVE_SCAN] is True) + self._inactivity_timeout = config.get( + CONF_HCI_INACTIVITY_TIMEOUT, DEFAULT_HCI_INACTIVITY_TIMEOUT + ) + self._last_hci_activity = {} + self._watchdog_handle = None self.discovery = True self.filter_duplicates = True self.aeskeys = {} @@ -517,7 +547,7 @@ def __init__(self, config, dataqueue): self.tracker_whitelist = [] self.report_unknown = False self.report_unknown_whitelist = [] - self.last_bt_reset = dt.now() + self.last_bt_reset = time.monotonic() if self.config[CONF_REPORT_UNKNOWN]: if self.config[CONF_REPORT_UNKNOWN] != "Off": self.report_unknown = self.config[CONF_REPORT_UNKNOWN] @@ -590,12 +620,18 @@ def __init__(self, config, dataqueue): aeskeys=self.aeskeys, ) - def process_hci_events(self, data, gateway_id=DOMAIN): + def process_hci_events(self, data, gateway_id=DOMAIN, hci=None): """Parse HCI events.""" - self.evt_cnt += 1 + if hci is not None: + self._record_hci_activity(hci) + self.evt_cnt[hci] = self.evt_cnt.get(hci, 0) + 1 if len(data) < 12: return - sensor_msg, tracker_msg = self.ble_parser.parse_raw_data(data) + for sensor_msg, tracker_msg in self.ble_parser.parse_raw_data_reports(data): + self._enqueue_parsed_messages(sensor_msg, tracker_msg, gateway_id) + + def _enqueue_parsed_messages(self, sensor_msg, tracker_msg, gateway_id): + """Enqueue one parsed sensor/tracker advertisement report.""" if sensor_msg: measurements = list(sensor_msg.keys()) device_type = sensor_msg["type"] @@ -624,6 +660,51 @@ def process_hci_events(self, data, gateway_id=DOMAIN): tracker_msg[CONF_GATEWAY_ID] = gateway_id self.dataqueue_tracker.sync_q.put_nowait(tracker_msg) + def _record_hci_activity(self, hci, now=None): + """Record receipt of any raw HCI event for an adapter.""" + self._last_hci_activity[hci] = time.monotonic() if now is None else now + + def _inactive_interfaces(self, now=None): + """Return adapters that have exceeded the raw-HCI inactivity timeout.""" + if self._inactivity_timeout == 0: + return [] + now = time.monotonic() if now is None else now + return [ + hci + for hci, last_activity in self._last_hci_activity.items() + if now - last_activity >= self._inactivity_timeout + ] + + def _check_inactivity(self): + """Restart the scanner event loop after raw-HCI inactivity.""" + if self._joining: + return + inactive = self._inactive_interfaces() + if inactive: + _LOGGER.warning( + "HCIdump thread: No raw HCI events received for %s seconds on %s; " + "restarting scanner", + self._inactivity_timeout, + ", ".join(f"hci{hci}" for hci in inactive), + ) + self._event_loop.stop() + return + self._schedule_watchdog() + + def _schedule_watchdog(self): + """Schedule the next inactivity check when the watchdog is enabled.""" + if self._inactivity_timeout > 0 and not self._joining: + self._watchdog_handle = self._event_loop.call_later( + min(self.WATCHDOG_CHECK_INTERVAL, self._inactivity_timeout), + self._check_inactivity, + ) + + def _schedule_scan_retry(self): + """Schedule a rate-limited retry after scan setup fails.""" + return self._event_loop.call_later( + self.SCAN_RETRY_INTERVAL, self._event_loop.stop + ) + def run(self): """Run HCIdump thread.""" while True: @@ -635,6 +716,7 @@ def run(self): interface_is_ok = {} interfaces_to_reset = [] initialized_evt = {} + retry_handle = None if self._event_loop is None: self._event_loop = asyncio.new_event_loop() asyncio.set_event_loop(self._event_loop) @@ -672,7 +754,9 @@ def run(self): fac[hci].close() mysocket[hci].close() else: - btctrl[hci].process = self.process_hci_events + btctrl[hci].process = partial( + self.process_hci_events, hci=hci + ) _LOGGER.debug("HCIdump thread: connected to hci%i", hci) try: self._event_loop.run_until_complete( @@ -689,17 +773,20 @@ def run(self): mysocket[hci].close() else: interface_is_ok[hci] = True + self._record_hci_activity(hci) _LOGGER.debug( "HCIdump thread: BLEScanRequester._initialized is %s for hci%i, " " connection established, send_scan_request succeeded.", initialized_evt[hci].is_set(), hci, ) + if interface_is_ok[hci] is False: + self._last_hci_activity.pop(hci, None) if (interface_is_ok[hci] is False) and (self.config[CONF_BT_AUTO_RESTART] is True): interfaces_to_reset.append(hci) if interfaces_to_reset: - ts_now = dt.now() - if (ts_now - self.last_bt_reset).seconds > 60: + ts_now = time.monotonic() + if ts_now - self.last_bt_reset > 60: for iface in interfaces_to_reset: _LOGGER.error( "HCIdump thread: Trying to power cycle Bluetooth adapter hci%i %s," @@ -709,11 +796,19 @@ def run(self): ) reset_bluetooth(iface) self.last_bt_reset = ts_now + if not all(interface_is_ok.values()): + retry_handle = self._schedule_scan_retry() + self._schedule_watchdog() _LOGGER.debug("HCIdump thread: start main event_loop") try: self._event_loop.run_forever() finally: _LOGGER.debug("HCIdump thread: main event_loop stopped, finishing.") + if self._watchdog_handle is not None: + self._watchdog_handle.cancel() + self._watchdog_handle = None + if retry_handle is not None: + retry_handle.cancel() if "disable" not in self.config[CONF_BT_INTERFACE]: for hci in self._interfaces: if interface_is_ok[hci] is True: @@ -741,12 +836,15 @@ def run(self): "HCIdump thread: Key error while closing connection on hci%i", hci, ) + self._last_hci_activity.pop(hci, None) self._event_loop.run_until_complete(asyncio.sleep(0)) if self._joining is True: break _LOGGER.debug("HCIdump thread: Scanning will be restarted") - _LOGGER.debug("%i HCI events processed for previous period", self.evt_cnt) - self.evt_cnt = 0 + _LOGGER.debug( + "HCI events processed for previous scan: %s", self.evt_cnt + ) + self.evt_cnt = {} self._event_loop.close() _LOGGER.debug("HCIdump thread: Run finished") diff --git a/custom_components/ble_monitor/ble_parser/__init__.py b/custom_components/ble_monitor/ble_parser/__init__.py index 1ceb0bf4..b0ecba7c 100644 --- a/custom_components/ble_monitor/ble_parser/__init__.py +++ b/custom_components/ble_monitor/ble_parser/__init__.py @@ -94,6 +94,48 @@ def __init__( def parse_raw_data(self, data): """Parse the raw data.""" + reports = self.parse_raw_data_reports(data) + if not reports: + return None, None + return reports[0] + + def parse_raw_data_reports(self, data): + """Parse every advertisement report in one HCI LE meta-event.""" + reports = [] + for report in self._split_hci_reports(data): + sensor_data, tracker_data = self._parse_single_report(report) + if sensor_data or tracker_data: + reports.append((sensor_data, tracker_data)) + return reports + + @staticmethod + def _split_hci_reports(data): + """Yield synthetic single-report events from legacy or extended events.""" + if len(data) < 5 or data[3] not in (0x02, 0x0D): + return [] + subevent = data[3] + fixed_size = 9 if subevent == 0x02 else 24 + data_length_offset = 8 if subevent == 0x02 else 23 + event_end = min(len(data), data[2] + 3) + offset = 5 + split_reports = [] + for _ in range(data[4]): + if offset + fixed_size > event_end: + break + payload_size = data[offset + data_length_offset] + report_size = fixed_size + payload_size + (1 if subevent == 0x02 else 0) + report_end = offset + report_size + if report_end > event_end: + break + report = data[offset:report_end] + split_reports.append( + bytes((data[0], data[1], len(report) + 2, subevent, 1)) + report + ) + offset = report_end + return split_reports + + def _parse_single_report(self, data): + """Parse one legacy or extended advertisement report.""" # check if packet is Extended scan result is_ext_packet = True if data[3] == 0x0D else False # check for no BR/EDR + LE General discoverable mode flags diff --git a/custom_components/ble_monitor/config_flow.py b/custom_components/ble_monitor/config_flow.py index 8ebafea4..da97fa34 100644 --- a/custom_components/ble_monitor/config_flow.py +++ b/custom_components/ble_monitor/config_flow.py @@ -16,23 +16,27 @@ from . import BT_MULTI_SELECT, DEFAULT_BT_INTERFACE from .const import (CONF_ACTIVE_SCAN, CONF_BT_AUTO_RESTART, CONF_BT_INTERFACE, CONF_DEVICE_DELETE_DEVICE, CONF_DEVICE_ENCRYPTION_KEY, - CONF_DEVICE_REPORT_UNKNOWN, CONF_DEVICE_RESET_TIMER, - CONF_DEVICE_RESTORE_STATE, CONF_DEVICE_TRACK, - CONF_DEVICE_TRACKER_CONSIDER_HOME, + CONF_DEVICE_MEASUREMENT_UPDATE, CONF_DEVICE_REPORT_UNKNOWN, + 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_LOG_SPIKES, CONF_PERIOD, CONF_REPORT_UNKNOWN, + CONF_HCI_INACTIVITY_TIMEOUT, CONF_LOG_SPIKES, + CONF_MEASUREMENT_UPDATE, CONF_PERIOD, CONF_REPORT_UNKNOWN, CONF_RESTORE_STATE, CONF_USE_MEDIAN, CONF_UUID, CONFIG_IS_FLOW, DEFAULT_ACTIVE_SCAN, DEFAULT_BT_AUTO_RESTART, DEFAULT_DEVICE_DELETE_DEVICE, DEFAULT_DEVICE_ENCRYPTION_KEY, DEFAULT_DEVICE_MAC, + DEFAULT_DEVICE_MEASUREMENT_UPDATE, DEFAULT_DEVICE_REPORT_UNKNOWN, DEFAULT_DEVICE_RESET_TIMER, DEFAULT_DEVICE_RESTORE_STATE, DEFAULT_DEVICE_TRACK, DEFAULT_DEVICE_TRACKER_CONSIDER_HOME, DEFAULT_DEVICE_TRACKER_SCAN_INTERVAL, DEFAULT_DEVICE_USE_MEDIAN, DEFAULT_DEVICE_UUID, - DEFAULT_DISCOVERY, DEFAULT_LOG_SPIKES, DEFAULT_PERIOD, - DEFAULT_REPORT_UNKNOWN, DEFAULT_RESTORE_STATE, - DEFAULT_USE_MEDIAN, DOMAIN, REPORT_UNKNOWN_LIST) + DEFAULT_DISCOVERY, DEFAULT_HCI_INACTIVITY_TIMEOUT, + DEFAULT_LOG_SPIKES, DEFAULT_MEASUREMENT_UPDATE, + DEFAULT_PERIOD, DEFAULT_REPORT_UNKNOWN, + DEFAULT_RESTORE_STATE, DEFAULT_USE_MEDIAN, DOMAIN, + MEASUREMENT_UPDATE_LIST, REPORT_UNKNOWN_LIST) from .helper import (detect_conf_type, dict_get_key_or, dict_get_or, validate_key, validate_mac, validate_uuid) @@ -54,6 +58,10 @@ vol.Optional(CONF_DEVICE_USE_MEDIAN, default=DEFAULT_DEVICE_USE_MEDIAN): vol.In( [DEFAULT_DEVICE_USE_MEDIAN, True, False] ), + vol.Optional( + CONF_DEVICE_MEASUREMENT_UPDATE, + default=DEFAULT_DEVICE_MEASUREMENT_UPDATE, + ): vol.In([DEFAULT_DEVICE_MEASUREMENT_UPDATE] + MEASUREMENT_UPDATE_LIST), vol.Optional( CONF_DEVICE_RESTORE_STATE, default=DEFAULT_DEVICE_RESTORE_STATE ): vol.In([DEFAULT_DEVICE_RESTORE_STATE, True, False]), @@ -85,9 +93,15 @@ CONF_BT_INTERFACE, default=[DEFAULT_BT_INTERFACE] ): cv.multi_select(BT_MULTI_SELECT), vol.Optional(CONF_BT_AUTO_RESTART, default=DEFAULT_BT_AUTO_RESTART): cv.boolean, + vol.Optional( + CONF_HCI_INACTIVITY_TIMEOUT, default=DEFAULT_HCI_INACTIVITY_TIMEOUT + ): cv.positive_int, vol.Optional(CONF_ACTIVE_SCAN, default=DEFAULT_ACTIVE_SCAN): cv.boolean, vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean, vol.Optional(CONF_USE_MEDIAN, default=DEFAULT_USE_MEDIAN): cv.boolean, + vol.Optional( + CONF_MEASUREMENT_UPDATE, default=DEFAULT_MEASUREMENT_UPDATE + ): vol.In(MEASUREMENT_UPDATE_LIST), vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, vol.Optional(CONF_LOG_SPIKES, default=DEFAULT_LOG_SPIKES): cv.boolean, vol.Optional(CONF_RESTORE_STATE, default=DEFAULT_RESTORE_STATE): cv.boolean, @@ -203,6 +217,13 @@ async def async_step_add_remove_device(self, user_input=None): CONF_DEVICE_USE_MEDIAN, default=user_input[CONF_DEVICE_USE_MEDIAN], ): vol.In([DEFAULT_DEVICE_USE_MEDIAN, True, False]), + vol.Optional( + CONF_DEVICE_MEASUREMENT_UPDATE, + default=user_input[CONF_DEVICE_MEASUREMENT_UPDATE], + ): vol.In( + [DEFAULT_DEVICE_MEASUREMENT_UPDATE] + + MEASUREMENT_UPDATE_LIST + ), vol.Optional( CONF_DEVICE_RESTORE_STATE, default=user_input[CONF_DEVICE_RESTORE_STATE], @@ -285,6 +306,15 @@ async def async_step_add_remove_device(self, user_input=None): CONF_DEVICE_USE_MEDIAN, DEFAULT_DEVICE_USE_MEDIAN ), ): vol.In([DEFAULT_DEVICE_USE_MEDIAN, True, False]), + vol.Optional( + CONF_DEVICE_MEASUREMENT_UPDATE, + default=self._sel_device.get( + CONF_DEVICE_MEASUREMENT_UPDATE, + DEFAULT_DEVICE_MEASUREMENT_UPDATE, + ), + ): vol.In( + [DEFAULT_DEVICE_MEASUREMENT_UPDATE] + MEASUREMENT_UPDATE_LIST + ), vol.Optional( CONF_DEVICE_RESTORE_STATE, default=self._sel_device.get( @@ -411,6 +441,13 @@ def _show_main_form(self, errors=None): CONF_BT_AUTO_RESTART, DEFAULT_BT_AUTO_RESTART ), ): cv.boolean, + vol.Optional( + CONF_HCI_INACTIVITY_TIMEOUT, + default=self.config_entry.options.get( + CONF_HCI_INACTIVITY_TIMEOUT, + DEFAULT_HCI_INACTIVITY_TIMEOUT, + ), + ): cv.positive_int, vol.Optional( CONF_ACTIVE_SCAN, default=self.config_entry.options.get( @@ -435,6 +472,12 @@ def _show_main_form(self, errors=None): CONF_USE_MEDIAN, DEFAULT_USE_MEDIAN ), ): cv.boolean, + vol.Optional( + CONF_MEASUREMENT_UPDATE, + default=self.config_entry.options.get( + CONF_MEASUREMENT_UPDATE, DEFAULT_MEASUREMENT_UPDATE + ), + ): vol.In(MEASUREMENT_UPDATE_LIST), vol.Optional( CONF_LOG_SPIKES, default=self.config_entry.options.get( diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 6618e6a0..215c8ea7 100644 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -28,9 +28,11 @@ # Configuration options CONF_BT_AUTO_RESTART = "bt_auto_restart" +CONF_HCI_INACTIVITY_TIMEOUT = "hci_inactivity_timeout" CONF_PERIOD = "period" CONF_LOG_SPIKES = "log_spikes" CONF_USE_MEDIAN = "use_median" +CONF_MEASUREMENT_UPDATE = "measurement_update" CONF_ACTIVE_SCAN = "active_scan" CONF_HCI_INTERFACE = "hci_interface" CONF_BT_INTERFACE = "bt_interface" @@ -39,6 +41,7 @@ CONF_RESTORE_STATE = "restore_state" CONF_DEVICE_ENCRYPTION_KEY = "encryption_key" CONF_DEVICE_USE_MEDIAN = "use_median" +CONF_DEVICE_MEASUREMENT_UPDATE = "measurement_update" CONF_DEVICE_REPORT_UNKNOWN = "report_unknown" CONF_DEVICE_RESTORE_STATE = "restore_state" CONF_DEVICE_RESET_TIMER = "reset_timer" @@ -56,9 +59,13 @@ # Default values for configuration options DEFAULT_BT_AUTO_RESTART = False +DEFAULT_HCI_INACTIVITY_TIMEOUT = 60 DEFAULT_PERIOD = 60 DEFAULT_LOG_SPIKES = False DEFAULT_USE_MEDIAN = False +DEFAULT_MEASUREMENT_UPDATE = "periodic" +DEFAULT_DEVICE_MEASUREMENT_UPDATE = "default" +MEASUREMENT_UPDATE_LIST = ["periodic", "instant"] DEFAULT_ACTIVE_SCAN = False DEFAULT_BATT_ENTITIES = True DEFAULT_REPORT_UNKNOWN = "Off" diff --git a/custom_components/ble_monitor/sensor.py b/custom_components/ble_monitor/sensor.py index 841a6eb9..1d6cda0d 100644 --- a/custom_components/ble_monitor/sensor.py +++ b/custom_components/ble_monitor/sensor.py @@ -16,12 +16,14 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import (AUTO_MANUFACTURER_DICT, AUTO_SENSOR_LIST, - CONF_DEVICE_RESET_TIMER, CONF_DEVICE_RESTORE_STATE, - CONF_DEVICE_USE_MEDIAN, CONF_HMAX, CONF_HMIN, - CONF_LOG_SPIKES, CONF_PERIOD, CONF_RESTORE_STATE, + CONF_DEVICE_MEASUREMENT_UPDATE, CONF_DEVICE_RESET_TIMER, + CONF_DEVICE_RESTORE_STATE, CONF_DEVICE_USE_MEDIAN, + CONF_HMAX, CONF_HMIN, CONF_LOG_SPIKES, + CONF_MEASUREMENT_UPDATE, CONF_PERIOD, CONF_RESTORE_STATE, CONF_TMAX, CONF_TMAX_KETTLES, CONF_TMAX_PROBES, CONF_TMIN, CONF_TMIN_KETTLES, CONF_TMIN_PROBES, CONF_USE_MEDIAN, - CONF_UUID, DEFAULT_DEVICE_RESET_TIMER, DOMAIN, KETTLES, + CONF_UUID, DEFAULT_DEVICE_RESET_TIMER, + DEFAULT_MEASUREMENT_UPDATE, DOMAIN, KETTLES, MANUFACTURER_DICT, MEASUREMENT_DICT, PROBES, RENAMED_FIRMWARE_DICT, RENAMED_MANUFACTURER_DICT, RENAMED_MODEL_DICT, SENSOR_TYPES, @@ -88,6 +90,16 @@ def __init__(self, blemonitor, add_entities): self.add_entities = add_entities _LOGGER.debug("BLE sensors updater initialized") + @staticmethod + async def async_publish_instant(entity, rssi): + """Publish an instant value only after Home Assistant attaches the entity.""" + if entity.ready_for_update is not True: + return False + entity.rssi_values = [int(rssi)] + await entity.async_update() + entity.async_write_ha_state() + return True + async def async_run(self, hass): """Entities updater loop.""" @@ -265,6 +277,12 @@ async def async_add_sensor(key, device_model, firmware, auto_sensors, manufactur instant_sensors = MEASUREMENT_DICT[device_model][1] entity.collect(data, period_cnt, batt_attr) if ( + isinstance(entity, MeasuringSensor) + and entity.instant_update + and entity.pending_update is True + ): + await self.async_publish_instant(entity, data["rssi"]) + elif ( measurement in instant_sensors or ts_now - ts_restart < timedelta(seconds=self.period) ): @@ -280,8 +298,6 @@ async def async_add_sensor(key, device_model, firmware, auto_sensors, manufactur continue ts_last_update = ts_now period_cnt += 1 - # restarting scanner - self.monitor.restart() # updating the state for every updated measuring device for key, edict in sensors_by_key.items(): for entity in edict.values(): @@ -575,6 +591,9 @@ def get_device_settings(self): dev_name = self._key dev_temperature_unit = UnitOfTemperature.CELSIUS dev_use_median = self._config[CONF_USE_MEDIAN] + dev_measurement_update = self._config.get( + CONF_MEASUREMENT_UPDATE, DEFAULT_MEASUREMENT_UPDATE + ) dev_restore_state = self._config[CONF_RESTORE_STATE] dev_reset_timer = DEFAULT_DEVICE_RESET_TIMER @@ -604,12 +623,18 @@ def get_device_settings(self): dev_restore_state = device[CONF_DEVICE_RESTORE_STATE] else: dev_restore_state = self._config[CONF_RESTORE_STATE] + if CONF_DEVICE_MEASUREMENT_UPDATE in device: + if device[CONF_DEVICE_MEASUREMENT_UPDATE] != "default": + dev_measurement_update = device[ + CONF_DEVICE_MEASUREMENT_UPDATE + ] if CONF_DEVICE_RESET_TIMER in device: dev_reset_timer = device[CONF_DEVICE_RESET_TIMER] device_settings = { "name": dev_name, "temperature unit": dev_temperature_unit, "use median": dev_use_median, + "measurement update": dev_measurement_update, "restore_state": dev_restore_state, "reset_timer": dev_reset_timer, } @@ -639,14 +664,22 @@ def __init__(self, config, key, devtype, firmware, entity_description, manufactu super().__init__(config, key, devtype, firmware, entity_description, manufacturer) self._jagged = False self._use_median = self._device_settings["use median"] + self._measurement_update = self._device_settings["measurement update"] self._period_cnt = 0 + @property + def instant_update(self): + """Return whether this entity publishes every accepted packet.""" + return self._measurement_update == "instant" + def collect(self, data, period_cnt, batt_attr=None): """Measurements collector.""" if self.enabled is False: self.pending_update = False return self._period_cnt = period_cnt + if self._measurement_update == "instant": + self._measurements.clear() self._measurements.append(data[self.entity_description.key]) self._extra_state_attributes["sensor_type"] = data["type"] self._extra_state_attributes["last_packet_id"] = data["packet"] @@ -664,6 +697,21 @@ async def async_update(self): textattr = "" try: measurements = self._measurements + if self._measurement_update == "instant": + self._state = measurements[-1] + if self.entity_description.key != "rssi" and self.rssi_values: + self._extra_state_attributes["rssi"] = self.rssi_values[-1] + for attribute in ( + "median", + "mean", + "last_median_of", + "last_mean_of", + ): + self._extra_state_attributes.pop(attribute, None) + self._measurements.clear() + self.rssi_values.clear() + self.pending_update = False + return state_median = sts.median(measurements) state_mean = sts.mean(measurements) if self._use_median: @@ -758,6 +806,8 @@ def collect(self, data, period_cnt, batt_attr=None): ) self.pending_update = False return + if self._measurement_update == "instant": + self._measurements.clear() self._measurements.append(data[self.entity_description.key]) self._extra_state_attributes["sensor_type"] = data["type"] self._extra_state_attributes["last_packet_id"] = data["packet"] @@ -804,8 +854,12 @@ def collect(self, data, period_cnt, batt_attr=None): self.pending_update = False return if self._jagged is True: + if self._measurement_update == "instant": + self._measurements.clear() self._measurements.append(int(data[self.entity_description.key])) else: + if self._measurement_update == "instant": + self._measurements.clear() self._measurements.append(data[self.entity_description.key]) self._extra_state_attributes["sensor_type"] = data["type"] self._extra_state_attributes["last_packet_id"] = data["packet"] diff --git a/custom_components/ble_monitor/strings.json b/custom_components/ble_monitor/strings.json index 3e886fc3..7739566f 100644 --- a/custom_components/ble_monitor/strings.json +++ b/custom_components/ble_monitor/strings.json @@ -11,10 +11,12 @@ "period": "Period to use for averaging", "log_spikes": "Log spikes", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "active_scan": "Use active scan instead of passive scan (affects battery)", "restore_state": "Restore state after a restart", "report_unknown": "Report unknown BLE advertisements to the HA log", - "bt_auto_restart": "Automatically restart Bluetooth adapter on failure" + "bt_auto_restart": "Automatically restart Bluetooth adapter on failure", + "hci_inactivity_timeout": "Raw HCI inactivity timeout (seconds, 0 = off)" } }, "add_remove_device": { @@ -25,6 +27,7 @@ "encryption_key": "Encryption key", "temperature_unit": "Temperature unit [as send by device]", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "restore_state": "Restore state after a restart", "reset_timer": "Reset timer (in seconds, 0 = off)", "report_unknown": "Report unknown BLE advertisements to the HA log", @@ -60,9 +63,11 @@ "period": "Period to use for averaging", "log_spikes": "Log spikes", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "active_scan": "Use active scan in stead of passive scan (affects battery)", "report_unknown": "Report unknown BLE advertisements to the HA log", "bt_auto_restart": "Automatically restart Bluetooth adapter on failure", + "hci_inactivity_timeout": "Raw HCI inactivity timeout (seconds, 0 = off)", "restore_state": "Restore state after a restart", "not_in_use": "YAML is used, change options in YAML" } @@ -75,6 +80,7 @@ "encryption_key": "Encryption key (set to '-' to remove)", "temperature_unit": "Temperature unit [as send by device]", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "restore_state": "Restore state after a restart", "reset_timer": "Reset timer (in seconds, 0 = off)", "report_unknown": "Report unknown BLE advertisements to the HA log", diff --git a/custom_components/ble_monitor/test/test_atc_parser.py b/custom_components/ble_monitor/test/test_atc_parser.py index f4b81481..f8a6bc53 100644 --- a/custom_components/ble_monitor/test/test_atc_parser.py +++ b/custom_components/ble_monitor/test/test_atc_parser.py @@ -62,6 +62,26 @@ def test_atc_custom(self): assert sensor_msg["battery"] == 88 assert sensor_msg["rssi"] == -34 + def test_atc_custom_duplicate_packet_filter(self): + """Suppress retransmissions while accepting the next packet ID.""" + first = bytes.fromhex( + "043e1f02010000f4830238c1a41312161a18f4830238c1a4a9066911b60b58f70dde" + ) + second = bytes.fromhex( + "043e1f02010000f4830238c1a41312161a18f4830238c1a4aa066911b60b58f80dde" + ) + ble_parser = BleParser(filter_duplicates=True) + + first_msg, _ = ble_parser.parse_raw_data(first) + duplicate_msg, _ = ble_parser.parse_raw_data(first) + second_msg, _ = ble_parser.parse_raw_data(second) + + assert first_msg["packet"] == 247 + assert duplicate_msg is None + assert second_msg["packet"] == 248 + assert first_msg["temperature"] == 17.05 + assert second_msg["temperature"] == 17.06 + def test_atc_custom_v2_9(self): """Test ATC parser for ATC custom format (firmware version 2.9 and above).""" data_string = "043E2202010000B2188D38C1A41602010612161A18B2188D38C1A42B089011F70A43200FC2" diff --git a/custom_components/ble_monitor/test/test_hcidump_watchdog.py b/custom_components/ble_monitor/test/test_hcidump_watchdog.py new file mode 100644 index 00000000..8db219ab --- /dev/null +++ b/custom_components/ble_monitor/test/test_hcidump_watchdog.py @@ -0,0 +1,95 @@ +"""Tests for the raw-HCI inactivity watchdog.""" +from unittest.mock import Mock, patch + +from ble_monitor.const import (CONF_HCI_INACTIVITY_TIMEOUT, + DEFAULT_HCI_INACTIVITY_TIMEOUT, DOMAIN) + +from ble_monitor import CONFIG_SCHEMA, HCIdump + + +def make_hcidump(timeout=60): + """Create a minimal HCIdump without opening queues or Bluetooth sockets.""" + hcidump = HCIdump.__new__(HCIdump) + hcidump._inactivity_timeout = timeout + hcidump._last_hci_activity = {} + hcidump._joining = False + hcidump._event_loop = Mock() + hcidump._watchdog_handle = None + hcidump.evt_cnt = {} + return hcidump + + +def test_watchdog_configuration_defaults_to_sixty_and_accepts_zero(): + """The watchdog is enabled by default and can be explicitly disabled.""" + assert CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN][CONF_HCI_INACTIVITY_TIMEOUT] == ( + DEFAULT_HCI_INACTIVITY_TIMEOUT + ) + assert CONFIG_SCHEMA( + {DOMAIN: {CONF_HCI_INACTIVITY_TIMEOUT: 0}} + )[DOMAIN][CONF_HCI_INACTIVITY_TIMEOUT] == 0 + + +def test_activity_is_tracked_per_adapter_before_packet_validation(): + """Every raw event refreshes only its source adapter, even if malformed.""" + hcidump = make_hcidump() + + with patch("ble_monitor.time.monotonic", return_value=12.5): + hcidump.process_hci_events(b"short", hci=1) + + assert hcidump._last_hci_activity == {1: 12.5} + assert hcidump.evt_cnt == {1: 1} + + +def test_new_activity_extends_timeout_for_one_adapter(): + """Activity on one adapter does not hide another adapter becoming stale.""" + hcidump = make_hcidump(timeout=60) + hcidump._record_hci_activity(0, now=10) + hcidump._record_hci_activity(1, now=30) + hcidump._record_hci_activity(1, now=69) + + assert hcidump._inactive_interfaces(now=70) == [0] + + +def test_timeout_stops_event_loop_instead_of_rescheduling(): + """An expired watchdog stops the loop so run() reopens the HCI sockets.""" + hcidump = make_hcidump(timeout=60) + hcidump._record_hci_activity(0, now=1) + + with patch("ble_monitor.time.monotonic", return_value=61): + hcidump._check_inactivity() + + hcidump._event_loop.stop.assert_called_once_with() + hcidump._event_loop.call_later.assert_not_called() + + +def test_zero_timeout_disables_watchdog(): + """A zero timeout neither detects inactivity nor schedules checks.""" + hcidump = make_hcidump(timeout=0) + hcidump._record_hci_activity(0, now=1) + + assert hcidump._inactive_interfaces(now=1000) == [] + hcidump._schedule_watchdog() + hcidump._event_loop.call_later.assert_not_called() + + +def test_shutdown_prevents_watchdog_reschedule(): + """A watchdog callback racing with shutdown does no further work.""" + hcidump = make_hcidump() + hcidump._joining = True + + hcidump._check_inactivity() + + hcidump._event_loop.stop.assert_not_called() + hcidump._event_loop.call_later.assert_not_called() + + +def test_failed_scan_retry_is_rate_limited(): + """Scan setup retries use the fixed delay and stop the current loop.""" + hcidump = make_hcidump() + handle = Mock() + hcidump._event_loop.call_later.return_value = handle + + assert hcidump._schedule_scan_retry() is handle + hcidump._event_loop.call_later.assert_called_once_with( + hcidump.SCAN_RETRY_INTERVAL, hcidump._event_loop.stop + ) diff --git a/custom_components/ble_monitor/test/test_measurement_update.py b/custom_components/ble_monitor/test/test_measurement_update.py new file mode 100644 index 00000000..910a9f86 --- /dev/null +++ b/custom_components/ble_monitor/test/test_measurement_update.py @@ -0,0 +1,126 @@ +"""Tests for periodic and instant measurement updates.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from ble_monitor.const import (CONF_DEVICE_MEASUREMENT_UPDATE, + CONF_MEASUREMENT_UPDATE, + DEFAULT_MEASUREMENT_UPDATE, DOMAIN) +from ble_monitor.sensor import BaseSensor, BLEupdater, MeasuringSensor +from homeassistant.const import CONF_DEVICES + +from ble_monitor import CONFIG_SCHEMA + + +def make_sensor(policy, measurements, rssi_values): + """Create a minimal measuring entity for update-policy tests.""" + sensor = MeasuringSensor.__new__(MeasuringSensor) + sensor._measurement_update = policy + sensor._measurements = list(measurements) + sensor.rssi_values = list(rssi_values) + sensor._use_median = False + sensor._period_cnt = 1 + sensor._extra_state_attributes = {} + sensor._state = None + sensor._err = None + sensor.pending_update = True + sensor.entity_description = SimpleNamespace(key="temperature", name="Temperature") + return sensor + + +def test_configuration_default_and_values(): + """Periodic is the default and both supported policies validate.""" + assert CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN][CONF_MEASUREMENT_UPDATE] == ( + DEFAULT_MEASUREMENT_UPDATE + ) + for policy in ("periodic", "instant"): + assert CONFIG_SCHEMA( + {DOMAIN: {CONF_MEASUREMENT_UPDATE: policy}} + )[DOMAIN][CONF_MEASUREMENT_UPDATE] == policy + + +def test_device_override_and_inheritance(): + """A device can override or inherit the global update policy.""" + sensor = BaseSensor.__new__(BaseSensor) + sensor._key = sensor._fkey = "A4C1380283F4" + sensor._type = "mac" + base = { + CONF_MEASUREMENT_UPDATE: "instant", + "use_median": False, + "restore_state": False, + } + sensor._config = {**base, CONF_DEVICES: [{"mac": sensor._fkey, CONF_DEVICE_MEASUREMENT_UPDATE: "default"}]} + assert sensor.get_device_settings()["measurement update"] == "instant" + sensor._config[CONF_DEVICES][0][CONF_DEVICE_MEASUREMENT_UPDATE] = "periodic" + assert sensor.get_device_settings()["measurement update"] == "periodic" + + +def test_instant_uses_latest_raw_value_and_current_rssi_only(): + """Instant updates do not combine values or RSSI between packets.""" + sensor = make_sensor("instant", [17.05], [-73]) + asyncio.run(sensor.async_update()) + assert sensor.native_value == 17.05 + assert sensor._extra_state_attributes["rssi"] == -73 + assert sensor._measurements == [] + assert sensor.rssi_values == [] + for attribute in ("median", "mean", "last_median_of", "last_mean_of"): + assert attribute not in sensor._extra_state_attributes + + +def test_instant_removes_restored_aggregation_attributes(): + """Switching to instant mode removes attributes restored from periodic mode.""" + sensor = make_sensor("instant", [17.05], [-73]) + sensor._extra_state_attributes.update( + {"median": 17.0, "mean": 17.1, "last_median_of": 3, "last_mean_of": 3} + ) + asyncio.run(sensor.async_update()) + assert sensor._extra_state_attributes == {"rssi": -73} + + +def test_updater_does_not_write_entity_before_it_is_ready(): + """An unattached entity retains its pending value without a HA state write.""" + entity = SimpleNamespace( + ready_for_update=False, + rssi_values=[], + async_update=AsyncMock(), + async_write_ha_state=Mock(), + ) + assert asyncio.run(BLEupdater.async_publish_instant(entity, -73)) is False + entity.async_update.assert_not_awaited() + entity.async_write_ha_state.assert_not_called() + + +def test_updater_writes_ready_entity_immediately(): + """An attached entity receives current RSSI and is written immediately.""" + entity = SimpleNamespace( + ready_for_update=True, + rssi_values=[], + async_update=AsyncMock(), + async_write_ha_state=Mock(), + ) + assert asyncio.run(BLEupdater.async_publish_instant(entity, -71)) is True + assert entity.rssi_values == [-71] + entity.async_update.assert_awaited_once_with() + entity.async_write_ha_state.assert_called_once_with() + + +def test_two_new_packets_produce_two_raw_instant_states(): + """Consecutive accepted packets are never combined in instant mode.""" + sensor = make_sensor("instant", [17.05], [-73]) + asyncio.run(sensor.async_update()) + states = [sensor.native_value] + + sensor._measurements.append(17.06) + sensor.rssi_values.append(-71) + asyncio.run(sensor.async_update()) + states.append(sensor.native_value) + + assert states == [17.05, 17.06] + + +def test_periodic_retains_mean_behavior(): + """Periodic updates retain existing mean aggregation.""" + sensor = make_sensor("periodic", [17.01, 17.05], [-70, -74]) + asyncio.run(sensor.async_update()) + assert sensor.native_value == 17.03 + assert sensor._extra_state_attributes["rssi"] == -72 diff --git a/custom_components/ble_monitor/test/test_multi_report_parser.py b/custom_components/ble_monitor/test/test_multi_report_parser.py new file mode 100644 index 00000000..159405d3 --- /dev/null +++ b/custom_components/ble_monitor/test/test_multi_report_parser.py @@ -0,0 +1,89 @@ +"""Tests for batched HCI LE advertising reports.""" +from types import SimpleNamespace +from unittest.mock import Mock + +from ble_monitor.ble_parser import BleParser + +from ble_monitor import HCIdump + +EXTENDED_ATC = bytes.fromhex( + "043E2B0D011300004E7CBC38C1A40100FF7FB90000000000000000001110161A18A4C138BC7C4E0102284F0B6720" +) +EXTENDED_CUSTOM = bytes.fromhex( + "043E300D011300008B376338C1A40100FF7FBA0000000000000000001602010612161A188B376338C1A4CE0913107F0B521204" +) +LEGACY_ATC = bytes.fromhex( + "043e1d02010000f4830238c1a41110161a18a4c1380283f400a22f5f0bf819df" +) +LEGACY_CUSTOM = bytes.fromhex( + "043e1f02010000f4830238c1a41312161a18f4830238c1a4a9066911b60b58f70dde" +) + + +def batch_reports(*events, count=None, trailing=b""): + """Combine synthetic single-report fixtures into one HCI event.""" + subevent = events[0][3] + reports = b"".join(event[5:] for event in events) + trailing + body = bytes((subevent, len(events) if count is None else count)) + reports + return bytes((events[0][0], events[0][1], len(body))) + body + + +def test_two_extended_pvvx_reports_are_both_parsed(): + """Every PVVX/ATC report in one extended event is returned.""" + parsed = BleParser().parse_raw_data_reports( + batch_reports(EXTENDED_ATC, EXTENDED_CUSTOM) + ) + assert [sensor["mac"] for sensor, _ in parsed] == [ + "A4C138BC7C4E", + "A4C13863378B", + ] + + +def test_legacy_multi_report_layout_remains_supported(): + """Legacy advertising events can also contain multiple reports.""" + parsed = BleParser().parse_raw_data_reports( + batch_reports(LEGACY_ATC, LEGACY_CUSTOM) + ) + assert len(parsed) == 2 + + +def test_unknown_report_does_not_discard_known_report(): + """A mixed batch retains a supported report.""" + unknown = bytearray(EXTENDED_ATC) + uuid_offset = bytes(unknown).index(bytes.fromhex("1a18")) + unknown[uuid_offset:uuid_offset + 2] = b"\xff\xff" + parsed = BleParser().parse_raw_data_reports( + batch_reports(bytes(unknown), EXTENDED_CUSTOM) + ) + assert len(parsed) == 1 + assert parsed[0][0]["mac"] == "A4C13863378B" + + +def test_truncated_later_report_keeps_complete_earlier_report(): + """Safe framing preserves reports preceding a truncated report.""" + event = batch_reports( + EXTENDED_ATC, + count=2, + trailing=EXTENDED_CUSTOM[5:20], + ) + parsed = BleParser().parse_raw_data_reports(event) + assert len(parsed) == 1 + assert parsed[0][0]["mac"] == "A4C138BC7C4E" + + +def test_hcidump_enqueues_all_reports_but_counts_one_raw_event(): + """HCIdump fans out parsed reports without inflating raw activity count.""" + hcidump = HCIdump.__new__(HCIdump) + hcidump.ble_parser = BleParser() + hcidump._last_hci_activity = {} + hcidump.evt_cnt = {} + hcidump.dataqueue_bin = SimpleNamespace(sync_q=Mock()) + hcidump.dataqueue_meas = SimpleNamespace(sync_q=Mock()) + hcidump.dataqueue_tracker = SimpleNamespace(sync_q=Mock()) + + hcidump.process_hci_events( + batch_reports(EXTENDED_ATC, EXTENDED_CUSTOM), hci=0 + ) + + assert hcidump.evt_cnt == {0: 1} + assert hcidump.dataqueue_meas.sync_q.put_nowait.call_count == 2 diff --git a/custom_components/ble_monitor/translations/en.json b/custom_components/ble_monitor/translations/en.json index 150ebdc5..6c11e1f7 100644 --- a/custom_components/ble_monitor/translations/en.json +++ b/custom_components/ble_monitor/translations/en.json @@ -11,10 +11,12 @@ "period": "Period to use for averaging", "log_spikes": "Log spikes", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "active_scan": "Use active scan instead of passive scan (affects battery)", "restore_state": "Restore state after a restart", "report_unknown": "Report unknown BLE advertisements to the HA log", - "bt_auto_restart": "Automatically restart Bluetooth adapter on failure" + "bt_auto_restart": "Automatically restart Bluetooth adapter on failure", + "hci_inactivity_timeout": "Raw HCI inactivity timeout (seconds, 0 = off)" } }, "add_remove_device": { @@ -25,6 +27,7 @@ "encryption_key": "Encryption key", "temperature_unit": "Temperature unit [as send by device]", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "restore_state": "Restore state after a restart", "reset_timer": "Reset timer (in seconds, 0 = off)", "report_unknown": "Report unknown BLE advertisements to the HA log", @@ -60,9 +63,11 @@ "period": "Period to use for averaging", "log_spikes": "Log spikes", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "active_scan": "Use active scan in stead of passive scan (affects battery)", "report_unknown": "Report unknown BLE advertisements to the HA log", "bt_auto_restart": "Automatically restart Bluetooth adapter on failure", + "hci_inactivity_timeout": "Raw HCI inactivity timeout (seconds, 0 = off)", "restore_state": "Restore state after a restart", "not_in_use": "YAML is used, change options in YAML" } @@ -75,6 +80,7 @@ "encryption_key": "Encryption key (set to '-' to remove)", "temperature_unit": "Temperature unit [as send by device]", "use_median": "Use median instead of mean", + "measurement_update": "Measurement update policy", "restore_state": "Restore state after a restart", "reset_timer": "Reset timer (in seconds, 0 = off)", "report_unknown": "Report unknown BLE advertisements to the HA log", diff --git a/docs/config_params.md b/docs/config_params.md index b70c0f00..1b455dee 100644 --- a/docs/config_params.md +++ b/docs/config_params.md @@ -76,6 +76,20 @@ ble_monitor: active_scan: True ``` +### hci_inactivity_timeout + + **Raw HCI inactivity timeout** + (positive integer)(Optional) Restarts the scanner and reopens its HCI socket + when an adapter produces no raw HCI advertising events for this many seconds. + The timer starts after scanning is established and is reset by every raw event, + including advertisements from unsupported devices. Set this option to `0` to + disable the watchdog. Default value: 60. + +```yaml +ble_monitor: + hci_inactivity_timeout: 60 +``` + ### discovery **Discover devices and sensors automatically** @@ -106,6 +120,20 @@ Data from sensors with other addresses will be ignored. Default value: True *The difference between the mean and the median is that the median is **selected** from the sensor readings, and not calculated as the average. That is, the median resolution is equal to the resolution of the sensor (one tenth of a degree or percent), while the mean allows you to slightly increase the resolution (the longer the measurement period, the larger the number of values will be averaged, and the higher the resolution can be achieved, if necessary with disabled rounding).* +### measurement_update + + **Measurement update policy** + (`periodic` or `instant`)(Optional) With `periodic`, measuring sensor values + are aggregated over `period`. With `instant`, every parser-accepted packet + is published immediately using its decoded value and current RSSI without + mean or median filtering. Parser-rejected duplicate RF retransmissions stay + suppressed. Default value: `periodic`. + +```yaml +ble_monitor: + measurement_update: instant +``` + ### log_spikes **Log spikes**