Skip to content

Commit e1c5657

Browse files
Convert to class
1 parent 0b3b722 commit e1c5657

File tree

2 files changed

+234
-150
lines changed

2 files changed

+234
-150
lines changed

src/bthome.py

Lines changed: 225 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,142 +1,232 @@
11
# BTHome MicroPython
22
# 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.
44
# See https://github.com/DavesCodeMusings/BTHome-MicroPython for more about this module.
5+
# Other interesting references are given as footnotes (e.g. [^1])
56

67
from micropython import const
78
from struct import pack
89

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

Comments
 (0)