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.
- {bleparser-3.6.0 → bleparser-3.7.3}/PKG-INFO +20 -8
- {bleparser-3.6.0 → bleparser-3.7.3}/README.md +16 -6
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/__init__.py +20 -6
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/airmentor.py +3 -3
- bleparser-3.7.3/package/bleparser/amazfit.py +67 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/brifit.py +1 -1
- bleparser-3.7.3/package/bleparser/bthome.py +355 -0
- bleparser-3.7.3/package/bleparser/bthome_const.py +267 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/govee.py +12 -6
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/hhcc.py +1 -1
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/inkbird.py +21 -16
- bleparser-3.7.3/package/bleparser/jaalee.py +60 -0
- bleparser-3.7.3/package/bleparser/sensirion.py +231 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/thermoplus.py +1 -1
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/xiaomi.py +199 -10
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/PKG-INFO +20 -8
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/SOURCES.txt +4 -2
- {bleparser-3.6.0 → bleparser-3.7.3}/setup.cfg +1 -1
- bleparser-3.6.0/package/bleparser/ha_ble.py +0 -233
- bleparser-3.6.0/package/bleparser/ha_ble_encryption.py +0 -89
- bleparser-3.6.0/package/bleparser/sensirion.py +0 -120
- {bleparser-3.6.0 → bleparser-3.7.3}/LICENSE +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/acconeer.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/almendo.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/altbeacon.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/atc.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/bluemaestro.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/bparasite.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/const.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/helpers.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/ibeacon.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/inode.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/jinou.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/kegtron.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/kkm.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/laica.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/mikrotik.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/miscale.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/moat.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/oral_b.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/qingping.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/relsib.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/ruuvitag.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/sensorpush.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/smartdry.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/switchbot.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/teltonika.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/thermopro.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/tilt.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser/xiaogui.py +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/dependency_links.txt +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/requires.txt +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/package/bleparser.egg-info/top_level.txt +0 -0
- {bleparser-3.6.0 → bleparser-3.7.3}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: bleparser
|
|
3
|
-
Version: 3.
|
|
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
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
200
|
-
sensor_data =
|
|
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
|
|
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
|
|
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
|
|
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(">
|
|
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
|