traces
This commit is contained in:
149
ccu_emulator/controller.py
Normal file
149
ccu_emulator/controller.py
Normal file
@@ -0,0 +1,149 @@
|
||||
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}")
|
||||
Reference in New Issue
Block a user