open289 0.1.1__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.
- open289/__init__.py +14 -0
- open289/__main__.py +5 -0
- open289/adapter.py +239 -0
- open289/cli.py +626 -0
- open289/errors.py +36 -0
- open289/lock.py +54 -0
- open289/logger.py +288 -0
- open289/mcp_server.py +1394 -0
- open289/meter.py +374 -0
- open289/paths.py +31 -0
- open289/reading.py +164 -0
- open289/records.py +78 -0
- open289/sim.py +2439 -0
- open289/transport.py +162 -0
- open289-0.1.1.dist-info/METADATA +679 -0
- open289-0.1.1.dist-info/RECORD +20 -0
- open289-0.1.1.dist-info/WHEEL +5 -0
- open289-0.1.1.dist-info/entry_points.txt +3 -0
- open289-0.1.1.dist-info/licenses/LICENSE +674 -0
- open289-0.1.1.dist-info/top_level.txt +1 -0
open289/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""open289: Fluke 287/289 multimeter access over a Fluke IR3000 FC Bluetooth LE adapter.
|
|
2
|
+
|
|
3
|
+
Modules:
|
|
4
|
+
cli - the `open289` command line (JSON output)
|
|
5
|
+
mcp_server - MCP server (`open289-mcp`) for AI agents
|
|
6
|
+
transport - BLE backend selection (real adapter or the virtual one), discovery, connection
|
|
7
|
+
adapter - IR3000 FC adapter service client (pass-through to the meter's serial protocol)
|
|
8
|
+
meter - Fluke 287/289 remote protocol: queries and binary record decoders
|
|
9
|
+
reading - decoder for the adapter's live reading notifications
|
|
10
|
+
logger - long-running logging with reconnect
|
|
11
|
+
sim - virtual IR3000 FC + Fluke 289 (tests, CI, trying the tool without hardware)
|
|
12
|
+
Run the CLI with `python -m open289 --help`.
|
|
13
|
+
"""
|
|
14
|
+
__version__ = "0.1.1"
|
open289/__main__.py
ADDED
open289/adapter.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""IR3000 FC AdapterLogging service client (service B6981805).
|
|
2
|
+
|
|
3
|
+
Control point B6982918: write [opcode][params]; replies arrive as notifications
|
|
4
|
+
[status][echoed opcode][payload...]. Bulk data arrives as notifications on the
|
|
5
|
+
buffer characteristic B6982919. (notes/03-adapter-command-protocol.md)
|
|
6
|
+
|
|
7
|
+
Safety: only an allowlist of opcodes can be sent, and only to the adapter
|
|
8
|
+
control point. Same byte values mean destructive things on other control
|
|
9
|
+
points (e.g. 0x80 on B6982911 erases MSP flash), so UUIDs are hard-coded here.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import re
|
|
15
|
+
import struct
|
|
16
|
+
|
|
17
|
+
from bleak import BleakClient
|
|
18
|
+
|
|
19
|
+
from .errors import Open289Error
|
|
20
|
+
|
|
21
|
+
ADAPTER_SVC = "b6981805-7562-11e2-b50d-00163e46f8fe"
|
|
22
|
+
CP = "b6982918-7562-11e2-b50d-00163e46f8fe"
|
|
23
|
+
BUF = "b6982919-7562-11e2-b50d-00163e46f8fe"
|
|
24
|
+
CNX_ID = "b6982902-7562-11e2-b50d-00163e46f8fe" # 1-byte id; writing it arms the adapter CP
|
|
25
|
+
|
|
26
|
+
GET_BUFFER_SIZE = 0x80
|
|
27
|
+
CLEAR_BUFFER = 0x81
|
|
28
|
+
GET_BUFFER_DATA = 0x82
|
|
29
|
+
CANCEL = 0x83
|
|
30
|
+
APPEND_OUT = 0x8D
|
|
31
|
+
SEND_OUT = 0x8E
|
|
32
|
+
GET_VERSION = 0x8F
|
|
33
|
+
GET_MODEL = 0x95
|
|
34
|
+
|
|
35
|
+
ALLOWED = {GET_BUFFER_SIZE, CLEAR_BUFFER, GET_BUFFER_DATA, CANCEL, APPEND_OUT, SEND_OUT,
|
|
36
|
+
GET_VERSION, GET_MODEL}
|
|
37
|
+
# 0x84-0x8C (189/289 download macros) are allowed only via explicit opt-in.
|
|
38
|
+
DOWNLOAD_MACROS = set(range(0x84, 0x8D))
|
|
39
|
+
FORBIDDEN = set(range(0x90, 0x95)) # 1550/1555 high-voltage insulation test control
|
|
40
|
+
|
|
41
|
+
STATUS = {0: "IDLE", 1: "IN_PROCESS", 2: "INCORRECT_MODEL", 3: "COMPLETE", 4: "CANCELLED", 5: "FAILED"}
|
|
42
|
+
|
|
43
|
+
# Meter commands we let through the pass-through: identification and queries only.
|
|
44
|
+
# Fluke 28x convention: every query starts with Q. Anything else (MP, PRESS,
|
|
45
|
+
# CSD = clear saved data, RI = reset instrument, OFF, ...) needs unsafe=True.
|
|
46
|
+
SAFE_METER_CMD = re.compile(rb"^(ID|IM|Q[A-Z]{1,10})( [0-9A-Za-z_,]*)?$", re.I)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Outgoing (to-meter) buffer limit. 87 bytes is the largest size verified to work;
|
|
50
|
+
# 181+ bytes produced no reply and 373+ bytes hung the adapter's MSP until it was
|
|
51
|
+
# power-cycled (2026-11-06). Never exceed the verified size.
|
|
52
|
+
MAX_OUT = 87
|
|
53
|
+
# Largest reply verified to come back intact through GET_BUFFER_DATA.
|
|
54
|
+
MAX_REPLY_VERIFIED = 1208
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AdapterError(Open289Error):
|
|
58
|
+
"""status: adapter STATUS_* code when the adapter answered (4/5 are transient), None for timeouts and
|
|
59
|
+
local refusals. code: stable error code (TIMEOUT, PROTOCOL_ERROR, UNSAFE_OPERATION)."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, msg: str, status: int | None = None, code: str | None = None, hint: str | None = None):
|
|
62
|
+
if code is None:
|
|
63
|
+
code = 'TIMEOUT' if 'timeout' in msg else 'PROTOCOL_ERROR'
|
|
64
|
+
if hint is None and code == 'TIMEOUT':
|
|
65
|
+
hint = 'the adapter did not answer: move closer; if it persists, power-cycle the IR3000 (button)'
|
|
66
|
+
super().__init__(code, msg, hint)
|
|
67
|
+
self.status = status
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Adapter:
|
|
71
|
+
def __init__(self, client: BleakClient, verbose: bool = False, allow_macros: bool = False):
|
|
72
|
+
self.c = client
|
|
73
|
+
self.verbose = verbose
|
|
74
|
+
self.allow_macros = allow_macros
|
|
75
|
+
self._cp_q: asyncio.Queue[bytes] = asyncio.Queue()
|
|
76
|
+
self.buf = bytearray()
|
|
77
|
+
self.buf_chunks: list[bytes] = []
|
|
78
|
+
self.write_response = True
|
|
79
|
+
self._desync = False # a reply timed out; late notifications may still arrive
|
|
80
|
+
# Bytes appended since the last successful SEND. Whether CLEAR (0x81) empties the
|
|
81
|
+
# adapter's OUT buffer is not verified, so after a failure part-way through the
|
|
82
|
+
# APPENDs we conservatively assume those bytes are still there.
|
|
83
|
+
self.pending_out = 0
|
|
84
|
+
|
|
85
|
+
async def start(self, cnx_id: int = 1) -> None:
|
|
86
|
+
# Observed on IR3000 FW 01.00.01 / MSP 01.00.15: the control point stores
|
|
87
|
+
# writes but never replies until the CNX ID has been written (the official
|
|
88
|
+
# app writes 0x01 on every connect).
|
|
89
|
+
await self.c.write_gatt_char(CNX_ID, bytes([cnx_id]), response=True)
|
|
90
|
+
await self.c.start_notify(CP, self._on_cp)
|
|
91
|
+
await self.c.start_notify(BUF, self._on_buf)
|
|
92
|
+
|
|
93
|
+
async def stop(self) -> None:
|
|
94
|
+
for u in (CP, BUF):
|
|
95
|
+
try:
|
|
96
|
+
await self.c.stop_notify(u)
|
|
97
|
+
except Exception: # noqa: BLE001
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def _on_cp(self, _h, data: bytearray) -> None:
|
|
101
|
+
if self.verbose:
|
|
102
|
+
print(f" <CP {bytes(data).hex(' ')}")
|
|
103
|
+
self._cp_q.put_nowait(bytes(data))
|
|
104
|
+
|
|
105
|
+
def _on_buf(self, _h, data: bytearray) -> None:
|
|
106
|
+
if self.verbose:
|
|
107
|
+
d = bytes(data)
|
|
108
|
+
print(f" <BUF {d.hex(' ')} |{''.join(chr(b) if 32 <= b < 127 else '.' for b in d)}|")
|
|
109
|
+
self.buf_chunks.append(bytes(data))
|
|
110
|
+
self.buf.extend(data)
|
|
111
|
+
|
|
112
|
+
async def command(self, payload: bytes, timeout: float = 5.0) -> tuple[int, bytes]:
|
|
113
|
+
"""Send one control-point command; wait for the reply echoing its opcode."""
|
|
114
|
+
op = payload[0]
|
|
115
|
+
if op in FORBIDDEN:
|
|
116
|
+
raise AdapterError(f"opcode 0x{op:02X} is forbidden", code='UNSAFE_OPERATION')
|
|
117
|
+
if op not in ALLOWED and not (self.allow_macros and op in DOWNLOAD_MACROS):
|
|
118
|
+
raise AdapterError(f"opcode 0x{op:02X} not in allowlist", code='UNSAFE_OPERATION')
|
|
119
|
+
if self._desync:
|
|
120
|
+
# Give a late reply to the timed-out command time to land, then drop it,
|
|
121
|
+
# so it cannot be mistaken for the reply to this command.
|
|
122
|
+
await asyncio.sleep(0.5)
|
|
123
|
+
self._desync = False
|
|
124
|
+
while not self._cp_q.empty():
|
|
125
|
+
stale = self._cp_q.get_nowait()
|
|
126
|
+
if self.verbose:
|
|
127
|
+
print(f" (dropped stale CP {stale.hex(' ')})")
|
|
128
|
+
if self.verbose:
|
|
129
|
+
print(f" >CP {payload.hex(' ')}")
|
|
130
|
+
await self.c.write_gatt_char(CP, payload, response=self.write_response)
|
|
131
|
+
loop = asyncio.get_running_loop()
|
|
132
|
+
deadline = loop.time() + timeout
|
|
133
|
+
while True:
|
|
134
|
+
left = deadline - loop.time()
|
|
135
|
+
if left <= 0:
|
|
136
|
+
self._desync = True
|
|
137
|
+
raise AdapterError(f"timeout waiting for reply to 0x{op:02X}")
|
|
138
|
+
try:
|
|
139
|
+
msg = await asyncio.wait_for(self._cp_q.get(), left)
|
|
140
|
+
except (asyncio.TimeoutError, TimeoutError):
|
|
141
|
+
self._desync = True
|
|
142
|
+
raise AdapterError(f"timeout waiting for reply to 0x{op:02X}") from None
|
|
143
|
+
if len(msg) >= 2 and msg[1] == op:
|
|
144
|
+
status = msg[0]
|
|
145
|
+
if status == 1: # in process: keep waiting
|
|
146
|
+
continue
|
|
147
|
+
return status, msg[2:]
|
|
148
|
+
if self.verbose:
|
|
149
|
+
print(f" (ignoring CP reply for other opcode: {msg.hex(' ')})")
|
|
150
|
+
|
|
151
|
+
async def version(self) -> str:
|
|
152
|
+
"""Adapter MSP firmware version, e.g. '01.00.15' (reply is 'ver,' CSV)."""
|
|
153
|
+
st, pl = await self.command(bytes([GET_VERSION]))
|
|
154
|
+
if st != 3:
|
|
155
|
+
raise AdapterError(f"GET_ADAPTER_VERSION -> {STATUS.get(st, st)}")
|
|
156
|
+
return pl.decode("ascii", "replace").split(",")[0]
|
|
157
|
+
|
|
158
|
+
async def model(self) -> str:
|
|
159
|
+
"""Model name per adapter (empty with a 289 attached; used for 1550/1555)."""
|
|
160
|
+
st, pl = await self.command(bytes([GET_MODEL]))
|
|
161
|
+
if st != 3:
|
|
162
|
+
raise AdapterError(f"GET_MODEL_NAME -> {STATUS.get(st, st)}")
|
|
163
|
+
return pl.decode("ascii", "replace").rstrip("\0")
|
|
164
|
+
|
|
165
|
+
async def buffer_size(self) -> int:
|
|
166
|
+
st, pl = await self.command(bytes([GET_BUFFER_SIZE]))
|
|
167
|
+
if st != 3 or len(pl) < 4:
|
|
168
|
+
raise AdapterError(f"GET_BUFFER_SIZE -> status {st} payload {pl.hex(' ')}", st)
|
|
169
|
+
return struct.unpack_from("<I", pl)[0]
|
|
170
|
+
|
|
171
|
+
async def read_buffer(self, offset: int, length: int, timeout: float = 10.0) -> bytes:
|
|
172
|
+
"""GET_BUFFER_DATA: data arrives as notifications on BUF, then 03 82 on the CP."""
|
|
173
|
+
self.buf.clear()
|
|
174
|
+
self.buf_chunks.clear()
|
|
175
|
+
st, pl = await self.command(bytes([GET_BUFFER_DATA]) + struct.pack("<II", offset, length), timeout)
|
|
176
|
+
if st != 3:
|
|
177
|
+
raise AdapterError(f"GET_BUFFER_DATA -> status {STATUS.get(st, st)} {pl.hex(' ')}", st)
|
|
178
|
+
for _ in range(50): # normally already complete; allow for late callbacks
|
|
179
|
+
if len(self.buf) >= length:
|
|
180
|
+
break
|
|
181
|
+
await asyncio.sleep(0.02)
|
|
182
|
+
return bytes(self.buf)
|
|
183
|
+
|
|
184
|
+
async def passthrough(self, meter_cmd: bytes, unsafe: bool = False, timeout: float = 10.0,
|
|
185
|
+
chunk: int = 400) -> bytes:
|
|
186
|
+
"""Send one meter command through the adapter and return the meter's raw reply.
|
|
187
|
+
|
|
188
|
+
Observed (IR3000 MSP 01.00.15 + Fluke 289 V1.71): the adapter appends the CR
|
|
189
|
+
itself; SEND answers 01 8E (in process) and 03 8E only once the meter's reply
|
|
190
|
+
is fully captured, so a single GET_BUFFER_SIZE afterwards is enough.
|
|
191
|
+
"""
|
|
192
|
+
meter_cmd = meter_cmd.rstrip(b"\r\n")
|
|
193
|
+
if not unsafe and not SAFE_METER_CMD.match(meter_cmd):
|
|
194
|
+
raise AdapterError(f"meter command {meter_cmd!r} is not a read-only query", code='UNSAFE_OPERATION',
|
|
195
|
+
hint='only ID, IM and Q* queries pass by default; add --unsafe for other commands')
|
|
196
|
+
return await self._send_raw(meter_cmd, timeout, chunk)
|
|
197
|
+
|
|
198
|
+
async def passthrough_batch(self, meter_cmds: list[bytes], timeout: float = 15.0,
|
|
199
|
+
chunk: int = 400) -> bytes:
|
|
200
|
+
"""Send several read-only queries in one SEND (CR-separated); returns the
|
|
201
|
+
concatenated replies. Verified: 8 x QSRR (87 bytes out, 1208 bytes back)."""
|
|
202
|
+
cmds = [c.rstrip(b"\r\n") for c in meter_cmds]
|
|
203
|
+
if not cmds:
|
|
204
|
+
raise AdapterError("empty batch", code='INVALID_ARG')
|
|
205
|
+
for c in cmds:
|
|
206
|
+
if not SAFE_METER_CMD.match(c):
|
|
207
|
+
raise AdapterError(f"meter command {c!r} is not a read-only query", code='UNSAFE_OPERATION')
|
|
208
|
+
return await self._send_raw(b"\r".join(cmds), timeout, chunk)
|
|
209
|
+
|
|
210
|
+
async def _send_raw(self, meter_cmd: bytes, timeout: float, chunk: int) -> bytes:
|
|
211
|
+
if len(meter_cmd) > MAX_OUT:
|
|
212
|
+
raise AdapterError(f"{len(meter_cmd)}-byte command exceeds the adapter's verified "
|
|
213
|
+
f"{MAX_OUT}-byte out buffer (larger sends can hang the adapter)", code='UNSAFE_OPERATION')
|
|
214
|
+
if self.pending_out + len(meter_cmd) > MAX_OUT:
|
|
215
|
+
raise AdapterError(f"adapter may still hold {self.pending_out} unsent bytes from a failed "
|
|
216
|
+
f"send; sending {len(meter_cmd)} more could exceed its {MAX_OUT}-byte "
|
|
217
|
+
f"buffer (send a shorter command, or power-cycle the IR3000)", code='UNSAFE_OPERATION')
|
|
218
|
+
st, pl = await self.command(bytes([CLEAR_BUFFER]))
|
|
219
|
+
if st != 3:
|
|
220
|
+
raise AdapterError(f"CLEAR -> {STATUS.get(st, st)} {pl.hex(' ')}", st)
|
|
221
|
+
for i in range(0, len(meter_cmd), 17): # CP rejects writes > 18 bytes (opcode + 17)
|
|
222
|
+
part = meter_cmd[i:i + 17]
|
|
223
|
+
self.pending_out += len(part) # counted before the write: a lost reply may still have landed
|
|
224
|
+
st, pl = await self.command(bytes([APPEND_OUT]) + part)
|
|
225
|
+
if st != 3:
|
|
226
|
+
raise AdapterError(f"APPEND -> {STATUS.get(st, st)} {pl.hex(' ')}", st)
|
|
227
|
+
st, pl = await self.command(bytes([SEND_OUT]), timeout=timeout)
|
|
228
|
+
if st != 3:
|
|
229
|
+
raise AdapterError(f"SEND -> {STATUS.get(st, st)} {pl.hex(' ')}", st)
|
|
230
|
+
self.pending_out = 0 # SEND transmitted (and consumed) the out buffer
|
|
231
|
+
size = await self.buffer_size()
|
|
232
|
+
out = bytearray()
|
|
233
|
+
while len(out) < size:
|
|
234
|
+
n = min(chunk, size - len(out))
|
|
235
|
+
part = await self.read_buffer(len(out), n, timeout=timeout)
|
|
236
|
+
if len(part) != n:
|
|
237
|
+
raise AdapterError(f"buffer read at {len(out)} returned {len(part)} of {n} bytes")
|
|
238
|
+
out += part
|
|
239
|
+
return bytes(out)
|