oep-client-python 0.0.27__py3-none-any.whl → 0.0.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oep_client/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.27"
5
+ __version__ = "0.0.28"
oep_client/__main__.py CHANGED
@@ -12,6 +12,8 @@
12
12
  oep speed <probe> [--candidates 921600,500000] [--verify [--flows in:2,out:2]] (port_speed, core §3.5 and the host
13
13
  guide §7: try the candidates in order on a UART bridge, report)
14
14
  oep linktest <probe> --rates now,921600 --patterns in,out,duplex --inflight 1,2 --sizes 128,496 --frames 300
15
+ oep pins <probe> --power 5 --wire swio (find the target's debug pins and reset line: classify the channels, scan,
16
+ identify, hold-low + attach under reset; prints a slot, --save writes it)
15
17
 
16
18
  <probe>: a serial port, tcp://HOST:PORT or usb[:VID:PID[:SERIAL]]. A change takes the lock (owner "oep config") and
17
19
  ends the session after it; it takes effect at once, and stays over a restart only after `save` (or --save).
@@ -63,6 +65,19 @@ def main(argv=None) -> int:
63
65
  lt.add_argument("--baud", type=int, default=link.BASE_BAUD, help="the boot speed to open at (default 115200)")
64
66
  lt.add_argument("--low-latency", choices=("on", "off"), default="on", help="the serial driver's low-latency mode")
65
67
  lt.add_argument("--json", action="store_true", help="one JSON object per cell")
68
+ pn = sub.add_parser("pins", help="find where a target is wired: classify the channels, scan the wire, find the "
69
+ "reset line, suggest a slot (writes nothing without --save)")
70
+ pn.add_argument("probe", help="a probe: a serial port, tcp://HOST:PORT or usb[:VID:PID[:SERIAL]]")
71
+ pn.add_argument("--power", type=int, help="the gpio channel that powers the target: switched off, then on, to "
72
+ "see which channels follow it (never touched without this)")
73
+ pn.add_argument("--exclude", default="", help="channels never to read, scan or hold, comma-separated")
74
+ pn.add_argument("--wire", choices=("swio", "rvswd", "swd"), default="swio")
75
+ pn.add_argument("--steps", default="classify,hold,scan,identify,reset,slot",
76
+ help="the steps to run, comma-separated (default all: classify, hold, scan, identify, reset, slot)")
77
+ pn.add_argument("--save", action="store_true", help="write the suggested slot (config set, then save)")
78
+ pn.add_argument("--slot", type=int, default=0, help="--save: the slot number (default 0)")
79
+ pn.add_argument("--name", help="--save: the slot's name (default: the target family, else 'target')")
80
+ pn.add_argument("--json", action="store_true", help="the report as JSON (the steps' lines go to stderr)")
66
81
  d = sub.add_parser("dump", help="list and describe every interface a probe offers")
67
82
  src = d.add_mutually_exclusive_group(required=True)
68
83
  src.add_argument("--fake", choices=sorted(fake.PROFILES), help="in-process example probe")
@@ -77,6 +92,8 @@ def main(argv=None) -> int:
77
92
  return _speed(args)
78
93
  if args.command == "linktest":
79
94
  return _linktest(args)
95
+ if args.command == "pins":
96
+ return _pins_cmd(args)
80
97
 
81
98
  if args.fake:
82
99
  call = fake.PROFILES[args.fake]().call
@@ -88,6 +105,33 @@ def main(argv=None) -> int:
88
105
  return 0
89
106
 
90
107
 
108
+ # ---- oep pins -----------------------------------------------------------------------------------------------------
109
+
110
+ def _pins_cmd(args) -> int:
111
+ from . import pins
112
+ exclude = [int(x, 0) for x in args.exclude.replace(" ", "").split(",") if x]
113
+ say = (lambda t: print(t, file=sys.stderr, flush=True)) if args.json else (lambda t: print(t, flush=True)) # noqa: E731
114
+ hst = link.open_host(args.probe)
115
+ try:
116
+ core.take(hst, 10000, owner="oep pins")
117
+ finder = pins.PinFinder(hst, wire=args.wire, power=args.power, exclude=exclude, say=say, probe=args.probe,
118
+ save=args.save, slot=args.slot, name=args.name)
119
+ report = finder.run(steps=tuple(s for s in args.steps.split(",") if s))
120
+ say(f"done in {report.elapsed_s:.1f} s")
121
+ if args.json:
122
+ print(json.dumps(report.as_dict(), indent=2))
123
+ try:
124
+ hst.end()
125
+ except host.OepError:
126
+ pass
127
+ except host.Rejected as e:
128
+ print(f"refused: {e}", file=sys.stderr)
129
+ return 2
130
+ finally:
131
+ hst.link.close()
132
+ return 0 if report.found else 1
133
+
134
+
91
135
  # ---- oep linktest -------------------------------------------------------------------------------------------------
92
136
 
93
137
  def _linktest(args) -> int:
oep_client/config.py CHANGED
@@ -73,14 +73,23 @@ class Label:
73
73
 
74
74
  @dataclass(kw_only=True)
75
75
  class Idle:
76
+ """The state of a channel no plan or connection uses (probe.config §1): at boot and after every release. mode:
77
+ hi-z, pull-up, pull-down, output-low, output-high (output_low / output_high too). An output mode keeps driving that
78
+ level while the channel is free - a target's power switch kept on - and a gpio plan that takes the channel keeps
79
+ it until its first set (fixture §1); a probe that cannot drive the channel refuses it unsupported."""
76
80
  channel: int
77
- mode: str = "pull-up" # hi-z, pull-up, pull-down
81
+ mode: str = "pull-up" # hi-z, pull-up, pull-down, output-low, output-high
78
82
  TAG = ITEM["idle"]
