bmx-ble 0.1.0__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.
- bmx_ble-0.1.0/LICENSE +21 -0
- bmx_ble-0.1.0/PKG-INFO +77 -0
- bmx_ble-0.1.0/README.md +50 -0
- bmx_ble-0.1.0/pyproject.toml +41 -0
- bmx_ble-0.1.0/setup.cfg +4 -0
- bmx_ble-0.1.0/src/bmx_ble/__init__.py +5 -0
- bmx_ble-0.1.0/src/bmx_ble/protocol.py +408 -0
- bmx_ble-0.1.0/src/bmx_ble.egg-info/PKG-INFO +77 -0
- bmx_ble-0.1.0/src/bmx_ble.egg-info/SOURCES.txt +11 -0
- bmx_ble-0.1.0/src/bmx_ble.egg-info/dependency_links.txt +1 -0
- bmx_ble-0.1.0/src/bmx_ble.egg-info/requires.txt +7 -0
- bmx_ble-0.1.0/src/bmx_ble.egg-info/top_level.txt +1 -0
- bmx_ble-0.1.0/tests/test_protocol.py +116 -0
bmx_ble-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Stewart
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
bmx_ble-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bmx-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BLE protocol support for BM2 battery monitors
|
|
5
|
+
Author: Andrew Stewart
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/andystewart999/bmx-ble
|
|
8
|
+
Project-URL: Issues, https://github.com/andystewart999/bmx-ble/issues
|
|
9
|
+
Keywords: BM2,Bluetooth,BLE,battery-monitor
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: bleak>=0.22.0
|
|
21
|
+
Requires-Dist: bleak-retry-connector>=3.0.0
|
|
22
|
+
Requires-Dist: pycryptodome>=3.20.0
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# bmx-ble
|
|
29
|
+
|
|
30
|
+
Python library for BMx battery monitor BLE advertisements and GATT notifications.
|
|
31
|
+
The initial release supports BM2; the package name allows additional BM models
|
|
32
|
+
to be supported later.
|
|
33
|
+
|
|
34
|
+
It decodes legacy percentage advertisements and encrypted enhanced voltage and
|
|
35
|
+
percentage advertisements, validates the BM2 notification characteristic, and
|
|
36
|
+
tries an active GATT reading before falling back to cached advertisement data.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m pip install bmx-ble
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from bmx_ble import BM2Protocol
|
|
48
|
+
|
|
49
|
+
monitor = BM2Protocol()
|
|
50
|
+
monitor.process_advertisement(service_info.manufacturer_data)
|
|
51
|
+
reading = await monitor.async_poll(ble_device) # None allows passive fallback.
|
|
52
|
+
print(reading.voltage, reading.percentage, reading.generation)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Supply a `bleak.backends.device.BLEDevice` for active reading. Connection
|
|
56
|
+
failures propagate when no usable advertisement has been cached.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python -m pip install -e '.[test]'
|
|
62
|
+
python -m pytest
|
|
63
|
+
python -m build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The Home Assistant integration is kept separately from this library.
|
|
67
|
+
|
|
68
|
+
## Publishing
|
|
69
|
+
|
|
70
|
+
Create a public repository with this source and an enabled issue tracker.
|
|
71
|
+
Set up PyPI Trusted Publishing for the repository's release workflow, tag a
|
|
72
|
+
release matching the version in `pyproject.toml`, then run the publish workflow.
|
|
73
|
+
Confirm the project name is available on PyPI before the first release.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT; see `LICENSE`.
|
bmx_ble-0.1.0/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# bmx-ble
|
|
2
|
+
|
|
3
|
+
Python library for BMx battery monitor BLE advertisements and GATT notifications.
|
|
4
|
+
The initial release supports BM2; the package name allows additional BM models
|
|
5
|
+
to be supported later.
|
|
6
|
+
|
|
7
|
+
It decodes legacy percentage advertisements and encrypted enhanced voltage and
|
|
8
|
+
percentage advertisements, validates the BM2 notification characteristic, and
|
|
9
|
+
tries an active GATT reading before falling back to cached advertisement data.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python -m pip install bmx-ble
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from bmx_ble import BM2Protocol
|
|
21
|
+
|
|
22
|
+
monitor = BM2Protocol()
|
|
23
|
+
monitor.process_advertisement(service_info.manufacturer_data)
|
|
24
|
+
reading = await monitor.async_poll(ble_device) # None allows passive fallback.
|
|
25
|
+
print(reading.voltage, reading.percentage, reading.generation)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Supply a `bleak.backends.device.BLEDevice` for active reading. Connection
|
|
29
|
+
failures propagate when no usable advertisement has been cached.
|
|
30
|
+
|
|
31
|
+
## Development
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python -m pip install -e '.[test]'
|
|
35
|
+
python -m pytest
|
|
36
|
+
python -m build
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The Home Assistant integration is kept separately from this library.
|
|
40
|
+
|
|
41
|
+
## Publishing
|
|
42
|
+
|
|
43
|
+
Create a public repository with this source and an enabled issue tracker.
|
|
44
|
+
Set up PyPI Trusted Publishing for the repository's release workflow, tag a
|
|
45
|
+
release matching the version in `pyproject.toml`, then run the publish workflow.
|
|
46
|
+
Confirm the project name is available on PyPI before the first release.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT; see `LICENSE`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bmx-ble"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "BLE protocol support for BM2 battery monitors"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "Andrew Stewart"}]
|
|
13
|
+
keywords = ["BM2", "Bluetooth", "BLE", "battery-monitor"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Home Automation",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"bleak>=0.22.0",
|
|
25
|
+
"bleak-retry-connector>=3.0.0",
|
|
26
|
+
"pycryptodome>=3.20.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/andystewart999/bmx-ble"
|
|
34
|
+
Issues = "https://github.com/andystewart999/bmx-ble/issues"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
asyncio_mode = "auto"
|
bmx_ble-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""BM2 BLE advertisements and active GATT protocol (no Home Assistant dependency)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from bleak import BleakError, BLEDevice
|
|
11
|
+
from bleak_retry_connector import (
|
|
12
|
+
BleakClientWithServiceCache,
|
|
13
|
+
establish_connection,
|
|
14
|
+
retry_bluetooth_connection_error,
|
|
15
|
+
)
|
|
16
|
+
from Crypto.Cipher import AES
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
GATT_TIMEOUT = 20
|
|
20
|
+
BM2_CHARACTERISTIC = "{0000fff4-0000-1000-8000-00805f9b34fb}"
|
|
21
|
+
VALID_BATTERY_STATUSES = frozenset({0, 1, 2, 4, 8})
|
|
22
|
+
|
|
23
|
+
# The BM2 GATT notification and the newer encrypted manufacturer advertisement
|
|
24
|
+
# use the same AES-128-CBC key and a zero IV.
|
|
25
|
+
BM2_AES_KEY = bytes(
|
|
26
|
+
[108, 101, 97, 103, 101, 110, 100, 255, 254, 49, 56, 56, 50, 52, 54, 54]
|
|
27
|
+
)
|
|
28
|
+
BM2_AES_IV = bytes(16)
|
|
29
|
+
|
|
30
|
+
# The useful manufacturer-data BODY is 14 bytes in Home Assistant. HA keeps
|
|
31
|
+
# the two-byte manufacturer identifier separately as the dict key, so those
|
|
32
|
+
# two bytes are prepended before decrypting the resulting 16-byte block.
|
|
33
|
+
BM2_ENHANCED_ADVERTISEMENT_PAYLOAD_LENGTH = 14
|
|
34
|
+
|
|
35
|
+
# Legacy BM2s use an iBeacon-shaped Apple manufacturer record. Home Assistant
|
|
36
|
+
# removes the 0x004C manufacturer ID, leaving a 23-byte body:
|
|
37
|
+
# 02 15 + fixed 16-byte UUID + major(2) + minor(2) + percentage(1)
|
|
38
|
+
BM2_LEGACY_MANUFACTURER_ID = 0x004C
|
|
39
|
+
BM2_LEGACY_PAYLOAD_LENGTH = 23
|
|
40
|
+
BM2_LEGACY_PREFIX = bytes.fromhex("0215655f83caae16a10a702e31f30d58dd82")
|
|
41
|
+
|
|
42
|
+
# Sanity bounds are aligned to what the BM2 itself supports
|
|
43
|
+
# 10-15 V range so unusual 12 V battery chemistries/states are not rejected.
|
|
44
|
+
BM2_MIN_VALID_VOLTAGE = 6.0
|
|
45
|
+
BM2_MAX_VALID_VOLTAGE = 20.0
|
|
46
|
+
BM2_MIN_VALID_PERCENTAGE = 0
|
|
47
|
+
BM2_MAX_VALID_PERCENTAGE = 100
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BM2Generation(StrEnum):
|
|
51
|
+
"""General BM2 protocol generation inferred from advertisement format."""
|
|
52
|
+
|
|
53
|
+
UNKNOWN = "Unknown"
|
|
54
|
+
LEGACY = "Legacy (percentage advertisement)"
|
|
55
|
+
ENHANCED = "Enhanced (voltage + percentage advertisement)"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class BM2Reading:
|
|
60
|
+
"""A decoded BM2 reading from a notification or advertisement."""
|
|
61
|
+
|
|
62
|
+
voltage: float | None
|
|
63
|
+
percentage: int | None
|
|
64
|
+
status: int | None
|
|
65
|
+
source: str
|
|
66
|
+
generation: BM2Generation = BM2Generation.UNKNOWN
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BM2Protocol:
|
|
70
|
+
"""Decode BM2 packets and read FFF4 notifications via Bleak.
|
|
71
|
+
|
|
72
|
+
Home Assistant is responsible for scan discovery, poll scheduling and entities.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
"""Create an independent BM2 reader with no cached telemetry."""
|
|
77
|
+
self._gattdata: bytes | None = None
|
|
78
|
+
self._ignore_advertisement = False
|
|
79
|
+
self._advertisement_reading: BM2Reading | None = None
|
|
80
|
+
self._bm2_generation = BM2Generation.UNKNOWN
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def bm2_generation(self) -> BM2Generation:
|
|
84
|
+
"""Return the most capable advertisement format seen so far."""
|
|
85
|
+
return self._bm2_generation
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def ignore_advertisement(self) -> bool:
|
|
89
|
+
"""Return whether an active notification read is in progress."""
|
|
90
|
+
return self._ignore_advertisement
|
|
91
|
+
|
|
92
|
+
def process_advertisement(self, manufacturer_data: dict[int, bytes]) -> None:
|
|
93
|
+
"""Cache recognised advertisements, retaining enhanced voltage when possible."""
|
|
94
|
+
reading = self._decode_advertisement(manufacturer_data)
|
|
95
|
+
if reading is not None:
|
|
96
|
+
# CONFIG FLOW / GENERATION:
|
|
97
|
+
# Enhanced BM2s can emit BOTH the old iBeacon-style percentage
|
|
98
|
+
# packet and the newer encrypted voltage+percentage packet.
|
|
99
|
+
#
|
|
100
|
+
# Once Enhanced has been positively observed, never downgrade the
|
|
101
|
+
# device back to Legacy merely because the next advertisement was
|
|
102
|
+
# the old-format packet.
|
|
103
|
+
if reading.generation is BM2Generation.ENHANCED:
|
|
104
|
+
self._bm2_generation = BM2Generation.ENHANCED
|
|
105
|
+
self._advertisement_reading = reading
|
|
106
|
+
|
|
107
|
+
elif (
|
|
108
|
+
reading.generation is BM2Generation.LEGACY
|
|
109
|
+
and self._bm2_generation is BM2Generation.ENHANCED
|
|
110
|
+
and self._advertisement_reading is not None
|
|
111
|
+
):
|
|
112
|
+
# Preserve the most recently known enhanced voltage while
|
|
113
|
+
# accepting the fresher percentage from the legacy packet.
|
|
114
|
+
self._advertisement_reading = BM2Reading(
|
|
115
|
+
voltage=self._advertisement_reading.voltage,
|
|
116
|
+
percentage=reading.percentage,
|
|
117
|
+
status=None,
|
|
118
|
+
source="advertisement",
|
|
119
|
+
generation=BM2Generation.ENHANCED,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
self._bm2_generation = reading.generation
|
|
124
|
+
self._advertisement_reading = reading
|
|
125
|
+
|
|
126
|
+
_LOGGER.debug(
|
|
127
|
+
"Cached BM2 advertisement reading: generation=%s, "
|
|
128
|
+
"voltage=%s, percentage=%s",
|
|
129
|
+
self._bm2_generation,
|
|
130
|
+
self._advertisement_reading.voltage,
|
|
131
|
+
self._advertisement_reading.percentage,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _decrypt(data: bytes) -> bytes:
|
|
136
|
+
"""Decrypt one complete 16-byte BM2 AES block."""
|
|
137
|
+
if len(data) != AES.block_size:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"BM2 encrypted payload must be {AES.block_size} bytes; "
|
|
140
|
+
f"received {len(data)}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
cipher = AES.new(BM2_AES_KEY, AES.MODE_CBC, BM2_AES_IV)
|
|
144
|
+
return cipher.decrypt(data)
|
|
145
|
+
|
|
146
|
+
# ADVERTISEMENT FALLBACK:
|
|
147
|
+
def _decode_advertisement(
|
|
148
|
+
self,
|
|
149
|
+
manufacturer_data: dict[int, bytes],
|
|
150
|
+
) -> BM2Reading | None:
|
|
151
|
+
"""Decode either known BM2 advertisement generation.
|
|
152
|
+
|
|
153
|
+
Enhanced/newer format:
|
|
154
|
+
- any 14-byte manufacturer-data body
|
|
155
|
+
- prepend the two-byte manufacturer ID (little-endian)
|
|
156
|
+
- AES decrypt the resulting 16-byte block
|
|
157
|
+
- decrypted bytes 6-7 = voltage * 100, big-endian
|
|
158
|
+
- decrypted byte 8 = battery percentage
|
|
159
|
+
|
|
160
|
+
Legacy/older format:
|
|
161
|
+
- Apple manufacturer ID 0x004C
|
|
162
|
+
- 23-byte iBeacon-shaped body
|
|
163
|
+
- fixed BM2 UUID prefix
|
|
164
|
+
- final byte = battery percentage
|
|
165
|
+
- no voltage is present in the advertisement
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
# Prefer the enhanced packet when both formats are advertised.
|
|
169
|
+
for manufacturer_id, payload in manufacturer_data.items():
|
|
170
|
+
if len(payload) != BM2_ENHANCED_ADVERTISEMENT_PAYLOAD_LENGTH:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
encrypted = manufacturer_id.to_bytes(2, byteorder="little") + payload
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
decrypted = self._decrypt(encrypted)
|
|
177
|
+
except ValueError:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
voltage = int.from_bytes(decrypted[6:8], byteorder="big") / 100.0
|
|
181
|
+
percentage = decrypted[8]
|
|
182
|
+
|
|
183
|
+
# Packet length alone is not enough to identify BM2 telemetry.
|
|
184
|
+
if not BM2_MIN_VALID_VOLTAGE <= voltage <= BM2_MAX_VALID_VOLTAGE:
|
|
185
|
+
continue
|
|
186
|
+
if not (BM2_MIN_VALID_PERCENTAGE <= percentage <= BM2_MAX_VALID_PERCENTAGE):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
return BM2Reading(
|
|
190
|
+
voltage=voltage,
|
|
191
|
+
percentage=percentage,
|
|
192
|
+
status=None,
|
|
193
|
+
source="advertisement",
|
|
194
|
+
generation=BM2Generation.ENHANCED,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Legacy packet: Home Assistant exposes 0x004C as the dict key, so the
|
|
198
|
+
# payload itself begins at the iBeacon 0x02 0x15 marker.
|
|
199
|
+
legacy_payload = manufacturer_data.get(BM2_LEGACY_MANUFACTURER_ID)
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
legacy_payload is not None
|
|
203
|
+
and len(legacy_payload) == BM2_LEGACY_PAYLOAD_LENGTH
|
|
204
|
+
and legacy_payload.startswith(BM2_LEGACY_PREFIX)
|
|
205
|
+
):
|
|
206
|
+
percentage = legacy_payload[-1]
|
|
207
|
+
|
|
208
|
+
if BM2_MIN_VALID_PERCENTAGE <= percentage <= BM2_MAX_VALID_PERCENTAGE:
|
|
209
|
+
return BM2Reading(
|
|
210
|
+
voltage=None,
|
|
211
|
+
percentage=percentage,
|
|
212
|
+
status=None,
|
|
213
|
+
source="advertisement",
|
|
214
|
+
generation=BM2Generation.LEGACY,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def _decode_gatt(self, data: bytes) -> BM2Reading:
|
|
220
|
+
"""Decode the BM2 GATT notification payload."""
|
|
221
|
+
decrypted = self._decrypt(data)
|
|
222
|
+
|
|
223
|
+
# Preserve the currently proven GATT byte/nibble mapping from the
|
|
224
|
+
# existing integration:
|
|
225
|
+
# voltage = decrypted hex chars [2:5] / 100
|
|
226
|
+
# status = decrypted hex char [5:6]
|
|
227
|
+
# percentage = decrypted hex chars [6:8]
|
|
228
|
+
raw = decrypted.hex()
|
|
229
|
+
|
|
230
|
+
return BM2Reading(
|
|
231
|
+
voltage=int(raw[2:5], 16) / 100.0,
|
|
232
|
+
percentage=int(raw[6:8], 16),
|
|
233
|
+
status=int(raw[5:6], 16),
|
|
234
|
+
source="gatt",
|
|
235
|
+
generation=BM2Generation.UNKNOWN,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@retry_bluetooth_connection_error()
|
|
239
|
+
async def _get_payload(
|
|
240
|
+
self,
|
|
241
|
+
client: BleakClientWithServiceCache,
|
|
242
|
+
) -> BM2Reading:
|
|
243
|
+
"""Read and decode the active BM2 GATT notification."""
|
|
244
|
+
self._gattdata = None
|
|
245
|
+
self._ignore_advertisement = True
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
await client.start_notify(
|
|
249
|
+
BM2_CHARACTERISTIC,
|
|
250
|
+
self.notification_handler,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
ticks = 0
|
|
254
|
+
while self._gattdata is None and ticks < GATT_TIMEOUT * 4:
|
|
255
|
+
await asyncio.sleep(0.25)
|
|
256
|
+
ticks += 1
|
|
257
|
+
|
|
258
|
+
finally:
|
|
259
|
+
# Always attempt to stop notification handling and, importantly,
|
|
260
|
+
# always clear the ignore flag even if Bleak throws.
|
|
261
|
+
try:
|
|
262
|
+
await client.stop_notify(BM2_CHARACTERISTIC)
|
|
263
|
+
finally:
|
|
264
|
+
self._ignore_advertisement = False
|
|
265
|
+
|
|
266
|
+
if self._gattdata is None:
|
|
267
|
+
# CHANGED:
|
|
268
|
+
# The previous implementation silently returned here. Raising
|
|
269
|
+
# makes a no-notification timeout a genuine failed active read,
|
|
270
|
+
# allowing async_poll() to use the cached advertisement.
|
|
271
|
+
raise BleakError(
|
|
272
|
+
f"Timed out waiting for BM2 GATT notification from {client.address}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
_LOGGER.debug(
|
|
276
|
+
"Successfully read characteristic %s",
|
|
277
|
+
BM2_CHARACTERISTIC,
|
|
278
|
+
)
|
|
279
|
+
return self._decode_gatt(self._gattdata)
|
|
280
|
+
|
|
281
|
+
def notification_handler(self, sender, data: bytearray) -> None:
|
|
282
|
+
"""Bluetooth notification handler."""
|
|
283
|
+
self._gattdata = bytes(data)
|
|
284
|
+
|
|
285
|
+
async def async_validate_active(self, ble_device: BLEDevice) -> bool:
|
|
286
|
+
"""Positively validate a BM2 using its active GATT protocol.
|
|
287
|
+
|
|
288
|
+
This is intended for config-flow validation only.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
True:
|
|
292
|
+
FFF4 exists, a notification was received, it decrypted with
|
|
293
|
+
the BM2 key, and the decoded values are plausible.
|
|
294
|
+
|
|
295
|
+
False:
|
|
296
|
+
The device is positively incompatible (for example FFF4 is
|
|
297
|
+
absent, or a notification decrypts to implausible BM2 data).
|
|
298
|
+
|
|
299
|
+
Connection errors and notification timeouts deliberately propagate.
|
|
300
|
+
The config flow treats those as "could not validate" rather than
|
|
301
|
+
incorrectly declaring that the device is not a BM2.
|
|
302
|
+
"""
|
|
303
|
+
client: BleakClientWithServiceCache | None = None
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
client = await establish_connection(
|
|
307
|
+
BleakClientWithServiceCache,
|
|
308
|
+
ble_device,
|
|
309
|
+
ble_device.address,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
target_uuid = BM2_CHARACTERISTIC.lower().strip("{}")
|
|
313
|
+
|
|
314
|
+
characteristic_found = any(
|
|
315
|
+
characteristic.uuid.lower().strip("{}") == target_uuid
|
|
316
|
+
for service in client.services
|
|
317
|
+
for characteristic in service.characteristics
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if not characteristic_found:
|
|
321
|
+
_LOGGER.debug(
|
|
322
|
+
"BM2 validation failed for %s: characteristic %s not found",
|
|
323
|
+
ble_device.address,
|
|
324
|
+
BM2_CHARACTERISTIC,
|
|
325
|
+
)
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
reading = await self._get_payload(client)
|
|
329
|
+
|
|
330
|
+
if reading.voltage is None or reading.percentage is None:
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
if not (BM2_MIN_VALID_VOLTAGE <= reading.voltage <= BM2_MAX_VALID_VOLTAGE):
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
if not (
|
|
337
|
+
BM2_MIN_VALID_PERCENTAGE
|
|
338
|
+
<= reading.percentage
|
|
339
|
+
<= BM2_MAX_VALID_PERCENTAGE
|
|
340
|
+
):
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
return not (
|
|
344
|
+
reading.status is not None
|
|
345
|
+
and reading.status not in VALID_BATTERY_STATUSES
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
finally:
|
|
349
|
+
if client is not None:
|
|
350
|
+
await client.disconnect()
|
|
351
|
+
|
|
352
|
+
async def async_poll(
|
|
353
|
+
self,
|
|
354
|
+
ble_device: BLEDevice | None,
|
|
355
|
+
) -> BM2Reading:
|
|
356
|
+
"""Prefer an active GATT read and fall back to advertisement data.
|
|
357
|
+
|
|
358
|
+
Passing ble_device=None means Home Assistant heard the device through a
|
|
359
|
+
passive scanner/proxy but currently has no connectable Bluetooth path.
|
|
360
|
+
"""
|
|
361
|
+
client: BleakClientWithServiceCache | None = None
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
if ble_device is None:
|
|
365
|
+
raise BleakError("No connectable Bluetooth path is currently available")
|
|
366
|
+
|
|
367
|
+
_LOGGER.debug(
|
|
368
|
+
"Connecting to Bluetooth device %s",
|
|
369
|
+
ble_device.address,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
client = await establish_connection(
|
|
373
|
+
BleakClientWithServiceCache,
|
|
374
|
+
ble_device,
|
|
375
|
+
ble_device.address,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
_LOGGER.debug(
|
|
379
|
+
"Connected to BM2 device %s",
|
|
380
|
+
ble_device.address,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
reading = await self._get_payload(client)
|
|
384
|
+
return reading
|
|
385
|
+
|
|
386
|
+
except Exception as ex:
|
|
387
|
+
# ADVERTISEMENT FALLBACK:
|
|
388
|
+
# Active connection/read failed. If the immediately preceding
|
|
389
|
+
# advertisement contained usable telemetry, publish it instead.
|
|
390
|
+
if self._advertisement_reading is not None:
|
|
391
|
+
address = ble_device.address if ble_device is not None else "unknown"
|
|
392
|
+
_LOGGER.debug(
|
|
393
|
+
"Active BM2 read failed for %s (%s); using cached "
|
|
394
|
+
"advertisement data",
|
|
395
|
+
address,
|
|
396
|
+
ex,
|
|
397
|
+
)
|
|
398
|
+
return self._advertisement_reading
|
|
399
|
+
|
|
400
|
+
# No usable passive fallback exists, so preserve the failure.
|
|
401
|
+
raise
|
|
402
|
+
|
|
403
|
+
finally:
|
|
404
|
+
if client is not None:
|
|
405
|
+
try:
|
|
406
|
+
await client.disconnect()
|
|
407
|
+
finally:
|
|
408
|
+
_LOGGER.debug("Disconnected from active Bluetooth client")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bmx-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BLE protocol support for BM2 battery monitors
|
|
5
|
+
Author: Andrew Stewart
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/andystewart999/bmx-ble
|
|
8
|
+
Project-URL: Issues, https://github.com/andystewart999/bmx-ble/issues
|
|
9
|
+
Keywords: BM2,Bluetooth,BLE,battery-monitor
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: bleak>=0.22.0
|
|
21
|
+
Requires-Dist: bleak-retry-connector>=3.0.0
|
|
22
|
+
Requires-Dist: pycryptodome>=3.20.0
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# bmx-ble
|
|
29
|
+
|
|
30
|
+
Python library for BMx battery monitor BLE advertisements and GATT notifications.
|
|
31
|
+
The initial release supports BM2; the package name allows additional BM models
|
|
32
|
+
to be supported later.
|
|
33
|
+
|
|
34
|
+
It decodes legacy percentage advertisements and encrypted enhanced voltage and
|
|
35
|
+
percentage advertisements, validates the BM2 notification characteristic, and
|
|
36
|
+
tries an active GATT reading before falling back to cached advertisement data.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m pip install bmx-ble
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from bmx_ble import BM2Protocol
|
|
48
|
+
|
|
49
|
+
monitor = BM2Protocol()
|
|
50
|
+
monitor.process_advertisement(service_info.manufacturer_data)
|
|
51
|
+
reading = await monitor.async_poll(ble_device) # None allows passive fallback.
|
|
52
|
+
print(reading.voltage, reading.percentage, reading.generation)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Supply a `bleak.backends.device.BLEDevice` for active reading. Connection
|
|
56
|
+
failures propagate when no usable advertisement has been cached.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python -m pip install -e '.[test]'
|
|
62
|
+
python -m pytest
|
|
63
|
+
python -m build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The Home Assistant integration is kept separately from this library.
|
|
67
|
+
|
|
68
|
+
## Publishing
|
|
69
|
+
|
|
70
|
+
Create a public repository with this source and an enabled issue tracker.
|
|
71
|
+
Set up PyPI Trusted Publishing for the repository's release workflow, tag a
|
|
72
|
+
release matching the version in `pyproject.toml`, then run the publish workflow.
|
|
73
|
+
Confirm the project name is available on PyPI before the first release.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT; see `LICENSE`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bmx_ble/__init__.py
|
|
5
|
+
src/bmx_ble/protocol.py
|
|
6
|
+
src/bmx_ble.egg-info/PKG-INFO
|
|
7
|
+
src/bmx_ble.egg-info/SOURCES.txt
|
|
8
|
+
src/bmx_ble.egg-info/dependency_links.txt
|
|
9
|
+
src/bmx_ble.egg-info/requires.txt
|
|
10
|
+
src/bmx_ble.egg-info/top_level.txt
|
|
11
|
+
tests/test_protocol.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bmx_ble
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""BM2 packet and connection behaviour without Home Assistant."""
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import AsyncMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from bmx_ble import BM2Generation, BM2Protocol
|
|
8
|
+
from bmx_ble.protocol import BM2_AES_IV, BM2_AES_KEY, BM2_CHARACTERISTIC
|
|
9
|
+
from Crypto.Cipher import AES
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _encrypt(plain: bytes) -> bytes:
|
|
13
|
+
return AES.new(BM2_AES_KEY, AES.MODE_CBC, BM2_AES_IV).encrypt(plain)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _enhanced_packet(
|
|
17
|
+
voltage_cents: int = 1250, percentage: int = 75
|
|
18
|
+
) -> dict[int, bytes]:
|
|
19
|
+
plain = bytearray(16)
|
|
20
|
+
plain[6:8] = voltage_cents.to_bytes(2, "big")
|
|
21
|
+
plain[8] = percentage
|
|
22
|
+
encrypted = _encrypt(bytes(plain))
|
|
23
|
+
return {int.from_bytes(encrypted[:2], "little"): encrypted[2:]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
LEGACY_PACKET = {
|
|
27
|
+
0x004C: bytes.fromhex("0215655f83caae16a10a702e31f30d58dd82")
|
|
28
|
+
+ bytes.fromhex("00010002")
|
|
29
|
+
+ bytes([48])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_legacy_packet() -> None:
|
|
34
|
+
"""A recognised legacy frame provides only percentage."""
|
|
35
|
+
monitor = BM2Protocol()
|
|
36
|
+
monitor.process_advertisement(LEGACY_PACKET)
|
|
37
|
+
assert monitor.bm2_generation is BM2Generation.LEGACY
|
|
38
|
+
reading = monitor._advertisement_reading
|
|
39
|
+
assert reading is not None
|
|
40
|
+
assert reading.voltage is None
|
|
41
|
+
assert reading.percentage == 48
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_enhanced_then_legacy_preserves_voltage() -> None:
|
|
45
|
+
"""Alternating packets must keep known enhanced voltage and fresh percentage."""
|
|
46
|
+
monitor = BM2Protocol()
|
|
47
|
+
monitor.process_advertisement(_enhanced_packet())
|
|
48
|
+
monitor.process_advertisement(LEGACY_PACKET)
|
|
49
|
+
assert monitor.bm2_generation is BM2Generation.ENHANCED
|
|
50
|
+
assert monitor._advertisement_reading is not None
|
|
51
|
+
assert monitor._advertisement_reading.voltage == 12.5
|
|
52
|
+
assert monitor._advertisement_reading.percentage == 48
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_invalid_packet_is_ignored() -> None:
|
|
56
|
+
"""Plausible frame shape alone must not identify a BM2."""
|
|
57
|
+
monitor = BM2Protocol()
|
|
58
|
+
monitor.process_advertisement(_enhanced_packet(voltage_cents=500))
|
|
59
|
+
monitor.process_advertisement({0x004C: b"\x02\x15" + bytes(21)})
|
|
60
|
+
assert monitor.bm2_generation is BM2Generation.UNKNOWN
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_gatt_payload() -> None:
|
|
64
|
+
"""GATT decryption preserves the established voltage/status mapping."""
|
|
65
|
+
plain = bytes.fromhex("004e2464" + "00" * 12)
|
|
66
|
+
reading = BM2Protocol()._decode_gatt(_encrypt(plain))
|
|
67
|
+
assert (reading.voltage, reading.percentage, reading.status) == (12.5, 100, 4)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_passive_fallback_and_missing_data() -> None:
|
|
72
|
+
"""A cached packet works without a connectable path; no packet fails."""
|
|
73
|
+
monitor = BM2Protocol()
|
|
74
|
+
with pytest.raises(Exception, match="No connectable Bluetooth path"):
|
|
75
|
+
await monitor.async_poll(None)
|
|
76
|
+
monitor.process_advertisement(LEGACY_PACKET)
|
|
77
|
+
reading = await monitor.async_poll(None)
|
|
78
|
+
assert reading.percentage == 48
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
async def test_active_validation_and_poll() -> None:
|
|
83
|
+
"""Validate FFF4 with a decryptable notification and return its data."""
|
|
84
|
+
notification = _encrypt(bytes.fromhex("004e2464" + "00" * 12))
|
|
85
|
+
client = SimpleNamespace(
|
|
86
|
+
address="AA:BB:CC:DD:EE:FF",
|
|
87
|
+
services=[
|
|
88
|
+
SimpleNamespace(characteristics=[SimpleNamespace(uuid=BM2_CHARACTERISTIC)])
|
|
89
|
+
],
|
|
90
|
+
start_notify=AsyncMock(
|
|
91
|
+
side_effect=lambda _uuid, callback: callback(0, bytearray(notification))
|
|
92
|
+
),
|
|
93
|
+
stop_notify=AsyncMock(),
|
|
94
|
+
disconnect=AsyncMock(),
|
|
95
|
+
)
|
|
96
|
+
device = SimpleNamespace(address=client.address)
|
|
97
|
+
with patch(
|
|
98
|
+
"bmx_ble.protocol.establish_connection", new=AsyncMock(return_value=client)
|
|
99
|
+
):
|
|
100
|
+
assert await BM2Protocol().async_validate_active(device)
|
|
101
|
+
reading = await BM2Protocol().async_poll(device)
|
|
102
|
+
assert reading.voltage == 12.5
|
|
103
|
+
assert reading.status == 4
|
|
104
|
+
assert client.disconnect.await_count == 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def test_active_validation_rejects_missing_characteristic() -> None:
|
|
109
|
+
"""Successful connection without FFF4 is a positive rejection."""
|
|
110
|
+
client = SimpleNamespace(services=[], disconnect=AsyncMock())
|
|
111
|
+
device = SimpleNamespace(address="AA:BB:CC:DD:EE:FF")
|
|
112
|
+
with patch(
|
|
113
|
+
"bmx_ble.protocol.establish_connection", new=AsyncMock(return_value=client)
|
|
114
|
+
):
|
|
115
|
+
assert not await BM2Protocol().async_validate_active(device)
|
|
116
|
+
client.disconnect.assert_awaited_once()
|