from __future__ import annotations import time from dataclasses import dataclass from h8536.bench_connect_lcd import BenchLogger, format_frame from .frames import ACTIVE_SEED_COMMAND0 from .modules import CcuModule, ModuleDecision from .policy import AckPolicy from .refresh import PeriodicRefresh from .serial_link import RxFrame, SerialLink @dataclass class CcuStats: rx_frames: int = 0 tx_frames: int = 0 ack_frames: int = 0 module_frames: int = 0 seed_frames: int = 0 refresh_frames: int = 0 started_at: float = 0.0 ended_at: float = 0.0 @property def elapsed(self) -> float: end = self.ended_at or time.monotonic() return max(0.0, end - self.started_at) @dataclass(frozen=True) class CcuConfig: seed_frames: tuple[bytes, ...] = (ACTIVE_SEED_COMMAND0,) seed_gap: float = 0.050 ack_delay: float = 0.0 ready_heartbeats: int = 1 ready_timeout: float = 10.0 loop_poll: float = 0.001 class CcuEmulator: """Event-driven fake CCU for the PT2-style RCP serial protocol.""" def __init__( self, link: SerialLink, logger: BenchLogger, *, config: CcuConfig | None = None, ack_policy: AckPolicy | None = None, refresh: PeriodicRefresh | None = None, modules: tuple[CcuModule, ...] = (), ) -> None: self.link = link self.logger = logger self.config = config or CcuConfig() self.ack_policy = ack_policy or AckPolicy() self.refresh = refresh or PeriodicRefresh() self.modules = modules self.stats = CcuStats() def run(self, duration: float) -> CcuStats: self.stats = CcuStats(started_at=time.monotonic()) self.logger.event( "CCU_START " f"duration={duration:.3f}s seed_frames={len(self.config.seed_frames)} " f"ack={format_frame(self.ack_policy.ack_frame)} modules={len(self.modules)}" ) self._wait_ready() self._send_seed_frames() self.refresh.start() deadline = time.monotonic() + max(0.0, duration) try: while time.monotonic() < deadline: self._service_rx() self._service_refresh() time.sleep(max(0.0, self.config.loop_poll)) except KeyboardInterrupt: self.logger.event("CCU_STOP keyboard_interrupt") finally: self.stats.ended_at = time.monotonic() self._emit_summary() return self.stats def _wait_ready(self) -> None: if self.config.ready_heartbeats <= 0: self.logger.event("READY skipped") return self.logger.event( f"READY_WAIT heartbeats={self.config.ready_heartbeats} timeout={self.config.ready_timeout:.3f}s" ) start_count = self.link.detector.labels["heartbeat"] deadline = time.monotonic() + max(0.0, self.config.ready_timeout) while time.monotonic() < deadline: for frame in self.link.read_available(): self._record_rx(frame) if self.link.detector.labels["heartbeat"] - start_count >= self.config.ready_heartbeats: self.logger.event(f"READY heartbeat_count={self.link.detector.labels['heartbeat']}") return time.sleep(max(0.0, self.config.loop_poll)) self.logger.event(f"READY_TIMEOUT heartbeat_count={self.link.detector.labels['heartbeat']}") def _send_seed_frames(self) -> None: for index, frame in enumerate(self.config.seed_frames, start=1): self.link.send(frame, f"seed[{index}]") self.stats.seed_frames += 1 self.stats.tx_frames += 1 self._listen_for(self.config.seed_gap) def _listen_for(self, seconds: float) -> None: deadline = time.monotonic() + max(0.0, seconds) while time.monotonic() < deadline: self._service_rx() self._service_refresh() time.sleep(max(0.0, self.config.loop_poll)) def _service_rx(self) -> None: for item in self.link.read_available(): self._record_rx(item) suppress_default_ack = self._service_modules(item) if suppress_default_ack: continue decision = self.ack_policy.decide(item.frame, item.label) if not decision.should_ack: self.logger.event(f"ACK_SKIP reason={decision.reason} frame={format_frame(item.frame)}") continue if self.config.ack_delay > 0: time.sleep(self.config.ack_delay) self.link.send(decision.frame, f"ack reason={decision.reason}") self.stats.ack_frames += 1 self.stats.tx_frames += 1 def _service_modules(self, item: RxFrame) -> bool: suppress_default_ack = False for module in self.modules: decision = module.on_rx(item.frame, item.label) if decision is None: continue if decision.reason: self.logger.event( f"MODULE {module.name} reason={decision.reason} frame={format_frame(item.frame)}" ) self._send_module_decision(decision) suppress_default_ack = suppress_default_ack or decision.suppress_default_ack return suppress_default_ack def _send_module_decision(self, decision: ModuleDecision) -> None: for tx in decision.tx: if tx.delay > 0: time.sleep(tx.delay) self.link.send(tx.frame, tx.label) self.stats.module_frames += 1 self.stats.tx_frames += 1 def _service_refresh(self) -> None: for frame in self.refresh.due_frames(): self.link.send(frame, "refresh") self.stats.refresh_frames += 1 self.stats.tx_frames += 1 def _record_rx(self, item: RxFrame) -> None: self.stats.rx_frames += 1 def _emit_summary(self) -> None: self.logger.emit() self.logger.emit("CCU Summary") self.logger.emit(f"elapsed={self.stats.elapsed:.3f}s") self.logger.emit(f"rx_frames={self.stats.rx_frames}") self.logger.emit(f"tx_frames={self.stats.tx_frames}") self.logger.emit(f"ack_frames={self.stats.ack_frames}") self.logger.emit(f"module_frames={self.stats.module_frames}") self.logger.emit(f"seed_frames={self.stats.seed_frames}") self.logger.emit(f"refresh_frames={self.stats.refresh_frames}") self.logger.emit(f"resync_events={self.link.detector.resync_events}") self.logger.emit(f"dropped_bytes={self.link.detector.dropped_bytes}") for label, count in sorted(self.link.detector.labels.items()): self.logger.emit(f"{label}={count}")