897 lines
35 KiB
Python
897 lines
35 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable, TextIO
|
|
|
|
from .bench_connect_lcd import (
|
|
BenchLogger,
|
|
FrameDetector,
|
|
add_serial_format_args,
|
|
_import_serial,
|
|
open_device_serial,
|
|
_read_for,
|
|
_relay_command,
|
|
_relay_settle,
|
|
_send_frame,
|
|
_wait_for_ready,
|
|
format_frame,
|
|
frame_checksum,
|
|
frame_checksum_ok,
|
|
parse_frame,
|
|
serial_format_label,
|
|
)
|
|
from .camera_snapshots import CameraSnapshots
|
|
from .serial_table_dump import build_read_frame, decode_table_read_response
|
|
|
|
|
|
DEFAULT_ACK_TARGET = bytes.fromhex("07804020902D")
|
|
DEFAULT_ACK_FRAME = bytes.fromhex("05004000001F")
|
|
HEARTBEAT_FRAME = bytes.fromhex("0000000080DA")
|
|
|
|
|
|
@dataclass
|
|
class ScenarioContext:
|
|
args: argparse.Namespace
|
|
logger: BenchLogger
|
|
detector: FrameDetector
|
|
device: Any
|
|
relay: Any | None = None
|
|
table_rows: list[dict[str, Any]] = field(default_factory=list)
|
|
target_counts: dict[str, int] = field(default_factory=dict)
|
|
tx_records: list[dict[str, Any]] = field(default_factory=list)
|
|
snapshot_records: list[dict[str, Any]] = field(default_factory=list)
|
|
snapshotter: CameraSnapshots | None = None
|
|
current_step_index: int | None = None
|
|
ack_sent: int = 0
|
|
abort_requested: bool = False
|
|
|
|
|
|
def default_log_path(scenario: dict[str, Any]) -> Path:
|
|
name = str(scenario.get("name") or "serial-scenario").strip() or "serial-scenario"
|
|
safe_name = "".join(char if char.isalnum() or char in "-_" else "-" for char in name)
|
|
return Path("captures") / f"{safe_name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
|
|
|
|
|
|
def _snapshot_output_dir(args: argparse.Namespace, log_path: Path) -> Path | None:
|
|
if args.snapshot_dir is not None:
|
|
return args.snapshot_dir
|
|
if args.camera_index is not None:
|
|
return log_path.parent / f"{log_path.stem}-snapshots"
|
|
return None
|
|
|
|
|
|
def _snapshot_camera_index(args: argparse.Namespace) -> int:
|
|
return 0 if args.camera_index is None else args.camera_index
|
|
|
|
|
|
def _snapshot_delays(args: argparse.Namespace) -> list[float]:
|
|
text = str(args.snapshot_delays or "").strip()
|
|
if not text:
|
|
return []
|
|
delays: list[float] = []
|
|
for part in text.split(","):
|
|
item = part.strip()
|
|
if not item:
|
|
continue
|
|
try:
|
|
delay = float(item)
|
|
except ValueError as exc:
|
|
raise SystemExit(f"invalid snapshot delay {item!r}") from exc
|
|
if delay < 0:
|
|
raise SystemExit("snapshot delays must be zero or positive")
|
|
delays.append(delay)
|
|
return delays
|
|
|
|
|
|
def build_arg_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Run JSON-described serial bench scenarios against the real RCP."
|
|
)
|
|
parser.add_argument("scenario", type=Path, help="JSON scenario file")
|
|
parser.add_argument("--port", default="COM5", help="RS232 serial port connected to the RCP")
|
|
parser.add_argument("--baud", type=int, default=38400, help="RCP serial baud rate")
|
|
add_serial_format_args(parser)
|
|
parser.add_argument("--relay-port", default="COM6", help="Pico relay serial port")
|
|
parser.add_argument("--relay-baud", type=int, default=115200, help="Pico relay serial baud rate")
|
|
parser.add_argument("--no-power-cycle", action="store_true", help="skip power_cycle actions")
|
|
parser.add_argument("--power-off-command", default="off", help="relay command used to remove DUT power")
|
|
parser.add_argument("--power-on-command", default="on", help="relay command used to apply DUT power")
|
|
parser.add_argument("--relay-settle", type=float, default=2.0, help="seconds to wait after opening the relay port")
|
|
parser.add_argument("--sync", choices=("checksum", "fixed"), default="checksum", help="RX frame sync strategy")
|
|
parser.add_argument("--log", type=Path, help="capture log path")
|
|
parser.add_argument("--result-json", type=Path, help="write machine-readable scenario summary")
|
|
parser.add_argument(
|
|
"--quiet-console",
|
|
action="store_true",
|
|
help="keep full logs in --log but suppress RX/DETECT chatter on the console",
|
|
)
|
|
parser.add_argument(
|
|
"--camera-index",
|
|
type=int,
|
|
help="enable webcam snapshots with this OpenCV camera index; use 0 for the default camera",
|
|
)
|
|
parser.add_argument(
|
|
"--snapshot-dir",
|
|
type=Path,
|
|
help="directory for webcam snapshots; enables camera index 0 if --camera-index is omitted",
|
|
)
|
|
parser.add_argument(
|
|
"--snapshot-delays",
|
|
default="0",
|
|
help="comma-separated seconds after each send to capture, e.g. 0,0.25,1.0",
|
|
)
|
|
parser.add_argument(
|
|
"--snapshot-before-send",
|
|
action="store_true",
|
|
help="also capture immediately before each send",
|
|
)
|
|
parser.add_argument(
|
|
"--snapshot-acks",
|
|
action="store_true",
|
|
help="capture snapshots for generated ACK frames as well as scenario send steps",
|
|
)
|
|
parser.add_argument("--snapshot-warmup", type=float, default=0.5, help="seconds to warm up the webcam")
|
|
parser.add_argument("--snapshot-width", type=int, help="requested webcam capture width")
|
|
parser.add_argument("--snapshot-height", type=int, help="requested webcam capture height")
|
|
parser.add_argument("--dry-run", action="store_true", help="print the plan without opening serial ports")
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int:
|
|
args = build_arg_parser().parse_args(argv)
|
|
scenario = load_scenario(args.scenario)
|
|
log_path = args.log or default_log_path(scenario)
|
|
|
|
if args.dry_run:
|
|
_print_dry_run(args, scenario, log_path, stdout)
|
|
return 0
|
|
|
|
serial = _import_serial()
|
|
console = _FilteredStdout(stdout, _quiet_console_line) if args.quiet_console else stdout
|
|
logger = BenchLogger(log_path, stdout=console)
|
|
detector = FrameDetector(sync_mode=args.sync)
|
|
snapshotter: CameraSnapshots | None = None
|
|
ctx: ScenarioContext | None = None
|
|
try:
|
|
logger.emit("Serial bench scenario")
|
|
logger.emit(f"name={scenario.get('name', args.scenario.stem)}")
|
|
logger.emit(
|
|
f"device={args.port} {args.baud} {serial_format_label(args)} "
|
|
f"relay={args.relay_port} {args.relay_baud} sync={args.sync}"
|
|
)
|
|
logger.emit(f"log={log_path}")
|
|
snapshot_dir = _snapshot_output_dir(args, log_path)
|
|
if snapshot_dir is not None:
|
|
snapshotter = CameraSnapshots(
|
|
camera_index=_snapshot_camera_index(args),
|
|
output_dir=snapshot_dir,
|
|
warmup_seconds=args.snapshot_warmup,
|
|
width=args.snapshot_width,
|
|
height=args.snapshot_height,
|
|
)
|
|
snapshotter.open()
|
|
logger.emit(
|
|
f"snapshots={snapshot_dir} camera_index={_snapshot_camera_index(args)} "
|
|
f"delays={','.join(f'{delay:g}' for delay in _snapshot_delays(args))}"
|
|
)
|
|
with open_device_serial(serial, args) as device:
|
|
ctx = ScenarioContext(
|
|
args=args,
|
|
logger=logger,
|
|
detector=detector,
|
|
device=device,
|
|
snapshotter=snapshotter,
|
|
)
|
|
try:
|
|
for index, step in enumerate(_scenario_steps(scenario), start=1):
|
|
if ctx.abort_requested:
|
|
logger.event("SCENARIO_ABORT requested by prior step")
|
|
break
|
|
action, spec = _normalize_step(step)
|
|
ctx.current_step_index = index
|
|
logger.event(f"STEP {index} {action}")
|
|
_run_step(ctx, action, spec)
|
|
finally:
|
|
ctx.current_step_index = None
|
|
if ctx.relay is not None:
|
|
ctx.relay.close()
|
|
if snapshotter is not None and ctx is not None:
|
|
snapshotter.close()
|
|
ctx.snapshot_records = snapshotter.records()
|
|
snapshotter = None
|
|
if ctx is None:
|
|
raise RuntimeError("scenario did not create a context")
|
|
_emit_summary(ctx, logger)
|
|
if args.result_json:
|
|
_write_result_json(args.result_json, scenario, log_path, ctx)
|
|
return 0
|
|
finally:
|
|
if snapshotter is not None:
|
|
snapshotter.close()
|
|
logger.close()
|
|
|
|
|
|
def load_scenario(path: Path) -> dict[str, Any]:
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
scenario = json.load(handle)
|
|
if not isinstance(scenario, dict):
|
|
raise SystemExit("scenario root must be a JSON object")
|
|
if not isinstance(scenario.get("steps"), list):
|
|
raise SystemExit("scenario must contain a steps array")
|
|
return scenario
|
|
|
|
|
|
def _scenario_steps(scenario: dict[str, Any]) -> list[Any]:
|
|
steps = scenario.get("steps", [])
|
|
if not isinstance(steps, list):
|
|
raise SystemExit("scenario steps must be an array")
|
|
return steps
|
|
|
|
|
|
def _normalize_step(step: Any) -> tuple[str, dict[str, Any]]:
|
|
if isinstance(step, str):
|
|
return step, {}
|
|
if not isinstance(step, dict):
|
|
raise SystemExit(f"invalid scenario step {step!r}")
|
|
if "action" in step:
|
|
spec = dict(step)
|
|
action = str(spec.pop("action"))
|
|
return action, spec
|
|
if len(step) == 1:
|
|
action, value = next(iter(step.items()))
|
|
if value is None:
|
|
return str(action), {}
|
|
if isinstance(value, dict):
|
|
return str(action), dict(value)
|
|
return str(action), {"value": value}
|
|
raise SystemExit(f"scenario step needs an action: {step!r}")
|
|
|
|
|
|
def _run_step(ctx: ScenarioContext, action: str, spec: dict[str, Any]) -> None:
|
|
if action == "power_cycle":
|
|
_step_power_cycle(ctx, spec)
|
|
elif action == "wait_ready":
|
|
ready = _wait_for_ready(
|
|
ctx.device,
|
|
ctx.detector,
|
|
ctx.logger,
|
|
float(spec.get("timeout", 10.0)),
|
|
int(spec.get("heartbeats", 2)),
|
|
)
|
|
if spec.get("require", False) and not ready:
|
|
raise SystemExit(2)
|
|
elif action in {"drain", "listen", "wait"}:
|
|
_listen(ctx, float(spec.get("seconds", spec.get("value", 0.0))))
|
|
elif action == "listen_ack":
|
|
_step_listen_ack(ctx, spec)
|
|
elif action == "send":
|
|
frame = _parse_required_frame(spec.get("frame"))
|
|
label = str(spec.get("label", "send"))
|
|
capture = bool(spec.get("snapshot", True))
|
|
_send_and_record(ctx, frame, label, capture=capture)
|
|
if float(spec.get("listen", 0.0)) > 0:
|
|
_listen(ctx, float(spec.get("listen", 0.0)))
|
|
elif action == "wait_for":
|
|
_step_wait_for(ctx, spec)
|
|
elif action == "prompt":
|
|
_step_prompt(ctx, spec)
|
|
elif action == "note":
|
|
_step_note(ctx, spec)
|
|
elif action == "table_sweep":
|
|
_step_table_sweep(ctx, spec)
|
|
elif action == "repeat":
|
|
_step_repeat(ctx, spec)
|
|
else:
|
|
raise SystemExit(f"unknown scenario action {action!r}")
|
|
|
|
|
|
def _step_power_cycle(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
|
|
if ctx.args.no_power_cycle:
|
|
ctx.logger.event("POWER_CYCLE skipped by --no-power-cycle")
|
|
ctx.device.reset_input_buffer()
|
|
ctx.detector = FrameDetector(sync_mode=ctx.args.sync)
|
|
return
|
|
serial = _import_serial()
|
|
if ctx.relay is None:
|
|
ctx.relay = serial.Serial(ctx.args.relay_port, ctx.args.relay_baud, timeout=0.25)
|
|
_relay_settle(ctx.relay, float(spec.get("relay_settle", ctx.args.relay_settle)), ctx.logger)
|
|
off_command = str(spec.get("off_command", ctx.args.power_off_command))
|
|
on_command = str(spec.get("on_command", ctx.args.power_on_command))
|
|
_relay_command(ctx.relay, off_command, ctx.logger)
|
|
time.sleep(float(spec.get("off_seconds", 1.5)))
|
|
ctx.device.reset_input_buffer()
|
|
ctx.detector = FrameDetector(sync_mode=ctx.args.sync)
|
|
_relay_command(ctx.relay, on_command, ctx.logger)
|
|
|
|
|
|
def _step_wait_for(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
|
|
targets = _parse_frame_list(spec.get("frames", spec.get("frame")))
|
|
timeout = float(spec.get("timeout", 1.0))
|
|
require = bool(spec.get("require", False))
|
|
ctx.logger.event(
|
|
"WAIT_FOR "
|
|
+ ",".join(format_frame(frame) for frame in targets)
|
|
+ f" timeout={timeout:.3f}s"
|
|
)
|
|
found = _listen_until(ctx, timeout, targets)
|
|
if require and not found:
|
|
raise SystemExit(3)
|
|
|
|
|
|
def _step_prompt(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
|
|
message = str(spec.get("message", spec.get("value", "Press Enter to continue.")))
|
|
ctx.logger.event(f"PROMPT {message}")
|
|
input(message + " ")
|
|
|
|
|
|
def _step_note(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
|
|
message = str(spec.get("message", spec.get("value", "")))
|
|
if bool(spec.get("banner", False)):
|
|
ctx.logger.emit("")
|
|
ctx.logger.emit("NOTE " + "=" * 68)
|
|
ctx.logger.event(f"NOTE {message}")
|
|
ctx.logger.emit("NOTE " + "=" * 68)
|
|
ctx.logger.emit("")
|
|
else:
|
|
ctx.logger.event(f"NOTE {message}")
|
|
|
|
|
|
def _step_repeat(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
|
|
count = max(0, int(spec.get("count", 1)))
|
|
steps = spec.get("steps", [])
|
|
if not isinstance(steps, list):
|
|
raise SystemExit("repeat step requires a steps array")
|
|
for repeat_index in range(count):
|
|
ctx.logger.event(f"REPEAT {repeat_index + 1}/{count}")
|
|
for step in steps:
|
|
action, child_spec = _normalize_step(step)
|
|
_run_step(ctx, action, child_spec)
|
|
|
|
|
|
def _step_table_sweep(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
|
|
selectors = _selector_list(spec)
|
|
gap = float(spec.get("gap", 0.080))
|
|
ack = _ack_config(spec.get("ack_on", {}))
|
|
ack_note = (
|
|
"ack=disabled"
|
|
if not ack["enabled"]
|
|
else f"ack_targets={len(ack['targets'])} ack_frame={format_frame(ack['frame'])}"
|
|
)
|
|
ctx.logger.event(
|
|
f"TABLE_SWEEP selectors={len(selectors)} gap={gap:.3f}s {ack_note}"
|
|
)
|
|
for selector in selectors:
|
|
if ctx.abort_requested:
|
|
ctx.logger.event("TABLE_SWEEP_ABORT stopping before next selector")
|
|
break
|
|
frame = build_read_frame(selector)
|
|
ctx.logger.event(f"READ selector=0x{selector:03X} frame={format_frame(frame)}")
|
|
_send_and_record(ctx, frame, f"read_0x{selector:03X}", capture=bool(spec.get("snapshot", False)))
|
|
_listen_with_ack(ctx, gap, selector, ack)
|
|
|
|
|
|
def _step_listen_ack(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
|
|
seconds = float(spec.get("seconds", spec.get("value", 1.0)))
|
|
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")),
|
|
}
|
|
)
|
|
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]:
|
|
spec = raw if isinstance(raw, dict) else {}
|
|
enabled = bool(spec.get("enabled", True))
|
|
ack_mode = str(spec.get("ack_mode", spec.get("mode", "fixed"))).strip().lower().replace("-", "_")
|
|
if ack_mode not in {"fixed", "cmd5_selector"}:
|
|
raise SystemExit(f"unknown ack_mode {ack_mode!r}")
|
|
target_mode = str(spec.get("target_mode", spec.get("match", "explicit"))).strip().lower().replace("-", "_")
|
|
if target_mode == "queued_report":
|
|
target_mode = "queued_reports"
|
|
if target_mode not in {"explicit", "queued_reports"}:
|
|
raise SystemExit(f"unknown target_mode {target_mode!r}")
|
|
limit_scope = str(spec.get("limit_scope", spec.get("scope", "global"))).strip().lower().replace("-", "_")
|
|
if limit_scope not in {"global", "local"}:
|
|
raise SystemExit(f"unknown limit_scope {limit_scope!r}")
|
|
targets = set() if target_mode == "queued_reports" else _parse_frame_list(spec.get("frames", spec.get("frame", DEFAULT_ACK_TARGET)))
|
|
if not enabled:
|
|
targets = set()
|
|
return {
|
|
"targets": set(targets),
|
|
"frame": _parse_optional_frame(spec.get("ack_frame"), DEFAULT_ACK_FRAME),
|
|
"guard": float(spec.get("ack_guard", 0.020)),
|
|
"poll_interval": float(spec.get("poll_interval", 0.005)),
|
|
"post_read": float(spec.get("post_ack_read", 0.250)),
|
|
"once_per_selector": bool(spec.get("once_per_selector", True)),
|
|
"enabled": enabled,
|
|
"max_acks": _optional_int(spec.get("max_acks")),
|
|
"max_target_hits": _optional_int(spec.get("max_target_hits")),
|
|
"abort_on_limit": bool(spec.get("abort_on_limit", True)),
|
|
"ack_mode": ack_mode,
|
|
"target_mode": target_mode,
|
|
"limit_scope": limit_scope,
|
|
}
|
|
|
|
|
|
def _ack_frame_for_target(target: bytes, ack: dict[str, Any]) -> bytes:
|
|
if ack["ack_mode"] == "fixed":
|
|
return ack["frame"]
|
|
if len(target) != 6:
|
|
raise SystemExit(f"cannot build selector ACK for malformed target {target!r}")
|
|
body = bytes([0x05, target[1] & 0x7F, target[2], 0x00, 0x00])
|
|
return body + bytes([frame_checksum(body)])
|
|
|
|
|
|
def _ack_matches(frame: bytes, ack: dict[str, Any]) -> bool:
|
|
if ack["target_mode"] == "explicit":
|
|
return frame in ack["targets"]
|
|
if frame == HEARTBEAT_FRAME or not frame_checksum_ok(frame):
|
|
return False
|
|
return frame[0] in {0x00, 0x01, 0x02} and not (frame[1] & 0x80)
|
|
|
|
|
|
def _listen_with_ack(
|
|
ctx: ScenarioContext,
|
|
seconds: float,
|
|
selector: int,
|
|
ack: dict[str, Any],
|
|
) -> list[bytes]:
|
|
deadline = time.monotonic() + max(0.0, seconds)
|
|
observed: list[bytes] = []
|
|
acked_targets: set[bytes] = set()
|
|
ack_start = ctx.ack_sent
|
|
target_start = sum(ctx.target_counts.values())
|
|
while time.monotonic() < deadline:
|
|
frames = _read_available(ctx, selector=selector)
|
|
observed.extend(frames)
|
|
if not frames:
|
|
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"]:
|
|
continue
|
|
for frame in frames:
|
|
if not _ack_matches(frame, ack):
|
|
continue
|
|
_count_target(ctx, frame)
|
|
if _ack_limit_reached(ctx, ack, ack_start=ack_start, target_start=target_start):
|
|
ctx.logger.event("ACK_LIMIT reached before ACK send")
|
|
if ack["abort_on_limit"]:
|
|
ctx.abort_requested = True
|
|
return observed
|
|
continue
|
|
if ack["once_per_selector"] and frame in acked_targets:
|
|
continue
|
|
acked_targets.add(frame)
|
|
if ack["guard"] > 0:
|
|
observed.extend(_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):
|
|
ctx.logger.event("ACK_LIMIT reached after ACK send")
|
|
if ack["abort_on_limit"]:
|
|
ctx.abort_requested = True
|
|
if ack["post_read"] > 0:
|
|
observed.extend(_listen(ctx, ack["post_read"], selector=selector))
|
|
if ctx.abort_requested:
|
|
return observed
|
|
return observed
|
|
|
|
|
|
def _listen_until(ctx: ScenarioContext, seconds: float, targets: set[bytes]) -> bool:
|
|
deadline = time.monotonic() + max(0.0, seconds)
|
|
while time.monotonic() < deadline:
|
|
interval = min(0.050, max(0.0, deadline - time.monotonic()))
|
|
if interval <= 0:
|
|
break
|
|
for frame in _listen(ctx, interval):
|
|
if frame in targets:
|
|
ctx.logger.event(f"WAIT_FOR_MATCH {format_frame(frame)}")
|
|
return True
|
|
return False
|
|
|
|
|
|
def _listen(ctx: ScenarioContext, seconds: float, *, selector: int | None = None) -> list[bytes]:
|
|
before = len(ctx.detector.frames)
|
|
_read_for(ctx.device, ctx.detector, ctx.logger, seconds)
|
|
frames = ctx.detector.frames[before:]
|
|
_record_table_rows(ctx, frames, selector)
|
|
return frames
|
|
|
|
|
|
def _read_available(ctx: ScenarioContext, *, selector: int | None = None) -> list[bytes]:
|
|
waiting = getattr(ctx.device, "in_waiting", 0)
|
|
if not waiting:
|
|
return []
|
|
dropped_before = ctx.detector.dropped_bytes
|
|
data = ctx.device.read(waiting)
|
|
if not data:
|
|
return []
|
|
ctx.logger.chunk("RX", data)
|
|
detected = ctx.detector.feed(data)
|
|
for frame, label in detected:
|
|
ctx.logger.event(f"DETECT {label} {format_frame(frame)}")
|
|
dropped_now = ctx.detector.dropped_bytes - dropped_before
|
|
if dropped_now:
|
|
ctx.logger.event(
|
|
f"RESYNC dropped_bytes={dropped_now} total_dropped={ctx.detector.dropped_bytes} "
|
|
f"buffered={len(ctx.detector.buffer)}"
|
|
)
|
|
frames = [frame for frame, _label in detected]
|
|
_record_table_rows(ctx, frames, selector)
|
|
return frames
|
|
|
|
|
|
def _record_table_rows(ctx: ScenarioContext, frames: list[bytes], selector: int | None) -> None:
|
|
for frame in frames:
|
|
decoded = decode_table_read_response(frame)
|
|
if decoded is None or selector is None:
|
|
continue
|
|
echo, value = decoded
|
|
row = {
|
|
"selector": selector,
|
|
"echo": echo,
|
|
"value": value,
|
|
"frame": format_frame(frame),
|
|
}
|
|
ctx.table_rows.append(row)
|
|
ctx.logger.event(f"TABLE selector=0x{selector:03X} echo={echo:02X} value={value:04X}")
|
|
|
|
|
|
def _send_and_record(ctx: ScenarioContext, frame: bytes, label: str, *, capture: bool = True) -> None:
|
|
if capture:
|
|
_capture_snapshots(ctx, frame, label, phase_prefix="pre", delays=[])
|
|
_send_frame(ctx.device, frame, ctx.logger, label)
|
|
if capture:
|
|
_capture_snapshots(ctx, frame, label, phase_prefix="tx", delays=_snapshot_delays(ctx.args))
|
|
ctx.tx_records.append(
|
|
{
|
|
"label": label,
|
|
"frame": format_frame(frame),
|
|
"checksum_ok": frame_checksum_ok(frame),
|
|
}
|
|
)
|
|
|
|
|
|
def _capture_snapshots(
|
|
ctx: ScenarioContext,
|
|
frame: bytes,
|
|
label: str,
|
|
*,
|
|
phase_prefix: str,
|
|
delays: list[float],
|
|
) -> None:
|
|
if ctx.snapshotter is None:
|
|
return
|
|
frame_text = format_frame(frame)
|
|
if phase_prefix == "pre":
|
|
if not ctx.args.snapshot_before_send:
|
|
return
|
|
phases = [("pre", 0.0)]
|
|
else:
|
|
phases = [(_delay_phase(delay), delay) for delay in delays]
|
|
|
|
for phase, delay in phases:
|
|
try:
|
|
record = ctx.snapshotter.schedule(
|
|
label=label,
|
|
frame_text=frame_text,
|
|
phase=phase,
|
|
delay_seconds=delay,
|
|
step_index=ctx.current_step_index,
|
|
)
|
|
except RuntimeError as exc:
|
|
ctx.logger.event(f"SNAPSHOT_ERROR {label} {phase} {exc}")
|
|
continue
|
|
ctx.snapshot_records.append(record)
|
|
ctx.logger.event(f"SNAPSHOT_SCHEDULE {phase} {label} {record['path']}")
|
|
|
|
|
|
def _delay_phase(delay: float) -> str:
|
|
millis = int(round(max(0.0, delay) * 1000.0))
|
|
return f"tx+{millis:04d}ms"
|
|
|
|
|
|
def _count_target(ctx: ScenarioContext, frame: bytes) -> None:
|
|
text = format_frame(frame)
|
|
ctx.target_counts[text] = ctx.target_counts.get(text, 0) + 1
|
|
ctx.logger.event(f"ACK_TARGET {text} count={ctx.target_counts[text]}")
|
|
|
|
|
|
def _selector_list(spec: dict[str, Any]) -> list[int]:
|
|
if "selectors" in spec:
|
|
raw_selectors = spec["selectors"]
|
|
if not isinstance(raw_selectors, list):
|
|
raise SystemExit("table_sweep selectors must be an array")
|
|
return [_int_value(selector) & 0x01FF for selector in raw_selectors]
|
|
start = _int_value(spec.get("start", 0))
|
|
count = max(0, _int_value(spec.get("count", 0x80)))
|
|
return [((start + offset) & 0x01FF) for offset in range(count)]
|
|
|
|
|
|
def _parse_frame_list(raw: Any) -> set[bytes]:
|
|
if raw is None:
|
|
return set()
|
|
values = raw if isinstance(raw, list) else [raw]
|
|
return {_parse_required_frame(value) for value in values}
|
|
|
|
|
|
def _parse_required_frame(raw: Any) -> bytes:
|
|
if raw is None:
|
|
raise SystemExit("frame is required")
|
|
if isinstance(raw, bytes):
|
|
return raw
|
|
if not isinstance(raw, str):
|
|
raise SystemExit(f"frame must be a hex string, got {raw!r}")
|
|
return parse_frame(raw)
|
|
|
|
|
|
def _parse_optional_frame(raw: Any, default: bytes) -> bytes:
|
|
if raw is None:
|
|
return default
|
|
return _parse_required_frame(raw)
|
|
|
|
|
|
def _int_value(raw: Any) -> int:
|
|
if isinstance(raw, int):
|
|
return raw
|
|
if isinstance(raw, str):
|
|
return int(raw, 0)
|
|
raise SystemExit(f"expected integer value, got {raw!r}")
|
|
|
|
|
|
def _optional_int(raw: Any) -> int | None:
|
|
if raw is None:
|
|
return None
|
|
return _int_value(raw)
|
|
|
|
|
|
def _ack_limit_reached(
|
|
ctx: ScenarioContext,
|
|
ack: dict[str, Any],
|
|
*,
|
|
ack_start: int = 0,
|
|
target_start: int = 0,
|
|
) -> bool:
|
|
if ack.get("limit_scope") == "local":
|
|
ack_count = ctx.ack_sent - ack_start
|
|
target_count = sum(ctx.target_counts.values()) - target_start
|
|
else:
|
|
ack_count = ctx.ack_sent
|
|
target_count = sum(ctx.target_counts.values())
|
|
max_acks = ack.get("max_acks")
|
|
if max_acks is not None and ack_count >= max_acks:
|
|
return True
|
|
max_target_hits = ack.get("max_target_hits")
|
|
if max_target_hits is not None and target_count >= max_target_hits:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _print_dry_run(args: argparse.Namespace, scenario: dict[str, Any], log_path: Path, stdout: TextIO) -> None:
|
|
print(f"scenario={scenario.get('name', args.scenario.stem)}", file=stdout)
|
|
print(f"device={args.port} {args.baud} {serial_format_label(args)}", file=stdout)
|
|
print(f"relay={args.relay_port} {args.relay_baud}", file=stdout)
|
|
print(f"sync={args.sync}", file=stdout)
|
|
print(f"log={log_path}", file=stdout)
|
|
snapshot_dir = _snapshot_output_dir(args, log_path)
|
|
if snapshot_dir is not None:
|
|
print(
|
|
f"snapshots={snapshot_dir} camera_index={_snapshot_camera_index(args)} "
|
|
f"before_send={int(args.snapshot_before_send)} "
|
|
f"delays={','.join(f'{delay:g}' for delay in _snapshot_delays(args))}",
|
|
file=stdout,
|
|
)
|
|
for index, step in enumerate(_scenario_steps(scenario), start=1):
|
|
action, spec = _normalize_step(step)
|
|
print(f"step[{index}]={action}", file=stdout)
|
|
_print_step_dry_run(action, spec, stdout)
|
|
|
|
|
|
class _FilteredStdout:
|
|
def __init__(self, stdout: TextIO, predicate: Callable[[str], bool]) -> None:
|
|
self.stdout = stdout
|
|
self.predicate = predicate
|
|
self.buffer = ""
|
|
|
|
def write(self, text: str) -> int:
|
|
self.buffer += text
|
|
while "\n" in self.buffer:
|
|
line, self.buffer = self.buffer.split("\n", 1)
|
|
if self.predicate(line):
|
|
self.stdout.write(line + "\n")
|
|
self.stdout.flush()
|
|
return len(text)
|
|
|
|
def flush(self) -> None:
|
|
if self.buffer:
|
|
if self.predicate(self.buffer):
|
|
self.stdout.write(self.buffer)
|
|
self.buffer = ""
|
|
self.stdout.flush()
|
|
|
|
|
|
def _quiet_console_line(line: str) -> bool:
|
|
if not line.strip():
|
|
return True
|
|
keep_fragments = (
|
|
"Serial bench scenario",
|
|
"name=",
|
|
"device=",
|
|
"log=",
|
|
"STEP ",
|
|
"PROMPT ",
|
|
"NOTE ",
|
|
"SNAPSHOT_SCHEDULE ",
|
|
"SNAPSHOT_ERROR ",
|
|
"Summary",
|
|
"rx_frames=",
|
|
"resync_events=",
|
|
"tx_frames=",
|
|
"abort_requested=",
|
|
"known_shutter",
|
|
"queued_shutter",
|
|
)
|
|
return any(fragment in line for fragment in keep_fragments)
|
|
|
|
|
|
def _print_step_dry_run(action: str, spec: dict[str, Any], stdout: TextIO, *, indent: str = " ") -> None:
|
|
if action == "send":
|
|
frame = _parse_required_frame(spec.get("frame"))
|
|
print(f"{indent}frame={format_frame(frame)} checksum_ok={int(frame_checksum_ok(frame))}", file=stdout)
|
|
if float(spec.get("listen", 0.0)) > 0:
|
|
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")),
|
|
}
|
|
)
|
|
print(f"{indent}seconds={float(spec.get('seconds', spec.get('value', 1.0))):.3f}", file=stdout)
|
|
if not ack["enabled"]:
|
|
print(f"{indent}ack=disabled", file=stdout)
|
|
else:
|
|
print(f"{indent}target_mode={ack['target_mode']}", file=stdout)
|
|
for target in sorted(ack["targets"]):
|
|
print(f"{indent}ack_target={format_frame(target)}", file=stdout)
|
|
print(f"{indent}ack_mode={ack['ack_mode']}", file=stdout)
|
|
if ack["ack_mode"] == "fixed":
|
|
print(f"{indent}ack_frame={format_frame(ack['frame'])}", file=stdout)
|
|
print(
|
|
f"{indent}limit_scope={ack['limit_scope']} max_acks={ack['max_acks']} "
|
|
f"max_target_hits={ack['max_target_hits']}",
|
|
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)
|
|
elif action == "wait_ready":
|
|
print(
|
|
f"{indent}heartbeats={int(spec.get('heartbeats', 2))} "
|
|
f"timeout={float(spec.get('timeout', 10.0)):.3f}s "
|
|
f"require={int(bool(spec.get('require', False)))}",
|
|
file=stdout,
|
|
)
|
|
elif action == "table_sweep":
|
|
selectors = _selector_list(spec)
|
|
ack = _ack_config(spec.get("ack_on", {}))
|
|
if selectors:
|
|
first = selectors[0]
|
|
last = selectors[-1]
|
|
print(f"{indent}selectors={len(selectors)} first=0x{first:03X} last=0x{last:03X}", file=stdout)
|
|
else:
|
|
print(f"{indent}selectors=0", file=stdout)
|
|
print(f"{indent}gap={float(spec.get('gap', 0.080)):.3f}", file=stdout)
|
|
if not ack["enabled"]:
|
|
print(f"{indent}ack=disabled", file=stdout)
|
|
else:
|
|
for target in sorted(ack["targets"]):
|
|
print(f"{indent}ack_target={format_frame(target)}", file=stdout)
|
|
print(f"{indent}ack_frame={format_frame(ack['frame'])}", file=stdout)
|
|
print(
|
|
f"{indent}limit_scope={ack['limit_scope']} max_acks={ack['max_acks']} "
|
|
f"max_target_hits={ack['max_target_hits']}",
|
|
file=stdout,
|
|
)
|
|
elif action == "repeat":
|
|
count = max(0, int(spec.get("count", 1)))
|
|
steps = spec.get("steps", [])
|
|
step_count = len(steps) if isinstance(steps, list) else 0
|
|
print(f"{indent}count={count} steps={step_count}", file=stdout)
|
|
if not isinstance(steps, list):
|
|
return
|
|
for child_index, child in enumerate(steps, start=1):
|
|
child_action, child_spec = _normalize_step(child)
|
|
print(f"{indent}child[{child_index}]={child_action}", file=stdout)
|
|
_print_step_dry_run(child_action, child_spec, stdout, indent=indent + " ")
|
|
|
|
|
|
def _emit_summary(ctx: ScenarioContext, logger: BenchLogger) -> None:
|
|
logger.emit()
|
|
logger.emit("Summary")
|
|
logger.emit(f"rx_frames={len(ctx.detector.frames)} trailing_unframed_bytes={len(ctx.detector.buffer)}")
|
|
logger.emit(f"resync_events={ctx.detector.resync_events} dropped_bytes={ctx.detector.dropped_bytes}")
|
|
logger.emit(f"tx_frames={len(ctx.tx_records)} ack_sent={ctx.ack_sent} table_response_rows={len(ctx.table_rows)}")
|
|
logger.emit(f"snapshots={len(ctx.snapshot_records)}")
|
|
logger.emit(f"abort_requested={int(ctx.abort_requested)}")
|
|
for target, count in sorted(ctx.target_counts.items()):
|
|
logger.emit(f"ack_target {target}={count}")
|
|
for label, count in sorted(ctx.detector.labels.items()):
|
|
logger.emit(f"{label}={count}")
|
|
for row in ctx.table_rows:
|
|
logger.emit(
|
|
f"table selector=0x{row['selector']:03X} echo=0x{row['echo']:02X} value=0x{row['value']:04X}"
|
|
)
|
|
|
|
|
|
def _write_result_json(path: Path, scenario: dict[str, Any], log_path: Path, ctx: ScenarioContext) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
result = {
|
|
"scenario": scenario.get("name", ""),
|
|
"log": str(log_path),
|
|
"rx_frames": len(ctx.detector.frames),
|
|
"trailing_unframed_bytes": len(ctx.detector.buffer),
|
|
"resync_events": ctx.detector.resync_events,
|
|
"dropped_bytes": ctx.detector.dropped_bytes,
|
|
"labels": dict(ctx.detector.labels),
|
|
"tx_frames": ctx.tx_records,
|
|
"ack_sent": ctx.ack_sent,
|
|
"snapshots": ctx.snapshot_records,
|
|
"abort_requested": ctx.abort_requested,
|
|
"ack_targets": ctx.target_counts,
|
|
"table_rows": ctx.table_rows,
|
|
}
|
|
path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_ACK_FRAME",
|
|
"DEFAULT_ACK_TARGET",
|
|
"build_arg_parser",
|
|
"load_scenario",
|
|
"main",
|
|
]
|