1
0
Files
h8-536-decoder/ccu_emulator/controller.py
2026-05-27 11:50:10 +10:00

150 lines
5.4 KiB
Python

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 .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
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,
) -> 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.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)}"
)
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)
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_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"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}")