"""Synchronous serial link to the STM32 debug protocol.

STM32 -> PC is a continuous 114-byte binary broadcast (magic-delimited,
repetition-validated, UART odd parity, ~100 Hz publish rate, each publish
repeated ~3-4x at line rate).  PC -> STM32 commands remain CRC-16 framed.
Mirrors code64/debug_console/protocol.py (kept in sync with the firmware's
debug_protocol.h).
"""

from __future__ import annotations

import dataclasses
import struct
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Iterator, Optional

import serial

# ── Protocol constants ───────────────────────────────────────────────

SYNC_BYTE = 0xAA

CMD_TELEMETRY = 0x01         # legacy; STM32->PC framed telemetry no longer sent
CMD_PARAM_WRITE = 0x02
CMD_PARAM_WRITE_ACK = 0x03   # legacy; acks now come via the broadcast round-robin
CMD_PARAM_READ_ALL = 0x04    # restarts the broadcast param round-robin cursor
CMD_PARAM_VALUE = 0x05       # legacy; STM32->PC framed replies no longer sent
CMD_PING = 0x10
CMD_PONG = 0x11              # legacy; pong now increments a broadcast field
CMD_SHUTDOWN = 0x12          # turn off converter
CMD_RESET = 0x13             # system reset
CMD_TEST_50 = 0x14           # 50% duty test mode
CMD_RELAY_ON = 0x15          # latch input relay closed (bench test)
CMD_RELAY_OFF = 0x16         # latch input relay open  (bench test)
CMD_HOLD_CONVERTER = 0x17    # toggle "hold converter off" (boot guard + disarm trips)
CMD_TOGGLE_PRECHARGE = 0x18  # toggle the precharge FET (bench test)
CMD_CLEAR_FLAGS = 0x19       # clear latched status flags
CMD_ERROR_MSG = 0xE0         # legacy; STM32->PC no longer sends framed text

PTYPE_FLOAT = 0
PTYPE_UINT16 = 1
PTYPE_UINT8 = 2
PTYPE_INT32 = 3
PTYPE_INT16 = 4  # wire format = sign-extended int32, stored firmware-side as int16_t


# ── CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection) ──────
# Matches the STM32 hardware CRC unit configured in main.c MX_CRC_Init.

def crc16(data: bytes) -> int:
    crc = 0xFFFF
    for b in data:
        crc ^= b << 8
        for _ in range(8):
            if crc & 0x8000:
                crc = ((crc << 1) ^ 0x1021) & 0xFFFF
            else:
                crc = (crc << 1) & 0xFFFF
    return crc


# ── STM32 -> PC broadcast ────────────────────────────────────────────
# A single fixed 114-byte struct streamed continuously (circular DMA). No framing
# length, no CRC. Frames are delimited by the 4-byte magic. Integrity = REPETITION
# (each `counter` value is re-sent back-to-back, so a corrupt/torn copy differs
# from its neighbours) + UART odd parity. See debug_protocol.h BroadcastFrame.

BCAST_MAGIC = 0xAA55AA55
BROADCAST_FMT = "<II16fhHhHHhBBIIIIBBhIHH"   # 114 bytes; h after param_type = iin_avg_ma, trailing H = sys_current_ma
BROADCAST_SIZE = struct.calcsize(BROADCAST_FMT)
MAGIC_BYTES = struct.pack("<I", BCAST_MAGIC)

