oep-client-python 0.0.52__py3-none-any.whl → 0.0.54__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.
oep_client/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.52"
5
+ __version__ = "0.0.54"
oep_client/__main__.py CHANGED
@@ -125,6 +125,17 @@ def main(argv=None) -> int:
125
125
  rs.add_argument("--reopen-s", type=float, default=0.0, metavar="S",
126
126
  help="when the probe is not back within its restart_max_ms, open it again for up to S more seconds "
127
127
  "(your reopen: a host where the device returns late, e.g. WSL re-attaching it through usbipd)")
128
+ ca = sub.add_parser("call", help="run any interface's op: by number with the request as hex bytes, or by the name and "
129
+ "fields its describe gives (core §7.4 op_name / op_field)")
130
+ ca.add_argument("probe", help="a probe: oep://UNIT_ID[?via=...], a serial port, tcp://HOST:PORT or usb")
131
+ ca.add_argument("interface", help="the interface's name (io.github.you.thing); its fn is looked up")
132
+ ca.add_argument("op", help="the op: a number (0x10) or its name")
133
+ ca.add_argument("args", nargs="*", help="NAME=VALUE per field (integers 10 or 0x, bools true / false, lists 1,2,3, "
134
+ "bytes hex, text as it is), or the request as hex bytes")
135
+ ca.add_argument("--text", action="store_true", help="the request is ARGS joined by spaces, as UTF-8 bytes")
136
+ ca.add_argument("--wait", type=float, metavar="S", help="the op is a job (oep::SimpleInterface::job): wait up to S "
137
+ "seconds for it by the interface's status op, then show that")
138
+ ca.add_argument("--json", action="store_true")
128
139
  fd = sub.add_parser("find", help="probes on the local network that announce _oep._tcp (DNS-SD over mDNS)")
129
140
  fd.add_argument("--timeout", type=float, default=2.0, help="seconds to listen for answers (default 2)")
