From d5fa9e43cf8ec839237be3abf9a3345a5b730ba1 Mon Sep 17 00:00:00 2001 From: Michele Campeotto Date: Thu, 18 Sep 2025 17:09:22 +0200 Subject: [PATCH 1/4] Finished support for buttons and dimmer events --- src/bthome.py | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/bthome.py b/src/bthome.py index db99d6c..571aa06 100644 --- a/src/bthome.py +++ b/src/bthome.py @@ -29,6 +29,10 @@ class BTHome: # Device name used in BLE advertisements. _local_name = "" + # Whether the device sends updates at regular intervals or on a trigger like a button. + _interval_advertising = True + _INTERVAL_ADVERTISING_FLAG = 0x4 + # For most sensors defined below, the naming convention is: # ::= "_" "_x" # Example, temperature sint16 0.01 becomes: @@ -94,7 +98,7 @@ class BTHome: HUMIDITY_UINT8_X1 = const(0x2E) # % MOISTURE_UINT8_X1 = const(0x2F) # % BUTTON_UINT8 = const(0x3A) # 01 = press, 02 = long press, etc. - DIMMER_UINT16 = const(0x03A) # 01xx = rotate left xx steps, 02xx = rotate right xx steps + DIMMER_UINT16 = const(0x3C) # 01xx = rotate left xx steps, 02xx = rotate right xx steps COUNT_UINT16_X1 = const(0x3D) COUNT_UINT32_X1 = const(0x3E) ROTATION_SINT16_X10 = const(0x3F) # ° @@ -132,6 +136,19 @@ class BTHome: PRECIPITATION_UINT16_X1 = const(0x5F) # mm CHANNEL_UINT8_X1 = const(0x60) + # Button events + BUTTON_EVENT_NONE = 0 + BUTTON_EVENT_PRESS = 0x1 + BUTTON_EVENT_DOUBLE_PRESS = 0x2 + BUTTON_EVENT_TRIPLE_PRESS = 0x3 + BUTTON_EVENT_LONG_PRESS = 0x4 + BUTTON_EVENT_LONG_DOUBLE_PRESS = 0x5 + BUTTON_EVENT_LONG_TRIPLE_PRESS = 0x6 + BUTTON_EVENT_HOLD_PRESS = 0x80 + + # Dimmer events are represented as number of steps: negative values for + # left/counterclockwise and positive for right/clockwise. + # There is more than one way to represent most sensor properties. This # dictionary maps the object id to the property name. _object_id_properties = { @@ -294,10 +311,14 @@ class BTHome: volume_storage = 0 water = 0 window = False + button = 0 + dimmer = 0 - def __init__(self, local_name="BTHome", debug=False): + def __init__(self, local_name="BTHome", interval_advertising=True, debug=False): local_name = local_name[:10] # Truncate to fit [^4] self._local_name = local_name + self._packet_id = 0 + self._interval_advertising = interval_advertising self.debug = debug @property @@ -323,6 +344,14 @@ def _pack_binary(self, object_id, value): def _pack_int8_x1(self, object_id, value): return pack("BB", object_id, round(value)) + def _pack_dimmer(self, object_id, value): + if value == 0: + return pack("BBB", object_id, 0, 0) + elif value > 0: # positive, clockwise, right + return pack("BBB", object_id, 2, round(value)) + else: # negative, counterclockwise, left + return pack("BBB", object_id, 1, round(-value)) + # 8-bit integer with scaling of 10 (1 decimal place) def _pack_int8_x10(self, object_id, value): return pack("BB", object_id, round(value * 10)) @@ -418,7 +447,7 @@ def _pack_raw_text(self, object_id, value): HUMIDITY_UINT8_X1: _pack_int8_x1, MOISTURE_UINT8_X1: _pack_int8_x1, BUTTON_UINT8: _pack_int8_x1, - DIMMER_UINT16: _pack_int16_x1, + DIMMER_UINT16: _pack_dimmer, COUNT_UINT16_X1: _pack_int16_x1, COUNT_UINT32_X1: _pack_int32_x1, ROTATION_SINT16_X10: _pack_int16_x10, @@ -463,7 +492,12 @@ def _pack_service_data(self, *args): "B", BTHome._SERVICE_DATA_UUID16 ) # indicates a 16-bit service UUID follows service_data_bytes += pack(" Date: Thu, 18 Sep 2025 17:56:27 +0200 Subject: [PATCH 2/4] Support tuples of (obj_id, value) in pack_advertisement This allows sending advertisements for multiple objects of the same type, for example a device with multiple buttons. --- README.md | 23 +++++++++++++++++++++++ src/bthome.py | 12 +++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 71c5ba1..2c0c7ff 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,29 @@ await aioble.advertise(BLE_ADV_INTERVAL_uS, adv_data=advert, connectable=False) See [main.py](src/main.py) for a more robust example. +Values can also be set directly in the `pack_advertisement()` call by using a +tuple, useful for sending multiple values of the same type, for example a +device with two buttons could send the state like this: + +``` +advert = beacon.pack_advertisement( + (BTHome.BUTTON_UINT8, BTHome.BUTTON_EVENT_NONE), + (BTHome.BUTTON_UINT8, BTHome.BUTTON_EVENT_PRESS) +) +``` + +[Note](https://bthome.io/format/#:~:text=Multiple%20events%20of%20the%20same%20type) +that the state for all duplicated objects must always be sent and always in the +same order. Buttons and dimmer events have a `0` state to represent no change. + +For buttons and dimmers it is advisable to set the trigger based device flag as +a hint to the receiver that the device might not be sending advertisements for +a long time: + +``` +beacon = BTHome("myBeacon", interval_advertising=False) +``` + ## Will it run on Microcontroller X? If the device has Bluetooth and can run recent versions of MicroPython, it should work. diff --git a/src/bthome.py b/src/bthome.py index 571aa06..a5dce23 100644 --- a/src/bthome.py +++ b/src/bthome.py @@ -498,10 +498,16 @@ def _pack_service_data(self, *args): else: flags |= self._INTERVAL_ADVERTISING_FLAG service_data_bytes += pack("B", flags) - for object_id in sorted(args): + args = sorted(args, key = lambda x: x if isinstance(x, int) else x[0]) + for object_id in args: + if isinstance(object_id, tuple): + value = object_id[1] + object_id = object_id[0] + property = BTHome._object_id_properties[object_id] + else: + property = BTHome._object_id_properties[object_id] + value = getattr(self, property) func = BTHome._object_id_functions[object_id] - property = BTHome._object_id_properties[object_id] - value = getattr(self, property) packed_representation = func(self, object_id, value) if self.debug: print("Using function:", func) From 1bd6906609a240ed44fce221f71b6840b1ec86c7 Mon Sep 17 00:00:00 2001 From: Michele Campeotto Date: Sat, 20 Sep 2025 08:16:59 +0200 Subject: [PATCH 3/4] Renamed trigger_based flag --- README.md | 2 +- src/bthome.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2c0c7ff..14ebbc6 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ a hint to the receiver that the device might not be sending advertisements for a long time: ``` -beacon = BTHome("myBeacon", interval_advertising=False) +beacon = BTHome("myBeacon", trigger_based=True) ``` ## Will it run on Microcontroller X? diff --git a/src/bthome.py b/src/bthome.py index a5dce23..7522801 100644 --- a/src/bthome.py +++ b/src/bthome.py @@ -30,8 +30,8 @@ class BTHome: _local_name = "" # Whether the device sends updates at regular intervals or on a trigger like a button. - _interval_advertising = True - _INTERVAL_ADVERTISING_FLAG = 0x4 + _trigger_based = False + _TRIGGER_BASED_FLAG = 0x4 # For most sensors defined below, the naming convention is: # ::= "_" "_x" @@ -314,11 +314,11 @@ class BTHome: button = 0 dimmer = 0 - def __init__(self, local_name="BTHome", interval_advertising=True, debug=False): + def __init__(self, local_name="BTHome", trigger_based=False, debug=False): local_name = local_name[:10] # Truncate to fit [^4] self._local_name = local_name self._packet_id = 0 - self._interval_advertising = interval_advertising + self._trigger_based = trigger_based self.debug = debug @property @@ -493,10 +493,10 @@ def _pack_service_data(self, *args): ) # indicates a 16-bit service UUID follows service_data_bytes += pack(" Date: Mon, 29 Sep 2025 17:43:28 +0200 Subject: [PATCH 4/4] Maintain the objects order in the advertising packet --- src/bthome.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/bthome.py b/src/bthome.py index 7522801..e8f4463 100644 --- a/src/bthome.py +++ b/src/bthome.py @@ -485,6 +485,14 @@ def _pack_raw_text(self, object_id, value): CHANNEL_UINT8_X1: _pack_int8_x1 } + # Sort the packet object ids or tuples while preserving original order + def _sort_packet_objects(self, args): + args = sorted(enumerate(args), key=lambda ix: ( + ix[1] if isinstance(ix[1], int) else ix[1][0], + ix[0] + )) + return [x[1] for x in args] + # Concatenate an arbitrary number of sensor readings using parameters # of sensor data constants to indicate what's to be included. def _pack_service_data(self, *args): @@ -498,8 +506,7 @@ def _pack_service_data(self, *args): else: flags &= ~self._TRIGGER_BASED_FLAG service_data_bytes += pack("B", flags) - args = sorted(args, key = lambda x: x if isinstance(x, int) else x[0]) - for object_id in args: + for object_id in self._sort_packet_objects(args): if isinstance(object_id, tuple): value = object_id[1] object_id = object_id[0]