Skip to content

Commit 62a09f9

Browse files
skialpineclaude
andcommitted
feat: scale telemetry via session_info + debug frames
Rebased onto current main (post decentespresso#58 WS-OOM gate, post decentespresso#60 ADC library swap) and restructured from the original "embed telemetry in status" shape to deliver telemetry out-of-band, so apps that don't care about diagnostics (on-device UI, decentespresso app, third-party scale apps) aren't paying ~21% extra bytes on every status broadcast tick. The collection bits — SoC die-temperature sampler, weight-stall watchdog, ADC recovery counter widened to volatile uint32_t, reset-reason capture at boot, StopWatch-tear-fix timer snapshot — are unchanged from review- round-2. Wire-protocol delivery is reorganized into three frames: session_info one-shot, server→client on WS_EVT_CONNECT. Carries the fields immutable for the connection (protocol_version, firmware_version, reset_reason). Clients no longer have to ask for these. debug events broadcast on diagnostic-relevant change. Emitted by: - sendWebsocketDebugStall(true) on stall onset - sendWebsocketDebugStall(false) on stall resume - sendWebsocketDebugAdcRecovery() on ADC power-cycle - sendWebsocketDebugTempPeak() on new SoC max temp Subscribers see the event the moment it happens; non- subscribers ignore the unknown type. No periodic broadcast — temp_peak fires at most once per warm-up curve, stall_*/adc_recovery only on real events. debug reply on-request snapshot per-client. Send {"command":"debug"} (also accepted: "diag") and the server replies with the full diagnostic set (current/peak temp, stall state + count + last, recovery count). Per-client, not a broadcast — no heap cost for other clients. All event broadcasts go through the existing wsBroadcastHeapOk() gate (PR decentespresso#58). Per-client sends don't need it (one allocation, not one-per- client). Net effect: - Status frame stays at its current 16 fields (≈310 B payload). Apps that don't care about diagnostics get back ~21% per status broadcast — meaningful under multi-client load where allocations stack. - Diagnostic consumers (soak tools, debug dashboards, this PR's own thermal_load_test.sh) get *immediate* notification of stall / recovery events instead of waiting up to 5 s for the next status. - session_info means clients no longer have to roundtrip a status request just to learn reset_reason after reconnect. Also includes: - Updated ADS1232 debug callback for the new library's field set (rebased from old API; the old dataMin/Max/Avg/StdDev / tareInProgress/tareTimes are gone in the upstream lib). Callback stays dormant by default (registered but setDebugEnabled(false) is the default in the lib). - StopWatch read-tear fix: sendWebsocketStatus and StatusAll now read g_timerRunning/g_timerElapsed (snapshotted once per main-loop pass) instead of touching stopWatch directly from the AsyncTCP task. - CLAUDE.md: "Fixing bugs you find along the way" guidance from review round 2. - README.md: full documentation of session_info + debug frames. - tools/thermal_load_test.sh: 1-hour multi-protocol soak runner. Build verified clean on esp32s3 (RAM 17.2%, Flash 45.5%). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a647cc7 commit 62a09f9

6 files changed

Lines changed: 516 additions & 16 deletions

File tree

‎CLAUDE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,10 @@ This document is meant to evolve with the codebase. During a session, if you (Cl
158158

159159
If you fix a bug whose symptom is documented in the "When something is broken" table, leave the entry in place — it's still the right "first place to look" for the next person.
160160

161+
## Fixing bugs you find along the way
162+
163+
Pre-existing bugs get fixed too — "it was already there" is not a reason to defer. When you turn up a bug while working on something else (a review flags it, you read past it, a test surfaces it), fix it as part of the same change; a pre-existing bug is no less bad than a newly introduced one, and the person touching the code is the right person to fix it. The only exception is when the fix is genuinely a large, independent effort — then call it out explicitly and agree on a separate change, rather than silently leaving it in place.
164+
161165
## Don't
162166

163167
- Don't call I²C / SPI / blocking IO from the AsyncTCP task.

‎README.md‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,81 @@ Status frame shape:
186186
}
187187
```
188188

189+
### `session_info` frame (per-connect, server → client)
190+
191+
Sent once to each client immediately after WebSocket handshake. Carries the
192+
fields that don't change for the life of the connection, so the hot status
193+
broadcast doesn't have to ship them on every tick.
194+
195+
```json
196+
{
197+
"type": "session_info",
198+
"protocol_version": 1,
199+
"firmware_version": "FW: 3.0.9",
200+
"reset_reason": "poweron",
201+
"ms": 12345
202+
}
203+
```
204+
205+
### `debug` frames (diagnostic telemetry, opt-in)
206+
207+
Diagnostic telemetry — SoC die temperature, weight-stall watchdog state, ADC
208+
power-cycle recovery count — is delivered out-of-band from the hot status
209+
broadcast so apps that don't care about it (the on-device UI, the
210+
decentespresso app, third-party scale apps) aren't paying ~21% extra bytes per
211+
status tick.
212+
213+
**On-request snapshot** — send `{"command":"debug"}` (also accepted: `diag`)
214+
to get the full diagnostic set per-client. Reply:
215+
216+
```json
217+
{
218+
"type": "debug",
219+
"status": "ok",
220+
"soc_temp_c": 33.3,
221+
"soc_temp_max_c": 41.2,
222+
"weight_stalled": false,
223+
"stall_count": 0,
224+
"last_stall_ms": 0,
225+
"last_stall_temp_c": 0.0,
226+
"adc_recovery_count": 0,
227+
"ms": 12345
228+
}
229+
```
230+
231+
**Event broadcasts** — emitted when something diagnostic-relevant changes.
232+
Subscribers keep their own snapshot from `session_info` + the on-request
233+
debug reply and apply these deltas. Non-subscribers ignore the unknown type.
234+
235+
```json
236+
{"type":"debug","event":"stall_start","stall_count":1,
237+
"last_stall_ms":12345,"last_stall_temp_c":42.1,"ms":12350}
238+
239+
{"type":"debug","event":"stall_end","ms":17890}
240+
241+
{"type":"debug","event":"adc_recovery","adc_recovery_count":3,"ms":34567}
242+
243+
{"type":"debug","event":"temp_peak","soc_temp_max_c":41.5,"ms":56789}
244+
```
245+
246+
Field meanings:
247+
248+
- `soc_temp_c` / `soc_temp_max_c` — current and peak ESP32-S3 die temperature
249+
(°C) since boot. `soc_temp_max_c` is `-100` until the first valid sample.
250+
- `weight_stalled` — `true` while the load-cell raw value has been frozen/railed
251+
for >8 s (readings have stopped), cleared when they resume.
252+
- `stall_count` — number of stall events since boot; `last_stall_ms` is the
253+
`millis()` of the most recent stall onset (`0` = none yet) and
254+
`last_stall_temp_c` is the die temp at that moment (valid only when
255+
`last_stall_ms != 0`).
256+
- `adc_recovery_count` — number of ADC power-cycle recoveries since boot. A
257+
climbing value is the signal for a perpetual-recovery loop (the case
258+
`weight_stalled` is blind to).
259+
- `reset_reason` (in `session_info`) — why the SoC last reset (`poweron`,
260+
`panic`, `brownout`, `task_wdt`, …), so a reboot mid-soak is explained.
261+
262+
These reset on reboot (not persisted to NVS).
263+
189264
For backwards compatibility, WiFi only sends weight snapshots by default. A
190265
client must send `events on` before periodic status, local scale button presses,
191266
or power-off notifications are emitted. The event stream resets to off when the

‎include/parameter.h‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,46 @@ static const unsigned long ZERO_DISPLAY_MISMATCH_TIMEOUT = 1500;
193193
static const float ZERO_DISPLAY_MISMATCH_THRESHOLD = 0.5;
194194
static const uint8_t ADC_ERROR_RECOVERY_COUNT = 2;
195195
static bool b_adc_recovery_active = false;
196-
static uint8_t i_adc_recovery_count = 0;
196+
// volatile: written on the main loop -- incremented on each ADC power-cycle
197+
// recovery, reset to 0 by resetAdcRecoveryState() -- and read in the WS status
198+
// frame (which can be built on the AsyncTCP task). uint32_t (not uint8_t) so a
199+
// *perpetual* recovery loop -- the one failure mode the stall watchdog is blind
200+
// to -- keeps counting truthfully over a long soak instead of saturating at 255.
201+
static volatile uint32_t i_adc_recovery_count = 0;
197202
//bool b_tempDisablePowerOff = true;
198203

204+
// Instrumentation for diagnosing the "weight stops being collected" failure
205+
// under sustained load (suspected thermal). These are all written on the main
206+
// loop and read by the WS status frame, which is built BOTH on the main loop
207+
// (periodic) AND on the AsyncTCP task (command responses) -- so the read crosses
208+
// a task boundary. volatile prevents the AsyncTCP reader caching a stale value
209+
// (single aligned scalars => the load/store is atomic on Xtensa, no mutex
210+
// needed). b_weightStalled is set by the pureScale() stall watchdog when the ADC
211+
// raw value is frozen/railed.
212+
volatile bool b_weightStalled = false;
213+
// volatile for the same cross-task reason; written once at boot in setup().
214+
volatile const char *g_resetReason = "unknown";
215+
// Peak/last-event stats since boot (no NVS; reset on reboot, which g_resetReason
216+
// then explains). g_socTempMaxC = highest SoC die temp seen. The *_stall_*
217+
// fields capture the most recent stall so the failure is visible after the fact
218+
// -- consumers must treat last_stall_temp_c as valid only when g_lastStallMs != 0
219+
// (0.0 otherwise means "no stall yet", not a real 0 C reading).
220+
volatile float g_socTempC = 0.0f; // latest SoC temperature (C)
221+
volatile float g_socTempMaxC = -100.0f; // peak SoC temperature since boot (C); -100 = no valid sample yet
222+
volatile uint32_t g_stallCount = 0; // number of weight-stall events since boot
223+
volatile unsigned long g_lastStallMs = 0; // millis() of the last stall onset (0 = none)
224+
volatile float g_lastStallTempC = 0.0f; // SoC temp when the last stall began (valid only if g_lastStallMs != 0)
225+
226+
// Snapshot of the stopWatch state, refreshed once per main-loop iteration. The
227+
// WS status frame is built BOTH on the main loop AND on the AsyncTCP task
228+
// (command responses); stopWatch is a multi-field object (running flag + start
229+
// ts + accumulator) also mutated from BLE/USB, so reading it directly off the
230+
// AsyncTCP task can tear (CLAUDE.md). The status frame reads these single
231+
// aligned volatiles instead. g_timerElapsed carries stopWatch.elapsed() in its
232+
// configured resolution (SECONDS) -- it is the WS "timer_seconds" field.
233+
volatile bool g_timerRunning = false;
234+
volatile unsigned long g_timerElapsed = 0;
235+
199236
bool b_negativeWeight = false;
200237

201238
bool b_weight_quick_zero = false; //Tare后快速显示为0优化

‎include/websocket.h‎

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,8 @@ void sendWebsocketStatus(AsyncWebSocketClient *client, const char *status) {
221221
websocketBatteryPercent(),
222222
f_batteryVoltage,
223223
websocketIsCharging() ? "true" : "false",
224-
stopWatch.isRunning() ? "true" : "false",
225-
(unsigned long)stopWatch.elapsed(),
224+
g_timerRunning ? "true" : "false",
225+
g_timerElapsed,
226226
b_u8g2Sleep ? "false" : "true",
227227
b_websocketLowPowerEnabled ? "true" : "false",
228228
b_softSleep ? "true" : "false",
@@ -250,8 +250,8 @@ void sendWebsocketStatusAll(const char *status) {
250250
websocketBatteryPercent(),
251251
f_batteryVoltage,
252252
websocketIsCharging() ? "true" : "false",
253-
stopWatch.isRunning() ? "true" : "false",
254-
(unsigned long)stopWatch.elapsed(),
253+
g_timerRunning ? "true" : "false",
254+
g_timerElapsed,
255255
b_u8g2Sleep ? "false" : "true",
256256
b_websocketLowPowerEnabled ? "true" : "false",
257257
b_softSleep ? "true" : "false",
@@ -266,6 +266,85 @@ void sendWebsocketWeightAll(float grams, unsigned long ms) {
266266
websocket.printfAll("{\"grams\":%.2f,\"ms\":%lu}", grams, ms);
267267
}
268268

269+
// --- Telemetry delivery ------------------------------------------------------
270+
//
271+
// Telemetry (SoC temp, weight-stall watchdog, ADC recovery count, reset reason)
272+
// is delivered out-of-band from the hot status frame so that apps which don't
273+
// care about it -- the on-device UI, the decentespresso app, third-party scale
274+
// apps -- aren't paying ~21% extra bytes on every status broadcast. The split:
275+
//
276+
// session_info one-shot, sent to each client on WS_EVT_CONNECT. Carries the
277+
// fields that don't change for the life of the connection
278+
// (protocol_version, firmware_version, reset_reason).
279+
//
280+
// debug events broadcast on the *change* that matters: stall_start /
281+
// stall_end, adc_recovery (count incremented), temp_peak (new
282+
// max). Subscribers see the event the moment it happens; non-
283+
// subscribers ignore the unknown type. No periodic broadcast.
284+
//
285+
// debug reply on-request snapshot. A client sends {"command":"debug"} and
286+
// the server replies with the full diagnostic set (current
287+
// SoC temp, max temp, stall state/count/last, recovery count).
288+
// Per-client, not a broadcast -- no heap cost for other clients.
289+
//
290+
// All event broadcasts use the same wsBroadcastHeapOk() gate as the other
291+
// printfAll helpers above. Per-client sends (session_info, debug reply) don't
292+
// need the gate since they're one allocation, not one-per-client.
293+
void sendWebsocketSessionInfo(AsyncWebSocketClient *client) {
294+
client->printf("{\"type\":\"session_info\",\"protocol_version\":1,\"firmware_version\":\"%s\",\"reset_reason\":\"%s\",\"ms\":%lu}",
295+
FIRMWARE_VER,
296+
(const char *)g_resetReason,
297+
millis());
298+
}
299+
300+
void sendWebsocketDebug(AsyncWebSocketClient *client, const char *status) {
301+
client->printf("{\"type\":\"debug\",\"status\":\"%s\",\"soc_temp_c\":%.1f,\"soc_temp_max_c\":%.1f,\"weight_stalled\":%s,\"stall_count\":%lu,\"last_stall_ms\":%lu,\"last_stall_temp_c\":%.1f,\"adc_recovery_count\":%lu,\"ms\":%lu}",
302+
status,
303+
g_socTempC,
304+
g_socTempMaxC,
305+
b_weightStalled ? "true" : "false",
306+
(unsigned long)g_stallCount,
307+
g_lastStallMs,
308+
g_lastStallTempC,
309+
(unsigned long)i_adc_recovery_count,
310+
millis());
311+
}
312+
313+
// Event broadcasts. Only fields relevant to the event are included -- subscribers
314+
// keep their own running snapshot from session_info + the on-request debug reply,
315+
// and update it from these deltas. Keeps each event small (~80-140 B).
316+
void sendWebsocketDebugStall(bool started) {
317+
if (!b_wifiEnabled || websocket.count() == 0) return;
318+
if (!wsBroadcastHeapOk()) return;
319+
if (started) {
320+
websocket.printfAll("{\"type\":\"debug\",\"event\":\"stall_start\",\"stall_count\":%lu,\"last_stall_ms\":%lu,\"last_stall_temp_c\":%.1f,\"ms\":%lu}",
321+
(unsigned long)g_stallCount,
322+
g_lastStallMs,
323+
g_lastStallTempC,
324+
millis());
325+
} else {
326+
websocket.printfAll("{\"type\":\"debug\",\"event\":\"stall_end\",\"ms\":%lu}", millis());
327+
}
328+
}
329+
330+
void sendWebsocketDebugAdcRecovery() {
331+
if (!b_wifiEnabled || websocket.count() == 0) return;
332+
if (!wsBroadcastHeapOk()) return;
333+
websocket.printfAll("{\"type\":\"debug\",\"event\":\"adc_recovery\",\"adc_recovery_count\":%lu,\"ms\":%lu}",
334+
(unsigned long)i_adc_recovery_count,
335+
millis());
336+
}
337+
338+
// temp_peak is broadcast when g_socTempMaxC ticks up. Rate-limited at the call
339+
// site (main loop) -- not here -- since the temp sampler already throttles.
340+
void sendWebsocketDebugTempPeak(float maxC) {
341+
if (!b_wifiEnabled || websocket.count() == 0) return;
342+
if (!wsBroadcastHeapOk()) return;
343+
websocket.printfAll("{\"type\":\"debug\",\"event\":\"temp_peak\",\"soc_temp_max_c\":%.1f,\"ms\":%lu}",
344+
maxC,
345+
millis());
346+
}
347+
269348
void sendWebsocketError(AsyncWebSocketClient *client, const char *code, const char *message) {
270349
client->printf("{\"type\":\"error\",\"code\":\"%s\",\"message\":\"%s\",\"ms\":%lu}",
271350
code,
@@ -328,6 +407,11 @@ bool handleWebsocketControlCommand(AsyncWebSocketClient *client, String command,
328407
return true;
329408
}
330409

410+
if (command == "debug" || command == "diag") {
411+
sendWebsocketDebug(client, "ok");
412+
return true;
413+
}
414+
331415
if (command == "events") {
332416
if (action == "on" || action == "enable" || action == "enabled") {
333417
b_websocketEventsEnabled = true;
@@ -594,6 +678,10 @@ void setupWebsocketEvents() {
594678
// when the radio frees up instead of dropping. A genuinely dead client is
595679
// still reaped, just later.
596680
client->client()->setAckTimeout(30000);
681+
// Send the session-immutable fields (protocol version, firmware version,
682+
// reset reason) once on connect, so clients don't have to ask -- and so
683+
// the hot status broadcast doesn't carry them on every tick.
684+
sendWebsocketSessionInfo(client);
597685
} else if (type == WS_EVT_DISCONNECT) {
598686
Serial.printf("Client %u disconnected\n", client->id());
599687
// Only reset shared session state when the LAST client leaves —

0 commit comments

Comments
 (0)