130
141
  fd.add_argument("--engine", choices=("auto", "zeroconf", "minimal"), default="auto",
@@ -157,6 +168,8 @@ def main(argv=None) -> int:
157
168
  return _restart_cmd(args)
158
169
  if args.command == "find":
159
170
  return _find_cmd(args)
171
+ if args.command == "call":
172
+ return _call_cmd(args)
160
173
 
161
174
  confirm = (0, 1)
162
175
  if args.virtual:
@@ -237,6 +250,82 @@ def _restart_cmd(args) -> int:
237
250
  return 0
238
251
 
239
252
 
253
+ # ---- oep call -----------------------------------------------------------------------------------------------------
254
+
255
+ def _field_value(field, text: str):
256
+ from . import call as c
257
+ t = field.type
258
+ if t & c.LIST:
259
+ items = [x for x in text.replace(" ", "").split(",") if x]
260
+ return [(x.lower() in ("1", "true")) if (t & ~c.LIST) == c.T["bool"] else int(x, 0) for x in items]
261
+ if t == c.T["bool"]:
262
+ if text.lower() not in ("0", "1", "true", "false"):
263
+ raise SystemExit(f"oep call: {field.name} takes true or false")
264
+ return text.lower() in ("1", "true")
265
+ if t == c.T["bytes"]:
266
+ return bytes.fromhex(text)
267
+ if t == c.T["text"]:
268
+ return text
269
+ return int(text, 0)
270
+
271
+
272
+ def _call_cmd(args) -> int:
273
+ import json
274
+ from . import call as c
275
+ hst = _open_host(args.probe)
276
+ try:
277
+ forms = c.ops(hst, args.interface)
278
+ except c.NotOffered as e:
279
+ raise SystemExit(f"oep call: {e}") from None
280
+ op: int | str = int(args.op, 0) if args.op[:1].isdigit() else args.op
281
+ form = forms.get(op) if isinstance(op, int) else next((f for f in forms.values() if f.name == op), None)
282
+ payload, fields = None, {}
283
+ if args.text:
284
+ payload = " ".join(args.args).encode()
285
+ elif form is not None and form.form and (any("=" in a for a in args.args) or not args.args):
286
+ by_name = {f.name: f for f in form.request}
287
+ for item in args.args:
288
+ name, eq, value = item.partition("=")
289
+ if not eq or name not in by_name:
290
+ raise SystemExit(f"oep call: {form.signature()}: give NAME=VALUE for its fields ({item!r})")
291
+ fields[name] = _field_value(by_name[name], value)
292
+ else:
293
+ try:
294
+ payload = bytes.fromhex("".join(args.args))
295
+ except ValueError:
296
+ raise SystemExit("oep call: the request as hex bytes (its describe gives this op no fields)") from None
297
+ if not (form is not None and form.lock_free):
298
+ core.take(hst, 10000 + int((args.wait or 0) * 1000), owner="oep call") # a job's wait inside the lease
299
+ try:
300
+ if args.wait is not None:
301
+ r = c.call_wait(hst, args.interface, op, payload, timeout=args.wait, **fields)
302
+ else:
303
+ r = c.call(hst, args.interface, op, payload, **fields)
304
+ except (c.JobFailed, c.JobCancelled, c.JobTimeout, c.ProbeRestarted) as e:
305
+ st = getattr(e, "status", {})
306
+ print(json.dumps({"result": type(e).__name__, "why": str(e), "status": st}, default=str) if args.json
307
+ else f"{type(e).__name__} {e}")
308
+ return 1
309
+ except TypeError as e:
310
+ raise SystemExit(f"oep call: {e}") from None
311
+ except c.NotOffered as e:
312
+ raise SystemExit(f"oep call: {e}") from None
313
+ except c.CommandFailed as e:
314
+ out = {"result": "failed", "op": e.op, "fields": e.fields, "payload": e.payload.hex()}
315
+ print(json.dumps(out, default=str) if args.json else f"failed {e}" + ("" if e.fields else f" {e.payload.hex()}"))
316
+ return 1
317
+ except c.CommandRejected as e:
318
+ print(json.dumps({"result": "rejected", "op": e.op, "why": str(e)}) if args.json else f"rejected {e}")
319
+ return 1
320
+ shown = {k: (v.hex() if isinstance(v, bytes) else v) for k, v in r.items()}
321
+ if args.json:
322
+ print(json.dumps({"result": "completed", "revision": r.revision, "fields": shown, "payload": r.payload.hex()}))
323
+ else:
324
+ body = " ".join(f"{k}={v}" for k, v in shown.items()) if shown or (form and form.form) else r.payload.hex()
325
+ print(f"completed rev {r.revision} {body}".rstrip())
326
+ return 0
327
+
328
+
240
329
  # ---- oep pins -----------------------------------------------------------------------------------------------------
241
330
 
242
331
  def _pins_cmd(args) -> int:
oep_client/arm.py CHANGED
@@ -315,6 +315,19 @@ class CortexM:
315
315
  raise h.OepError(f"stopped at {pc:#010x}, not at the return breakpoint")
316
316
  return self.reg(0)
317
317
 
318
+ DEMCR, VC_CORERESET = 0xE000EDFC, 1
319
+
320
+ def reset_halt(self, timeout: float = 1.0) -> int:
321
+ """Reset the core and stop it on its first instruction (DEMCR.VC_CORERESET through AIRCR.SYSRESETREQ), the
322
+ catch cleared again: a clean core to call ROM functions on (a core left locked up in a fault handler is not
323
+ one). C_MASKINTS is cleared first (it survives the reset). -> PC"""
324
+ self.mem.write32(self.DHCSR, self.KEY | self.C_DEBUGEN | self.C_HALT)
325
+ self.mem.write32(self.DEMCR, self.mem.read32(self.DEMCR) | self.VC_CORERESET)
326
+ self.mem.write32(self.AIRCR, 0x05FA0004)
327
+ self._wait(self.S_HALT, timeout)
328
+ self.mem.write32(self.DEMCR, self.mem.read32(self.DEMCR) & ~self.VC_CORERESET)
329
+ return self.reg(self.PC)
330
+
318
331
  def sys_reset(self) -> None:
319
332
  """AIRCR.SYSRESETREQ: the core restarts; debug-domain state (DHCSR) survives, so clear the mask first."""
320
333
  self.mem.write32(self.DHCSR, self.KEY | self.C_DEBUGEN | self.C_HALT)
oep_client/call.py ADDED
@@ -0,0 +1,284 @@
1
+ """Call any interface's op by its number, or by the name and fields its describe gives (oep-core §7.4 op_name /
2
+ op_field) - an interface this host has no module for, such as a sketch's oep::SimpleInterface.
3
+
4
+ from oep_client import link, core
5
+ from oep_client.call import call
6
+ hst = link.open_host("/dev/ttyACM0")
7
+ core.take(hst, 10000, owner="my-test")
8
+ r = call(hst, "io.github.yourname.mine", "add", a=2, b=3)
9
+ r["sum"], r.sum, r.revision # the success fields by name; the interface's revision
10
+ call(hst, "io.github.yourname.mine", 0x10, b"\\x02\\0\\0\\0\\x03\\0\\0\\0").payload # by number, the bytes as they are
11
+
12
+ Errors: InterfaceMissing / RevisionMismatch / OpMissing (all NotOffered: a test can turn them into a skip),
13
+ CommandRejected (the probe refused it: the reason), CommandFailed (completed failed: code and message when the op
14
+ gives them).
15
+
16
+ A job (oep::SimpleInterface::job: it starts work on the probe's worker and answers at once) and its status op:
17
+
18
+ st = call_wait(hst, "io.github.yourname.mine", "collect", count=3, timeout=10)
19
+ st.start_ns, st.end_ns # on the probe's clock (a capture's markers' one)
20
+ # wait=: a stream's wait (acquire.Request(during=...)) keeps the capture collecting while this waits
21
+
22
+ JobFailed (the job's Worker::fail: code, message), JobCancelled, JobTimeout (cancel sent first), ProbeRestarted (its
23
+ boot_id changed while the job ran)."""
24
+
25
+ from __future__ import annotations
26
+
27
+ import struct
28
+ import time
29
+ from typing import Any, Callable
30
+
31
+ from . import catalog, core, host as h
32
+
33
+ T = catalog.OP_FIELD_TYPES
34
+ LIST = catalog.OP_FIELD_LIST
35
+ _INT = {T["u8"]: "<B", T["u16"]: "<H", T["u32"]: "<I", T["u64"]: "<Q",
36
+ T["i8"]: "<b", T["i16"]: "<h", T["i32"]: "<i", T["i64"]: "<q", T["bool"]: "<B"}
37
+
38
+
39
+ class NotOffered(LookupError):
40
+ """The probe does not offer what was asked: no such interface, another revision, no such op."""
41
+
42
+
43
+ class InterfaceMissing(NotOffered):
44
+ pass
45
+
46
+
47
+ class RevisionMismatch(NotOffered):
48
+ def __init__(self, interface: str, revision: int, wanted: int):
49
+ super().__init__(f"{interface} is revision {revision} on this probe, {wanted} asked")
50
+ self.revision, self.wanted = revision, wanted
51
+
52
+
53
+ class OpMissing(NotOffered):
54
+ pass
55
+
56
+
57
+ class CommandRejected(h.Rejected):
58
+ """The probe refused the op (resolution rejected): `result.detail` is the reason (core §4.3)."""
59
+
60
+ def __init__(self, op: str, result):
61
+ super().__init__(result)
62
+ self.op = op
63
+ self.args = (f"{op}: {result.describe()}",)
64
+
65
+
66
+ class CommandFailed(h.Failed):
67
+ """The op ran and did not work (completed failed / partial): `fields` the failed part decoded (code, message for
68
+ an oep::SimpleInterface op), `payload` the bytes."""
69
+
70
+ def __init__(self, op: str, result, fields: dict | None):
71
+ why = f"{op}: {result.describe()}"
72
+ if fields and "code" in fields:
73
+ why += f" (code {fields['code']}" + (f": {fields['message']}" if fields.get("message") else "") + ")"
74
+ super().__init__(result, why)
75
+ self.op, self.fields, self.payload = op, fields or {}, result.payload
76
+ self.code, self.message = self.fields.get("code"), self.fields.get("message")
77
+
78
+
79
+ class JobFailed(h.OepError):
80
+ """The job ended failed: `code` and `message` from its Worker::fail(), `status` the status op's fields."""
81
+
82
+ def __init__(self, op: str, status: dict):
83
+ super().__init__(f"{op}: failed (code {status.get('code')}"
84
+ + (f": {status['message']}" if status.get("message") else "") + ")")
85
+ self.op, self.status, self.code, self.message = op, status, status.get("code"), status.get("message")
86
+
87
+
88
+ class JobCancelled(h.OepError):
89
+ """The job stopped on a cancel (`status` the status op's fields)."""
90
+
91
+ def __init__(self, op: str, status: dict):
92
+ super().__init__(f"{op}: cancelled")
93
+ self.op, self.status = op, status
94
+
95
+
96
+ class JobTimeout(h.OepError, TimeoutError):
97
+ """The job still ran at the timeout; a cancel was sent (`status` the last status op's fields)."""
98
+
99
+ def __init__(self, op: str, timeout: float, status: dict):
100
+ super().__init__(f"{op}: still running after {timeout} s (cancel sent)")
101
+ self.op, self.status = op, status
102
+
103
+
104
+ class ProbeRestarted(h.OepError):
105
+ """The probe's boot_id changed while the job ran (core §6.5): it restarted, the job with it."""
106
+
107
+
108
+ # The status op's state (oep::Worker::State): the firmware's convention, not the registry's
109
+ JOB_STATES = {"idle": 0, "running": 1, "done": 2, "failed": 3, "cancelled": 4}
110
+
111
+
112
+ class CallResult(dict):
113
+ """The success fields by name (a dict, and attributes), the raw payload, the interface's revision and the op."""
114
+
115
+ def __init__(self, fields: dict, payload: bytes, revision: int, op: catalog.OpForm | int):
116
+ super().__init__(fields)
117
+ self.payload, self.revision, self.op = payload, revision, op
118
+
119
+ def __getattr__(self, name: str) -> Any:
120
+ try:
121
+ return self[name]
122
+ except KeyError:
123
+ raise AttributeError(name) from None
124
+
125
+
126
+ def encode(fields: list[catalog.OpField], values: dict) -> bytes:
127
+ """A request's fixed part from values by field name (ints, bools, bytes, str, lists of ints / bools)."""
128
+ missing = [f.name for f in fields if f.name not in values]
129
+ extra = sorted(set(values) - {f.name for f in fields})
130
+ if missing or extra:
131
+ raise TypeError(f"fields {', '.join(f'{f.name}: {f.type_name}' for f in fields) or 'none'}"
132
+ + (f"; missing {missing}" if missing else "") + (f"; not fields {extra}" if extra else ""))
133
+ out = b""
134
+ for f in fields:
135
+ v = values[f.name]
136
+ if f.type & LIST:
137
+ items = list(v)
138
+ out += struct.pack("<H", len(items)) + b"".join(struct.pack(_INT[f.type & ~LIST], int(x)) for x in items)
139
+ elif f.type == T["bytes"]:
140
+ b = bytes(v)
141
+ out += struct.pack("<H", len(b)) + b
142
+ elif f.type == T["text"]:
143
+ b = str(v).encode()
144
+ out += struct.pack("<H", len(b)) + b
145
+ elif f.type in _INT:
146
+ out += struct.pack(_INT[f.type], int(v))
147
+ else:
148
+ raise TypeError(f"{f.name}: type {f.type} unknown to this host")
149
+ return out
150
+
151
+
152
+ def decode(fields: list[catalog.OpField], payload: bytes) -> tuple[dict, bytes]:
153
+ """A fixed part's fields by name, and what follows them (the TLVs)."""
154
+ out, at = {}, 0
155
+ for f in fields:
156
+ if f.type & LIST:
157
+ (n,) = struct.unpack_from("<H", payload, at)
158
+ fmt = _INT[f.type & ~LIST]
159
+ w = struct.calcsize(fmt)
160
+ vals = [struct.unpack_from(fmt, payload, at + 2 + i * w)[0] for i in range(n)]
161
+ out[f.name] = [bool(x) for x in vals] if (f.type & ~LIST) == T["bool"] else vals
162
+ at += 2 + n * w
163
+ elif f.type in (T["bytes"], T["text"]):
164
+ (n,) = struct.unpack_from("<H", payload, at)
165
+ if at + 2 + n > len(payload):
166
+ raise h.ShortPayload(f"{f.name}: {n} bytes, the answer ends before")
167
+ raw = payload[at + 2:at + 2 + n]
168
+ out[f.name] = raw.decode("utf-8", "replace") if f.type == T["text"] else raw
169
+ at += 2 + n
170
+ else:
171
+ fmt = _INT[f.type]
172
+ out[f.name] = struct.unpack_from(fmt, payload, at)[0]
173
+ if f.type == T["bool"]:
174
+ out[f.name] = bool(out[f.name])
175
+ at += struct.calcsize(fmt)
176
+ return out, payload[at:]
177
+
178
+
179
+ def ops(hst: h.Host, interface: str | int) -> dict[int, catalog.OpForm]:
180
+ """The op_name / op_field an interface's describe gives (by op number; empty when it gives none)."""
181
+ fn = interface if isinstance(interface, int) else _fn(hst, interface)
182
+ return catalog.decode_description(b"".join(catalog.tlv(t, v) for t, v in core.describe(hst, fn))).op_forms
183
+
184
+
185
+ def _fn(hst: h.Host, interface: str) -> int:
186
+ try:
187
+ return core.find(hst, interface)
188
+ except LookupError as e:
189
+ raise InterfaceMissing(str(e)) from None
190
+
191
+
192
+ def call(hst: h.Host, interface: str, op: int | str, payload: bytes | None = None, *, locked: bool | None = None,
193
+ revision: int | None = None, expect_ms: int = 0, **fields) -> CallResult:
194
+ """Run `op` (a number, or a name from the describe's op_name) of `interface` on the session `hst` holds.
195
+ payload: the request as bytes (then no fields); otherwise the fields by name, packed as op_field says.
196
+ locked: send it in the session (default: unless op_name says lock_free). revision: the one expected."""
197
+ fn = _fn(hst, interface)
198
+ rev = core.revision(hst, interface, fn)
199
+ if revision is not None and rev != revision:
200
+ raise RevisionMismatch(interface, rev, revision)
201
+ forms = ops(hst, fn)
202
+ if isinstance(op, str):
203
+ form = next((f for f in forms.values() if f.name == op), None)
204
+ if form is None:
205
+ raise OpMissing(f"{interface} has no op named {op!r}" + (f" (it names {', '.join(f.name for f in forms.values())})"
206
+ if forms else " (its describe names none)"))
207
+ code = form.op
208
+ else:
209
+ code, form = op, forms.get(op)
210
+ label = form.name if form else f"0x{code:02x}"
211
+ if not core.offers(hst, fn, code):
212
+ raise OpMissing(f"{interface} does not offer op {label}")
213
+ if payload is None:
214
+ if fields:
215
+ if form is None or not form.form:
216
+ raise TypeError(f"{interface} {label}: its describe gives no fields; pass payload= as bytes")
217
+ payload = encode(form.request, fields)
218
+ elif form is not None and form.form and form.request:
219
+ payload = encode(form.request, fields) # raises: the fields it needs
220
+ else:
221
+ payload = b""
222
+ elif fields:
223
+ raise TypeError("payload= or fields, not both")
224
+ if locked is None:
225
+ locked = not (form is not None and form.lock_free)
226
+ try:
227
+ r = hst.request(fn, code, payload, locked=locked, expect_ms=expect_ms)
228
+ except h.Rejected as e:
229
+ raise CommandRejected(label, e.result) from None
230
+ if r.succeeded:
231
+ got = decode(form.success, r.payload)[0] if form is not None and form.form else {}
232
+ return CallResult(got, r.payload, rev, form or code)
233
+ failed = None
234
+ if r.ran and form is not None and form.form and form.failed:
235
+ try:
236
+ failed = decode(form.failed, r.payload)[0]
237
+ except (struct.error, h.ShortPayload):
238
+ failed = None # a failed answer without its fields (a firmware bug)
239
+ raise CommandFailed(label, r, failed)
240
+
241
+
242
+ def call_wait(hst: h.Host, interface: str, op: int | str, payload: bytes | None = None, *, timeout: float,
243
+ wait: Callable[[float], None] | None = None, interval: float = 0.05, status: str = "status",
244
+ cancel: str = "cancel", revision: int | None = None, **fields) -> CallResult:
245
+ """Start the job `op` (as call()) and wait for it by its `status` op: the status's fields when it ended done.
246
+ wait(seconds) sleeps between the status reads (time.sleep by default; a stream's wait keeps a capture going)."""
247
+ boot = hst.clock().boot_id
248
+ started = call(hst, interface, op, payload, revision=revision, **fields)
249
+ code = started.op.op if isinstance(started.op, catalog.OpForm) else started.op
250
+ label = started.op.name if isinstance(started.op, catalog.OpForm) else f"0x{code:02x}"
251
+ pause = wait or time.sleep
252
+ deadline = time.monotonic() + timeout
253
+ while True:
254
+ try:
255
+ st = call(hst, interface, status)
256
+ except (h.OepError, OSError):
257
+ _restarted(hst, boot, label)
258
+ raise
259
+ if st["op"] != code or st["state"] == JOB_STATES["idle"]:
260
+ _restarted(hst, boot, label)
261
+ raise ProbeRestarted(f"{label}: the status names op {st['op']} in state {st['state']}, not this job")
262
+ if st["state"] != JOB_STATES["running"]:
263
+ break
264
+ if time.monotonic() >= deadline:
265
+ try:
266
+ call(hst, interface, cancel)
267
+ except (h.OepError, OSError):
268
+ pass # the timeout is the error to report
269
+ raise JobTimeout(label, timeout, dict(st))
270
+ pause(interval)
271
+ if st["state"] == JOB_STATES["failed"]:
272
+ raise JobFailed(label, dict(st))
273
+ if st["state"] == JOB_STATES["cancelled"]:
274
+ raise JobCancelled(label, dict(st))
275
+ return st
276
+
277
+
278
+ def _restarted(hst: h.Host, boot: int, label: str) -> None:
279
+ try:
280
+ now = hst.clock().boot_id
281
+ except (h.OepError, OSError):
282
+ return
283
+ if now != boot:
284
+ raise ProbeRestarted(f"{label}: the probe restarted while the job ran (boot_id {boot:#x} -> {now:#x})")
oep_client/catalog.py CHANGED
@@ -32,6 +32,13 @@ MIN_CLOCK_HZ = _COMMON["min_clock_hz"] # u32
32
32
  FEATURES = _COMMON["features"] # u32, bits defined by the interface
33
33
  CHANNEL_GROUP = _COMMON["channel_group"] # group(u8) n(u8) then n x (role(u8), channel(u16)): a fixed pin set
34
34
  OPS = _COMMON["ops"] # base(u8) bitmap: the ops this fn offers (core §1.2, §7.4)
35
+ OP_NAME = _COMMON["op_name"] # op(u8) flags(u8) name: an op's name (core §7.4, optional)
36
+ OP_FIELD = _COMMON["op_field"] # op(u8) part(u8) type(u8) name: one field of an op's fixed part (core §7.4)
37
+ OP_NAME_FORM = reg.CORE.enum["op_name_flag"]["form"]
38
+ OP_NAME_LOCK_FREE = reg.CORE.enum["op_name_flag"]["lock_free"]
39
+ OP_FIELD_PARTS = reg.CORE.enum["op_field_part"] # request / success / failed
40
+ OP_FIELD_TYPES = reg.CORE.enum["op_field_type"] # u8 ... text, and the list bit
41
+ OP_FIELD_LIST = OP_FIELD_TYPES["list"]
35
42
  assert OPS == m.TAG_OPS
36
43
  CRITICAL = 0x80
37
44
  INTERFACE_TAG_FIRST = 0x40
@@ -197,6 +204,50 @@ def text(tag: int, s: str) -> bytes:
197
204
  return tlv(tag, s.encode("ascii"))
198
205
 
199
206
 
207
+ class OpField:
208
+ """One field of an op's fixed part (core §7.4 op_field): its name and type (registry op_field_type)."""
209
+
210
+ def __init__(self, name: str, type: int):
211
+ self.name, self.type = name, type
212
+
213
+ @property
214
+ def type_name(self) -> str:
215
+ names = {v: k for k, v in OP_FIELD_TYPES.items() if k != "list"}
216
+ base = self.type & ~OP_FIELD_LIST
217
+ label = names.get(base, f"type{base}")
218
+ return label + "[]" if self.type & OP_FIELD_LIST else label
219
+
220
+ def __repr__(self) -> str:
221
+ return f"{self.name}: {self.type_name}"
222
+
223
+
224
+ @dataclass
225
+ class OpForm:
226
+ """An op's name and, with form, its request / success / failed fixed parts' fields (core §7.4)."""
227
+
228
+ op: int
229
+ name: str
230
+ flags: int
231
+ request: list[OpField] = field(default_factory=list)
232
+ success: list[OpField] = field(default_factory=list)
233
+ failed: list[OpField] = field(default_factory=list)
234
+
235
+ @property
236
+ def form(self) -> bool:
237
+ return bool(self.flags & OP_NAME_FORM)
238
+
239
+ @property
240
+ def lock_free(self) -> bool:
241
+ return bool(self.flags & OP_NAME_LOCK_FREE)
242
+
243
+ def signature(self) -> str:
244
+ """measure(mode: u8) -> temp: i32, hum: u32 (the name alone without form)"""
245
+ if not self.form:
246
+ return self.name
247
+ s = f"{self.name}({', '.join(map(repr, self.request))})"
248
+ return s + (" -> " + ", ".join(map(repr, self.success)) if self.success else "")
249
+
250
+
200
251
  @dataclass
201
252
  class Description:
202
253
  """Common tags decoded; interface-specific and unknown tags kept raw, in order."""
@@ -212,11 +263,13 @@ class Description:
212
263
  specific: list[tuple[int, bytes]] = field(default_factory=list)
213
264
  unknown_critical: list[int] = field(default_factory=list)
214
265
  broken: list[int] = field(default_factory=list) # common tags whose value is not their defined length (not used)
266
+ op_forms: dict[int, OpForm] = field(default_factory=dict) # op -> its op_name / op_field (core §7.4)
215
267
 
216
268
 
217
269
  def decode_description(data: bytes) -> Description:
218
270
  d = Description()
219
271
  seen: set[int] = set()
272
+ fields: list[tuple[int, int, int, str]] = [] # op_field (op, part, type, name), in the describe's order
220
273
  for tag, value in split_tlv(data):
221
274
  if tag & CRITICAL:
222
275
  continue # bit 7 in an answer: an unknown tag, skipped (core §2.2)
@@ -248,11 +301,28 @@ def decode_description(data: bytes) -> Description:
248
301
  d.ops_invalid = why
249
302
  else:
250
303
  d.ops = unpack_ops(value)
304
+ elif t == OP_NAME:
305
+ if len(value) < 3:
306
+ d.broken.append(tag)
307
+ continue
308
+ if value[0] in d.op_forms:
309
+ continue # a second op_name for an op: the first is used
310
+ d.op_forms[value[0]] = OpForm(value[0], value[2:].decode("ascii", "replace"), value[1])
311
+ elif t == OP_FIELD:
312
+ if len(value) < 4:
313
+ d.broken.append(tag)
314
+ continue
315
+ fields.append((value[0], value[1], value[2], value[3:].decode("ascii", "replace")))
251
316
  elif t >= INTERFACE_TAG_FIRST:
252
317
  d.specific.append((tag, value))
253
318
  elif tag & CRITICAL:
254
319
  d.unknown_critical.append(tag)
255
320
  # an unknown non-critical common tag is skipped
321
+ parts = {OP_FIELD_PARTS["request"]: "request", OP_FIELD_PARTS["success"]: "success", OP_FIELD_PARTS["failed"]: "failed"}
322
+ for op, part, type_, name in fields: # only an op named with form takes its fields
323
+ form = d.op_forms.get(op)
324
+ if form is not None and form.form and part in parts:
325
+ getattr(form, parts[part]).append(OpField(name, type_))
256
326
  for role in d.roles:
257
327
  d.roles[role] = sorted(set(d.roles[role]))
258
328
  if d.ops_invalid:
@@ -145,7 +145,7 @@ def checker_sources_sha256():
145
145
  for name in CHECKER_SOURCES}
