1
0

webcam copy

This commit is contained in:
Aiden
2026-05-27 12:17:12 +10:00
parent c0304c575c
commit 21f0e455ee
6 changed files with 410 additions and 9 deletions

View File

@@ -63,6 +63,19 @@ To start the current emulator harness:
``` ```
The real-device bench helper uses `pyserial`; install repo dependencies with `.\.venv\Scripts\python.exe -m pip install -r requirements.txt` if needed. The real-device bench helper uses `pyserial`; install repo dependencies with `.\.venv\Scripts\python.exe -m pip install -r requirements.txt` if needed.
Optional webcam snapshots for `scripts\serial_scenario.py` use OpenCV; install it only on the bench machine that needs panel photos:
```powershell
.\.venv\Scripts\python.exe -m pip install opencv-python
```
Example snapshot run:
```powershell
.\.venv\Scripts\python.exe scripts\serial_scenario.py scenarios\copy-step-006d-006c-1000ms.json --parity E --quiet-console --log captures\copy-webcam.txt --result-json captures\copy-webcam-result.json --snapshot-dir captures\copy-webcam-shots --camera-index 4 --snapshot-delays 0.5
```
Current bench calibration: webcam index `4` gives the useful panel view, and a `0.5` second post-command delay is enough for LCD changes to settle without producing too many images.
The current PT2/protocol reconstruction is documented in [docs/pt2-protocol.md](docs/pt2-protocol.md), with focused mini-notes for [COPY state](docs/pt2-copy-state-machine.md), [menu state](docs/pt2-menu-state-machine.md), the [session rhythm ROM trace](docs/pt2-session-rhythm-trace.md), [continuation commands](docs/pt2-continuation-command-trace.md), and [report aftermath handling](docs/pt2-report-aftermath-trace.md). The current PT2/protocol reconstruction is documented in [docs/pt2-protocol.md](docs/pt2-protocol.md), with focused mini-notes for [COPY state](docs/pt2-copy-state-machine.md), [menu state](docs/pt2-menu-state-machine.md), the [session rhythm ROM trace](docs/pt2-session-rhythm-trace.md), [continuation commands](docs/pt2-continuation-command-trace.md), and [report aftermath handling](docs/pt2-report-aftermath-trace.md).
@@ -135,6 +148,7 @@ Minimal smoke-test shape:
- Includes a bench ACK probe that reproduces the `01 00 00...` -> `01 00 01...` visible retry burst, waits for `07 80 40 20 90 2D`, then sends a candidate command-5 ACK and reports whether the target keeps repeating. - Includes a bench ACK probe that reproduces the `01 00 00...` -> `01 00 01...` visible retry burst, waits for `07 80 40 20 90 2D`, then sends a candidate command-5 ACK and reports whether the target keeps repeating.
- Includes a checksum-resynchronizing bench receiver that scans RX byte streams for valid six-byte frames, avoids common shifted-heartbeat false locks, and can fall back to the old fixed six-byte slicer with `--sync fixed`. - Includes a checksum-resynchronizing bench receiver that scans RX byte streams for valid six-byte frames, avoids common shifted-heartbeat false locks, and can fall back to the old fixed six-byte slicer with `--sync fixed`.
- Includes a JSON scenario bench runner for repeatable multi-step serial tests, including low-latency ACK-aware command-1 probes that can send the current command-5 ACK candidate immediately after the retry frame appears, with explicit max-ACK/max-target guardrails. - Includes a JSON scenario bench runner for repeatable multi-step serial tests, including low-latency ACK-aware command-1 probes that can send the current command-5 ACK candidate immediately after the retry frame appears, with explicit max-ACK/max-target guardrails.
- The scenario runner can optionally schedule webcam snapshots on each command send, writing image paths into logs/result JSON without delaying timing-sensitive serial steps.
- Includes a modular fake-CCU runner in `ccu_emulator/` that seeds active state, listens for complete RCP report frames, and immediately sends the neutral command-5 ACK `05 00 40 00 00 1F`; optional periodic refresh frames let lamp/value streaming be tested separately from report ACKs. - Includes a modular fake-CCU runner in `ccu_emulator/` that seeds active state, listens for complete RCP report frames, and immediately sends the neutral command-5 ACK `05 00 40 00 00 1F`; optional periodic refresh frames let lamp/value streaming be tested separately from report ACKs.
- Includes a PT2 state-map-aware bench runner/analyzer for the current CONNECT gate proof: it hunts a fresh device `07...` visible-drain token candidate, sends exactly one selector-zero command-4 force, probes `E000[0]` with command 1, optionally uses command 7 to recover a hidden finalized response, and labels likely token-destroying turns. - Includes a PT2 state-map-aware bench runner/analyzer for the current CONNECT gate proof: it hunts a fresh device `07...` visible-drain token candidate, sends exactly one selector-zero command-4 force, probes `E000[0]` with command 1, optionally uses command 7 to recover a hidden finalized response, and labels likely token-destroying turns.
- Includes a bounded emulator CONNECT state-search tool that patches small ROM-derived RAM/table surfaces, runs either the direct CONNECT branch or the selector-zero queue dispatch path, and classifies LCD outcomes as OK, DXC, NOT ACT, or other. - Includes a bounded emulator CONNECT state-search tool that patches small ROM-derived RAM/table surfaces, runs either the direct CONNECT branch or the selector-zero queue dispatch path, and classifies LCD outcomes as OK, DXC, NOT ACT, or other.

