oep-client-python 0.0.36__py3-none-any.whl → 0.0.37__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.
Files changed (56) hide show
  1. oep_client/__init__.py +1 -1
  2. oep_client/__main__.py +171 -205
  3. oep_client/capture.py +74 -32
  4. oep_client/conformance.py +1 -1
  5. oep_client/conformance_capture_metadata.py +7 -10
  6. oep_client/conformance_host_request_sample.py +0 -2
  7. oep_client/conformance_host_requests.py +3 -9
  8. oep_client/conformance_host_wait.py +7 -7
  9. oep_client/conformance_host_wait_sample.py +6 -6
  10. oep_client/conformance_registered_ops.py +1 -2
  11. oep_client/conformance_selected_metadata.py +3 -9
  12. oep_client/core.py +24 -18
  13. oep_client/discovery.py +1 -1
  14. oep_client/dump.py +7 -3
  15. oep_client/endpoint.py +367 -328
  16. oep_client/hid_stream.py +10 -3
  17. oep_client/host.py +8 -2
  18. oep_client/interfaces.py +3 -7
  19. oep_client/link.py +21 -7
  20. oep_client/message.py +2 -2
  21. oep_client/pins.py +23 -21
  22. oep_client/registry.py +23 -27
  23. oep_client/riscv.py +8 -1
  24. oep_client/settings.py +570 -0
  25. oep_client/usb_stream.py +292 -20
  26. oep_client/virtual_analog_model.py +2 -2
  27. oep_client/virtual_bench.py +128 -58
  28. oep_client/virtual_bench_serve.py +3 -3
  29. oep_client/virtual_capture_group_model.py +1 -1
  30. oep_client/virtual_capture_metadata_model.py +3 -3
  31. oep_client/virtual_capture_timeline_model.py +1 -1
  32. oep_client/virtual_registered_ops_model.py +1 -1
  33. oep_client/virtual_selected_metadata_model.py +4 -4
  34. {oep_client_python-0.0.36.dist-info → oep_client_python-0.0.37.dist-info}/METADATA +18 -15
  35. {oep_client_python-0.0.36.dist-info → oep_client_python-0.0.37.dist-info}/RECORD +38 -55
  36. oep_client/config.py +0 -675
  37. oep_client/conformance_config.py +0 -139
  38. oep_client/conformance_config_execution.py +0 -84
  39. oep_client/conformance_config_execution_sample.py +0 -13
  40. oep_client/conformance_config_fallback.py +0 -72
  41. oep_client/conformance_config_fallback_sample.py +0 -25
  42. oep_client/conformance_config_paging.py +0 -87
  43. oep_client/conformance_config_paging_sample.py +0 -29
  44. oep_client/conformance_config_power_loss.py +0 -89
  45. oep_client/conformance_config_power_loss_sample.py +0 -15
  46. oep_client/conformance_config_sample.py +0 -13
  47. oep_client/conformance_config_storage.py +0 -79
  48. oep_client/conformance_config_storage_sample.py +0 -15
  49. oep_client/virtual_config_execution_model.py +0 -68
  50. oep_client/virtual_config_model.py +0 -176
  51. oep_client/virtual_config_paging_model.py +0 -12
  52. oep_client/virtual_config_power_loss_model.py +0 -77
  53. oep_client/virtual_config_storage_model.py +0 -20
  54. {oep_client_python-0.0.36.dist-info → oep_client_python-0.0.37.dist-info}/WHEEL +0 -0
  55. {oep_client_python-0.0.36.dist-info → oep_client_python-0.0.37.dist-info}/entry_points.txt +0 -0
  56. {oep_client_python-0.0.36.dist-info → oep_client_python-0.0.37.dist-info}/licenses/LICENSE +0 -0
oep_client/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.36"
5
+ __version__ = "0.0.37"
oep_client/__main__.py CHANGED
@@ -1,11 +1,12 @@
1
- """The oep command: what a probe offers (dump) and its settings (config).
1
+ """The oep command: what a probe offers (dump) and its settings (settings, core §13).
2
2
 
3
3
  oep dump --port /run/board-identify/by-id/<probe> oep dump --virtual p4-x035 --prefix oep.target --json
4
- oep config show <probe> (the settings and what the probe declares; lock-free)
5
- oep config state <probe> (the storage state; lock-free)
6
- oep config label <probe> 23 nrst oep config idle <probe> 20 pull-up
7
- oep config disable <probe> 3 4 (channels the probe never drives or sets up; remove disable CH re-enables)
8
- oep config remove <probe> idle 20 oep config save <probe> oep config erase <probe>
4
+ oep settings show <probe> (every value with its origin, the storage state; lock-free)
5
+ oep settings items <probe> (what each owner declares: names, forms, choices)
6
+ oep settings set <probe> core channel.GPIO23.label=nrst channel.GPIO20.idle=pull-up [--save]
7
+ oep settings unset <probe> core channel.GPIO20.idle oep settings unset --all <probe> --save (empties the storage)
8
+ oep settings save <probe> oep settings state <probe> oep settings network <probe>
9
+ oep settings export <probe> jig.oep-settings.json oep settings import <probe> jig.oep-settings.json [--save]
9
10
  oep find [--timeout 2] [--json] [--no-verify] (probes announcing _oep._tcp by DNS-SD over mDNS: unit_id, host,
10
11
  port, IP; each verified first by confirm + describe's unit_id, host guide
11
12
  §4.1 - one that fails is dropped with a warning)
