1
0
This commit is contained in:
Aiden
2026-05-27 21:37:50 +10:00
parent 21f0e455ee
commit 4364d0ed48
54 changed files with 30241 additions and 191 deletions

View File

@@ -48,9 +48,26 @@ Optionally add periodic state refresh traffic:
.\.venv\Scripts\python.exe scripts\ccu_emulator.py --refresh-active --refresh-interval 0.600 --duration 30
```
Enable the bench-proven IRIS/M.BLACK LINK closed-loop module:
```powershell
.\.venv\Scripts\python.exe scripts\ccu_emulator.py --iris-mblack-link --duration 60 --log captures\ccu-iris-mblack-link.txt
```
When the RCP reports selector `0x0013` as `0x4000` or `0x0000`, this module sends:
```text
05 00 13 00 00 4C ; ACK selector 0x0013
00 00 13 40 00 09 ; mirror active, or 00 00 13 00 00 49 for clear
```
That matches the bench-proven IRIS/M.BLACK LINK state machine: the RCP reports local intent, the CCU acknowledges the selector, then the CCU mirrors the accepted state back so the next physical press toggles the other way.
## Layout
- `frames.py`: checksums, built-in frames, and simple host-frame builders.
- `iris_mblack_link.py`: selector `0x0013` ACK-and-mirror state-machine module.
- `modules.py`: small protocol-module interface for feature-specific CCU behavior.
- `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.

View File

@@ -11,6 +11,7 @@ from .frames import (
frame_checksum_ok,
parse_frame,
)
from .iris_mblack_link import IrisMblackLinkModule
from .policy import AckPolicy
__all__ = [
@@ -19,6 +20,7 @@ __all__ = [
"CcuEmulator",
"CcuStats",
"HEARTBEAT_FRAME",
"IrisMblackLinkModule",
"NEUTRAL_ACK_FRAME",
"AckPolicy",
"format_frame",

View File

@@ -20,6 +20,8 @@ from h8536.bench_connect_lcd import (
from .controller import CcuConfig, CcuEmulator
from .frames import ACTIVE_SEED_COMMAND0, CONNECT_CADENCE_SEQUENCE, NEUTRAL_ACK_FRAME, format_frame, parse_frame
from .iris_mblack_link import IrisMblackLinkModule
from .modules import CcuModule
from .policy import AckPolicy
from .refresh import PeriodicRefresh
from .serial_link import SerialLink
@@ -64,6 +66,18 @@ def build_arg_parser() -> argparse.ArgumentParser:
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(
"--iris-mblack-link",
action="store_true",
help="enable the selector 0x0013 IRIS/M.BLACK LINK ACK-and-mirror module",
)
parser.add_argument(
"--iris-mblack-link-mirror-delay",
type=float,
default=0.050,
help="seconds between the selector 0x0013 ACK and command-0 mirror",
)
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")
@@ -80,10 +94,11 @@ 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)
modules = _modules(args)
log_path = args.log or _default_log_path()
if args.dry_run:
_print_dry_run(args, seed_frames, refresh_frames, log_path, stdout)
_print_dry_run(args, seed_frames, refresh_frames, modules, log_path, stdout)
return 0
serial = _import_serial()
@@ -100,6 +115,8 @@ def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int:
f"refresh_interval={args.refresh_interval:.3f}s frames="
+ " | ".join(format_frame(frame) for frame in refresh_frames)
)
if modules:
logger.emit("modules=" + " | ".join(module.name for module in modules))
with open_device_serial(serial, args) as device:
if args.power_cycle:
@@ -128,7 +145,9 @@ def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int:
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)
CcuEmulator(link, logger, config=config, ack_policy=policy, refresh=refresh, modules=modules).run(
args.duration
)
return 0
finally:
if relay is not None:
@@ -153,10 +172,18 @@ def _refresh_frames(args: argparse.Namespace) -> list[bytes]:
return frames
def _modules(args: argparse.Namespace) -> tuple[CcuModule, ...]:
modules: list[CcuModule] = []
if args.iris_mblack_link:
modules.append(IrisMblackLinkModule(mirror_delay=max(0.0, args.iris_mblack_link_mirror_delay)))
return tuple(modules)
def _print_dry_run(
args: argparse.Namespace,
seed_frames: list[bytes],
refresh_frames: list[bytes],
modules: tuple[CcuModule, ...],
log_path: Path,
stdout: TextIO,
) -> None:
@@ -170,6 +197,7 @@ def _print_dry_run(
+ (" | ".join(format_frame(frame) for frame in refresh_frames) or "none"),
file=stdout,
)
print("modules=" + (" | ".join(module.name for module in modules) or "none"), file=stdout)
def _default_log_path() -> Path:

