traces
This commit is contained in:
69
ccu_emulator/README.md
Normal file
69
ccu_emulator/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# PT2 Fake CCU
|
||||
|
||||
This folder contains a small modular fake-CCU runner for the Sony RCP PT2-style serial link.
|
||||
|
||||
The first goal is simple:
|
||||
|
||||
1. Open the RCP serial link at `38400 8E1`.
|
||||
2. Optionally wait for heartbeat.
|
||||
3. Seed active selector-zero state.
|
||||
4. Listen for complete RCP report frames.
|
||||
5. Immediately answer report-looking frames with the neutral command-5 ACK:
|
||||
|
||||
```text
|
||||
05 00 40 00 00 1F
|
||||
```
|
||||
|
||||
ROM and emulator evidence says this consumes the outstanding report cursor without triggering COPY or lamp/display side effects.
|
||||
|
||||
## Run
|
||||
|
||||
Dry-run configuration:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts\ccu_emulator.py --dry-run
|
||||
```
|
||||
|
||||
Run against the bench RCP on COM5:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts\ccu_emulator.py --port COM5 --duration 30 --log captures\ccu-keepalive.txt
|
||||
```
|
||||
|
||||
Power-cycle first through the COM6 relay:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts\ccu_emulator.py --port COM5 --power-cycle --relay-port COM6 --duration 30 --log captures\ccu-keepalive-powercycle.txt
|
||||
```
|
||||
|
||||
Try the older three-frame CONNECT cadence seed instead of the command-0 active seed:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts\ccu_emulator.py --seed connect-sequence --seed-gap 0.700 --duration 30
|
||||
```
|
||||
|
||||
Optionally add periodic state refresh traffic:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts\ccu_emulator.py --refresh-active --refresh-interval 0.600 --duration 30
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
- `frames.py`: checksums, built-in frames, and simple host-frame builders.
|
||||
- `policy.py`: decides whether an RCP frame should be ACKed.
|
||||
- `refresh.py`: optional periodic state-refresh scheduling.
|
||||
- `serial_link.py`: serial read/write plus checksum-resync frame detection.
|
||||
- `controller.py`: event loop and stats.
|
||||
- `cli.py`: command-line entry point.
|
||||
|
||||
## Why Periodic Lamp/Value Writes Help
|
||||
|
||||
Repeated button/lamp/status writes probably do play into the normal CCU flow. The ROM reloads the broad `F9C5` session watchdog on every complete six-byte RX frame, and command-0/command-4/command-6 table writes refresh the selector tables that drive lamps, readouts, and menus.
|
||||
|
||||
So a real CCU likely does both:
|
||||
|
||||
- reactively ACK RCP reports so the report queue advances, and
|
||||
- stream or refresh panel state so the visible UI remains current.
|
||||
|
||||
The first version keeps those concerns separate: reactive ACKs are always available, while periodic refresh frames are opt-in with `--refresh-frame` or `--refresh-active`.
|
||||
28
ccu_emulator/__init__.py
Normal file
28
ccu_emulator/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Small fake-CCU helpers for the Sony RCP PT2-style serial link."""
|
||||
|
||||
from .controller import CcuEmulator, CcuStats
|
||||
from .frames import (
|
||||
ACTIVE_SEED_COMMAND0,
|
||||
CONNECT_CADENCE_SEQUENCE,
|
||||
HEARTBEAT_FRAME,
|
||||
NEUTRAL_ACK_FRAME,
|
||||
format_frame,
|
||||
frame_checksum,
|
||||
frame_checksum_ok,
|
||||
parse_frame,
|
||||
)
|
||||
from .policy import AckPolicy
|
||||
|
||||
__all__ = [
|
||||
"ACTIVE_SEED_COMMAND0",
|
||||
"CONNECT_CADENCE_SEQUENCE",
|
||||
"CcuEmulator",
|
||||
"CcuStats",
|
||||
"HEARTBEAT_FRAME",
|
||||
"NEUTRAL_ACK_FRAME",
|
||||
"AckPolicy",
|
||||
"format_frame",
|
||||
"frame_checksum",
|
||||
"frame_checksum_ok",
|
||||
"parse_frame",
|
||||
]
|
||||
5
ccu_emulator/__main__.py
Normal file
5
ccu_emulator/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
180
ccu_emulator/cli.py
Normal file
180
ccu_emulator/cli.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from h8536.bench_connect_lcd import (
|
||||
BenchLogger,
|
||||
_import_serial,
|
||||
_read_relay_lines,
|
||||
_relay_command,
|
||||
_relay_settle,
|
||||
add_serial_format_args,
|
||||
open_device_serial,
|
||||
serial_format_label,
|
||||
)
|
||||
|
||||
from .controller import CcuConfig, CcuEmulator
|
||||
from .frames import ACTIVE_SEED_COMMAND0, CONNECT_CADENCE_SEQUENCE, NEUTRAL_ACK_FRAME, format_frame, parse_frame
|
||||
from .policy import AckPolicy
|
||||
from .refresh import PeriodicRefresh
|
||||
from .serial_link import SerialLink
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Run a small fake CCU for the Sony RCP PT2-style serial link.")
|
||||
parser.add_argument("--port", default="COM5", help="RS232 serial port connected to the RCP")
|
||||
parser.add_argument("--baud", type=int, default=38400, help="RCP serial baud rate")
|
||||
add_serial_format_args(parser)
|
||||
parser.add_argument("--duration", type=float, default=30.0, help="seconds to run the CCU loop")
|
||||
parser.add_argument("--sync", choices=("checksum", "fixed"), default="checksum", help="RX frame sync strategy")
|
||||
parser.add_argument("--log", type=Path, help="capture log path")
|
||||
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
choices=("command0", "connect-sequence", "none"),
|
||||
default="command0",
|
||||
help="built-in wake-up seed before reactive ACK loop",
|
||||
)
|
||||
parser.add_argument("--seed-frame", action="append", type=parse_frame, help="custom seed frame; repeatable")
|
||||
parser.add_argument("--seed-gap", type=float, default=0.050, help="seconds to listen after each seed frame")
|
||||
parser.add_argument("--ready-heartbeats", type=int, default=1, help="heartbeats to observe before seeding")
|
||||
parser.add_argument("--ready-timeout", type=float, default=10.0, help="seconds to wait for ready heartbeat")
|
||||
|
||||
parser.add_argument("--ack-frame", type=parse_frame, default=NEUTRAL_ACK_FRAME, help="ACK frame to send after RCP reports")
|
||||
parser.add_argument("--ack-delay", type=float, default=0.0, help="seconds to wait after detecting an RCP frame before ACK")
|
||||
parser.add_argument("--no-ack-heartbeats", action="store_true", help="do not ACK heartbeat frames")
|
||||
parser.add_argument("--no-ack-reports", action="store_true", help="do not ACK report-looking frames")
|
||||
parser.add_argument(
|
||||
"--no-ack-unlabeled",
|
||||
action="store_true",
|
||||
help="do not ACK checksum-valid unlabeled frames outside known report command bytes",
|
||||
)
|
||||
|
||||
parser.add_argument("--refresh-frame", action="append", type=parse_frame, help="optional periodic refresh frame")
|
||||
parser.add_argument(
|
||||
"--refresh-active",
|
||||
action="store_true",
|
||||
help="periodically refresh selector zero with command0 0x8080",
|
||||
)
|
||||
parser.add_argument("--refresh-interval", type=float, default=0.0, help="seconds between optional refresh frames")
|
||||
parser.add_argument("--loop-poll", type=float, default=0.001, help="sleep between service loop iterations")
|
||||
|
||||
parser.add_argument("--power-cycle", action="store_true", help="power-cycle DUT through relay before starting")
|
||||
parser.add_argument("--relay-port", default="COM6", help="Pico relay serial port")
|
||||
parser.add_argument("--relay-baud", type=int, default=115200, help="Pico relay serial baud rate")
|
||||
parser.add_argument("--power-off-command", default="off", help="relay command used to remove DUT power")
|
||||
parser.add_argument("--power-on-command", default="on", help="relay command used to apply DUT power")
|
||||
parser.add_argument("--off-seconds", type=float, default=1.5, help="seconds to hold DUT powered off")
|
||||
parser.add_argument("--relay-settle", type=float, default=2.0, help="seconds to wait after opening relay port")
|
||||
|
||||
parser.add_argument("--dry-run", action="store_true", help="print configuration without opening serial ports")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int:
|
||||
args = build_arg_parser().parse_args(argv)
|
||||
seed_frames = _seed_frames(args)
|
||||
refresh_frames = _refresh_frames(args)
|
||||
log_path = args.log or _default_log_path()
|
||||
|
||||
if args.dry_run:
|
||||
_print_dry_run(args, seed_frames, refresh_frames, log_path, stdout)
|
||||
return 0
|
||||
|
||||
serial = _import_serial()
|
||||
logger = BenchLogger(log_path, stdout=stdout)
|
||||
relay = None
|
||||
try:
|
||||
logger.emit("PT2 fake CCU")
|
||||
logger.emit(f"device={args.port} {args.baud} {serial_format_label(args)} sync={args.sync}")
|
||||
logger.emit(f"log={log_path}")
|
||||
logger.emit(f"ack_frame={format_frame(args.ack_frame)}")
|
||||
logger.emit("seed_frames=" + (" | ".join(format_frame(frame) for frame in seed_frames) or "none"))
|
||||
if refresh_frames:
|
||||
logger.emit(
|
||||
f"refresh_interval={args.refresh_interval:.3f}s frames="
|
||||
+ " | ".join(format_frame(frame) for frame in refresh_frames)
|
||||
)
|
||||
|
||||
with open_device_serial(serial, args) as device:
|
||||
if args.power_cycle:
|
||||
relay = serial.Serial(args.relay_port, args.relay_baud, timeout=0.25)
|
||||
_relay_settle(relay, args.relay_settle, logger)
|
||||
_relay_command(relay, args.power_off_command, logger)
|
||||
time.sleep(max(0.0, args.off_seconds))
|
||||
device.reset_input_buffer()
|
||||
_relay_command(relay, args.power_on_command, logger)
|
||||
else:
|
||||
device.reset_input_buffer()
|
||||
|
||||
link = SerialLink(device, logger, sync_mode=args.sync)
|
||||
config = CcuConfig(
|
||||
seed_frames=tuple(seed_frames),
|
||||
seed_gap=args.seed_gap,
|
||||
ack_delay=args.ack_delay,
|
||||
ready_heartbeats=args.ready_heartbeats,
|
||||
ready_timeout=args.ready_timeout,
|
||||
loop_poll=args.loop_poll,
|
||||
)
|
||||
policy = AckPolicy(
|
||||
ack_frame=args.ack_frame,
|
||||
ack_reports=not args.no_ack_reports,
|
||||
ack_heartbeats=not args.no_ack_heartbeats,
|
||||
ack_unlabeled_checksum_frames=not args.no_ack_unlabeled,
|
||||
)
|
||||
refresh = PeriodicRefresh(frames=refresh_frames, interval=args.refresh_interval)
|
||||
CcuEmulator(link, logger, config=config, ack_policy=policy, refresh=refresh).run(args.duration)
|
||||
return 0
|
||||
finally:
|
||||
if relay is not None:
|
||||
relay.close()
|
||||
logger.close()
|
||||
|
||||
|
||||
def _seed_frames(args: argparse.Namespace) -> list[bytes]:
|
||||
if args.seed_frame:
|
||||
return list(args.seed_frame)
|
||||
if args.seed == "none":
|
||||
return []
|
||||
if args.seed == "connect-sequence":
|
||||
return list(CONNECT_CADENCE_SEQUENCE)
|
||||
return [ACTIVE_SEED_COMMAND0]
|
||||
|
||||
|
||||
def _refresh_frames(args: argparse.Namespace) -> list[bytes]:
|
||||
frames = list(args.refresh_frame or [])
|
||||
if args.refresh_active:
|
||||
frames.append(ACTIVE_SEED_COMMAND0)
|
||||
return frames
|
||||
|
||||
|
||||
def _print_dry_run(
|
||||
args: argparse.Namespace,
|
||||
seed_frames: list[bytes],
|
||||
refresh_frames: list[bytes],
|
||||
log_path: Path,
|
||||
stdout: TextIO,
|
||||
) -> None:
|
||||
print(f"device={args.port} {args.baud} {serial_format_label(args)} sync={args.sync}", file=stdout)
|
||||
print(f"duration={args.duration:.3f}s log={log_path}", file=stdout)
|
||||
print(f"power_cycle={int(args.power_cycle)} relay={args.relay_port} {args.relay_baud}", file=stdout)
|
||||
print(f"ack_frame={format_frame(args.ack_frame)}", file=stdout)
|
||||
print("seed_frames=" + (" | ".join(format_frame(frame) for frame in seed_frames) or "none"), file=stdout)
|
||||
print(
|
||||
f"refresh_interval={args.refresh_interval:.3f}s frames="
|
||||
+ (" | ".join(format_frame(frame) for frame in refresh_frames) or "none"),
|
||||
file=stdout,
|
||||
)
|
||||
|
||||
|
||||
def _default_log_path() -> Path:
|
||||
return Path("captures") / f"ccu-emulator-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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}")
|
||||
59
ccu_emulator/frames.py
Normal file
59
ccu_emulator/frames.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from h8536.bench_connect_lcd import format_frame, frame_checksum, frame_checksum_ok, parse_frame
|
||||
|
||||
|
||||
FRAME_LENGTH = 6
|
||||
|
||||
HEARTBEAT_FRAME = bytes.fromhex("0000000080DA")
|
||||
|
||||
# Command 0, selector 0, value 0x8080. This seeds E000/E800 selector zero.
|
||||
ACTIVE_SEED_COMMAND0 = bytes.fromhex("00000080805A")
|
||||
|
||||
# The older bench cadence sequence. It is still useful as an optional wake-up
|
||||
# strategy because the real panel proved timing-sensitive around these frames.
|
||||
CONNECT_CADENCE_SEQUENCE = (
|
||||
bytes.fromhex("04000040001E"),
|
||||
bytes.fromhex("0400008000DE"),
|
||||
bytes.fromhex("040000C0009E"),
|
||||
)
|
||||
|
||||
# Command 5, selector 0x0040, value ignored. ROM trace shows this is the safest
|
||||
# neutral report-consume candidate.
|
||||
NEUTRAL_ACK_FRAME = bytes.fromhex("05004000001F")
|
||||
|
||||
|
||||
def build_frame(command: int, selector: int, value: int) -> bytes:
|
||||
"""Build a six-byte host frame for simple page-0/page-1 selectors.
|
||||
|
||||
This helper covers the selector encodings we currently use in fake-CCU
|
||||
probes. Keep more exotic mapping in one place when we learn it.
|
||||
"""
|
||||
|
||||
if not 0 <= command <= 0xFF:
|
||||
raise ValueError("command byte out of range")
|
||||
if not 0 <= selector <= 0x01FF:
|
||||
raise ValueError("selector out of supported range 0x000-0x1FF")
|
||||
if not 0 <= value <= 0xFFFF:
|
||||
raise ValueError("value out of range")
|
||||
|
||||
if selector <= 0x007F:
|
||||
byte1 = 0x00
|
||||
byte2 = selector
|
||||
elif selector <= 0x017F:
|
||||
byte1 = 0x01
|
||||
byte2 = selector - 0x0080
|
||||
else:
|
||||
byte1 = 0x02
|
||||
byte2 = selector - 0x0180
|
||||
|
||||
frame = bytes(
|
||||
[
|
||||
command & 0xFF,
|
||||
byte1 & 0xFF,
|
||||
byte2 & 0xFF,
|
||||
(value >> 8) & 0xFF,
|
||||
value & 0xFF,
|
||||
]
|
||||
)
|
||||
return frame + bytes([frame_checksum(frame)])
|
||||
44
ccu_emulator/policy.py
Normal file
44
ccu_emulator/policy.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .frames import HEARTBEAT_FRAME, NEUTRAL_ACK_FRAME, frame_checksum_ok
|
||||
|
||||
|
||||
REPORT_COMMAND_BYTES = frozenset({0x00, 0x01, 0x02})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AckDecision:
|
||||
should_ack: bool
|
||||
frame: bytes = NEUTRAL_ACK_FRAME
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AckPolicy:
|
||||
"""Decides whether an RCP-origin frame should get a continuation ACK."""
|
||||
|
||||
ack_frame: bytes = NEUTRAL_ACK_FRAME
|
||||
ack_reports: bool = True
|
||||
ack_heartbeats: bool = True
|
||||
ack_unlabeled_checksum_frames: bool = True
|
||||
|
||||
def decide(self, frame: bytes, label: str = "") -> AckDecision:
|
||||
if not frame_checksum_ok(frame):
|
||||
return AckDecision(False, self.ack_frame, "checksum_bad")
|
||||
|
||||
if frame == HEARTBEAT_FRAME:
|
||||
if self.ack_heartbeats:
|
||||
return AckDecision(True, self.ack_frame, "heartbeat_report")
|
||||
return AckDecision(False, self.ack_frame, "heartbeat_ignored")
|
||||
|
||||
if frame[0] in REPORT_COMMAND_BYTES:
|
||||
if self.ack_reports:
|
||||
return AckDecision(True, self.ack_frame, f"report_cmd_{frame[0]:02X}")
|
||||
return AckDecision(False, self.ack_frame, "reports_disabled")
|
||||
|
||||
if label == "checksum_ok_unlabeled" and self.ack_unlabeled_checksum_frames:
|
||||
return AckDecision(True, self.ack_frame, "unlabeled_checksum_ok")
|
||||
|
||||
return AckDecision(False, self.ack_frame, f"non_report_cmd_{frame[0]:02X}")
|
||||
41
ccu_emulator/refresh.py
Normal file
41
ccu_emulator/refresh.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeriodicRefresh:
|
||||
"""Small scheduler for optional CCU state-refresh frames."""
|
||||
|
||||
frames: list[bytes] = field(default_factory=list)
|
||||
interval: float = 0.0
|
||||
_next_due: float | None = None
|
||||
_index: int = 0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.frames) and self.interval > 0
|
||||
|
||||
def start(self, now: float | None = None) -> None:
|
||||
if not self.enabled:
|
||||
self._next_due = None
|
||||
return
|
||||
self._next_due = (time.monotonic() if now is None else now) + self.interval
|
||||
|
||||
def due_frames(self, now: float | None = None) -> list[bytes]:
|
||||
if not self.enabled:
|
||||
return []
|
||||
current = time.monotonic() if now is None else now
|
||||
if self._next_due is None:
|
||||
self._next_due = current + self.interval
|
||||
return []
|
||||
if current < self._next_due:
|
||||
return []
|
||||
|
||||
frame = self.frames[self._index % len(self.frames)]
|
||||
self._index += 1
|
||||
|
||||
while self._next_due <= current:
|
||||
self._next_due += self.interval
|
||||
return [frame]
|
||||
67
ccu_emulator/serial_link.py
Normal file
67
ccu_emulator/serial_link.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from h8536.bench_connect_lcd import BenchLogger, FrameDetector, format_frame, label_frame
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RxFrame:
|
||||
frame: bytes
|
||||
label: str
|
||||
|
||||
|
||||
class SerialLink:
|
||||
"""Thin serial-port wrapper with checksum-resync frame detection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Any,
|
||||
logger: BenchLogger,
|
||||
*,
|
||||
sync_mode: str = "checksum",
|
||||
) -> None:
|
||||
self.device = device
|
||||
self.logger = logger
|
||||
self.detector = FrameDetector(sync_mode=sync_mode)
|
||||
|
||||
def reset_input(self) -> None:
|
||||
self.device.reset_input_buffer()
|
||||
self.detector = FrameDetector(sync_mode=self.detector.sync_mode)
|
||||
|
||||
def read_available(self) -> list[RxFrame]:
|
||||
waiting = getattr(self.device, "in_waiting", 0)
|
||||
data = self.device.read(waiting or 1)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
dropped_before = self.detector.dropped_bytes
|
||||
self.logger.chunk("RX", data)
|
||||
frames = [RxFrame(frame, label) for frame, label in self.detector.feed(data)]
|
||||
for item in frames:
|
||||
self.logger.event(f"DETECT {item.label} {format_frame(item.frame)}")
|
||||
dropped_now = self.detector.dropped_bytes - dropped_before
|
||||
if dropped_now:
|
||||
self.logger.event(
|
||||
f"RESYNC dropped_bytes={dropped_now} total_dropped={self.detector.dropped_bytes} "
|
||||
f"buffered={len(self.detector.buffer)}"
|
||||
)
|
||||
return frames
|
||||
|
||||
def send(self, frame: bytes, label: str) -> None:
|
||||
self.device.write(frame)
|
||||
self.device.flush()
|
||||
self.logger.chunk("TX", frame)
|
||||
self.logger.event(f"SENT {label} {format_frame(frame)}")
|
||||
|
||||
def labels(self) -> dict[str, int]:
|
||||
return dict(self.detector.labels)
|
||||
|
||||
|
||||
def label_for_frame(frame: bytes) -> str:
|
||||
return label_frame(frame)
|
||||
|
||||
|
||||
def format_frames(frames: Iterable[bytes]) -> str:
|
||||
return " | ".join(format_frame(frame) for frame in frames)
|
||||
Reference in New Issue
Block a user