"""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 = " human label (matches the firmware FLAG_* defines) FLAG_NAMES = { 0: "STARTUP: waiting (Vfly guard)", 1: "PRECHARGE TIMEOUT", 2: "GUARD: VIN_MAX", 3: "GUARD: VIN 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(" 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(" 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)