# status_flags bit -> human label (matches the firmware FLAG_* defines)
FLAG_NAMES = {
    0:  "STARTUP: waiting (Vfly guard)",
    1:  "PRECHARGE TIMEOUT",
    2:  "GUARD: VIN_MAX",
    3:  "GUARD: VIN<VOUT+5V",
    4:  "GUARD: IIN",
    5:  "GUARD: IOUT",
    6:  "GUARD: VFLY",
    7:  "GUARD: ETEMP",
    8:  "GUARD: BTEMP",
    9:  "LIMIT: VOUT_MAX",
    10: "LIMIT: VIN_SHUTOFF",
    11: "LIMIT: IIN reverse-feed",
    12: "LIMIT: IIN_MIN",
    13: "LIMIT: IOUT_MAX",
    14: "LIMIT: IOUT_MIN",
    15: "LIMIT: VFLY_MAX",
    16: "LIMIT: VIN<VOUT+5V",
    17: "FMAC OVF",
    18: "FMAC UNF",
    19: "FMAC SAT",
    20: "TEMP: ETEMP",
    21: "TEMP: BTEMP",
    22: "HARDFAULT",
    23: "OCP: VOUT comparator",
    24: "OCP: IIN comparator",
    25: "OCP: ILOAD comparator",
    26: "Clock Security System",
    27: "Error_Handler reached",
    28: "OUTPUTS ENABLED (converter went active)",
    29: "TURNOFF: current-decay timeout (not COMP4)",
}
# flags that are informational / transient (yellow), the rest are faults (red):
# STARTUP_WAITING (bit 0) + all GUARD_* (bits 2..8) + OUTPUTS ENABLED (bit 28)
FLAG_INFO_MASK = (1 << 0) | sum(1 << b for b in range(2, 9)) | (1 << 28)


def flags_to_names(flags: int) -> list:
    return [name for bit, name in FLAG_NAMES.items() if flags & (1 << bit)]


def decode_param_bits(param_type: int, raw: int) -> float:
    """Interpret a raw 32-bit param_value from the broadcast per its type."""
    if param_type == PTYPE_FLOAT:
        return struct.unpack("<f", struct.pack("<I", raw & 0xFFFFFFFF))[0]
    if param_type == PTYPE_UINT16:
        return float(raw & 0xFFFF)
    if param_type == PTYPE_UINT8:
        return float(raw & 0xFF)
    if param_type in (PTYPE_INT32, PTYPE_INT16):
        return float(struct.unpack("<i", struct.pack("<I", raw & 0xFFFFFFFF))[0])
    return float(raw)


@dataclass
class BroadcastData:
    """One decoded broadcast frame — everything the board publishes."""
    counter: int = 0
    vin: float = 0.0       # mV
    vout: float = 0.0      # mV
    iin: float = 0.0       # mA (negative = into converter)
    iout: float = 0.0      # mA (fast ADC5; protection only — use iout_slow for power)
    vfly: float = 0.0      # mV
    etemp: float = 0.0     # °C (heatsink / external)
    btemp: float = 0.0     # °C (board)
    vfly_integral: float = 0.0
    vfly_avg_debug: float = 0.0
    cc_output_f: float = 0.0
    mppt_iref: float = 0.0
    mppt_last_vin: float = 0.0
    mppt_last_iin: float = 0.0
    p_in: float = 0.0      # board-computed
    p_out: float = 0.0     # board-computed
    iout_slow: float = 0.0  # mA (ADC4, PWM-synchronous avg-point)
    last_tmp: int = 0
    VREF: int = 0
    vfly_correction: int = 0
    cmp_outer: int = 0     # HRTIM Timer F CMP1xR (outer pair, T1/T4)
    cmp_inner: int = 0     # HRTIM Timer E CMP1xR (inner pair, T2/T3)
    vfly_ofs_applied: int = 0  # master-phase offset last written, signed ticks
    ctrl_mode: int = 0     # 0=OFF 1=MPPT 2=CV 3=CC
    vfly_active: int = 0
    status_flags: int = 0
    fmac_sr: int = 0
    fault_pc: int = 0
    cfsr: int = 0
    param_id: int = 0      # broadcast param round-robin
    param_type: int = 0
    param_value: int = 0   # raw bits; interpret via decode_param_bits(param_type, ...)
    pong: int = 0          # increments when the MCU processes CMD_PING
    sys_current_ma: int = 0   # Vout-rail housekeeping current, mA
    iin_avg_ma: int = 0       # 8-sample boxcar of iin, mA (the IIN_MAX trip quantity)
    timestamp: float = field(default_factory=time.time)

    @property
    def seq(self) -> int:       # back-compat for code that used t.seq
        return self.counter & 0xFFFF

    @property
    def vin_V(self) -> float:
        return self.vin / 1000.0

    @property
    def vout_V(self) -> float:
        return self.vout / 1000.0

    @property
    def iin_A(self) -> float:
        return self.iin / 1000.0

    @property
    def iout_A(self) -> float:
        return self.iout / 1000.0

    @property
    def vfly_V(self) -> float:
        return self.vfly / 1000.0

    @property
    def power_in_W(self) -> float:
        return self.vin * (-self.iin) / 1e6

    @property
    def power_out_W(self) -> float:
        # iout_slow is the accurate output current (fast iout is protection-only)
        return self.vout * self.iout_slow / 1e6

    @property
    def power_sys_W(self) -> float:
        return self.vout * self.sys_current_ma / 1e6

    @property
    def efficiency(self) -> float:
        """Gross efficiency P_out/P_in (%)."""
        p_in = self.power_in_W
        return (self.power_out_W / p_in * 100.0) if p_in > 0.1 else 0.0

    @property
    def efficiency_net(self) -> float:
        """Net efficiency (P_out - P_sys)/P_in (%) — self-supply subtracted."""
        p_in = self.power_in_W
        if p_in <= 0.1:
            return 0.0
        return (self.power_out_W - self.power_sys_W) / p_in * 100.0


