|
1 | 1 | # BTHome MicroPython |
2 | 2 | # Construct Bluetooth Low Energy (BLE) advertising payloads for BTHome v2. |
3 | | -# See https://bthome.io/ for more information about BTHome. |
| 3 | +# See https://bthome.io for more about how BTHome communicates data. |
4 | 4 | # See https://github.com/DavesCodeMusings/BTHome-MicroPython for more about this module. |
| 5 | +# Other interesting references are given as footnotes (e.g. [^1]) |
5 | 6 |
|
6 | 7 | from micropython import const |
7 | 8 | from struct import pack |
8 | 9 |
|
9 | | -# See "Advertising Payload" at https://bthome.io/format/ for details. |
10 | | -_ADVERT_LENGTH_MAX = const(255) |
11 | | -_ADVERT_FLAGS = bytes.fromhex( |
12 | | - "020106" |
13 | | -) # length (02), flags indicator (01), flag bits (06) |
14 | | -_DEVICE_NAME_LENGTH_MAX = const(10) |
15 | | -_SERVICE_DATA_UUID16 = const(0x16) |
16 | | -_SERVICE_UUID16 = const( |
17 | | - 0xFCD2 |
18 | | -) # See: https://bthome.io/images/License_Statement_-_BTHOME.pdf |
19 | | -_DEVICE_INFO_FLAGS = const( |
20 | | - 0x40 |
21 | | -) # Currently hardcoded: no encryption, regular updates, version 2 |
22 | | - |
23 | | -# See "Sensor Data" table at https://bthome.io/format/ for details. |
24 | | -BATTERY_UINT8 = const(0x01) |
25 | | -TEMPERATURE_SINT16 = const(0x02) |
26 | | -HUMIDITY_UINT16 = const(0x03) |
27 | | -PRESSURE_UINT24 = const(0x04) |
28 | | -ILLUMINANCE_UINT24 = const(0x05) |
29 | | -MASS_KG_UINT16 = const(0x06) |
30 | | -MASS_LB_UINT16 = const(0x07) |
31 | | -DEWPOINT_SINT16 = const(0x08) |
32 | | -COUNT_UINT8 = const(0x09) |
33 | | -ENERGY_UINT24 = const(0x0A) |
34 | | -POWER_UINT24 = const(0x0B) |
35 | | -VOLTAGE_UINT16 = const(0x0C) |
36 | | - |
37 | | -# Default value decimal places hint at precision |
38 | | -battery = 0 # percent |
39 | | -temperature = 0.00 # degrees Celsius |
40 | | -humidity = 0.00 # percent (relative humidity) |
41 | | -pressure = 0.00 # hectoPascals (millibars) |
42 | | -illuminance = 0.00 # Lux |
43 | | -mass = 0.00 # kg or lb |
44 | | -dewpoint = 0.00 # degrees |
45 | | -count = 0 # integer |
46 | | -energy = 0.000 # kWh |
47 | | -power = 0.00 # W |
48 | | -voltage = 0.000 # V |
49 | | -device_name = "BTHome-MPY" # Limit to 10 characters |
50 | | - |
51 | | - |
52 | | -def _pack_device_name(): |
53 | | - assert len(device_name) > 0 |
54 | | - assert len(device_name) <= _DEVICE_NAME_LENGTH_MAX |
55 | | - name_type = bytes.fromhex("09") # indicator for complete name |
56 | | - device_name_bytes = name_type + device_name.encode() |
57 | | - device_name_bytes = bytes([len(device_name_bytes)]) + device_name_bytes |
58 | | - return device_name_bytes |
59 | | - |
60 | | - |
61 | | -# 8-bit unsigned integer with scaling of 1 (no scaling, 0 decimal places) |
62 | | -def _pack_uint8_x1(object_id, value): |
63 | | - return pack("BB", object_id, value) |
64 | | - |
65 | | - |
66 | | -# 16-bit signed integer with scalling of 100 (2 decimal places) |
67 | | -def _pack_sint16_x100(object_id, value): |
68 | | - return pack("<Bh", object_id, round(value * 100)) |
69 | | - |
70 | | - |
71 | | -# 16-bit unsigned integer with scalling of 100 (2 decimal places) |
72 | | -def _pack_uint16_x100(object_id, value): |
73 | | - return pack("<BH", object_id, round(value * 100)) |
74 | | - |
75 | | - |
76 | | -# 16-bit unsigned integer with scalling of 1000 (3 decimal places) |
77 | | -def _pack_uint16_x1000(object_id, value): |
78 | | - return pack("<BH", object_id, round(value * 1000)) |
79 | | - |
80 | | - |
81 | | -# 24-bit unsigned integer with scaling of 100 (2 decimal places) |
82 | | -def _pack_uint24_x100(object_id, value): |
83 | | - return pack("<BL", object_id, round(value * 100))[:-1] |
84 | | - |
85 | | - |
86 | | -# 24-bit unsigned integer with scaling of 1000 (3 decimal places) |
87 | | -def _pack_uint24_x1000(object_id, value): |
88 | | - return pack("<BL", object_id, round(value * 1000))[:-1] |
89 | | - |
90 | | - |
91 | | -# The BTHome object ID determines the number of bytes and fixed point decimal multiplier. |
92 | | -def _pack_bthome_data(object_id): |
93 | | - if object_id == BATTERY_UINT8: |
94 | | - bthome_bytes = _pack_uint8_x1(BATTERY_UINT8, battery) |
95 | | - elif object_id == TEMPERATURE_SINT16: |
96 | | - bthome_bytes = _pack_sint16_x100(TEMPERATURE_SINT16, temperature) |
97 | | - elif object_id == HUMIDITY_UINT16: |
98 | | - bthome_bytes = _pack_uint16_x100(HUMIDITY_UINT16, humidity) |
99 | | - elif object_id == PRESSURE_UINT24: |
100 | | - bthome_bytes = _pack_uint24_x100(PRESSURE_UINT24, pressure) |
101 | | - elif object_id == ILLUMINANCE_UINT24: |
102 | | - bthome_bytes = _pack_uint24_x100(ILLUMINANCE_UINT24, illuminance) |
103 | | - elif object_id == MASS_KG_UINT16: |
104 | | - bthome_bytes = _pack_uint24_x100(MASS_KG_UINT16, mass) |
105 | | - elif object_id == MASS_LB_UINT16: |
106 | | - bthome_bytes = _pack_uint24_x100(MASS_LB_UINT16, mass) |
107 | | - elif object_id == DEWPOINT_SINT16: |
108 | | - bthome_bytes = _pack_sint16_x100(DEWPOINT_SINT16, dewpoint) |
109 | | - elif object_id == COUNT_UINT8: |
110 | | - bthome_bytes = _pack_uint8_x1(COUNT_UINT8, count) |
111 | | - elif object_id == ENERGY_UINT24: |
112 | | - bthome_bytes = _pack_uint24_x1000(ENERGY_UINT24, energy) |
113 | | - elif object_id == POWER_UINT24: |
114 | | - bthome_bytes = _pack_uint24_x100(POWER_UINT24, power) |
115 | | - elif object_id == VOLTAGE_UINT16: |
116 | | - bthome_bytes = _pack_uint16_x1000(VOLTAGE_UINT16, voltage) |
117 | | - else: |
118 | | - bthome_bytes = bytes() |
119 | | - print("Packing with data:", bthome_bytes.hex().upper()) |
120 | | - return bthome_bytes |
121 | | - |
122 | | - |
123 | | -# Concatenate an arbitrary number of sensor readings using parameters of sensor data constants to indicate what's included. |
124 | | -def _pack_service_data(*args): |
125 | | - service_data_bytes = pack( |
126 | | - "B", _SERVICE_DATA_UUID16 |
127 | | - ) # indicates a 16-bit service UUID follows |
128 | | - service_data_bytes += pack("<h", _SERVICE_UUID16) |
129 | | - service_data_bytes += pack("B", _DEVICE_INFO_FLAGS) |
130 | | - for object_id in args: |
131 | | - service_data_bytes += _pack_bthome_data(object_id) |
132 | | - service_data_bytes = pack("B", len(service_data_bytes)) + service_data_bytes |
133 | | - return service_data_bytes |
134 | | - |
135 | | - |
136 | | -# Construct advertising payload suitable for use by MicroPython's aioble.advertise(adv_data) |
137 | | -def pack_advertisement(*args): |
138 | | - advertisement_bytes = _ADVERT_FLAGS # All BTHome adverts start this way. |
139 | | - advertisement_bytes += _pack_device_name() |
140 | | - advertisement_bytes += _pack_service_data(*args) |
141 | | - assert len(advertisement_bytes) < _ADVERT_LENGTH_MAX |
142 | | - return advertisement_bytes |
| 10 | + |
| 11 | +class BTHome: |
| 12 | + # Bluetooth Low Energy flags |
| 13 | + _ADVERT_FLAGS = bytes.fromhex( |
| 14 | + "020106" |
| 15 | + ) # length (02), flags indicator (01), flag bits (06) [^1] |
| 16 | + _SERVICE_DATA_UUID16 = 0x16 |
| 17 | + _SERVICE_UUID16 = 0xFCD2 # 16-bit UUID reserved for BTHome. [^2] |
| 18 | + |
| 19 | + # BTHome specific flags |
| 20 | + _DEVICE_INFO_FLAGS = ( |
| 21 | + 0x40 # Currently hardcoded as: no encryption, regular updates, version 2 |
| 22 | + ) |
| 23 | + |
| 24 | + debug = False |
| 25 | + |
| 26 | + # Device name used in BLE advertisements. |
| 27 | + _local_name = "" |
| 28 | + |
| 29 | + # See "Sensor Data" table at https://bthome.io/format/ Object ID column. |
| 30 | + BATTERY_UINT8 = const(0x01) |
| 31 | + TEMPERATURE_SINT16 = const(0x02) |
| 32 | + HUMIDITY_UINT16 = const(0x03) |
| 33 | + PRESSURE_UINT24 = const(0x04) |
| 34 | + ILLUMINANCE_UINT24 = const(0x05) |
| 35 | + MASS_KG_UINT16 = const(0x06) |
| 36 | + MASS_LB_UINT16 = const(0x07) |
| 37 | + DEWPOINT_SINT16 = const(0x08) |
| 38 | + COUNT_UINT8 = const(0x09) |
| 39 | + ENERGY_UINT24 = const(0x0A) |
| 40 | + POWER_UINT24 = const(0x0B) |
| 41 | + VOLTAGE_UINT16 = const(0x0C) |
| 42 | + PM2_5_UINT16 = const(0x0D) |
| 43 | + PM10_UINT16 = const(0x0E) |
| 44 | + CO2_UINT16 = const(0x12) |
| 45 | + TVOC_UINT16 = const(0x13) |
| 46 | + MOISTURE_UINT16 = const(0x14) |
| 47 | + HUMIDITY_UINT8 = const(0x2E) |
| 48 | + |
| 49 | + # There is more than one way to represent most sensor properties. This |
| 50 | + # dictionary maps the object id to the property name. |
| 51 | + _object_id_properties = { |
| 52 | + BATTERY_UINT8: "battery", |
| 53 | + TEMPERATURE_SINT16: "temperature", |
| 54 | + HUMIDITY_UINT16: "humidity", |
| 55 | + PRESSURE_UINT24: "pressure", |
| 56 | + ILLUMINANCE_UINT24: "illuminance", |
| 57 | + MASS_KG_UINT16: "mass", |
| 58 | + MASS_LB_UINT16: "mass", |
| 59 | + DEWPOINT_SINT16: "dewpoint", |
| 60 | + COUNT_UINT8: "count", |
| 61 | + ENERGY_UINT24: "energy", |
| 62 | + POWER_UINT24: "power", |
| 63 | + VOLTAGE_UINT16: "voltage", |
| 64 | + PM2_5_UINT16: "pm2.5", |
| 65 | + PM10_UINT16: "pm10", |
| 66 | + CO2_UINT16: "co2", |
| 67 | + TVOC_UINT16: "tvoc", |
| 68 | + MOISTURE_UINT16: "moisture", |
| 69 | + HUMIDITY_UINT8: "humidity" |
| 70 | + } |
| 71 | + |
| 72 | + # See "Sensor Data" table at https://bthome.io/format/ Property column. |
| 73 | + acceleration = 0 |
| 74 | + battery = 0 |
| 75 | + channel = 0 |
| 76 | + co2 = 0 |
| 77 | + conductivity = 0 |
| 78 | + count = 0 |
| 79 | + current = 0 |
| 80 | + dewpoint = 0 |
| 81 | + direction = 0 |
| 82 | + distance = 0 |
| 83 | + duration = 0 |
| 84 | + energy = 0 |
| 85 | + gas = 0 |
| 86 | + gyroscope = 0 |
| 87 | + humidity = 0 |
| 88 | + illuminance = 0 |
| 89 | + mass = 0 |
| 90 | + moisture = 0 |
| 91 | + pm10 = 0 |
| 92 | + pm2_5 = 0 |
| 93 | + power = 0 |
| 94 | + precipitation = 0 |
| 95 | + pressure = 0 |
| 96 | + raw = 0 |
| 97 | + rotation = 0 |
| 98 | + speed = 0 |
| 99 | + temperature = 0 |
| 100 | + text = "" |
| 101 | + timestamp = 0 |
| 102 | + tvoc = 0 |
| 103 | + uv_index = 0 |
| 104 | + voltage = 0 |
| 105 | + volume = 0 |
| 106 | + volume_flow_rate = 0 |
| 107 | + volume_storage = 0 |
| 108 | + water = 0 |
| 109 | + |
| 110 | + def __init__(self, local_name="BTHome", debug=False): |
| 111 | + local_name = local_name[:10] # Truncate to fit [^3] |
| 112 | + self._local_name = local_name |
| 113 | + self.debug = debug |
| 114 | + |
| 115 | + @property |
| 116 | + def local_name(self): |
| 117 | + return self._local_name |
| 118 | + |
| 119 | + def pack_local_name(self): |
| 120 | + name_type = bytes.fromhex("09") # indicator for complete name |
| 121 | + local_name_bytes = name_type + self._local_name.encode() |
| 122 | + local_name_bytes = bytes([len(local_name_bytes)]) + local_name_bytes |
| 123 | + if self.debug: |
| 124 | + print("Local name:", self._local_name) |
| 125 | + print("Packed representation:", local_name_bytes) |
| 126 | + return local_name_bytes |
| 127 | + |
| 128 | + # Technically, the functions below could be static methods, but @staticmethod |
| 129 | + # on a dictionary of functions only works with Python >3.10, but MicroPython |
| 130 | + # is based on 3.4. Also, __func__ and __get()__ workarounds throw errors in |
| 131 | + # MicroPython. [^4] |
| 132 | + |
| 133 | + # 8-bit unsigned integer with scaling of 1 (no decimal places) |
| 134 | + def _pack_uint8_x1(self, object_id, value): |
| 135 | + return pack("BB", object_id, value) |
| 136 | + |
| 137 | + # 16-bit signed integer with scalling of 100 (2 decimal places) |
| 138 | + def _pack_sint16_x100(self, object_id, value): |
| 139 | + return pack("<Bh", object_id, round(value * 100)) |
| 140 | + |
| 141 | + # 16-bit unsigned integer with scalling of 1 (no decimal places) |
| 142 | + def _pack_uint16_x1(self, object_id, value): |
| 143 | + return pack("<BH", object_id, round(value)) |
| 144 | + |
| 145 | + # 16-bit unsigned integer with scalling of 100 (2 decimal places) |
| 146 | + def _pack_uint16_x100(self, object_id, value): |
| 147 | + return pack("<BH", object_id, round(value * 100)) |
| 148 | + |
| 149 | + # 16-bit unsigned integer with scalling of 1000 (3 decimal places) |
| 150 | + def _pack_uint16_x1000(self, object_id, value): |
| 151 | + return pack("<BH", object_id, round(value * 1000)) |
| 152 | + |
| 153 | + # 24-bit unsigned integer with scaling of 100 (2 decimal places) |
| 154 | + def _pack_uint24_x100(self, object_id, value): |
| 155 | + return pack("<BL", object_id, round(value * 100))[:-1] |
| 156 | + |
| 157 | + # 24-bit unsigned integer with scaling of 1000 (3 decimal places) |
| 158 | + def _pack_uint24_x1000(self, object_id, value): |
| 159 | + return pack("<BL", object_id, round(value * 1000))[:-1] |
| 160 | + |
| 161 | + _object_id_functions = { |
| 162 | + BATTERY_UINT8: _pack_uint8_x1, |
| 163 | + TEMPERATURE_SINT16: _pack_sint16_x100, |
| 164 | + HUMIDITY_UINT16: _pack_uint16_x100, |
| 165 | + PRESSURE_UINT24: _pack_uint24_x100, |
| 166 | + ILLUMINANCE_UINT24: _pack_uint24_x100, |
| 167 | + MASS_KG_UINT16: _pack_uint16_x100, |
| 168 | + MASS_LB_UINT16: _pack_uint16_x100, |
| 169 | + DEWPOINT_SINT16: _pack_sint16_x100, |
| 170 | + COUNT_UINT8: _pack_uint8_x1, |
| 171 | + ENERGY_UINT24: _pack_uint24_x1000, |
| 172 | + POWER_UINT24: _pack_uint24_x100, |
| 173 | + VOLTAGE_UINT16: _pack_uint16_x1000, |
| 174 | + PM2_5_UINT16: _pack_uint16_x1, |
| 175 | + PM10_UINT16: _pack_uint16_x1, |
| 176 | + CO2_UINT16: _pack_uint16_x1, |
| 177 | + TVOC_UINT16: _pack_uint16_x1, |
| 178 | + MOISTURE_UINT16: _pack_uint16_x100, |
| 179 | + HUMIDITY_UINT8: _pack_uint8_x1 |
| 180 | + } |
| 181 | + |
| 182 | + # Concatenate an arbitrary number of sensor readings using parameters |
| 183 | + # of sensor data constants to indicate what's to be included. |
| 184 | + def _pack_service_data(self, *args): |
| 185 | + service_data_bytes = pack( |
| 186 | + "B", BTHome._SERVICE_DATA_UUID16 |
| 187 | + ) # indicates a 16-bit service UUID follows |
| 188 | + service_data_bytes += pack("<H", BTHome._SERVICE_UUID16) |
| 189 | + service_data_bytes += pack("B", BTHome._DEVICE_INFO_FLAGS) |
| 190 | + for object_id in args: |
| 191 | + func = BTHome._object_id_functions[object_id] |
| 192 | + property = BTHome._object_id_properties[object_id] |
| 193 | + value = getattr(self, property) |
| 194 | + packed_representation = func(self, object_id, value) |
| 195 | + if self.debug: |
| 196 | + print("Using function:", func) |
| 197 | + print("Data property:", property) |
| 198 | + print("Data value:", value) |
| 199 | + print("Packed representation:", packed_representation.hex().upper()) |
| 200 | + service_data_bytes += packed_representation |
| 201 | + service_data_bytes = pack("B", len(service_data_bytes)) + service_data_bytes |
| 202 | + return service_data_bytes |
| 203 | + |
| 204 | + def pack_advertisement(self, *args): |
| 205 | + advertisement_bytes = self._ADVERT_FLAGS # All BTHome adverts start this way. |
| 206 | + advertisement_bytes += self.pack_local_name() |
| 207 | + advertisement_bytes += self._pack_service_data(*args) |
| 208 | + if self.debug: |
| 209 | + print("BLE Advertisement:", advertisement_bytes.hex().upper()) |
| 210 | + return advertisement_bytes |
| 211 | + |
| 212 | + |
| 213 | +# This demo's values match what is used by the example at: https://bthome.io/format |
| 214 | +# The advertisement printed here should match the example BTHome payload. |
| 215 | +def demo(): |
| 216 | + beacon = BTHome("DIY-sensor", debug=True) |
| 217 | + beacon.temperature = 25 |
| 218 | + beacon.humidity = 50.55 |
| 219 | + ble_advert = beacon.pack_advertisement( |
| 220 | + BTHome.TEMPERATURE_SINT16, BTHome.HUMIDITY_UINT16 |
| 221 | + ) |
| 222 | + return ble_advert |
| 223 | + |
| 224 | + |
| 225 | +if __name__ == "__main__": |
| 226 | + demo() |
| 227 | + |
| 228 | + |
| 229 | +# [^1]: https://community.silabs.com/s/article/kba-bt-0201-bluetooth-advertising-data-basics |
| 230 | +# [^2]: https://bthome.io/images/License_Statement_-_BTHOME.pdf |
| 231 | +# [^3]: https://community.st.com/t5/stm32-mcus-wireless/ble-name-advertising/m-p/254711/highlight/true#M10645 |
| 232 | +# [^4]: https://stackoverflow.com/questions/41921255/staticmethod-object-is-not-callable |
0 commit comments