Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:

Expand Down
13 changes: 11 additions & 2 deletions include/parameter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 11 additions & 33 deletions include/websocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions src/hds.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down