Files
mppt-testbench/code64/debug_console/protocol.py
grabowski e7a23a3c7e Add LVSolarBuck64 firmware and debug console with uv support
STM32G474RB firmware for solar buck converter with MPPT, CC control,
Vfly compensation, and adaptive deadtime. Includes Textual TUI debug
console for real-time telemetry, parameter tuning, and SQLite logging.

Added pyproject.toml for uv: `cd code64 && uv run debug-console`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:38:23 +07:00

234 lines
7.7 KiB
Python

"""Binary protocol encoder/decoder matching STM32 debug_protocol.h"""
import struct
from dataclasses import dataclass, field
from typing import Optional
SYNC_BYTE = 0xAA
HEADER_SIZE = 3
CMD_TELEMETRY = 0x01
CMD_PARAM_WRITE = 0x02
CMD_PARAM_WRITE_ACK = 0x03
CMD_PARAM_READ_ALL = 0x04
CMD_PARAM_VALUE = 0x05
CMD_PING = 0x10
CMD_PONG = 0x11
CMD_ERROR_MSG = 0xE0
PTYPE_FLOAT = 0
PTYPE_UINT16 = 1
PTYPE_UINT8 = 2
PTYPE_INT32 = 3
# CRC8 table (poly 0x07)
_CRC8_TABLE = [0] * 256
def _init_crc8():
for i in range(256):
crc = i
for _ in range(8):
if crc & 0x80:
crc = ((crc << 1) ^ 0x07) & 0xFF
else:
crc = (crc << 1) & 0xFF
_CRC8_TABLE[i] = crc
_init_crc8()
def crc8(data: bytes) -> int:
crc = 0x00
for b in data:
crc = _CRC8_TABLE[crc ^ b]
return crc
@dataclass
class TelemetryData:
vin: float = 0.0
vout: float = 0.0
iin: float = 0.0
iout: float = 0.0
vfly: float = 0.0
etemp: float = 0.0
last_tmp: int = 0
VREF: int = 0
vfly_correction: int = 0
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
seq: int = 0
TELEMETRY_FMT = "<6f hHh h 6f 2f B3x" # 68 bytes
TELEMETRY_SIZE = struct.calcsize(TELEMETRY_FMT)
def decode_telemetry(payload: bytes) -> Optional[TelemetryData]:
if len(payload) < TELEMETRY_SIZE:
return None
vals = struct.unpack(TELEMETRY_FMT, payload[:TELEMETRY_SIZE])
return TelemetryData(
vin=vals[0], vout=vals[1], iin=vals[2], iout=vals[3],
vfly=vals[4], etemp=vals[5],
last_tmp=vals[6], VREF=vals[7], vfly_correction=vals[8],
# vals[9] is pad
vfly_integral=vals[10], vfly_avg_debug=vals[11],
cc_output_f=vals[12], mppt_iref=vals[13],
mppt_last_vin=vals[14], mppt_last_iin=vals[15],
p_in=vals[16], p_out=vals[17],
seq=vals[18],
)
def build_frame(cmd: int, payload: bytes = b"") -> bytes:
header = bytes([SYNC_BYTE, cmd, len(payload)])
frame_no_crc = header + payload
return frame_no_crc + bytes([crc8(frame_no_crc)])
def build_param_write(param_id: int, param_type: int, value) -> bytes:
if param_type == PTYPE_FLOAT:
val_bytes = struct.pack("<f", float(value))
elif param_type == PTYPE_UINT16:
val_bytes = struct.pack("<HH", int(value), 0) # pad to 4 bytes
elif param_type == PTYPE_UINT8:
val_bytes = struct.pack("<Bxxx", int(value))
elif param_type == PTYPE_INT32:
val_bytes = struct.pack("<i", int(value))
else:
val_bytes = struct.pack("<I", int(value))
payload = struct.pack("<BBxx", param_id, param_type) + val_bytes
return build_frame(CMD_PARAM_WRITE, payload)
def build_ping() -> bytes:
return build_frame(CMD_PING)
def build_param_read_all() -> bytes:
return build_frame(CMD_PARAM_READ_ALL)
def decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
"""Decode a PARAM_VALUE payload. Returns (param_id, value) or None."""
if len(payload) < 8:
return None
param_id = payload[0]
param_type = payload[1]
value_bytes = payload[4:8]
if param_type == PTYPE_FLOAT:
value = struct.unpack("<f", value_bytes)[0]
elif param_type == PTYPE_UINT16:
value = float(struct.unpack("<H", value_bytes[:2])[0])
elif param_type == PTYPE_UINT8:
value = float(value_bytes[0])
elif param_type == PTYPE_INT32:
value = float(struct.unpack("<i", value_bytes)[0])
else:
value = float(struct.unpack("<I", value_bytes)[0])
return (param_id, value)
class FrameParser:
"""State machine parser for incoming frames."""
WAIT_SYNC = 0
WAIT_CMD = 1
WAIT_LEN = 2
WAIT_PAYLOAD = 3
WAIT_CRC = 4
def __init__(self):
self.state = self.WAIT_SYNC
self.cmd = 0
self.length = 0
self.buf = bytearray()
self.payload = bytearray()
self.idx = 0
def feed(self, data: bytes):
"""Feed bytes, yield (cmd, payload) tuples for complete frames."""
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
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
elif self.state == self.WAIT_CRC:
expected = crc8(bytes(self.buf))
self.state = self.WAIT_SYNC
if b == expected:
yield (self.cmd, bytes(self.payload))
# Parameter registry
@dataclass
class ParamDef:
id: int
name: str
ptype: int
group: str
min_val: float = -1e9
max_val: float = 1e9
fmt: str = ".4f"
PARAMS = [
# Compensator
ParamDef(0x25, "VREF", PTYPE_UINT16, "Compensator", 3100, 3700, ".0f"),
# Vfly
ParamDef(0x20, "vfly_kp", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"),
ParamDef(0x21, "vfly_ki", PTYPE_FLOAT, "Vfly", -10, 10, ".6f"),
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, 1, ".0f"),
# 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", 0, 10000, ".1f"),
ParamDef(0x41, "mppt_iref_min", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"),
ParamDef(0x42, "mppt_iref_max", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"),
ParamDef(0x43, "mppt_dv_thresh", PTYPE_FLOAT, "MPPT", 0, 10000, ".1f"),
ParamDef(0x44, "mppt_loop_trig", PTYPE_UINT16, "MPPT", 1, 10000, ".0f"),
ParamDef(0x45, "mppt_active", PTYPE_INT32, "MPPT", 0, 1, ".0f"),
ParamDef(0x46, "mppt_init_iref", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"),
ParamDef(0x47, "mppt_deadband", PTYPE_FLOAT, "MPPT", 0, 1, ".4f"),
# Global
ParamDef(0x50, "vin_min_ctrl", PTYPE_FLOAT, "Global", 0, 90000, ".0f"),
# Deadtime
ParamDef(0x60, "dt 0-3A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
ParamDef(0x61, "dt 3-5A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
ParamDef(0x62, "dt 5-10A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
ParamDef(0x63, "dt 10-20A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
ParamDef(0x64, "dt 20-30A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
ParamDef(0x65, "dt 30-45A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
]
PARAM_BY_ID = {p.id: p for p in PARAMS}
PARAM_BY_NAME = {p.name: p for p in PARAMS}