GUI: live STM32 telemetry + sweep guards + auto-logging + bench-plot
- stm32_link.py: port to the live 114-byte broadcast protocol (magic 0xAA55AA55, odd parity 8-O-1, 100 Hz publish, repetition-validated, no CRC); 39 params incl. adc4_trig_phase/iin_zero_sum, CLEAR_FLAGS, 30-bit flag table; commands stay CRC-16 framed; Telemetry aliases BroadcastData, efficiency uses iout_slow and eff_net subtracts P_sys - gui_workers.py: STM32Worker reader thread with counter dedup, rate/ loss counters, 20 s graph history, full-rate telemetry CSV writer - gui.py: right-side telemetry panel (link state, power + EFF net, heatsink/board temps, Vfly group, control, HRTIM, status-flag checkboxes, fault registers), Vfly + selectable corr/phase-ofs graphs, 20 s rolling window on all plots, dual CSV logging (merged stm_* columns + <stem>_telem.csv), logging on by default into logs/data_<timestamp>.csv, Plot Eff button - sweep guards: PSU 20 A input-current gate (conservative estimate + measured backstop + I-limit clamp), thermal pause at 57/77 C holding the load at 1 A until cooled 5 C below threshold, CC range pinned to R2 for the whole run with empirical range-max readback rejection - plot_eff.py + bench-plot entry point: efficiency vs Vin vs current maps from any logged CSV (sweep / data log / telem autodetect), file dialog when launched without args - bench.py: HIOKI FAST response speed, 5 s settle defaults; cli.py stm32-read prints the full broadcast; README + .gitignore updates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+329
-212
@@ -1,17 +1,20 @@
|
||||
"""Synchronous serial link to the STM32 debug protocol.
|
||||
|
||||
Provides blocking read/write of telemetry and parameters, suitable
|
||||
for automated tuning scripts (not a TUI). Mirrors the binary protocol
|
||||
from code64/debug_console/protocol.py (kept in sync with the firmware's
|
||||
debug_protocol.h — CRC-16, 78-byte telemetry, current parameter map).
|
||||
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 Optional
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import serial
|
||||
|
||||
@@ -19,13 +22,13 @@ import serial
|
||||
|
||||
SYNC_BYTE = 0xAA
|
||||
|
||||
CMD_TELEMETRY = 0x01
|
||||
CMD_TELEMETRY = 0x01 # legacy; STM32->PC framed telemetry no longer sent
|
||||
CMD_PARAM_WRITE = 0x02
|
||||
CMD_PARAM_WRITE_ACK = 0x03
|
||||
CMD_PARAM_READ_ALL = 0x04
|
||||
CMD_PARAM_VALUE = 0x05
|
||||
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
|
||||
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
|
||||
@@ -33,7 +36,8 @@ 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_ERROR_MSG = 0xE0
|
||||
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
|
||||
@@ -57,36 +61,116 @@ def crc16(data: bytes) -> int:
|
||||
return crc
|
||||
|
||||
|
||||
# ── Telemetry ────────────────────────────────────────────────────────
|
||||
# ── 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 Telemetry:
|
||||
"""Decoded telemetry packet from the STM32 (78-byte payload)."""
|
||||
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
|
||||
iout: float = 0.0 # mA (fast ADC5; protection only — use iout_slow for power)
|
||||
vfly: float = 0.0 # mV
|
||||
etemp: float = 0.0 # °C (FET / external)
|
||||
etemp: float = 0.0 # °C (heatsink / external)
|
||||
btemp: float = 0.0 # °C (board)
|
||||
last_tmp: int = 0
|
||||
VREF: int = 0
|
||||
vfly_correction: int = 0
|
||||
cmp_outer: int = 0 # HRTIM Timer F CMP1xR (outer pair, T1/T4)
|
||||
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
|
||||
p_out: float = 0.0
|
||||
iout_slow: float = 0.0
|
||||
seq: int = 0
|
||||
cmp_inner: int = 0 # HRTIM Timer E CMP1xR (inner pair, T2/T3)
|
||||
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
|
||||
@@ -103,39 +187,91 @@ class Telemetry:
|
||||
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:
|
||||
return self.vout * self.iout / 1e6
|
||||
# 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
|
||||
|
||||
|
||||
_TELEM_FMT = "<7f hHhH 6f 3f BxH h" # 78 bytes
|
||||
_TELEM_SIZE = struct.calcsize(_TELEM_FMT)
|
||||
@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
|
||||
|
||||
|
||||
def _decode_telemetry(payload: bytes) -> Optional[Telemetry]:
|
||||
if len(payload) < _TELEM_SIZE:
|
||||
return None
|
||||
v = struct.unpack(_TELEM_FMT, payload[:_TELEM_SIZE])
|
||||
return Telemetry(
|
||||
vin=v[0], vout=v[1], iin=v[2], iout=v[3], vfly=v[4], etemp=v[5], btemp=v[6],
|
||||
last_tmp=v[7], VREF=v[8], vfly_correction=v[9], cmp_outer=v[10],
|
||||
vfly_integral=v[11], vfly_avg_debug=v[12],
|
||||
cc_output_f=v[13], mppt_iref=v[14],
|
||||
mppt_last_vin=v[15], mppt_last_iin=v[16],
|
||||
p_in=v[17], p_out=v[18], iout_slow=v[19],
|
||||
seq=v[20], cmp_inner=v[21], vfly_ofs_applied=v[22],
|
||||
# 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
|
||||
@@ -196,22 +332,25 @@ PARAMS = [
|
||||
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 ───────────────────────────────────────────────────
|
||||
# ── Frame building (PC -> STM32 commands, CRC-16 framed) ─────────────
|
||||
|
||||
def _build_frame(cmd: int, payload: bytes = b"") -> bytes:
|
||||
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:
|
||||
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:
|
||||
@@ -225,90 +364,57 @@ def _build_param_write(param_id: int, ptype: int, value) -> bytes:
|
||||
else:
|
||||
val_bytes = struct.pack("<I", int(value))
|
||||
payload = struct.pack("<BBxx", param_id, ptype) + val_bytes
|
||||
return _build_frame(CMD_PARAM_WRITE, payload)
|
||||
return build_frame(CMD_PARAM_WRITE, payload)
|
||||
|
||||
|
||||
def _decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
|
||||
if len(payload) < 8:
|
||||
return None
|
||||
param_id, ptype = payload[0], payload[1]
|
||||
vb = payload[4:8]
|
||||
if ptype == PTYPE_FLOAT:
|
||||
value = struct.unpack("<f", vb)[0]
|
||||
elif ptype == PTYPE_UINT16:
|
||||
value = float(struct.unpack("<H", vb[:2])[0])
|
||||
elif ptype == PTYPE_UINT8:
|
||||
value = float(vb[0])
|
||||
elif ptype == PTYPE_INT32:
|
||||
value = float(struct.unpack("<i", vb)[0])
|
||||
elif ptype == PTYPE_INT16:
|
||||
value = float(struct.unpack("<i", vb)[0]) # sign-extended 32-bit wire
|
||||
else:
|
||||
value = float(struct.unpack("<I", vb)[0])
|
||||
return (param_id, value)
|
||||
def build_ping() -> bytes:
|
||||
return build_frame(CMD_PING)
|
||||
|
||||
|
||||
# ── Frame parser state machine ───────────────────────────────────────
|
||||
def build_shutdown() -> bytes:
|
||||
return build_frame(CMD_SHUTDOWN)
|
||||
|
||||
class _FrameParser:
|
||||
WAIT_SYNC = 0
|
||||
WAIT_CMD = 1
|
||||
WAIT_LEN = 2
|
||||
WAIT_PAYLOAD = 3
|
||||
WAIT_CRC_HI = 4
|
||||
WAIT_CRC_LO = 5
|
||||
|
||||
def __init__(self):
|
||||
self.state = self.WAIT_SYNC
|
||||
self.cmd = 0
|
||||
self.length = 0
|
||||
self.buf = bytearray()
|
||||
self.payload = bytearray()
|
||||
self.idx = 0
|
||||
self.crc_hi = 0
|
||||
def build_reset() -> bytes:
|
||||
return build_frame(CMD_RESET)
|
||||
|
||||
def feed(self, data: bytes):
|
||||
for b in data:
|
||||
if self.state == self.WAIT_SYNC:
|
||||
if b == SYNC_BYTE:
|
||||
self.buf = bytearray([b])
|
||||
self.state = self.WAIT_CMD
|
||||
elif self.state == self.WAIT_CMD:
|
||||
self.cmd = b
|
||||
self.buf.append(b)
|
||||
self.state = self.WAIT_LEN
|
||||
elif self.state == self.WAIT_LEN:
|
||||
self.length = b
|
||||
self.buf.append(b)
|
||||
self.payload = bytearray()
|
||||
self.idx = 0
|
||||
if b == 0:
|
||||
self.state = self.WAIT_CRC_HI
|
||||
elif b > 128:
|
||||
self.state = self.WAIT_SYNC
|
||||
else:
|
||||
self.state = self.WAIT_PAYLOAD
|
||||
elif self.state == self.WAIT_PAYLOAD:
|
||||
self.payload.append(b)
|
||||
self.buf.append(b)
|
||||
self.idx += 1
|
||||
if self.idx >= self.length:
|
||||
self.state = self.WAIT_CRC_HI
|
||||
elif self.state == self.WAIT_CRC_HI:
|
||||
self.crc_hi = b
|
||||
self.state = self.WAIT_CRC_LO
|
||||
elif self.state == self.WAIT_CRC_LO:
|
||||
received = (self.crc_hi << 8) | b
|
||||
expected = crc16(bytes(self.buf))
|
||||
self.state = self.WAIT_SYNC
|
||||
if received == expected:
|
||||
yield (self.cmd, bytes(self.payload))
|
||||
|
||||
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 STM32 debug protocol.
|
||||
"""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::
|
||||
|
||||
@@ -321,9 +427,17 @@ class STM32Link:
|
||||
"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 460800, timeout: float = 2.0):
|
||||
self.ser = serial.Serial(port, baudrate, timeout=timeout)
|
||||
self._parser = _FrameParser()
|
||||
self._param_cache: dict[int, float] = {}
|
||||
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:
|
||||
@@ -340,92 +454,95 @@ class STM32Link:
|
||||
def _send(self, frame: bytes):
|
||||
self.ser.write(frame)
|
||||
|
||||
def _recv_frames(self, timeout: float = 1.0) -> list[tuple[int, bytes]]:
|
||||
"""Read available data and return decoded frames."""
|
||||
frames = []
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
data = self.ser.read(self.ser.in_waiting or 1)
|
||||
if data:
|
||||
for cmd, payload in self._parser.feed(data):
|
||||
frames.append((cmd, payload))
|
||||
if frames:
|
||||
# Drain any remaining data
|
||||
time.sleep(0.02)
|
||||
data = self.ser.read(self.ser.in_waiting)
|
||||
if data:
|
||||
for cmd, payload in self._parser.feed(data):
|
||||
frames.append((cmd, payload))
|
||||
break
|
||||
return frames
|
||||
def _drain_serial(self) -> None:
|
||||
"""Read one serial chunk and queue ALL decoded frames.
|
||||
|
||||
def _wait_for(self, target_cmd: int, timeout: float = 2.0) -> Optional[bytes]:
|
||||
"""Wait for a specific command response, processing others."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
data = self.ser.read(self.ser.in_waiting or 1)
|
||||
if data:
|
||||
for cmd, payload in self._parser.feed(data):
|
||||
if cmd == target_cmd:
|
||||
return payload
|
||||
# Cache param values seen in passing
|
||||
if cmd in (CMD_PARAM_VALUE, CMD_PARAM_WRITE_ACK):
|
||||
result = _decode_param_value(payload)
|
||||
if result:
|
||||
self._param_cache[result[0]] = result[1]
|
||||
# Cache telemetry too
|
||||
if cmd == CMD_TELEMETRY:
|
||||
self._last_telemetry = _decode_telemetry(payload)
|
||||
return None
|
||||
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 if PONG received."""
|
||||
self._send(_build_frame(CMD_PING))
|
||||
return self._wait_for(CMD_PONG, timeout) is not None
|
||||
"""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_frame(CMD_SHUTDOWN))
|
||||
self._send(build_shutdown())
|
||||
|
||||
def reset(self):
|
||||
"""Command a system reset."""
|
||||
self._send(_build_frame(CMD_RESET))
|
||||
self._send(build_reset())
|
||||
|
||||
def test_50(self):
|
||||
"""Enter 50% duty test mode."""
|
||||
self._send(_build_frame(CMD_TEST_50))
|
||||
self._send(build_test_50())
|
||||
|
||||
def relay_on(self):
|
||||
"""Latch the input relay closed (bench test)."""
|
||||
self._send(_build_frame(CMD_RELAY_ON))
|
||||
self._send(build_relay_on())
|
||||
|
||||
def relay_off(self):
|
||||
"""Latch the input relay open (bench test)."""
|
||||
self._send(_build_frame(CMD_RELAY_OFF))
|
||||
self._send(build_relay_off())
|
||||
|
||||
def hold_converter(self):
|
||||
"""Toggle 'hold converter off' (boot guard + disarm trips)."""
|
||||
self._send(_build_frame(CMD_HOLD_CONVERTER))
|
||||
self._send(build_hold_converter())
|
||||
|
||||
def toggle_precharge(self):
|
||||
"""Toggle the precharge FET (bench test)."""
|
||||
self._send(_build_frame(CMD_TOGGLE_PRECHARGE))
|
||||
self._send(build_toggle_precharge())
|
||||
|
||||
def read_telemetry(self, timeout: float = 2.0) -> Optional[Telemetry]:
|
||||
"""Wait for next telemetry packet."""
|
||||
payload = self._wait_for(CMD_TELEMETRY, timeout)
|
||||
if payload:
|
||||
return _decode_telemetry(payload)
|
||||
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[Telemetry]:
|
||||
"""Read n telemetry packets and return the average."""
|
||||
samples: list[Telemetry] = []
|
||||
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())
|
||||
@@ -433,43 +550,38 @@ class STM32Link:
|
||||
samples.append(t)
|
||||
if not samples:
|
||||
return None
|
||||
# Average all analog float fields
|
||||
avg = Telemetry()
|
||||
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"):
|
||||
"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))
|
||||
avg.seq = samples[-1].seq
|
||||
return avg
|
||||
|
||||
def request_all_params(self):
|
||||
"""Request all parameter values from the STM32."""
|
||||
self._send(_build_frame(CMD_PARAM_READ_ALL))
|
||||
# ── Parameters ───────────────────────────────────────────────────
|
||||
|
||||
def read_all_params(self, timeout: float = 3.0) -> dict[str, float]:
|
||||
"""Request and collect all parameter values."""
|
||||
self._param_cache.clear()
|
||||
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()
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
data = self.ser.read(self.ser.in_waiting or 1)
|
||||
if data:
|
||||
for cmd, payload in self._parser.feed(data):
|
||||
if cmd == CMD_PARAM_VALUE:
|
||||
result = _decode_param_value(payload)
|
||||
if result:
|
||||
self._param_cache[result[0]] = result[1]
|
||||
time.sleep(0.05)
|
||||
# Convert to name->value
|
||||
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._param_cache.items()
|
||||
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. Returns True if ACK received."""
|
||||
"""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}")
|
||||
@@ -477,17 +589,22 @@ class STM32Link:
|
||||
raise ValueError(
|
||||
f"{name}: {value} out of range [{pdef.min_val}, {pdef.max_val}]"
|
||||
)
|
||||
frame = _build_param_write(pdef.id, pdef.ptype, value)
|
||||
self._send(frame)
|
||||
if wait_ack:
|
||||
payload = self._wait_for(CMD_PARAM_WRITE_ACK, timeout=2.0)
|
||||
if payload:
|
||||
result = _decode_param_value(payload)
|
||||
if result:
|
||||
self._param_cache[result[0]] = result[1]
|
||||
return True
|
||||
return False
|
||||
return True
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user