@@ -20,7 +21,7 @@
20
21
  oep restart <probe> [--reopen-s S] (oep.probe.restart: the probe restarts; waits until it is back, its new boot_id)
21
22
 
22
23
  <probe>: a serial port, tcp://HOST[:PORT] (no port: the one DNS-SD finds), tcp:UNIT_ID (found by DNS-SD) or
23
- usb[:VID:PID[:SERIAL]]. A change takes the lock (owner "oep config") and
24
+ usb[:VID:PID[:SERIAL]]. A change takes the lock (owner "oep settings") and
24
25
  ends the session after it; it takes effect at once, and stays over a restart only after `save` (or --save).
25
26
  """
26
27
 
@@ -29,12 +30,10 @@ from __future__ import annotations
29
30
  import argparse
30
31
  import sys
31
32
 
32
- import getpass
33
33
  import json
34
34
  import os
35
- import struct
36
35
 
37
- from . import catalog, config, core, dump, virtual_bench, host, link
36
+ from . import catalog, core, dump, settings, virtual_bench, host, link
38
37
 
39
38
  USB_HINT = ("install the usb / hid extras (pip install 'oep-client-python[usb-async,hid]') so every way in to the USB "
40
39
  "device can be tried, or name the probe's port (a serial port such as /dev/ttyACM0 or COM3, tcp://HOST:PORT)")
@@ -56,7 +55,7 @@ def _open_host(target: str, **kw):
56
55
  def main(argv=None) -> int:
57
56
  parser = argparse.ArgumentParser(prog="oep", description="Open Embedded Probe: what a probe offers, its settings")
58
57
  sub = parser.add_subparsers(dest="command", required=True)
59
- _config_parser(sub)
58
+ _settings_parser(sub)
60
59
  sp = sub.add_parser("speed", help="port_speed (oep-if-link §3; this client's procedure): try faster rates on a UART bridge, "
61
60
  "print the report")
62
61
  sp.add_argument("probe", help="the probe's serial port (a UART bridge this host opens)")
@@ -104,7 +103,7 @@ def main(argv=None) -> int:
104
103
  pn.add_argument("--wire", choices=("swio", "rvswd", "swd"), default="swio")
105
104
  pn.add_argument("--steps", default="classify,hold,scan,identify,reset,labels",
106
105
  help="the steps to run, comma-separated (default all: classify, hold, scan, identify, reset, labels)")
107
- pn.add_argument("--save", action="store_true", help="write the suggested labels (config set, then save)")
106
+ pn.add_argument("--save", action="store_true", help="write the suggested labels (settings set, then save)")
108
107
  pn.add_argument("--name", help="the host's connection profile name: labels <name>.nrst / <name>.power_hi (a probe "
109
108
  "serving several targets; default: nrst / power_hi)")
110
109
  pn.add_argument("--json", action="store_true", help="the report as JSON (the steps' lines go to stderr)")
@@ -136,8 +135,8 @@ def main(argv=None) -> int:
136
135
  d.add_argument("--exact", action="store_true", help="the prefix is a whole name")
137
136
  d.add_argument("--json", action="store_true", help="machine-readable output")
138
137
  args = parser.parse_args(argv)
139
- if args.command == "config":
140
- return _config(args)
138
+ if args.command == "settings":
139
+ return _settings(args)
141
140
  if args.command == "speed":
142
141
  return _speed(args)
143
142
  if args.command == "linktest":
@@ -331,172 +330,114 @@ def _speed(args) -> int:
331
330
  return 0 if report.supported else 2
332
331
 
333
332
 
334
- # ---- oep config ----------------------------------------------------------------------------------------------------
333
+ # ---- oep settings ---------------------------------------------------------------------------------------------------
335
334
 
336
- def _config_parser(sub) -> None:
337
- c = sub.add_parser("config", help="the probe's settings (oep.probe.config): labels, idle, disable, pass-throughs, "
338
- "Wi-Fi / IPv4, its own settings, save")
335
+ def _settings_parser(sub) -> None:
336
+ c = sub.add_parser("settings", help="the probe's settings (core §13): show, set / unset by name, save, export / "
337
+ "import a settings file, the network")
339
338
  cs = c.add_subparsers(dest="action", required=True)
340
- show = cs.add_parser("show", help="the settings, what the probe declares, and the storage state")
339
+ show = cs.add_parser("show", help="every value (category, name, value, origin) and the storage state (lock-free)")
341
340
  show.add_argument("probe")
342
341
  show.add_argument("--json", action="store_true")
343
- state = cs.add_parser("state", help="the storage state (op state, lock-free)")
342
+ items = cs.add_parser("items", help="what the probe declares: each category's items, forms and choices")
343
+ items.add_argument("probe")
344
+ items.add_argument("--json", action="store_true")
345
+ st = cs.add_parser("set", help="values by name in one category: NAME=VALUE ... (core channel.GPIO2.label=nrst)")
346
+ st.add_argument("probe")
347
+ st.add_argument("category", help="core, or an interface's name/instance (oep.fixture.uart/0)")
348
+ st.add_argument("values", nargs="+", metavar="NAME=VALUE")
349
+ st.add_argument("--save", action="store_true")
350
+ un = cs.add_parser("unset", help="values by name back to their default (or none)")
351
+ un.add_argument("probe")
352
+ un.add_argument("category", nargs="?")
353
+ un.add_argument("names", nargs="*")
354
+ un.add_argument("--all", action="store_true", help="every value the user set (then --save empties the storage)")
355
+ un.add_argument("--save", action="store_true")
356
+ cs.add_parser("save", help="save the values now in effect").add_argument("probe")
357
+ state = cs.add_parser("state", help="the storage state (lock-free)")
344
358
  state.add_argument("probe")
345
359
  state.add_argument("--json", action="store_true")
346
- label = cs.add_parser("label", help="name a channel (a setting, read with config show)")
347
- label.add_argument("probe")
348
- label.add_argument("channel", type=int)
349
- label.add_argument("text")
350
- label.add_argument("--save", action="store_true")
351
- idle = cs.add_parser("idle", help="the state of a free pin")
352
- idle.add_argument("probe")
353
- idle.add_argument("channel", type=int)
354
- idle.add_argument("mode", choices=sorted(config.IDLE))
355
- idle.add_argument("--save", action="store_true")
356
- rest = cs.add_parser("rvswd-rest", help="how rvswd rests a port's SWCLK: auto (the probe decides from the target_id), "
357
- "or high / low fixed (the automatic choice off; debug §3.1)")
358
- rest.add_argument("probe")
359
- rest.add_argument("channel", type=int, help="the port's SWCLK channel")
360
- rest.add_argument("mode", choices=sorted(config.REST_MODE))
361
- rest.add_argument("--save", action="store_true")
362
- hold = cs.add_parser("wire-hold", help="whether a debug wire's lines stay held at their rest after a close "
363
- "(on, the default) or go free at once (off; debug §2)")
364
- hold.add_argument("probe")
365
- hold.add_argument("channel", type=int, help="the port's SWDIO channel")
366
- hold.add_argument("mode", choices=sorted(config.HOLD_MODE))
367
- hold.add_argument("--save", action="store_true")
368
- dis = cs.add_parser("disable", help="channels the probe never drives or sets up (not on this board)")
369
- dis.add_argument("probe")
370
- dis.add_argument("channels", type=int, nargs="+")
371
- dis.add_argument("--save", action="store_true")
372
- rm = cs.add_parser("remove", help="remove one item (unset): label CH, idle CH, disable CH")
373
- rm.add_argument("probe")
374
- rm.add_argument("kind", help="the item's name: label, idle, disable, passthrough, wifi, ... or a probe's own")
375
- rm.add_argument("channel", type=int)
376
- rm.add_argument("--save", action="store_true")
377
- wifi = cs.add_parser("wifi", help="a Wi-Fi network the probe tries, by entry (the passphrase asked, never shown)")
378
- wifi.add_argument("probe")
379
- wifi.add_argument("entry", type=int)
380
- wifi.add_argument("ssid")
381
- pw = wifi.add_mutually_exclusive_group()
382
- pw.add_argument("--open", action="store_true", help="a network without a passphrase")
383
- pw.add_argument("--keep", action="store_true", help="keep the entry's passphrase (pass_len 0xFF)")
384
- pw.add_argument("--passphrase-env", metavar="VAR", help="take the passphrase from this environment variable")
385
- wifi.add_argument("--save", action="store_true")
386
- ip = cs.add_parser("ipv4", help="how a netif gets its IPv4 address: dhcp, or a.b.c.d/prefix")
387
- ip.add_argument("probe")
388
- ip.add_argument("address", help="dhcp, or a.b.c.d/prefix")
389
- ip.add_argument("--netif", default="wifi", help="the netif's name (describe's netif list; default wifi)")
390
- ip.add_argument("--gateway")
391
- ip.add_argument("--dns")
392
- ip.add_argument("--save", action="store_true")
393
360
  net = cs.add_parser("network", help="the network now: the TCP port, each netif's state and address (lock-free)")
394
361
  net.add_argument("probe")
395
362
  net.add_argument("--json", action="store_true")
396
- pt = cs.add_parser("passthrough", help="a serial port carries a fixture UART's receive outside the OEP frames")
397
- pt.add_argument("probe")
398
- pt.add_argument("port", type=int, help="fn 0's transport index of a serial port")
399
- pt.add_argument("uart", help="the fixture UART: fn number or name#instance")
400
- pt.add_argument("rx", type=int, help="the RX channel")
401
- pt.add_argument("--baud", type=int, default=115200)
402
- pt.add_argument("--format", type=int, default=0)
403
- pt.add_argument("--save", action="store_true")
404
- cp = cs.add_parser("console-passthrough", help="a serial port carries the target's debug console outside the frames")
405
- cp.add_argument("probe")
406
- cp.add_argument("port", type=int, help="fn 0's transport index of a serial port")
407
- cp.add_argument("wire", help="the wire: fn number or name#instance (oep.wire.rvswd / oep.wire.swio)")
408
- cp.add_argument("swdio", type=int)
409
- cp.add_argument("swclk", type=int, nargs="?", default=0xFFFF)
410
- cp.add_argument("--console", required=True, help="the console: fn number or name#instance")
411
- cp.add_argument("--mechanism", required=True, help="the console's mechanism by name (sdi, dmdata, dmseq, ...)")
412
- cp.add_argument("--max-speed", type=int, default=1_000_000)
413
- cp.add_argument("--save", action="store_true")
414
- own = cs.add_parser("set", help="a probe's own setting by its name (reverse-DNS), its form from describe")
415
- own.add_argument("probe")
416
- own.add_argument("name")
417
- own.add_argument("value", help="text, a number (u32), 0 / 1 (bool), a.b.c.d (ipv4) or hex (bytes)")
418
- own.add_argument("--key", type=int, default=0)
419
- own.add_argument("--save", action="store_true")
420
- for name in ("save", "erase"):
421
- cs.add_parser(name, help=f"{name} the stored settings").add_argument("probe")
422
-
423
-
424
- def _change(hst, cfg, items, save: bool) -> None:
425
- core.take(hst, 3000, owner="oep config")
363
+ ex = cs.add_parser("export", help="write the user's values to a settings file (JSON; secrets left out)")
364
+ ex.add_argument("probe")
365
+ ex.add_argument("file", help="the file to write, - for stdout")
366
+ im = cs.add_parser("import", help="make the probe's user values a settings file's (shows what changes first)")
367
+ im.add_argument("probe")
368
+ im.add_argument("file")
369
+ im.add_argument("--yes", action="store_true", help="apply without asking")
370
+ im.add_argument("--save", action="store_true")
371
+ im.add_argument("--secret-env", action="append", default=[], metavar="CATEGORY:NAME=VAR",
372
+ help="a secret the file cannot carry, from an environment variable")
373
+
374
+
375
+ def _change(hst, fn, save: bool) -> None:
376
+ core.take(hst, 3000, owner="oep settings")
426
377
  try:
427
- gen = cfg.set(items)
428
- print(f"set: generation {gen}")
378
+ gen = fn()
379
+ if gen is not None:
380
+ print(f"set: generation {gen}")
429
381
  if save:
430
- if cfg.needs_save():
431
- print(f"saved: generation {cfg.save()}")
382
+ st = settings.Settings(hst)
383
+ if st.needs_save():
384
+ print(f"saved: generation {st.save()}")
432
385
  else:
433
386
  print("saved already (saved_generation is the current generation): not written")
434
387
  finally:
435
388
  hst.end()
436
389
 
437
390
 
438
- def _config(args) -> int:
391
+ def _pairs(texts: list[str]) -> dict:
392
+ out = {}
393
+ for t in texts:
394
+ name, eq, value = t.partition("=")
395
+ if not eq:
396
+ raise SystemExit(f"{t!r}: NAME=VALUE")
397
+ out[name] = value
398
+ return out
399
+
400
+
401
+ def _settings(args) -> int:
439
402
  hst = _open_host(args.probe)
440
403
  try:
441
- cfg = config.ProbeConfig(hst)
404
+ st = settings.Settings(hst)
442
405
  if args.action == "show":
443
- return _show(hst, cfg, args.json)
406
+ return _show(st, args.json)
407
+ if args.action == "items":
408
+ return _items(st, args.json)
444
409
  if args.action == "state":
445
- return _state(cfg, args.json)
446
- if args.action == "label":
447
- _change(hst, cfg, [config.Label(channel=args.channel, text=args.text)], args.save)
448
- elif args.action == "idle":
449
- _change(hst, cfg, [config.Idle(channel=args.channel, mode=args.mode)], args.save)
450
- elif args.action == "wire-hold":
451
- _change(hst, cfg, [config.WireHold(channel=args.channel, mode=args.mode)], args.save)
452
- elif args.action == "rvswd-rest":
453
- _change(hst, cfg, [config.RvswdRest(channel=args.channel, mode=args.mode)], args.save)
454
- elif args.action == "disable":
455
- _change(hst, cfg, [config.Disable(channel=ch) for ch in args.channels], args.save)
456
- elif args.action == "remove":
457
- _change(hst, cfg, [config.remove(args.kind, args.channel)], args.save)
458
- elif args.action == "wifi":
459
- if args.keep:
460
- passphrase = None
461
- elif args.open:
462
- passphrase = ""
463
- elif args.passphrase_env:
464
- passphrase = os.environ[args.passphrase_env]
410
+ return _state(st, args.json)
411
+ if args.action == "network":
412
+ return _network(st, args.json)
413
+ if args.action == "set":
414
+ values = _pairs(args.values)
415
+ _change(hst, lambda: st.set_named(args.category, values), args.save)
416
+ elif args.action == "unset":
417
+ if args.all:
418
+ _change(hst, st.unset_all, args.save)
465
419
  else:
466
- passphrase = getpass.getpass(f"passphrase for {args.ssid}: ")
467
- _change(hst, cfg, [config.Wifi(entry=args.entry, ssid=args.ssid, passphrase=passphrase)], args.save)
468
- elif args.action == "ipv4":
469
- address = None if args.address == "dhcp" else args.address
470
- _change(hst, cfg, [config.Ipv4(netif=args.netif, address=address, gateway=args.gateway, dns=args.dns)],
471
- args.save)
472
- elif args.action == "network":
473
- return _network(cfg, args.json)
474
- elif args.action == "passthrough":
475
- _change(hst, cfg, [config.Passthrough(port=args.port, fn=_fn(hst, args.uart), rx=args.rx, baud=args.baud,
476
- format=args.format)], args.save)
477
- elif args.action == "console-passthrough":
478
- from . import console as _console
479
- cfn = _fn(hst, args.console)
480
- names = [v.decode() for t, v in core.describe(hst, cfn) if t == _console.Console.TAG_MECHANISM]
481
- if args.mechanism not in names:
482
- raise SystemExit(f"the console declares no mechanism {args.mechanism} ({', '.join(names)})")
483
- mech = names.index(args.mechanism) + 1 # the place in describe's list, from 1 (core §7.7)
484
- _change(hst, cfg, [config.ConsolePassthrough(port=args.port, wire_fn=_fn(hst, args.wire), swdio=args.swdio,
485
- swclk=args.swclk, max_speed=args.max_speed, console_fn=cfn,
486
- mechanism=mech)], args.save)
487
- elif args.action == "set":
488
- _change(hst, cfg, [config.Own(args.name, _own_value(cfg, args.name, args.value), args.key)], args.save)
489
- else:
490
- core.take(hst, 3000, owner="oep config")
491
- try:
492
- if args.action == "save":
493
- print(f"saved: generation {cfg.save()}")
494
- else:
495
- cfg.erase()
496
- print("erased (the settings now in effect stay until the probe restarts)")
497
- finally:
498
- hst.end()
420
+ if not args.category or not args.names:
421
+ raise SystemExit("unset: CATEGORY NAME ..., or --all")
422
+ _change(hst, lambda: st.unset_named(args.category, args.names), args.save)
423
+ elif args.action == "save":
424
+ _change(hst, lambda: None, True)
425
+ elif args.action == "export":
426
+ doc = st.export()
427
+ text = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
428
+ if args.file == "-":
429
+ sys.stdout.write(text)
430
+ else:
431
+ with open(args.file, "w", encoding="utf-8") as f:
432
+ f.write(text)
433
+ n = sum(len(v) for v in doc["settings"].values())
434
+ print(f"wrote {n} values to {args.file}")
435
+ elif args.action == "import":
436
+ return _import(hst, st, args)
499
437
  return 0
438
+ except (LookupError, ValueError) as e:
439
+ print(f"settings: {e}", file=sys.stderr)
440
+ return 2
500
441
  except host.Rejected as e:
501
442
  print(f"refused: {e}", file=sys.stderr)
502
443
  return 2
@@ -504,32 +445,51 @@ def _config(args) -> int:
504
445
  hst.link.close()
505
446
 
506
447
 
507
- def _fn(hst, ref: str) -> int:
508
- """A fn by number, or by `name#instance` (host guide §5.3; #0 may be left out) resolved through list."""
509
- if ref.isdigit():
510
- return int(ref)
511
- name, _, instance = ref.partition("#")
512
- for e in core.list_entries(hst, name, exact=True):
513
- if e.instance == int(instance or 0):
514
- return e.fn
515
- raise SystemExit(f"no {ref} on this probe")
516
-
517
-
518
- def _own_value(cfg, name: str, text: str):
519
- """A probe's own setting's value from the command line, by the form its describe declares (probe.config §1.6)."""
520
- form = cfg.describe().by_name(name).form
521
- if form == config.FORM["u32"]:
522
- return int(text, 0)
523
- if form == config.FORM["bool"]:
524
- return text.lower() in ("1", "true", "on", "yes")
525
- if form == config.FORM["bytes"]:
526
- return bytes.fromhex(text)
527
- return text
448
+ def _import(hst, st, args) -> int:
449
+ with open(args.file, encoding="utf-8") as f:
450
+ doc = json.load(f)
451
+ plan = st.plan_import(doc)
452
+ cat = st.catalog()
453
+ secrets = {}
454
+ for spec in args.secret_env:
455
+ where, eq, var = spec.partition("=")
456
+ category, _, name = where.partition(":")
457
+ if not eq or not name:
458
+ raise SystemExit(f"--secret-env {spec!r}: CATEGORY:NAME=VAR")
459
+ secrets[(category, name)] = os.environ[var]
460
+ if plan.other_probe:
461
+ print(f"note: the file is of another probe ({doc.get('probe', {})})")
462
+ for category, flat in plan.unresolved:
463
+ print(f" not on this probe: {category} {flat}")
464
+ for fn, number, key, data in plan.sets:
465
+ it = cat.item(fn, number)
466
+ print(f" set {cat.category[fn]} {cat.flat(it, key)} = {json.dumps(cat.to_json(it, data))}")
467
+ for fn, number, key in plan.unsets:
468
+ it = cat.item(fn, number)
469
+ print(f" unset {cat.category[fn]} {cat.flat(it, key)}")
470
+ for category, flat in plan.secret_without_value:
471
+ if (category, flat) in secrets:
472
+ it, key = cat.resolve(category, flat)
473
+ plan.sets.append((it.fn, it.number, key, secrets[(category, flat)].encode()))
474
+ print(f" set {category} {flat} (secret, from the environment)")
475
+ else:
476
+ print(f" no value on the probe for secret {category} {flat}: give it with --secret-env")
477
+ if not plan.sets and not plan.unsets:
478
+ print("nothing to change")
479
+ if args.save:
480
+ _change(hst, lambda: None, True)
481
+ return 0
482
+ if not args.yes:
483
+ if input("apply? [y/N] ").strip().lower() not in ("y", "yes"):
484
+ print("not applied")
485
+ return 1
486
+ _change(hst, lambda: st.apply_import(plan), args.save)
487
+ return 0
528
488
 
529
489
 
530
- def _network(cfg, as_json: bool) -> int:
490
+ def _network(st, as_json: bool) -> int:
531
491
  """op network (lock-free): the TCP port and each netif's state."""
532
- net = cfg.network()
492
+ net = st.network()
533
493
  if as_json:
534
494
  print(json.dumps({"tcp_port": net.tcp_port, "netifs": [vars(n) for n in net.netifs]}, indent=2))
535
495
  return 0
@@ -543,41 +503,47 @@ def _network(cfg, as_json: bool) -> int:
543
503
  return 0
544
504
 
545
505
 
546
- def _plain(o):
547
- return {k: (v.hex() if isinstance(v, bytes) else v) for k, v in vars(o).items()} if hasattr(o, "__dict__") \
548
- else list(o)
549
-
550
-
551
- def _state(cfg, as_json: bool) -> int:
506
+ def _state(st, as_json: bool) -> int:
552
507
  """The storage state alone (op state, lock-free)."""
553
- st = cfg.state()
508
+ s = st.state()
554
509
  if as_json:
555
- print(json.dumps(vars(st), indent=2))
510
+ print(json.dumps(vars(s), indent=2))
556
511
  return 0
557
- why = f" ({st.reason})" if st.reason else ""
558
- print(f"storage: {st.storage}{why}, saved generation {st.saved_generation}")
512
+ why = f" ({s.reason})" if s.reason else ""
513
+ print(f"storage: {s.storage}{why}, saved generation {s.saved_generation}")
559
514
  return 0
560
515
 
561
516
 
562
- def _show(hst, cfg, as_json: bool) -> int:
563
- gen, _ = cfg.get()
564
- items = cfg.items()
565
- decl = cfg.describe()
566
- st = cfg.state() if cfg.offers(cfg.STATE) else None
517
+ def _show(st, as_json: bool) -> int:
518
+ gen, _ = st.get()
519
+ rows = st.show()
520
+ state = st.state() if st.offers("state") else None
567
521
  if as_json:
568
- out = {"generation": gen, "items": [dict(type=type(i).__name__, **_plain(i)) if hasattr(i, "__dict__")
569
- else _plain(i) for i in items],
570
- "declared": _plain(decl), "state": vars(st) if st else None}
571
- print(json.dumps(out, indent=2, default=lambda o: o.hex() if isinstance(o, bytes) else str(o)))
522
+ print(json.dumps({"generation": gen, "state": vars(state) if state else None,
523
+ "values": [dict(category=c, name=n, value=v, origin=o) for c, n, v, o in rows]}, indent=2))
572
524
  return 0
573
- if st is not None:
574
- why = f" ({st.reason})" if st.reason else ""
575
- print(f"storage: {st.storage}{why} ({decl.storage_bytes} bytes), saved generation {st.saved_generation}; "
576
- f"now {gen}")
525
+ if state is not None:
526
+ why = f" ({state.reason})" if state.reason else ""
527
+ print(f"storage: {state.storage}{why}, saved generation {state.saved_generation}; now {gen}")
577
528
  else:
578
529
  print(f"no storage; generation {gen}")
579
- for it in items:
580
- print(f" {it}")
530
+ for c, n, v, o in rows:
531
+ shown = "(secret)" if isinstance(v, dict) else json.dumps(v)
532
+ print(f" {c} {n} = {shown}" + ("" if o == "user" else f" ({o})"))
533
+ return 0
534
+
535
+
536
+ def _items(st, as_json: bool) -> int:
537
+ cat = st.catalog()
538
+ rows = [dict(category=cat.category[fn], number=it.number, name=it.name, form=it.form, key=it.key_kind,
539
+ keys=it.keys, max_len=it.max_len, secret=it.secret, choices=sorted(it.choices.values()))
540
+ for fn in sorted(cat.items) for it in cat.items[fn]]
541
+ if as_json:
542
+ print(json.dumps(rows, indent=2))
543
+ return 0
544
+ for r in rows:
545
+ extra = f" {'|'.join(r['choices'])}" if r["choices"] else ""
546
+ print(f" {r['category']} {r['name']} {r['form']}{extra}" + (" secret" if r["secret"] else ""))
581
547
  return 0
582
548
 
583
549
 
oep_client/capture.py CHANGED
@@ -368,7 +368,9 @@ class CaptureDrops:
368
368
  no room for - then the way the data goes (1 the zero-copy stages, 0 the store), the stages held, and the memory a
369
369
  capture draws on: internal RAM's and DMA-capable internal RAM's largest free blocks, PSRAM's total (0: none); then
370
370
  the most the stream's buffer (the DMA ring, in PSRAM on a P4) held not yet sent since start (bytes; None from a
371
- firmware before it)."""
371
+ firmware before it); then the spill behind the ring: the most it held since start and its size (bytes, 0 0: none;
372
+ None from a firmware before it); then every byte the stream dropped since start, whatever the way (the copied way's
373
+ segments with no room to send too; None from a firmware before it)."""
372
374
  no_stage_bytes: int