View File

@@ -124,15 +124,25 @@ Family-00 comparison run on 2026-05-27:
-> 02 00 04 responses after 006C -> 02 00 04 responses after 006C
``` ```
This suggests the family-00 forms update/read back selector values but should not Webcam-confirmed LCD results from the same 2026-05-27 run:
be treated as equivalent to the command-5 COPY side-effect selectors unless an
LCD observation proves otherwise. ```text
00 00 6D 00 00 37 -> COPY IN PROGRESS
00 00 6C 00 00 36 -> COPY COMPLETED
05 00 6D 00 00 32 -> COPY IN PROGRESS
05 00 6C 00 00 33 -> COPY COMPLETED
```
The 250 ms and 1000 ms gap variants both produced the same LCD sequence. This
means the family-00 selector/write form can reach the COPY LCD side effects, even
though its serial response rhythm is the table-readback rhythm rather than the
command-5 `01 00 02` / `02 00 04` rhythm.
Current interpretation: Current interpretation:
- `0x006D` is a copy-start/progress-window refresh selector. - `0x006D` is a copy-start/progress-window refresh selector.
- `0x006C` is a completion/exit selector that only behaves cleanly while the copy window is live. - `0x006C` is a completion/exit selector that only behaves cleanly while the copy window is live.
- The observed `00 00 6C 00 00 36` frame is copy-related, but should not be read as "COPY COMPLETED" by itself. - The observed `00 00 6C 00 00 36` frame can display `COPY COMPLETED` when it is sent inside a live copy window, but should not be read as a stateless "show completed" command.
- The copy window is transient and timer-controlled. - The copy window is transient and timer-controlled.
- The panel does not treat `0x006C` as a stateless "show completed" command. - The panel does not treat `0x006C` as a stateless "show completed" command.

View File

@@ -646,6 +646,25 @@ The gated scenario first seeds candidate secondary-table feature bits with comma
Then it streams `E000[0x008F]=0x1800` and `E000[0x0093]=0x90FF`. This is the better next watch if quiet/broad E000-only refreshes do not produce local-control TX. Then it streams `E000[0x008F]=0x1800` and `E000[0x0093]=0x90FF`. This is the better next watch if quiet/broad E000-only refreshes do not produce local-control TX.
## Bench Webcam Capture
For tests where LCD timing/state matters, `serial_scenario.py` can take webcam
snapshots tied to exact TX commands. Current bench calibration:
```powershell
--camera-index 4 --snapshot-delays 0.5
```
Camera index `4` is the working panel-facing webcam on the current PC. A single
`0.5` second post-TX delay captures readable LCD changes without creating the
large image sets produced by `0,0.25,1.0`.
Example:
```powershell
.\.venv\Scripts\python.exe scripts\serial_scenario.py scenarios\copy-step-006d-006c-1000ms.json --parity E --quiet-console --log captures\copy-webcam.txt --result-json captures\copy-webcam-result.json --snapshot-dir captures\copy-webcam-shots --camera-index 4 --snapshot-delays 0.5
```
After a run, summarize unexpected device frames with: After a run, summarize unexpected device frames with:
```powershell ```powershell

