From 1a0c74f4edab47639a9b2d94172b162a704dc180 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:29:17 +0000 Subject: [PATCH 1/2] Initial plan From 43bfbae9d3c0c1f74ba8c0fc3cf94aad015062e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:42:33 +0000 Subject: [PATCH 2/2] feat: add vendor/proprietary parser extension examples, tests and docs Agent-Logs-Url: https://github.com/RonanB96/bluetooth-sig-python/sessions/4d8b35b1-c0dc-4061-bd4b-f96e0f9c3f3e Co-authored-by: RonanB96 <22995167+RonanB96@users.noreply.github.com> --- docs/source/how-to/usage.md | 110 ++++++++ examples/vendor_parsers/__init__.py | 1 + examples/vendor_parsers/register_parsers.py | 243 ++++++++++++++++++ pyproject.toml | 2 +- .../test_characteristic_test_coverage.py | 1 + .../characteristics/test_vendor_parsers.py | 193 ++++++++++++++ 6 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 examples/vendor_parsers/__init__.py create mode 100644 examples/vendor_parsers/register_parsers.py create mode 100644 tests/gatt/characteristics/test_vendor_parsers.py diff --git a/docs/source/how-to/usage.md b/docs/source/how-to/usage.md index 7025fa4a..90866521 100644 --- a/docs/source/how-to/usage.md +++ b/docs/source/how-to/usage.md @@ -385,6 +385,116 @@ print(f"Battery: {result}%") # Battery: 50% Use `validate=False` for testing with synthetic data or debugging firmware. Keep validation enabled (default) for production code. +## Vendor / Proprietary Parser Extensions + +Many BLE devices expose characteristics under vendor-specific (non-SIG) UUIDs that the standard registry cannot recognise. +You can register parsers for these UUIDs at runtime so that `BluetoothSIGTranslator` handles them transparently alongside SIG characteristics. + +### Step 1 — Define a custom characteristic class + +Subclass `CustomBaseCharacteristic`, declare `_info` with your proprietary UUID, and implement `_decode_value` / `_encode_value`. + +```python +import struct + +import msgspec + +from bluetooth_sig.gatt.characteristics.custom import CustomBaseCharacteristic +from bluetooth_sig.gatt.context import CharacteristicContext +from bluetooth_sig.types import CharacteristicInfo +from bluetooth_sig.types.uuid import BluetoothUUID + + +# Govee-style thermometer: 4-byte payload, sint16 LE temperature (÷100 °C) + uint16 LE humidity (÷100 %) +GOVEE_THERMO_UUID = "494e5445-4c4c-494e-5445-4c4c49000001" + + +class GoveeThermometerReading(msgspec.Struct, frozen=True, kw_only=True): + temperature: float # °C + humidity: float # % + + +class GoveeThermometerCharacteristic(CustomBaseCharacteristic): + """Govee-style thermometer: 4-byte payload.""" + + expected_length: int = 4 + + _info = CharacteristicInfo( + uuid=BluetoothUUID(GOVEE_THERMO_UUID), + name="Govee Thermometer Reading", + unit="°C / %", + python_type=float, + ) + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> GoveeThermometerReading: + temp_raw, hum_raw = struct.unpack_from(" bytearray: + return bytearray(struct.pack(" int: + """Parse LED state byte (0 = off, 1 = on).""" + return data[0] + + def _encode_value(self, data: int) -> bytearray: + """Encode LED state to single byte.""" + return bytearray([data & 0x01]) + + +# --------------------------------------------------------------------------- +# Nordic LED Button Service — button state characteristic +# --------------------------------------------------------------------------- + + +class NordicButtonCharacteristic(CustomBaseCharacteristic): + """Nordic LBS button state: 1 byte, 0x00 = released, 0x01 = pressed.""" + + expected_length: int = 1 + min_value: int = 0 + max_value: int = 1 + expected_type: type = int + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NUS_BUTTON_UUID), + name="Nordic LBS Button State", + unit="", + python_type=int, + ) + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> int: + """Parse button state byte (0 = released, 1 = pressed).""" + return data[0] + + def _encode_value(self, data: int) -> bytearray: + """Encode button state to single byte.""" + return bytearray([data & 0x01]) + + +# --------------------------------------------------------------------------- +# Govee-style thermometer — compound temperature + humidity characteristic +# --------------------------------------------------------------------------- + + +class GoveeThermometerReading(msgspec.Struct, frozen=True, kw_only=True): + """Parsed Govee-style temperature and humidity reading. + + Attributes: + temperature: Temperature in degrees Celsius (resolution 0.01 °C). + humidity: Relative humidity in percent (resolution 0.01 %). + """ + + temperature: float + humidity: float + + +class GoveeThermometerCharacteristic(CustomBaseCharacteristic): + """Govee-style thermometer: 4-byte payload with temperature and humidity. + + Payload layout (little-endian): + Bytes 0-1: sint16 temperature raw value (divide by 100 → °C) + Bytes 2-3: uint16 humidity raw value (divide by 100 → %) + """ + + expected_length: int = 4 + + _info = CharacteristicInfo( + uuid=BluetoothUUID(GOVEE_THERMO_UUID), + name="Govee Thermometer Reading", + unit="°C / %", + python_type=float, + ) + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> GoveeThermometerReading: + """Parse 4-byte Govee thermometer payload.""" + temp_raw, hum_raw = struct.unpack_from(" bytearray: + """Encode temperature and humidity to 4-byte Govee payload.""" + temp_raw = round(data.temperature * 100) + hum_raw = round(data.humidity * 100) + return bytearray(struct.pack(" BluetoothSIGTranslator: + """Register all vendor characteristic classes with a translator instance. + + Explicitly registers each vendor characteristic class so that the + translator can dispatch by UUID. Using ``override=True`` makes this + call idempotent — safe to call multiple times (e.g. in tests). + + Args: + translator: Existing translator to use, or None to use the singleton. + + Returns: + The translator instance used for registration. + """ + if translator is None: + translator = BluetoothSIGTranslator.get_instance() + + registrations: list[tuple[str, type[CustomBaseCharacteristic]]] = [ + (NUS_LED_UUID, NordicLEDCharacteristic), + (NUS_BUTTON_UUID, NordicButtonCharacteristic), + (GOVEE_THERMO_UUID, GoveeThermometerCharacteristic), + ] + for uuid, cls in registrations: + translator.register_custom_characteristic_class(uuid, cls, override=True) + + return translator + + +# --------------------------------------------------------------------------- +# Demo entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """Run a short demo parsing simulated payloads with registered vendor parsers.""" + translator = register_all() + + print("=== Vendor Parser Demo ===\n") + + # --- Nordic LED --- + led_on_payload = bytearray([0x01]) + led_result = translator.parse_characteristic(NUS_LED_UUID, led_on_payload) + print(f"[Nordic LED] payload={led_on_payload.hex()!r} → LED state: {led_result} (1 = on)") + + led_off_payload = bytearray([0x00]) + led_result = translator.parse_characteristic(NUS_LED_UUID, led_off_payload) + print(f"[Nordic LED] payload={led_off_payload.hex()!r} → LED state: {led_result} (0 = off)") + + # --- Nordic Button --- + btn_pressed_payload = bytearray([0x01]) + btn_result = translator.parse_characteristic(NUS_BUTTON_UUID, btn_pressed_payload) + print(f"[Nordic Button] payload={btn_pressed_payload.hex()!r} → Button: {btn_result} (1 = pressed)") + + # --- Govee thermometer: 22.50 °C, 65.10 % --- + # temp_raw = 2250, hum_raw = 6510 + govee_payload = bytearray(struct.pack(" BluetoothSIGTranslator: + """Ensure all vendor parsers are registered before each test.""" + return register_all() + + +# --------------------------------------------------------------------------- +# Nordic LED characteristic +# --------------------------------------------------------------------------- + + +class TestNordicLEDCharacteristic: + """Tests for the Nordic LBS LED state characteristic.""" + + def test_parse_led_on(self) -> None: + """LED state byte 0x01 decodes to 1 (on).""" + char = NordicLEDCharacteristic() + result = char.parse_value(bytearray([0x01])) + assert result == 1 + + def test_parse_led_off(self) -> None: + """LED state byte 0x00 decodes to 0 (off).""" + char = NordicLEDCharacteristic() + result = char.parse_value(bytearray([0x00])) + assert result == 0 + + def test_encode_led_on(self) -> None: + """Encoding 1 produces a single byte 0x01.""" + char = NordicLEDCharacteristic() + encoded = char.build_value(1) + assert encoded == bytearray([0x01]) + + def test_encode_led_off(self) -> None: + """Encoding 0 produces a single byte 0x00.""" + char = NordicLEDCharacteristic() + encoded = char.build_value(0) + assert encoded == bytearray([0x00]) + + def test_round_trip_on(self) -> None: + """Parse(encode(1)) == 1.""" + char = NordicLEDCharacteristic() + assert char.parse_value(char.build_value(1)) == 1 + + def test_round_trip_off(self) -> None: + """Parse(encode(0)) == 0.""" + char = NordicLEDCharacteristic() + assert char.parse_value(char.build_value(0)) == 0 + + def test_empty_payload_raises(self) -> None: + """Empty payload raises CharacteristicParseError.""" + char = NordicLEDCharacteristic() + with pytest.raises(CharacteristicParseError): + char.parse_value(bytearray([])) + + def test_translator_dispatches_by_uuid(self) -> None: + """Translator correctly dispatches LED UUID to NordicLEDCharacteristic.""" + translator = BluetoothSIGTranslator.get_instance() + result = translator.parse_characteristic(NUS_LED_UUID, bytearray([0x01])) + assert result == 1 + + +# --------------------------------------------------------------------------- +# Nordic Button characteristic +# --------------------------------------------------------------------------- + + +class TestNordicButtonCharacteristic: + """Tests for the Nordic LBS button state characteristic.""" + + def test_parse_button_pressed(self) -> None: + """Button byte 0x01 decodes to 1 (pressed).""" + char = NordicButtonCharacteristic() + result = char.parse_value(bytearray([0x01])) + assert result == 1 + + def test_parse_button_released(self) -> None: + """Button byte 0x00 decodes to 0 (released).""" + char = NordicButtonCharacteristic() + result = char.parse_value(bytearray([0x00])) + assert result == 0 + + def test_empty_payload_raises(self) -> None: + """Empty payload raises CharacteristicParseError.""" + char = NordicButtonCharacteristic() + with pytest.raises(CharacteristicParseError): + char.parse_value(bytearray([])) + + def test_translator_dispatches_by_uuid(self) -> None: + """Translator correctly dispatches button UUID to NordicButtonCharacteristic.""" + translator = BluetoothSIGTranslator.get_instance() + result = translator.parse_characteristic(NUS_BUTTON_UUID, bytearray([0x00])) + assert result == 0 + + +# --------------------------------------------------------------------------- +# Govee thermometer characteristic +# --------------------------------------------------------------------------- + + +class TestGoveeThermometerCharacteristic: + """Tests for the Govee-style temperature + humidity characteristic.""" + + def test_parse_typical_reading(self) -> None: + """Parse a typical 22.50 °C / 65.10 % payload.""" + char = GoveeThermometerCharacteristic() + payload = bytearray(struct.pack(" None: + """Parse a sub-zero temperature: -5.00 °C / 30.00 %.""" + char = GoveeThermometerCharacteristic() + payload = bytearray(struct.pack(" None: + """Parse edge case: 0 °C / 100.00 % humidity.""" + char = GoveeThermometerCharacteristic() + payload = bytearray(struct.pack(" None: + """Encode then parse produces the original values.""" + char = GoveeThermometerCharacteristic() + original = GoveeThermometerReading(temperature=18.75, humidity=52.50) + encoded = char.build_value(original) + assert len(encoded) == 4 + restored = char.parse_value(encoded) + assert restored.temperature == pytest.approx(18.75, abs=0.01) + assert restored.humidity == pytest.approx(52.50, abs=0.01) + + def test_too_short_payload_raises(self) -> None: + """Payload shorter than 4 bytes raises CharacteristicParseError.""" + char = GoveeThermometerCharacteristic() + with pytest.raises(CharacteristicParseError): + char.parse_value(bytearray([0xCA, 0x08])) + + def test_empty_payload_raises(self) -> None: + """Empty payload raises CharacteristicParseError.""" + char = GoveeThermometerCharacteristic() + with pytest.raises(CharacteristicParseError): + char.parse_value(bytearray([])) + + def test_translator_dispatches_by_uuid(self) -> None: + """Translator correctly dispatches Govee UUID to GoveeThermometerCharacteristic.""" + translator = BluetoothSIGTranslator.get_instance() + payload = bytearray(struct.pack(" None: + """Translator raises for completely unknown UUID after registration.""" + translator = BluetoothSIGTranslator.get_instance() + with pytest.raises(CharacteristicParseError): + translator.parse_characteristic( + "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + bytearray([0x01]), + )