146
146
 
147
147
 
148
- REVIEWED_CORE_SHA256 = '0408d232e728bb9692ab1c24cefa1fdbf109c35aeebd378034d86c0f4d19789a' # oep-spec docs/oep-core.ja.md
148
+ REVIEWED_CORE_SHA256 = '81cffc532f2626812b109d0aa5eef81636799011f410a8bc2fb9ea520fcbf4bc' # oep-spec docs/oep-core.ja.md
149
149
 
150
150
 
151
151
  def require_reviewed_contract(registry, spec=None):
@@ -8,7 +8,7 @@ import struct
8
8
  from . import ops, require, tlvs
9
9
  from .. import conformance as _helpers
10
10
 
11
- REVIEWED_CORE_SHA256 = '0408d232e728bb9692ab1c24cefa1fdbf109c35aeebd378034d86c0f4d19789a' # oep-spec docs/oep-core.ja.md
11
+ REVIEWED_CORE_SHA256 = '81cffc532f2626812b109d0aa5eef81636799011f410a8bc2fb9ea520fcbf4bc' # oep-spec docs/oep-core.ja.md
12
12
 
13
13
 
14
14
  class CoreSmokeChecks:
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
  import struct
5
5
  from . import require,spec_identity
6
6
 
7
- REVIEWED_CORE_SHA256='0408d232e728bb9692ab1c24cefa1fdbf109c35aeebd378034d86c0f4d19789a'
7
+ REVIEWED_CORE_SHA256='81cffc532f2626812b109d0aa5eef81636799011f410a8bc2fb9ea520fcbf4bc'
8
8
  CORE_OPS={'confirm':1,'list':2,'describe':3,'clock':4,'open':16,'end':17,'keepalive':18,'lock_state':19}
