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.
@@ -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.
@@ -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
+ ![Please note this picture is for showcase only. It is currently not possible to connect two instances to one meter.](img/showcase.png)
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
+ ![Please note this picture is for showcase only. It is currently not possible to connect two instances to one meter.](img/showcase.png)
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)