79
83
 
84
+ def __post_init__(self):
85
+ self.mode = self.mode.replace("_", "-")
86
+
80
87
  def key(self) -> tuple:
81
88
  return (self.channel,)
82
89
 
83
90
  def value(self) -> bytes:
91
+ if self.mode not in IDLE:
92
+ raise ValueError(f"idle mode {self.mode!r}: one of {', '.join(IDLE)}")
84
93
  return struct.pack("<HB", self.channel, IDLE[self.mode])
85
94
 
86
95
 
@@ -395,3 +404,50 @@ class ProbeConfig(Interface):
395
404
  return st
396
405
  first_slot += n_slots
397
406
  first_bind += n_binds
407
+
408
+
409
+ # The label names of a target's power and reset lines (host-development-guide §8.1): `nrst` its reset, `power_hi` high
410
+ # powers it, `power_lo` low powers it. On a probe with several slots they are `<slot name>.<name>`.
411
+ LINE_NAMES = ("nrst", "power_hi", "power_lo")
412
+
413
+
414
+ class AmbiguousLine(LookupError):
415
+ """find_line could not tell which channel is meant; `candidates` holds (label text, channel)."""
416
+
417
+ def __init__(self, message: str, candidates: list[tuple[str, int]]):
418
+ super().__init__(message)
419
+ self.candidates = candidates
420
+
421
+
422
+ def find_line(hst_or_items, name: str, slot: int | str | None = None) -> int | None:
423
+ """The channel labelled for `name` (nrst, power_hi, power_lo: host-development-guide §8.1), or None when there is
424
+ none. hst_or_items: a Host (its settings are read: `ProbeConfig.items()`, no lock) or the decoded items. slot: the
425
+ slot's name or number; None picks the probe's one slot.
426
+
427
+ `<slot>.<name>` first, then the bare `name` - the bare name only when the settings hold at most one slot (a bare
428
+ name is for a one-slot probe). With several slots and no `slot`, or two channels with the same label, it raises
429
+ AmbiguousLine listing the candidates."""
430
+ items = hst_or_items if isinstance(hst_or_items, (list, tuple)) else ProbeConfig(hst_or_items).items()
431
+ labels = [(it.text, it.channel) for it in items if isinstance(it, Label)]
432
+ slots = [it for it in items if isinstance(it, Slot)]
433
+ if isinstance(slot, int):
434
+ named = [s.name for s in slots if s.slot == slot]
435
+ if not named:
436
+ raise LookupError(f"no slot {slot} in the probe's settings")
437
+ slot = named[0]
438
+ if slot is None and len(slots) > 1:
439
+ candidates = sorted((t, c) for t, c in labels if t == name or t.endswith("." + name))
440
+ if candidates:
441
+ listed = ", ".join(f"{t} (channel {c})" for t, c in candidates)
442
+ raise AmbiguousLine(f"{name}: {len(slots)} slots and no slot given; candidates: {listed}", candidates)
443
+ return None
444
+ if slot is None and slots:
445
+ slot = slots[0].name
446
+ texts = ([f"{slot}.{name}"] if slot is not None else []) + ([name] if len(slots) <= 1 else [])
447
+ for text in texts:
448
+ found = sorted(c for t, c in labels if t == text)
449
+ if len(found) > 1:
450
+ raise AmbiguousLine(f"{text}: on {len(found)} channels", [(text, c) for c in found])
451
+ if found:
452
+ return found[0]
453
+ return None
oep_client/endpoint.py CHANGED
@@ -26,10 +26,16 @@ define:
26
26
  interfaces the profiles offer: oep.wire.rvswd / swio (scan with its TLVs, attach on declared pin pairs with the
27
27
  reset TLV, several connections up to max_connections, the seat rule, connections), oep.target.riscv-dm on one
28
28
  `FakeTarget` per pin pair (dmi / run answers count their values; run's stopped 2), oep.target.console streams (one
29
- live stream per connection, lifetime by its users, the streams list), oep.fixture.gpio, oep.fixture.uart (its stream
30
- made by the plan, status, the settings' uart item), and oep.probe.config (plan / label / idle / slot / bind / uart
29
+ live stream per connection, lifetime by its users, the streams list), oep.fixture.gpio (the lines outside through
30
+ the test hook `gpio_world`; a target's `reset_line` makes the other reset channels reset nothing), oep.fixture.uart (its stream
31
+ made by the plan, status, the settings' uart item), oep.fixture.i2c-target / spi-target (fixture §3 / §4: the plan's
32
+ SDA / SCL and SCK / MOSI / MISO / CS, role_channels and an exact channel_group; configure, arm, preload, read_rx,
33
+ status, reset, stretch when declared; no bus controller of its own - the test hooks `i2c_write` / `i2c_read` /
34
+ `spi_transfer` are one transaction on the bus), and oep.probe.config (plan / label / idle / slot / bind / uart
31
35
  / disable items, get / set / unset / save / erase, the state op - describe is declarations only; a disabled channel
32
- is refused everywhere with unavailable cause 5 and never parked: `parked` records the free pins' states it set)
36
+ is refused everywhere with unavailable cause 5 and never parked: `parked` records the free pins' states it set -
37
+ idle modes 3 / 4 drive their level, refused unsupported on `input_only` channels; every release goes there, and a
38
+ gpio line taken keeps that state until its first set)
33
39
  - the serial ports' raw side (core §3.4, probe.config §1.2): `port_input` / `port_output` carry the bytes outside
34
40
  the frames for each serial port by its bind; a port the lock holder's requests came in on is held until the
35
41
  session ends, then resumes from the session's last host reset. The byte framing itself is `fake_serial`.
