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 +81 -0
- brymenble/commands.py +83 -0
- brymenble/console.py +115 -0
- brymenble/constants.py +185 -0
- brymenble/crc.py +17 -0
- brymenble/formatter.py +31 -0
- brymenble/parsers.py +567 -0
- brymenble/py.typed +0 -0
- brymenble/scanner.py +101 -0
- brymenble/transport.py +613 -0
- brymenble-0.5.2.dist-info/METADATA +172 -0
- brymenble-0.5.2.dist-info/RECORD +15 -0
- brymenble-0.5.2.dist-info/WHEEL +5 -0
- brymenble-0.5.2.dist-info/licenses/LICENSE +21 -0
- brymenble-0.5.2.dist-info/top_level.txt +1 -0
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()
|