# Back-compat alias: tuner/cli were written against the old Telemetry class.
Telemetry = BroadcastData


def decode_broadcast(frame: bytes) -> BroadcastData:
    v = struct.unpack(BROADCAST_FMT, frame)
    return BroadcastData(
        counter=v[1],
        vin=v[2], vout=v[3], iin=v[4], iout=v[5], vfly=v[6], etemp=v[7], btemp=v[8],
        vfly_integral=v[9], vfly_avg_debug=v[10], cc_output_f=v[11],
        mppt_iref=v[12], mppt_last_vin=v[13], mppt_last_iin=v[14],
        p_in=v[15], p_out=v[16], iout_slow=v[17],
        last_tmp=v[18], VREF=v[19], vfly_correction=v[20],
        cmp_outer=v[21], cmp_inner=v[22], vfly_ofs_applied=v[23],
        ctrl_mode=v[24], vfly_active=v[25],
        status_flags=v[26], fmac_sr=v[27], fault_pc=v[28], cfsr=v[29],
        param_id=v[30], param_type=v[31], param_value=v[33], pong=v[34],
        sys_current_ma=v[35], iin_avg_ma=v[32],
    )


class BroadcastParser:
    """Parse the continuous 114-byte broadcast stream. Resync on the 4-byte magic;
    validate a frame by REPETITION -- accept only when two consecutive byte-identical
    copies arrive (a corrupt/torn copy differs from its neighbours -> discarded). No
    CRC. Re-sends of the same `counter` are normal; dedup on counter downstream."""

    def __init__(self):
        self.buf = bytearray()
        self._prev = None   # previous raw frame awaiting a matching repeat

    def feed(self, data: bytes):
        self.buf += data
        while True:
            i = self.buf.find(MAGIC_BYTES)
            if i < 0:
                # no magic yet; keep only a trailing partial-magic (3 bytes)
                if len(self.buf) > 3:
                    del self.buf[:-3]
                return
            if i > 0:
                del self.buf[:i]           # drop junk / dropped-byte shift before magic
            if len(self.buf) < BROADCAST_SIZE:
                return                     # wait for a full frame
            frame = bytes(self.buf[:BROADCAST_SIZE])
            del self.buf[:BROADCAST_SIZE]
            if frame == self._prev:
                self._prev = None          # two identical copies -> accept, need a fresh pair next
                yield decode_broadcast(frame)
            else:
                self._prev = frame         # first sighting / differs -> hold, wait for the repeat