@@ -74,6 +80,7 @@ STREAM_STATE, STREAM_USERS = _CON.enum["stream_state"], _CON.enum["stream_users"
74
80
  ATTACH_FLAGS = reg.WIRE_RVSWD.enum["attach_flags"]
75
81
  RUN_STOPPED = _RV.enum["run_stopped"]
76
82
  ITEM = _CFG.tlv["item"]
83
+ IDLE_MODE = _CFG.enum["idle_mode"]
77
84
  CFG_DESCRIBE = _CFG.tlv["describe"]
78
85
  SLOT_ATTACH = _CFG.enum["slot_attach"]
79
86
  SLOT_STATE = _CFG.enum["slot_state"]
@@ -87,6 +94,10 @@ NO_SLOT, NEVER_NS = 0xFF, 0xFFFFFFFFFFFFFFFF
87
94
  PIN_ROLE_RESET = reg.WIRE_RVSWD.enum["pin_role"]["reset"] # the channels an attach's reset TLV may take
88
95
  TARGET_ID_LEN = reg.WIRE_RVSWD.enum["target_id_len"]["wch_dmi_7f"]
89
96
  TARGET_ID_SCHEMES = set(reg.WIRE_RVSWD.enum["target_id_scheme"].values()) | set(reg.WIRE_SWD.enum["target_id_scheme"].values())
97
+ _I2C, _SPI = reg.FIXTURE_I2C_TARGET, reg.FIXTURE_SPI_TARGET
98
+ I2C_MODE, I2C_FEATURES, SPI_FEATURES = _I2C.enum["mode"], _I2C.enum["features"], _SPI.enum["features"]
99
+ TARGET_ROLES = {"oep.fixture.gpio": {1}, "oep.fixture.uart": {1, 2}, _I2C.name: set(_I2C.enum["role"].values()),
100
+ _SPI.name: set(_SPI.enum["role"].values())} # the plan roles each fixture takes
90
101
  UART_FORMAT_MASK = 0x1F # the defined format bits (fixture §2)
91
102
  UART_CONFIGURED = _UART.enum["uart_configured"] # status's configured byte: default / session / item / item_fallback
92
103
  _T_SCAN, _T_ATTACH, _T_DETACH, _T_ATTACH_ANSWER = (reg.WIRE_RVSWD.tlv[k] for k in ("scan", "attach", "detach", "attach_answer"))
@@ -212,6 +223,7 @@ class FakeTarget:
212
223
  # run(pc, regs) -> (stopped, dpc, elapsed_us); default: halts 0x10 past the start
213
224
  run_hook: Callable | None = None
214
225
  unstoppable: bool = False # run: the limit passes and the hart cannot be halted (stopped 2)
226
+ reset_line: int | None = None # the channel wired to its reset (None: any reset channel resets it)
215
227
 
216
228
  def dmstatus(self) -> int:
217
229
  return 0x82 | ((0x300 if self.halted else 0xC00)) | (0xC0000 if self.havereset else 0)
@@ -223,6 +235,33 @@ class FakeTarget:
223
235
  return self.dmi.get(address, 0)
224
236
 
225
237
 
238
+ @dataclass
239
+ class I2cState:
240
+ """One oep.fixture.i2c-target (fixture §3): what configure made, the frames waiting for read_rx, the tx slots."""
241
+ state: int = 0 # 0 not configured, 1 running
242
+ address: int = 0
243
+ mode: int = 0
244
+ armed: int = 0 # mode 1: the length arm_rx waits for (0 = not armed)
245
+ queue: list = field(default_factory=list) # (frame, ns)
246
+ rx_frames: int = 0
247
+ errors: int = 0
248
+ slots: int = 0 # preload_tx's running count (u8, wraps)
249
+ tx: list = field(default_factory=list) # preloaded, unread
250
+ stretch_us: int = 0
251
+
252
+
253
+ @dataclass
254
+ class SpiState:
255
+ """One oep.fixture.spi-target (fixture §4): what configure made, the armed transaction, the finished ones."""
256
+ state: int = 0
257
+ mode: int = 0
258
+ bit_order: int = 0
259
+ armed: tuple[int, bytes] | None = None # (length, MISO bytes) of the one transaction it waits for
260
+ queue: list = field(default_factory=list) # (bits, MOSI bytes, ns)
261
+ transactions: int = 0
262
+ errors: int = 0
263
+
264
+
226
265
  @dataclass
227
266
  class Stream:
228
267
  """A position stream (common §1): bytes from position `base`, marks with serials; a console stream's users
@@ -362,6 +401,8 @@ class Endpoint:
362
401
  self.fns = {name: fn for fn, name in sorted(self.names.items(), reverse=True)} # first fn of each name
363
402
  self.static = {o.fn: o.tlvs for o in probe.offered}
364
403
  self.static_labels: dict[int, str] = {}
404
+ # channels the probe can only read (a test sets them): an idle of mode 3 / 4 there is unsupported (probe.config §1)
405
+ self.input_only: set[int] = set()
365
406
  self.transports: list[int] = []
366
407
  self.max_op_ms = reg.LIMITS["max_op_ms_reference"]
367
408
  self.plan_roles: int | None = None
@@ -418,6 +459,14 @@ class Endpoint:
418
459
  for fn, name in self.names.items() if name == "oep.fixture.uart"}
419
460
  self.uart_max_hz = {fn: next((struct.unpack_from("<I", t, 2)[0] for t in self.static[fn] if t[0] == catalog.MAX_CLOCK_HZ),
420
461
  3_000_000) for fn, name in self.names.items() if name == "oep.fixture.uart"}
462
+ def own(fn: int, tag: int, fmt: str, default: int) -> int:
463
+ return next((struct.unpack_from("<" + fmt, t, 2)[0] for t in self.static[fn] if t[0] == tag), default)
464
+ # the fixture targets' declarations (fixture §3 / §4): max_length, features, queue_depth, max_stretch_us
465
+ # (i2c-target only; 0 when not declared)
466
+ self.target_decl = {fn: (own(fn, catalog.MAX_LENGTH, "H", 1), own(fn, catalog.FEATURES, "I", 0),
467
+ own(fn, _I2C.tlv["describe"]["queue_depth"], "B", 1),
468
+ own(fn, _I2C.tlv["describe"]["max_stretch_us"], "I", 0) if name == _I2C.name else 0)
469
+ for fn, name in self.names.items() if name in (_I2C.name, _SPI.name)}
421
470
  cfg_fn = self.fns.get("oep.probe.config")
422
471
  cfg = {t[0]: t[2:2 + t[1]] for t in self.static.get(cfg_fn, ())}
423
472
  self.slots_max = cfg[CFG_DESCRIBE["slots_max"]][0] if CFG_DESCRIBE["slots_max"] in cfg else 0
@@ -430,6 +479,9 @@ class Endpoint:
430
479
  self.port_speed_base: int | None = 115200 if any(t[0] == PORT_SPEED_TAG for t in self.static.get(0, ())) else None
431
480
  self.broken_rates: dict[int, BrokenRate] = {} # the line: rates that break frames (fake_serial applies it)
432
481
  self._transport = 0 # the transport the request being handled came in on
482
+ # the lines outside (a test hook): gpio_world(channel, mode) -> the level an input mode reads (None: the
483
+ # default - gpio_inputs, a pull-up 1); reads see the world as the modes set it (gpio_modes)
484
+ self.gpio_world: Callable[[int, int], int | None] | None = None
433
485
  self._boot()
434
486
 
435
487
  def _boot(self) -> None:
@@ -469,6 +521,8 @@ class Endpoint:
469
521
  self.uart_clock_hz = 80_000_000 # the UARTs' divider clock (a test lowers it: the item's fallback)
470
522
  self.uart_session_cfg: set[int] = set() # fns a session's configure set (it beats the uart item)
471
523
  self.uart_tx: dict[int, bytearray] = {} # what a serial port's raw bytes sent out on a fixture UART
524
+ self.i2c: dict[int, I2cState] = {fn: I2cState() for fn, n in self.names.items() if n == _I2C.name}
525
+ self.spi: dict[int, SpiState] = {fn: SpiState() for fn, n in self.names.items() if n == _SPI.name}
472
526
  self.config: dict[tuple[int, int], bytes | list[bytes]] = {} # (item tag, key) -> value (plan: list)
473
527
  self.saved: dict | None = getattr(self, "saved", None)
474
528
  self.saved_ids: dict[int, tuple] = getattr(self, "saved_ids", {}) # saved fn -> (name, instance, revision)
@@ -822,6 +876,8 @@ class Endpoint:
822
876
  return m.REJECTED, m.UNKNOWN_FUNCTION, b""
823
877
  if req.fn == m.CORE_FN and req.op == OP_PORT_SPEED and self.port_speed_base is None:
824
878
  return m.REJECTED, m.UNKNOWN_OPERATION, b"" # the optional feature off (core §3.5)
879
+ if req.fn in self.i2c and req.op == _I2C.op["stretch"] and not self.target_decl[req.fn][1] & I2C_FEATURES["stretch"]:
880
+ return m.REJECTED, m.UNKNOWN_OPERATION, b"" # stretch is features bit1's (fixture §3)
825
881
  if not self._lock_free(req.fn, req.op):
826
882
  refused = self._check(req.session)
827
883
  if refused:
@@ -1047,6 +1103,7 @@ class Endpoint:
1047
1103
  named = {fn for fn, _, _ in got}
1048
1104
  if any(fn not in self.names for fn in named):
1049
1105
  raise Reject(m.UNKNOWN_FUNCTION)
1106
+ self._check_target_roles(got) # malformed before held_by_settings (core §4.3)
1050
1107
  if named & self.plan_from_config: # the settings' plan is the settings' (core §8)
1051
1108
  raise unavailable("held_by_settings", holder_fn=min(named & self.plan_from_config),
1052
1109
  holder_kind="settings_plan")
@@ -1054,7 +1111,7 @@ class Endpoint:
1054
1111
  self._refuse_disabled(ch for _, _, ch in got) # a disabled channel (probe.config §1): cause 5
1055
1112
  if self.plan_roles is not None and len([a for a in self.plan if a[0] not in named]) + len(got) > self.plan_roles:
1056
1113
  raise unavailable("limit") # plan_roles (core §8)
1057
- self.plan = {a for a in self.plan if a[0] not in named} | set(got)
1114
+ self._replace_plans(named, got)
1058
1115
  for fn in named:
1059
1116
  self._uart_plan_changed(fn)
1060
1117
  return m.COMPLETED, m.SUCCESS, b""
@@ -1072,8 +1129,19 @@ class Endpoint:
1072
1129
  their analog function, cutting their digital input and output (oep-if-capture §1.2), as on this library's ESP32s."""
1073
1130
  return fn in self.captures and not self.captures[fn].analog
1074
1131
 
1132
+ def _check_target_roles(self, got: list[tuple[int, int, int]]) -> None:
1133
+ """An i2c-target / spi-target plan holds each of its roles exactly once, on distinct channels (fixture §3 /
1134
+ §4): a missing role, a role twice or two roles on one channel -> malformed. A role the fn does not define is
1135
+ left to the declaration check (unsupported)."""
1136
+ for fn in {f for f, _, _ in got if f in self.i2c or f in self.spi}:
1137
+ roles = TARGET_ROLES[self.names[fn]]
1138
+ mine = [(role, ch) for f, role, ch in got if f == fn and role in roles]
1139
+ if sorted(r for r, _ in mine) != sorted(roles) or len({ch for _, ch in mine}) != len(mine):
1140
+ raise Reject(m.MALFORMED)
1141
+
1075
1142
  def _check_plan(self, got: list[tuple[int, int, int]]) -> None:
1076
1143
  """plan_apply's all-or-nothing check: the roles each fn has, and no pin another fn (or a slot) holds."""
1144
+ self._check_target_roles(got)
1077
1145
  named = {fn for fn, _, _ in got}
1078
1146
  others = {a for a in self.plan if a[0] not in named}
1079
1147
  kept = {a for a in others if not self._listens(a[0])}
@@ -1105,17 +1173,31 @@ class Endpoint:
1105
1173
  if role not in self._declared_roles(fn, ch):
1106
1174
  raise not_declared(ch)
1107
1175
  continue
1108
- roles = {"oep.fixture.gpio": {1}, "oep.fixture.uart": {1, 2}}.get(self.names.get(fn, ""), set())
1176
+ roles = TARGET_ROLES.get(self.names.get(fn, ""), set())
1109
1177
  if role not in roles or ch not in self._declared_channels(fn):
1110
1178
  raise not_declared(ch)
1179
+ if fn in self.i2c or fn in self.spi: # role_channels binds the roles it lists (core §7.4)
1180
+ listed = self._listed_roles(fn)
1181
+ if role in listed and role not in self._declared_roles(fn, ch):
1182
+ raise not_declared(ch)
1111
1183
  if any(k[2] == ch for k in kept) or ch in slot_pins:
1112
1184
  raise held(ch, next((k[0] for k in kept if k[2] == ch), None))
1185
+ for fn in {f for f, _, _ in got if f in self.i2c or f in self.spi}:
1186
+ groups = [set(catalog.unpack_channel_group(t[2:2 + t[1]])[1]) for t in self.static[fn]
1187
+ if t[0] == catalog.CHANNEL_GROUP]
1188
+ mine = {(role, ch) for f, role, ch in got if f == fn}
1189
+ if groups and mine not in groups: # one channel_group exactly (core §7.4)
1190
+ raise not_declared(min(ch for _, ch in mine))
1113
1191
 
1114
1192
  def _declared_roles(self, fn: int, channel: int) -> set[int]:
1115
1193
  """The roles fn's describe offers on `channel` (role_channels)."""
1116
1194
  return {t[2] for t in self.static[fn] if t[0] == catalog.ROLE_CHANNELS
1117
1195
  and channel in catalog.bitmap_to_channels(struct.unpack_from("<H", t, 3)[0], t[5:2 + t[1]])}
1118
1196
 
1197
+ def _listed_roles(self, fn: int) -> set[int]:
1198
+ """The roles fn's role_channels name (the only ones it binds, core §7.4)."""
1199
+ return {t[2] for t in self.static[fn] if t[0] == catalog.ROLE_CHANNELS}
1200
+
1119
1201
  def _declared_channels(self, fn: int) -> set[int]:
1120
1202
  """Every channel fn's describe offers in any role (role_channels and channel_group)."""
1121
1203
  out = set()
@@ -1126,6 +1208,24 @@ class Endpoint:
1126
1208
  out.update(c for _, c in catalog.unpack_channel_group(t[2:2 + t[1]])[1])
1127
1209
  return out
1128
1210
 
1211
+ def _replace_plans(self, fns: set[int], got) -> None:
1212
+ """The plans of `fns` become `got` as one change (core §8): a channel leaving goes to its idle state, one in
1213
+ both the old and the new plan of a gpio keeps its state and drive, and a new gpio line keeps the state it was
1214
+ in - an output idle keeps driving - until the first set (fixture §1); the idle modes 0-4 are the gpio modes of
1215
+ the same numbers."""
1216
+ old = {a for a in self.plan if a[0] in fns}
1217
+ kept = {ch for f, _, ch in old if self.names.get(f) == "oep.fixture.gpio"} & \
1218
+ {ch for f, _, ch in got if self.names.get(f) == "oep.fixture.gpio"}
1219
+ released = {a[2] for a in old} - {a[2] for a in got}
1220
+ for ch in {a[2] for a in old} - kept:
1221
+ self.gpio_modes.pop(ch, None)
1222
+ self.plan = {a for a in self.plan if a[0] not in fns}
1223
+ self._park(released)
1224
+ self.plan |= set(got)
1225
+ for fn, _, ch in got:
1226
+ if ch not in kept and self.names.get(fn) == "oep.fixture.gpio" and self.parked.get(ch, 0):
1227
+ self.gpio_modes[ch] = self.parked[ch]
1228
+
1129
1229
  def _drop_plan(self, fn: int) -> None:
1130
1230
  released = set()
1131
1231
  for a in [a for a in self.plan if a[0] == fn]:
@@ -1139,7 +1239,12 @@ class Endpoint:
1139
1239
  # ---- oep.fixture.uart's stream: made by the plan, gone with it (fixture §2) ---------------------
1140
1240
  def _uart_plan_changed(self, fn: int) -> None:
1141
1241
  """fn's plan moved: a fixture UART with RX or TX gets its stream (carrying on from where the last one ended,
1142
- common §1.1) and the settings' uart item unless a session's configure holds; one without loses it."""
1242
+ common §1.1) and the settings' uart item unless a session's configure holds; one without loses it. A fixture
1243
+ I2C / SPI target goes back to not configured (state 0): its configure is made on the plan's pins."""
1244
+ if fn in self.i2c:
1245
+ self.i2c[fn] = I2cState()
1246
+ if fn in self.spi:
1247
+ self.spi[fn] = SpiState()
1143
1248
  if self.names.get(fn) != "oep.fixture.uart":
