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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)