From a53e1305f3cdae889fc34f73216211ded7025b66 Mon Sep 17 00:00:00 2001 From: Sergei Kolesnikov Date: Tue, 22 Sep 2026 11:35:22 +0300 Subject: [PATCH] Add holyiot-25055 beacons --- .../ble_monitor/ble_parser/__init__.py | 4 ++ .../ble_monitor/ble_parser/holyiot.py | 19 ++++++ custom_components/ble_monitor/const.py | 1 + .../ble_monitor/test/test_holyiot.py | 58 +++++++++++++++++++ docs/_devices/HolyIOT.md | 1 + 5 files changed, 83 insertions(+) diff --git a/custom_components/ble_monitor/ble_parser/__init__.py b/custom_components/ble_monitor/ble_parser/__init__.py index 6753916d0..0a0538fe8 100644 --- a/custom_components/ble_monitor/ble_parser/__init__.py +++ b/custom_components/ble_monitor/ble_parser/__init__.py @@ -286,6 +286,10 @@ def parse_advertisement( # UUID16 = HolyIOT sensor_data = parse_holyiot(self, service_data, mac) break + elif uuid16 == 0x180A and len(service_data) == 13: + # UUID16 = Device Information service (used by HolyIOT B1) + sensor_data = parse_holyiot(self, service_data, mac) + break elif uuid16 in [0xAA20, 0xAA21, 0xAA22] and local_name == "ECo": # UUID16 = Relsib sensor_data = parse_relsib(self, service_data, mac) diff --git a/custom_components/ble_monitor/ble_parser/holyiot.py b/custom_components/ble_monitor/ble_parser/holyiot.py index 97552a55c..ca16dd5a4 100644 --- a/custom_components/ble_monitor/ble_parser/holyiot.py +++ b/custom_components/ble_monitor/ble_parser/holyiot.py @@ -63,6 +63,25 @@ def parse_holyiot(self, data: str, mac: bytes): "battery": batt } ) + elif msg_length == 13: + # HolyIOT B1 (B1-B, B1-S, holyiot-25055 board) status frame on + # 0x180A service data: + # frame type: 0x01 = beacon mode, 0x02 = iBeacon mode (observed); + # the settings byte changes with adv interval/tx power config (0x04 -> 0xf8), + # meaning not fully reverse-engineered - not parsed. + device_type = "HolyIOT Beacon" + if data[4] not in (0x01, 0x02): + return None + holyiot_mac = data[5:11] + if holyiot_mac != mac: + _LOGGER.debug( + "HolyIOT MAC address doesn't match data MAC address. Data: %s with source mac: %s and HolyIOT mac: %s", + data.hex(), + mac, + holyiot_mac, + ) + return None + result.update({"battery": data[12]}) else: if self.report_unknown == "HolyIOT": _LOGGER.info( diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 67efa9d84..906e11bb0 100644 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -2458,6 +2458,7 @@ class BLEMonitorBinarySensorEntityDescription( 'BeckettLinkPro' : 'Beckett', 'GenisysGas' : 'Beckett', 'HHCCJCY10' : 'HHCC', + 'HolyIOT Beacon' : 'HolyIOT', 'HolyIOT BLE tracker' : 'HolyIOT', 'JTYJGD03MI' : 'Honeywell', 'Supramatic E4 BS' : 'Hörmann', diff --git a/custom_components/ble_monitor/test/test_holyiot.py b/custom_components/ble_monitor/test/test_holyiot.py index 45a4a3372..399cf991c 100644 --- a/custom_components/ble_monitor/test/test_holyiot.py +++ b/custom_components/ble_monitor/test/test_holyiot.py @@ -105,3 +105,61 @@ def test_holyiot_button(self): assert sensor_msg["battery"] == 100 assert sensor_msg["button"] == "toggle" assert sensor_msg["rssi"] == -52 + + def test_holyiot_b1_b_battery(self): + """Test HolyIOT parser for B1-B beacon (real capture).""" + data_string = "043e49020102016eee4ce590e13d0201061affffff0215fda50693a4e24fb1afcfc6eb07647825271b4cb9c9110942312d420000000000000000000000000c160a1801e190e54cee6e0461b9" + data = bytes(bytearray.fromhex(data_string)) + # pylint: disable=unused-variable + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + assert sensor_msg["firmware"] == "HolyIOT" + assert sensor_msg["type"] == "HolyIOT Beacon" + assert sensor_msg["mac"] == "E190E54CEE6E" + assert sensor_msg["packet"] == "no packet id" + assert sensor_msg["data"] + assert sensor_msg["battery"] == 97 + assert sensor_msg["rssi"] == -71 + + def test_holyiot_b1_s_battery(self): + """Test HolyIOT parser for B1-S beacon (real capture).""" + data_string = "043e4902010201bd483e2817fb3d0201061affffff0215fda50693a4e24fb1afcfc6eb07647825271b4cb9c9110942312d530000000000000000000000000c160a1801fb17283e48bd0462b9" + data = bytes(bytearray.fromhex(data_string)) + # pylint: disable=unused-variable + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + assert sensor_msg["firmware"] == "HolyIOT" + assert sensor_msg["type"] == "HolyIOT Beacon" + assert sensor_msg["mac"] == "FB17283E48BD" + assert sensor_msg["packet"] == "no packet id" + assert sensor_msg["data"] + assert sensor_msg["battery"] == 98 + assert sensor_msg["rssi"] == -71 + + def test_holyiot_beacon_ibeacon_mode(self): + """Test HolyIOT parser for the iBeacon-mode frame (type byte 0x02, real capture).""" + data_string = "043e49020102016eee4ce590e13d0201061affffff02156b1e44d99f274c8eb3f10a5d82e49c7300010001c9110942312d420000000000000000000000000c160a1802e190e54cee6ef85fb0" + data = bytes(bytearray.fromhex(data_string)) + # pylint: disable=unused-variable + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + assert sensor_msg["firmware"] == "HolyIOT" + assert sensor_msg["type"] == "HolyIOT Beacon" + assert sensor_msg["mac"] == "E190E54CEE6E" + assert sensor_msg["packet"] == "no packet id" + assert sensor_msg["data"] + assert sensor_msg["battery"] == 95 + assert sensor_msg["rssi"] == -80 + + def test_holyiot_b1_mac_mismatch(self): + """Test HolyIOT parser drops B1 frames with a foreign MAC in payload.""" + data_string = "043e49020102016eee4ce590e13d0201061affffff0215fda50693a4e24fb1afcfc6eb07647825271b4cb9c9110942312d420000000000000000000000000c160a1801e090e54cee6e0461b9" + data = bytes(bytearray.fromhex(data_string)) + # pylint: disable=unused-variable + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + assert sensor_msg is None diff --git a/docs/_devices/HolyIOT.md b/docs/_devices/HolyIOT.md index ec078ea18..c753f10d4 100644 --- a/docs/_devices/HolyIOT.md +++ b/docs/_devices/HolyIOT.md @@ -21,5 +21,6 @@ encryption_key: true custom_firmware: notes: - Supported measurement types depend on the model. + - New-generation HolyIOT beacons (e.g. B1-B, B1-S; managed by the NexBeacon pro app) broadcast their battery level. Button events (single, double, triple, long press) are only sent over a BLE connection (Nordic UART service) and cannot be captured from advertisements. - After each button press, the sensor state shows 'Toggle'. It will return to `no press` after the time set with the [reset_timer](configuration_params#reset_timer) option. ---