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 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`.
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """BM2 Bluetooth battery monitor protocol."""
2
+
3
+ from .protocol import BM2Generation, BM2Protocol, BM2Reading
4
+
5
+ __all__ = ["BM2Generation", "BM2Protocol", "BM2Reading"]
@@ -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,7 @@
1
+ bleak>=0.22.0
2
+ bleak-retry-connector>=3.0.0
3
+ pycryptodome>=3.20.0
4
+
5
+ [test]
6
+ pytest>=8
7
+ pytest-asyncio>=0.24
@@ -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()