373
375
  unqueued_bytes: int
374
376
  dma_overruns: int
@@ -379,14 +381,19 @@ class CaptureDrops:
379
381
  dma_largest: int
380
382
  psram_total: int
381
383
  buffer_peak: int | None = None
384
+ spill_peak: int | None = None
385
+ spill_bytes: int | None = None
386
+ dropped_bytes: int | None = None
382
387
 
383
- FORMAT = "<IIIIBBIIII"
388
+ FORMAT = "<IIIIBBIIIIIIQ"
389
+ FORMAT_BEFORE_DROPPED = "<IIIIBBIIIIII" # a firmware before dropped_bytes (until 2026-10-21)
390
+ FORMAT_BEFORE_SPILL = "<IIIIBBIIII" # a firmware before the spill (until 2026-10-20)
384
391
  FORMAT_BEFORE_PEAK = "<IIIIBBIII" # a firmware before buffer_peak (until 2026-10-20)
385
392
 
386
393
  @classmethod
387
394
  def unpack(cls, data: bytes) -> "CaptureDrops | None":
388
395
  """-> its fields, or None for data of another length (a form this host does not know)."""
389
- for form in (cls.FORMAT, cls.FORMAT_BEFORE_PEAK):
396
+ for form in (cls.FORMAT, cls.FORMAT_BEFORE_DROPPED, cls.FORMAT_BEFORE_SPILL, cls.FORMAT_BEFORE_PEAK):
390
397
  if len(data) == struct.calcsize(form):