168
h8536/camera_snapshots.py Normal file
View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import re
import heapq
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+")
class CameraSnapshots:
"""Small optional OpenCV webcam wrapper for bench-test snapshots."""
def __init__(
self,
*,
camera_index: int,
output_dir: Path,
warmup_seconds: float = 0.5,
width: int | None = None,
height: int | None = None,
) -> None:
self.camera_index = camera_index
self.output_dir = output_dir
self.warmup_seconds = max(0.0, warmup_seconds)
self.width = width
self.height = height
self.cv2: Any | None = None
self.device: Any | None = None
self._condition = threading.Condition()
self._tasks: list[tuple[float, int, dict[str, Any]]] = []
self._records: list[dict[str, Any]] = []
self._thread: threading.Thread | None = None
self._seq = 0
self._closing = False
def open(self) -> None:
try:
import cv2
except ImportError as exc: # pragma: no cover - depends on local bench environment.
raise SystemExit(
"OpenCV is required for webcam snapshots. Install it with: "
".\\.venv\\Scripts\\python.exe -m pip install opencv-python"
) from exc
self.output_dir.mkdir(parents=True, exist_ok=True)
self.cv2 = cv2
self.device = cv2.VideoCapture(self.camera_index)
if not self.device.isOpened():
raise SystemExit(f"could not open webcam index {self.camera_index}")
if self.width:
self.device.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
if self.height:
self.device.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
self._warm_up()
self._thread = threading.Thread(target=self._run, name="camera-snapshots", daemon=True)
self._thread.start()
def close(self) -> None:
with self._condition:
self._closing = True
self._condition.notify_all()
if self._thread is not None:
self._thread.join()
self._thread = None
if self.device is not None:
self.device.release()
self.device = None
def schedule(
self,
*,
label: str,
frame_text: str,
phase: str,
delay_seconds: float = 0.0,
step_index: int | None = None,
) -> dict[str, Any]:
if self.cv2 is None or self.device is None:
raise RuntimeError("camera snapshotter is not open")
due_monotonic = time.monotonic() + max(0.0, delay_seconds)
due_timestamp = datetime.now() + timedelta(seconds=max(0.0, delay_seconds))
timestamp = due_timestamp.strftime("%Y%m%d-%H%M%S-%f")[:-3]
step_part = f"step{step_index:03d}_" if step_index is not None else ""
frame_part = frame_text.replace(" ", "")
filename = _safe_name(f"{timestamp}_{step_part}{phase}_{label}_{frame_part}.jpg")
path = self.output_dir / filename
record: dict[str, Any] = {
"path": str(path),
"scheduled_timestamp": timestamp,
"camera_index": self.camera_index,
"step_index": step_index,
"phase": phase,
"delay_seconds": delay_seconds,
"label": label,
"frame": frame_text,
"status": "scheduled",
}
with self._condition:
self._seq += 1
heapq.heappush(self._tasks, (due_monotonic, self._seq, record))
self._records.append(record)
self._condition.notify_all()
return record
def records(self) -> list[dict[str, Any]]:
with self._condition:
return [dict(record) for record in self._records]
def _run(self) -> None:
while True:
with self._condition:
while not self._tasks and not self._closing:
self._condition.wait()
if not self._tasks and self._closing:
return
due_monotonic, _seq, record = self._tasks[0]
wait_seconds = due_monotonic - time.monotonic()
if wait_seconds > 0:
self._condition.wait(wait_seconds)
continue
heapq.heappop(self._tasks)
self._write_snapshot(record)
def _write_snapshot(self, record: dict[str, Any]) -> None:
if self.cv2 is None or self.device is None:
record["status"] = "error"
record["error"] = "camera closed before capture"
return
image = None
ok = False
for _attempt in range(3):
ok, image = self.device.read()
if ok:
break
time.sleep(0.020)
if not ok or image is None:
record["status"] = "error"
record["error"] = f"webcam index {self.camera_index} did not return an image"
return
path = Path(str(record["path"]))
if not self.cv2.imwrite(str(path), image):
record["status"] = "error"
record["error"] = f"failed to write webcam snapshot {path}"
return
record["status"] = "written"
record["captured_timestamp"] = datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
def _warm_up(self) -> None:
if self.device is None:
return
deadline = time.monotonic() + self.warmup_seconds
while time.monotonic() < deadline:
self.device.read()
time.sleep(0.020)
self.device.read()
def _safe_name(text: str) -> str:
cleaned = _SAFE_NAME_RE.sub("_", text.strip()).strip("._")
return cleaned or "snapshot.jpg"