9
9
  # the settings' ops (core §13.5 / §13.7, transports §7.3): their numbers reviewed, their results not read here
10
10
  SETTINGS_OPS={'get':32,'set':33,'unset':34,'save':35,'state':36,'network':37}
@@ -9,8 +9,8 @@ from pathlib import Path
9
9
  from . import require,spec_identity
10
10
 
11
11
 
12
- REVIEWED={'docs/oep-core.ja.md':'0408d232e728bb9692ab1c24cefa1fdbf109c35aeebd378034d86c0f4d19789a',
13
- 'docs/oep-transports.ja.md':'2e99bf8039a3d8f5b55c80b66ac97ac40ddf7ad4711079177ffc947c5790103f'}
12
+ REVIEWED={'docs/oep-core.ja.md':'81cffc532f2626812b109d0aa5eef81636799011f410a8bc2fb9ea520fcbf4bc',
13
+ 'docs/oep-transports.ja.md':'a22754ee25997058be0e698bd62ea132537627914f38a06caffd48e3d79f096e'}
14
14
  QUICK_MS=1000 # core_quick_op_max_ms
15
15
 
16
16
 
@@ -12,11 +12,11 @@ from . import require, spec_identity
12
12
  REVIEWED = {
13
13
  'interfaces/oep-if-plan.ja.md': 'da8bd60af53499573c07bc33dbd8c4df93eb61a93b91dc57907f26620ae36bbf',
14
14
  'interfaces/oep-if-fixture.ja.md': '777a972349a14d1efa6e4575d3118b4e0566b81a6f22320401d529e4dca585f1',
15
- 'docs/oep-core.ja.md': '0408d232e728bb9692ab1c24cefa1fdbf109c35aeebd378034d86c0f4d19789a',
15
+ 'docs/oep-core.ja.md': '81cffc532f2626812b109d0aa5eef81636799011f410a8bc2fb9ea520fcbf4bc',
16
16
  'interfaces/oep-if-debug.ja.md': '6f21171b494e67d50bfc78b4da9e16949033c9e24d960efb42887335da021681',
17
17
  'interfaces/oep-if-capture.ja.md': 'f1035dc14cf469d929d782dbe710fc2c1e1567006da41a001abe3c5c25761324',
18
18
  }
