brymenble 0.5.2__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
brymenble/commands.py ADDED
@@ -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
+ )
brymenble/console.py ADDED
@@ -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)
brymenble/constants.py ADDED
@@ -0,0 +1,185 @@
1
+ # Lookup tables and enumerations for the BM78xBT protocol
2
+
3
+ # --- Packet framing / layout constants ----------------------------------------
4
+
5
+ # Fixed packet sizes (bytes)
6
+ INFO_PACKET_LENGTH = 24
7
+ READING_PACKET_LENGTH = 32
8
+ STREAM_FRAME_LENGTH = 152 # 1 info packet + 4 reading packets
9
+ READINGS_PER_FRAME = 4
10
+
11
+ # Byte [0] / [1]: header (HeadByte0 / HeadByte1)
12
+ HEAD_BYTE0 = 0xFF
13
+ HEAD_BYTE1_INFO = 0x01
14
+ HEAD_BYTE1_READING = 0x02
15
+
16
+ # Byte [2]: packet length field (always equals INFO/READING_PACKET_LENGTH)
17
+ # Byte [3]: packet type
18
+ INFO_PACKET_TYPE = 0x04 # Device Information
19
+ READING_PACKET_TYPE = 0x05 # Device Reading
20
+ # Byte [4]: protocol version
21
+ PROTOCOL_VERSION = 0x01
22
+
23
+ # Byte [30] / [31]: trailer (EndByte0 / EndByte1)
24
+ END_BYTE0 = 0xFF
25
+ END_BYTE1 = 0x03
26
+
27
+ # CRC-16 field: computed over bytes [start:end], stored little-endian at [end:end+2]
28
+ INFO_CRC_START = 2
29
+ INFO_CRC_END = 20
30
+ READING_CRC_START = 2
31
+ READING_CRC_END = 28
32
+
33
+ # --- Command packet framing (32 bytes) -----------------------------------------
34
+ COMMAND_PACKET_LENGTH = 32
35
+ COMMAND_HEAD_BYTE0 = 0xFF # Byte [0]
36
+ COMMAND_HEAD_BYTE1 = 0x01 # Byte [1] (SOH)
37
+ COMMAND_PACKET_LEN_BYTE = 0x20 # Byte [2] (32)
38
+ COMMAND_PACKET_TYPE = 0x01 # Byte [3]: Command (0x02 = Response)
39
+ # Byte [4] protocol version: reuses PROTOCOL_VERSION
40
+ PASSWORD_ID = 0x01 # Byte [13]: password identification
41
+ COMMAND_END_BYTE0 = 0xFF # Byte [30]
42
+ COMMAND_END_BYTE1 = 0x03 # Byte [31] (ETX)
43
+ COMMAND_CRC_START = 2
44
+ COMMAND_CRC_END = 28
45
+
46
+ # Command IDs (2 bytes, little-endian on the wire)
47
+ CMD_VERIFY_PASSWORD = bytes([0x51, 0x01]) # 0x0151 (legacy bytes form)
48
+
49
+ # Full command table (numeric form; send_command / builders accept either)
50
+ CMD_GET_FIRMWARE_VERSION = 0x0004
51
+ CMD_RTC_TIME_CALIBRATION = 0x0010
52
+ CMD_GET_MODEL_SERIES_ID = 0x0116
53
+ CMD_SET_CONNECTION_PASSWORD = 0x0140
54
+ CMD_GET_CONNECTION_PASSWORD = 0x0141
55
+ CMD_SET_DEVICE_NAME = 0x0142
56
+ CMD_GET_DEVICE_NAME = 0x0143
57
+ CMD_VERIFY_CONNECTION_PASSWORD = 0x0151
58
+
59
+ # Command/response packet framing
60
+ COMMAND_RESPONSE_TYPE = 0x02 # Byte [3]: Response (0x01 = Command)
61
+ CMD_FAILURE = 0x8001 # response Command ID signalling failure
62
+
63
+ # Error codes carried in 0x8001 failure responses (Arg[3:2], little-endian)
64
+ ERROR_CODES = {
65
+ 0: "Checksum error",
66
+ 1: "Invalid channel ID",
67
+ 2: "Out of setting range",
68
+ 3: "Invalid password",
69
+ 4: "Invalid password",
70
+ 5: "Invalid arguments",
71
+ 6: "Insufficient permissions",
72
+ }
73
+
74
+ # Device Category IDs
75
+ CATEGORY_MULTIMETER = 0x02
76
+ CATEGORY_CLAMP_METER = 0x03
77
+
78
+ # Battery Status
79
+ BATTERY_NORMAL = 0x00
80
+ BATTERY_LOW = 0x02
81
+
82
+ # Value -> human-readable name lookups
83
+ CATEGORY_NAMES = {
84
+ CATEGORY_MULTIMETER: "Multimeter",
85
+ CATEGORY_CLAMP_METER: "Clamp-on",
86
+ }
87
+
88
+ BATTERY_NAMES = {
89
+ BATTERY_NORMAL: "Normal",
90
+ BATTERY_LOW: "Low",
91
+ }
92
+
93
+ # Function IDs mapping (Main Function ID, Sub-Function ID -> description)
94
+ FUNCTION_NAMES = {
95
+ (0x02, 0x00): "LoZ-ACV",
96
+ (0x02, 0x01): "LoZ-DCV",
97
+ (0x02, 0x03): "AUTO",
98
+ (0x03, 0x00): "ACV",
99
+ (0x03, 0x01): "DCV",
100
+ (0x03, 0x02): "DC+ACV",
101
+ (0x17, 0x00): "Hz of VFD-ACV",
102
+ (0x17, 0x01): "VFD-ACV",
103
+ (0x04, 0x00): "ACmV",
104
+ (0x04, 0x01): "DCmV",
105
+ (0x04, 0x02): "DC+ACmV",
106
+ (0x05, 0x00): "ACµA",
107
+ (0x05, 0x01): "DCµA",
108
+ (0x05, 0x02): "DC+ACµA",
109
+ (0x06, 0x00): "ACmA",
110
+ (0x06, 0x01): "DCmA",
111
+ (0x06, 0x02): "DC+ACmA",
112
+ (0x06, 0x08): "%4~20mA",
113
+ (0x07, 0x00): "ACA",
114
+ (0x07, 0x01): "DCA",
115
+ (0x07, 0x02): "DC+ACA",
116
+ (0x0C, 0x00): "T1",
117
+ (0x0C, 0x01): "T2",
118
+ (0x0C, 0x02): "T1-T2",
119
+ (0x0D, 0x00): "Resistance",
120
+ (0x0E, 0x00): "Capacitance",
121
+ (0x0F, 0x00): "Continuity",
122
+ (0x10, 0x00): "Diode",
123
+ (0x11, 0x00): "nS Conductance",
124
+ (0x12, 0x00): "Duty Cycle (%)",
125
+ (0x13, 0x00): "Logic-Hz",
126
+ (0x22, 0x00): "EF-Lo",
127
+ (0x22, 0x01): "EF-Hi",
128
+ (0x23, 0x00): "Hz of Line Signal",
129
+ }
130
+
131
+ # Unit codes (Byte 26) -> SI unit string
132
+ UNIT_CODES = {
133
+ 0x02: "V",
134
+ 0x03: "A",
135
+ 0x04: "Ω",
136
+ 0x05: "S",
137
+ 0x06: "F",
138
+ 0x08: "Hz",
139
+ 0x0A: "%",
140
+ 0x14: "°C",
141
+ 0x15: "°F",
142
+ 0x4F: "%4~20mA",
143
+ }
144
+
145
+ # Metrics Prefixes (Byte 25) -> symbol
146
+ PREFIX_MAP = {
147
+ -9: "n",
148
+ -6: "µ",
149
+ -3: "m",
150
+ 0: "",
151
+ 3: "k",
152
+ 6: "M",
153
+ 9: "G",
154
+ }
155
+
156
+ # ASCII reading mappings (when Status Flag 0 Bit 2 = 1)
157
+ ASCII_READING_MAP = {
158
+ 0x000001: "Auto",
159
+ 0x000002: "InEr",
160
+ 0x000003: "-",
161
+ 0x000004: "--",
162
+ 0x000005: "---",
163
+ 0x000006: "----",
164
+ 0x000007: "-----",
165
+ 0x00000A: "EF-H",
166
+ 0x00000B: "EF-L",
167
+ }
168
+
169
+ # Status Flag 0 bit definitions (Byte 14)
170
+ STATUS0_CREST = 0x80
171
+ STATUS0_REL = 0x40
172
+ STATUS0_HOLD = 0x20
173
+ STATUS0_AUTO_RANGE = 0x10
174
+ STATUS0_AUTO_HOLD = 0x08
175
+ STATUS0_ASCII_READING = 0x04
176
+
177
+ # Status Flag 1 bit definitions (Byte 15)
178
+ STATUS1_SIGN = 0x40
179
+ STATUS1_OL = 0x20
180
+ STATUS1_RECORD = 0x10
181
+ STATUS1_MAX = 0x08
182
+ STATUS1_MIN = 0x04
183
+ STATUS1_AVG = 0x02
184
+
185
+ # Status Flag 2 (Byte 16) is "don't care"
brymenble/crc.py ADDED
@@ -0,0 +1,17 @@
1
+ # CRC-16 (Reverse, polynomial 0xA001) calculation and verification
2
+
3
+ def calculate_crc(data: bytes) -> int:
4
+ """Return CRC-16 (little-endian) over the given bytes."""
5
+ reg_crc = 0xFFFF
6
+ for byte in data:
7
+ reg_crc ^= byte
8
+ for _ in range(8):
9
+ if reg_crc & 0x01:
10
+ reg_crc = (reg_crc >> 1) ^ 0xA001
11
+ else:
12
+ reg_crc >>= 1
13
+ return reg_crc
14
+
15
+ def verify_crc(data: bytes, expected_crc: int) -> bool:
16
+ """Check if the CRC of 'data' matches 'expected_crc'."""
17
+ return calculate_crc(data) == expected_crc
brymenble/formatter.py ADDED
@@ -0,0 +1,31 @@
1
+ # Convert a parsed reading packet into a human-readable string
2
+
3
+ from .parsers import ReadingPacket
4
+
5
+ def format_reading(reading: ReadingPacket) -> str:
6
+ """
7
+ Given a parsed ReadingPacket, return a string like "123.45 V".
8
+ Handles overload, ASCII displays, and signs.
9
+ """
10
+ if reading is None:
11
+ return "Invalid packet"
12
+ if reading.is_overload:
13
+ return "OL"
14
+ if reading.is_ascii:
15
+ return reading.ascii_text or "???"
16
+
17
+ # Derived from the packet's canonical value/decimals surface rather than
18
+ # re-scaling the raw fields here — so this can never drift from other
19
+ # consumers (overlay, to_dict(), etc.) on edge cases like decimal_pos == 0.
20
+ value = reading.value
21
+ if value is None: # defensive; unreachable after the overload/ASCII checks
22
+ return "???"
23
+ number_str = f"{value:.{reading.decimals}f}"
24
+
25
+ # Combine with prefix and unit
26
+ prefix = reading.prefix # symbol like 'k', 'm', etc.
27
+ unit = reading.unit
28
+ if prefix:
29
+ return f"{number_str} {prefix}{unit}"
30
+ else:
31
+ return f"{number_str} {unit}".strip()