updates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
65
ccu_emulator/iris_mblack_link.py
Normal file
65
ccu_emulator/iris_mblack_link.py
Normal 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
25
ccu_emulator/modules.py
Normal 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."""
|
||||
Reference in New Issue
Block a user