bleparser 3.6.0__tar.gz → 3.7.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {bleparser-3.6.0 → bleparser-3.7.3}/PKG-INFO +20 -8
  2. {bleparser-3.6.0 → bleparser-3.7.3}/README.md +16 -6
  3. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/__init__.py +20 -6
  4. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/airmentor.py +3 -3
  5. bleparser-3.7.3/package/bleparser/amazfit.py +67 -0
  6. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/brifit.py +1 -1
  7. bleparser-3.7.3/package/bleparser/bthome.py +355 -0
  8. bleparser-3.7.3/package/bleparser/bthome_const.py +267 -0
  9. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/govee.py +12 -6
  10. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/hhcc.py +1 -1
  11. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/inkbird.py +21 -16
  12. bleparser-3.7.3/package/bleparser/jaalee.py +60 -0
  13. bleparser-3.7.3/package/bleparser/sensirion.py +231 -0
  14. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/thermoplus.py +1 -1
  15. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/xiaomi.py +199 -10
  16. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/PKG-INFO +20 -8
  17. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/SOURCES.txt +4 -2
  18. {bleparser-3.6.0 → bleparser-3.7.3}/setup.cfg +1 -1
  19. bleparser-3.6.0/package/bleparser/ha_ble.py +0 -233
  20. bleparser-3.6.0/package/bleparser/ha_ble_encryption.py +0 -89
  21. bleparser-3.6.0/package/bleparser/sensirion.py +0 -120
  22. {bleparser-3.6.0 → bleparser-3.7.3}/LICENSE +0 -0
  23. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/acconeer.py +0 -0
  24. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/almendo.py +0 -0
  25. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/altbeacon.py +0 -0
  26. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/atc.py +0 -0
  27. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/bluemaestro.py +0 -0
  28. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/bparasite.py +0 -0
  29. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/const.py +0 -0
  30. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/helpers.py +0 -0
  31. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/ibeacon.py +0 -0
  32. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/inode.py +0 -0
  33. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/jinou.py +0 -0
  34. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/kegtron.py +0 -0
  35. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/kkm.py +0 -0
  36. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/laica.py +0 -0
  37. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/mikrotik.py +0 -0
  38. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/miscale.py +0 -0
  39. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/moat.py +0 -0
  40. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/oral_b.py +0 -0
  41. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/qingping.py +0 -0
  42. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/relsib.py +0 -0
  43. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/ruuvitag.py +0 -0
  44. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/sensorpush.py +0 -0
  45. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/smartdry.py +0 -0
  46. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/switchbot.py +0 -0
  47. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/teltonika.py +0 -0
  48. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/thermopro.py +0 -0
  49. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/tilt.py +0 -0
  50. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/xiaogui.py +0 -0
  51. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/dependency_links.txt +0 -0
  52. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/requires.txt +0 -0
  53. {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/top_level.txt +0 -0
  54. {bleparser-3.6.0 → bleparser-3.7.3}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: bleparser
3
- Version: 3.6.0
3
+ Version: 3.7.3
4
4
  Summary: Parser for passive BLE advertisements
5
5
  Home-page: https://github.com/Ernst79/bleparser
6
6
  Author: Ernst79
@@ -12,6 +12,8 @@ Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.8
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
+ Requires-Dist: pycryptodomex
16
+ Dynamic: license-file
15
17
 
16
18
  # BLE parser for passive BLE advertisements
17
19
 
@@ -29,18 +31,17 @@ Supported sensor brands
29
31
 
30
32
  - Acconeer
31
33
  - Air Mentor
32
- - Almendo bluSensor
33
- - AltBeacon
34
+ - Amazfit
34
35
  - ATC (custom firmware for Xiaomi/Qingping sensors)
35
36
  - BlueMaestro
36
37
  - Brifit
38
+ - BTHome
37
39
  - b-parasite
38
40
  - Govee
39
- - HA BLE
40
41
  - HHCC
41
- - iBeacon
42
42
  - Inkbird
43
43
  - iNode
44
+ - Jaalee
44
45
  - Jinou
45
46
  - Kegtron
46
47
  - KKM
@@ -52,6 +53,7 @@ Supported sensor brands
52
53
  - Ruuvitag
53
54
  - Sensirion
54
55
  - SensorPush
56
+ - SmartDry
55
57
  - Switchbot
56
58
  - Teltonika
57
59
  - Thermoplus
@@ -101,11 +103,11 @@ sensor_data, tracker_data = self.parse_advertisement(
101
103
  )
102
104
  ```
103
105
 
104
- `service_data_list` and `man_spec_data_list` have to be in a list, as a BKE advertisement can contain multiple service data/manufacturer specific data packets.
106
+ `service_data_list` and `man_spec_data_list` have to be in a list, as a BLE advertisement can contain multiple service data/manufacturer specific data packets.
105
107
 
106
108
  **report_unknown**
107
109
 
108
- Report unknown sensors. Can be set to `ATC`, `b-parasite`, `BlueMaestro`, `Brifit`, `Govee`, `Inkbird`, `iNode`, `Jinou`, `Kegtron`, `KKM`, `Mikrotik`, `Moat`, `Mi Scale`, `Oral-B`, `Qingping`, `Ruuvitag`, `SensorPush`, `Sensirion`, `Teltonika`, `Thermoplus`, `Thermopro`, `Xiaogui` or `Xiaomi` to report unknown sensors of a specific brand to the logger. You can set it to `Other` to report all unknown advertisements to the logger. Default: `False`
110
+ Report unknown sensors. Can be set to `Acconeer`, `Air Mentor`, `Amazfit`, `ATC`, `b-parasite`, `BlueMaestro`, `Brifit`, `BTHome`, `Govee`, `HHCC`, `Inkbird`, `iNode`, `Jaalee`, `Jinou`, `Kegtron`, `KKM`, `Mikrotik`, `Moat`, `Mi Scale`, `Oral-B`, `Qingping`, `Relsib`, `Ruuvitag`, `SensorPush`, `Sensirion`, `SmartDry`, `Switchbot`, `Teltonika`, `Thermoplus`, `Thermopro`, `Tilt`, `Xiaogui` or `Xiaomi` to report unknown sensors of a specific brand to the logger. You can set it to `Other` to report all unknown advertisements to the logger. Default: `False`
109
111
 
110
112
  **discovery**
111
113
 
@@ -119,6 +121,11 @@ Boolean. Most sensors send multipe advertisements with the exact same data, to i
119
121
 
120
122
  List with MAC addresses or UUIDs of devices that are being parsed, if `discovery` is set to `False`. If `discovery` is set to `True`, all supported sensors will be parsed. Default: `[]`
121
123
 
124
+ The MAC addresses should be in bytes.
125
+ ```python
126
+ sensor_whitelist=[bytes.fromhex("A4:C1:38:56:53:84".replace(":", ""))],
127
+ ```
128
+
122
129
  **tracker_whitelist**
123
130
 
124
131
  List with devices (MAC addresses or UUIDs) to track. Default: `[]`
@@ -127,6 +134,11 @@ List with devices (MAC addresses or UUIDs) to track. Default: `[]`
127
134
 
128
135
  Dictionary with mac + encryption key pairs, for sensors that require an encryption key to decrypt the payload. Default: `{}`
129
136
 
137
+ The mac and encryption key pairs should be in bytes.
138
+ ```python
139
+ aeskeys={bytes.fromhex("A4:C1:38:56:53:84".replace(":", "")): bytes.fromhex("a115210eed7a88e50ad52662e732a9fb") for mac, aeskey in AESKEYS.items()},
140
+ ```
141
+
130
142
  ## Result
131
143
 
132
144
  The parser result are two two dictionaries, one with sensor data (e.g. temperature readings) and one with tracking data.
@@ -14,18 +14,17 @@ Supported sensor brands
14
14
 
15
15
  - Acconeer
16
16
  - Air Mentor
17
- - Almendo bluSensor
18
- - AltBeacon
17
+ - Amazfit
19
18
  - ATC (custom firmware for Xiaomi/Qingping sensors)
20
19
  - BlueMaestro
21
20
  - Brifit
21
+ - BTHome
22
22
  - b-parasite
23
23
  - Govee
24
- - HA BLE
25
24
  - HHCC
26
- - iBeacon
27
25
  - Inkbird
28
26
  - iNode
27
+ - Jaalee
29
28
  - Jinou
30
29
  - Kegtron
31
30
  - KKM
@@ -37,6 +36,7 @@ Supported sensor brands
37
36
  - Ruuvitag
38
37
  - Sensirion
39
38
  - SensorPush
39
+ - SmartDry
40
40
  - Switchbot
41
41
  - Teltonika
42
42
  - Thermoplus
@@ -86,11 +86,11 @@ sensor_data, tracker_data = self.parse_advertisement(
86
86
  )
87
87
  ```
88
88
 
89
- `service_data_list` and `man_spec_data_list` have to be in a list, as a BKE advertisement can contain multiple service data/manufacturer specific data packets.
89
+ `service_data_list` and `man_spec_data_list` have to be in a list, as a BLE advertisement can contain multiple service data/manufacturer specific data packets.
90
90
 
91
91
  **report_unknown**
92
92
 
93
- Report unknown sensors. Can be set to `ATC`, `b-parasite`, `BlueMaestro`, `Brifit`, `Govee`, `Inkbird`, `iNode`, `Jinou`, `Kegtron`, `KKM`, `Mikrotik`, `Moat`, `Mi Scale`, `Oral-B`, `Qingping`, `Ruuvitag`, `SensorPush`, `Sensirion`, `Teltonika`, `Thermoplus`, `Thermopro`, `Xiaogui` or `Xiaomi` to report unknown sensors of a specific brand to the logger. You can set it to `Other` to report all unknown advertisements to the logger. Default: `False`
93
+ Report unknown sensors. Can be set to `Acconeer`, `Air Mentor`, `Amazfit`, `ATC`, `b-parasite`, `BlueMaestro`, `Brifit`, `BTHome`, `Govee`, `HHCC`, `Inkbird`, `iNode`, `Jaalee`, `Jinou`, `Kegtron`, `KKM`, `Mikrotik`, `Moat`, `Mi Scale`, `Oral-B`, `Qingping`, `Relsib`, `Ruuvitag`, `SensorPush`, `Sensirion`, `SmartDry`, `Switchbot`, `Teltonika`, `Thermoplus`, `Thermopro`, `Tilt`, `Xiaogui` or `Xiaomi` to report unknown sensors of a specific brand to the logger. You can set it to `Other` to report all unknown advertisements to the logger. Default: `False`
94
94
 
95
95
  **discovery**
96
96
 
@@ -104,6 +104,11 @@ Boolean. Most sensors send multipe advertisements with the exact same data, to i
104
104
 
105
105
  List with MAC addresses or UUIDs of devices that are being parsed, if `discovery` is set to `False`. If `discovery` is set to `True`, all supported sensors will be parsed. Default: `[]`
106
106
 
107
+ The MAC addresses should be in bytes.
108
+ ```python
109
+ sensor_whitelist=[bytes.fromhex("A4:C1:38:56:53:84".replace(":", ""))],
110
+ ```
111
+
107
112
  **tracker_whitelist**
108
113
 
109
114
  List with devices (MAC addresses or UUIDs) to track. Default: `[]`
@@ -112,6 +117,11 @@ List with devices (MAC addresses or UUIDs) to track. Default: `[]`
112
117
 
113
118
  Dictionary with mac + encryption key pairs, for sensors that require an encryption key to decrypt the payload. Default: `{}`
114
119
 
120
+ The mac and encryption key pairs should be in bytes.
121
+ ```python
122
+ aeskeys={bytes.fromhex("A4:C1:38:56:53:84".replace(":", "")): bytes.fromhex("a115210eed7a88e50ad52662e732a9fb") for mac, aeskey in AESKEYS.items()},
123
+ ```
124
+
115
125
  ## Result
116
126
 
117
127
  The parser result are two two dictionaries, one with sensor data (e.g. temperature readings) and one with tracking data.
@@ -6,18 +6,20 @@ from .acconeer import parse_acconeer
6
6
  from .airmentor import parse_airmentor
7
7
  from .almendo import parse_almendo
8
8
  from .altbeacon import parse_altbeacon
9
+ from .amazfit import parse_amazfit
9
10
  from .atc import parse_atc
10
11
  from .bluemaestro import parse_bluemaestro
11
12
  from .bparasite import parse_bparasite
12
13
  from .brifit import parse_brifit
14
+ from .bthome import parse_bthome
13
15
  from .const import TILT_TYPES
14
16
  from .govee import parse_govee
15
17
  from .helpers import to_mac, to_unformatted_mac
16
- from .ha_ble import parse_ha_ble
17
18
  from .hhcc import parse_hhcc
18
19
  from .ibeacon import parse_ibeacon
19
20
  from .inkbird import parse_inkbird
20
21
  from .inode import parse_inode
22
+ from .jaalee import parse_jaalee
21
23
  from .jinou import parse_jinou
22
24
  from .kegtron import parse_kegtron
23
25
  from .kkm import parse_kkm
@@ -196,13 +198,21 @@ class BleParser:
196
198
  sensor_data = parse_miscale(self, service_data, mac, rssi)
197
199
  break
198
200
  elif uuid16 in [0x181C, 0x181E]:
199
- # UUID16 = User Data and Bond Management (used by BLE HA)
200
- sensor_data = parse_ha_ble(self, service_data, uuid16, mac, rssi)
201
+ # UUID16 = User Data and Bond Management (used by BTHome V1)
202
+ sensor_data = parse_bthome(self, service_data, uuid16, mac, rssi)
201
203
  break
202
204
  elif uuid16 in [0xAA20, 0xAA21, 0xAA22] and local_name == "ECo":
203
205
  # UUID16 = Relsib
204
206
  sensor_data = parse_relsib(self, service_data, mac, rssi)
205
207
  break
208
+ elif uuid16 == 0xF525:
209
+ # UUID16 = Jaalee
210
+ sensor_data = parse_jaalee(self, service_data, mac, rssi)
211
+ break
212
+ elif uuid16 == 0xFCD2:
213
+ # UUID16 = Allterco Robotics ltd (BTHome V2)
214
+ sensor_data = parse_bthome(self, service_data, uuid16, mac, rssi)
215
+ break
206
216
  elif uuid16 in [0xFD3D, 0x0D00]:
207
217
  # UUID16 = unknown (used by Switchbot)
208
218
  sensor_data = parse_switchbot(self, service_data, mac, rssi)
@@ -228,6 +238,10 @@ class BleParser:
228
238
  # UUID16 = Google (used by Ruuvitag V2/V4)
229
239
  sensor_data = parse_ruuvitag(self, service_data, mac, rssi)
230
240
  break
241
+ elif uuid16 == 0xFEE0:
242
+ # UUID16 = Anhui Huami Information Technology Co., Ltd. (Amazfit)
243
+ sensor_data = parse_amazfit(self, service_data, mac, rssi)
244
+ break
231
245
  elif uuid16 == 0xFFF9:
232
246
  # UUID16 = FIDO (used by Cleargrass)
233
247
  sensor_data = parse_qingping(self, service_data, mac, rssi)
@@ -289,7 +303,7 @@ class BleParser:
289
303
  # Sensirion
290
304
  sensor_data = parse_sensirion(self, man_spec_data, local_name, mac, rssi)
291
305
  break
292
- elif comp_id in [0x2121, 0x2122] and data_len == 0x0B:
306
+ elif comp_id in [0x2111, 0x2112, 0x2121, 0x2122] and data_len == 0x0B:
293
307
  # Air Mentor
294
308
  sensor_data = parse_airmentor(self, man_spec_data, mac, rssi)
295
309
  break
@@ -348,11 +362,11 @@ class BleParser:
348
362
  sensor_data = parse_govee(self, man_spec_data, mac, rssi)
349
363
  break
350
364
  elif service_class_uuid16 == 0xF0FF:
351
- if comp_id in [0x0010, 0x0011, 0x0015] and data_len in [0x15, 0x17]:
365
+ if comp_id in [0x0010, 0x0011, 0x0015, 0x0018] and data_len in [0x15, 0x17]:
352
366
  # Thermoplus
353
367
  sensor_data = parse_thermoplus(self, man_spec_data, mac, rssi)
354
368
  break
355
- elif (comp_id in [0x0000, 0x0001] or local_name in ["iBBQ", "sps", "tps"]) and (
369
+ elif (comp_id in [0x0000, 0x0001] or local_name in ["iBBQ", "xBBQ", "sps", "tps"]) and (
356
370
  data_len in [0x0A, 0x0D, 0x0F, 0x13, 0x17]
357
371
  ):
358
372
  # Inkbird
@@ -20,7 +20,7 @@ def parse_airmentor(self, data, source_mac, rssi):
20
20
  airmentor_mac = source_mac
21
21
  msg_type = data[2]
22
22
  xvalue = data[4:]
23
- if msg_type == 0x22:
23
+ if msg_type in [0x12, 0x22]:
24
24
  (tvoc, temp, temp_cal, humi, aqi) = unpack(">HHBBH", xvalue)
25
25
  temperature = (temp - 4000) * 0.01
26
26
  temperature_calibrated = temperature - temp_cal * 0.1
@@ -47,7 +47,7 @@ def parse_airmentor(self, data, source_mac, rssi):
47
47
  "aqi": aqi,
48
48
  "air quality": air_quality
49
49
  }
50
- if msg_type == 0x21:
50
+ if msg_type in [0x11, 0x21]:
51
51
  (co2, pm25, pm10, co, o3) = unpack(">HHHBB", xvalue)
52
52
  # CO and O3 are not used
53
53
  result = {
@@ -68,7 +68,7 @@ def parse_airmentor(self, data, source_mac, rssi):
68
68
  return None
69
69
 
70
70
  # check for MAC presence in sensor whitelist, if needed
71
- if self.discovery is False and airmentor_mac.lower() not in self.sensor_whitelist:
71
+ if self.discovery is False and airmentor_mac not in self.sensor_whitelist:
72
72
  _LOGGER.debug("Discovery is disabled. MAC: %s is not whitelisted!", to_mac(airmentor_mac))
73
73
  return None
74
74
 
@@ -0,0 +1,67 @@
1
+ """Parser for Amazfit BLE advertisements"""
2
+ import logging
3
+ from struct import unpack
4
+
5
+ from .helpers import (
6
+ to_mac,
7
+ to_unformatted_mac,
8
+ )
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ def parse_amazfit(self, data, source_mac, rssi):
14
+ """Parse Amazfit BLE advertisements"""
15
+ msg_length = len(data)
16
+
17
+ if msg_length == 24:
18
+ device_type = "Amazfit Smart Scale"
19
+ firmware = "Amazfit"
20
+ xvalue = data[4:25]
21
+ # Not all info is known
22
+ # byte 0-4: unknown
23
+ # byte 5-6: impedance / 10
24
+ # byte 7-8: weight / 200
25
+ # byte 9-11: unknown, used now as "if 000000 than non-stabilized weight"
26
+ # byte 12: pulse
27
+ # byte 13: unknown
28
+ # byte 14-19: user information, not used yet
29
+ (impedance, weight, unk_1, unk_2, pulse) = unpack("<5xHHBHB1x6x", xvalue)
30
+ if unk_1 == 0 and unk_2 == 0:
31
+ result = {
32
+ "non-stabilized weight": weight / 200,
33
+ "stabilized": 0,
34
+ "weight unit": 'kg',
35
+ }
36
+ else:
37
+ result = {
38
+ "non-stabilized weight": weight / 200,
39
+ "stabilized": 1,
40
+ "weight": weight / 200,
41
+ "impedance": impedance / 10,
42
+ "pulse": pulse,
43
+ "weight unit": 'kg',
44
+ }
45
+ else:
46
+ if self.report_unknown == "Amazfit":
47
+ _LOGGER.info(
48
+ "BLE ADV from UNKNOWN Amazfit Scale DEVICE: MAC: %s, ADV: %s",
49
+ to_mac(source_mac),
50
+ data.hex()
51
+ )
52
+ return None
53
+
54
+ # check for MAC presence in sensor whitelist, if needed
55
+ if self.discovery is False and source_mac.lower() not in self.sensor_whitelist:
56
+ _LOGGER.debug("Discovery is disabled. MAC: %s is not whitelisted!", to_mac(source_mac))
57
+ return None
58
+
59
+ result.update({
60
+ "type": device_type,
61
+ "firmware": firmware,
62
+ "mac": to_unformatted_mac(source_mac),
63
+ "packet": 'no packet id',
64
+ "rssi": rssi,
65
+ "data": True,
66
+ })
67
+ return result
@@ -23,7 +23,7 @@ def parse_brifit(self, data, source_mac, rssi):
23
23
 
24
24
  brifit_mac = data[6:12]
25
25
  xvalue = data[12:19]
26
- (volt, temp, humi, batt) = unpack(">hHHB", xvalue)
26
+ (volt, temp, humi, batt) = unpack(">HhHB", xvalue)
27
27
  result = {
28
28
  "temperature": temp / 100,
29
29
  "humidity": humi / 100,
@@ -0,0 +1,355 @@
1
+ """Parser for BTHome (DIY sensors) advertisements"""
2
+ import logging
3
+ import struct
4
+ from typing import Any
5
+ from Cryptodome.Cipher import AES
6
+
7
+ from .bthome_const import MEAS_TYPES
8
+ from .helpers import (
9
+ to_mac,
10
+ to_unformatted_mac,
11
+ )
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ def parse_uint(data_obj: bytes, factor: float = 1.0) -> float:
17
+ """Convert bytes (as unsigned integer) and factor to float."""
18
+ decimal_places = -int(f"{factor:e}".split("e")[-1])
19
+ return round(
20
+ int.from_bytes(data_obj, "little", signed=False) * factor, decimal_places
21
+ )
22
+
23
+
24
+ def parse_int(data_obj: bytes, factor: float = 1.0) -> float:
25
+ """Convert bytes (as signed integer) and factor to float."""
26
+ decimal_places = -int(f"{factor:e}".split("e")[-1])
27
+ return round(
28
+ int.from_bytes(data_obj, "little", signed=True) * factor, decimal_places
29
+ )
30
+
31
+
32
+ def parse_float(data_obj: bytes, factor: float = 1.0):
33
+ """Convert bytes (as float) and factor to float."""
34
+ decimal_places = -int(f"{factor:e}".split("e")[-1])
35
+ if len(data_obj) == 2:
36
+ [val] = struct.unpack("e", data_obj)
37
+ elif len(data_obj) == 4:
38
+ [val] = struct.unpack("f", data_obj)
39
+ elif len(data_obj) == 8:
40
+ [val] = struct.unpack("d", data_obj)
41
+ else:
42
+ _LOGGER.error("only 2, 4 or 8 byte long floats are supported in BTHome BLE")
43
+ return None
44
+ return round(val * factor, decimal_places)
45
+
46
+
47
+ def parse_string(data_obj: bytes) -> str:
48
+ """Convert bytes to string."""
49
+ return data_obj.decode("UTF-8")
50
+
51
+
52
+ dispatch = {
53
+ 0x00: parse_uint,
54
+ 0x01: parse_int,
55
+ 0x02: parse_float,
56
+ 0x03: parse_string,
57
+ }
58
+
59
+
60
+ def parse_bthome(self, data, uuid16, source_mac, rssi):
61
+ """BTHome BLE parser"""
62
+ self.uuid16 = uuid16
63
+ self.bthome_mac = source_mac
64
+ self.rssi = rssi
65
+
66
+ if self.uuid16 == 0xFCD2:
67
+ # BTHome V2 format
68
+ return parse_bthome_v2(self, data)
69
+ elif self.uuid16 in [0x181C, 0x181E]:
70
+ # BTHome V1 format
71
+ return parse_bthome_v1(self, data)
72
+ else:
73
+ return None
74
+
75
+
76
+ def parse_bthome_v1(self, data):
77
+ "Parse data in BTHome V1 format"
78
+ self.device_type = "BTHome"
79
+ self.packet_id = None
80
+ sw_version = 1
81
+ payload = data[4:]
82
+ if self.uuid16 == 0x181C:
83
+ # Non-encrypted BTHome V1 format
84
+ self.firmware = "BTHome V1"
85
+ self.packet_id = None
86
+ elif self.uuid16 == 0x181E:
87
+ # Encrypted BTHome V1 format
88
+ self.firmware = "BTHome V1 (encrypted)"
89
+ try:
90
+ payload, count_id = decrypt_data(self, payload, sw_version)
91
+ except (ValueError, TypeError):
92
+ return None
93
+
94
+ self.packet_id = parse_uint(count_id)
95
+ else:
96
+ return None
97
+
98
+ return parse_payload(self, payload, sw_version)
99
+
100
+
101
+ def parse_bthome_v2(self, data):
102
+ "Parse data in BTHome V2 format"
103
+ self.device_type = "BTHome"
104
+ self.packet_id = None
105
+
106
+ adv_info = data[4]
107
+
108
+ # Determine if encryption is used and check BTHome version
109
+ encryption = adv_info & (1 << 0) # bit 0
110
+ sw_version = (adv_info >> 5) & 7 # 3 bits (5-7)
111
+ if sw_version == 2:
112
+ if encryption == 1:
113
+ self.firmware = f"BTHome V{sw_version} (encrypted)"
114
+ else:
115
+ self.firmware = f"BTHome V{sw_version}"
116
+ else:
117
+ _LOGGER.error(
118
+ "Sensor is set to use BTHome version %s, which is not existing. "
119
+ "Please modify the version in the first byte of the service data",
120
+ sw_version,
121
+ )
122
+ return False
123
+
124
+ payload = data[5:]
125
+
126
+ if encryption == 1:
127
+ try:
128
+ payload, count_id = decrypt_data(self, payload, sw_version)
129
+ except (ValueError, TypeError):
130
+ return None
131
+
132
+ self.packet_id = parse_uint(count_id)
133
+
134
+ return parse_payload(self, payload, sw_version)
135
+
136
+
137
+ def parse_payload(self, payload, sw_version):
138
+ "Parse the payload"
139
+ payload_length = len(payload)
140
+ next_obj_start = 0
141
+ prev_obj_meas_type = 0
142
+ result = {}
143
+ measurements: list[dict[str, Any]] = []
144
+ postfix_dict: dict[int, int] = {}
145
+ obj_data_format: str | int
146
+
147
+ # Create a list with all individual objects
148
+ while payload_length >= next_obj_start + 1:
149
+ obj_start = next_obj_start
150
+
151
+ if sw_version == 1:
152
+ # BTHome V1
153
+ obj_meas_type = payload[obj_start + 1]
154
+ obj_data_unit = MEAS_TYPES[obj_meas_type].unit_of_measurement
155
+ obj_control_byte = payload[obj_start]
156
+ obj_data_length = (obj_control_byte >> 0) & 31 # 5 bits (0-4)
157
+ obj_data_format = (obj_control_byte >> 5) & 7 # 3 bits (5-7)
158
+ obj_data_start = obj_start + 2
159
+ next_obj_start = obj_start + obj_data_length + 1
160
+ else:
161
+ # BTHome V2
162
+ obj_meas_type = payload[obj_start]
163
+ if prev_obj_meas_type > obj_meas_type:
164
+ _LOGGER.warning(
165
+ "BTHome device is not sending object ids in numerical order (from low to "
166
+ "high object id). This can cause issues with your BTHome receiver, "
167
+ "payload: %s",
168
+ payload.hex(),
169
+ )
170
+ if obj_meas_type not in MEAS_TYPES:
171
+ _LOGGER.debug(
172
+ "Invalid Object ID found in payload: %s",
173
+ payload.hex(),
174
+ )
175
+ break
176
+ prev_obj_meas_type = obj_meas_type
177
+ obj_data_length = MEAS_TYPES[obj_meas_type].data_length
178
+ obj_data_format = MEAS_TYPES[obj_meas_type].data_format
179
+ obj_data_unit = MEAS_TYPES[obj_meas_type].unit_of_measurement
180
+ obj_data_start = obj_start + 1
181
+ next_obj_start = obj_start + obj_data_length + 1
182
+
183
+ if obj_data_length == 0:
184
+ _LOGGER.debug(
185
+ "Invalid payload data length found with length 0, payload: %s",
186
+ payload.hex(),
187
+ )
188
+ continue
189
+
190
+ if payload_length < next_obj_start:
191
+ _LOGGER.debug("Invalid payload data length, payload: %s", payload.hex())
192
+ break
193
+ measurements.append(
194
+ {
195
+ "data format": obj_data_format,
196
+ "data unit": obj_data_unit,
197
+ "data length": obj_data_length,
198
+ "measurement type": obj_meas_type,
199
+ "measurement data": payload[obj_data_start:next_obj_start],
200
+ "device id": None,
201
+ }
202
+ )
203
+
204
+ # Get a list of measurement types that are included more than once.
205
+ seen_meas_types = set()
206
+ dup_meas_types = set()
207
+ for meas in measurements:
208
+ if meas["measurement type"] in seen_meas_types:
209
+ dup_meas_types.add(meas["measurement type"])
210
+ else:
211
+ seen_meas_types.add(meas["measurement type"])
212
+
213
+ # Parse each object into readable information
214
+ for meas in measurements:
215
+ if meas["measurement type"] not in MEAS_TYPES:
216
+ _LOGGER.debug(
217
+ "UNKNOWN measurement type %s in BTHome BLE payload! Adv: %s",
218
+ meas["measurement type"],
219
+ payload.hex(),
220
+ )
221
+ continue
222
+
223
+ # Old Shelly buttons send 0xFE when held, rather than 0x80.
224
+ if (
225
+ MEAS_TYPES[meas["measurement type"]].meas_format == "button" and
226
+ meas["measurement data"] == b"\xFE"
227
+ ):
228
+ meas["measurement data"] = b'\x80'
229
+
230
+ if meas["measurement type"] in dup_meas_types:
231
+ # Add a postfix for advertisements with multiple measurements of the same type
232
+ postfix_counter = postfix_dict.get(meas["measurement type"], 0) + 1
233
+ postfix_dict[meas["measurement type"]] = postfix_counter
234
+ postfix = f"_{postfix_counter}"
235
+ else:
236
+ postfix = ""
237
+
238
+ meas_type = MEAS_TYPES[meas["measurement type"]]
239
+ meas_unit = meas_type.unit_of_measurement
240
+ meas_format = f"{meas_type.meas_format}{postfix}"
241
+ meas_factor = meas_type.factor
242
+ value: None | str | int | float
243
+
244
+ if meas["data format"] == 0 or meas["data format"] == "unsigned_integer":
245
+ value = parse_uint(meas["measurement data"], meas_factor)
246
+ elif meas["data format"] == 1 or meas["data format"] == "signed_integer":
247
+ value = parse_int(meas["measurement data"], meas_factor)
248
+ elif meas["data format"] == 2 or meas["data format"] == "float":
249
+ value = parse_float(meas["measurement data"], meas_factor)
250
+ elif meas["data format"] == 3 or meas["data format"] == "string":
251
+ value = parse_string(meas["measurement data"])
252
+ else:
253
+ _LOGGER.error(
254
+ "UNKNOWN dataobject in BTHome BLE payload! Adv: %s",
255
+ payload.hex(),
256
+ )
257
+ continue
258
+
259
+ if value is not None:
260
+ result.update({meas_format: value})
261
+ if meas_unit == "lbs":
262
+ # Weight measurement with non-standard unit of measurement (lb)
263
+ result.update({"weight unit": meas_unit})
264
+
265
+ if not result:
266
+ if self.report_unknown == "BTHome":
267
+ _LOGGER.info(
268
+ "BLE ADV from UNKNOWN Home Assistant BLE DEVICE: RSSI: %s, MAC: %s, ADV: %s",
269
+ self.rssi,
270
+ to_mac(self.bthome_mac),
271
+ payload.hex()
272
+ )
273
+ return None
274
+
275
+ # Check for packet id in payload
276
+ if result.get("packet"):
277
+ self.packet_id = result["packet"]
278
+
279
+ # Check for duplicate messages
280
+ if self.packet_id:
281
+ try:
282
+ prev_packet = self.lpacket_ids[self.bthome_mac]
283
+ except KeyError:
284
+ # start with empty first packet
285
+ prev_packet = None
286
+ if prev_packet == self.packet_id:
287
+ # only process new messages
288
+ if self.filter_duplicates is True:
289
+ return None
290
+ self.lpacket_ids[self.bthome_mac] = self.packet_id
291
+ else:
292
+ self.packet_id = "no packet id"
293
+
294
+ # check for MAC presence in sensor whitelist, if needed
295
+ if self.discovery is False and self.bthome_mac not in self.sensor_whitelist:
296
+ _LOGGER.debug("Discovery is disabled. MAC: %s is not whitelisted!", to_mac(self.bthome_mac))
297
+ return None
298
+
299
+ result.update({
300
+ "rssi": self.rssi,
301
+ "mac": to_unformatted_mac(self.bthome_mac),
302
+ "packet": self.packet_id,
303
+ "type": self.device_type,
304
+ "firmware": self.firmware,
305
+ "data": True
306
+ })
307
+ return result
308
+
309
+
310
+ def decrypt_data(self, data: bytes, sw_version: int):
311
+ """Decrypt encrypted BTHome advertisements"""
312
+ # check for minimum length of encrypted advertisement
313
+ if len(data) < (15 if sw_version == 1 else 14):
314
+ _LOGGER.debug("Invalid data length (for decryption), adv: %s", data.hex())
315
+ # try to find encryption key for current device
316
+ try:
317
+ key = self.aeskeys[self.bthome_mac]
318
+ if len(key) != 16:
319
+ _LOGGER.error("Encryption key should be 16 bytes (32 characters) long")
320
+ return None, None
321
+ except KeyError:
322
+ # no encryption key found
323
+ _LOGGER.error("No encryption key found for device with MAC %s", to_mac(self.bthome_mac))
324
+ return None, None
325
+
326
+ # prepare the data for decryption
327
+ if sw_version == 1:
328
+ uuid = b"\x1e\x18"
329
+ else:
330
+ uuid = b"\xd2\xfc\x41"
331
+ encrypted_payload = data[:-8]
332
+ count_id = data[-8:-4]
333
+ mic = data[-4:]
334
+
335
+ # nonce: mac [6], uuid16 [2 (v1) or 3 (v2)], count_id [4]
336
+ nonce = b"".join([self.bthome_mac, uuid, count_id])
337
+ cipher = AES.new(key, AES.MODE_CCM, nonce=nonce, mac_len=4)
338
+ if sw_version == 1:
339
+ cipher.update(b"\x11")
340
+
341
+ try:
342
+ decrypted_payload = cipher.decrypt_and_verify(encrypted_payload, mic)
343
+ except ValueError as error:
344
+ _LOGGER.warning("Decryption failed: %s", error)
345
+ _LOGGER.debug("mic: %s", mic.hex())
346
+ _LOGGER.debug("nonce: %s", nonce.hex())
347
+ _LOGGER.debug("encrypted_payload: %s", encrypted_payload.hex())
348
+ return None
349
+ if decrypted_payload is None:
350
+ _LOGGER.error(
351
+ "Decryption failed for %s, decrypted payload is None",
352
+ to_mac(self.bthome_mac),
353
+ )
354
+ return None, None
355
+ return decrypted_payload, count_id