150 lines
5.4 KiB
Python
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}")
|