brymenble 0.5.2__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.
- brymenble-0.5.2/LICENSE +21 -0
- brymenble-0.5.2/PKG-INFO +172 -0
- brymenble-0.5.2/README.md +151 -0
- brymenble-0.5.2/pyproject.toml +38 -0
- brymenble-0.5.2/setup.cfg +4 -0
- brymenble-0.5.2/src/brymenble/__init__.py +81 -0
- brymenble-0.5.2/src/brymenble/commands.py +83 -0
- brymenble-0.5.2/src/brymenble/console.py +115 -0
- brymenble-0.5.2/src/brymenble/constants.py +185 -0
- brymenble-0.5.2/src/brymenble/crc.py +17 -0
- brymenble-0.5.2/src/brymenble/formatter.py +31 -0
- brymenble-0.5.2/src/brymenble/parsers.py +567 -0
- brymenble-0.5.2/src/brymenble/py.typed +0 -0
- brymenble-0.5.2/src/brymenble/scanner.py +101 -0
- brymenble-0.5.2/src/brymenble/transport.py +613 -0
- brymenble-0.5.2/src/brymenble.egg-info/PKG-INFO +172 -0
- brymenble-0.5.2/src/brymenble.egg-info/SOURCES.txt +23 -0
- brymenble-0.5.2/src/brymenble.egg-info/dependency_links.txt +1 -0
- brymenble-0.5.2/src/brymenble.egg-info/requires.txt +9 -0
- brymenble-0.5.2/src/brymenble.egg-info/top_level.txt +1 -0
- brymenble-0.5.2/tests/test_commands.py +120 -0
- brymenble-0.5.2/tests/test_console.py +82 -0
- brymenble-0.5.2/tests/test_pipeline.py +390 -0
- brymenble-0.5.2/tests/test_scanner.py +150 -0
- brymenble-0.5.2/tests/test_transport.py +734 -0
brymenble-0.5.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Chan
|
|
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.
|
brymenble-0.5.2/PKG-INFO
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brymenble
|
|
3
|
+
Version: 0.5.2
|
|
4
|
+
Summary: Unofficial Python SDK for the Brymen BM78xBT Bluetooth Low Energy multimeter
|
|
5
|
+
Author: Martin Chan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/milksplash/brymenble
|
|
8
|
+
Project-URL: Repository, https://github.com/milksplash/brymenble
|
|
9
|
+
Project-URL: Issues, https://github.com/milksplash/brymenble/issues
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: bleak>=0.21
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest; extra == "test"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: build; extra == "dev"
|
|
19
|
+
Requires-Dist: twine; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# brymenble SDK
|
|
23
|
+
|
|
24
|
+
> **⚠️ Unofficial.** This is an independent, community-developed project. It is
|
|
25
|
+
> **not affiliated with, endorsed by, or sponsored by** Brymen Technology Corporation. "Brymen" and the device model names are trademarks of their
|
|
26
|
+
> respective owners.
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
Open-source Python SDK for **Brymen BM78xBT** Bluetooth Low Energy wireless multimeters. This is the monorepo for both the SDK itself (`src/brymenble/`) and
|
|
31
|
+
its example apps (`examples/`): a live-readings console and a raw-protocol
|
|
32
|
+
debug tool.
|
|
33
|
+
|
|
34
|
+
## Related projects
|
|
35
|
+
|
|
36
|
+
The SDK is used by two companion projects in the same family:
|
|
37
|
+
|
|
38
|
+
- [**brymenble-overlay**](https://github.com/milksplash/brymenble-overlay) — emulates readings from the multimeter as an overlay
|
|
39
|
+
for OBS (or any browser), driven live by this SDK over BLE.
|
|
40
|
+
- [**brymenble-tc-bridge**](https://github.com/milksplash/brymenble-tc-bridge) — re-emits the SDK's parsed readings over a TCP
|
|
41
|
+
socket so [**TestController**](https://lygte-info.dk/project/TestControllerIntro%20UK.html)
|
|
42
|
+
(lygte-info.dk's freeware multi-device test & logging tool). can be used to log the meter.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## SDK
|
|
46
|
+
|
|
47
|
+
The SDK is a pip-installable package that handles the whole protocol:
|
|
48
|
+
|
|
49
|
+
- `brymenble.constants` — protocol constants and lookup tables
|
|
50
|
+
- `brymenble.crc` — CRC-16 (poly 0xA001) used by the protocol
|
|
51
|
+
- `brymenble.commands` — building command packets (auth, etc.)
|
|
52
|
+
- `brymenble.parsers` — turning raw packets/frames into `InfoPacket`, `ReadingPacket`, `RtcTime`
|
|
53
|
+
- `brymenble.formatter` — converting a parsed reading into a display string (`"123.45 V"`)
|
|
54
|
+
- `brymenble.console` — shared console output helpers (`ts()`, `status()`, `reading_line()`, `state()`, and lifecycle/status callbacks `retry` / `paused` / `lost` / `reconnected` / `scanning` / `using` / `found` / `connecting` / `connected` / `disconnected`) so every consumer prints identically
|
|
55
|
+
- `brymenble.transport` — `BrymenbleClient`: connect, authenticate, subscribe, stream parsed frames, plus a retry/reconnect policy (`ensure_connected`), a high-level streaming loop (`read_stream`) that waits out function-switch pauses and reconnects on a real link drop, and idempotent `close()`
|
|
56
|
+
|
|
57
|
+
### Documentation
|
|
58
|
+
|
|
59
|
+
- `docs/SDK_DATA_REFERENCE.md` — reference of every parsed field the SDK exposes (`ReadingPacket`, `InfoPacket`, `RtcTime`, `CommandResponse`)
|
|
60
|
+
|
|
61
|
+
### Install
|
|
62
|
+
|
|
63
|
+
From PyPI (recommended):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install brymenble
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
From a source checkout (for development or unreleased changes):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e . # from the repo root (installs `brymenble` + `bleak`)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Directly from GitHub (no PyPI needed):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install git+https://github.com/milksplash/brymenble.git
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Minimal use
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import asyncio
|
|
85
|
+
from brymenble import BrymenbleClient, DEFAULT_PASSWORD, find_meters, format_reading
|
|
86
|
+
|
|
87
|
+
async def main():
|
|
88
|
+
meters = await find_meters()
|
|
89
|
+
if not meters:
|
|
90
|
+
print("No BM78xBT meters found.")
|
|
91
|
+
return
|
|
92
|
+
mac = meters[0].address
|
|
93
|
+
|
|
94
|
+
async with BrymenbleClient(mac, DEFAULT_PASSWORD, sync_rtc_on_connect=True) as client:
|
|
95
|
+
async for frame in client:
|
|
96
|
+
print(frame.info.mac_str, format_reading(frame.readings[0]))
|
|
97
|
+
|
|
98
|
+
asyncio.run(main())
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`find_first_meter()` is the same discovery wrapped in a retry loop for
|
|
102
|
+
long-running apps — it scans until a meter is found (retrying every
|
|
103
|
+
`retry_interval` seconds, calling `on_retry(attempt)` before each re-scan).
|
|
104
|
+
Pass `retry_interval=0` for a single-shot scan that returns `None` when
|
|
105
|
+
nothing is found.
|
|
106
|
+
|
|
107
|
+
### Long-running consumers
|
|
108
|
+
|
|
109
|
+
For apps that must survive the meter powering off (overlays, loggers),
|
|
110
|
+
`BrymenbleClient.read_stream()` is a self-healing loop: a data gap while the
|
|
111
|
+
BLE link is up is treated as a function-switch pause and waited out, while a
|
|
112
|
+
real link drop is confirmed and transparently reconnected. Optional
|
|
113
|
+
`on_pause` / `on_lost` / `on_reconnected` callbacks report lifecycle
|
|
114
|
+
changes; `retries=None` reconnects forever.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
async for frame in client.read_stream(retries=None):
|
|
118
|
+
print(frame.info.mac_str, format_reading(frame.readings[0]))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
> **One connection per meter.** BLE is point-to-point — a BM78xBT accepts a
|
|
122
|
+
> single connection, so a second instance (or any other app) cannot connect
|
|
123
|
+
> while another holds it; it just looks like "out of range". If a connect
|
|
124
|
+
> fails/times out, the error and a one-time warning include a hint to check
|
|
125
|
+
> the meter isn't connected elsewhere. Stop the other app before retrying.
|
|
126
|
+
|
|
127
|
+
## Example apps
|
|
128
|
+
|
|
129
|
+
- **`examples/live.py`** — a thin program built on the SDK: it connects to a
|
|
130
|
+
meter and prints its readings as they arrive, using the shared
|
|
131
|
+
`brymenble.console` helpers so the output matches the overlay and the TC
|
|
132
|
+
bridge. With no MAC given it scans for the first BM78xBT meter it finds.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python examples/live.py [MAC] [PASSWORD]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **`examples/debug_stream.py`** — a debug/test script that dumps the raw
|
|
139
|
+
protocol stream via `examples/display.py`: the full raw frame hex, the
|
|
140
|
+
device-info packet, each reading packet, and packet-timing statistics. Use
|
|
141
|
+
it to inspect exactly what the meter sends (the clean console view above is
|
|
142
|
+
`examples/live.py`).
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
python examples/debug_stream.py [MAC] [PASSWORD]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
For on-demand reads and hardware probing, see `tools/probe.py` (exercises the
|
|
149
|
+
command/response layer against a real meter) and `tools/capture.py` (records
|
|
150
|
+
real frames for the test fixtures).
|
|
151
|
+
|
|
152
|
+
## Platform support
|
|
153
|
+
|
|
154
|
+
Linux and Windows are supported. macOS randomizes BLE device MAC addresses and behavior is not tested.
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
Offline tests:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
.venv\Scripts\python.exe -m pytest
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`tools/capture.py` captures real frames from a meter into
|
|
165
|
+
`tests/fixtures/captures.json`.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT — see [LICENSE](LICENSE).
|
|
170
|
+
|
|
171
|
+
"Brymen" and the device model names are trademarks of their respective owners;
|
|
172
|
+
this project is not affiliated with or endorsed by Brymen Technology Corporation.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# brymenble SDK
|
|
2
|
+
|
|
3
|
+
> **⚠️ Unofficial.** This is an independent, community-developed project. It is
|
|
4
|
+
> **not affiliated with, endorsed by, or sponsored by** Brymen Technology Corporation. "Brymen" and the device model names are trademarks of their
|
|
5
|
+
> respective owners.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
Open-source Python SDK for **Brymen BM78xBT** Bluetooth Low Energy wireless multimeters. This is the monorepo for both the SDK itself (`src/brymenble/`) and
|
|
10
|
+
its example apps (`examples/`): a live-readings console and a raw-protocol
|
|
11
|
+
debug tool.
|
|
12
|
+
|
|
13
|
+
## Related projects
|
|
14
|
+
|
|
15
|
+
The SDK is used by two companion projects in the same family:
|
|
16
|
+
|
|
17
|
+
- [**brymenble-overlay**](https://github.com/milksplash/brymenble-overlay) — emulates readings from the multimeter as an overlay
|
|
18
|
+
for OBS (or any browser), driven live by this SDK over BLE.
|
|
19
|
+
- [**brymenble-tc-bridge**](https://github.com/milksplash/brymenble-tc-bridge) — re-emits the SDK's parsed readings over a TCP
|
|
20
|
+
socket so [**TestController**](https://lygte-info.dk/project/TestControllerIntro%20UK.html)
|
|
21
|
+
(lygte-info.dk's freeware multi-device test & logging tool). can be used to log the meter.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## SDK
|
|
25
|
+
|
|
26
|
+
The SDK is a pip-installable package that handles the whole protocol:
|
|
27
|
+
|
|
28
|
+
- `brymenble.constants` — protocol constants and lookup tables
|
|
29
|
+
- `brymenble.crc` — CRC-16 (poly 0xA001) used by the protocol
|
|
30
|
+
- `brymenble.commands` — building command packets (auth, etc.)
|
|
31
|
+
- `brymenble.parsers` — turning raw packets/frames into `InfoPacket`, `ReadingPacket`, `RtcTime`
|
|
32
|
+
- `brymenble.formatter` — converting a parsed reading into a display string (`"123.45 V"`)
|
|
33
|
+
- `brymenble.console` — shared console output helpers (`ts()`, `status()`, `reading_line()`, `state()`, and lifecycle/status callbacks `retry` / `paused` / `lost` / `reconnected` / `scanning` / `using` / `found` / `connecting` / `connected` / `disconnected`) so every consumer prints identically
|
|
34
|
+
- `brymenble.transport` — `BrymenbleClient`: connect, authenticate, subscribe, stream parsed frames, plus a retry/reconnect policy (`ensure_connected`), a high-level streaming loop (`read_stream`) that waits out function-switch pauses and reconnects on a real link drop, and idempotent `close()`
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
- `docs/SDK_DATA_REFERENCE.md` — reference of every parsed field the SDK exposes (`ReadingPacket`, `InfoPacket`, `RtcTime`, `CommandResponse`)
|
|
39
|
+
|
|
40
|
+
### Install
|
|
41
|
+
|
|
42
|
+
From PyPI (recommended):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install brymenble
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
From a source checkout (for development or unreleased changes):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e . # from the repo root (installs `brymenble` + `bleak`)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Directly from GitHub (no PyPI needed):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install git+https://github.com/milksplash/brymenble.git
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Minimal use
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from brymenble import BrymenbleClient, DEFAULT_PASSWORD, find_meters, format_reading
|
|
65
|
+
|
|
66
|
+
async def main():
|
|
67
|
+
meters = await find_meters()
|
|
68
|
+
if not meters:
|
|
69
|
+
print("No BM78xBT meters found.")
|
|
70
|
+
return
|
|
71
|
+
mac = meters[0].address
|
|
72
|
+
|
|
73
|
+
async with BrymenbleClient(mac, DEFAULT_PASSWORD, sync_rtc_on_connect=True) as client:
|
|
74
|
+
async for frame in client:
|
|
75
|
+
print(frame.info.mac_str, format_reading(frame.readings[0]))
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`find_first_meter()` is the same discovery wrapped in a retry loop for
|
|
81
|
+
long-running apps — it scans until a meter is found (retrying every
|
|
82
|
+
`retry_interval` seconds, calling `on_retry(attempt)` before each re-scan).
|
|
83
|
+
Pass `retry_interval=0` for a single-shot scan that returns `None` when
|
|
84
|
+
nothing is found.
|
|
85
|
+
|
|
86
|
+
### Long-running consumers
|
|
87
|
+
|
|
88
|
+
For apps that must survive the meter powering off (overlays, loggers),
|
|
89
|
+
`BrymenbleClient.read_stream()` is a self-healing loop: a data gap while the
|
|
90
|
+
BLE link is up is treated as a function-switch pause and waited out, while a
|
|
91
|
+
real link drop is confirmed and transparently reconnected. Optional
|
|
92
|
+
`on_pause` / `on_lost` / `on_reconnected` callbacks report lifecycle
|
|
93
|
+
changes; `retries=None` reconnects forever.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
async for frame in client.read_stream(retries=None):
|
|
97
|
+
print(frame.info.mac_str, format_reading(frame.readings[0]))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> **One connection per meter.** BLE is point-to-point — a BM78xBT accepts a
|
|
101
|
+
> single connection, so a second instance (or any other app) cannot connect
|
|
102
|
+
> while another holds it; it just looks like "out of range". If a connect
|
|
103
|
+
> fails/times out, the error and a one-time warning include a hint to check
|
|
104
|
+
> the meter isn't connected elsewhere. Stop the other app before retrying.
|
|
105
|
+
|
|
106
|
+
## Example apps
|
|
107
|
+
|
|
108
|
+
- **`examples/live.py`** — a thin program built on the SDK: it connects to a
|
|
109
|
+
meter and prints its readings as they arrive, using the shared
|
|
110
|
+
`brymenble.console` helpers so the output matches the overlay and the TC
|
|
111
|
+
bridge. With no MAC given it scans for the first BM78xBT meter it finds.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python examples/live.py [MAC] [PASSWORD]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **`examples/debug_stream.py`** — a debug/test script that dumps the raw
|
|
118
|
+
protocol stream via `examples/display.py`: the full raw frame hex, the
|
|
119
|
+
device-info packet, each reading packet, and packet-timing statistics. Use
|
|
120
|
+
it to inspect exactly what the meter sends (the clean console view above is
|
|
121
|
+
`examples/live.py`).
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python examples/debug_stream.py [MAC] [PASSWORD]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For on-demand reads and hardware probing, see `tools/probe.py` (exercises the
|
|
128
|
+
command/response layer against a real meter) and `tools/capture.py` (records
|
|
129
|
+
real frames for the test fixtures).
|
|
130
|
+
|
|
131
|
+
## Platform support
|
|
132
|
+
|
|
133
|
+
Linux and Windows are supported. macOS randomizes BLE device MAC addresses and behavior is not tested.
|
|
134
|
+
|
|
135
|
+
## Tests
|
|
136
|
+
|
|
137
|
+
Offline tests:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
.venv\Scripts\python.exe -m pytest
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`tools/capture.py` captures real frames from a meter into
|
|
144
|
+
`tests/fixtures/captures.json`.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT — see [LICENSE](LICENSE).
|
|
149
|
+
|
|
150
|
+
"Brymen" and the device model names are trademarks of their respective owners;
|
|
151
|
+
this project is not affiliated with or endorsed by Brymen Technology Corporation.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "brymenble"
|
|
7
|
+
version = "0.5.2"
|
|
8
|
+
description = "Unofficial Python SDK for the Brymen BM78xBT Bluetooth Low Energy multimeter"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Martin Chan" },
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"bleak>=0.21",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/milksplash/brymenble"
|
|
21
|
+
Repository = "https://github.com/milksplash/brymenble"
|
|
22
|
+
Issues = "https://github.com/milksplash/brymenble/issues"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
test = [
|
|
26
|
+
"pytest",
|
|
27
|
+
]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest",
|
|
30
|
+
"build",
|
|
31
|
+
"twine",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
brymenble = ["py.typed"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""brymenble — unofficial, open-source SDK for the Brymen BM78xBT BLE multimeter.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
- ``BrymenbleClient``: connect/authenticate/subscribe and stream parsed frames
|
|
6
|
+
- ``parsers``: ``InfoPacket`` / ``ReadingPacket`` / ``RtcTime`` and parse helpers
|
|
7
|
+
- ``commands``: command-packet builders
|
|
8
|
+
- ``formatter``: turn a parsed reading into a display string
|
|
9
|
+
- ``console``: shared console output (status lines) for every consumer
|
|
10
|
+
- ``scanner``: find BM78xBT meters from their BLE advertising packets
|
|
11
|
+
- ``crc`` / ``constants``: protocol primitives
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from . import commands, console, constants, crc, formatter, parsers, scanner
|
|
15
|
+
from .commands import (
|
|
16
|
+
build_command_packet,
|
|
17
|
+
build_rtc_time_packet,
|
|
18
|
+
build_verify_password_packet,
|
|
19
|
+
)
|
|
20
|
+
from .crc import calculate_crc, verify_crc
|
|
21
|
+
from .formatter import format_reading
|
|
22
|
+
from .parsers import (
|
|
23
|
+
CommandResponse,
|
|
24
|
+
InfoPacket,
|
|
25
|
+
ReadingPacket,
|
|
26
|
+
RtcTime,
|
|
27
|
+
StreamFrame,
|
|
28
|
+
parse_command_response,
|
|
29
|
+
parse_info_packet,
|
|
30
|
+
parse_reading_packet,
|
|
31
|
+
parse_stream_frame,
|
|
32
|
+
)
|
|
33
|
+
from .transport import (
|
|
34
|
+
BrymenbleClient,
|
|
35
|
+
COMMAND_CHAR_UUID,
|
|
36
|
+
DEFAULT_PASSWORD,
|
|
37
|
+
NOTIFY_CHAR_UUID,
|
|
38
|
+
CommandError,
|
|
39
|
+
)
|
|
40
|
+
from .scanner import (
|
|
41
|
+
DiscoveredMeter,
|
|
42
|
+
find_first_meter,
|
|
43
|
+
find_meters,
|
|
44
|
+
is_brymenble_advertisement,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__version__ = "0.5.2"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"commands",
|
|
51
|
+
"console",
|
|
52
|
+
"constants",
|
|
53
|
+
"crc",
|
|
54
|
+
"formatter",
|
|
55
|
+
"parsers",
|
|
56
|
+
"scanner",
|
|
57
|
+
"find_first_meter",
|
|
58
|
+
"find_meters",
|
|
59
|
+
"is_brymenble_advertisement",
|
|
60
|
+
"DiscoveredMeter",
|
|
61
|
+
"build_command_packet",
|
|
62
|
+
"build_rtc_time_packet",
|
|
63
|
+
"build_verify_password_packet",
|
|
64
|
+
"calculate_crc",
|
|
65
|
+
"verify_crc",
|
|
66
|
+
"format_reading",
|
|
67
|
+
"InfoPacket",
|
|
68
|
+
"ReadingPacket",
|
|
69
|
+
"RtcTime",
|
|
70
|
+
"StreamFrame",
|
|
71
|
+
"CommandResponse",
|
|
72
|
+
"parse_info_packet",
|
|
73
|
+
"parse_reading_packet",
|
|
74
|
+
"parse_stream_frame",
|
|
75
|
+
"parse_command_response",
|
|
76
|
+
"BrymenbleClient",
|
|
77
|
+
"CommandError",
|
|
78
|
+
"COMMAND_CHAR_UUID",
|
|
79
|
+
"DEFAULT_PASSWORD",
|
|
80
|
+
"NOTIFY_CHAR_UUID",
|
|
81
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Building of BM78xBT command packets (32 bytes, see protocol spec section 2)."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
from . import constants
|
|
7
|
+
from . import crc
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_command_packet(
|
|
11
|
+
mac_address: str, command_id: Union[int, bytes], args: bytes = b""
|
|
12
|
+
) -> bytes:
|
|
13
|
+
"""
|
|
14
|
+
Build a 32-byte command packet.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
mac_address: BLE device address as 'XX:XX:XX:XX:XX:XX'.
|
|
18
|
+
command_id: command ID as a 2-byte little-endian bytes value
|
|
19
|
+
(e.g. constants.CMD_VERIFY_PASSWORD) or as an int
|
|
20
|
+
(e.g. constants.CMD_RTC_TIME_CALIBRATION).
|
|
21
|
+
args: command-specific arguments (up to 14 bytes), zero-padded.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: if mac_address is not a 6-byte address or command_id is
|
|
25
|
+
not a valid 2-byte ID.
|
|
26
|
+
"""
|
|
27
|
+
mac_bytes = bytes.fromhex(mac_address.replace(':', ''))
|
|
28
|
+
if len(mac_bytes) != 6:
|
|
29
|
+
raise ValueError("MAC address must be 6 bytes")
|
|
30
|
+
if isinstance(command_id, int):
|
|
31
|
+
command_id = command_id.to_bytes(2, 'little')
|
|
32
|
+
if len(command_id) != 2:
|
|
33
|
+
raise ValueError("command_id must be exactly 2 bytes")
|
|
34
|
+
|
|
35
|
+
header = bytes([constants.COMMAND_HEAD_BYTE0, constants.COMMAND_HEAD_BYTE1])
|
|
36
|
+
payload = (
|
|
37
|
+
bytes([
|
|
38
|
+
constants.COMMAND_PACKET_LEN_BYTE,
|
|
39
|
+
constants.COMMAND_PACKET_TYPE,
|
|
40
|
+
constants.PROTOCOL_VERSION,
|
|
41
|
+
])
|
|
42
|
+
+ mac_bytes[::-1] # BLE device address, reversed byte order
|
|
43
|
+
+ command_id
|
|
44
|
+
+ bytes([constants.PASSWORD_ID])
|
|
45
|
+
+ args.ljust(14, b'\x00')
|
|
46
|
+
)
|
|
47
|
+
crc_bytes = crc.calculate_crc(payload).to_bytes(2, 'little')
|
|
48
|
+
footer = bytes([constants.COMMAND_END_BYTE0, constants.COMMAND_END_BYTE1])
|
|
49
|
+
return header + payload + crc_bytes + footer
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_verify_password_packet(mac_address: str, password: str = "0000") -> bytes:
|
|
53
|
+
"""Build a 'Verify Password' (0x0151) command packet."""
|
|
54
|
+
if len(password) != 4 or not password.isdigit():
|
|
55
|
+
raise ValueError("Password must be a 4-digit string")
|
|
56
|
+
args = bytes(int(ch) for ch in password)
|
|
57
|
+
return build_command_packet(mac_address, constants.CMD_VERIFY_PASSWORD, args)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def encode_rtc_time_args(when: datetime) -> bytes:
|
|
61
|
+
"""Encode a datetime as RTC Calibration (0x0010) args.
|
|
62
|
+
|
|
63
|
+
Arg[0..6] = second, minute, hour, date, day-of-week (Mon=1..Sun=7),
|
|
64
|
+
month, year-2000; Arg[7..13] zero (see protocol spec).
|
|
65
|
+
"""
|
|
66
|
+
day_of_week = when.weekday() + 1 # Mon=1 .. Sun=7 (protocol range)
|
|
67
|
+
return bytes([
|
|
68
|
+
when.second, when.minute, when.hour, when.day,
|
|
69
|
+
day_of_week, when.month, when.year - 2000,
|
|
70
|
+
]) + b'\x00' * 7
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_rtc_time_packet(mac_address: str, when: Optional[datetime] = None) -> bytes:
|
|
74
|
+
"""Build an 'RTC Time Calibration' (0x0010) command packet.
|
|
75
|
+
|
|
76
|
+
The meter has no RTC battery, so its clock resets on power-off and must be
|
|
77
|
+
re-synced after connecting. Defaults to the host's local time.
|
|
78
|
+
"""
|
|
79
|
+
if when is None:
|
|
80
|
+
when = datetime.now()
|
|
81
|
+
return build_command_packet(
|
|
82
|
+
mac_address, constants.CMD_RTC_TIME_CALIBRATION, encode_rtc_time_args(when)
|
|
83
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Shared console output for every brymenble consumer.
|
|
2
|
+
|
|
3
|
+
All streaming consumers — ``examples/live.py``, ``tools/connection_state.py``,
|
|
4
|
+
the display overlay, the TestController bridge — print the same timestamped
|
|
5
|
+
status lines, lifecycle events and reading format via this module, so the
|
|
6
|
+
console looks uniform no matter which tool is running.
|
|
7
|
+
|
|
8
|
+
Reading lines use the SDK's protocol-faithful formatter (overload -> ``OL``).
|
|
9
|
+
Consumers that add their own display accommodations at the UI layer (e.g. the
|
|
10
|
+
overlay/bridge showing ``----`` for a temperature overload) still override
|
|
11
|
+
there; the shared format itself stays protocol-faithful.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Optional, TextIO
|
|
18
|
+
|
|
19
|
+
from .formatter import format_reading
|
|
20
|
+
from .parsers import ReadingPacket
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ts() -> str:
|
|
24
|
+
"""``[HH:MM:SS.mmm]`` prefix used by every status line."""
|
|
25
|
+
now = datetime.now()
|
|
26
|
+
return f"{now:%H:%M:%S}.{now.microsecond // 1000:03d}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def status(message: str, *, stream: Optional[TextIO] = None) -> None:
|
|
30
|
+
"""Print a timestamped event line: ``[HH:MM:SS] message``."""
|
|
31
|
+
print(f"[{ts()}] {message}", file=stream, flush=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reading_line(reading: Optional[ReadingPacket]) -> str:
|
|
35
|
+
"""One-line display of a reading: ``DCV 607.80 V`` / ``Resistance OL``.
|
|
36
|
+
|
|
37
|
+
Mirrors the meter LCD: ``<function> <value>``. Overload/ASCII states show
|
|
38
|
+
as ``<function> OL`` / ``<function> <text>``.
|
|
39
|
+
"""
|
|
40
|
+
if reading is None:
|
|
41
|
+
return "?"
|
|
42
|
+
if reading.is_overload:
|
|
43
|
+
return f"{reading.function_name} OL"
|
|
44
|
+
if reading.is_ascii:
|
|
45
|
+
return f"{reading.function_name} {reading.ascii_text or '?'}"
|
|
46
|
+
return f"{reading.function_name} {format_reading(reading)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- lifecycle events (drop-in SDK callbacks / wrappers) -----------------
|
|
50
|
+
|
|
51
|
+
def retry(attempt: int, max_retries: Optional[int], error: Exception) -> None:
|
|
52
|
+
"""SDK ``on_retry`` callback: ``[HH:MM:SS] retry N[/M]: <error>``."""
|
|
53
|
+
label = f"retry {attempt}" if max_retries is None else f"retry {attempt}/{max_retries}"
|
|
54
|
+
status(f"{label}: {error}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def paused(seconds: float = 1.0) -> None:
|
|
58
|
+
"""Link-up silence = pause (e.g. function switch). Deliberately silent:
|
|
59
|
+
the pause is a lifecycle event (``on_pause``) that consumers act on — e.g.
|
|
60
|
+
the overlay blanks its display — not a status line. Kept so
|
|
61
|
+
``on_pause=console.paused`` remains a valid hook."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lost(reason: str = "link_down") -> None:
|
|
65
|
+
"""SDK ``on_lost`` callback: link-down (power off) or pause_cap."""
|
|
66
|
+
if reason == "pause_cap":
|
|
67
|
+
status("link up but silent too long — forcing reconnect")
|
|
68
|
+
else:
|
|
69
|
+
status("BLE link lost — meter powered off; reconnecting")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def reconnected() -> None:
|
|
73
|
+
"""SDK ``on_reconnected`` callback."""
|
|
74
|
+
status("reconnected and subscribed")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scanning() -> None:
|
|
78
|
+
status("scanning for a BM78xBT meter...")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def scanning_retry(attempt: int) -> None:
|
|
82
|
+
status(f"no BM78xBT meter in range yet (attempt {attempt}) — retrying...")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def using(mac: str, name: Optional[str] = None) -> None:
|
|
86
|
+
status(f"using {name or 'BM78xBT'} at {mac}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def connecting(mac: str) -> None:
|
|
90
|
+
"""``[HH:MM:SS] connecting to <mac>...``"""
|
|
91
|
+
status(f"connecting to {mac}...")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def connected(mac: str, *, detail: Optional[str] = None) -> None:
|
|
95
|
+
"""``[HH:MM:SS] connected to <mac>[ — <detail>]``"""
|
|
96
|
+
suffix = f" — {detail}" if detail else ""
|
|
97
|
+
status(f"connected to {mac}{suffix}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def disconnected() -> None:
|
|
101
|
+
"""``[HH:MM:SS] disconnected``"""
|
|
102
|
+
status("disconnected")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def found(mac: str, name: Optional[str] = None, rssi: Optional[float] = None) -> None:
|
|
106
|
+
"""``[HH:MM:SS] found <name> at <mac>[, rssi=..]`` — a meter from a scan."""
|
|
107
|
+
label = name or "BM78xBT"
|
|
108
|
+
rssi_txt = f", rssi={rssi}" if rssi is not None else ""
|
|
109
|
+
status(f"found {label} at {mac}{rssi_txt}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def state(name: str, detail: str, *, stream: Optional[TextIO] = None) -> None:
|
|
113
|
+
"""A link/data state-report line: ``[HH:MM:SS] <name> <detail>`` with the
|
|
114
|
+
state name padded to a 20-char column (used by the connection-state tools)."""
|
|
115
|
+
print(f"[{ts()}] {name:<20} {detail}", file=stream, flush=True)
|