19
- REVIEWED_REGISTRY_SHA256='82456b04874c4d20775744d2bde3398d7a748d955d73b8f295a5e8e889a46477'
19
+ REVIEWED_REGISTRY_SHA256='183922c36d78c0173cedece530410478dc3f1de0094d1922d3a960e752863d1e'
20
20
 
21
21
 
22
22
  def reviewed_identity(spec):
@@ -6,8 +6,8 @@ from .. import registry as _reg
6
6
  SAVE=_reg.CORE.op['save'] # fn 0's save (core §13.7)
7
7
 
8
8
 
9
- REVIEWED={'docs/oep-core.ja.md':'0408d232e728bb9692ab1c24cefa1fdbf109c35aeebd378034d86c0f4d19789a',
10
- 'docs/oep-transports.ja.md':'2e99bf8039a3d8f5b55c80b66ac97ac40ddf7ad4711079177ffc947c5790103f'}
9
+ REVIEWED={'docs/oep-core.ja.md':'81cffc532f2626812b109d0aa5eef81636799011f410a8bc2fb9ea520fcbf4bc',
10
+ 'docs/oep-transports.ja.md':'a22754ee25997058be0e698bd62ea132537627914f38a06caffd48e3d79f096e'}
11
11
  QUICK_MS=1000 # core_quick_op_max_ms
