tooling: sync bench + debug console to live STM32 protocol
The mppt-testbench Python tooling had drifted from the flashed firmware
(bundled fw e7a23a3 vs live 1b85532) and could no longer communicate:
- stm32_link.py: CRC8 -> CRC-16/CCITT-FALSE; telemetry 68B -> 78B
(btemp, cmp_outer/inner, iout_slow, vfly_ofs_applied); add PTYPE_INT16
and commands 0x12-0x18; replace PARAMS with the current 37-param map
(single dt_normal, no dt brackets; test_corr/phase_ofs, phase PI,
precharge PI, duty dither; vfly_active 0-3).
- tuner.py: retire per-bracket deadtime; sweep the single dt_normal.
- cli.py: update tune-deadtime, help/examples, btemp readout;
default ports COM11 (load) / COM4 (stm32).
- debug console TUI: sync protocol.py/app.py/status_bar/telemetry_panel
from live (new command keys, link RX/TX/loss stats, single dead-time,
new telemetry fields, param-write auto-retry); add duty_fft.py.
- README: rewrite parameter table, deadtime section, ports, keybindings.
Verified: protocol round-trip self-tests + live `bench stm32-read`
reading all 37 params over COM4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+102
-16
@@ -1,12 +1,14 @@
|
||||
"""Textual TUI application for LVSolarBuck debug console."""
|
||||
import time
|
||||
from collections import deque
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.widgets import Header, Footer, RichLog
|
||||
|
||||
from .protocol import (
|
||||
CMD_TELEMETRY, CMD_PARAM_WRITE_ACK, CMD_PARAM_VALUE, CMD_PONG, CMD_ERROR_MSG,
|
||||
decode_telemetry, decode_param_value, build_ping, build_param_read_all,
|
||||
CMD_TELEMETRY, CMD_PARAM_WRITE, CMD_PARAM_WRITE_ACK, CMD_PARAM_VALUE, CMD_PONG, CMD_ERROR_MSG,
|
||||
decode_telemetry, decode_param_value, build_ping, build_shutdown, build_reset, build_test_50, build_param_read_all,
|
||||
build_relay_on, build_relay_off, build_hold_converter, build_toggle_precharge,
|
||||
PARAM_BY_ID,
|
||||
)
|
||||
from .serial_worker import SerialWorker
|
||||
@@ -49,9 +51,14 @@ class DebugConsoleApp(App):
|
||||
BINDINGS = [
|
||||
("q", "quit", "Quit"),
|
||||
("p", "ping", "Ping"),
|
||||
("r", "read_params", "Read Params"),
|
||||
("f", "toggle_filter", "Filter On/Off"),
|
||||
("l", "show_log_path", "Log Path"),
|
||||
("s", "shutdown", "SHUTOFF"),
|
||||
("x", "reset", "RESET"),
|
||||
("t", "test50", "TEST 50%"),
|
||||
("c", "relay_on", "Relay ON"),
|
||||
("d", "relay_off", "Relay OFF"),
|
||||
("h", "hold_converter", "HOLD CONV"),
|
||||
("g", "toggle_precharge", "PRECHARGE"),
|
||||
]
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 460800):
|
||||
@@ -60,9 +67,23 @@ class DebugConsoleApp(App):
|
||||
self.serial_baudrate = baudrate
|
||||
self.worker: SerialWorker | None = None
|
||||
self.logger: DataLogger | None = None
|
||||
# Per-tick accumulators (bumped in _on_frame); _sent_count counts the
|
||||
# frames the MCU generated (incl. lost) from the telemetry seq advance.
|
||||
self._telem_count = 0
|
||||
self._fps_time = time.monotonic()
|
||||
self._sent_count = 0
|
||||
# Sliding-window link-rate estimator: a deque of (time, recv, sent)
|
||||
# buckets covering the last _rate_window_s, so the Hz/loss readout is a
|
||||
# moving average refreshed every status tick — smooth (no ±1-frame
|
||||
# 8/12 Hz quantization) yet still responsive.
|
||||
self._rate_window_s = 3.0
|
||||
self._rate_buckets: deque = deque()
|
||||
self._rate_window_start = time.monotonic()
|
||||
self._last_seq = -1
|
||||
# Un-ACKed param writes awaiting retry: the MCU's RX side is EMI-lossy
|
||||
# while the converter is switching (TX/telemetry unaffected), so writes
|
||||
# are resent until the matching CMD_PARAM_WRITE_ACK arrives.
|
||||
# param_id -> [frame_bytes, tries, deadline]
|
||||
self._pending_writes: dict[int, list] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
@@ -76,6 +97,7 @@ class DebugConsoleApp(App):
|
||||
yield ParamGroup("CC", self._send_data)
|
||||
yield ParamGroup("MPPT", self._send_data)
|
||||
yield ParamGroup("Deadtime", self._send_data)
|
||||
yield ParamGroup("Manual", self._send_data)
|
||||
yield RichLog(id="error-log", markup=True, wrap=True)
|
||||
yield StatusBar()
|
||||
yield Footer()
|
||||
@@ -93,6 +115,7 @@ class DebugConsoleApp(App):
|
||||
self.worker.start()
|
||||
self.set_interval(0.5, self._update_status)
|
||||
self.set_interval(2.0, self._request_params)
|
||||
self.set_interval(0.3, self._flush_pending_writes)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
if self.worker:
|
||||
@@ -102,8 +125,33 @@ class DebugConsoleApp(App):
|
||||
|
||||
def _send_data(self, data: bytes) -> None:
|
||||
if self.worker:
|
||||
# Track param writes for ACK-based auto-retry. Frame layout:
|
||||
# [SYNC, cmd, len, payload...]; ParamWritePayload starts with
|
||||
# param_id. A newer write to the same param replaces the pending
|
||||
# entry, so retries never resurrect a stale value.
|
||||
if len(data) > 3 and data[1] == CMD_PARAM_WRITE:
|
||||
self._pending_writes[data[3]] = [data, 0, time.monotonic() + 0.3]
|
||||
self.worker.send(data)
|
||||
|
||||
def _flush_pending_writes(self) -> None:
|
||||
if not self.worker or not self._pending_writes:
|
||||
return
|
||||
now = time.monotonic()
|
||||
for pid in list(self._pending_writes):
|
||||
entry = self._pending_writes.get(pid)
|
||||
if entry is None or now < entry[2]:
|
||||
continue
|
||||
if entry[1] >= 30:
|
||||
self._pending_writes.pop(pid, None)
|
||||
p = PARAM_BY_ID.get(pid)
|
||||
name = p.name if p else f"0x{pid:02X}"
|
||||
self.notify(f"Param write '{name}' got no ACK after 30 tries",
|
||||
severity="error", timeout=10)
|
||||
continue
|
||||
entry[1] += 1
|
||||
entry[2] = now + 0.3
|
||||
self.worker.send(entry[0])
|
||||
|
||||
def _request_params(self) -> None:
|
||||
if self.worker and self.worker.connected:
|
||||
self._send_data(build_param_read_all())
|
||||
@@ -114,11 +162,16 @@ class DebugConsoleApp(App):
|
||||
if t is not None:
|
||||
self._telem_count += 1
|
||||
if self._last_seq >= 0:
|
||||
# seq increments per frame the MCU SENT, so the advance
|
||||
# counts generated frames whether or not they arrived.
|
||||
self._sent_count += (t.seq - self._last_seq) & 0xFF
|
||||
expected = (self._last_seq + 1) & 0xFF
|
||||
if t.seq != expected:
|
||||
diff = (t.seq - expected) & 0xFF
|
||||
if self.worker:
|
||||
self.worker.drop_count += diff
|
||||
else:
|
||||
self._sent_count += 1
|
||||
self._last_seq = t.seq
|
||||
if self.logger:
|
||||
self.logger.log_telemetry(t)
|
||||
@@ -134,6 +187,7 @@ class DebugConsoleApp(App):
|
||||
result = decode_param_value(payload)
|
||||
if result:
|
||||
param_id, value = result
|
||||
self._pending_writes.pop(param_id, None)
|
||||
if self.logger:
|
||||
self.logger.log_param(param_id, value)
|
||||
self.call_from_thread(self._update_param, param_id, value)
|
||||
@@ -180,30 +234,62 @@ class DebugConsoleApp(App):
|
||||
|
||||
def _update_status(self) -> None:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._fps_time
|
||||
fps = self._telem_count / elapsed if elapsed > 0 else 0.0
|
||||
# Push this tick's counts, then slide the window forward, dropping
|
||||
# buckets older than _rate_window_s. The retained buckets exactly cover
|
||||
# (_rate_window_start, now], so summing their counts and dividing by
|
||||
# that span is an unbiased moving-average rate — no short-window
|
||||
# quantization, and it tracks real changes within the window.
|
||||
self._rate_buckets.append((now, self._telem_count, self._sent_count))
|
||||
self._telem_count = 0
|
||||
self._fps_time = now
|
||||
self._sent_count = 0
|
||||
while len(self._rate_buckets) > 1 and now - self._rate_buckets[0][0] > self._rate_window_s:
|
||||
self._rate_window_start = self._rate_buckets.popleft()[0]
|
||||
span = now - self._rate_window_start
|
||||
recv = sum(b[1] for b in self._rate_buckets)
|
||||
sent = sum(b[2] for b in self._rate_buckets)
|
||||
fps = recv / span if span > 0 else 0.0
|
||||
sent_fps = sent / span if span > 0 else 0.0
|
||||
loss_pct = 100.0 * (1.0 - recv / sent) if sent > 0 else 0.0
|
||||
status = self.query_one(StatusBar)
|
||||
status.fps = fps
|
||||
status.sent_fps = sent_fps
|
||||
status.loss_pct = loss_pct
|
||||
if self.worker:
|
||||
status.connected = self.worker.connected
|
||||
status.refresh_status()
|
||||
# The StatusBar is hidden behind the Footer on most terminals, so the
|
||||
# link stats are mirrored into the always-visible telemetry panel.
|
||||
panel = self.query_one(TelemetryPanel)
|
||||
panel.link_rx = fps
|
||||
panel.link_tx = sent_fps
|
||||
panel.link_loss = loss_pct
|
||||
|
||||
def action_ping(self) -> None:
|
||||
self._send_data(build_ping())
|
||||
|
||||
def action_read_params(self) -> None:
|
||||
self._request_params()
|
||||
def action_shutdown(self) -> None:
|
||||
self._send_data(build_shutdown())
|
||||
|
||||
def action_reset(self) -> None:
|
||||
self._send_data(build_reset())
|
||||
|
||||
def action_test50(self) -> None:
|
||||
self._send_data(build_test_50())
|
||||
|
||||
def action_relay_on(self) -> None:
|
||||
self._send_data(build_relay_on())
|
||||
|
||||
def action_relay_off(self) -> None:
|
||||
self._send_data(build_relay_off())
|
||||
|
||||
def action_hold_converter(self) -> None:
|
||||
self._send_data(build_hold_converter())
|
||||
|
||||
def action_toggle_precharge(self) -> None:
|
||||
self._send_data(build_toggle_precharge())
|
||||
|
||||
def action_toggle_filter(self) -> None:
|
||||
panel = self.query_one(TelemetryPanel)
|
||||
panel.filter_enabled = not panel.filter_enabled
|
||||
state = "ON" if panel.filter_enabled else "OFF"
|
||||
self.notify(f"Filter {state}")
|
||||
|
||||
def action_show_log_path(self) -> None:
|
||||
if self.logger:
|
||||
self.notify(f"Log: {self.logger.db_path}", timeout=10)
|
||||
else:
|
||||
self.notify("Logger not active", severity="warning")
|
||||
|
||||
Reference in New Issue
Block a user