View File

@@ -6,6 +6,7 @@ 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
@@ -16,6 +17,7 @@ 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
@@ -48,12 +50,14 @@ class CcuEmulator:
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:
@@ -61,7 +65,7 @@ class CcuEmulator:
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)}"
f"ack={format_frame(self.ack_policy.ack_frame)} modules={len(self.modules)}"
)
self._wait_ready()
self._send_seed_frames()
@@ -115,6 +119,9 @@ class CcuEmulator:
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)}")
@@ -125,6 +132,28 @@ class CcuEmulator:
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")
@@ -141,6 +170,7 @@ class CcuEmulator:
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}")

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass
from .frames import build_frame, frame_checksum_ok
from .modules import ModuleDecision, ModuleTx
SELECTOR_IRIS_MBLACK_LINK = 0x0013
IRIS_MBLACK_LINK_CLEAR = 0x0000
IRIS_MBLACK_LINK_ACTIVE = 0x4000
def selector_from_frame(frame: bytes) -> int | None:
if len(frame) != 6:
return None
if frame[1] == 0x00:
return frame[2]
if frame[1] == 0x01:
return 0x0080 + frame[2]
if frame[1] == 0x02:
return 0x0180 + frame[2]
return None
def value_from_frame(frame: bytes) -> int | None:
if len(frame) != 6:
return None
return ((frame[3] << 8) | frame[4]) & 0xFFFF
@dataclass
class IrisMblackLinkModule:
"""Closed-loop CCU side for the IRIS/M.BLACK LINK button/report path."""
mirror_delay: float = 0.050
report_commands: frozenset[int] = frozenset({0x00, 0x01, 0x02})
handled_values: frozenset[int] = frozenset({IRIS_MBLACK_LINK_CLEAR, IRIS_MBLACK_LINK_ACTIVE})
name: str = "iris_mblack_link"
def on_rx(self, frame: bytes, label: str = "") -> ModuleDecision | None:
if not frame_checksum_ok(frame) or frame[0] not in self.report_commands:
return None
selector = selector_from_frame(frame)
if selector != SELECTOR_IRIS_MBLACK_LINK:
return None
value = value_from_frame(frame)
if value not in self.handled_values:
return None
state = "active" if value == IRIS_MBLACK_LINK_ACTIVE else "clear"
ack = build_frame(0x05, SELECTOR_IRIS_MBLACK_LINK, 0x0000)
mirror = build_frame(0x00, SELECTOR_IRIS_MBLACK_LINK, value)
return ModuleDecision(
tx=(
ModuleTx(ack, f"{self.name} ack selector=0x0013 value=0x{value:04X}"),
ModuleTx(
mirror,
f"{self.name} mirror {state} selector=0x0013 value=0x{value:04X}",
delay=self.mirror_delay,
),
),
suppress_default_ack=True,
reason=f"{self.name}_{state}_report",
)

25
ccu_emulator/modules.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ModuleTx:
frame: bytes
label: str
delay: float = 0.0
@dataclass(frozen=True)
class ModuleDecision:
tx: tuple[ModuleTx, ...] = ()
suppress_default_ack: bool = False
reason: str = ""
class CcuModule(Protocol):
name: str
def on_rx(self, frame: bytes, label: str = "") -> ModuleDecision | None:
"""Inspect an RCP frame and optionally provide CCU response frames."""