From d70424f127d89c961a06921f0696a6b6045c0ab5 Mon Sep 17 00:00:00 2001 From: Albert Drenth Date: Wed, 12 Aug 2026 15:28:46 +0200 Subject: [PATCH] Add support for Minew E9 temperature beacons Adds a parser for the Minew temperature frame (service UUID 0xFFE1, frame type 0xA1, version 0x13) as sent by the Minew E9, exposing temperature (signed 8.8 fixed-point) and battery percentage. Other Minew 0xA1 frames are ignored. The frame layout and the test frames follow reelyActive's advlib-ble-services (MIT): https://github.com/reelyactive/advlib-ble-services/blob/master/lib/minew.js --- README.md | 1 + .../ble_monitor/ble_parser/__init__.py | 5 ++ .../ble_monitor/ble_parser/minew.py | 47 +++++++++++++++++++ custom_components/ble_monitor/const.py | 3 ++ .../ble_monitor/test/test_minew_parser.py | 45 ++++++++++++++++++ docs/_devices/Minew_E9.md | 18 +++++++ 6 files changed, 119 insertions(+) create mode 100644 custom_components/ble_monitor/ble_parser/minew.py create mode 100644 custom_components/ble_monitor/test/test_minew_parser.py create mode 100644 docs/_devices/Minew_E9.md diff --git a/README.md b/README.md index c6ca24ad3..8366e4958 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ This custom component for [Home Assistant](https://www.home-assistant.io) passiv - Kegtron - KKM - Mikrotik +- Minew (E9) - Moat - Oras - Oral-B diff --git a/custom_components/ble_monitor/ble_parser/__init__.py b/custom_components/ble_monitor/ble_parser/__init__.py index 1111de71f..17cd0a7ee 100644 --- a/custom_components/ble_monitor/ble_parser/__init__.py +++ b/custom_components/ble_monitor/ble_parser/__init__.py @@ -31,6 +31,7 @@ from .laica import parse_laica from .michelin import parse_michelin_tms from .mikrotik import parse_mikrotik +from .minew import parse_minew from .miscale import parse_miscale from .moat import parse_moat from .mocreo import parse_mocreo @@ -327,6 +328,10 @@ def parse_advertisement( # UUID16 = Google (used by Ruuvitag V2/V4) sensor_data = parse_ruuvitag(self, service_data, mac) break + elif uuid16 == 0xFFE1: + # UUID16 = Minew + sensor_data = parse_minew(self, service_data, mac) + break elif uuid16 == 0xFEE0: # UUID16 = Anhui Huami Information Technology Co., Ltd. (Amazfit) if man_spec_data_list: diff --git a/custom_components/ble_monitor/ble_parser/minew.py b/custom_components/ble_monitor/ble_parser/minew.py new file mode 100644 index 000000000..667fe6e2f --- /dev/null +++ b/custom_components/ble_monitor/ble_parser/minew.py @@ -0,0 +1,47 @@ +"""Parser for Minew BLE advertisements""" +from __future__ import annotations + +import logging +from struct import unpack +from typing import Any + +from .helpers import to_mac, to_unformatted_mac + +_LOGGER = logging.getLogger(__name__) + +MINEW_FRAME_TYPE = 0xA1 +E9_TEMPERATURE_VERSION = 0x13 +E9_TEMPERATURE_LENGTH = 15 + + +def parse_minew(self, data: bytes, mac: bytes) -> dict[str, Any] | None: + """Parser for Minew sensor beacons. + + Minew sends several frames under frame type 0xA1 (info, acceleration, ...), + told apart by the version byte after it. Only the E9 temperature frame (0x13) + is supported; its temperature is signed 8.8 fixed-point in °C. + + Frame layout as decoded by reelyActive's advlib-ble-services (MIT): + https://github.com/reelyactive/advlib-ble-services/blob/master/lib/minew.js + """ + if ( + len(data) == E9_TEMPERATURE_LENGTH + and data[4] == MINEW_FRAME_TYPE + and data[5] == E9_TEMPERATURE_VERSION + ): + (battery, temp) = unpack(">Bh", data[6:9]) + return { + "mac": to_unformatted_mac(mac), + "type": "E9", + "firmware": "Minew", + "temperature": round(temp / 256, 2), + "battery": battery, + "data": True, + } + if self.report_unknown == "Minew": + _LOGGER.info( + "BLE ADV from UNKNOWN Minew DEVICE: MAC: %s, ADV: %s", + to_mac(mac), + data.hex(), + ) + return None diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 2b0d158e7..5be07b3a8 100644 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -2232,6 +2232,7 @@ class BLEMonitorBinarySensorEntityDescription( '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"], [], []], + 'E9' : [["temperature", "battery", "rssi"], [], []], 'Meter TH S1' : [["temperature", "humidity", "battery", "rssi"], [], []], 'Meter TH plus' : [["temperature", "humidity", "battery", "rssi"], [], []], 'Laica Smart Scale' : [["weight", "impedance", "rssi"], [], []], @@ -2392,6 +2393,7 @@ class BLEMonitorBinarySensorEntityDescription( 'Air Mentor Pro 2' : 'Air Mentor', 'Air Mentor 2S' : 'Air Mentor', 'bluSensor Mini' : 'Almendo', + 'E9' : 'Minew', 'Meter TH S1' : 'Switchbot', 'Meter TH plus' : 'Switchbot', 'Laica Smart Scale' : 'Laica', @@ -2645,6 +2647,7 @@ class BLEMonitorBinarySensorEntityDescription( "Mi Band", "Mi Scale", "Mikrotik", + "Minew", "Moat", "MOCREO", "Oras", diff --git a/custom_components/ble_monitor/test/test_minew_parser.py b/custom_components/ble_monitor/test/test_minew_parser.py new file mode 100644 index 000000000..81d9017ab --- /dev/null +++ b/custom_components/ble_monitor/test/test_minew_parser.py @@ -0,0 +1,45 @@ +"""The tests for the Minew ble_parser.""" +from ble_monitor.ble_parser import BleParser + + +class TestMinew: + """Tests for the Minew E9 parser. + + Frames are taken from the reelyActive advlib-ble-services unit tests. + """ + def test_minew_e9(self): + """Test Minew E9 temperature frame parser.""" + data_string = "043E2202010301AABBCCDDEEFF160201060303E1FF0E16E1FFA113631973AABBCCDDEEFFC4" + 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"] == "Minew" + assert sensor_msg["type"] == "E9" + assert sensor_msg["mac"] == "FFEEDDCCBBAA" + assert sensor_msg["data"] + assert sensor_msg["temperature"] == 25.45 + assert sensor_msg["battery"] == 99 + assert sensor_msg["rssi"] == -60 + + def test_minew_e9_negative_temperature(self): + """Test Minew E9 temperature frame parser below 0 °C.""" + data_string = "043E2202010301AABBCCDDEEFF160201060303E1FF0E16E1FFA11363FB80AABBCCDDEEFFC4" + 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"] == "E9" + assert sensor_msg["temperature"] == -4.5 + + def test_minew_other_frame_ignored(self): + """Test that other Minew frames of the same length (here TVOC) are not parsed.""" + data_string = "043E2202010301AABBCCDDEEFF160201060303E1FF0E16E1FFA112634000AABBCCDDEEFFC4" + 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/Minew_E9.md b/docs/_devices/Minew_E9.md new file mode 100644 index 000000000..9f8323be6 --- /dev/null +++ b/docs/_devices/Minew_E9.md @@ -0,0 +1,18 @@ +--- +manufacturer: Minew +name: Temperature Beacon +model: E9 +image: +physical_description: Waterproof beacon +broadcasted_properties: + - temperature + - battery + - rssi +broadcasted_property_notes: +broadcast_rate: +active_scan: +encryption_key: +custom_firmware: +notes: + - Only the Minew temperature frame (service UUID `0xFFE1`, frame `0xA1 0x13`) is supported. +---