# ── Parameter definitions ────────────────────────────────────────────

@dataclass
class ParamDef:
    id: int
    name: str
    ptype: int
    group: str
    min_val: float = -1e9
    max_val: float = 1e9
    fmt: str = ".4f"


# Mirrors code64/debug_console/protocol.py PARAMS (firmware debug_protocol.c).
PARAMS = [
    # Compensator
    ParamDef(0x25, "VREF",             PTYPE_UINT16, "Compensator", 2340, 3500, ".0f"),
    # Vfly
    ParamDef(0x20, "vfly_kp",          PTYPE_FLOAT,  "Vfly", -10, 10, ".4f"),
    ParamDef(0x21, "vfly_ki",          PTYPE_FLOAT,  "Vfly", -10, 10, ".6f"),
    ParamDef(0x62, "vfly_kp_phase",    PTYPE_FLOAT,  "Vfly", -10, 10, ".4f"),   # mode 2: P gain, error -> phase
    ParamDef(0x63, "vfly_phase_clamp", PTYPE_UINT16, "Vfly", 0, 10000, ".0f"),  # mode 2: phase offset clamp
    ParamDef(0x22, "vfly_clamp",       PTYPE_UINT16, "Vfly", 0, 10000, ".0f"),
    ParamDef(0x23, "vfly_loop_trig",   PTYPE_UINT16, "Vfly", 1, 10000, ".0f"),
    ParamDef(0x24, "vfly_active",      PTYPE_UINT8,  "Vfly", 0, 3, ".0f"),      # 0=off 1=duty-asym PI 2=phase P 3=manual both
    ParamDef(0x26, "test_corr",        PTYPE_INT16,  "Vfly", -3000, 3000, ".0f"),  # mode-3 manual duty asymmetry
    ParamDef(0x27, "phase_ofs",        PTYPE_INT16,  "Vfly", -3000, 3000, ".0f"),  # master-phase: mode-3 manual, mode-2 readback
    # CC
    ParamDef(0x30, "cc_target",        PTYPE_FLOAT,  "CC", 0, 60000, ".0f"),
    ParamDef(0x31, "cc_gain",          PTYPE_FLOAT,  "CC", -1, 1, ".4f"),
    ParamDef(0x32, "cc_min_step",      PTYPE_FLOAT,  "CC", -1000, 0, ".1f"),
    ParamDef(0x33, "cc_max_step",      PTYPE_FLOAT,  "CC", 0, 1000, ".1f"),
    ParamDef(0x34, "cc_loop_trig",     PTYPE_UINT16, "CC", 1, 10000, ".0f"),
    ParamDef(0x35, "cc_active",        PTYPE_INT32,  "CC", 0, 1, ".0f"),
    # MPPT
    ParamDef(0x40, "mppt_step",        PTYPE_FLOAT,  "MPPT", 1, 200, ".0f"),
    ParamDef(0x41, "mppt_duty_min",    PTYPE_FLOAT,  "MPPT", 0, 6800, ".0f"),
    ParamDef(0x42, "mppt_duty_max",    PTYPE_FLOAT,  "MPPT", 0, 6800, ".0f"),
    ParamDef(0x44, "mppt_loop_trig",   PTYPE_UINT16, "MPPT", 1, 50000, ".0f"),
    ParamDef(0x45, "mppt_active",      PTYPE_INT32,  "MPPT", 0, 1, ".0f"),
    ParamDef(0x46, "cv_threshold",     PTYPE_FLOAT,  "MPPT", 20000, 30000, ".0f"),
    ParamDef(0x47, "cv_hysteresis",    PTYPE_FLOAT,  "MPPT", 0, 5000, ".0f"),
    ParamDef(0x48, "cc_threshold",     PTYPE_FLOAT,  "MPPT", 0, 55000, ".0f"),
    ParamDef(0x50, "cc_hysteresis",    PTYPE_FLOAT,  "MPPT", 0, 10000, ".0f"),
    # Deadtime (single static value, dt register units)
    ParamDef(0x60, "dt_normal",        PTYPE_UINT16, "Deadtime", 14, 200, ".0f"),
    # Manual fixed-duty mode (base duty in CMP ticks; D = override_duty/7158, 716..6442 = 10..90%)
    ParamDef(0x64, "override_duty",    PTYPE_UINT16, "Manual", 716, 6442, ".0f"),
    ParamDef(0x65, "manual_duty_en",   PTYPE_UINT8,  "Manual", 0, 1, ".0f"),
    # Closed-loop precharge PI (drives precharge FET PWM TIM3_CH1 to Vin/2)
    ParamDef(0x76, "precharge_kp",     PTYPE_FLOAT,  "Precharge", 0, 100, ".3f"),
    ParamDef(0x78, "precharge_ki",     PTYPE_FLOAT,  "Precharge", 0, 10, ".4f"),
    ParamDef(0x77, "precharge_reg_en", PTYPE_UINT8,  "Precharge", 0, 1, ".0f"),
    # Duty dither: delta-sigma the commanded duty between two out-of-band anchors (CMP ticks)
    ParamDef(0x70, "dither_en",        PTYPE_UINT8,  "Dither", 0, 1, ".0f"),
    ParamDef(0x71, "dither_band_lo",   PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
    ParamDef(0x72, "dither_band_hi",   PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
    ParamDef(0x73, "dither_anear",     PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
    ParamDef(0x74, "dither_afar",      PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
    ParamDef(0x75, "dither_dzero",     PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
    # ADC calibration
    ParamDef(0x79, "adc4_trig_phase",  PTYPE_UINT16, "ADC", 3, 14313, ".0f"),  # HRTIM master CMP3: iout_slow sample instant
    ParamDef(0x7A, "iin_zero_sum",     PTYPE_UINT16, "ADC", 0, 32760, ".0f"),  # IIN software zero offset, sum-of-8 counts
]

PARAM_BY_ID: dict[int, ParamDef] = {p.id: p for p in PARAMS}
PARAM_BY_NAME: dict[str, ParamDef] = {p.name: p for p in PARAMS}


# ── Frame building (PC -> STM32 commands, CRC-16 framed) ─────────────

def build_frame(cmd: int, payload: bytes = b"") -> bytes:
    header = bytes([SYNC_BYTE, cmd, len(payload)])
    frame = header + payload
    crc = crc16(frame)
    return frame + bytes([(crc >> 8) & 0xFF, crc & 0xFF])  # big-endian: hi, lo


def build_param_write(param_id: int, ptype: int, value) -> bytes:
    if ptype == PTYPE_FLOAT:
        val_bytes = struct.pack("<f", float(value))
    elif ptype == PTYPE_UINT16:
        val_bytes = struct.pack("<HH", int(value), 0)
    elif ptype == PTYPE_UINT8:
        val_bytes = struct.pack("<Bxxx", int(value))
    elif ptype == PTYPE_INT32:
        val_bytes = struct.pack("<i", int(value))
    elif ptype == PTYPE_INT16:
        val_bytes = struct.pack("<i", int(value))  # sign-extended 32-bit wire
    else:
        val_bytes = struct.pack("<I", int(value))
    payload = struct.pack("<BBxx", param_id, ptype) + val_bytes
    return build_frame(CMD_PARAM_WRITE, payload)


def build_ping() -> bytes:
    return build_frame(CMD_PING)


def build_shutdown() -> bytes:
    return build_frame(CMD_SHUTDOWN)


def build_reset() -> bytes:
    return build_frame(CMD_RESET)


def build_test_50() -> bytes:
    return build_frame(CMD_TEST_50)


def build_relay_on() -> bytes:
    return build_frame(CMD_RELAY_ON)


def build_relay_off() -> bytes:
    return build_frame(CMD_RELAY_OFF)


def build_hold_converter() -> bytes:
    return build_frame(CMD_HOLD_CONVERTER)


def build_toggle_precharge() -> bytes:
    return build_frame(CMD_TOGGLE_PRECHARGE)


def build_param_read_all() -> bytes:
    return build_frame(CMD_PARAM_READ_ALL)


def build_clear_flags() -> bytes:
    return build_frame(CMD_CLEAR_FLAGS)


# ── STM32Link — synchronous serial interface ─────────────────────────

class STM32Link:
    """Blocking serial link to the STM32 debug protocol.

    Telemetry and parameter echoes arrive via the continuous broadcast;
    commands go out CRC-16 framed.  The port MUST be opened with odd
    parity (8-O-1) or the broadcast never validates.

    Usage::

        link = STM32Link("COM4")
        link.ping()
        t = link.read_telemetry()
        print(f"Vin={t.vin_V:.1f}V  Iout={t.iout_A:.1f}A  EFF={t.efficiency:.1f}%")
        link.write_param("dt_normal", 20)
        link.close()
    """

    def __init__(self, port: str, baudrate: int = 460800, timeout: float = 2.0):
        self.timeout = timeout
        self.ser = serial.Serial(
            port, baudrate, timeout=0.1,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_ODD,
            stopbits=serial.STOPBITS_ONE,
        )
        self._parser = BroadcastParser()
        self._params: dict[int, float] = {}
        self._pending: deque = deque()   # decoded frames not yet consumed
        self._last_counter = -1

    def close(self):
        if self.ser and self.ser.is_open:
            self.ser.close()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

    # ── Low-level ────────────────────────────────────────────────────

    def _send(self, frame: bytes):
        self.ser.write(frame)

    def _drain_serial(self) -> None:
        """Read one serial chunk and queue ALL decoded frames.

        Param round-robin echoes are stashed into self._params in passing
        (only once counter > 0 — fields are stale during the post-reset window).
        """
        data = self.ser.read(4096)
        if not data:
            return
        for b in self._parser.feed(data):
            if b.counter > 0 and b.param_id in PARAM_BY_ID:
                self._params[b.param_id] = decode_param_bits(b.param_type, b.param_value)
            self._pending.append(b)

    def _pump(self, deadline: float) -> Iterator[BroadcastData]:
        """Yield validated frames until deadline.

        Frames are staged through self._pending so nothing is lost when a
        caller stops iterating early (generator abandoned mid-chunk).
        """
        while True:
            while self._pending:
                yield self._pending.popleft()
            if time.monotonic() >= deadline:
                return
            self._drain_serial()

    # ── Commands ─────────────────────────────────────────────────────

    def ping(self, timeout: float = 2.0) -> bool:
        """Send PING, return True when the broadcast pong counter increments."""
        baseline: Optional[int] = None
        for b in self._pump(time.monotonic() + min(0.7, timeout)):
            baseline = b.pong   # pong is valid even at counter == 0
            break
        if baseline is None:
            return False        # no broadcast at all -> not connected
        self._send(build_ping())
        for b in self._pump(time.monotonic() + timeout):
            if b.pong != baseline:
                return True
        return False

    def shutdown(self):
        """Command the converter off."""
        self._send(build_shutdown())

    def reset(self):
        """Command a system reset."""
        self._send(build_reset())

    def test_50(self):
        """Enter 50% duty test mode."""
        self._send(build_test_50())

    def relay_on(self):
        """Latch the input relay closed (bench test)."""
        self._send(build_relay_on())

    def relay_off(self):
        """Latch the input relay open (bench test)."""
        self._send(build_relay_off())

    def hold_converter(self):
        """Toggle 'hold converter off' (boot guard + disarm trips)."""
        self._send(build_hold_converter())

    def toggle_precharge(self):
        """Toggle the precharge FET (bench test)."""
        self._send(build_toggle_precharge())

    def clear_flags(self):
        """Clear latched status flags."""
        self._send(build_clear_flags())

    # ── Telemetry ────────────────────────────────────────────────────

    def read_telemetry(self, timeout: float = 2.0) -> Optional[BroadcastData]:
        """Return the next FRESH publish (counter-deduped — repeats skipped)."""
        for b in self._pump(time.monotonic() + timeout):
            if b.counter > 0 and b.counter != self._last_counter:
                self._last_counter = b.counter
                return b
        return None

    def read_telemetry_avg(self, n: int = 10, timeout: float = 5.0) -> Optional[BroadcastData]:
        """Average n fresh publishes (~n/100 s). Float fields are averaged;
        int/flag fields come from the last sample."""
        samples: list[BroadcastData] = []
        deadline = time.monotonic() + timeout
        while len(samples) < n and time.monotonic() < deadline:
            t = self.read_telemetry(timeout=deadline - time.monotonic())
            if t:
                samples.append(t)
        if not samples:
            return None
        avg = dataclasses.replace(samples[-1])
        for attr in ("vin", "vout", "iin", "iout", "vfly", "etemp", "btemp",
                     "vfly_integral", "vfly_avg_debug", "cc_output_f",
                     "mppt_iref", "mppt_last_vin", "mppt_last_iin",
                     "p_in", "p_out", "iout_slow"):
            setattr(avg, attr, sum(getattr(s, attr) for s in samples) / len(samples))
        return avg

    # ── Parameters ───────────────────────────────────────────────────

    def request_all_params(self):
        """Restart the broadcast param round-robin from the first param."""
        self._send(build_param_read_all())

    def read_all_params(self, timeout: float = 5.0) -> dict[str, float]:
        """Collect all parameter values from the broadcast round-robin
        (one param per publish -> full rotation ~0.4 s at 100 Hz)."""
        self._params.clear()
        self.request_all_params()
        for _ in self._pump(time.monotonic() + timeout):
            if len(self._params) >= len(PARAMS):
                break
        return {
            PARAM_BY_ID[pid].name: val
            for pid, val in self._params.items()
            if pid in PARAM_BY_ID
        }

    def write_param(self, name: str, value: float, wait_ack: bool = True) -> bool:
        """Write a parameter by name. The write is acknowledged when the
        broadcast round-robin echoes the new value back (clamped + formatted
        the way the firmware reports it); retries until it matches."""
        pdef = PARAM_BY_NAME.get(name)
        if not pdef:
            raise ValueError(f"Unknown parameter: {name!r}")
        if value < pdef.min_val or value > pdef.max_val:
            raise ValueError(
                f"{name}: {value} out of range [{pdef.min_val}, {pdef.max_val}]"
            )
        frame = build_param_write(pdef.id, pdef.ptype, value)
        if not wait_ack:
            self._send(frame)
            return True
        clamped = max(pdef.min_val, min(pdef.max_val, float(value)))
        expect = f"{clamped:{pdef.fmt}}"
        for _ in range(6):
            self._send(frame)
            # one full round-robin rotation is ~0.4 s; 0.5 s sees the echo once
            for b in self._pump(time.monotonic() + 0.5):
                if b.counter > 0 and b.param_id == pdef.id:
                    echoed = decode_param_bits(b.param_type, b.param_value)
                    if f"{echoed:{pdef.fmt}}" == expect:
                        self._params[pdef.id] = echoed
                        return True
        return False

    def write_param_by_id(self, param_id: int, value: float) -> bool:
        """Write a parameter by ID."""
        pdef = PARAM_BY_ID.get(param_id)
        if not pdef:
            raise ValueError(f"Unknown param ID: 0x{param_id:02X}")
        return self.write_param(pdef.name, value)
