1
0
This commit is contained in:
Aiden
2026-05-27 21:37:50 +10:00
parent 21f0e455ee
commit 4364d0ed48
54 changed files with 30241 additions and 191 deletions

View File

@@ -270,6 +270,8 @@ def _run_step(ctx: ScenarioContext, action: str, spec: dict[str, Any]) -> None:
_listen(ctx, float(spec.get("seconds", spec.get("value", 0.0))))
elif action == "listen_ack":
_step_listen_ack(ctx, spec)
elif action == "listen_ack_until_quiet":
_step_listen_ack_until_quiet(ctx, spec)
elif action == "send":
frame = _parse_required_frame(spec.get("frame"))
label = str(spec.get("label", "send"))
@@ -378,7 +380,38 @@ def _step_table_sweep(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
def _step_listen_ack(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
seconds = float(spec.get("seconds", spec.get("value", 1.0)))
ack = _ack_config(
ack = _ack_config_from_step(spec)
ack_text = (
f"ack_frame={format_frame(ack['frame'])}"
if ack["ack_mode"] == "fixed"
else f"ack_mode={ack['ack_mode']}"
)
ctx.logger.event(
f"LISTEN_ACK seconds={seconds:.3f} target_mode={ack['target_mode']} targets={len(ack['targets'])} "
f"{ack_text} limit_scope={ack['limit_scope']} max_acks={ack['max_acks']}"
)
_listen_with_ack(ctx, seconds, None, ack)
def _step_listen_ack_until_quiet(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
seconds = float(spec.get("seconds", spec.get("value", 10.0)))
quiet_seconds = float(spec.get("quiet_seconds", spec.get("quiet", 0.750)))
ack = _ack_config_from_step(spec)
ack_text = (
f"ack_frame={format_frame(ack['frame'])}"
if ack["ack_mode"] == "fixed"
else f"ack_mode={ack['ack_mode']}"
)
ctx.logger.event(
f"LISTEN_ACK_UNTIL_QUIET seconds={seconds:.3f} quiet={quiet_seconds:.3f} "
f"target_mode={ack['target_mode']} targets={len(ack['targets'])} "
f"{ack_text} limit_scope={ack['limit_scope']} max_acks={ack['max_acks']}"
)
_listen_with_ack(ctx, seconds, None, ack, quiet_seconds=quiet_seconds)
def _ack_config_from_step(spec: dict[str, Any]) -> dict[str, Any]:
return _ack_config(
{
"enabled": spec.get("enabled", True),
"frames": spec.get("frames", spec.get("frame")),
@@ -393,18 +426,9 @@ def _step_listen_ack(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
"ack_mode": spec.get("ack_mode", spec.get("mode", "fixed")),
"target_mode": spec.get("target_mode", spec.get("match", "explicit")),
"limit_scope": spec.get("limit_scope", spec.get("scope", "local")),
"respond_on": spec.get("respond_on", spec.get("send_on", [])),
}
)
ack_text = (
f"ack_frame={format_frame(ack['frame'])}"
if ack["ack_mode"] == "fixed"
else f"ack_mode={ack['ack_mode']}"
)
ctx.logger.event(
f"LISTEN_ACK seconds={seconds:.3f} target_mode={ack['target_mode']} targets={len(ack['targets'])} "
f"{ack_text} limit_scope={ack['limit_scope']} max_acks={ack['max_acks']}"
)
_listen_with_ack(ctx, seconds, None, ack)
def _ack_config(raw: Any) -> dict[str, Any]:
@@ -438,6 +462,7 @@ def _ack_config(raw: Any) -> dict[str, Any]:
"ack_mode": ack_mode,
"target_mode": target_mode,
"limit_scope": limit_scope,
"respond_on": _response_rules(spec.get("respond_on", [])),
}
@@ -458,28 +483,71 @@ def _ack_matches(frame: bytes, ack: dict[str, Any]) -> bool:
return frame[0] in {0x00, 0x01, 0x02} and not (frame[1] & 0x80)
def _response_rules(raw: Any) -> list[dict[str, Any]]:
values = raw if isinstance(raw, list) else ([raw] if raw else [])
rules: list[dict[str, Any]] = []
for index, value in enumerate(values):
if not isinstance(value, dict):
raise SystemExit("respond_on entries must be objects")
targets = _parse_frame_list(value.get("frames", value.get("frame")))
response_frame = _parse_required_frame(value.get("send", value.get("response")))
label = str(value.get("label", f"respond_on_{index + 1}"))
rules.append(
{
"targets": targets,
"frame": response_frame,
"label": label,
"delay": float(value.get("delay", 0.0)),
"listen": float(value.get("listen", 0.0)),
"once": bool(value.get("once", True)),
}
)
return rules
def _listen_with_ack(
ctx: ScenarioContext,
seconds: float,
selector: int,
selector: int | None,
ack: dict[str, Any],
*,
quiet_seconds: float | None = None,
) -> list[bytes]:
deadline = time.monotonic() + max(0.0, seconds)
observed: list[bytes] = []
pending: list[bytes] = []
pending_index = 0
last_activity = time.monotonic()
acked_targets: set[bytes] = set()
fired_responses: set[int] = set()
ack_start = ctx.ack_sent
target_start = sum(ctx.target_counts.values())
def enqueue(frames: list[bytes]) -> None:
nonlocal last_activity
if not frames:
return
observed.extend(frames)
pending.extend(frames)
last_activity = time.monotonic()
while time.monotonic() < deadline:
frames = _read_available(ctx, selector=selector)
observed.extend(frames)
if not frames:
enqueue(frames)
if not frames and pending_index >= len(pending):
if quiet_seconds is not None and time.monotonic() - last_activity >= quiet_seconds:
ctx.logger.event(f"LISTEN_ACK_QUIET quiet={quiet_seconds:.3f}s")
break
sleep_for = min(max(0.001, ack["poll_interval"]), max(0.0, deadline - time.monotonic()))
if sleep_for > 0:
time.sleep(sleep_for)
continue
if not ack["enabled"]:
pending_index = len(pending)
continue
for frame in frames:
while pending_index < len(pending):
frame = pending[pending_index]
pending_index += 1
if not _ack_matches(frame, ack):
continue
_count_target(ctx, frame)
@@ -493,7 +561,7 @@ def _listen_with_ack(
continue
acked_targets.add(frame)
if ack["guard"] > 0:
observed.extend(_listen(ctx, ack["guard"], selector=selector))
enqueue(_listen(ctx, ack["guard"], selector=selector))
_send_and_record(ctx, _ack_frame_for_target(frame, ack), "ack", capture=ctx.args.snapshot_acks)
ctx.ack_sent += 1
if _ack_limit_reached(ctx, ack, ack_start=ack_start, target_start=target_start):
@@ -501,7 +569,22 @@ def _listen_with_ack(
if ack["abort_on_limit"]:
ctx.abort_requested = True
if ack["post_read"] > 0:
observed.extend(_listen(ctx, ack["post_read"], selector=selector))
enqueue(_listen(ctx, ack["post_read"], selector=selector))
for rule_index, rule in enumerate(ack["respond_on"]):
if frame not in rule["targets"]:
continue
if rule["once"] and rule_index in fired_responses:
continue
fired_responses.add(rule_index)
if rule["delay"] > 0:
time.sleep(rule["delay"])
ctx.logger.event(
f"RESPOND_ON target={format_frame(frame)} "
f"send={format_frame(rule['frame'])} label={rule['label']}"
)
_send_and_record(ctx, rule["frame"], rule["label"], capture=ctx.args.snapshot_acks)
if rule["listen"] > 0:
enqueue(_listen(ctx, rule["listen"], selector=selector))
if ctx.abort_requested:
return observed
return observed
@@ -753,6 +836,7 @@ def _quiet_console_line(line: str) -> bool:
"NOTE ",
"SNAPSHOT_SCHEDULE ",
"SNAPSHOT_ERROR ",
"RESPOND_ON ",
"Summary",
"rx_frames=",
"resync_events=",
@@ -760,6 +844,8 @@ def _quiet_console_line(line: str) -> bool:
"abort_requested=",
"known_shutter",
"queued_shutter",
"iris_mblack",
"selector_0013",
)
return any(fragment in line for fragment in keep_fragments)
@@ -772,25 +858,14 @@ def _print_step_dry_run(action: str, spec: dict[str, Any], stdout: TextIO, *, in
print(f"{indent}listen={float(spec.get('listen', 0.0)):.3f}s", file=stdout)
elif action in {"drain", "listen", "wait"}:
print(f"{indent}seconds={float(spec.get('seconds', spec.get('value', 0.0))):.3f}", file=stdout)
elif action == "listen_ack":
ack = _ack_config(
{
"enabled": spec.get("enabled", True),
"frames": spec.get("frames", spec.get("frame")),
"ack_frame": spec.get("ack_frame"),
"ack_guard": spec.get("ack_guard", 0.020),
"poll_interval": spec.get("poll_interval", 0.005),
"post_ack_read": spec.get("post_ack_read", 0.250),
"once_per_selector": spec.get("once_per_frame", False),
"max_acks": spec.get("max_acks"),
"max_target_hits": spec.get("max_target_hits"),
"abort_on_limit": spec.get("abort_on_limit", False),
"ack_mode": spec.get("ack_mode", spec.get("mode", "fixed")),
"target_mode": spec.get("target_mode", spec.get("match", "explicit")),
"limit_scope": spec.get("limit_scope", spec.get("scope", "local")),
}
)
elif action in {"listen_ack", "listen_ack_until_quiet"}:
ack = _ack_config_from_step(spec)
print(f"{indent}seconds={float(spec.get('seconds', spec.get('value', 1.0))):.3f}", file=stdout)
if action == "listen_ack_until_quiet":
print(
f"{indent}quiet={float(spec.get('quiet_seconds', spec.get('quiet', 0.750))):.3f}s",
file=stdout,
)
if not ack["enabled"]:
print(f"{indent}ack=disabled", file=stdout)
else:
@@ -805,6 +880,13 @@ def _print_step_dry_run(action: str, spec: dict[str, Any], stdout: TextIO, *, in
f"max_target_hits={ack['max_target_hits']}",
file=stdout,
)
for rule in ack["respond_on"]:
print(
f"{indent}respond_on={len(rule['targets'])} "
f"send={format_frame(rule['frame'])} label={rule['label']} "
f"once={int(rule['once'])}",
file=stdout,
)
elif action in {"prompt", "note"}:
message = str(spec.get("message", spec.get("value", "Press Enter to continue.")))
print(f"{indent}message={message}", file=stdout)