From d84afcad7d82c4d844e4afe374ae8127cd2a264c Mon Sep 17 00:00:00 2001 From: Albert Drenth Date: Wed, 12 Aug 2026 15:25:20 +0200 Subject: [PATCH] Add support for Eddystone-TLM beacons Adds a parser for Google's Eddystone-TLM protocol (service UUID 0xFEAA, frame type 0x20), exposing beacon temperature and battery voltage/percentage. TLM frames are distinguished from the existing FEAA-based KKM and Ruuvitag parsers by service data length. Spec: https://github.com/google/eddystone/blob/master/eddystone-tlm/tlm-plain.md Closes #1562 --- README.md | 1 + .../ble_monitor/ble_parser/__init__.py | 7 +- .../ble_monitor/ble_parser/eddystone_tlm.py | 64 +++++++++++++++++++ custom_components/ble_monitor/const.py | 3 + .../test/test_eddystone_tlm_parser.py | 39 +++++++++++ docs/_devices/Eddystone_TLM.md | 24 +++++++ 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 custom_components/ble_monitor/ble_parser/eddystone_tlm.py create mode 100644 custom_components/ble_monitor/test/test_eddystone_tlm_parser.py create mode 100644 docs/_devices/Eddystone_TLM.md diff --git a/README.md b/README.md index c6ca24ad3..90fa7dd52 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This custom component for [Home Assistant](https://www.home-assistant.io) passiv - BTHome - b-parasite - Chef iQ +- Eddystone (TLM) - Ela - Govee - Grundfos diff --git a/custom_components/ble_monitor/ble_parser/__init__.py b/custom_components/ble_monitor/ble_parser/__init__.py index 1111de71f..400f0c255 100644 --- a/custom_components/ble_monitor/ble_parser/__init__.py +++ b/custom_components/ble_monitor/ble_parser/__init__.py @@ -15,6 +15,7 @@ from .bthome import parse_bthome from .chefiq import parse_chefiq from .const import JAALEE_TYPES, TILT_TYPES +from .eddystone_tlm import parse_eddystone_tlm from .govee import parse_govee from .grundfos import parse_grundfos from .helpers import to_mac, to_unformatted_mac, to_uuid @@ -319,7 +320,11 @@ def parse_advertisement( sensor_data = parse_xiaomi(self, service_data, mac) break elif uuid16 == 0xFEAA: - if len(service_data) == 19: + if len(service_data) == 18 and service_data[4] == 0x20: + # UUID16 = Google (Eddystone-TLM) + sensor_data = parse_eddystone_tlm(self, service_data, mac) + break + elif len(service_data) == 19: # UUID16 = Google (used by KKM) sensor_data = parse_kkm(self, service_data, mac) break diff --git a/custom_components/ble_monitor/ble_parser/eddystone_tlm.py b/custom_components/ble_monitor/ble_parser/eddystone_tlm.py new file mode 100644 index 000000000..e64099341 --- /dev/null +++ b/custom_components/ble_monitor/ble_parser/eddystone_tlm.py @@ -0,0 +1,64 @@ +"""Parser for Eddystone-TLM BLE advertisements""" +import logging +from struct import unpack + +from .helpers import to_mac, to_unformatted_mac + +_LOGGER = logging.getLogger(__name__) + +TLM_FRAME_TYPE = 0x20 +TLM_VERSION = 0x00 +TLM_TEMPERATURE_UNSUPPORTED = -128.0 + + +def parse_eddystone_tlm(self, data: bytes, mac: bytes): + """Parser for Eddystone-TLM beacons""" + device_type = "Eddystone-TLM" + result = { + "mac": to_unformatted_mac(mac), + "type": device_type, + "data": False, + } + (frame_type, version, volt, temp_int, temp_frac, adv_cnt) = unpack( + ">BBHbBI", data[4:14] + ) + if frame_type != TLM_FRAME_TYPE or version != TLM_VERSION: + result = None + else: + result.update( + { + "packet": adv_cnt, + "data": True, + } + ) + # a voltage of 0 means the beacon does not report its battery level + if volt != 0: + result["voltage"] = volt / 1000 + temperature = temp_int + temp_frac / 256 + if temperature != TLM_TEMPERATURE_UNSUPPORTED: + result["temperature"] = round(temperature, 2) + if result is None: + if self.report_unknown == "Eddystone": + _LOGGER.info( + "BLE ADV from UNKNOWN Eddystone DEVICE: MAC: %s, ADV: %s", + to_mac(mac), + data.hex(), + ) + return None + # reformat battery info to match BLE monitor format + if "voltage" in result: + voltage = result["voltage"] + # calculate battery in % + if voltage >= 3.00: + batt = 100 + elif voltage >= 2.60: + batt = 60 + (voltage - 2.60) * 100 + elif voltage >= 2.50: + batt = 40 + (voltage - 2.50) * 200 + elif voltage >= 2.45: + batt = 20 + (voltage - 2.45) * 400 + else: + batt = 0 + result["battery"] = round(batt, 1) + + return result diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 2b0d158e7..c6178261c 100644 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -2229,6 +2229,7 @@ class BLEMonitorBinarySensorEntityDescription( 'BEC07-5' : [["temperature", "humidity", "rssi"], [], []], 'iBeacon' : [["rssi", "measured power", "cypress temperature", "cypress humidity"], ["uuid", "mac", "major", "minor"], []], # mac can be dynamic 'AltBeacon' : [["rssi", "measured power"], ["uuid", "mac", "major", "minor"], []], # mac can be dynamic + 'Eddystone-TLM' : [["temperature", "voltage", "battery", "rssi"], [], []], 'Air Mentor Pro 2' : [["temperature", "temperature calibrated", "humidity", "co2", "tvoc", "aqi", "air quality", "pm2.5", "pm10", "rssi"], [], []], 'Air Mentor 2S' : [["temperature", "temperature calibrated", "humidity", "co2", "tvoc", "aqi", "formaldehyde", "air quality", "pm2.5", "pm10", "rssi"], [], []], 'bluSensor Mini' : [["temperature", "humidity", "co2", "tvoc", "aqi", "rssi"], [], []], @@ -2389,6 +2390,7 @@ class BLEMonitorBinarySensorEntityDescription( 'BEC07-5' : 'Jinou', 'iBeacon' : 'Apple', 'AltBeacon' : 'Radius Networks', + 'Eddystone-TLM' : 'Google', 'Air Mentor Pro 2' : 'Air Mentor', 'Air Mentor 2S' : 'Air Mentor', 'bluSensor Mini' : 'Almendo', @@ -2629,6 +2631,7 @@ class BLEMonitorBinarySensorEntityDescription( "Blustream", "BTHome", 'Chef iQ', + "Eddystone", "Garnet", "Govee", "Grundfos", diff --git a/custom_components/ble_monitor/test/test_eddystone_tlm_parser.py b/custom_components/ble_monitor/test/test_eddystone_tlm_parser.py new file mode 100644 index 000000000..1c539e1bd --- /dev/null +++ b/custom_components/ble_monitor/test/test_eddystone_tlm_parser.py @@ -0,0 +1,39 @@ +"""The tests for the Eddystone-TLM ble_parser.""" +from ble_monitor.ble_parser import BleParser + + +class TestEddystoneTlm: + """Tests for the Eddystone-TLM parser""" + def test_eddystone_tlm(self): + """Test Eddystone-TLM parser.""" + data_string = "043E2502010301494BEFBEADDE190201060303AAFE1116AAFE20000C6F1E000002006D41DB9AB6D7" + 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["type"] == "Eddystone-TLM" + assert sensor_msg["mac"] == "DEADBEEF4B49" + assert sensor_msg["packet"] == 131181 + assert sensor_msg["data"] + assert sensor_msg["temperature"] == 30.0 + assert sensor_msg["voltage"] == 3.183 + assert sensor_msg["battery"] == 100 + assert sensor_msg["rssi"] == -41 + + def test_eddystone_tlm_unsupported_readings(self): + """Test Eddystone-TLM parser with an unsupported battery voltage and temperature.""" + data_string = "043E2502010301494BEFBEADDE190201060303AAFE1116AAFE2000000080000002006D41DB9AB6D7" + 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["type"] == "Eddystone-TLM" + assert sensor_msg["mac"] == "DEADBEEF4B49" + assert sensor_msg["packet"] == 131181 + assert sensor_msg["data"] + assert "temperature" not in sensor_msg + assert "voltage" not in sensor_msg + assert "battery" not in sensor_msg + assert sensor_msg["rssi"] == -41 diff --git a/docs/_devices/Eddystone_TLM.md b/docs/_devices/Eddystone_TLM.md new file mode 100644 index 000000000..4261daebb --- /dev/null +++ b/docs/_devices/Eddystone_TLM.md @@ -0,0 +1,24 @@ +--- +manufacturer: Google +name: Eddystone-TLM +model: Eddystone-TLM +image: +physical_description: +broadcasted_properties: + - temperature + - voltage + - battery + - rssi +broadcasted_property_notes: + - property: temperature + note: The beacon's chip temperature, not that of a separate sensor. Not reported by beacons that send -128 °C. + - property: voltage + note: Not reported by beacons that send 0 mV. +broadcast_rate: +active_scan: +encryption_key: +custom_firmware: +notes: + - Eddystone-TLM is not a device, but Google's open telemetry frame, sent by beacons from e.g. Minew and BlueUp. + - Only unencrypted TLM frames (version `0x00`) are supported. +---