diff --git a/README.md b/README.md index c43d8d5..76d848a 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ WiFi snapshots are intentionally kept small, especially at 10 Hz: ``` Battery, charging, timer, and power/display state are sent in typed `status` -frames instead of every snapshot. `status`, `battery`, and `info` return a -status frame immediately. After `events on`, the firmware also sends a periodic -`type: "status"` frame every 5 seconds. +frames on demand only. Send `status`, `battery`, or `info` to get one. The +firmware does not push status periodically (mirrors the BT side, which has no +periodic state push either — clients request battery via `0x22`, etc.). WiFi clients can send these text commands over the same WebSocket: @@ -187,9 +187,9 @@ Status frame shape: ``` For backwards compatibility, WiFi only sends weight snapshots by default. A -client must send `events on` before periodic status, local scale button presses, -or power-off notifications are emitted. The event stream resets to off when the -WebSocket disconnects. +client must send `events on` before local scale button presses or power-off +notifications are emitted (status frames remain on-request regardless of the +`events` flag). The event stream resets to off when the WebSocket disconnects. Button event fields: diff --git a/include/parameter.h b/include/parameter.h index 4f12594..c1b4c0d 100644 --- a/include/parameter.h +++ b/include/parameter.h @@ -16,14 +16,23 @@ const unsigned long WEBSOCKET_2HZ_NOTIFY_INTERVAL_MS = 500; const unsigned long WEBSOCKET_5HZ_NOTIFY_INTERVAL_MS = 200; const unsigned long WEBSOCKET_10HZ_NOTIFY_INTERVAL_MS = 100; const unsigned long WEBSOCKET_DEFAULT_NOTIFY_INTERVAL_MS = WEBSOCKET_2HZ_NOTIFY_INTERVAL_MS; -const unsigned long WEBSOCKET_STATUS_NOTIFY_INTERVAL_MS = 5000; // volatile: written from the AsyncTCP task (WS event callback) and read // from the main loop. Without volatile, the compiler may keep these cached // in a register across the loop's WS gate check on the other core. volatile unsigned long weightWebsocketNotifyInterval = WEBSOCKET_DEFAULT_NOTIFY_INTERVAL_MS; volatile bool b_websocketEventsEnabled = false; volatile bool b_websocketLowPowerEnabled = false; -volatile unsigned long t_lastWebsocketStatusUpdate = 0; + +// Snapshot of the stopWatch state, refreshed once per main-loop iteration. The +// WS status frame is built on the AsyncTCP task (response to {"command":"status"} +// from handleWebsocketControlCommand); stopWatch is a multi-field object (running +// flag + start ts + accumulator) also mutated from BLE/USB and the main loop, so +// reading it directly off the AsyncTCP task can tear (CLAUDE.md threading model +// "stopWatch.* -- No -- multi-field..."). sendWebsocketStatus reads these single +// aligned volatiles instead. g_timerElapsed carries stopWatch.elapsed() in its +// configured resolution (SECONDS) -- the WS "timer_seconds" field. +volatile bool g_timerRunning = false; +volatile unsigned long g_timerElapsed = 0; // Websocket pending-command mask. Set on the AsyncTCP task by the WS event // callback; drained on the main loop. Defers hardware-touching ops (u8g2, diff --git a/include/websocket.h b/include/websocket.h index 1a9d907..7a1db33 100644 --- a/include/websocket.h +++ b/include/websocket.h @@ -162,17 +162,18 @@ void processWsPendingCmds() { // that allocation then throws std::bad_alloc -> std::terminate() -> abort() // (Arduino-ESP32 builds with -fno-exceptions, so the throw can't be caught) -> // reboot. That OOM-reboot is the "weight stops being collected under sustained -// multi-client load" failure. Skipping a frame is invisible (the next weight -// frame is <=500 ms away, status <=5 s); crashing is not. The floor sits above -// the 15 KB heap watchdog (wifi_setup.cpp) so broadcasts back off well before a -// reboot is even considered. Every broadcast helper below runs on the main loop, -// so the skip counter needs no synchronization. +// multi-client load" failure. Skipping a weight frame is invisible (the next is +// <=500 ms away); skipping a button or power-off broadcast is rarer but also +// tolerable; crashing is not. The floor sits above the 15 KB heap watchdog +// (wifi_setup.cpp) so broadcasts back off well before a reboot is even +// considered. Every broadcast helper below runs on the main loop, so the skip +// counter needs no synchronization. // // The floor is 32 KB (vs. the original 25 KB) to leave headroom for lwIP TX // buffers during the post-broadcast drain. Under 4-client load the 25 KB floor -// let free heap dip to ~22 KB after a status burst, where lwIP silently failed -// to allocate pbufs and WiFi packets dropped while the state machine still -// reported CONNECTED (no disc/rec increments). Raising the floor by ~7 KB +// let free heap dip to ~22 KB after a broadcast burst, where lwIP silently +// failed to allocate pbufs and WiFi packets dropped while the state machine +// still reported CONNECTED (no disc/rec increments). Raising the floor by ~7 KB // keeps the post-burst trough above the lwIP starvation knee. Measured: 2h+ // soak at 4 clients, 0% ping loss, 0 reconnects, ~300 averted OOMs. static const uint32_t WS_BROADCAST_HEAP_FLOOR = 32000; @@ -229,8 +230,8 @@ void sendWebsocketStatus(AsyncWebSocketClient *client, const char *status) { websocketBatteryPercent(), f_batteryVoltage, websocketIsCharging() ? "true" : "false", - stopWatch.isRunning() ? "true" : "false", - (unsigned long)stopWatch.elapsed(), + g_timerRunning ? "true" : "false", + g_timerElapsed, b_u8g2Sleep ? "false" : "true", b_websocketLowPowerEnabled ? "true" : "false", b_softSleep ? "true" : "false", @@ -247,27 +248,6 @@ void sendWebsocketStatus(AsyncWebSocketClient *client, const char *status) { // race client disconnects on the AsyncTCP task). With // setCloseClientOnQueueFull(false), a backed-up client drops its own frame // without blocking the others. -void sendWebsocketStatusAll(const char *status) { - if (!b_wifiEnabled || !b_websocketEventsEnabled || websocket.count() == 0) return; - if (!wsBroadcastHeapOk()) return; - websocket.printfAll("{\"type\":\"status\",\"status\":\"%s\",\"protocol_version\":1,\"firmware_version\":\"%s\",\"grams\":%.2f,\"ms\":%lu,\"battery_percent\":%d,\"battery_voltage\":%.2f,\"charging\":%s,\"timer_running\":%s,\"timer_seconds\":%lu,\"display_on\":%s,\"low_power\":%s,\"soft_sleep\":%s,\"events_enabled\":%s,\"rate_hz\":%lu,\"interval_ms\":%lu}", - status, - FIRMWARE_VER, - f_displayedValue, - millis(), - websocketBatteryPercent(), - f_batteryVoltage, - websocketIsCharging() ? "true" : "false", - stopWatch.isRunning() ? "true" : "false", - (unsigned long)stopWatch.elapsed(), - b_u8g2Sleep ? "false" : "true", - b_websocketLowPowerEnabled ? "true" : "false", - b_softSleep ? "true" : "false", - b_websocketEventsEnabled ? "true" : "false", - websocketRateForInterval(weightWebsocketNotifyInterval), - weightWebsocketNotifyInterval); -} - void sendWebsocketWeightAll(float grams, unsigned long ms) { if (!b_wifiEnabled || websocket.count() == 0) return; if (!wsBroadcastHeapOk()) return; @@ -339,7 +319,6 @@ bool handleWebsocketControlCommand(AsyncWebSocketClient *client, String command, if (command == "events") { if (action == "on" || action == "enable" || action == "enabled") { b_websocketEventsEnabled = true; - t_lastWebsocketStatusUpdate = millis(); sendWebsocketStatus(client, "ok"); return true; } @@ -611,7 +590,6 @@ void setupWebsocketEvents() { if (server->count() == 0) { weightWebsocketNotifyInterval = WEBSOCKET_DEFAULT_NOTIFY_INTERVAL_MS; b_websocketEventsEnabled = false; - t_lastWebsocketStatusUpdate = 0; } } else if (type == WS_EVT_ERROR) { // arg = reason code (uint16_t*), data/len = human-readable reason. Log diff --git a/src/hds.ino b/src/hds.ino index c759711..8fc43cd 100644 --- a/src/hds.ino +++ b/src/hds.ino @@ -1381,6 +1381,13 @@ void loop() { ElegantOTA.loop(); } + // Snapshot stopWatch state for cross-task-safe reads in + // sendWebsocketStatus. stopWatch is multi-field and mutated from BLE/USB + // and this loop; the WS status frame is built on the AsyncTCP task and + // can tear if it reads stopWatch directly (CLAUDE.md threading model). + g_timerRunning = stopWatch.isRunning(); + g_timerElapsed = (unsigned long)stopWatch.elapsed(); + // Unified weight-output tick: ONE 100 ms grid timer drives every active // interface, each at its own rate (sends every NotifyInterval/base // ticks) -- USB-text 1 Hz, USB-binary/WS at their NotifyInterval, BLE @@ -1414,13 +1421,6 @@ void loop() { } } - // Periodic WS status frame (5 s) -- not a weight frame, keeps its own gate. - if (b_wifiEnabled && b_websocketEventsEnabled && - millis() - t_lastWebsocketStatusUpdate >= WEBSOCKET_STATUS_NOTIFY_INTERVAL_MS) { - sendWebsocketStatusAll("periodic"); - t_lastWebsocketStatusUpdate = millis(); - } - // ADS debug BLE stream -- separate debug channel, keeps its own gate. if (b_ble_enabled && deviceConnected && bleDebugMode != DEBUG_OFF) { // SINGLE fires once; CONTINUOUS rate-limited to ~10 Hz