oep-client-python 0.0.53__py3-none-any.whl → 0.0.55__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/brokers.py +149 -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/gdb_server.py +528 -0
- oep_client/host.py +4 -1
- oep_client/ide.py +341 -0
- oep_client/link.py +27 -4
- oep_client/pyserial/protocol_oep.py +2 -2
- oep_client/pytest_plugin.py +433 -0
- oep_client/registry.py +5 -5
- oep_client/{rp2040_peer.py → rp2040_swd.py} +11 -77
- oep_client/virtual_bench.py +15 -3
- oep_client/virtual_bench_mdns.py +9 -4
- {oep_client_python-0.0.53.dist-info → oep_client_python-0.0.55.dist-info}/METADATA +13 -1
- {oep_client_python-0.0.53.dist-info → oep_client_python-0.0.55.dist-info}/RECORD +27 -23
- {oep_client_python-0.0.53.dist-info → oep_client_python-0.0.55.dist-info}/entry_points.txt +3 -0
- oep_client/pytest_rp2040_peer.py +0 -68
- {oep_client_python-0.0.53.dist-info → oep_client_python-0.0.55.dist-info}/WHEEL +0 -0
- {oep_client_python-0.0.53.dist-info → oep_client_python-0.0.55.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/brokers.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Finding a broker that serves a probe, and opening a session that shares its lock (OEP host guide §7.1, §7.2).
|
|
2
|
+
|
|
3
|
+
A broker publishes itself in the user's directory `oep/brokers/<unit_id>.json`: where to connect (`endpoints`, each with
|
|
4
|
+
a `kind`; `tcp` is transports §1's TCP form) and the sharing extensions it takes (`share`). A tool tries, in order:
|
|
5
|
+
|
|
6
|
+
1. the environment variable `OEP_BROKER` (`<host>:<port>`), for a broker on another machine or a test;
|
|
7
|
+
2. the file: each endpoint of a kind it knows, checking that fn 0's describe names the file's unit_id (a dead broker's
|
|
8
|
+
port may now be another probe's broker);
|
|
9
|
+
3. the probe itself.
|
|
10
|
+
|
|
11
|
+
The tool joins with the sharing extension `oep.probe.share` (critical): sessions and resources are the client's, the
|
|
12
|
+
broker holds the probe's lock and lease. A tool never deletes a broker's files: only the broker holding
|
|
13
|
+
`<unit_id>.lock` removes or rewrites `<unit_id>.json`, and `<unit_id>.lock` stays.
|
|
14
|
+
|
|
15
|
+
from oep_client import brokers
|
|
16
|
+
hst = brokers.open_host("30eda0e31108", owner="my-tool") # through the broker when one serves it, else directly
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
SHARE = "oep.probe.share" # host guide §7.1
|
|
30
|
+
ENV = "OEP_BROKER" # host guide §7.2: <host>:<port>, before the file
|
|
31
|
+
LEASE_MS = 60000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def directory() -> Path:
|
|
35
|
+
"""The user's broker directory (host guide §7.2): Linux $XDG_RUNTIME_DIR/oep/brokers, else
|
|
36
|
+
$TMPDIR/oep-<uid>/brokers (/tmp without TMPDIR); macOS $TMPDIR/oep/brokers; Windows %LOCALAPPDATA%\\oep\\brokers."""
|
|
37
|
+
if sys.platform == "win32":
|
|
38
|
+
return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "oep" / "brokers"
|
|
39
|
+
if sys.platform == "darwin":
|
|
40
|
+
return Path(os.environ.get("TMPDIR") or tempfile.gettempdir()) / "oep" / "brokers"
|
|
41
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
42
|
+
if runtime:
|
|
43
|
+
return Path(runtime) / "oep" / "brokers"
|
|
44
|
+
return Path(os.environ.get("TMPDIR") or "/tmp") / f"oep-{os.getuid()}" / "brokers"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _private(path: Path) -> bool:
|
|
48
|
+
"""The directory is the user's own and closed to others (0700); not checked on Windows (its ACLs)."""
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
return path.is_dir()
|
|
51
|
+
try:
|
|
52
|
+
st = path.stat()
|
|
53
|
+
except OSError:
|
|
54
|
+
return False
|
|
55
|
+
return stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and stat.S_IMODE(st.st_mode) & 0o077 == 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Broker:
|
|
60
|
+
"""One broker as its file (or OEP_BROKER) names it."""
|
|
61
|
+
unit_id: str
|
|
62
|
+
endpoints: list[dict] = field(default_factory=list)
|
|
63
|
+
share: list[str] = field(default_factory=list)
|
|
64
|
+
pid: int | None = None
|
|
65
|
+
implementation: dict = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
def tcp(self) -> list[tuple[str, int]]:
|
|
68
|
+
"""The endpoints this client can use: kind tcp, `host:port`. Other kinds are skipped."""
|
|
69
|
+
out = []
|
|
70
|
+
for e in self.endpoints:
|
|
71
|
+
if isinstance(e, dict) and e.get("kind") == "tcp" and isinstance(e.get("address"), str):
|
|
72
|
+
host, _, port = e["address"].rpartition(":")
|
|
73
|
+
if host and port.isdigit():
|
|
74
|
+
out.append((host, int(port)))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find(unit_id: str, *, environ=None, base: Path | None = None) -> Broker | None:
|
|
79
|
+
"""The broker for this probe: OEP_BROKER, else its file in the user's directory, else None. Not connected yet."""
|
|
80
|
+
environ = os.environ if environ is None else environ
|
|
81
|
+
unit = unit_id.lower()
|
|
82
|
+
forced = environ.get(ENV)
|
|
83
|
+
if forced:
|
|
84
|
+
return Broker(unit, [{"kind": "tcp", "address": forced}], [SHARE])
|
|
85
|
+
if unit.startswith("x-"):
|
|
86
|
+
return None # not unique: never keyed by (core §7.5)
|
|
87
|
+
folder = base or directory()
|
|
88
|
+
if not _private(folder):
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
doc = json.loads((folder / f"{unit}.json").read_text(encoding="utf-8"))
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
return None
|
|
94
|
+
if not isinstance(doc, dict) or str(doc.get("unit_id", "")).lower() != unit:
|
|
95
|
+
return None
|
|
96
|
+
share = [s for s in doc.get("share") or [] if isinstance(s, str)]
|
|
97
|
+
endpoints = [e for e in doc.get("endpoints") or [] if isinstance(e, dict)]
|
|
98
|
+
pid = doc.get("pid") if isinstance(doc.get("pid"), int) else None
|
|
99
|
+
impl = doc.get("implementation") if isinstance(doc.get("implementation"), dict) else {}
|
|
100
|
+
return Broker(unit, endpoints, share, pid, impl)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def connect(broker: Broker, *, timeout: float = 3.0):
|
|
104
|
+
"""A Host on the first endpoint that answers with the broker's unit_id in fn 0's describe, not opened; None when
|
|
105
|
+
none does (a dead broker, or its port reused by another probe's broker)."""
|
|
106
|
+
from . import core, host as oh, link
|
|
107
|
+
for address, port in broker.tcp():
|
|
108
|
+
try:
|
|
109
|
+
lk = link.SerialLink.on_stream(link.TcpStream(address, port), "length", timeout)
|
|
110
|
+
except OSError:
|
|
111
|
+
continue
|
|
112
|
+
lk.resend = False # the broker retries towards the probe itself (link.open_host)
|
|
113
|
+
lk.transport = "tcp"
|
|
114
|
+
h = oh.Host(lk.send)
|
|
115
|
+
lk.attach_host(h)
|
|
116
|
+
h.link = lk
|
|
117
|
+
try:
|
|
118
|
+
same = (core.unit_id(h) or "").lower() == broker.unit_id
|
|
119
|
+
except Exception:
|
|
120
|
+
same = False
|
|
121
|
+
if same:
|
|
122
|
+
return h
|
|
123
|
+
lk.close()
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def open_host(unit_id: str, owner: str, *, lease_ms: int = LEASE_MS, direct: str | None = None, environ=None):
|
|
128
|
+
"""An opened Host on the probe: sharing the broker's lock when one serves it (host guide §7.2), else the probe
|
|
129
|
+
itself (`direct`, default oep://<unit_id>). A broker that does not take oep.probe.share is used without it."""
|
|
130
|
+
from . import host as oh, link, message as m
|
|
131
|
+
broker = find(unit_id, environ=environ)
|
|
132
|
+
h = connect(broker) if broker else None
|
|
133
|
+
if h is not None:
|
|
134
|
+
try:
|
|
135
|
+
if SHARE in broker.share:
|
|
136
|
+
try:
|
|
137
|
+
h.open(lease_ms, owner=owner, extensions=[SHARE])
|
|
138
|
+
return h
|
|
139
|
+
except oh.Unsupported as e:
|
|
140
|
+
if e.tag != m.TAG_NAMED_EXTENSION | m.TAG_CRITICAL:
|
|
141
|
+
raise
|
|
142
|
+
h.open(lease_ms, owner=owner)
|
|
143
|
+
return h
|
|
144
|
+
except Exception:
|
|
145
|
+
h.link.close()
|
|
146
|
+
raise
|
|
147
|
+
h = link.open_host(direct or f"oep://{unit_id}")
|
|
148
|
+
h.open(lease_ms, owner=owner)
|
|
149
|
+
return h
|
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):
|