1144
1249
  return
1145
1250
  planned = any(a[0] == fn for a in self.plan)
@@ -1395,7 +1500,10 @@ class Endpoint:
1395
1500
  for n, s in self.slots.items(): # a new connection for an evicted slot: a new cue
1396
1501
  if s.wire_fn == fn and s.pair == pair:
1397
1502
  self.slot_rt[n].evicted = False
1398
- if reset is not None: # the reset line held, then let go: a host reset
1503
+ if reset is not None and tg.reset_line not in (None, channel):
1504
+ if method == 1: # a line that resets nothing: halted where it ran
1505
+ tg.halted, tg.dpc = True, tg.reset_vector + 0x2f8
1506
+ elif reset is not None: # the reset line held, then let go: a host reset
1399
1507
  tg.halted = method == 1
1400
1508
  tg.dpc = tg.reset_vector if method == 1 else tg.reset_vector + 0x200
1401
1509
  self._host_reset(cid, MARK_RESET["attach_reset"])
@@ -1848,7 +1956,9 @@ class Endpoint:
1848
1956
  levels = []
1849
1957
  for ch in chans:
1850
1958
  mode = self.gpio_modes.get(ch, 0)
1851
- levels.append({1: 1, 3: 0, 4: 1, 5: 0, 6: 1}.get(mode, self.gpio_inputs.get(ch, 0)))
1959
+ outside = self.gpio_world(ch, mode) if self.gpio_world and mode in (0, 1, 2, 6, 7) else None
1960
+ levels.append(outside if outside is not None else
1961
+ {1: 1, 3: 0, 4: 1, 5: 0, 6: 1}.get(mode, self.gpio_inputs.get(ch, 0)))
1852
1962
  return self._answer(bytes([len(levels)]) + bytes(levels), ignored) # n(u8) n x level (fixture §1)