View File

@@ -26,6 +26,7 @@ from .bench_connect_lcd import (
parse_frame, parse_frame,
serial_format_label, serial_format_label,
) )
from .camera_snapshots import CameraSnapshots
from .serial_table_dump import build_read_frame, decode_table_read_response from .serial_table_dump import build_read_frame, decode_table_read_response
@@ -44,6 +45,9 @@ class ScenarioContext:
table_rows: list[dict[str, Any]] = field(default_factory=list) table_rows: list[dict[str, Any]] = field(default_factory=list)
target_counts: dict[str, int] = field(default_factory=dict) target_counts: dict[str, int] = field(default_factory=dict)
tx_records: list[dict[str, Any]] = field(default_factory=list) 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 ack_sent: int = 0
abort_requested: bool = False abort_requested: bool = False
@@ -54,6 +58,37 @@ def default_log_path(scenario: dict[str, Any]) -> Path:
return Path("captures") / f"{safe_name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt" 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: def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run JSON-described serial bench scenarios against the real RCP." description="Run JSON-described serial bench scenarios against the real RCP."
@@ -76,6 +111,34 @@ def build_arg_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="keep full logs in --log but suppress RX/DETECT chatter on the console", 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") parser.add_argument("--dry-run", action="store_true", help="print the plan without opening serial ports")
return parser return parser
@@ -93,6 +156,8 @@ def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int:
console = _FilteredStdout(stdout, _quiet_console_line) if args.quiet_console else stdout console = _FilteredStdout(stdout, _quiet_console_line) if args.quiet_console else stdout
logger = BenchLogger(log_path, stdout=console) logger = BenchLogger(log_path, stdout=console)
detector = FrameDetector(sync_mode=args.sync) detector = FrameDetector(sync_mode=args.sync)
snapshotter: CameraSnapshots | None = None
ctx: ScenarioContext | None = None
try: try:
logger.emit("Serial bench scenario") logger.emit("Serial bench scenario")
logger.emit(f"name={scenario.get('name', args.scenario.stem)}") logger.emit(f"name={scenario.get('name', args.scenario.stem)}")
@@ -101,24 +166,54 @@ def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int:
f"relay={args.relay_port} {args.relay_baud} sync={args.sync}" f"relay={args.relay_port} {args.relay_baud} sync={args.sync}"
) )
logger.emit(f"log={log_path}") 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: with open_device_serial(serial, args) as device:
ctx = ScenarioContext(args=args, logger=logger, detector=detector, device=device) ctx = ScenarioContext(
args=args,
logger=logger,
detector=detector,
device=device,
snapshotter=snapshotter,
)
try: try:
for index, step in enumerate(_scenario_steps(scenario), start=1): for index, step in enumerate(_scenario_steps(scenario), start=1):
if ctx.abort_requested: if ctx.abort_requested:
logger.event("SCENARIO_ABORT requested by prior step") logger.event("SCENARIO_ABORT requested by prior step")
break break
action, spec = _normalize_step(step) action, spec = _normalize_step(step)
ctx.current_step_index = index
logger.event(f"STEP {index} {action}") logger.event(f"STEP {index} {action}")
_run_step(ctx, action, spec) _run_step(ctx, action, spec)
finally: finally:
ctx.current_step_index = None
if ctx.relay is not None: if ctx.relay is not None:
ctx.relay.close() 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) _emit_summary(ctx, logger)
if args.result_json: if args.result_json:
_write_result_json(args.result_json, scenario, log_path, ctx) _write_result_json(args.result_json, scenario, log_path, ctx)
return 0 return 0
finally: finally:
if snapshotter is not None:
snapshotter.close()
logger.close() logger.close()
@@ -178,7 +273,8 @@ def _run_step(ctx: ScenarioContext, action: str, spec: dict[str, Any]) -> None:
elif action == "send": elif action == "send":
frame = _parse_required_frame(spec.get("frame")) frame = _parse_required_frame(spec.get("frame"))
label = str(spec.get("label", "send")) label = str(spec.get("label", "send"))
_send_and_record(ctx, frame, label) capture = bool(spec.get("snapshot", True))
_send_and_record(ctx, frame, label, capture=capture)
if float(spec.get("listen", 0.0)) > 0: if float(spec.get("listen", 0.0)) > 0:
_listen(ctx, float(spec.get("listen", 0.0))) _listen(ctx, float(spec.get("listen", 0.0)))
elif action == "wait_for": elif action == "wait_for":
@@ -276,7 +372,7 @@ def _step_table_sweep(ctx: ScenarioContext, spec: dict[str, Any]) -> None:
break break
frame = build_read_frame(selector) frame = build_read_frame(selector)
ctx.logger.event(f"READ selector=0x{selector:03X} frame={format_frame(frame)}") ctx.logger.event(f"READ selector=0x{selector:03X} frame={format_frame(frame)}")
_send_and_record(ctx, frame, f"read_0x{selector:03X}") _send_and_record(ctx, frame, f"read_0x{selector:03X}", capture=bool(spec.get("snapshot", False)))
_listen_with_ack(ctx, gap, selector, ack) _listen_with_ack(ctx, gap, selector, ack)
@@ -398,7 +494,7 @@ def _listen_with_ack(
acked_targets.add(frame) acked_targets.add(frame)
if ack["guard"] > 0: if ack["guard"] > 0:
observed.extend(_listen(ctx, ack["guard"], selector=selector)) observed.extend(_listen(ctx, ack["guard"], selector=selector))
_send_and_record(ctx, _ack_frame_for_target(frame, ack), "ack") _send_and_record(ctx, _ack_frame_for_target(frame, ack), "ack", capture=ctx.args.snapshot_acks)
ctx.ack_sent += 1 ctx.ack_sent += 1
if _ack_limit_reached(ctx, ack, ack_start=ack_start, target_start=target_start): if _ack_limit_reached(ctx, ack, ack_start=ack_start, target_start=target_start):
ctx.logger.event("ACK_LIMIT reached after ACK send") ctx.logger.event("ACK_LIMIT reached after ACK send")
@@ -471,8 +567,12 @@ def _record_table_rows(ctx: ScenarioContext, frames: list[bytes], selector: int
ctx.logger.event(f"TABLE selector=0x{selector:03X} echo={echo:02X} value={value:04X}") 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) -> None: 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) _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( ctx.tx_records.append(
{ {
"label": label, "label": label,
@@ -482,6 +582,45 @@ def _send_and_record(ctx: ScenarioContext, frame: bytes, label: str) -> None:
) )
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: def _count_target(ctx: ScenarioContext, frame: bytes) -> None:
text = format_frame(frame) text = format_frame(frame)
ctx.target_counts[text] = ctx.target_counts.get(text, 0) + 1 ctx.target_counts[text] = ctx.target_counts.get(text, 0) + 1
@@ -564,6 +703,14 @@ def _print_dry_run(args: argparse.Namespace, scenario: dict[str, Any], log_path:
print(f"relay={args.relay_port} {args.relay_baud}", file=stdout) print(f"relay={args.relay_port} {args.relay_baud}", file=stdout)
print(f"sync={args.sync}", file=stdout) print(f"sync={args.sync}", file=stdout)
print(f"log={log_path}", 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): for index, step in enumerate(_scenario_steps(scenario), start=1):
action, spec = _normalize_step(step) action, spec = _normalize_step(step)
print(f"step[{index}]={action}", file=stdout) print(f"step[{index}]={action}", file=stdout)
@@ -604,6 +751,8 @@ def _quiet_console_line(line: str) -> bool:
"STEP ", "STEP ",
"PROMPT ", "PROMPT ",
"NOTE ", "NOTE ",
"SNAPSHOT_SCHEDULE ",
"SNAPSHOT_ERROR ",
"Summary", "Summary",
"rx_frames=", "rx_frames=",
"resync_events=", "resync_events=",
@@ -706,6 +855,7 @@ def _emit_summary(ctx: ScenarioContext, logger: BenchLogger) -> None:
logger.emit(f"rx_frames={len(ctx.detector.frames)} trailing_unframed_bytes={len(ctx.detector.buffer)}") 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"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"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)}") logger.emit(f"abort_requested={int(ctx.abort_requested)}")
for target, count in sorted(ctx.target_counts.items()): for target, count in sorted(ctx.target_counts.items()):
logger.emit(f"ack_target {target}={count}") logger.emit(f"ack_target {target}={count}")
@@ -729,6 +879,7 @@ def _write_result_json(path: Path, scenario: dict[str, Any], log_path: Path, ctx
"labels": dict(ctx.detector.labels), "labels": dict(ctx.detector.labels),
"tx_frames": ctx.tx_records, "tx_frames": ctx.tx_records,
"ack_sent": ctx.ack_sent, "ack_sent": ctx.ack_sent,
"snapshots": ctx.snapshot_records,
"abort_requested": ctx.abort_requested, "abort_requested": ctx.abort_requested,
"ack_targets": ctx.target_counts, "ack_targets": ctx.target_counts,
"table_rows": ctx.table_rows, "table_rows": ctx.table_rows,

View File

@@ -45,6 +45,45 @@ class SerialScenarioTest(unittest.TestCase):
self.assertEqual(DEFAULT_ACK_TARGET, bytes.fromhex("07804020902D")) self.assertEqual(DEFAULT_ACK_TARGET, bytes.fromhex("07804020902D"))
self.assertEqual(DEFAULT_ACK_FRAME, bytes.fromhex("05004000001F")) self.assertEqual(DEFAULT_ACK_FRAME, bytes.fromhex("05004000001F"))
def test_dry_run_summarizes_webcam_snapshot_options(self):
scenario = {
"name": "unit-camera",
"steps": [
{
"action": "send",
"label": "camera_test",
"frame": "00 00 00 80 80 5A",
}
],
}
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "scenario.json"
snapshot_dir = Path(tmpdir) / "shots"
path.write_text(json.dumps(scenario), encoding="utf-8")
stdout = io.StringIO()
exit_code = main(
[
str(path),
"--dry-run",
"--snapshot-dir",
str(snapshot_dir),
"--camera-index",
"1",
"--snapshot-before-send",
"--snapshot-delays",
"0,0.25,1",
],
stdout=stdout,
)
output = stdout.getvalue()
self.assertEqual(exit_code, 0)
self.assertIn(f"snapshots={snapshot_dir}", output)
self.assertIn("camera_index=1", output)
self.assertIn("before_send=1", output)
self.assertIn("delays=0,0.25,1", output)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()