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 +1 -1
- oep_client/__main__.py +89 -0
- oep_client/arm.py +13 -0
- oep_client/call.py +284 -0
- oep_client/catalog.py +70 -0
- oep_client/conformance/__init__.py +1 -1
- oep_client/conformance/core_smoke.py +1 -1
- oep_client/conformance/host_core.py +1 -1
- oep_client/conformance/host_deadline.py +2 -2
- oep_client/conformance/host_requests.py +2 -2
- oep_client/conformance/host_wait.py +2 -2
- oep_client/dump.py +7 -1
- oep_client/pyserial/__init__.py +1 -0
- oep_client/pyserial/protocol_oep.py +92 -0
- oep_client/pytest_rp2040_peer.py +68 -0
- oep_client/registry.py +5 -5
- oep_client/rp2040.py +68 -0
- oep_client/rp2040_peer.py +358 -0
- oep_client/rp2350.py +11 -8
- oep_client/virtual_bench.py +14 -2
- {oep_client_python-0.0.52.dist-info → oep_client_python-0.0.54.dist-info}/METADATA +1 -1
- {oep_client_python-0.0.52.dist-info → oep_client_python-0.0.54.dist-info}/RECORD +25 -19
- {oep_client_python-0.0.52.dist-info → oep_client_python-0.0.54.dist-info}/WHEEL +0 -0
- {oep_client_python-0.0.52.dist-info → oep_client_python-0.0.54.dist-info}/entry_points.txt +0 -0
- {oep_client_python-0.0.52.dist-info → oep_client_python-0.0.54.dist-info}/licenses/LICENSE +0 -0
oep_client/__init__.py
CHANGED
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 = '
|
|
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 = '
|
|
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='
|
|
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':'
|
|
13
|
-
'docs/oep-transports.ja.md':'
|
|
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': '
|
|
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='
|
|
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':'
|
|
10
|
-
'docs/oep-transports.ja.md':'
|
|
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"] =
|
|
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."""
|