12
12
 
13
13
 
oep_client/dump.py CHANGED
@@ -179,7 +179,11 @@ def describe_offer(o: Offer) -> dict:
179
179
  if getattr(d, key) is not None:
180
180
  out[key] = getattr(d, key)
181
181
  if d.ops is not None:
182
- out["ops"] = interfaces.op_names(name, d.ops)
182
+ out["ops"] = [d.op_forms[op].name if op in d.op_forms else n
183
+ for op, n in zip(sorted(d.ops), interfaces.op_names(name, d.ops))]
184
+ forms = [f.signature() for op, f in sorted(d.op_forms.items()) if f.form]
185
+ if forms:
186
+ out["op_forms"] = forms
183
187
  if d.features is not None:
184
188
  out["features"] = _features(d.features, known.features if known else {})
185
189
  specific = {}
@@ -241,6 +245,8 @@ def to_text(caps: Capabilities) -> str:
241
245
  lines.append(f"{pad} " + ", ".join(limits))
242
246
  if "ops" in r:
243
247
  lines.append(f"{pad} ops: " + (", ".join(r["ops"]) or "none"))
248
+ for sig in r.get("op_forms", []):
249
+ lines.append(f"{pad} {sig}")
244
250
  if r.get("features"):
245
251
  lines.append(f"{pad} features: " + ", ".join(r["features"]))
246
252
  for k, v in r.get("declares", {}).items():
@@ -0,0 +1 @@
1
+ """pyserial URL handlers (serial.protocol_handler_packages.append("oep_client.pyserial")): oep:// - see protocol_oep."""