391
398
  f = struct.unpack(form, data)
392
399
  return cls(*f[:4], bool(f[4]), *f[5:])
@@ -606,35 +613,8 @@ class LogicCapture(Interface):
606
613
  got = into or Received()
607
614
  deadline = time.monotonic() + seconds if seconds is not None else None
608
615
  kept = getattr(got, "_kept", None) or time.monotonic() # on from the last call with the same `into`: a host
609
- expect_seq = getattr(got, "_seq", None) # calling in short slices still keeps the lease
610
- event_seqs = getattr(got, "_events", set()) # events share the fn's seq (they stay on the link for the caller)
611
- while True:
612
- for e in link.events:
613
- if struct.unpack_from("<H", e, 1)[0] == self.fn:
614
- event_seqs.add(struct.unpack_from("<H", e, 3)[0])
615
- for seq, position, data, generation, info in take_pushes(link, self.fn):
616
- while expect_seq is not None and expect_seq != seq:
617
- if expect_seq in event_seqs:
618
- event_seqs.discard(expect_seq)
619
- else:
620
- got.seq_lost += 1
621
- expect_seq = (expect_seq + 1) & 0xFFFF
622
- expect_seq = (seq + 1) & 0xFFFF
623
- if generation is not None and self.generation is not None and generation != self.generation:
624
- got.stale += 1 # the generation before: not this capture's bytes
625
- continue
626
- if info is not None:
627
- got.segments[info.position] = info # the latest: samples grow while it is taken
628
- if got.start is None:
629
- got.start = position
630
- else:
631
- expected = got.start + len(got.data) + sum(n for _, n in got.gaps) # u64 positions: no wrap
632
- skipped = position - expected
633
- if skipped:
634
- got.gaps.append((len(got.data), skipped))
635
- got.data += data
636
- got.frames += 1
637
- got._seq, got._events = expect_seq, event_seqs
616
+ while True: # calling in short slices still keeps the lease
617
+ self._collect(link, got)
638
618
  if keepalive_s and time.monotonic() - kept >= keepalive_s:
639
619
  self.host.keepalive()
640
620
  kept = time.monotonic()
@@ -645,6 +625,37 @@ class LogicCapture(Interface):
645
625
  return got
646
626
  link.pump(0.02, until_one=True)
647
627
 
628
+ def _collect(self, link, got: Received) -> None:
629
+ """The pushes of this fn waiting on the link into `got` (stream()'s rules: seq, generation, gaps, segments)."""
630
+ expect_seq = getattr(got, "_seq", None)
631
+ event_seqs = getattr(got, "_events", set()) # events share the fn's seq (they stay on the link for the caller)
632
+ for e in link.events:
633
+ if struct.unpack_from("<H", e, 1)[0] == self.fn:
634
+ event_seqs.add(struct.unpack_from("<H", e, 3)[0])
635
+ for seq, position, data, generation, info in take_pushes(link, self.fn):
636
+ while expect_seq is not None and expect_seq != seq:
637
+ if expect_seq in event_seqs:
638
+ event_seqs.discard(expect_seq)
639
+ else:
640
+ got.seq_lost += 1
641
+ expect_seq = (expect_seq + 1) & 0xFFFF
642
+ expect_seq = (seq + 1) & 0xFFFF
643
+ if generation is not None and self.generation is not None and generation != self.generation:
644
+ got.stale += 1 # the generation before: not this capture's bytes
645
+ continue
646
+ if info is not None:
647
+ got.segments[info.position] = info # the latest: samples grow while it is taken
648
+ if got.start is None:
649
+ got.start = position
650
+ else:
651
+ expected = got.start + len(got.data) + sum(n for _, n in got.gaps) # u64 positions: no wrap
652
+ skipped = position - expected
653
+ if skipped:
654
+ got.gaps.append((len(got.data), skipped))
655
+ got.data += data
656
+ got.frames += 1
657
+ got._seq, got._events = expect_seq, event_seqs
658
+
648
659
  def finish(self, link, got: Received, timeout: float = 5.0) -> Received:
649
660
  """Streaming, after stop() or an error stop: collect the pushes still to come, up to the last byte captured
650
661
  (status's write position), or until `timeout`. In state 6 the bytes at or past write_pos are dropped (§2.2)."""
@@ -1210,6 +1221,37 @@ class CaptureGroup(Interface):
1210
1221
  self.stale_events += len(got) - len(out)
1211
1222
  return out
1212
1223
 
1224
+ def stream(self, link, tracks: list[LogicCapture], *, seconds: float | None = None, nbytes: int | None = None,
1225
+ into: dict[int, Received] | None = None, keepalive_s: float = 1.0) -> dict[int, Received]:
1226
+ """Streaming tracks of this group (§4.1: all in mode 3, each subscribed): collect every track's pushes together,
1227
+ each into its own Received by fn (LogicCapture.stream()'s rules per track), until each has `nbytes` or
1228
+ `seconds` have passed (at least one is needed). The lock is kept alive every `keepalive_s`."""
1229
+ if seconds is None and nbytes is None:
1230
+ raise ValueError("stream() needs seconds or nbytes")
1231
+ got = into if into is not None else {}
1232
+ for t in tracks:
1233
+ got.setdefault(t.fn, Received())
1234
+ deadline = time.monotonic() + seconds if seconds is not None else None
1235
+ kept = getattr(self, "_kept", None) or time.monotonic()
1236
+ while True:
1237
+ for t in tracks:
1238
+ t._collect(link, got[t.fn])
1239
+ if keepalive_s and time.monotonic() - kept >= keepalive_s:
1240
+ self.host.keepalive()
1241
+ kept = time.monotonic()
1242
+ self._kept = kept
1243
+ if nbytes is not None and all(len(got[t.fn].data) >= nbytes for t in tracks):
1244
+ return got
1245
+ if deadline is not None and time.monotonic() >= deadline:
1246
+ return got
1247
+ link.pump(0.02, until_one=True)
1248
+
1249
+ def finish(self, link, tracks: list[LogicCapture], got: dict[int, Received], timeout: float = 5.0) -> dict[int, Received]:
1250
+ """Streaming, after stop(): each track's pushes still to come (LogicCapture.finish() per track)."""
1251
+ for t in tracks:
1252
+ t.finish(link, got.setdefault(t.fn, Received()), timeout)
1253
+ return got
1254
+
1213
1255
  def wait(self, timeout: float = 5.0, keepalive_s: float = 1.0) -> GroupStatus:
1214
1256
  """Poll until every track is done (one-shot). The lock is kept alive every `keepalive_s` while it waits."""
1215
1257
  deadline = time.monotonic() + timeout