1853
1963
  return m.REJECTED, m.UNKNOWN_OPERATION, b""
1854
1964
 
@@ -1889,6 +1999,194 @@ class Endpoint:
1889
1999
  """Bytes arrive on fixture UART `fn`'s RX."""
1890
2000
  self.uarts[fn].data += data
1891
2001
 
2002
+ # ---- oep.fixture.i2c-target (fixture §3) ------------------------------------------------------
2003
+ def _i2c_op(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
2004
+ st, (max_length, features, depth, max_stretch) = self.i2c[fn], self.target_decl[fn]
2005
+ ops = _I2C.op
2006
+ planned = any(a[0] == fn for a in self.plan)
2007
+ if op == ops["configure"]: # core §4.3's order: malformed, unsupported, unavailable
2008
+ address, mode = t.take("BB")
2009
+ _, ignored = t.tail()
2010
+ if address > 0x7F or mode not in I2C_MODE.values():
2011
+ raise Reject(m.MALFORMED)
2012
+ if mode == I2C_MODE["preloaded_tx"] and not features & I2C_FEATURES["preloaded_tx"]:
2013
+ raise unsupported_fixed() # mode 3 without features bit0
2014
+ if not planned:
2015
+ raise unavailable("wrong_state") # before the plan (cause 6)
2016
+ self.i2c[fn] = I2cState(state=1, address=address, mode=mode, stretch_us=st.stretch_us) # made anew
2017
+ return self._answer(b"", ignored)
2018
+ if op == ops["arm_rx"]:
2019
+ length = t.take("H")
2020
+ _, ignored = t.tail()
2021
+ if length == 0:
2022
+ raise Reject(m.MALFORMED)
2023
+ if length > max_length:
2024
+ raise unsupported_fixed()
2025
+ if st.state == 0 or st.mode != I2C_MODE["fixed_rx"]:
2026
+ raise unavailable("wrong_state") # mode 1 only
2027
+ st.armed = length # an earlier wait is dropped for this one
2028
+ return self._answer(b"", ignored)
2029
+ if op == ops["read_rx"]:
2030
+ _, ignored = t.tail()
2031
+ if st.state == 0:
2032
+ raise unavailable("wrong_state") # cause 6
2033
+ if not st.queue:
2034
+ return self._answer(struct.pack("<BH", 0, 0), ignored)
2035
+ frame, ns = st.queue.pop(0)
2036
+ return self._answer(struct.pack("<BH", min(len(st.queue), 255), len(frame)) + frame
2037
+ + m.tlv(_I2C.tlv["read_rx_answer"]["ns"], struct.pack("<Q", ns)), ignored)
2038
+ if op == ops["preload_tx"]:
2039
+ count = t.take("H")
2040
+ data = t.bytes(count)
2041
+ _, ignored = t.tail()
2042
+ if count == 0:
2043
+ raise Reject(m.MALFORMED)
2044
+ if count > max_length:
2045
+ raise unsupported_fixed()
2046
+ if st.state == 0 or st.mode != I2C_MODE["preloaded_tx"]:
2047
+ raise unavailable("wrong_state") # mode 3 only
2048
+ if len(st.tx) >= depth:
2049
+ raise unavailable("limit") # every slot holds an unread preload
2050
+ st.tx.append(data)
2051
+ st.slots = (st.slots + 1) & 0xFF
2052
+ return self._answer(bytes([st.slots]), ignored)
2053
+ if op == ops["status"]: # lock-free
2054
+ _, ignored = t.tail()
2055
+ return self._answer(struct.pack("<BBBBIBI", st.state, st.mode, int(st.armed > 0), min(len(st.queue), 255),
2056
+ st.rx_frames, len(st.tx) if st.mode == I2C_MODE["preloaded_tx"] else 0,
2057
+ st.errors), ignored)
2058
+ if op == ops["reset"]:
2059
+ _, ignored = t.tail()
2060
+ if st.state == 0:
2061
+ raise unavailable("wrong_state")
2062
+ self.i2c[fn] = I2cState(state=1, address=st.address, mode=st.mode, stretch_us=st.stretch_us)
2063
+ return self._answer(b"", ignored)
2064
+ if op == ops["stretch"] and features & I2C_FEATURES["stretch"]:
2065
+ us = t.take("I")
2066
+ _, ignored = t.tail()
2067
+ if us > max_stretch:
2068
+ raise unsupported_fixed() # past describe's max_stretch_us
2069
+ st.stretch_us = us # any state; configure / reset keep it
2070
+ return self._answer(b"", ignored)
2071
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
2072
+
2073
+ def _i2c_frame(self, st: I2cState, frame: bytes, depth: int) -> None:
2074
+ if len(st.queue) >= depth:
2075
+ st.errors += 1 # the queue overflows: the new frame goes
2076
+ return
2077
+ st.queue.append((bytes(frame), self.now_ns()))
2078
+ st.rx_frames += 1
2079
+
2080
+ def i2c_write(self, fn: int, data: bytes, address: int | None = None) -> bool:
2081
+ """TEST HOOK: a bus controller writes `data` to i2c-target `fn` in one transaction (START, address + W, data,
2082
+ STOP). -> whether the target ACKed its address (configured, and `address` None or its own). An address-only
2083
+ write (empty data) counts nothing in any mode. Mode 1: the armed length exactly is a frame and the wait goes on
2084
+ (armed until the next arm_rx / reset / configure / plan release); another length is a receive error; no wait
2085
+ -> ACKed, dropped, an error. Mode 2: the first byte is the length of the rest, the rest a frame (a length that
2086
+ does not match is a receive error). Mode 3 takes no writes: ACKed, dropped, an error."""
2087
+ st, (max_length, _, depth, _) = self.i2c[fn], self.target_decl[fn]
2088
+ if st.state == 0 or (address is not None and address != st.address):
2089
+ return False
2090
+ if not data:
2091
+ pass # address only: nothing counts
2092
+ elif st.mode == I2C_MODE["fixed_rx"] and st.armed:
2093
+ if len(data) == st.armed:
2094
+ self._i2c_frame(st, data, depth)
2095
+ else:
2096
+ st.errors += 1
2097
+ elif st.mode == I2C_MODE["framed_rx"] and 0 < data[0] == len(data) - 1 <= max_length:
2098
+ self._i2c_frame(st, data[1:], depth)
2099
+ else:
2100
+ st.errors += 1
2101
+ return True
2102
+
2103
+ def i2c_read(self, fn: int, n: int, address: int | None = None) -> bytes | None:
2104
+ """TEST HOOK: a bus controller reads n bytes from i2c-target `fn` in one transaction. -> the bytes, or None
2105
+ when the address is not ACKed. Mode 3 answers from the oldest preloaded slot (cut or padded with 0xFF to n:
2106
+ the next read starts at the next slot); an empty slot list, mode 1 and mode 2 answer 0xFF."""
2107
+ st = self.i2c[fn]
2108
+ if st.state == 0 or (address is not None and address != st.address):
2109
+ return None
2110
+ if st.mode == I2C_MODE["preloaded_tx"] and st.tx:
2111
+ slot = st.tx.pop(0)
2112
+ return (slot + b"\xff" * n)[:n]
2113
+ return b"\xff" * n
2114
+
2115
+ # ---- oep.fixture.spi-target (fixture §4) ------------------------------------------------------
2116
+ def _spi_op(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
2117
+ st, (max_length, features, _, _) = self.spi[fn], self.target_decl[fn]
2118
+ ops = _SPI.op
2119
+ if op == ops["configure"]:
2120
+ mode, order = t.take("BB")
2121
+ _, ignored = t.tail()
2122
+ if mode > 3 or order > 1:
2123
+ raise Reject(m.MALFORMED)
2124
+ if order == 1 and not features & SPI_FEATURES["lsb_first"]:
2125
+ raise unsupported_fixed()
2126
+ if not any(a[0] == fn for a in self.plan):
2127
+ raise unavailable("wrong_state") # before the plan (cause 6)
2128
+ self.spi[fn] = SpiState(state=1, mode=mode, bit_order=order)
2129
+ return self._answer(b"", ignored)
2130
+ if op == ops["arm"]:
2131
+ length, count = t.take("HH")
2132
+ tx = t.bytes(count)
2133
+ _, ignored = t.tail()
2134
+ if length == 0 or count > length:
2135
+ raise Reject(m.MALFORMED)
2136
+ if length > max_length:
2137
+ raise unsupported_fixed()
2138
+ if st.state == 0 or st.armed is not None:
2139
+ raise unavailable("wrong_state") # not configured, or one is armed already
2140
+ st.armed = (length, tx)
2141
+ return self._answer(b"", ignored)
2142
+ if op == ops["read_rx"]:
2143
+ _, ignored = t.tail()
2144
+ if st.state == 0:
2145
+ raise unavailable("wrong_state") # cause 6
2146
+ if not st.queue:
2147
+ return self._answer(struct.pack("<BIH", 0, 0, 0), ignored)
2148
+ bits, data, ns = st.queue.pop(0)
2149
+ return self._answer(struct.pack("<BIH", min(len(st.queue), 255), bits, len(data)) + data
2150
+ + m.tlv(_SPI.tlv["read_rx_answer"]["ns"], struct.pack("<Q", ns)), ignored)
2151
+ if op == ops["status"]: # lock-free
2152
+ _, ignored = t.tail()
2153
+ return self._answer(struct.pack("<BBBBBII", st.state, st.mode, st.bit_order, int(st.armed is not None),
2154
+ min(len(st.queue), 255), st.transactions, st.errors), ignored)
2155
+ if op == ops["reset"]:
2156
+ _, ignored = t.tail()
2157
+ if st.state == 0:
2158
+ raise unavailable("wrong_state")
2159
+ self.spi[fn] = SpiState(state=1, mode=st.mode, bit_order=st.bit_order)
2160
+ return self._answer(b"", ignored)
2161
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
2162
+
2163
+ def spi_transfer(self, fn: int, mosi: bytes, bits: int | None = None) -> bytes:
2164
+ """TEST HOOK: a bus controller runs one transaction on spi-target `fn` (CS low, `bits` clocks - 8 a MOSI byte
2165
+ by default - CS high). -> the MISO bytes: the armed tx, 0 past it and when not armed. Armed: the MOSI bytes
2166
+ (bits / 8 rounded up) up to the armed length (more is an error) and the bits are queued, the wait ends; a full
2167
+ queue drops them (an error). Not armed: MOSI dropped, an error. Every transaction of a configured target
2168
+ counts; a target not configured sees nothing. 0 bits (CS edges without SCK) is no transaction: nothing counts,
2169
+ an arm keeps waiting."""
2170
+ st, (_, _, depth, _) = self.spi[fn], self.target_decl[fn]
2171
+ n = len(mosi)
2172
+ bits = 8 * n if bits is None else bits
2173
+ if st.state == 0 or bits == 0:
2174
+ return bytes(n)
2175
+ st.transactions += 1
2176
+ if st.armed is None:
2177
+ st.errors += 1
2178
+ return bytes(n)
2179
+ length, tx = st.armed
2180
+ st.armed = None
2181
+ got = (bits + 7) // 8
2182
+ if got > length:
2183
+ st.errors += 1 # past the armed length: dropped
2184
+ if len(st.queue) >= depth:
2185
+ st.errors += 1
2186
+ else:
2187
+ st.queue.append((bits, bytes(mosi[:min(got, length)]), self.now_ns()))
2188
+ return (tx + bytes(n))[:n]
2189
+
1892
2190
  # ---- oep.probe.config -----------------------------------------------------------------------
1893
2191
  def _config_op(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
1894
2192
  if op == _CFG.op["get"]:
@@ -2113,8 +2411,10 @@ class Endpoint:
2113
2411
  if key not in self.names or key == m.CORE_FN:
2114
2412
  raise Reject(m.UNKNOWN_FUNCTION if key else m.MALFORMED)
2115
2413
  elif tag == ITEM["idle"]:
2116
- if len(value) != 3 or value[2] > 2:
2414
+ if len(value) != 3 or value[2] > IDLE_MODE["output_high"]:
2117
2415
  raise Reject(m.MALFORMED)
2416
+ if value[2] in (IDLE_MODE["output_low"], IDLE_MODE["output_high"]) and key in self.input_only:
2417
+ raise unsupported_fixed(m.tlv(_UNA["channel"], struct.pack("<H", key))) # cannot drive it (§1)
2118
2418
  elif tag == ITEM["disable"] and key not in self._all_channels():
2119
2419
  # a channel the firmware does not declare: unsupported, as idle (probe.config §1)
2120
2420
  raise unsupported_fixed(m.tlv(_UNA["channel"], struct.pack("<H", key)))
@@ -2146,10 +2446,13 @@ class Endpoint:
2146
2446
  old_idle = {key: v for (tag, key), v in self.config.items() if tag == ITEM["idle"]}
2147
2447
  repark = (self.disabled - disabled) | {key for key in idles | set(old_idle) if new.get((ITEM["idle"], key)) != old_idle.get(key)}
2148
2448
  self.config = new # the disable items first: a dropped plan's pins
2449
+ # idle before the plans (probe.config §2: idle, plan, uart, the at-boot attach): at boot every free channel,
2450
+ # later the ones whose idle changed (or enabled again); a gpio plan then takes a line in that state
2451
+ self._park(self._all_channels() if boot else repark)
2149
2452
  for fn in old_plan_fns - set(plans): # are not parked when the same set disables them
2150
2453
  self._drop_plan(fn)
2151
2454
  for fn, assigned in plans.items():
2152
- self.plan = {a for a in self.plan if a[0] != fn} | set(assigned)
2455
+ self._replace_plans({fn}, assigned)
2153
2456
  self.plan_from_config.add(fn)
2154
2457
  self._uart_plan_changed(fn)
2155
2458
  for fn in self.uarts: # the uart item (or its going) on the planned UARTs no session set
@@ -2167,8 +2470,6 @@ class Endpoint:
2167
2470
  self.flows.pop((port, key), None)
2168
2471
  self.mixed_out.pop(port, None)
2169
2472
  self.binds = binds
2170
- if not boot:
2171
- self._park(repark) # an idle item set, or a channel enabled again: now
2172
2473
  for n, s in slots.items():
2173
2474
  if s.attach == SLOT_ATTACH["at_boot"] and (boot or changed_slots is None or n in changed_slots):
2174
2475
  self.slot_rt[n].evicted = False
@@ -2509,4 +2810,4 @@ class Endpoint:
2509
2810
  SIMS = {"oep.wire.rvswd": "wire", "oep.wire.swio": "wire", "oep.target.riscv-dm": "dm",
2510
2811
  "oep.target.console": "console", "oep.fixture.gpio": "gpio", "oep.fixture.uart": "uart",
2511
2812
  "oep.probe.config": "config_op", "oep.fixture.logic": "capture", "oep.fixture.analog": "capture",
2512
- "oep.fixture.capture-group": "group"}
2813
+ "oep.fixture.capture-group": "group", "oep.fixture.i2c-target": "i2c_op", "oep.fixture.spi-target": "spi_op"}