From 9ddbb4f3fd86a47e2b3846ed7b900a28046d24c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:41:45 +0000 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20add=20the=20second-pass=20review?= =?UTF-8?q?=20remediation=20plan=20(F-19=E2=80=A6F-44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the findings of a full static review of the firmware, numbered on from the first pass so an F-nn reference in a comment stays unambiguous. Each finding carries a severity weighed by whether a train can move uncommanded, an operator can lose the ability to stop one, or the device can crash while one is moving; a batch sized to one flash-and-test cycle; and bench criteria that stay unticked until run on the board. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- CLAUDE.md | 5 +- docs/REVIEW_REMEDIATION_PLAN_2.md | 630 ++++++++++++++++++++++++++++++ 2 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 docs/REVIEW_REMEDIATION_PLAN_2.md diff --git a/CLAUDE.md b/CLAUDE.md index a1cbd87..c2e654d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,9 @@ and stop as soon as you know enough: diagrams per flow, per-layer reference, protocol wire formats. `docs/REVIEW_REMEDIATION_PLAN.md` is the record of a completed hardening pass (F-01…F-18, -all Done). Read a finding when you are about to touch the code it covers — several of the -"why is it written like this" answers live there and nowhere else. +all Done); `docs/REVIEW_REMEDIATION_PLAN_2.md` is the second pass (F-19…F-44, in progress, +with a per-finding status). Read a finding when you are about to touch the code it covers — +several of the "why is it written like this" answers live there and nowhere else. **`.github/copilot-instructions.md` covers the same conventions for Copilot.** Where the two overlap they must be changed together; where they disagree, this file wins for Claude, diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md new file mode 100644 index 0000000..0417450 --- /dev/null +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -0,0 +1,630 @@ +# Review Remediation Plan — Second Pass + +**Created:** 2026-09-24 +**Status:** In progress +**Source:** Static review of the whole firmware (`main/`, build config, CI), with the +orchestrator-facing behaviour checked against `bazauto/layout-orchestration`. Nothing was +flashed during the review: every "bench" criterion below is unverified until someone runs it +on the board. + +IDs continue from the first pass (`docs/REVIEW_REMEDIATION_PLAN.md`, F-01…F-18), so an `F-nn` +reference in a code comment is unambiguous across both documents. + +Severity is weighed the way CLAUDE.md asks: by whether a train moves when nobody commanded it, +whether the operator can lose the ability to stop one, or whether the device can crash or +freeze while one is moving. + +--- + +## Progress Tracker + +| ID | Finding | Severity | Effort | Batch | Status | +|----|---------|----------|--------|-------|--------| +| F-19 | [Unbounded encoder delta](#f-19-unbounded-encoder-delta) | HIGH | Small | 1 | Open | +| F-20 | [Orchestrator acquire starts from speed 0](#f-20-orchestrator-acquire-starts-from-speed-0) | HIGH | Medium | 1 | Open | +| F-21 | [Use-after-free on return to the main screen](#f-21-use-after-free-on-return-to-the-main-screen) | HIGH | Medium | 1 | Open | +| F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Open | +| F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Open | +| F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Open | +| F-25 | [Physical knobs not gated; optimistic update outlives a failed send](#f-25-physical-knobs-not-gated-optimistic-update-outlives-a-failed-send) | MEDIUM | Small | 4 | Open | +| F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Open | +| F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Open | +| F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Open | +| F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Open | +| F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Open | +| F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Open | +| F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Open | +| F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Open | +| F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Open | +| F-35 | [Orchestrator roster refused above ~35 locos](#f-35-orchestrator-roster-refused-above-35-locos) | MEDIUM | Small | 5 | Open | +| F-36 | [Operator credential and session cookie in cleartext](#f-36-operator-credential-and-session-cookie-in-cleartext) | MEDIUM | Small (decision) | 5 | Open | +| F-37 | [WiThrottle updates unvalidated](#f-37-withrottle-updates-unvalidated) | LOW | Small | 5 | Open | +| F-38 | [Hot-path logging and repaint cost](#f-38-hot-path-logging-and-repaint-cost) | LOW | Small | 5 | Open | +| F-39 | [NVS writes on the LVGL task](#f-39-nvs-writes-on-the-lvgl-task) | LOW | Small | 5 | Open | +| F-40 | [WiFi credential save and reboot-on-error](#f-40-wifi-credential-save-and-reboot-on-error) | LOW | Small | 3 | Open | +| F-41 | [Seesaw read timing](#f-41-seesaw-read-timing) | LOW | Small | 5 | Open | +| F-42 | [Protocol hygiene odds and ends](#f-42-protocol-hygiene-odds-and-ends) | LOW | Small | 5 | Open | +| F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Open | +| F-44 | [Threading-model task table drift](#f-44-threading-model-task-table-drift) | LOW | Small | 5 | Open | + +**Status key:** *Open* — not started. *Code done* — implemented and compiled, bench criteria +still unticked. *Done* — bench criteria confirmed on the board. + +--- + +## Implementation Order + +Batches are sized to one flash-and-test cycle each. Nothing here can be verified by CI beyond +"it compiles", so a batch is only closed when its bench checks have been run. + +1. **Batch 1 — local, high-consequence fixes.** F-19, F-20, F-21, and F-32 and the + connection-slot half of F-30 as consequences of F-21's fix. Each is confined to a file or + two and needs no protocol change. +2. **Batch 2 — WiThrottle session lifecycle.** F-22, F-23, F-31. All three are about what + survives a connection ending, and are tested with the same bench procedure (restart JMRI, + pull the ESP's power). +3. **Batch 3 — recovery.** F-24, F-34, F-40. Tested by power-cycling the router and the + orchestrator host independently of the device. +4. **Batch 4 — operator-facing control.** F-25 to F-29. +5. **Batch 5 — the rest.** Measurement (F-33), limits, hygiene and docs. + +--- + +## Detailed Findings + +--- + +### F-19: Unbounded encoder delta + +**Severity:** HIGH | **Effort:** Small | **Batch:** 1 + +#### Description + +`RotaryEncoderHal::readEncoderDelta` assembles a 32-bit signed delta from four I2C bytes and +`pollOnce` passes it to the rotation callback unchecked. `ThrottleController::onKnobRotation` +computes `delta * stepsPerClick` — signed overflow, which is undefined behaviour, for a large +delta — and clamps the result to ±126. So one corrupted upper byte on a bus that runs alongside +DCC wiring commands **full speed, in either direction**. A garbage delta in `SELECTING` state +also reaches `Knob::handleRotation`, whose `while` wrap loops can run for millions of iterations +while holding `m_stateMutex`. + +The double-read workaround makes this likelier than it looks. If the Seesaw is answering the +*previous* request (the likely reason the second read is the one that "works"), one failed +transaction shifts the pipeline and a GPIO-bulk word — bit 24 set, about 16.7 million — is read +as a delta. + +#### Plan + +1. In `RotaryEncoderHal::pollOnce`, treat a delta outside ±`MAX_PLAUSIBLE_DELTA` as a failed + read: log it at WARN with the raw value and drop it. One full revolution per poll is far + beyond a human hand; that is the bound. +2. In `ThrottleController::onKnobRotation`, clamp `delta` before multiplying, so no caller can + reach the overflow. +3. Replace the `while` wrap loops in `Knob::handleRotation` with modular arithmetic. +4. Unit tests for the controller and the knob with an absurd delta. + +**Files:** `RotaryEncoderHal.cpp`, `ThrottleController.cpp`, `Knob.cpp`, tests + +#### Acceptance Criteria + +- [ ] A delta beyond the plausibility bound never reaches the rotation callback. +- [ ] `onKnobRotation(knob, INT_MAX)` is well-defined and moves the throttle by at most a + bounded amount. +- [ ] `Knob::handleRotation` with a huge delta returns promptly with a valid index. +- [ ] Bench: log raw deltas while spinning fast, and while unplugging an encoder mid-spin; no + out-of-bound value reaches the controller. + +--- + +### F-20: Orchestrator acquire starts from speed 0 + +**Severity:** HIGH | **Effort:** Medium | **Batch:** 1 + +#### Description + +On acquire, `Throttle::assignLocomotive` zeroes the model and +`OrchestratorBackend::acquireLocomotive` zeroes its shadow. The loco's actual state arrived in +the opening `STATE_SNAPSHOT` (and in every `LOCO_STATE` since), but `onLocoState` only routes +addresses that are already assigned, so it was discarded. Taking over a loco another operator +or an automation run has at speed 60, **one click clockwise commands speed 4; one click +anticlockwise commands reverse 4.** + +WiThrottle does not have this problem: JMRI restates speed and direction after `M…+`. + +#### Plan + +1. `OrchestratorClient` keeps a compact cache of the latest state per address, filled from + `STATE_SNAPSHOT` (which replaces it wholesale) and `LOCO_STATE`, cleared when the link goes + down. Bounded in size. +2. `OrchestratorBackend::acquireLocomotive` seeds its shadow from the cache and replays the + cached state through the throttle-state callback, exactly as a `LOCO_STATE` would arrive. + This is display-side use of layout state: nothing is sent outward, so the "a snapshot is + never replayed as commands" rule stands. +3. Unit test: snapshot, then acquire, then assert the update carried the snapshot's speed and + direction. + +**Files:** `OrchestratorClient.h/.cpp`, `OrchestratorBackend.cpp`, tests + +#### Acceptance Criteria + +- [ ] Acquiring a loco the orchestrator already reports as moving shows its speed and + direction immediately. +- [ ] The next knob click moves from that speed, not from 0. +- [ ] Nothing is sent to the orchestrator on acquire. +- [ ] Bench: drive a loco from the web UI, take it over on the device, click once each way. + +--- + +### F-21: Use-after-free on return to the main screen + +**Severity:** HIGH | **Effort:** Medium | **Batch:** 1 + +#### Description + +`ThrottleController::updateUI` reads the UI callback and its `userData` without a lock, and +`MainScreen::onUIUpdateNeeded` then blocks in `lvgl_port_lock(200)`. If the LVGL task is running +a Back handler at that moment, `AppController::showMainScreen()` destroys the old `MainScreen` +and builds a new one while holding the LVGL lock. Deregistering in the destructor is too late: +the encoder or network task already holds the old pointer, and when the lock frees it repaints +freed memory — heap corruption or a reboot with trains running. `PowerStatusBar`'s track-power +callback has the same shape. + +#### Plan + +Stop destroying the main screen. `AppController` builds one `MainScreen` for the life of the +application and `showMainScreen()` re-shows it (`lv_scr_load` plus a repaint). A screen that is +never freed cannot be used after it is freed, and the fix also closes F-32. + +That makes `MainScreen::create` a one-off, which exposes a second problem: it overwrote the +WiThrottle client's single connection-state slot, which the active backend owns and the knob +gating depends on, and `JmriConfigScreen` then nulled that slot on Back. Once the main screen is +no longer rebuilt on return, nothing would put it back. So: + +1. `MainScreen` no longer registers on `WiThrottleClient` at all: the backend's own callback + already reaches `updateUI`, which repaints the main screen. +2. `JmriConfigScreen` polls its status on an LVGL timer, as `SettingsScreen` and + `OrchestratorConfigScreen` already do, instead of taking either client's slot. + +**Files:** `AppController.h/.cpp`, `MainScreen.h/.cpp`, `JmriConfigScreen.h/.cpp` + +#### Acceptance Criteria + +- [ ] `MainScreen` is constructed once per boot. +- [ ] No UI class calls `setConnectionStateCallback` on a client. +- [ ] Knob gating still follows the WiThrottle link after visiting the JMRI screen and + returning. +- [ ] Bench: navigate Settings → Back twenty times while spinning a knob, on a build with + `CONFIG_HEAP_POISONING_COMPREHENSIVE`; no crash, no heap-corruption report. + +--- + +### F-22: WiThrottle reconnect leaks a socket and loses the session + +**Severity:** HIGH | **Effort:** Medium | **Batch:** 2 + +#### Description + +When JMRI drops the link, `receiveTask` exits but nothing calls `disconnect()`: the socket is +never closed, the task-exit semaphore is left given, and `m_throttleStates` still records every +loco as acquired. The reconnect task then calls `connect()` directly, which overwrites +`m_socket`. Each JMRI restart or long WiFi drop leaks one of lwIP's ten sockets, and the next +`disconnect()` "joins" a task that has not exited. + +Worse, the new JMRI session never has the locos acquired. The UI shows every throttle live, the +client sends `M…A` actions for throttles JMRI does not know about, and JMRI's MultiThrottle +should drop them — so the knob and the **stop** press do nothing while the loco runs on at +whatever the command station last refreshed. + +#### Plan + +1. `WiThrottleClient::connect()` tears down any previous socket and receive task first, and + takes the exit semaphore to a known state before starting a new task. +2. On a fresh session, re-acquire every loco that was acquired on the old one (the client + already records address and type per throttle). JMRI then restates speed and direction, + which re-seeds the display. +3. Keep `m_throttleStates` consistent with what the server actually has: cleared on an explicit + `disconnect()`, replayed on reconnect. + +**Files:** `WiThrottleClient.h/.cpp`, possibly `JmriConnectionController.cpp` + +#### Acceptance Criteria + +- [ ] Socket count is stable across ten JMRI restarts (lwIP stats or `socket()` fd numbers in + the log). +- [ ] After a JMRI restart the device re-acquires its locos without operator action. +- [ ] Bench: restart JMRI with a loco running; after reconnect, the knob and stop press + control it. + +--- + +### F-23: WiThrottle heartbeat never armed + +**Severity:** HIGH | **Effort:** Small | **Batch:** 2 + +#### Description + +`WiThrottleClient::connect()` never sends `*+`, and nothing sends a periodic `*`. The repo's own +protocol notes call the heartbeat "CRITICAL". Without it, JMRI never e-stops this device's locos +when the device crashes, reboots, or goes half-open on WiFi — the dead-man switch is simply +off. The device also sends `HESP32-S3`, where the protocol expects `HU`. + +#### Plan + +1. Send `HU` (a stable per-device id) and `N` on connect. +2. Parse the server's `*` announcement; if non-zero, send `*+` and run a heartbeat + from a small dedicated task at half that interval. Stop it before the socket closes. + +**Files:** `WiThrottleClient.h/.cpp` + +#### Acceptance Criteria + +- [ ] `*+` is sent once per session when the server announces a non-zero interval. +- [ ] `*` is sent at half the announced interval for the life of the session. +- [ ] Bench: pull the ESP's power with a loco running; JMRI e-stops it within the interval. + +--- + +### F-24: No automatic recovery after an ordinary outage + +**Severity:** HIGH | **Effort:** Medium | **Batch:** 3 + +#### Description + +- WiFi gives up after five immediate retries and stays `FAILED` for good. A router reboot takes + longer than those retries, and nothing retries afterwards. +- The orchestrator login is attempted once at boot. With the layout on one switch, the host + comes up after the ESP and the device sits at `FAILED`. +- JMRI auto-connect waits 30 s for WiFi and then exits without ever starting the reconnect task. + +In each case the operator has no control until they fix it by hand in settings. + +#### Plan + +1. `WiFiManager`: after the fast retries, keep retrying with a capped backoff until the + operator disconnects or forgets the network. +2. Orchestrator: a supervising task that retries login and socket with backoff until + connected, and re-logs in (rather than relying on the WebSocket's cookie-reusing + auto-reconnect) when the socket stays down. +3. JMRI: the auto-connect task waits for WiFi indefinitely and always leaves auto-reconnect + enabled once settings exist. + +**Files:** `WiFiManager.h/.cpp`, `AppController.h/.cpp`, `JmriConnectionController.cpp` + +#### Acceptance Criteria + +- [ ] Bench: power-cycle the router with the device running; it reconnects without a touch. +- [ ] Bench: boot the device before the orchestrator host; it connects when the host is up. +- [ ] Bench: same for JMRI. + +--- + +### F-25: Physical knobs not gated; optimistic update outlives a failed send + +**Severity:** MEDIUM | **Effort:** Small | **Batch:** 4 + +#### Description + +CLAUDE.md says K1/K2 are disabled while the transport is disconnected, but only the touch path +checks: the encoder path (`AppController` → `ThrottleController::onKnobRotation`) does not. The +model is also updated before the send, and `sendSpeedCommand` ignores the result, so a refused +command leaves the display ahead of the loco. The orchestrator refuses manual commands while +`offline`; the display creeps to 40 with the loco at 0, and the first click afterwards sends 44. + +#### Plan + +1. `onKnobRotation` and the `CONTROLLING` branch of `onKnobPress` refuse when the backend is not + connected. +2. Roll the model back to the pre-command value when the send fails, and repaint. + +**Files:** `ThrottleController.cpp`, tests + +#### Acceptance Criteria + +- [ ] A knob turn while disconnected changes nothing. +- [ ] A refused send leaves the model where it was. + +--- + +### F-26: `OrchestratorClient::m_client` destroyed under a sender + +**Severity:** MEDIUM | **Effort:** Medium | **Batch:** 4 + +#### Description + +`connect()` calls `disconnect()`, which stops and destroys the WebSocket handle, while +`sendJson` on the encoder task dereferences `m_client` without a lock. Pressing Connect on the +orchestrator screen while driving — or the screen's connect racing the boot `orch_connect` task +— is a use-after-free inside `esp_websocket_client_send_text`. The "already connecting" check is +also check-then-act, so two connect tasks can both pass it. + +#### Plan + +Guard the handle with its own mutex held across send and across stop/destroy (sends are +bounded at 1 s), and make the connecting check atomic. + +**Files:** `OrchestratorClient.h/.cpp` + +#### Acceptance Criteria + +- [ ] No path reads `m_client` without the handle mutex. +- [ ] Bench: press Connect repeatedly while spinning a knob; no crash. + +--- + +### F-27: No emergency stop; power button fails the wrong way + +**Severity:** MEDIUM | **Effort:** Medium | **Batch:** 4 + +#### Description + +`OrchestratorClient::sendEmergencyStop` exists but nothing calls it, and nothing sends +WiThrottle's `X`. A loco with no knob assigned cannot be stopped without assigning one. The +track-power toggle turns power **on** when the state is unknown — the wrong default for a panic +press — and under WiThrottle it depends on the separate JSON link, so it silently does nothing +when that link is down. + +#### Plan + +1. Add `emergencyStop()` to the `ThrottleBackend` port (orchestrator: `EMERGENCY_STOP`; + WiThrottle: `X` on every acquired throttle) and a dedicated on-screen control. +2. An unknown power state turns power off, not on. + +**Files:** `ThrottleBackend.h`, both backends, `ThrottleController`, `MainScreen`, +`PowerStatusBar.cpp` + +--- + +### F-28: Every function is momentary under the orchestrator + +**Severity:** MEDIUM | **Effort:** Small | **Batch:** 4 + +#### Description + +The UI sends `true` on press and `false` on release. That is right for WiThrottle, where JMRI +applies each function's latching setting. The orchestrator stores `FUNCTION_COMMAND.state` as +the new absolute state (`LayoutService.handleFunctionCommand`), so headlights and sound only stay +on while a finger is held down. + +#### Plan + +A port capability — press/release events versus absolute state — and a controller method that +toggles from the known state on press for state-style backends and ignores the release. + +**Files:** `ThrottleBackend.h`, both backends, `ThrottleController`, `MainScreen.cpp`, tests + +--- + +### F-29: Documented lock order is the reverse of the code's + +**Severity:** MEDIUM | **Effort:** Small | **Batch:** 4 + +#### Description + +CLAUDE.md (hard constraint 3) and `THREADING_MODEL.md` say `m_stateMutex` before +`lvgl_port_lock`, and the threading diagram shows `uiUpdateCallback` running inside the state +lock. The code does the reverse throughout and does it consistently: `onUIUpdateNeeded` takes +the LVGL lock and then `getThrottleSnapshot` takes the state mutex, and every LVGL event handler +calls into the controller the same way; the controller never calls out to the UI while holding +its own mutex. A contributor following the documented rule would introduce the deadlock it +claims to prevent. + +#### Plan + +Correct CLAUDE.md, `THREADING_MODEL.md` (text and diagram), and any other copy of the rule to +"LVGL lock before `m_stateMutex`; the controller never calls the UI while holding its mutex". + +--- + +### F-30: Callback slots unsynchronised and clobbered + +**Severity:** MEDIUM | **Effort:** Medium | **Batch:** 1 (clobbering), 5 (synchronisation) + +#### Description + +On `WiThrottleClient`, `JmriJsonClient`, `WiFiManager` and `ThrottleController`, a +`std::function` (or function pointer and `userData` pair) is assigned on the LVGL task and +invoked in place from network tasks, so a torn pair or a closure destroyed mid-call is possible. +Separately, `MainScreen` and `JmriConfigScreen` overwrote the WiThrottle client's single +connection slot that the backend owns — the F-08 problem the orchestrator screens were written +to avoid. `OrchestratorClient` copies callbacks under its mutex and invokes the copy; that is +the pattern to follow. + +#### Plan + +1. Batch 1: stop the UI taking backend-owned slots (see F-21). +2. Batch 5: copy-under-lock for every remaining slot. + +--- + +### F-31: JMRI heartbeat task deleted from outside + +**Severity:** MEDIUM | **Effort:** Small | **Batch:** 2 + +#### Description + +`JmriJsonClient::stopHeartbeat` calls `vTaskDelete` on a task that may be inside +`esp_websocket_client_send_text`, holding the client's lock — the F-02 shape again. It is +called from both the WebSocket task and `disconnect()` with an unguarded handle, so the same +handle can be deleted twice. The task's stack is 2 KB with `ESP_LOG` in its path. + +#### Plan + +Cooperative shutdown with an exit signal, as F-02 did for the WiThrottle receive task, and a +3 KB stack pending F-33's measurement. + +--- + +### F-32: Main screen LVGL tree leaks on every return + +**Severity:** MEDIUM | **Effort:** Small | **Batch:** 1 + +#### Description + +`lv_scr_load` does not free the previous screen — the comment in `MainScreen::~MainScreen` +says it does — and nothing else freed the main screen's tree. Every return to it leaked four +meters, the power bar, the carousel and a 29-button function panel. Small LVGL allocations come +from internal RAM first, the same heap task stacks and WiFi need. F-03's "20 round trips" +check was never ticked. + +#### Plan + +Closed by F-21's fix: the main screen is built once and re-shown. + +#### Acceptance Criteria + +- [ ] Bench: `heap_caps_get_free_size(MALLOC_CAP_INTERNAL)` is stable across twenty + Settings → Back round trips. + +--- + +### F-33: Task stack headroom unmeasured + +**Severity:** MEDIUM | **Effort:** Small | **Batch:** 5 + +#### Description + +`rotary_enc` (3 KB) and `withrottle_rx` (4 KB) run the whole `updateAllThrottles` LVGL repaint +on their own stacks; the receive task also ran `JmriJsonClient::connect` from the `PW` callback +(see F-34). A too-small stack fails as corruption, not as an error. + +#### Plan + +Log `uxTaskGetStackHighWaterMark` for every application task on the bench under load, record +the results in `THREADING_MODEL.md`, and resize with a margin. + +--- + +### F-34: JMRI config screen connect/disconnect faults + +**Severity:** MEDIUM | **Effort:** Medium | **Batch:** 3 + +#### Description + +- Disconnect runs on the LVGL task and blocks it for up to about two seconds. +- Disconnect does not stick: the reconnect task undoes it within five seconds. +- `setWebPortCallback` is registered after `connect()` has started the receive task, so it + races the `PW` line. +- That callback reconnects the JSON client on the WiThrottle receive task, concurrently with + the reconnect task — a possible double `esp_websocket_client_destroy`. +- A manual connect never updates `JmriConnectionController`'s saved address or enables + auto-reconnect, so later reconnects go to whatever was loaded at boot. + +#### Plan + +Route both the screen's connect and disconnect through `JmriConnectionController`, which owns +the saved settings, the auto-reconnect flag and the one task that connects. + +--- + +### F-35: Orchestrator roster refused above ~35 locos + +**Severity:** MEDIUM | **Effort:** Small | **Batch:** 5 + +#### Description + +`HTTP_RESPONSE_LIMIT` is 8 KB and each `LocoRecord` is about 230 bytes (two UUIDs among its +fields). A truncated response is refused whole, so a larger roster leaves nothing selectable. + +#### Plan + +Raise the limit for the roster read (allocating from PSRAM), or parse incrementally keeping +only address and name. + +--- + +### F-36: Operator credential and session cookie in cleartext + +**Severity:** MEDIUM | **Effort:** Small (decision) | **Batch:** 5 + +#### Description + +The login POST, the session cookie and the control plane travel over plain HTTP and WS. +`SESSION_TTL_MS` is 30 days with sliding refresh, so a cookie captured on the layout WiFi +drives trains for a month. Storing it in plaintext NVS is the accepted F-18 risk; nothing +records a decision about cleartext on the wire. + +#### Plan + +Record the decision in CLAUDE.md's transport section. If TLS is wanted, size it with +`idf.py size` first against the flash budget. + +--- + +### F-37: WiThrottle updates unvalidated + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +- `M…A` updates are applied without checking the address against the throttle's current loco, + so a late reply for the previous loco can set the wrong baseline. +- Function numbers from the server are unbounded, so `Throttle::setFunctionState` and the + function panel grow without limit. + +--- + +### F-38: Hot-path logging and repaint cost + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +- Every received WiThrottle line, knob tick and throttle update logs at INFO; console writes + block the encoder and receive tasks. +- `PowerStatusBar::refresh()` runs four times per repaint, and labels are rewritten (and so + invalidated) even when unchanged. + +--- + +### F-39: NVS writes on the LVGL task + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +`JmriConfigScreen::saveSettings`, the orchestrator screen's save and `saveSpeedSteps` still +write NVS from event handlers, against F-05's rule. + +--- + +### F-40: WiFi credential save and reboot-on-error + +**Severity:** LOW | **Effort:** Small | **Batch:** 3 + +`WiFiManager::connect` saves credentials before it knows they work, overwriting a good password +with a typo; and `ESP_ERROR_CHECK(esp_wifi_set_config)` turns a recoverable error into a reboot. + +--- + +### F-41: Seesaw read timing + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +The double read compensates for a missing write → delay → read sequence (the Seesaw needs time +to prepare a response). Rotation during the 5 ms between reads is lost. Split each transaction +into a write, a short delay and a read, and drop the double read — bench-verify before merging. + +--- + +### F-42: Protocol hygiene odds and ends + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +- `JmriJsonClient::requestPowerList` (unused) treats a byte count as an error and blocks with + `portMAX_DELAY`. +- Several `OrchestratorClient` lock-timeout fallbacks write shared strings unlocked. +- WebSocket continuation frames are accepted but the buffer is cleared per fragment (fails safe). +- `getLayoutId` always uses the first layout and is not cleared when the host changes. +- `handleRosterMessage`'s warning can read past the end of a malformed message. + +--- + +### F-43: CI hardening + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +No `permissions:` block, actions pinned by tag rather than SHA, and +`espressif/esp_websocket_client: '*'` in the manifest (the lock file pins it). + +--- + +### F-44: Threading-model task table drift + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +`THREADING_MODEL.md`'s table is missing `jmri_connect` (6 KB) and `wifi_scan` (4 KB), and will +need the measured figures from F-33. From 7c980f9b6860382a20d7b81559231a8455bc8f4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:48:26 +0000 Subject: [PATCH 02/12] fix: bound encoder deltas, seed orchestrator acquire, keep one main screen (F-19, F-20, F-21) F-19: one corrupted I2C read reached the controller as a delta and was clamped to full speed in one direction or the other. The encoder HAL now drops anything beyond a full revolution per poll as a failed read; the controller clamps before multiplying (that multiply overflowed), and the knob's roster wrap is modular rather than a loop that could spin for millions of iterations holding the state mutex. F-20: taking over a loco on the orchestrator zeroed the model and the shadow, so the first click on a loco doing 60 commanded speed 4, or a reversal. OrchestratorClient now keeps each loco's last reported state (snapshot replaces it; LOCO_STATE updates it; link loss clears it), and acquire replays it to the new throttle as a LOCO_STATE would arrive. Display path only: nothing is sent on acquire. F-21 (and F-32): showMainScreen() destroyed the old MainScreen inside an LVGL handler while an encoder or network task could already hold its pointer and be waiting on the LVGL lock to repaint it. The main screen is now built once and re-shown, which also stops the whole LVGL tree leaking on every return. That made the screen's registration on the WiThrottle client's single connection slot a lasting problem -- JmriConfigScreen nulled it on Back and nothing restored it -- so neither screen takes that slot any more: MainScreen gets link changes through the controller, and JmriConfigScreen polls on a timer like the other config screens. Compiled for the app and the test configuration; the new Unity tests and the bench checks in docs/REVIEW_REMEDIATION_PLAN_2.md still need the board. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- docs/REVIEW_REMEDIATION_PLAN_2.md | 41 ++++--- docs/components/COMMUNICATION_LAYER.md | 9 +- docs/components/CONTROLLER_LAYER.md | 2 +- docs/components/HARDWARE_LAYER.md | 2 +- docs/components/UI_LAYER.md | 10 +- docs/flows/SCREEN_NAVIGATION.md | 22 ++-- main/communication/OrchestratorBackend.cpp | 124 +++++++++++++-------- main/communication/OrchestratorBackend.h | 12 ++ main/communication/OrchestratorClient.cpp | 88 +++++++++++++++ main/communication/OrchestratorClient.h | 38 +++++++ main/controller/AppController.cpp | 10 +- main/controller/ThrottleController.cpp | 8 ++ main/hardware/RotaryEncoderHal.cpp | 9 +- main/hardware/RotaryEncoderHal.h | 16 +++ main/model/Knob.cpp | 18 +-- main/tests/OrchestratorProtocolTests.cpp | 90 +++++++++++++++ main/tests/ThrottleControllerTests.cpp | 40 +++++++ main/tests/ThrottleModelTests.cpp | 22 ++++ main/ui/JmriConfigScreen.cpp | 44 +++++--- main/ui/JmriConfigScreen.h | 10 ++ main/ui/MainScreen.cpp | 44 ++++---- main/ui/MainScreen.h | 24 ++-- 22 files changed, 544 insertions(+), 139 deletions(-) diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index 0417450..8a8a4d4 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -1,7 +1,7 @@ # Review Remediation Plan — Second Pass **Created:** 2026-09-24 -**Status:** In progress +**Status:** In progress — Batch 1 code done, awaiting its bench cycle **Source:** Static review of the whole firmware (`main/`, build config, CI), with the orchestrator-facing behaviour checked against `bazauto/layout-orchestration`. Nothing was flashed during the review: every "bench" criterion below is unverified until someone runs it @@ -20,9 +20,9 @@ freeze while one is moving. | ID | Finding | Severity | Effort | Batch | Status | |----|---------|----------|--------|-------|--------| -| F-19 | [Unbounded encoder delta](#f-19-unbounded-encoder-delta) | HIGH | Small | 1 | Open | -| F-20 | [Orchestrator acquire starts from speed 0](#f-20-orchestrator-acquire-starts-from-speed-0) | HIGH | Medium | 1 | Open | -| F-21 | [Use-after-free on return to the main screen](#f-21-use-after-free-on-return-to-the-main-screen) | HIGH | Medium | 1 | Open | +| F-19 | [Unbounded encoder delta](#f-19-unbounded-encoder-delta) | HIGH | Small | 1 | Code done | +| F-20 | [Orchestrator acquire starts from speed 0](#f-20-orchestrator-acquire-starts-from-speed-0) | HIGH | Medium | 1 | Code done | +| F-21 | [Use-after-free on return to the main screen](#f-21-use-after-free-on-return-to-the-main-screen) | HIGH | Medium | 1 | Code done | | F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Open | | F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Open | | F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Open | @@ -31,9 +31,9 @@ freeze while one is moving. | F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Open | | F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Open | | F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Open | -| F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Open | +| F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Slots: code done; sync: open | | F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Open | -| F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Open | +| F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | | F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Open | | F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Open | | F-35 | [Orchestrator roster refused above ~35 locos](#f-35-orchestrator-roster-refused-above-35-locos) | MEDIUM | Small | 5 | Open | @@ -107,10 +107,14 @@ as a delta. #### Acceptance Criteria -- [ ] A delta beyond the plausibility bound never reaches the rotation callback. -- [ ] `onKnobRotation(knob, INT_MAX)` is well-defined and moves the throttle by at most a - bounded amount. -- [ ] `Knob::handleRotation` with a huge delta returns promptly with a valid index. +- [x] A delta beyond the plausibility bound never reaches the rotation callback + (`RotaryEncoderHal::isPlausibleDelta`, ±24). +- [x] `onKnobRotation(knob, INT_MIN)` is well-defined: the delta is clamped before the + multiply, and the result saturates at the end of the range. +- [x] `Knob::handleRotation` with a huge delta returns at once with a valid index. +- [ ] Unit tests `test_encoder_implausible_delta_is_refused`, + `test_controller_extreme_delta_is_well_defined` and + `test_knob_rotation_huge_delta_wraps_directly` pass on the board (they compile). - [ ] Bench: log raw deltas while spinning fast, and while unplugging an encoder mid-spin; no out-of-bound value reaches the controller. @@ -147,10 +151,14 @@ WiThrottle does not have this problem: JMRI restates speed and direction after ` #### Acceptance Criteria -- [ ] Acquiring a loco the orchestrator already reports as moving shows its speed and - direction immediately. -- [ ] The next knob click moves from that speed, not from 0. -- [ ] Nothing is sent to the orchestrator on acquire. +- [x] Acquiring a loco the orchestrator already reports as moving shows its speed and + direction immediately (replayed through the throttle-state callback). +- [x] The next knob click moves from that speed, not from 0 — the controller's model and + the backend's shadow are both seeded. +- [x] Nothing is sent to the orchestrator on acquire. +- [ ] Unit tests `test_orch_backend_acquire_seeds_from_layout_state`, + `test_orch_backend_acquire_of_unreported_loco_shows_nothing` and + `test_orch_snapshot_replaces_cached_state` pass on the board (they compile). - [ ] Bench: drive a loco from the web UI, take it over on the device, click once each way. --- @@ -189,8 +197,9 @@ no longer rebuilt on return, nothing would put it back. So: #### Acceptance Criteria -- [ ] `MainScreen` is constructed once per boot. -- [ ] No UI class calls `setConnectionStateCallback` on a client. +- [x] `MainScreen` is constructed once per boot; `MainScreen::create` now takes only the + controller. +- [x] No UI class calls `setConnectionStateCallback` on a client. - [ ] Knob gating still follows the WiThrottle link after visiting the JMRI screen and returning. - [ ] Bench: navigate Settings → Back twenty times while spinning a knob, on a build with diff --git a/docs/components/COMMUNICATION_LAYER.md b/docs/components/COMMUNICATION_LAYER.md index f85a516..3525499 100644 --- a/docs/components/COMMUNICATION_LAYER.md +++ b/docs/components/COMMUNICATION_LAYER.md @@ -101,7 +101,7 @@ The login is **blocking**, so it never runs on the LVGL task (F-05). See | Inbound | Effect | |---------|--------| -| `STATE_SNAPSHOT` | Applies every loco's state and the system status. **Display only** — never replayed outward as a command. | +| `STATE_SNAPSHOT` | Applies every loco's state and the system status, and replaces the per-loco state cache. **Display only** — never replayed outward as a command. | | `LOCO_STATE` | One loco's speed, direction and functions. | | `SYSTEM_STATUS` | Online / safe-stop / offline, with reason. | | `HEARTBEAT` | Liveness timestamp only (`secondsSinceLastMessage()`). | @@ -187,6 +187,13 @@ handshake. The adapter also shadows each throttle's last commanded speed and dir because `THROTTLE_COMMAND` carries both together and a caller changing one still has to supply the other. +**Acquire starts from what the loco is doing.** `OrchestratorClient` keeps each loco's last +reported state (from the snapshot and every `LOCO_STATE`; emptied when the link drops), and +acquiring replays it to the new throttle exactly as a `LOCO_STATE` would arrive. Taking over a +loco another operator has at speed 60 therefore shows 60, and the next click moves from there +— starting from zero made that click command speed 4, or a reversal (F-20). This is the +display path only: nothing is sent on acquire. + **Release sends nothing.** There is no session to hand back, and this device is not the only thing that can drive that loco — an automation run or another operator may be in charge of it. Stopping it because one throttle stopped displaying it would be a movement nobody diff --git a/docs/components/CONTROLLER_LAYER.md b/docs/components/CONTROLLER_LAYER.md index be53c41..338da69 100644 --- a/docs/components/CONTROLLER_LAYER.md +++ b/docs/components/CONTROLLER_LAYER.md @@ -45,7 +45,7 @@ configured). | `m_jmriConnectionController` | `unique_ptr` | Auto-connect + reconnect | | `m_throttleController` | `unique_ptr` | Throttle/knob state | | `m_encoderHal` | `unique_ptr` | Encoder hardware | -| `m_mainScreen` | `unique_ptr` | Current main screen instance | +| `m_mainScreen` | `unique_ptr` | The main screen. Built on first show and kept for the life of the app: rebuilding it was a use-after-free (F-21) | | `m_settingsScreen` | `unique_ptr` | Device settings. Rebuilt on each open, because which status rows it shows depends on the selected transport | ### Key Methods diff --git a/docs/components/HARDWARE_LAYER.md b/docs/components/HARDWARE_LAYER.md index 2ef5810..9ded884 100644 --- a/docs/components/HARDWARE_LAYER.md +++ b/docs/components/HARDWARE_LAYER.md @@ -32,7 +32,7 @@ HAL for 2× Adafruit I2C QT Rotary Encoders using the Seesaw protocol over I2C. | `initialise()` | I2C scan, configure button pin as input + pullup | | `startPollingTask()` | Spawns `rotary_enc` FreeRTOS task | | `getStatus(index)` | Returns `EncoderStatus { address, present }` | -| `setRotationCallback(fn)` | `fn(int knobId, int delta)` — called from polling task | +| `setRotationCallback(fn)` | `fn(int knobId, int delta)` — called from polling task. A delta beyond ±`MAX_PLAUSIBLE_DELTA` (24) is dropped as a corrupted read and never reaches `fn` (F-19) | | `setPressCallback(fn)` | `fn(int knobId, bool pressed)` — edge-detected, called on press down only | ### Threading diff --git a/docs/components/UI_LAYER.md b/docs/components/UI_LAYER.md index 19ef311..c04c196 100644 --- a/docs/components/UI_LAYER.md +++ b/docs/components/UI_LAYER.md @@ -58,15 +58,17 @@ the bottom. Every screen follows it. ``` **Dependencies (raw pointers, not owned):** -- `WiThrottleClient*` -- `JmriJsonClient*` -- `ThrottleController*` +- `ThrottleController*` — and nothing else. Connection state, knob gating, functions and + power all come through it, never from a concrete client. + +**Lifetime:** built once per boot and re-shown with `show()`; never destroyed (F-21). **Key Methods:** | Method | Description | |--------|-------------| -| `create(WT*, JC*, TC*)` | Build LVGL widget tree, register callbacks | +| `create(TC*)` | Build LVGL widget tree, register the UI update callback. First show only | +| `show()` | Re-load the existing screen and repaint it | | `updateThrottle(id)` | Refresh one throttle meter from snapshot | | `updateAllThrottles()` | Refresh all meters + roster carousel | diff --git a/docs/flows/SCREEN_NAVIGATION.md b/docs/flows/SCREEN_NAVIGATION.md index 84981fd..eef8b04 100644 --- a/docs/flows/SCREEN_NAVIGATION.md +++ b/docs/flows/SCREEN_NAVIGATION.md @@ -32,28 +32,32 @@ sequenceDiagram Note over TC: State persists across screen changes - AC->>MS: new + create(WT*, JC*, TC*) + AC->>MS: new + create(TC*) — first show_main_screen() only Note over MS: Active — showing throttles User->>MS: Press "Settings" MS->>AC: show_wifi_config_screen() - AC->>MS: delete MainScreen - AC->>WCS: new WiFiConfigScreen(WiFiManager&) AC->>WCS: create() + Note over MS: Kept, hidden — still repainted by TC Note over WCS: Active — showing WiFi config User->>WCS: Press "Back" WCS->>AC: close_wifi_config_screen() → show_main_screen() - AC->>WCS: delete WiFiConfigScreen - AC->>MS: new MainScreen() - AC->>MS: create(WT*, JC*, TC*) - Note over MS: Rebuilt from scratch — state intact from TC + AC->>MS: show() + Note over MS: Same instance re-shown and repainted + WCS->>WCS: lv_obj_del_async(its own LVGL screen) ``` ## Key Points -- **MainScreen is recreated** each time `showMainScreen()` is called. This is safe because all state lives in `ThrottleController`. -- **Config screens are heap-allocated** and deleted when navigating away. +- **MainScreen is built once and re-shown**, never destroyed. Destroying it from a Back + handler was a use-after-free: an encoder or network task that had already read the UI + callback could be waiting on the LVGL lock to repaint it (F-21). And `lv_scr_load` frees + nothing, so rebuilding it also leaked the whole tree on every return (F-32). +- **Config screens delete their own LVGL tree** (`lv_obj_del_async`) when left. Their C++ + objects are kept by `AppController`, except the settings screen, which is rebuilt on each + open. None of them registers on a client's connection callback: they poll on an LVGL + timer, because that slot belongs to the active backend. - **Wrapper functions** (`show_main_screen()`, `show_wifi_config_screen()`, etc.) provide `extern "C"` linkage so `main.c` and inter-screen navigation work without C++ name mangling. - **LVGL lock** must be held when creating/destroying screens (handled by the callers). diff --git a/main/communication/OrchestratorBackend.cpp b/main/communication/OrchestratorBackend.cpp index 30c1f70..263bd12 100644 --- a/main/communication/OrchestratorBackend.cpp +++ b/main/communication/OrchestratorBackend.cpp @@ -111,15 +111,36 @@ esp_err_t OrchestratorBackend::acquireLocomotive(int throttleId, int address, bo return ESP_ERR_TIMEOUT; } m_assignments[throttleId].address = address; - // Assume nothing about what the loco is doing. The next LOCO_STATE tells us, - // and until then these shadow values only matter if the operator commands - // something, which overwrites them anyway. m_assignments[throttleId].speed = 0; m_assignments[throttleId].forward = true; unlock(); ESP_LOGI(TAG, "Throttle %d now drives loco %d (no handshake; the orchestrator has no sessions)", throttleId, address); + + // The loco may already be moving -- another operator or an automation run + // may have it. Starting from zero would make the first click command + // speed 4 to a loco doing 60, or reverse it (F-20). So replay what the + // layout last reported, exactly as though that LOCO_STATE had just + // arrived. Display-side only: nothing is sent. + // + // The assignment is already in place, so any LOCO_STATE processed from here + // on also reaches this throttle. The second read catches one that landed + // between the first read and the publish, which would otherwise leave the + // older value on screen. + OrchestratorClient::LocoState known; + if (m_client && m_client->getLastLocoState(address, known)) { + const ThrottleStateCallback callback = copyThrottleStateCallback(); + publishToThrottle(throttleId, known, callback); + + OrchestratorClient::LocoState latest; + if (m_client->getLastLocoState(address, latest) && + (latest.speed != known.speed || latest.direction != known.direction)) { + publishToThrottle(throttleId, latest, callback); + } + } else { + ESP_LOGI(TAG, "Layout has reported nothing for loco %d; starting from stopped", address); + } return ESP_OK; } @@ -280,7 +301,7 @@ bool OrchestratorBackend::getRosterEntry(int index, RosterEntry& outEntry) const return true; } -void OrchestratorBackend::onLocoState(const OrchestratorClient::LocoState& state) +OrchestratorBackend::ThrottleStateCallback OrchestratorBackend::copyThrottleStateCallback() const { ThrottleStateCallback callback; if (lock(pdMS_TO_TICKS(50))) { @@ -289,58 +310,71 @@ void OrchestratorBackend::onLocoState(const OrchestratorClient::LocoState& state } else { callback = m_throttleStateCallback; } + return callback; +} - if (!callback) { - return; - } +void OrchestratorBackend::onLocoState(const OrchestratorClient::LocoState& state) +{ + const ThrottleStateCallback callback = copyThrottleStateCallback(); // A loco can legitimately sit on more than one of this device's throttles, // so this is a loop rather than a lookup that stops at the first match. for (int throttleId = 0; throttleId < MAX_THROTTLES; ++throttleId) { - int address = 0; - if (lock(pdMS_TO_TICKS(50))) { - address = m_assignments[throttleId].address; - if (address == state.address) { - // Keep the shadow in step, so a later speed-only change is - // paired with the direction the layout actually has. - m_assignments[throttleId].speed = state.speed; - if (state.direction != OrchestratorClient::Direction::STOP) { - m_assignments[throttleId].forward = - (state.direction == OrchestratorClient::Direction::FORWARD); - } + publishToThrottle(throttleId, state, callback); + } +} + +void OrchestratorBackend::publishToThrottle(int throttleId, + const OrchestratorClient::LocoState& state, + const ThrottleStateCallback& callback) +{ + if (!isValidThrottle(throttleId)) { + return; + } + + bool holdsLoco = false; + if (lock(pdMS_TO_TICKS(50))) { + holdsLoco = (m_assignments[throttleId].address == state.address); + if (holdsLoco) { + // Keep the shadow in step, so a later speed-only change is paired + // with the direction the layout actually has. + m_assignments[throttleId].speed = state.speed; + if (state.direction != OrchestratorClient::Direction::STOP) { + m_assignments[throttleId].forward = + (state.direction == OrchestratorClient::Direction::FORWARD); } - unlock(); } + unlock(); + } - if (address != state.address) { - continue; - } + if (!holdsLoco || !callback) { + return; + } - ThrottleUpdate update; - update.throttleId = throttleId; - update.address = state.address; - update.speed = state.speed; - - // 'stop' is not a heading, so it leaves the displayed direction alone - // rather than being flattened into "reverse". - switch (state.direction) { - case OrchestratorClient::Direction::FORWARD: update.direction = 1; break; - case OrchestratorClient::Direction::REVERSE: update.direction = 0; break; - case OrchestratorClient::Direction::STOP: - default: update.direction = -1; break; - } + ThrottleUpdate update; + update.throttleId = throttleId; + update.address = state.address; + update.speed = state.speed; - callback(update); + // 'stop' is not a heading, so it leaves the displayed direction alone + // rather than being flattened into "reverse". + switch (state.direction) { + case OrchestratorClient::Direction::FORWARD: update.direction = 1; break; + case OrchestratorClient::Direction::REVERSE: update.direction = 0; break; + case OrchestratorClient::Direction::STOP: + default: update.direction = -1; break; + } - // Functions travel one per update, matching the port's shape. - for (const auto& fn : state.functions) { - ThrottleUpdate fnUpdate; - fnUpdate.throttleId = throttleId; - fnUpdate.address = state.address; - fnUpdate.function = fn.first; - fnUpdate.functionState = fn.second; - callback(fnUpdate); - } + callback(update); + + // Functions travel one per update, matching the port's shape. + for (const auto& fn : state.functions) { + ThrottleUpdate fnUpdate; + fnUpdate.throttleId = throttleId; + fnUpdate.address = state.address; + fnUpdate.function = fn.first; + fnUpdate.functionState = fn.second; + callback(fnUpdate); } } diff --git a/main/communication/OrchestratorBackend.h b/main/communication/OrchestratorBackend.h index 83746df..33b661b 100644 --- a/main/communication/OrchestratorBackend.h +++ b/main/communication/OrchestratorBackend.h @@ -88,6 +88,18 @@ class OrchestratorBackend : public ThrottleBackend { /** Routes an incoming LocoState to whichever throttles hold that address. */ void onLocoState(const OrchestratorClient::LocoState& state); + /** + * @brief Applies a LocoState to one throttle: shadow first, then display. + * + * Does nothing when the throttle no longer holds that address, so a state + * racing a release or a re-acquire cannot land on the wrong loco. + */ + void publishToThrottle(int throttleId, + const OrchestratorClient::LocoState& state, + const ThrottleStateCallback& callback); + + ThrottleStateCallback copyThrottleStateCallback() const; + bool lock(TickType_t timeout) const; void unlock() const; diff --git a/main/communication/OrchestratorClient.cpp b/main/communication/OrchestratorClient.cpp index e6cd0f5..432caad 100644 --- a/main/communication/OrchestratorClient.cpp +++ b/main/communication/OrchestratorClient.cpp @@ -439,6 +439,9 @@ void OrchestratorClient::disconnect() m_sessionToken.clear(); } + // What the layout told us is no longer current once we stop listening. + clearLocoStates(); + setState(ConnectionState::DISCONNECTED); } @@ -463,6 +466,9 @@ void OrchestratorClient::websocketEventHandler(void* handlerArgs, case WEBSOCKET_EVENT_DISCONNECTED: ESP_LOGW(TAG, "Control plane disconnected"); + // Stale from here on; the snapshot that opens the next connection + // refills it. + self->clearLocoStates(); self->setState(ConnectionState::DISCONNECTED); break; @@ -602,6 +608,10 @@ void OrchestratorClient::handleStateSnapshot(const void* payloadPtr) handleDccLink(dccLink); } + // The snapshot is the layout's whole belief, so a loco it does not mention + // is one it knows nothing about: nothing cached before it still stands. + clearLocoStates(); + // `locos` is an object keyed by address. This is the layout's belief about // what is already moving -- it updates what we display, and is never // replayed outward as a command. @@ -670,6 +680,10 @@ void OrchestratorClient::handleLocoState(const void* payloadPtr) } } + // Cached before dispatch, so a throttle taking this loco over from now on + // starts from this state even if it is not assigned anywhere yet (F-20). + cacheLocoState(state); + LocoStateCallback callback; if (lockState(pdMS_TO_TICKS(50))) { callback = m_locoStateCallback; @@ -1145,6 +1159,80 @@ bool OrchestratorClient::getRosterEntry(int index, RosterEntry& outEntry) const return found; } +// --- Last reported loco state ---------------------------------------------- + +void OrchestratorClient::cacheLocoState(const LocoState& state) +{ + CachedLocoState cached; + cached.speed = state.speed; + cached.direction = state.direction; + for (const auto& fn : state.functions) { + // The parser admits F0-F28 only; checked again because a shift past + // 31 is undefined. + if (fn.first < 0 || fn.first > 28) { + continue; + } + const uint32_t bit = 1u << fn.first; + cached.functionsKnown |= bit; + if (fn.second) { + cached.functionsOn |= bit; + } + } + + if (!lockState(pdMS_TO_TICKS(50))) { + ESP_LOGW(TAG, "Could not lock to cache state for loco %d", state.address); + return; + } + auto it = m_locoStates.find(state.address); + if (it != m_locoStates.end()) { + it->second = cached; + } else if (m_locoStates.size() < MAX_CACHED_LOCOS) { + m_locoStates.emplace(state.address, cached); + } else { + ESP_LOGW(TAG, "Loco state cache full; not caching loco %d", state.address); + } + unlockState(); +} + +void OrchestratorClient::clearLocoStates() +{ + if (lockState(pdMS_TO_TICKS(200))) { + m_locoStates.clear(); + unlockState(); + } else { + ESP_LOGW(TAG, "Could not lock to clear the loco state cache"); + } +} + +bool OrchestratorClient::getLastLocoState(int address, LocoState& outState) const +{ + CachedLocoState cached; + bool found = false; + if (lockState(pdMS_TO_TICKS(50))) { + auto it = m_locoStates.find(address); + if (it != m_locoStates.end()) { + cached = it->second; + found = true; + } + unlockState(); + } + if (!found) { + return false; + } + + outState = LocoState{}; + outState.address = address; + outState.speed = cached.speed; + outState.direction = cached.direction; + for (int fn = 0; fn <= 28; ++fn) { + const uint32_t bit = 1u << fn; + if (cached.functionsKnown & bit) { + outState.functions[fn] = (cached.functionsOn & bit) != 0; + } + } + return true; +} + // --- Callbacks ------------------------------------------------------------- void OrchestratorClient::setConnectionStateCallback(ConnectionStateCallback callback) diff --git a/main/communication/OrchestratorClient.h b/main/communication/OrchestratorClient.h index 9cf0958..e839550 100644 --- a/main/communication/OrchestratorClient.h +++ b/main/communication/OrchestratorClient.h @@ -176,6 +176,23 @@ class OrchestratorClient { size_t getRosterSize() const; bool getRosterEntry(int index, RosterEntry& outEntry) const; + // --- Last reported loco state ------------------------------------------- + + /** + * @brief The layout's last reported state for a loco, if any. + * + * Filled from STATE_SNAPSHOT (which replaces it wholesale) and LOCO_STATE, + * emptied when the link drops. Read when a throttle takes a loco over, so + * the display -- and the knob arithmetic that starts from it -- begins from + * what the loco is doing rather than from zero (F-20). + * + * Display-side only, like the snapshot it comes from: nothing read here is + * ever sent back out as a command. + * + * @return false when the layout has not reported that loco. + */ + bool getLastLocoState(int address, LocoState& outState) const; + // --- Notifications ---------------------------------------------------- void setConnectionStateCallback(ConnectionStateCallback callback); @@ -235,6 +252,26 @@ class OrchestratorClient { /** Cached so the roster fetch and the power POST can both reach it. */ std::string getLayoutId(); + /** + * @brief One loco's last reported state, kept compact. + * + * LocoState's function map costs a heap node per function; a pair of + * bitmasks holds F0-F28 in eight bytes. + */ + struct CachedLocoState { + int speed = 0; + Direction direction = Direction::STOP; + uint32_t functionsKnown = 0; ///< bit n set: Fn was reported + uint32_t functionsOn = 0; ///< bit n set: Fn is on + }; + + /** Bounds the cache against a server reporting ever more addresses. */ + static constexpr size_t MAX_CACHED_LOCOS = 128; + + /** Records a validated LocoState. Takes the state mutex. */ + void cacheLocoState(const LocoState& state); + void clearLocoStates(); + esp_err_t sendJson(const std::string& json); static void websocketEventHandler(void* handlerArgs, @@ -259,6 +296,7 @@ class OrchestratorClient { TrackPower m_trackPower; std::string m_layoutId; std::vector m_roster; + std::map m_locoStates; int64_t m_lastMessageUs; ConnectionStateCallback m_connectionCallback; diff --git a/main/controller/AppController.cpp b/main/controller/AppController.cpp index efb3588..ba14c05 100644 --- a/main/controller/AppController.cpp +++ b/main/controller/AppController.cpp @@ -138,12 +138,18 @@ void AppController::showMainScreen() { initialise(); + // Built once and re-shown, never rebuilt. Rebuilding destroyed the old + // screen from inside an LVGL handler, while an encoder or network task + // could already hold its pointer and be waiting on the LVGL lock to repaint + // it -- a use-after-free with trains running (F-21). It also leaked the + // whole LVGL tree each time, since lv_scr_load frees nothing (F-32). if (m_mainScreen) { - m_mainScreen.reset(); + m_mainScreen->show(); + return; } m_mainScreen = std::make_unique(); - m_mainScreen->create(m_wiThrottleClient.get(), m_jmriClient.get(), m_throttleController.get()); + m_mainScreen->create(m_throttleController.get()); } void AppController::showWiFiConfigScreen() diff --git a/main/controller/ThrottleController.cpp b/main/controller/ThrottleController.cpp index 98cf129..ecb5a0f 100644 --- a/main/controller/ThrottleController.cpp +++ b/main/controller/ThrottleController.cpp @@ -8,6 +8,11 @@ static const char* TAG = "ThrottleController"; static const char* NVS_NAMESPACE = "jmri"; static const char* NVS_KEY_SPEED_STEPS = "speed_steps"; +// No legitimate rotation needs more clicks than the whole speed range. The +// encoder HAL already drops implausible reads; this is the last line before a +// multiplication that would overflow (F-19). +static constexpr int MAX_ROTATION_DELTA = 126; + // The port validates throttle ids against its own bound, so a divergence here // would let an id this controller thinks is valid be refused by every backend. static_assert(ThrottleController::NUM_THROTTLES == ThrottleBackend::MAX_THROTTLES, @@ -171,6 +176,9 @@ void ThrottleController::onKnobRotation(int knobId, int delta) { if (knobId < 0 || knobId >= NUM_KNOBS) return; + if (delta > MAX_ROTATION_DELTA) delta = MAX_ROTATION_DELTA; + if (delta < -MAX_ROTATION_DELTA) delta = -MAX_ROTATION_DELTA; + if (!lockState(pdMS_TO_TICKS(50))) { ESP_LOGW(TAG, "Failed to lock state for knob rotation"); return; diff --git a/main/hardware/RotaryEncoderHal.cpp b/main/hardware/RotaryEncoderHal.cpp index 03b855d..c8122d3 100644 --- a/main/hardware/RotaryEncoderHal.cpp +++ b/main/hardware/RotaryEncoderHal.cpp @@ -107,7 +107,14 @@ void RotaryEncoderHal::pollOnce() int32_t deltaRaw = 0; bool deltaOk = readEncoderDelta(m_status[i].address, deltaRaw); - if (deltaOk) { + if (deltaOk && !isPlausibleDelta(deltaRaw)) { + // A corrupted word, not a rotation -- most likely a response to a + // different request after a failed transaction. Dropped as a + // failed read (F-19). + ESP_LOGW(TAG, "Encoder %d: discarding implausible delta %ld (0x%08lX)", i, + static_cast(deltaRaw), + static_cast(static_cast(deltaRaw))); + } else if (deltaOk) { if (deltaRaw != 0 && m_rotationCallback) { ESP_LOGD(TAG, "Encoder %d delta=%ld", i, static_cast(deltaRaw)); m_rotationCallback(i, static_cast(deltaRaw)); diff --git a/main/hardware/RotaryEncoderHal.h b/main/hardware/RotaryEncoderHal.h index bcc3947..a26e5cd 100644 --- a/main/hardware/RotaryEncoderHal.h +++ b/main/hardware/RotaryEncoderHal.h @@ -32,6 +32,22 @@ class RotaryEncoderHal { void setRotationCallback(std::function callback); void setPressCallback(std::function callback); + /** + * @brief Largest delta one poll can plausibly carry. + * + * A full revolution of a 24-detent encoder between two polls -- several + * times faster than a hand spins one. Anything beyond it is a corrupted + * read, not a rotation, and must never reach the controller: there it + * would be clamped to full speed in one direction or the other (F-19). + */ + static constexpr int32_t MAX_PLAUSIBLE_DELTA = 24; + + /** Public and pure so the bound can be tested without an I2C bus. */ + static bool isPlausibleDelta(int32_t delta) + { + return delta >= -MAX_PLAUSIBLE_DELTA && delta <= MAX_PLAUSIBLE_DELTA; + } + private: static void pollingTask(void* arg); void pollOnce(); diff --git a/main/model/Knob.cpp b/main/model/Knob.cpp index b334bd0..14b6be1 100644 --- a/main/model/Knob.cpp +++ b/main/model/Knob.cpp @@ -70,16 +70,16 @@ void Knob::handleRotation(int delta, int rosterSize) return; } - // Update roster index with wrapping - m_rosterIndex += delta; - - // Wrap around - while (m_rosterIndex < 0) { - m_rosterIndex += rosterSize; - } - while (m_rosterIndex >= rosterSize) { - m_rosterIndex -= rosterSize; + // Modular rather than repeated += / -=: a loop runs once per roster length + // of delta, which for a corrupted delta is millions of iterations with the + // controller's state mutex held (F-19). Reducing each term first also keeps + // the sum clear of overflow, and copes with a roster that shrank. + int index = (m_rosterIndex % rosterSize) + (delta % rosterSize); + index %= rosterSize; + if (index < 0) { + index += rosterSize; } + m_rosterIndex = index; ESP_LOGD(TAG, "Knob %d roster index: %d (delta=%d)", m_id, m_rosterIndex, delta); } diff --git a/main/tests/OrchestratorProtocolTests.cpp b/main/tests/OrchestratorProtocolTests.cpp index f54a96b..2c94328 100644 --- a/main/tests/OrchestratorProtocolTests.cpp +++ b/main/tests/OrchestratorProtocolTests.cpp @@ -373,6 +373,93 @@ static void test_orch_backend_release_sends_nothing(void) TEST_ASSERT_NOT_EQUAL(ESP_OK, backend.setSpeed(1, 10)); } +namespace { + +/** The layout already has 4472 running in reverse, headlight on. */ +const char* SNAPSHOT_WITH_4472_RUNNING = + "{\"type\":\"STATE_SNAPSHOT\",\"payload\":{" + "\"systemStatus\":\"online\"," + "\"locos\":{" + "\"4472\":{\"address\":4472,\"speed\":60,\"direction\":\"rev\"," + "\"functions\":{\"0\":true}}}}}"; + +} // namespace + +static void test_orch_backend_acquire_seeds_from_layout_state(void) +{ + OrchestratorClient client; + client.initialize(); + OrchestratorBackend backend(&client); + + std::vector updates; + backend.setThrottleStateCallback( + [&updates](const ThrottleBackend::ThrottleUpdate& u) { updates.push_back(u); }); + + client.testHandleMessage(SNAPSHOT_WITH_4472_RUNNING); + // Not on any throttle yet, so nothing is displayed. + TEST_ASSERT_EQUAL_INT(0, (int)updates.size()); + + // Taking it over must start from what it is doing, not from stopped (F-20): + // otherwise the first click commands speed 4, or reverse, to a loco doing 60. + TEST_ASSERT_EQUAL(ESP_OK, backend.acquireLocomotive(1, 4472, true)); + + TEST_ASSERT_GREATER_THAN_INT(0, (int)updates.size()); + TEST_ASSERT_EQUAL_INT(1, updates[0].throttleId); + TEST_ASSERT_EQUAL_INT(60, updates[0].speed); + TEST_ASSERT_EQUAL_INT(0, updates[0].direction); + + bool sawHeadlight = false; + for (const auto& u : updates) { + if (u.function == 0 && u.functionState) { + sawHeadlight = true; + } + } + TEST_ASSERT_TRUE(sawHeadlight); +} + +static void test_orch_backend_acquire_of_unreported_loco_shows_nothing(void) +{ + OrchestratorClient client; + client.initialize(); + OrchestratorBackend backend(&client); + + std::vector updates; + backend.setThrottleStateCallback( + [&updates](const ThrottleBackend::ThrottleUpdate& u) { updates.push_back(u); }); + + client.testHandleMessage(SNAPSHOT_WITH_4472_RUNNING); + TEST_ASSERT_EQUAL(ESP_OK, backend.acquireLocomotive(0, 12, false)); + + // Nothing known about 12, so nothing is invented for it. + TEST_ASSERT_EQUAL_INT(0, (int)updates.size()); +} + +static void test_orch_snapshot_replaces_cached_state(void) +{ + OrchestratorClient client; + client.initialize(); + + client.testHandleMessage(SNAPSHOT_WITH_4472_RUNNING); + OrchestratorClient::LocoState state; + TEST_ASSERT_TRUE(client.getLastLocoState(4472, state)); + TEST_ASSERT_EQUAL_INT(60, state.speed); + TEST_ASSERT_TRUE(state.direction == OrchestratorClient::Direction::REVERSE); + + // A later LOCO_STATE updates it... + client.testHandleMessage( + "{\"type\":\"LOCO_STATE\",\"payload\":" + "{\"address\":4472,\"speed\":20,\"direction\":\"fwd\"}}"); + TEST_ASSERT_TRUE(client.getLastLocoState(4472, state)); + TEST_ASSERT_EQUAL_INT(20, state.speed); + + // ...and a snapshot that no longer mentions it means the layout knows + // nothing of it: the cached state must not outlive that. + client.testHandleMessage( + "{\"type\":\"STATE_SNAPSHOT\",\"payload\":{" + "\"systemStatus\":\"online\",\"locos\":{}}}"); + TEST_ASSERT_FALSE(client.getLastLocoState(4472, state)); +} + extern "C" void register_orchestrator_tests(void) { RUN_TEST(test_orch_cookie_extracted_from_set_cookie); @@ -396,4 +483,7 @@ extern "C" void register_orchestrator_tests(void) RUN_TEST(test_orch_backend_refuses_commands_without_a_loco); RUN_TEST(test_orch_backend_routes_state_to_matching_throttle); RUN_TEST(test_orch_backend_release_sends_nothing); + RUN_TEST(test_orch_backend_acquire_seeds_from_layout_state); + RUN_TEST(test_orch_backend_acquire_of_unreported_loco_shows_nothing); + RUN_TEST(test_orch_snapshot_replaces_cached_state); } diff --git a/main/tests/ThrottleControllerTests.cpp b/main/tests/ThrottleControllerTests.cpp index 33375a8..41adb77 100644 --- a/main/tests/ThrottleControllerTests.cpp +++ b/main/tests/ThrottleControllerTests.cpp @@ -2,6 +2,9 @@ #include "ThrottleController.h" #include "ThrottleBackend.h" #include "Locomotive.h" +#include "RotaryEncoderHal.h" + +#include namespace { struct UiCallbackState { @@ -542,6 +545,41 @@ static void test_controller_hides_track_power_when_unsupported(void) TEST_ASSERT_EQUAL_INT(0, (int)backend.trackPowerWrites.size()); } +static void test_encoder_implausible_delta_is_refused(void) +{ + // The encoder HAL is what stops a corrupted read becoming a rotation + // (F-19); this is the bound it applies. + TEST_ASSERT_TRUE(RotaryEncoderHal::isPlausibleDelta(0)); + TEST_ASSERT_TRUE(RotaryEncoderHal::isPlausibleDelta(RotaryEncoderHal::MAX_PLAUSIBLE_DELTA)); + TEST_ASSERT_TRUE(RotaryEncoderHal::isPlausibleDelta(-RotaryEncoderHal::MAX_PLAUSIBLE_DELTA)); + TEST_ASSERT_FALSE(RotaryEncoderHal::isPlausibleDelta(RotaryEncoderHal::MAX_PLAUSIBLE_DELTA + 1)); + TEST_ASSERT_FALSE(RotaryEncoderHal::isPlausibleDelta(-RotaryEncoderHal::MAX_PLAUSIBLE_DELTA - 1)); + + // The shape the double-read workaround can produce after a failed read: a + // GPIO-bulk word with the button's pull-up (bit 24) set. + TEST_ASSERT_FALSE(RotaryEncoderHal::isPlausibleDelta(0x01000000)); + TEST_ASSERT_FALSE(RotaryEncoderHal::isPlausibleDelta(INT32_MIN)); +} + +static void test_controller_extreme_delta_is_well_defined(void) +{ + FakeThrottleBackend backend; + ThrottleController controller(&backend); + + setupThrottleWithLoco(controller, 0, 0, "LocoX", 3); + Throttle* throttle = controller.getThrottle(0); + TEST_ASSERT_NOT_NULL(throttle); + throttle->setSpeed(0); + + // Was signed overflow in delta * stepsPerClick (F-19). Now it saturates at + // the end of the range and sends exactly one command. Keeping such a delta + // from arriving at all is the encoder HAL's job, tested above. + controller.onKnobRotation(0, INT_MIN); + + TEST_ASSERT_LESS_OR_EQUAL_INT(126, throttle->getCurrentSpeed()); + TEST_ASSERT_EQUAL_INT(1, (int)(backend.speeds.size() + backend.pairedCommands.size())); +} + extern "C" void register_controller_tests(void) { RUN_TEST(test_controller_assign_knob_to_unallocated); @@ -563,4 +601,6 @@ extern "C" void register_controller_tests(void) RUN_TEST(test_controller_reports_track_power_from_backend); RUN_TEST(test_controller_forwards_track_power_changes); RUN_TEST(test_controller_hides_track_power_when_unsupported); + RUN_TEST(test_encoder_implausible_delta_is_refused); + RUN_TEST(test_controller_extreme_delta_is_well_defined); } diff --git a/main/tests/ThrottleModelTests.cpp b/main/tests/ThrottleModelTests.cpp index 942f44f..2a7eac9 100644 --- a/main/tests/ThrottleModelTests.cpp +++ b/main/tests/ThrottleModelTests.cpp @@ -2,6 +2,8 @@ #include "Throttle.h" #include "Knob.h" +#include + static void test_throttle_assign_release(void) { Throttle throttle(1); @@ -174,6 +176,25 @@ static void test_knob_rotation_ignored_when_no_roster(void) TEST_ASSERT_EQUAL(0, knob.getRosterIndex()); } +static void test_knob_rotation_huge_delta_wraps_directly(void) +{ + // A corrupted encoder read used to drive the wrap loop once per roster + // length of delta -- millions of iterations with the controller's state + // mutex held (F-19). Modular arithmetic answers at once, and in range. + Knob knob(1); + knob.assignToThrottle(0); + + knob.handleRotation(INT_MAX, 7); + TEST_ASSERT_EQUAL(1, knob.getRosterIndex()); // INT_MAX % 7 == 1 + + knob.handleRotation(INT_MIN, 7); + TEST_ASSERT_EQUAL(6, knob.getRosterIndex()); // (1 + INT_MIN % 7) mod 7 + + // A roster that shrank since the last turn still lands in range. + knob.handleRotation(1, 4); + TEST_ASSERT_EQUAL(3, knob.getRosterIndex()); // (6 % 4 + 1) mod 4 +} + void register_throttle_tests(void) { RUN_TEST(test_throttle_assign_release); @@ -188,4 +209,5 @@ void register_throttle_tests(void) RUN_TEST(test_throttle_assign_loco_requires_selecting); RUN_TEST(test_knob_reassign_overwrites_previous); RUN_TEST(test_knob_rotation_ignored_when_no_roster); + RUN_TEST(test_knob_rotation_huge_delta_wraps_directly); } diff --git a/main/ui/JmriConfigScreen.cpp b/main/ui/JmriConfigScreen.cpp index a93ec5a..c5c8c1c 100644 --- a/main/ui/JmriConfigScreen.cpp +++ b/main/ui/JmriConfigScreen.cpp @@ -56,6 +56,7 @@ JmriConfigScreen::JmriConfigScreen(JmriJsonClient& jsonClient, , m_backButton(nullptr) , m_keyboard(nullptr) , m_keyboardLabel(nullptr) + , m_statusTimer(nullptr) , m_connectInProgress(false) , m_jsonClient(jsonClient) , m_wiThrottleClient(wiThrottleClient) @@ -67,9 +68,25 @@ JmriConfigScreen::JmriConfigScreen(JmriJsonClient& jsonClient, JmriConfigScreen::~JmriConfigScreen() { - // Deregister callbacks to prevent dangling this pointer - m_jsonClient.setConnectionStateCallback(nullptr); - m_wiThrottleClient.setConnectionStateCallback(nullptr); + stopStatusTimer(); +} + +void JmriConfigScreen::statusTimerCb(lv_timer_t* timer) +{ + // Runs on the LVGL task, so no lock is needed here. + // LVGL 8.4 has no lv_timer_get_user_data; the field is read directly. + auto* self = static_cast(timer->user_data); + if (self) { + self->updateStatus(); + } +} + +void JmriConfigScreen::stopStatusTimer() +{ + if (m_statusTimer) { + lv_timer_del(m_statusTimer); + m_statusTimer = nullptr; + } } lv_obj_t* JmriConfigScreen::create() @@ -105,20 +122,11 @@ lv_obj_t* JmriConfigScreen::create() createButtonSection(buttonContainer); createKeyboard(); - m_jsonClient.setConnectionStateCallback([this](JmriJsonClient::ConnectionState) { - if (lvgl_port_lock(100)) { - updateStatus(); - lvgl_port_unlock(); - } - }); + // Polled, not registered on either client's connection callback: see + // statusTimerCb. Twice a second is plenty for two status labels. + stopStatusTimer(); + m_statusTimer = lv_timer_create(statusTimerCb, 500, this); - m_wiThrottleClient.setConnectionStateCallback([this](WiThrottleClient::ConnectionState) { - if (lvgl_port_lock(100)) { - updateStatus(); - lvgl_port_unlock(); - } - }); - // Load saved settings loadSettings(); @@ -612,8 +620,8 @@ void JmriConfigScreen::onBackButtonClicked(lv_event_t* e) // Hide keyboard if visible screen->hideKeyboard(); - screen->m_jsonClient.setConnectionStateCallback(nullptr); - screen->m_wiThrottleClient.setConnectionStateCallback(nullptr); + // Stop the poll before the widgets go, or it paints a deleted label. + screen->stopStatusTimer(); // Back to settings, which is where this screen is reached from. show_settings_screen(); diff --git a/main/ui/JmriConfigScreen.h b/main/ui/JmriConfigScreen.h index 47c715a..ea99cbd 100644 --- a/main/ui/JmriConfigScreen.h +++ b/main/ui/JmriConfigScreen.h @@ -76,6 +76,15 @@ class JmriConfigScreen { static void onTextAreaFocused(lv_event_t* e); static void onTextAreaDefocused(lv_event_t* e); static void connectTask(void* arg); + + /** + * Status is polled on an LVGL timer, not pushed through either client's + * connection callback. Those are single slots, and the WiThrottle one + * belongs to the active backend: taking it here, and nulling it on Back, + * cut the knob gating off from the link state (F-21, F-30). + */ + static void statusTimerCb(lv_timer_t* timer); + void stopStatusTimer(); // LVGL objects lv_obj_t* m_screen; @@ -89,6 +98,7 @@ class JmriConfigScreen { lv_obj_t* m_backButton; lv_obj_t* m_keyboard; lv_obj_t* m_keyboardLabel; + lv_timer_t* m_statusTimer; std::atomic m_connectInProgress; // Client references diff --git a/main/ui/MainScreen.cpp b/main/ui/MainScreen.cpp index bdcc7d7..8134eda 100644 --- a/main/ui/MainScreen.cpp +++ b/main/ui/MainScreen.cpp @@ -29,46 +29,46 @@ MainScreen::MainScreen() , m_virtualEncoderPanel(nullptr) #endif , m_throttleController(nullptr) - , m_wiThrottleClient(nullptr) - , m_jmriClient(nullptr) { // Throttles are now managed by ThrottleController } MainScreen::~MainScreen() { + // Deregistering here is not enough to make destroying a MainScreen safe: + // a task that has already read the callback can still be waiting on the + // LVGL lock to call it. That is why AppController never destroys this + // screen (F-21). Kept so that a destruction at teardown is at least tidy. if (m_throttleController) { m_throttleController->setUIUpdateCallback(nullptr, nullptr); } - if (m_wiThrottleClient) { - m_wiThrottleClient->setConnectionStateCallback(nullptr); - } - // Don't delete LVGL objects here - LVGL manages screen lifecycle - // When lv_scr_load() is called with a new screen, LVGL will clean up the old one - // Our ThrottleMeter objects will be destroyed naturally with their parent containers + // lv_scr_load() does NOT free the screen it replaces, so LVGL objects built + // here are only freed by deleting m_screen. Nothing does, deliberately: + // this screen lives as long as the application (F-32). } -lv_obj_t* MainScreen::create(WiThrottleClient* wiThrottleClient, JmriJsonClient* jmriClient, ThrottleController* throttleController) +void MainScreen::show() +{ + if (!m_screen) { + return; + } + lv_scr_load(m_screen); + updateAllThrottles(); +} + +lv_obj_t* MainScreen::create(ThrottleController* throttleController) { - m_wiThrottleClient = wiThrottleClient; - m_jmriClient = jmriClient; m_throttleController = throttleController; // Store reference (not owned) - - // Register UI update callback with the controller + + // The one registration this screen needs. Link up/down reaches it through + // here too: the active backend owns the client's connection-state slot and + // routes it to ThrottleController::updateUI. Taking that slot from the UI, + // as this screen used to, silently cut the backend off from it. if (m_throttleController) { m_throttleController->setUIUpdateCallback(onUIUpdateNeeded, this); } - if (m_wiThrottleClient) { - m_wiThrottleClient->setConnectionStateCallback([this](WiThrottleClient::ConnectionState) { - if (lvgl_port_lock(100)) { - updateAllThrottles(); - lvgl_port_unlock(); - } - }); - } - // Create a new screen or clean the current one m_screen = lv_obj_create(nullptr); diff --git a/main/ui/MainScreen.h b/main/ui/MainScreen.h index b4ce050..e19c810 100644 --- a/main/ui/MainScreen.h +++ b/main/ui/MainScreen.h @@ -14,8 +14,6 @@ #include "components/FunctionPanel.h" #include "../model/Throttle.h" #include "../controller/ThrottleController.h" -#include "../communication/WiThrottleClient.h" -#include "../communication/JmriJsonClient.h" #include #include @@ -39,13 +37,23 @@ class MainScreen { MainScreen& operator=(const MainScreen&) = delete; /** - * @brief Create and show the main screen - * @param wiThrottleClient WiThrottle client for DCC control - * @param jmriClient JMRI JSON client for power control + * @brief Build and show the main screen. Called once per boot. + * + * Takes the controller and nothing else: connection state, knob gating, + * functions and power all come through it, never from a concrete client. + * * @param throttleController Throttle controller (owned by application layer) * @return The LVGL screen object */ - lv_obj_t* create(WiThrottleClient* wiThrottleClient, JmriJsonClient* jmriClient, ThrottleController* throttleController); + lv_obj_t* create(ThrottleController* throttleController); + + /** + * @brief Show the already-built screen again and repaint it. + * + * The screen is kept for the life of the application rather than rebuilt + * on each return to it (F-21, F-32). + */ + void show(); /** * @brief Update throttle displays with current state @@ -85,10 +93,6 @@ class MainScreen { // Throttle controller (not owned - managed at application layer) ThrottleController* m_throttleController; - // Client references (not owned) - WiThrottleClient* m_wiThrottleClient; - JmriJsonClient* m_jmriClient; - // Throttle UI event handlers static void onKnobIndicatorTouched(lv_event_t* e); static void onFunctionsButtonClicked(lv_event_t* e); From 68452a5300a21e41baa62e87c239259892d0ab68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:56:15 +0000 Subject: [PATCH 03/12] fix: reap and re-acquire WiThrottle sessions, arm the heartbeat, join the JMRI ping task (F-22, F-23, F-31) F-22: when JMRI dropped the link the receive task exited but nothing closed its socket, and the reconnect task's connect() overwrote it -- one of lwIP's ten sockets lost per JMRI restart or long WiFi drop. connect() now reaps any previous session first. Teardown clears m_socket under the send mutex before closing and the receive task reads its own copy, so no send or recv can land on a descriptor lwIP has handed to another socket. Worse, the new session held no locos while the UI still showed every throttle live, so JMRI ignored the knob and the stop press alike. The acquisition record now mirrors the UI rather than the TCP session: it survives a disconnect, every connect() re-acquires from it (JMRI's reply re-seeds the display), and only a release removes an entry. F-23: the client never sent "*+", so JMRI's heartbeat monitoring -- its dead-man stop for a crashed or half-open device -- was never on. It now arms it when the server announces a non-zero interval and sends "*" at half that from the receive task, which wakes every second anyway. It also sends HU in place of the malformed "HESP32-S3". F-31: stopHeartbeat() vTaskDelete()d the ping task from outside, possibly mid-send holding the WebSocket client's lock. The task is now woken to exit, signals and suspends, and is deleted only after that signal, under a mutex shared with startHeartbeat(). Stack 2 KB -> 3 KB. Compiled for the app and the test configuration; bench checks are listed in docs/REVIEW_REMEDIATION_PLAN_2.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- docs/REVIEW_REMEDIATION_PLAN_2.md | 30 ++- docs/architecture/THREADING_MODEL.md | 4 +- docs/components/COMMUNICATION_LAYER.md | 36 +++- main/communication/JmriJsonClient.cpp | 112 +++++++--- main/communication/JmriJsonClient.h | 14 +- main/communication/WiThrottleClient.cpp | 276 +++++++++++++++++++----- main/communication/WiThrottleClient.h | 34 ++- main/tests/ProtocolParsingTests.cpp | 35 +++ 8 files changed, 451 insertions(+), 90 deletions(-) diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index 8a8a4d4..4fcf1cb 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -1,7 +1,7 @@ # Review Remediation Plan — Second Pass **Created:** 2026-09-24 -**Status:** In progress — Batch 1 code done, awaiting its bench cycle +**Status:** In progress — Batches 1 and 2 code done, awaiting their bench cycles **Source:** Static review of the whole firmware (`main/`, build config, CI), with the orchestrator-facing behaviour checked against `bazauto/layout-orchestration`. Nothing was flashed during the review: every "bench" criterion below is unverified until someone runs it @@ -23,8 +23,8 @@ freeze while one is moving. | F-19 | [Unbounded encoder delta](#f-19-unbounded-encoder-delta) | HIGH | Small | 1 | Code done | | F-20 | [Orchestrator acquire starts from speed 0](#f-20-orchestrator-acquire-starts-from-speed-0) | HIGH | Medium | 1 | Code done | | F-21 | [Use-after-free on return to the main screen](#f-21-use-after-free-on-return-to-the-main-screen) | HIGH | Medium | 1 | Code done | -| F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Open | -| F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Open | +| F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Code done | +| F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Code done | | F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Open | | F-25 | [Physical knobs not gated; optimistic update outlives a failed send](#f-25-physical-knobs-not-gated-optimistic-update-outlives-a-failed-send) | MEDIUM | Small | 4 | Open | | F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Open | @@ -32,7 +32,7 @@ freeze while one is moving. | F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Open | | F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Open | | F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Slots: code done; sync: open | -| F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Open | +| F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Code done | | F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | | F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Open | | F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Open | @@ -238,6 +238,11 @@ whatever the command station last refreshed. #### Acceptance Criteria +- [x] `connect()` reaps any previous socket and receive task before opening another; teardown + clears `m_socket` under the send mutex before closing, and the receive task reads its own + copy of the descriptor. +- [x] The acquisition record survives a disconnect and is re-acquired on every new session; + only `releaseLocomotive()` removes an entry, even when disconnected. - [ ] Socket count is stable across ten JMRI restarts (lwIP stats or `socket()` fd numbers in the log). - [ ] After a JMRI restart the device re-acquires its locos without operator action. @@ -267,8 +272,12 @@ off. The device also sends `HESP32-S3`, where the protocol expects `HU` replaces the malformed `HESP32-S3`. +- [ ] Unit tests `test_withrottle_heartbeat_announcement_arms_monitoring` and + `test_withrottle_malformed_heartbeat_is_ignored` pass on the board (they compile). - [ ] Bench: pull the ESP's power with a loco running; JMRI e-stops it within the interval. --- @@ -461,6 +470,15 @@ handle can be deleted twice. The task's stack is 2 KB with `ESP_LOG` in its path Cooperative shutdown with an exit signal, as F-02 did for the WiThrottle receive task, and a 3 KB stack pending F-33's measurement. +#### Acceptance Criteria + +- [x] The task is woken to exit by a notification, signals, and suspends itself; + `stopHeartbeat()` deletes it only after that signal, under a mutex that also guards + `startHeartbeat()`. +- [x] Stack raised to 3 KB. +- [ ] Bench: repeated JMRI JSON connect/disconnect (twenty cycles) with no hang and no heap + growth. + --- ### F-32: Main screen LVGL tree leaks on every return diff --git a/docs/architecture/THREADING_MODEL.md b/docs/architecture/THREADING_MODEL.md index 25c0013..bd55836 100644 --- a/docs/architecture/THREADING_MODEL.md +++ b/docs/architecture/THREADING_MODEL.md @@ -11,8 +11,8 @@ The ESP32-S3 is dual-core. LVGL rendering runs on a dedicated task; network I/O | Task Name | Stack | Priority | Purpose | Creates | |-----------|-------|----------|---------|---------| | `LVGL timer` | 6 KB | 2 | LVGL rendering + event handling | `lvgl_port.c` | -| `withrottle_rx` | 4 KB | 5 | WiThrottle TCP receive loop | `WiThrottleClient::connect()` | -| `jmri_heartbeat` | 2 KB | 5 | JSON WebSocket ping every 30 s | `JmriJsonClient::startHeartbeat()` | +| `withrottle_rx` | 4 KB | 5 | WiThrottle TCP receive loop; also sends the `*` heartbeat | `WiThrottleClient::connect()` | +| `jmri_heartbeat` | 3 KB | 5 | JSON WebSocket ping every 30 s; stopped cooperatively, never deleted mid-send | `JmriJsonClient::startHeartbeat()` | | `jmri_autoconn` | 4 KB | 5 | Wait for WiFi → auto-connect JMRI | `JmriConnectionController::startAutoConnectTask()` | | `jmri_reconnect` | 3 KB | 4 | Monitor connections, exponential backoff | `JmriConnectionController::enableAutoReconnect()` | | `rotary_enc` | 3 KB | 4 | I2C encoder polling every 100 ms | `RotaryEncoderHal::startPollingTask()` | diff --git a/docs/components/COMMUNICATION_LAYER.md b/docs/components/COMMUNICATION_LAYER.md index 3525499..a88a88e 100644 --- a/docs/components/COMMUNICATION_LAYER.md +++ b/docs/components/COMMUNICATION_LAYER.md @@ -267,10 +267,32 @@ stateDiagram-v2 | Method | Description | |--------|-------------| | `initialize()` | Prepare client state | -| `connect(host, port=12090)` | TCP connect, send device ID, start receive task | -| `disconnect()` | Close socket, stop tasks | +| `connect(host, port=12090)` | Reap any previous session, TCP connect, send `HU` and `N`, start receive task, re-acquire every loco still on the record | +| `disconnect()` | Stop the receive task and close the socket. Keeps the acquisition record | | `isConnected()` | Check connection state | | `sendHeartbeat()` | Send `*` keepalive | +| `getHeartbeatPeriodMs()` | Heartbeat period this session, or 0 when the server does no monitoring | + +### Sessions and the acquisition record + +The client records every loco it acquires, per throttle. That record mirrors what the UI shows +as allocated, not what the current TCP session holds, so it survives a disconnect of either +kind: an explicit `disconnect()` or JMRI dropping the link. Every `connect()` re-acquires what +is on it, and JMRI answers with each loco's speed and direction, which re-seeds the display. +Only `releaseLocomotive()` removes an entry, whether or not the release reaches the server. + +This is F-22's fix. Before it, a JMRI restart left the UI showing every throttle live while +the new session held nothing, so JMRI ignored the knob and the stop press alike; and the +reconnect overwrote the old socket without closing it, leaking one of lwIP's ten sockets +each time. + +### Heartbeat (dead-man switch) + +After `N`, JMRI announces its heartbeat interval as `*`. A non-zero interval makes +the client send `*+`, which switches monitoring on, and then `*` at half the interval from the +receive task. If the heartbeats stop — the device crashed, rebooted, lost power or went +half-open on WiFi — JMRI e-stops this device's locos. Never sending `*+` had left that switch +off (F-23). ### API — Throttle Control @@ -298,7 +320,11 @@ stateDiagram-v2 ### Threading -- `withrottle_rx` task (4 KB, priority 5): blocking `recv()` loop, parses messages, fires callbacks. +- `withrottle_rx` task (4 KB, priority 5): `recv()` loop with a one-second timeout, so it + notices a requested shutdown promptly and sends heartbeats when due. Parses messages and + fires callbacks. It reads from its own copy of the descriptor; teardown clears `m_socket` + under the send mutex before closing, so neither a send nor the receive loop can land on a + descriptor lwIP has already handed to another socket. - `m_stateMutex`: protects internal `m_throttleStates` map. - All callbacks fire from the receive task — callers must handle their own locking. @@ -314,7 +340,7 @@ stateDiagram-v2 | `ML` | `M0LL41<;>]\[Headlight]\[...` | Function labels | | `M+` | `M0+L41<;>` | Loco added confirmation | | `M-` | `M0-L41<;>` | Loco removed confirmation | -| `*` | `*10` | Heartbeat interval | +| `*` | `*10` | Heartbeat interval; non-zero arms monitoring with `*+` | --- @@ -341,7 +367,7 @@ Same as WiThrottleClient: `DISCONNECTED → CONNECTING → CONNECTED / FAILED`. | `getPower()` | Request current power state | | `requestPowerList()` | Request all power managers | | `startHeartbeat()` | Spawn heartbeat task (ping every 30 s) | -| `stopHeartbeat()` | Stop heartbeat task | +| `stopHeartbeat()` | Stop heartbeat task and wait for it to exit (join, then delete). Up to about a second when the task is mid-send (F-31) | | `setConfiguredPowerName(name)` | Set power manager name (e.g. `"DCC++"`) | ### Callbacks diff --git a/main/communication/JmriJsonClient.cpp b/main/communication/JmriJsonClient.cpp index ccda31d..e7c9d3f 100644 --- a/main/communication/JmriJsonClient.cpp +++ b/main/communication/JmriJsonClient.cpp @@ -39,12 +39,26 @@ namespace { static const char* TAG = "JmriJsonClient"; +static constexpr uint32_t HEARTBEAT_INTERVAL_MS = 30000; + +// ESP_LOG, a std::string and a WebSocket send: 2 KB was tight for that, and a +// stack overflow shows up as corruption rather than as an error. Pending the +// measured figures F-33 asks for. +static constexpr uint32_t HEARTBEAT_STACK_BYTES = 3072; + +// The heartbeat's send times out after 1 s, so a task that has not exited by +// then plus a margin is not going to. +static constexpr uint32_t HEARTBEAT_EXIT_WAIT_MS = 3000; + JmriJsonClient::JmriJsonClient() : m_state(ConnectionState::DISCONNECTED) , m_client(nullptr) , m_serverHost("") , m_serverPort(12080) , m_heartbeatTask(nullptr) + , m_heartbeatMutex(nullptr) + , m_heartbeatExitSemaphore(nullptr) + , m_heartbeatRunning(false) , m_configuredPowerName("DCC++") , m_powerMutex(nullptr) , m_powerCallback(nullptr) @@ -54,6 +68,11 @@ JmriJsonClient::JmriJsonClient() if (!m_powerMutex) { ESP_LOGE(TAG, "Failed to create power mutex"); } + m_heartbeatMutex = xSemaphoreCreateMutex(); + m_heartbeatExitSemaphore = xSemaphoreCreateBinary(); + if (!m_heartbeatMutex || !m_heartbeatExitSemaphore) { + ESP_LOGE(TAG, "Failed to create heartbeat synchronisation"); + } } JmriJsonClient::~JmriJsonClient() @@ -64,6 +83,14 @@ JmriJsonClient::~JmriJsonClient() vSemaphoreDelete(m_powerMutex); m_powerMutex = nullptr; } + if (m_heartbeatMutex) { + vSemaphoreDelete(m_heartbeatMutex); + m_heartbeatMutex = nullptr; + } + if (m_heartbeatExitSemaphore) { + vSemaphoreDelete(m_heartbeatExitSemaphore); + m_heartbeatExitSemaphore = nullptr; + } } esp_err_t JmriJsonClient::initialize() @@ -397,36 +424,63 @@ esp_err_t JmriJsonClient::sendJsonCommand(const std::string& type, const std::st void JmriJsonClient::startHeartbeat() { - // Don't start if already running - if (m_heartbeatTask != nullptr) { + if (!m_heartbeatMutex || xSemaphoreTake(m_heartbeatMutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + ESP_LOGW(TAG, "Could not lock to start the heartbeat"); return; } - - // Create heartbeat task - BaseType_t result = xTaskCreate( - heartbeatTask, - "jmri_heartbeat", - 2048, - this, - 5, - &m_heartbeatTask - ); - - if (result == pdPASS) { - ESP_LOGI(TAG, "Heartbeat task started"); - } else { - ESP_LOGE(TAG, "Failed to create heartbeat task"); - m_heartbeatTask = nullptr; + + // Don't start if already running + if (m_heartbeatTask == nullptr) { + // Only a stop that timed out can have left a stale signal here. + if (m_heartbeatExitSemaphore) { + xSemaphoreTake(m_heartbeatExitSemaphore, 0); + } + m_heartbeatRunning = true; + BaseType_t result = xTaskCreate(heartbeatTask, "jmri_heartbeat", HEARTBEAT_STACK_BYTES, + this, 5, &m_heartbeatTask); + if (result == pdPASS) { + ESP_LOGI(TAG, "Heartbeat task started"); + } else { + ESP_LOGE(TAG, "Failed to create heartbeat task"); + m_heartbeatTask = nullptr; + m_heartbeatRunning = false; + } } + + xSemaphoreGive(m_heartbeatMutex); } void JmriJsonClient::stopHeartbeat() { - if (m_heartbeatTask != nullptr) { + if (!m_heartbeatMutex || xSemaphoreTake(m_heartbeatMutex, pdMS_TO_TICKS(HEARTBEAT_EXIT_WAIT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "Could not lock to stop the heartbeat"); + return; + } + + TaskHandle_t task = m_heartbeatTask; + m_heartbeatTask = nullptr; + + if (task != nullptr) { ESP_LOGI(TAG, "Stopping heartbeat task"); - vTaskDelete(m_heartbeatTask); - m_heartbeatTask = nullptr; + m_heartbeatRunning = false; + // Safe even if the task is already on its way out: it suspends itself + // rather than exiting, so its handle stays valid until deleted below. + xTaskNotifyGive(task); + + // Join, then delete. Deleting it from outside without waiting -- as + // this did -- could kill it mid-send while it held the WebSocket + // client's lock, leaving that lock held for good (F-31). + if (m_heartbeatExitSemaphore && + xSemaphoreTake(m_heartbeatExitSemaphore, pdMS_TO_TICKS(HEARTBEAT_EXIT_WAIT_MS)) == pdTRUE) { + vTaskDelete(task); + } else { + // Leave it rather than delete it mid-send; it exits and suspends + // on its own once the send returns. + ESP_LOGE(TAG, "Heartbeat task did not exit in time; leaving it to finish"); + } } + + xSemaphoreGive(m_heartbeatMutex); } void JmriJsonClient::heartbeatTask(void* pvParameters) @@ -435,9 +489,12 @@ void JmriJsonClient::heartbeatTask(void* pvParameters) ESP_LOGI(TAG, "Heartbeat task running"); - while (true) { - // Wait 30 seconds - vTaskDelay(pdMS_TO_TICKS(30000)); + while (client->m_heartbeatRunning) { + // Sleeps for the interval, or until stopHeartbeat() wakes it to exit. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(HEARTBEAT_INTERVAL_MS)); + if (!client->m_heartbeatRunning) { + break; + } // Send heartbeat if connected if (client->isConnected()) { @@ -445,4 +502,11 @@ void JmriJsonClient::heartbeatTask(void* pvParameters) client->sendHeartbeat(); } } + + // Signal, then wait to be deleted. stopHeartbeat() deletes this task only + // once it has seen the signal, so never while it is inside a send. + if (client->m_heartbeatExitSemaphore) { + xSemaphoreGive(client->m_heartbeatExitSemaphore); + } + vTaskSuspend(nullptr); } diff --git a/main/communication/JmriJsonClient.h b/main/communication/JmriJsonClient.h index c3c1085..9644871 100644 --- a/main/communication/JmriJsonClient.h +++ b/main/communication/JmriJsonClient.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -7,6 +8,7 @@ #include "esp_websocket_client.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "freertos/task.h" #include "sdkconfig.h" /** @@ -142,7 +144,11 @@ class JmriJsonClient { void startHeartbeat(); /** - * @brief Stop heartbeat task + * @brief Stop heartbeat task, waiting for it to exit. + * + * Blocks for up to about a second when the task is mid-send. Called from + * the WebSocket task too, where that is the send timing out on the lock + * the WebSocket task itself holds while dispatching the event. */ void stopHeartbeat(); @@ -167,8 +173,12 @@ class JmriJsonClient { std::string m_serverHost; uint16_t m_serverPort; - // Heartbeat task + // Heartbeat task. Started and stopped from both the WebSocket task and + // connect()/disconnect(), so the handle is guarded by m_heartbeatMutex. TaskHandle_t m_heartbeatTask; + SemaphoreHandle_t m_heartbeatMutex; + SemaphoreHandle_t m_heartbeatExitSemaphore; + std::atomic m_heartbeatRunning; // Configured power manager name to control and monitor std::string m_configuredPowerName; diff --git a/main/communication/WiThrottleClient.cpp b/main/communication/WiThrottleClient.cpp index 363170e..d719818 100644 --- a/main/communication/WiThrottleClient.cpp +++ b/main/communication/WiThrottleClient.cpp @@ -1,14 +1,36 @@ #include "WiThrottleClient.h" #include "esp_log.h" +#include "esp_mac.h" +#include "esp_timer.h" #include "lwip/sockets.h" #include "lwip/netdb.h" +#include +#include #include static const char* TAG = "WiThrottleClient"; // WiThrottle protocol commands static const char* CMD_HEARTBEAT = "*"; +static const char* CMD_HEARTBEAT_ON = "*+"; static const char* CMD_TRACK_POWER = "PPA"; // Power command +static const char* DEVICE_NAME = "ESP32-Layout-Controller"; + +// The receive task wakes at least this often, which is what lets it notice a +// requested shutdown and keep heartbeats on time without a task of their own. +static constexpr int RX_TIMEOUT_S = 1; + +// Comfortably longer than one receive timeout plus a callback's worst-case wait +// for the LVGL lock, so a teardown that gives up means the task is wedged. +static constexpr int RX_EXIT_WAIT_MS = 3000; + +// A send takes microseconds; one holding the send mutex this long is stuck on a +// peer that stopped reading, and teardown proceeds without waiting for it. +static constexpr int SEND_LOCK_WAIT_MS = 500; + +// Longest heartbeat interval accepted from the server. Anything larger is taken +// as malformed rather than as permission to go quiet for hours. +static constexpr long MAX_HEARTBEAT_INTERVAL_S = 3600; WiThrottleClient::WiThrottleClient() : m_state(ConnectionState::DISCONNECTED) @@ -28,6 +50,8 @@ WiThrottleClient::WiThrottleClient() , m_taskExitSemaphore(nullptr) , m_receiveTaskHandle(nullptr) , m_running(false) + , m_heartbeatPeriodMs(0) + , m_lastHeartbeatUs(0) { m_stateMutex = xSemaphoreCreateMutex(); if (!m_stateMutex) { @@ -72,6 +96,11 @@ esp_err_t WiThrottleClient::connect(const std::string& host, uint16_t port) ESP_LOGW(TAG, "Already connected or connecting"); return ESP_ERR_INVALID_STATE; } + + // When the server ends a session, its receive task exits but the socket is + // left open. Reap that here, before a new socket replaces it: overwriting it + // leaked one of lwIP's ten sockets per JMRI restart or WiFi drop (F-22). + teardownSession(); m_serverHost = host; m_serverPort = port; @@ -87,9 +116,9 @@ esp_err_t WiThrottleClient::connect(const std::string& host, uint16_t port) return ESP_FAIL; } - // Set socket timeout + // Receive timeout: see RX_TIMEOUT_S. struct timeval timeout; - timeout.tv_sec = 5; + timeout.tv_sec = RX_TIMEOUT_S; timeout.tv_usec = 0; setsockopt(m_socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); @@ -122,52 +151,132 @@ esp_err_t WiThrottleClient::connect(const std::string& host, uint16_t port) ESP_LOGI(TAG, "Connected to WiThrottle server"); setState(ConnectionState::CONNECTED); - // Send device name (identifies us to JMRI) + // Identify: HU first, a stable per-device id, so JMRI can recognise this + // device across reconnects; then the name shown in JMRI. The server answers + // N with its heartbeat interval, handled in handleHeartbeatAnnouncement. ESP_LOGI(TAG, "Sending device identification..."); - sendCommand(std::string("N") + "ESP32-Layout-Controller"); - - // Send hardware identifier - sendCommand(std::string("H") + "ESP32-S3"); + sendCommand("HU" + deviceId()); + sendCommand(std::string("N") + DEVICE_NAME); ESP_LOGI(TAG, "Waiting for server messages (version, roster, etc.)..."); - + + // Heartbeats are off until this session's server announces an interval. + m_heartbeatPeriodMs = 0; + m_lastHeartbeatUs = 0; + + // Only a task that timed out on its way out can have left a stale signal + // here; clear it so the next teardown waits for the task it is stopping. + if (m_taskExitSemaphore) { + xSemaphoreTake(m_taskExitSemaphore, 0); + } + // Start receive task m_running = true; - xTaskCreate(receiveTask, "withrottle_rx", 4096, this, 5, &m_receiveTaskHandle); + if (xTaskCreate(receiveTask, "withrottle_rx", 4096, this, 5, &m_receiveTaskHandle) != pdPASS) { + ESP_LOGE(TAG, "Failed to create receive task"); + m_receiveTaskHandle = nullptr; + teardownSession(); + setState(ConnectionState::FAILED); + return ESP_FAIL; + } + + // A new session holds nothing, even when the UI still shows locos allocated + // from the last one. Without this, after a JMRI restart every throttle looks + // live while JMRI ignores its commands -- the stop press included (F-22). + reacquireLocomotives(); return ESP_OK; } +void WiThrottleClient::teardownSession() +{ + // Take the socket away from senders first, under the send mutex, so no new + // send() can start on it. Bounded: a send() stuck on a peer that stopped + // reading holds the mutex, and the shutdown() below is what frees it. + const bool haveSendLock = + m_sendMutex && xSemaphoreTake(m_sendMutex, pdMS_TO_TICKS(SEND_LOCK_WAIT_MS)) == pdTRUE; + const int sock = m_socket; + m_socket = -1; + if (haveSendLock) { + xSemaphoreGive(m_sendMutex); + } + + if (sock < 0) { + return; + } + + // Unblocks recv() -- the task reads from its own copy of the descriptor -- + // and any send() still in flight. + m_running = false; + shutdown(sock, SHUT_RDWR); + + bool exited = true; + if (m_receiveTaskHandle && m_taskExitSemaphore) { + exited = (xSemaphoreTake(m_taskExitSemaphore, pdMS_TO_TICKS(RX_EXIT_WAIT_MS)) == pdTRUE); + m_receiveTaskHandle = nullptr; + } + + // If a send() was in flight, let it leave before the descriptor goes. + if (!haveSendLock && m_sendMutex && + xSemaphoreTake(m_sendMutex, pdMS_TO_TICKS(SEND_LOCK_WAIT_MS)) == pdTRUE) { + xSemaphoreGive(m_sendMutex); + } + + if (exited) { + close(sock); + } else { + // Closing now would free the descriptor for reuse while the task may + // still read from it -- and lwIP hands out the lowest free index, so + // the next socket opened would be the one it read. One leaked socket + // is the lesser harm, and only a wedged task gets here. + ESP_LOGE(TAG, "Receive task did not exit within %d ms; leaving socket %d open", + RX_EXIT_WAIT_MS, sock); + } +} + +void WiThrottleClient::reacquireLocomotives() +{ + std::vector> held; + if (lockState(pdMS_TO_TICKS(100))) { + for (const auto& entry : m_throttleStates) { + if (entry.second.acquired) { + held.emplace_back(entry.first, entry.second); + } + } + unlockState(); + } else { + ESP_LOGW(TAG, "Failed to lock state to re-acquire locos"); + return; + } + + for (const auto& entry : held) { + const char throttleId = entry.first; + const ThrottleState& state = entry.second; + const std::string address = std::string(1, state.addressType) + std::to_string(state.address); + ESP_LOGI(TAG, "Re-acquiring loco %s on throttle %c for the new session", + address.c_str(), throttleId); + // JMRI answers with the loco's current speed and direction, which + // re-seeds the display for the new session. + sendCommand("M" + std::string(1, throttleId) + "+" + address + "<;>" + address); + } +} + void WiThrottleClient::disconnect() { if (m_socket >= 0) { ESP_LOGI(TAG, "Disconnecting from WiThrottle server"); - m_running = false; - - // Unblock recv() by shutting down the socket read side - shutdown(m_socket, SHUT_RDWR); - - // Wait for receive task to exit cooperatively (up to 2 seconds) - if (m_receiveTaskHandle && m_taskExitSemaphore) { - if (xSemaphoreTake(m_taskExitSemaphore, pdMS_TO_TICKS(2000)) == pdTRUE) { - ESP_LOGI(TAG, "Receive task exited cleanly"); - } else { - ESP_LOGW(TAG, "Receive task did not exit within timeout"); - } - m_receiveTaskHandle = nullptr; - } - - close(m_socket); - m_socket = -1; } + teardownSession(); setState(ConnectionState::DISCONNECTED); m_mainTrackPower = PowerState::UNKNOWN; m_progTrackPower = PowerState::UNKNOWN; - if (lockState(pdMS_TO_TICKS(50))) { - m_throttleStates.clear(); - unlockState(); - } + + // The acquisition record is kept on purpose. It mirrors what the UI shows + // as allocated -- ThrottleController does not release on a disconnect -- + // and the next connect() re-acquires from it. Clearing it here left the + // UI showing live throttles whose every command this client then refused. + // Only releaseLocomotive() removes an entry. } esp_err_t WiThrottleClient::setTrackPower(const std::string& track, bool on) @@ -236,8 +345,18 @@ esp_err_t WiThrottleClient::acquireLocomotive(char throttleId, int address, bool esp_err_t WiThrottleClient::releaseLocomotive(char throttleId) { + // Forget the loco whether or not the release reaches the server. The + // record follows what the UI shows, and the UI has released it: keeping it + // would re-acquire, on the next session, a loco the operator let go (F-22). + if (lockState(pdMS_TO_TICKS(50))) { + m_throttleStates.erase(throttleId); + unlockState(); + } else { + ESP_LOGW(TAG, "Failed to lock state for release tracking"); + } + if (!isConnected()) { - ESP_LOGW(TAG, "Not connected to server"); + ESP_LOGW(TAG, "Not connected to server; release recorded locally only"); return ESP_ERR_INVALID_STATE; } @@ -248,21 +367,7 @@ esp_err_t WiThrottleClient::releaseLocomotive(char throttleId) ESP_LOGI(TAG, "Releasing throttle %c", throttleId); - esp_err_t result = sendCommand(command); - - // Clear the throttle state - if (result == ESP_OK) { - if (lockState(pdMS_TO_TICKS(50))) { - m_throttleStates[throttleId].acquired = false; - m_throttleStates[throttleId].address = 0; - m_throttleStates[throttleId].addressType = 'S'; - unlockState(); - } else { - ESP_LOGW(TAG, "Failed to lock state for release tracking"); - } - } - - return result; + return sendCommand(command); } esp_err_t WiThrottleClient::setSpeed(char throttleId, int speed) @@ -515,9 +620,17 @@ void WiThrottleClient::receiveTask(void* arg) WiThrottleClient* client = static_cast(arg); char buffer[512]; std::string messageBuffer; + + // This task's own copy. teardownSession() clears m_socket before closing, + // and must not be able to swap a different descriptor under a live recv(). + const int sock = client->m_socket; while (client->m_running) { - int len = recv(client->m_socket, buffer, sizeof(buffer) - 1, 0); + // Every pass -- after data or after the one-second receive timeout -- + // is a chance to send a heartbeat that has come due. + client->serviceHeartbeat(); + + int len = recv(sock, buffer, sizeof(buffer) - 1, 0); if (len < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { @@ -617,8 +730,8 @@ void WiThrottleClient::processMessage(const std::string& message) ESP_LOGD(TAG, "Heartbeat acknowledged"); break; - case '*': // Heartbeat request from server - sendHeartbeat(); + case '*': // Heartbeat interval, announced by the server after N + handleHeartbeatAnnouncement(message); break; case 'M': // Multi-throttle (throttle state changes) @@ -677,6 +790,58 @@ void WiThrottleClient::handlePowerMessage(const std::string& message) } } +void WiThrottleClient::handleHeartbeatAnnouncement(const std::string& message) +{ + // "*": how long JMRI will wait between messages before e-stopping + // this device's locos -- but only once monitoring is switched on with "*+". + // Never sending "*+" left the dead-man switch off, so a crashed, rebooted + // or half-open device left its locos running (F-23). + const char* digits = message.c_str() + 1; + char* end = nullptr; + const long seconds = std::strtol(digits, &end, 10); + if (end == digits || *end != '\0' || seconds < 0 || seconds > MAX_HEARTBEAT_INTERVAL_S) { + ESP_LOGW(TAG, "Ignoring malformed heartbeat announcement: %s", message.c_str()); + return; + } + + if (seconds == 0) { + ESP_LOGW(TAG, "Server has heartbeat monitoring disabled; no dead-man stop for this device"); + m_heartbeatPeriodMs = 0; + return; + } + + // Half the interval, so one late or lost heartbeat is not an e-stop. + m_heartbeatPeriodMs = static_cast(seconds) * 500; + m_lastHeartbeatUs = esp_timer_get_time(); + sendCommand(CMD_HEARTBEAT_ON); + ESP_LOGI(TAG, "Heartbeat monitoring on: server interval %lds, sending every %lu ms", + seconds, static_cast(m_heartbeatPeriodMs)); +} + +void WiThrottleClient::serviceHeartbeat() +{ + if (m_heartbeatPeriodMs == 0) { + return; + } + const int64_t now = esp_timer_get_time(); + if (now - m_lastHeartbeatUs >= static_cast(m_heartbeatPeriodMs) * 1000) { + m_lastHeartbeatUs = now; + sendHeartbeat(); + } +} + +std::string WiThrottleClient::deviceId() +{ + uint8_t mac[6] = {}; + if (esp_read_mac(mac, ESP_MAC_WIFI_STA) != ESP_OK) { + return "ESP32LC"; + } + char id[13]; + snprintf(id, sizeof(id), "%02X%02X%02X%02X%02X%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return std::string(id); +} + void WiThrottleClient::setState(ConnectionState newState) { if (m_state != newState) { @@ -701,9 +866,20 @@ esp_err_t WiThrottleClient::sendCommand(const std::string& command) ESP_LOGW(TAG, "Failed to acquire send mutex"); return ESP_ERR_TIMEOUT; } + + // Re-read under the mutex: teardownSession() clears it under the same + // mutex before closing, so a descriptor read here is still open. + const int sock = m_socket; + if (sock < 0) { + if (m_sendMutex) { + xSemaphoreGive(m_sendMutex); + } + ESP_LOGW(TAG, "Cannot send command - session ended"); + return ESP_ERR_INVALID_STATE; + } std::string fullCommand = command + "\n"; - int len = send(m_socket, fullCommand.c_str(), fullCommand.length(), 0); + int len = send(sock, fullCommand.c_str(), fullCommand.length(), 0); if (m_sendMutex) { xSemaphoreGive(m_sendMutex); diff --git a/main/communication/WiThrottleClient.h b/main/communication/WiThrottleClient.h index d75b750..49e094e 100644 --- a/main/communication/WiThrottleClient.h +++ b/main/communication/WiThrottleClient.h @@ -269,10 +269,19 @@ class WiThrottleClient { /** * @brief Send heartbeat (keep-alive) - * Should be called periodically when connected + * Sent by the receive task at half the server's announced interval. */ void sendHeartbeat(); + /** + * @brief How often this session sends a heartbeat, or 0 when off. + * + * Half the interval the server announced with "*". Non-zero means + * heartbeat monitoring was switched on with "*+", so JMRI e-stops this + * device's locos if the heartbeats stop (F-23). + */ + uint32_t getHeartbeatPeriodMs() const { return m_heartbeatPeriodMs; } + private: const std::vector& getRoster() const { return m_roster; } @@ -283,8 +292,26 @@ class WiThrottleClient { void handlePowerMessage(const std::string& message); void handleRosterMessage(const std::string& message); void handleThrottleMessage(const std::string& message); + void handleHeartbeatAnnouncement(const std::string& message); void setState(ConnectionState newState); esp_err_t sendCommand(const std::string& command); + + /** + * @brief Stop the receive task and close the socket, if there is one. + * + * Deliberately leaves the acquisition record alone: that mirrors what the + * UI shows as allocated, and the next session re-acquires from it. + */ + void teardownSession(); + + /** Re-acquires every loco the record holds, on a fresh session (F-22). */ + void reacquireLocomotives(); + + /** Called by the receive task on every wake; sends "*" when one is due. */ + void serviceHeartbeat(); + + /** Stable per-device id for the "HU" line: the WiFi station MAC. */ + static std::string deviceId(); static void receiveTask(void* arg); @@ -322,4 +349,9 @@ class WiThrottleClient { TaskHandle_t m_receiveTaskHandle; bool m_running; + + // Touched only by the receive task once it is running, and reset in + // connect() before it starts. + uint32_t m_heartbeatPeriodMs; + int64_t m_lastHeartbeatUs; }; diff --git a/main/tests/ProtocolParsingTests.cpp b/main/tests/ProtocolParsingTests.cpp index 33c9f41..ed847d1 100644 --- a/main/tests/ProtocolParsingTests.cpp +++ b/main/tests/ProtocolParsingTests.cpp @@ -77,10 +77,45 @@ static void test_withrottle_send_returns_error_when_disconnected(void) TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, client.releaseLocomotive('0')); } +static void test_withrottle_heartbeat_announcement_arms_monitoring(void) +{ + WiThrottleClient client; + client.initialize(); + + // Off until the server announces an interval. + TEST_ASSERT_EQUAL_UINT32(0, client.getHeartbeatPeriodMs()); + + // "*10" arms the dead-man switch (F-23): heartbeats at half the interval, + // so one late or lost heartbeat is not an e-stop. + client.testProcessMessage("*10"); + TEST_ASSERT_EQUAL_UINT32(5000, client.getHeartbeatPeriodMs()); + + // "*0" is the server saying it does no monitoring. + client.testProcessMessage("*0"); + TEST_ASSERT_EQUAL_UINT32(0, client.getHeartbeatPeriodMs()); +} + +static void test_withrottle_malformed_heartbeat_is_ignored(void) +{ + WiThrottleClient client; + client.initialize(); + client.testProcessMessage("*10"); + + // None of these may switch heartbeats off or stretch them out. + client.testProcessMessage("*"); + client.testProcessMessage("*ten"); + client.testProcessMessage("*10x"); + client.testProcessMessage("*999999"); + client.testProcessMessage("*-5"); + TEST_ASSERT_EQUAL_UINT32(5000, client.getHeartbeatPeriodMs()); +} + extern "C" void register_protocol_tests(void) { RUN_TEST(test_withrottle_roster_parsing); RUN_TEST(test_withrottle_throttle_update_parsing); RUN_TEST(test_jmri_power_parsing); RUN_TEST(test_withrottle_send_returns_error_when_disconnected); + RUN_TEST(test_withrottle_heartbeat_announcement_arms_monitoring); + RUN_TEST(test_withrottle_malformed_heartbeat_is_ignored); } From b35b84c4ae22e27358c5564e2848eb538c4526ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:09:11 +0000 Subject: [PATCH 04/12] fix: reconnect WiFi, the orchestrator and JMRI without operator help (F-24, F-34, F-40) F-24: after an ordinary outage the device stayed offline, every throttle dead, until someone went into settings. - WiFi gave up for good after five immediate retries -- shorter than a router reboot. It now reports FAILED but keeps retrying on a one-shot esp_timer (5 s doubling to 60 s) until Disconnect or Forget, which the WiFi screen now offers in that state. The timer only calls esp_wifi_connect(), which does not block the esp_timer task. - The orchestrator login was tried once at boot. orch_connect now supervises the link for the life of the app: waits for WiFi however long, retries logins with backoff (under the server's 5/min limit), logs in afresh when the socket's own cookie-reusing reconnect has not recovered in 30 s, and retries a failed roster read. The config screen's Connect wakes it instead of racing it with a task of its own. - JMRI auto-connect waited 30 s for WiFi, then never reconnected. F-34: JMRI connects happened on three tasks and disconnect on the LVGL task (blocking it for seconds), the JSON client was connected from the WiThrottle receive task, a manual disconnect was undone within five seconds, and a manual connect never updated the address reconnects used. JmriConnectionController now owns one jmri_conn task that does every connect, reconnect and disconnect, saves the settings, and learns the JSON port from PW via the receive task without connecting anything there. The screen only asks. Under the orchestrator, Connect just saves. F-40: WiFi credentials are saved once they produce an IP, not before, so a typo no longer replaces a good password; and a refused esp_wifi_set_config reports FAILED instead of rebooting the device. Compiled for the app and the test configuration; bench checks are listed in docs/REVIEW_REMEDIATION_PLAN_2.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- docs/REVIEW_REMEDIATION_PLAN_2.md | 49 ++- docs/architecture/NVS_STORAGE.md | 10 +- docs/architecture/THREADING_MODEL.md | 18 +- docs/components/COMMUNICATION_LAYER.md | 23 +- docs/components/CONTROLLER_LAYER.md | 13 +- docs/components/UI_LAYER.md | 12 +- docs/flows/CONNECTION_FLOW.md | 76 +++- docs/flows/STARTUP_FLOW.md | 8 +- main/communication/WiFiManager.cpp | 147 ++++++- main/communication/WiFiManager.h | 33 +- main/controller/AppController.cpp | 129 ++++-- main/controller/AppController.h | 20 +- main/controller/JmriConnectionController.cpp | 440 +++++++++++++------ main/controller/JmriConnectionController.h | 93 +++- main/ui/JmriConfigScreen.cpp | 163 ++----- main/ui/JmriConfigScreen.h | 24 +- main/ui/OrchestratorConfigScreen.cpp | 35 +- main/ui/OrchestratorConfigScreen.h | 3 - main/ui/WiFiConfigScreen.cpp | 6 +- 19 files changed, 862 insertions(+), 440 deletions(-) diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index 4fcf1cb..dd0b0d3 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -1,7 +1,7 @@ # Review Remediation Plan — Second Pass **Created:** 2026-09-24 -**Status:** In progress — Batches 1 and 2 code done, awaiting their bench cycles +**Status:** In progress — Batches 1–3 (every HIGH) code done, awaiting their bench cycles **Source:** Static review of the whole firmware (`main/`, build config, CI), with the orchestrator-facing behaviour checked against `bazauto/layout-orchestration`. Nothing was flashed during the review: every "bench" criterion below is unverified until someone runs it @@ -25,9 +25,9 @@ freeze while one is moving. | F-21 | [Use-after-free on return to the main screen](#f-21-use-after-free-on-return-to-the-main-screen) | HIGH | Medium | 1 | Code done | | F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Code done | | F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Code done | -| F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Open | +| F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Code done | | F-25 | [Physical knobs not gated; optimistic update outlives a failed send](#f-25-physical-knobs-not-gated-optimistic-update-outlives-a-failed-send) | MEDIUM | Small | 4 | Open | -| F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Open | +| F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Concurrent connect removed (batch 3); handle lock: open | | F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Open | | F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Open | | F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Open | @@ -35,13 +35,13 @@ freeze while one is moving. | F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Code done | | F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | | F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Open | -| F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Open | +| F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Code done | | F-35 | [Orchestrator roster refused above ~35 locos](#f-35-orchestrator-roster-refused-above-35-locos) | MEDIUM | Small | 5 | Open | | F-36 | [Operator credential and session cookie in cleartext](#f-36-operator-credential-and-session-cookie-in-cleartext) | MEDIUM | Small (decision) | 5 | Open | | F-37 | [WiThrottle updates unvalidated](#f-37-withrottle-updates-unvalidated) | LOW | Small | 5 | Open | | F-38 | [Hot-path logging and repaint cost](#f-38-hot-path-logging-and-repaint-cost) | LOW | Small | 5 | Open | -| F-39 | [NVS writes on the LVGL task](#f-39-nvs-writes-on-the-lvgl-task) | LOW | Small | 5 | Open | -| F-40 | [WiFi credential save and reboot-on-error](#f-40-wifi-credential-save-and-reboot-on-error) | LOW | Small | 3 | Open | +| F-39 | [NVS writes on the LVGL task](#f-39-nvs-writes-on-the-lvgl-task) | LOW | Small | 5 | JMRI settings: done (batch 3); rest: open | +| F-40 | [WiFi credential save and reboot-on-error](#f-40-wifi-credential-save-and-reboot-on-error) | LOW | Small | 3 | Code done | | F-41 | [Seesaw read timing](#f-41-seesaw-read-timing) | LOW | Small | 5 | Open | | F-42 | [Protocol hygiene odds and ends](#f-42-protocol-hygiene-odds-and-ends) | LOW | Small | 5 | Open | | F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Open | @@ -310,6 +310,13 @@ In each case the operator has no control until they fix it by hand in settings. #### Acceptance Criteria +- [x] `WiFiManager` keeps retrying after the immediate retries (one-shot `esp_timer`, 5 s + doubling to 60 s) until Disconnect or Forget, both of which the WiFi screen now offers + while it is in `FAILED`. +- [x] `orch_connect` supervises for the life of the app: waits for WiFi indefinitely, retries + a failed login with backoff, logs in afresh after 30 s without a socket, retries a failed + roster read. The config screen's Connect wakes it instead of connecting itself. +- [x] `jmri_conn` waits for WiFi indefinitely (see F-34). - [ ] Bench: power-cycle the router with the device running; it reconnects without a touch. - [ ] Bench: boot the device before the orchestrator host; it connects when the host is up. - [ ] Bench: same for JMRI. @@ -360,6 +367,11 @@ also check-then-act, so two connect tasks can both pass it. Guard the handle with its own mutex held across send and across stop/destroy (sends are bounded at 1 s), and make the connecting check atomic. +**Progress:** Batch 3 made the `orch_connect` supervisor the only caller of `connect()` — the +config screen now wakes it rather than connecting on a task of its own — so two connects can +no longer race. A re-login still destroys the handle under a concurrent sender; that half +remains. + **Files:** `OrchestratorClient.h/.cpp` #### Acceptance Criteria @@ -541,6 +553,19 @@ the results in `THREADING_MODEL.md`, and resize with a margin. Route both the screen's connect and disconnect through `JmriConnectionController`, which owns the saved settings, the auto-reconnect flag and the one task that connects. +#### Acceptance Criteria + +- [x] One task, `jmri_conn`, carries out every JMRI connect and disconnect, including the + screen's; `jmri_autoconn`, `jmri_reconnect` and the screen's `jmri_connect` are gone. +- [x] Disconnect and Connect return at once on the LVGL task; saving happens on the worker. +- [x] Disconnect turns reconnecting off until the next Connect. +- [x] The `PW` callback is registered once, before any connect, and only records the port; + the worker saves it as `json_port` and moves the JSON client. +- [x] A manual connect updates the settings the worker reconnects to. +- [x] Under the orchestrator, Connect saves only (one-shot `jmri_save` task). +- [ ] Bench: Connect, Disconnect (stays down past ten seconds), Connect again; change server + address and confirm reconnects go to the new one. + --- ### F-35: Orchestrator roster refused above ~35 locos @@ -606,6 +631,10 @@ Record the decision in CLAUDE.md's transport section. If TLS is wanted, size it `JmriConfigScreen::saveSettings`, the orchestrator screen's save and `saveSpeedSteps` still write NVS from event handlers, against F-05's rule. +**Progress:** the JMRI settings are now saved by `JmriConnectionController` on its own task +(batch 3, F-34). The orchestrator screen's save, `saveSpeedSteps`, and the WiFi screen's +Forget remain. + --- ### F-40: WiFi credential save and reboot-on-error @@ -615,6 +644,14 @@ write NVS from event handlers, against F-05's rule. `WiFiManager::connect` saves credentials before it knows they work, overwriting a good password with a typo; and `ESP_ERROR_CHECK(esp_wifi_set_config)` turns a recoverable error into a reboot. +#### Acceptance Criteria + +- [x] Credentials entered on the screen are saved on `IP_EVENT_STA_GOT_IP`, read back from the + driver; the stored-credential path saves nothing. +- [x] A failed `esp_wifi_set_config` reports `FAILED` and returns the error. +- [ ] Bench: enter a wrong password over a working network, reboot; the device rejoins the + working network. + --- ### F-41: Seesaw read timing diff --git a/docs/architecture/NVS_STORAGE.md b/docs/architecture/NVS_STORAGE.md index 5fadfe4..63aa385 100644 --- a/docs/architecture/NVS_STORAGE.md +++ b/docs/architecture/NVS_STORAGE.md @@ -8,10 +8,10 @@ All persistent configuration is stored in ESP-IDF's NVS (Non-Volatile Storage). |-----------|-----|------|---------|------------|---------| | `wifi` | `ssid` | string | — | `WiFiManager` | `WiFiManager` | | `wifi` | `password` | string | — | `WiFiManager` | `WiFiManager` | -| `jmri` | `server_ip` | string | — | `JmriConfigScreen` | `JmriConnectionController` | -| `jmri` | `wt_port` | string | `"12090"` | `JmriConfigScreen` | `JmriConnectionController` | +| `jmri` | `server_ip` | string | — | `JmriConnectionController` | `JmriConnectionController`, `JmriConfigScreen` | +| `jmri` | `wt_port` | string | `"12090"` | `JmriConnectionController` | `JmriConnectionController`, `JmriConfigScreen` | | `jmri` | `json_port` | string | `"12080"` | `JmriConnectionController` | `JmriConnectionController` | -| `jmri` | `power_mgr` | string | `"DCC++"` | `JmriConfigScreen` | `JmriConnectionController` | +| `jmri` | `power_mgr` | string | `"DCC++"` | `JmriConnectionController` | `JmriConnectionController`, `JmriConfigScreen` | | `jmri` | `speed_steps` | i32 | `4` | `JmriConfigScreen` | `ThrottleController` | | `orch` | `transport` | u8 | `0` (WiThrottle) | `JmriConfigScreen` | `AppController` | | `orch` | `host` | string | — | `OrchestratorConfigScreen` | `AppController` | @@ -40,6 +40,6 @@ Two refusals are deliberate, both in `TransportSettings::load()`: same accepted and documented risk as the WiFi password (F-18), not an oversight. It is masked on screen and never logged. -- **WiFi credentials** are saved on successful connection and loaded on boot for auto-connect. -- **JMRI settings** are saved when the user presses "Connect" on the JMRI config screen. The `json_port` is typically discovered automatically from the WiThrottle `PW` message rather than configured manually. +- **WiFi credentials** are saved on successful connection — only once they have produced an IP address, so a mistyped password never replaces a good one (F-40) — and loaded on boot for auto-connect. +- **JMRI settings** are saved when the user presses "Connect" on the JMRI config screen, by `JmriConnectionController` on its own task, not on the LVGL task. The `json_port` is discovered from the WiThrottle `PW` message and saved by the same task when it changes, rather than configured manually. - **Speed steps per click** (1–20) controls how many speed steps each encoder detent applies. Higher values = coarser control. Configurable from the JMRI settings screen. diff --git a/docs/architecture/THREADING_MODEL.md b/docs/architecture/THREADING_MODEL.md index bd55836..0849a5b 100644 --- a/docs/architecture/THREADING_MODEL.md +++ b/docs/architecture/THREADING_MODEL.md @@ -13,12 +13,11 @@ The ESP32-S3 is dual-core. LVGL rendering runs on a dedicated task; network I/O | `LVGL timer` | 6 KB | 2 | LVGL rendering + event handling | `lvgl_port.c` | | `withrottle_rx` | 4 KB | 5 | WiThrottle TCP receive loop; also sends the `*` heartbeat | `WiThrottleClient::connect()` | | `jmri_heartbeat` | 3 KB | 5 | JSON WebSocket ping every 30 s; stopped cooperatively, never deleted mid-send | `JmriJsonClient::startHeartbeat()` | -| `jmri_autoconn` | 4 KB | 5 | Wait for WiFi → auto-connect JMRI | `JmriConnectionController::startAutoConnectTask()` | -| `jmri_reconnect` | 3 KB | 4 | Monitor connections, exponential backoff | `JmriConnectionController::enableAutoReconnect()` | +| `jmri_conn` | 6 KB | 4 | Every JMRI connect and disconnect: waits for WiFi, keeps both links up with backoff, carries out the config screen's requests | `JmriConnectionController::start()` | +| `jmri_save` | 3 KB | 4 | One-shot: saves JMRI settings when the orchestrator is the transport | `JmriConnectionController::requestConnect()` | | `rotary_enc` | 3 KB | 4 | I2C encoder polling every 100 ms | `RotaryEncoderHal::startPollingTask()` | | `throttle_poll` | 4 KB | 3 | Refresh speed/direction every 10 s, for allocated throttles only | `ThrottleController::initialize()` | -| `orch_connect` | 6 KB | 5 | Wait for WiFi → orchestrator login → fetch roster | `AppController::startOrchestratorConnectTask()` | -| `orch_ui_conn` | 6 KB | 5 | Same, triggered by the config screen's Connect button | `OrchestratorConfigScreen::onConnectClicked()` | +| `orch_connect` | 6 KB | 5 | Supervises the orchestrator link for the life of the app: login once WiFi is up, retries with backoff, fresh login when the socket stays down; woken by the config screen's Connect | `AppController::startOrchestratorConnectTask()` | | `websocket_task` | 6 KB | — | Orchestrator control-plane receive loop (owned by `esp_websocket_client`) | `OrchestratorClient::connect()` | | `track_power` | 4 KB | 5 | One-shot track-power write | `ThrottleController::requestTrackPower()` | @@ -32,10 +31,12 @@ task is created at all and its 4 KB stack is never allocated. no orchestrator task does; under the orchestrator, `orch_connect` starts and JMRI auto-connect is never begun. Nothing sits retrying a server the operator has not chosen. -`orch_connect`, `orch_ui_conn` and `track_power` all exist for the same reason: the +`orch_connect` and `track_power` both exist for the same reason: the orchestrator's login, roster read and power command are **blocking HTTP round trips**, and none of that may happen on the LVGL task (F-05). `track_power` in particular is spawned -straight from a button handler. All three are one-shot and delete themselves when done. +straight from a button handler and is one-shot; `orch_connect` runs for the life of the +application and is the only task that logs in (F-24). `jmri_conn` is the JMRI counterpart: +the only task that connects or disconnects either JMRI client (F-34). --- @@ -120,8 +121,7 @@ flowchart TB subgraph core0["Core 0 (or any)"] WT["withrottle_rx\n(TCP receive)"] JH["jmri_heartbeat\n(WS ping)"] - JA["jmri_autoconn\n(startup)"] - JR["jmri_reconnect\n(monitor)"] + JR["jmri_conn\n(connect + reconnect)"] RE["rotary_enc\n(I2C poll)"] end @@ -138,7 +138,7 @@ flowchart TB RE -->|callback| TC TC -->|uiUpdate| LP WT -->|direct UI| LP - JA -->|connect| TC + JR -->|connect| TC LV --> LP ``` diff --git a/docs/components/COMMUNICATION_LAYER.md b/docs/components/COMMUNICATION_LAYER.md index a88a88e..c0cc342 100644 --- a/docs/components/COMMUNICATION_LAYER.md +++ b/docs/components/COMMUNICATION_LAYER.md @@ -216,9 +216,12 @@ stateDiagram-v2 [*] --> DISCONNECTED DISCONNECTED --> CONNECTING : connect() CONNECTING --> CONNECTED : IP obtained - CONNECTING --> FAILED : Max retries (5) + CONNECTED --> CONNECTING : link lost (immediate retries) + CONNECTING --> FAILED : 5 immediate retries spent + FAILED --> CONNECTED : background retry succeeds + FAILED --> CONNECTING : connect() CONNECTED --> DISCONNECTED : disconnect() - FAILED --> CONNECTING : connect() retry + FAILED --> DISCONNECTED : disconnect() / forgetNetwork() ``` ### API @@ -227,8 +230,8 @@ stateDiagram-v2 |--------|-------------| | `initialize()` | Init NVS, WiFi driver, event handlers | | `connect()` | Connect using stored NVS credentials | -| `connect(ssid, password)` | Connect with explicit credentials, save to NVS on success | -| `disconnect()` | Disconnect WiFi STA | +| `connect(ssid, password)` | Connect with explicit credentials; saved to NVS only once they produce an IP (F-40) | +| `disconnect()` | Disconnect WiFi STA and stop retrying | | `forgetNetwork()` | Erase NVS credentials | | `startScan()` | Trigger async AP scan | | `getScanResults()` | Return vector of discovered APs | @@ -237,6 +240,18 @@ stateDiagram-v2 | `getIpAddress()` | Current IP as string | | `setStateCallback(fn)` | `fn(State, string ip)` | +### Reconnection + +After five immediate retries the state reports `FAILED`, but a one-shot `esp_timer` keeps +retrying — 5 s, doubling, capped at a minute — until the operator disconnects or forgets the +network (F-24). Giving up for good had meant a router reboot left the device offline, and +every throttle dead, until someone went into settings. The timer callback does nothing but +call `esp_wifi_connect()`, which returns at once, so it never blocks the shared `esp_timer` +task (F-09). + +A failed `esp_wifi_set_config` — refused while the station is mid-connect — is reported as +`FAILED` rather than stopping the device through `ESP_ERROR_CHECK` (F-40). + ### NVS Namespace: `wifi`, Keys: `ssid`, `password` diff --git a/docs/components/CONTROLLER_LAYER.md b/docs/components/CONTROLLER_LAYER.md index 338da69..f83eed3 100644 --- a/docs/components/CONTROLLER_LAYER.md +++ b/docs/components/CONTROLLER_LAYER.md @@ -25,7 +25,7 @@ decides which network stack comes up at all: | Selected | Started | Not started | |----------|---------|-------------| | WiThrottle (default) | `WiThrottleBackend`, JMRI auto-connect | No orchestrator client is even constructed | -| Orchestrator | `OrchestratorClient` + `OrchestratorBackend`, `orch_connect` task | JMRI auto-connect never begins | +| Orchestrator | `OrchestratorClient` + `OrchestratorBackend`, `orch_connect` supervisor task | JMRI auto-connect never begins | The backend is chosen once and never swapped on a live `ThrottleController` — doing so would strand locos mid-command — so a transport change takes effect on restart. @@ -197,13 +197,14 @@ JmriConnectionController(JmriJsonClient* json, WiThrottleClient* wt, WiFiControl | Task | Stack | Purpose | |------|-------|---------| -| `jmri_autoconn` | 4 KB | Wait for WiFi (up to 30 s) → load NVS → connect both clients | -| `jmri_reconnect` | 3 KB | Monitor every 5 s, exponential backoff (5 s → 60 s cap) | +| `jmri_conn` | 6 KB | Every JMRI connect and disconnect: waits for WiFi indefinitely, connects both clients, reconnects with backoff (5 s → 60 s cap), carries out the config screen's requests | +| `jmri_save` | 3 KB | One-shot: saves the JMRI screen's settings when the orchestrator is the transport and nothing may connect | ### Key Methods | Method | Description | |--------|-------------| -| `loadSettingsAndAutoConnect()` | Read NVS, connect WiThrottle + JSON | -| `startAutoConnectTask()` | Spawn background auto-connect task | -| `enableAutoReconnect(bool)` | Start/stop reconnect monitoring task | +| `start()` | Read NVS and start `jmri_conn`. Idempotent. WiThrottle transport only | +| `requestConnect(ip, wtPort, powerMgr)` | Save these settings and (re)connect, on the worker. Returns at once | +| `requestDisconnect()` | Disconnect both and stop reconnecting until the next connect. Returns at once | +| `isBusy()` | True while a request is being carried out, for the screen's buttons | diff --git a/docs/components/UI_LAYER.md b/docs/components/UI_LAYER.md index c04c196..0e93a0b 100644 --- a/docs/components/UI_LAYER.md +++ b/docs/components/UI_LAYER.md @@ -124,7 +124,10 @@ screen titled "JMRI Server Configuration" had no business owning any of them. **JMRI connections panel:** WiThrottle and JMRI JSON status. Device-wide status (software, hardware, WiFi, encoders) is on the settings screen, not duplicated here. -**Connect flow:** Connects WiThrottle first; when the server sends back the `PW` (web port) message, auto-connects the JSON client using the discovered port. +**Connect flow:** Connect and Disconnect are requests to `JmriConnectionController`, carried +out on its `jmri_conn` task: saving, connecting and the wait for the receive task to exit all +happen there, never on the LVGL task (F-34). Disconnect sticks until the next Connect. The +JSON client's port comes from WiThrottle's `PW` message. Status is polled on an LVGL timer. **Navigation:** Back button → `show_settings_screen()` @@ -167,9 +170,10 @@ transports are peers, and this one needs four fields including a credential. The password field is masked on screen. It is still plaintext in NVS — the accepted F-18 risk. -**Connect flow:** Save & Connect writes NVS, then does the login and roster fetch **on its -own task** (`orch_ui_conn`). The login is a blocking HTTP round trip; running it on the LVGL -task would freeze every throttle at once (F-05). +**Connect flow:** Save & Connect writes NVS, then wakes the `orch_connect` supervisor, which +logs in with the saved settings and fetches the roster. The login is a blocking HTTP round +trip; running it on the LVGL task would freeze every throttle at once (F-05). The screen +used to run its own connect task, which raced the supervisor for the same client. **Navigation:** Back button → `show_settings_screen()` diff --git a/docs/flows/CONNECTION_FLOW.md b/docs/flows/CONNECTION_FLOW.md index cc2ed1b..5c9be6b 100644 --- a/docs/flows/CONNECTION_FLOW.md +++ b/docs/flows/CONNECTION_FLOW.md @@ -35,7 +35,7 @@ sequenceDiagram AC->>AC: TransportSettings::load() Note over AC: Orchestrator selected,
so JMRI auto-connect is skipped AC->>OT: startOrchestratorConnectTask() - OT->>OT: wait for WiFi (up to 30 s) + OT->>OT: wait for WiFi (however long it takes) OT->>OC: connect(host, port, user, pass) OC->>ORCH: POST /api/auth/login @@ -53,7 +53,7 @@ sequenceDiagram OC->>ORCH: GET /api/layouts OC->>ORCH: GET /api/layouts/{id}/locos ORCH-->>OC: roster - Note over OT: task deletes itself + Note over OT: task stays, supervising the link loop while connected ORCH-->>OC: LOCO_STATE / SYSTEM_STATUS / HEARTBEAT @@ -61,6 +61,20 @@ sequenceDiagram end ``` +### Orchestrator reconnection + +`orch_connect` runs for the life of the application and is the only thing that calls +`OrchestratorClient::connect()` (F-24): + +- It waits for WiFi however long that takes, then logs in. +- A failed login is retried with backoff — 5 s, doubling, capped at a minute, which keeps + under the orchestrator's five-logins-a-minute limit. +- A dropped socket first gets 30 s of `esp_websocket_client`'s own reconnect, which reuses the + session cookie. If it is still down after that, the supervisor logs in afresh. +- A roster read that fails after a good login is retried every 30 s. +- **Connect** on the orchestrator config screen saves the settings and wakes the supervisor, + which logs in at once with them. + --- ## WiThrottle Connection Sequence @@ -96,9 +110,8 @@ sequenceDiagram Note over JCC,JMRI_JSON: Phase 2: JMRI (background task) - JCC->>JCC: jmri_autoconn task: poll WiFi (30s max) - JCC->>JCC: loadSettingsAndAutoConnect() - JCC->>JCC: Read NVS (server_ip, wt_port, power_mgr) + JCC->>JCC: start(): read NVS (server_ip, wt_port, json_port, power_mgr) + JCC->>JCC: jmri_conn task: wait for WiFi (however long it takes) JCC->>WT: connect(serverIp, 12090) WT->>JMRI_WT: TCP connect @@ -110,8 +123,9 @@ sequenceDiagram WT-->>JCC: PowerStateCallback(state) JMRI_WT-->>WT: PW12080 (web port) WT-->>JCC: WebPortCallback(12080) + Note over JCC: recorded only; the jmri_conn task
moves the JSON client if the port differs - JCC->>JC: connect(serverIp, 12080) + JCC->>JC: connect(serverIp, json_port) JC->>JMRI_JSON: WebSocket /json/ JMRI_JSON-->>JC: {"type":"hello",...} JC->>JMRI_JSON: Subscribe to power updates @@ -119,36 +133,52 @@ sequenceDiagram Note over JCC: Phase 3: Auto-reconnect - JCC->>JCC: enableAutoReconnect(true) - Note over JCC: jmri_reconnect task:\nmonitor every 5s,\nexponential backoff (5s→60s) + Note over JCC: the same jmri_conn task keeps both links up:\nchecks every second, reconnects with backoff (5s→60s) ``` --- ## Auto-Reconnect Behaviour +Every JMRI connect and disconnect — at boot, after an outage, and from the config screen — runs +on the one `jmri_conn` task (F-34). The screen only asks. + ```mermaid flowchart TD - A["jmri_reconnect task\n(runs every 5s)"] --> B{"WiFi connected?"} - B -->|No| C["Reset backoff\nWait 5s"] - C --> A - B -->|Yes| D{"WiThrottle connected?"} - D -->|Yes| E{"JSON connected?"} - D -->|No| F["Attempt WiThrottle connect"] - F --> G{"Success?"} - G -->|Yes| A - G -->|No| H["Exponential backoff\n(5s → 10s → 20s → 40s → 60s cap)"] - H --> A - E -->|Yes| I["All connected ✓\nReset backoff"] - I --> A - E -->|No| J["Attempt JSON connect"] - J --> A + A["jmri_conn task\n(every 1 s, or at once on a request)"] --> R{"Request?"} + R -->|Disconnect| S["Disconnect both\nstop reconnecting"] + S --> A + R -->|Connect| T["Save settings\ndisconnect both\nattempt now"] + T --> B + R -->|None| B{"Server saved,\nreconnect on,\nWiFi up?"} + B -->|No| A + B -->|Yes| D{"Both links up?"} + D -->|Yes| E["Reset backoff"] + E --> A + D -->|No| F{"Backoff elapsed?"} + F -->|No| A + F -->|Yes| G["Connect whichever is down\n(JSON only if not already connecting)\nbackoff 5s → 10s → … → 60s cap"] + G --> A ``` +- **Disconnect sticks.** It turns reconnecting off until the next Connect or a reboot. The old + reconnect task undid it within five seconds. +- **WiFi is waited for indefinitely.** The old auto-connect gave up after 30 s and never + started reconnecting (F-24). +- **The JSON port** comes from WiThrottle's `PW` line. The receive task only records it; the + worker saves it as `json_port` and moves the JSON client if it changed. Connecting the JSON + client from the receive task raced the reconnect task (F-34). +- **Under the orchestrator** the task is never started, and Connect on the JMRI screen only + saves the settings, on a one-shot `jmri_save` task. + ## WiFi Config Screen If WiFi credentials are not stored (first boot) or the user navigates to settings, the `WiFiConfigScreen` provides: - Network scanning - SSID/password entry with on-screen keyboard - Connect/disconnect/forget actions -- Credentials saved to NVS on successful connection +- Credentials saved to NVS on successful connection — only then, so a mistyped password does + not replace a good one (F-40) +- After five immediate retries the connection shows as failed but keeps retrying in the + background (5 s, doubling, capped at a minute) until the operator presses Disconnect or + Forget, which both stay available while it does (F-24) diff --git a/docs/flows/STARTUP_FLOW.md b/docs/flows/STARTUP_FLOW.md index 1064dda..53fc7fb 100644 --- a/docs/flows/STARTUP_FLOW.md +++ b/docs/flows/STARTUP_FLOW.md @@ -30,8 +30,8 @@ sequenceDiagram AC->>JC: initialize() AC->>JCC: new(JmriJsonClient, WiThrottleClient, WiFiController) - AC->>JCC: startAutoConnectTask() - Note over JCC: FreeRTOS task: wait WiFi → load NVS → connect both clients + AC->>JCC: start() + Note over JCC: jmri_conn task: wait for WiFi → connect both clients → keep them up AC->>TC: new(WiThrottleClient) AC->>TC: initialize() @@ -49,7 +49,7 @@ sequenceDiagram main->>main: lvgl_port_lock(-1) main->>AC: show_main_screen() AC->>MS: new MainScreen() - AC->>MS: create(WT*, JC*, TC*) + AC->>MS: create(TC*) Note over MS: Build LVGL widgets, register UI update callback main->>main: lvgl_port_unlock() ``` @@ -58,7 +58,7 @@ sequenceDiagram 1. **Hardware first** — LCD, touch, and I2C bus are initialised before any application code runs. 2. **WiFi auto-connect** — attempts immediately using stored NVS credentials. Non-blocking. -3. **JMRI auto-connect** — runs in a background task that waits up to 30 s for WiFi before attempting. +3. **JMRI auto-connect** — runs on the `jmri_conn` task, which waits for WiFi however long it takes and then keeps both links up. Under the orchestrator, the `orch_connect` supervisor does the same for its link instead. 4. **Encoder polling** — starts regardless of whether physical encoders are detected. Missing encoders are logged but don't block startup. 5. **UI last** — the main screen is created after all services are initialised, ensuring it can safely reference all controllers. 6. **Test mode** — when `CONFIG_THROTTLE_TESTS` is set in Kconfig, `app_main()` calls `run_throttle_tests()` instead of the above sequence. diff --git a/main/communication/WiFiManager.cpp b/main/communication/WiFiManager.cpp index 66f55bd..52c0716 100644 --- a/main/communication/WiFiManager.cpp +++ b/main/communication/WiFiManager.cpp @@ -9,12 +9,21 @@ WiFiManager::WiFiManager() : m_state(State::DISCONNECTED) , m_stateCallback(nullptr) , m_retryCount(0) + , m_slowRetryCount(0) , m_initialized(false) + , m_wantConnected(false) + , m_saveOnSuccess(false) + , m_retryTimer(nullptr) { } WiFiManager::~WiFiManager() { + if (m_retryTimer) { + esp_timer_stop(m_retryTimer); + esp_timer_delete(m_retryTimer); + m_retryTimer = nullptr; + } if (m_initialized) { esp_event_handler_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, &WiFiManager::eventHandler); esp_event_handler_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, &WiFiManager::eventHandler); @@ -57,6 +66,19 @@ esp_err_t WiFiManager::initialize() // Start WiFi (required for scanning even without connection) ESP_ERROR_CHECK(esp_wifi_start()); + const esp_timer_create_args_t retryTimerArgs = { + .callback = &WiFiManager::retryTimerCallback, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "wifi_retry", + .skip_unhandled_events = true, + }; + if (esp_timer_create(&retryTimerArgs, &m_retryTimer) != ESP_OK) { + // Not fatal: the immediate retries still work, only the slow ones do not. + ESP_LOGE(TAG, "Failed to create WiFi retry timer; no reconnection after the first retries"); + m_retryTimer = nullptr; + } + m_initialized = true; ESP_LOGI(TAG, "WiFi Manager initialized"); @@ -73,36 +95,55 @@ esp_err_t WiFiManager::connect() return ESP_ERR_NOT_FOUND; } - return connect(ssid, password); + // Already saved; nothing to write when they work. + return connectTo(ssid, password, false); } esp_err_t WiFiManager::connect(const std::string& ssid, const std::string& password) +{ + return connectTo(ssid, password, true); +} + +esp_err_t WiFiManager::connectTo(const std::string& ssid, const std::string& password, + bool saveOnSuccess) { if (!m_initialized) { ESP_LOGE(TAG, "WiFi Manager not initialized"); return ESP_ERR_INVALID_STATE; } - // Save credentials for auto-reconnect - saveCredentials(ssid, password); + // A slow retry of the previous network must not land on top of this one. + cancelSlowRetry(); wifi_config_t wifi_config = {}; strncpy((char*)wifi_config.sta.ssid, ssid.c_str(), sizeof(wifi_config.sta.ssid) - 1); strncpy((char*)wifi_config.sta.password, password.c_str(), sizeof(wifi_config.sta.password) - 1); wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + // Refused while the station is mid-connect -- for instance a slow retry in + // flight. That is a reason to report failure and let the operator press + // again, not to reboot the device with trains running (F-40). + esp_err_t ret = esp_wifi_set_config(WIFI_IF_STA, &wifi_config); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_set_config failed: %s", esp_err_to_name(ret)); + setState(State::FAILED); + return ret; + } // WiFi is already started in initialize(), just connect - setState(State::CONNECTING); + m_saveOnSuccess = saveOnSuccess; + m_wantConnected = true; m_retryCount = 0; + m_slowRetryCount = 0; + setState(State::CONNECTING); ESP_LOGI(TAG, "Connecting to SSID: %s", ssid.c_str()); - esp_err_t ret = esp_wifi_connect(); + ret = esp_wifi_connect(); if (ret != ESP_OK) { ESP_LOGE(TAG, "esp_wifi_connect failed: %d", ret); setState(State::FAILED); + scheduleSlowRetry(); } return ret; @@ -110,6 +151,11 @@ esp_err_t WiFiManager::connect(const std::string& ssid, const std::string& passw void WiFiManager::disconnect() { + // The operator's choice: stop retrying, and do not retry on the + // disconnect event this is about to cause. + m_wantConnected = false; + cancelSlowRetry(); + if (m_state != State::DISCONNECTED) { esp_wifi_disconnect(); setState(State::DISCONNECTED); @@ -117,10 +163,51 @@ void WiFiManager::disconnect() } } +void WiFiManager::scheduleSlowRetry() +{ + if (!m_retryTimer || !m_wantConnected) { + return; + } + + const int shift = m_slowRetryCount < 4 ? m_slowRetryCount : 4; + uint32_t delayMs = SLOW_RETRY_BASE_MS << shift; + if (delayMs > SLOW_RETRY_MAX_MS) { + delayMs = SLOW_RETRY_MAX_MS; + } + m_slowRetryCount++; + + esp_timer_stop(m_retryTimer); // Not running is fine. + if (esp_timer_start_once(m_retryTimer, static_cast(delayMs) * 1000) == ESP_OK) { + ESP_LOGI(TAG, "WiFi retry %d in %lu s", m_slowRetryCount, + static_cast(delayMs / 1000)); + } +} + +void WiFiManager::cancelSlowRetry() +{ + if (m_retryTimer) { + esp_timer_stop(m_retryTimer); + } +} + +void WiFiManager::retryTimerCallback(void* arg) +{ + auto* manager = static_cast(arg); + // Deliberately nothing but esp_wifi_connect(), which returns at once: this + // runs on the shared esp_timer task (F-09). The outcome arrives as an + // event, which schedules the next retry or reports the connection. + // The state check covers a retry that fired just as a connection landed: + // connecting again on top of a live one would only drop it. + if (manager && manager->m_wantConnected && manager->m_state != State::CONNECTED) { + esp_wifi_connect(); + } +} + void WiFiManager::forgetNetwork() { // Disconnect first disconnect(); + m_saveOnSuccess = false; // Clear stored credentials nvs_handle_t handle; @@ -252,22 +339,52 @@ void WiFiManager::handleWiFiEvent(esp_event_base_t event_base, int32_t event_id, if (event_id == WIFI_EVENT_STA_START) { ESP_LOGI(TAG, "WiFi started"); } else if (event_id == WIFI_EVENT_STA_DISCONNECTED) { - if (m_state == State::CONNECTING || m_state == State::CONNECTED) { - if (m_retryCount < MAX_RETRY_ATTEMPTS) { - esp_wifi_connect(); - m_retryCount++; - ESP_LOGI(TAG, "Retry connecting to WiFi (%d/%d)", m_retryCount, MAX_RETRY_ATTEMPTS); - setState(State::CONNECTING); - } else { - ESP_LOGE(TAG, "Failed to connect to WiFi after %d attempts", MAX_RETRY_ATTEMPTS); - setState(State::FAILED); + // Retry for as long as the operator wants this network. Giving up + // for good after five quick attempts meant a router reboot left the + // device offline -- and every throttle dead -- until someone went + // into settings (F-24). + if (!m_wantConnected) { + return; + } + if (m_retryCount < MAX_RETRY_ATTEMPTS) { + esp_wifi_connect(); + m_retryCount++; + ESP_LOGI(TAG, "Retry connecting to WiFi (%d/%d)", m_retryCount.load(), MAX_RETRY_ATTEMPTS); + setState(State::CONNECTING); + } else { + // Reported as failed, so the settings screen offers Connect, + // but still retried in the background. + if (m_state != State::FAILED) { + ESP_LOGE(TAG, "Failed to connect to WiFi after %d attempts; retrying with backoff", + MAX_RETRY_ATTEMPTS); } + setState(State::FAILED); + scheduleSlowRetry(); } } } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t* event = (ip_event_got_ip_t*)event_data; ESP_LOGI(TAG, "Got IP address: " IPSTR, IP2STR(&event->ip_info.ip)); m_retryCount = 0; + m_slowRetryCount = 0; + cancelSlowRetry(); + + // Only now are the credentials known to work, so only now do they + // replace the saved ones. Read back from the driver, which holds what + // just connected, rather than from anything the UI task might change. + if (m_saveOnSuccess.exchange(false)) { + wifi_config_t cfg = {}; + if (esp_wifi_get_config(WIFI_IF_STA, &cfg) == ESP_OK) { + const std::string ssid(reinterpret_cast(cfg.sta.ssid), + strnlen(reinterpret_cast(cfg.sta.ssid), + sizeof(cfg.sta.ssid))); + const std::string password(reinterpret_cast(cfg.sta.password), + strnlen(reinterpret_cast(cfg.sta.password), + sizeof(cfg.sta.password))); + saveCredentials(ssid, password); + } + } + setState(State::CONNECTED); } } diff --git a/main/communication/WiFiManager.h b/main/communication/WiFiManager.h index 57c3691..dfb4229 100644 --- a/main/communication/WiFiManager.h +++ b/main/communication/WiFiManager.h @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include "esp_wifi.h" #include "esp_event.h" +#include "esp_timer.h" /** * @brief Manages WiFi connection with configuration support. @@ -11,7 +13,8 @@ * Features: * - Connect to WiFi with stored credentials * - Configuration via UI - * - Automatic reconnection + * - Automatic reconnection: a few immediate retries, then a capped backoff + * that keeps going until the operator disconnects or forgets the network * - Connection status callbacks * - NVS storage for credentials */ @@ -54,7 +57,11 @@ class WiFiManager { esp_err_t connect(); /** - * @brief Connect with specific credentials (and save to NVS) + * @brief Connect with specific credentials. + * + * Saved to NVS once they have produced an IP address, not before: saving + * up front let a mistyped password replace a good one (F-40). + * * @param ssid WiFi SSID * @param password WiFi password * @return ESP_OK if connection started successfully @@ -131,11 +138,31 @@ class WiFiManager { static constexpr const char* NVS_PASSWORD_KEY = "password"; static constexpr int MAX_RETRY_ATTEMPTS = 5; + // After the immediate retries: 5 s, doubling, capped at a minute (F-24). + static constexpr uint32_t SLOW_RETRY_BASE_MS = 5000; + static constexpr uint32_t SLOW_RETRY_MAX_MS = 60000; + State m_state; StateCallback m_stateCallback; - int m_retryCount; + std::atomic m_retryCount; + int m_slowRetryCount; bool m_initialized; + /** True from connect() until disconnect() or forgetNetwork(). */ + std::atomic m_wantConnected; + + /** Set by connect(ssid, password); cleared once those credentials are saved. */ + std::atomic m_saveOnSuccess; + + /** One-shot timer for the slow retries. Its callback only calls + * esp_wifi_connect(), which does not block the esp_timer task (F-09). */ + esp_timer_handle_t m_retryTimer; + + esp_err_t connectTo(const std::string& ssid, const std::string& password, bool saveOnSuccess); + void scheduleSlowRetry(); + void cancelSlowRetry(); + static void retryTimerCallback(void* arg); + // Event handlers static void eventHandler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data); diff --git a/main/controller/AppController.cpp b/main/controller/AppController.cpp index ba14c05..eaee18f 100644 --- a/main/controller/AppController.cpp +++ b/main/controller/AppController.cpp @@ -14,11 +14,32 @@ #include "JmriConnectionController.h" #include "../hardware/RotaryEncoderHal.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" static const char* TAG = "AppController"; +namespace { + +/** How often the orchestrator supervisor looks at the link. A Connect press + * wakes it at once. */ +constexpr uint32_t ORCH_SUPERVISE_TICK_MS = 1000; + +/** How long a dropped socket's own reconnect -- which reuses the session + * cookie -- gets before the supervisor logs in afresh. */ +constexpr int64_t ORCH_RELOGIN_AFTER_US = 30LL * 1000 * 1000; + +/** Backoff between failed logins: 5 s, doubling, capped at a minute. The + * orchestrator allows five logins a minute; this stays under it. */ +constexpr uint32_t ORCH_RETRY_MIN_MS = 5000; +constexpr uint32_t ORCH_RETRY_MAX_MS = 60000; + +/** How often a roster read that failed after a good login is retried. */ +constexpr int64_t ORCH_ROSTER_RETRY_US = 30LL * 1000 * 1000; + +} // namespace + AppController& AppController::instance() { static AppController instance; @@ -81,7 +102,7 @@ void AppController::initialise() // using, and would light the JMRI status indicators for a link nothing // drives. if (m_jmriConnectionController && !useOrchestrator) { - m_jmriConnectionController->startAutoConnectTask(); + m_jmriConnectionController->start(); } else if (useOrchestrator) { ESP_LOGI(TAG, "Orchestrator transport selected; JMRI auto-connect not started"); } @@ -187,10 +208,8 @@ void AppController::showJmriConfigScreen() if (!m_jmriConfigScreen) { m_jmriConfigScreen = std::make_unique(*m_jmriClient, - *m_wiThrottleClient, - m_wifiController.get(), - m_rotaryEncoderHal.get(), - m_throttleController.get()); + *m_wiThrottleClient, + m_jmriConnectionController.get()); } m_jmriConfigScreen->create(); } @@ -212,8 +231,19 @@ void AppController::showOrchestratorConfigScreen() void AppController::autoConnectJmri() { initialise(); - if (m_jmriConnectionController) { - m_jmriConnectionController->loadSettingsAndAutoConnect(); + // Idempotent, and only ever for the transport the operator chose. + if (m_jmriConnectionController && + m_transportSettings.transport == ThrottleTransport::WITHROTTLE) { + m_jmriConnectionController->start(); + } +} + +void AppController::requestOrchestratorReconnect() +{ + if (m_orchestratorTask) { + xTaskNotifyGive(static_cast(m_orchestratorTask)); + } else { + ESP_LOGW(TAG, "Orchestrator is not the selected transport; nothing to reconnect"); } } @@ -225,36 +255,66 @@ JmriJsonClient* AppController::getJmriClient() const void AppController::orchestratorConnectTask(void* arg) { auto* self = static_cast(arg); + OrchestratorClient* client = self->m_orchestratorClient.get(); + + // Read on this task only. Reloaded when the operator presses Connect, + // which is how changed settings arrive. + TransportSettings settings = self->m_transportSettings; + + int64_t nextLoginUs = 0; // due at once + uint32_t backoffMs = ORCH_RETRY_MIN_MS; + bool forceLogin = false; // set by a Connect press + bool rosterLoaded = false; + int64_t nextRosterUs = 0; + + // Never exits. It used to try once and give up: an orchestrator host that + // booted after the ESP -- the normal case with the layout on one switch -- + // left every throttle dead until someone went into settings (F-24). + for (;;) { + const bool wifiUp = self->m_wifiController && self->m_wifiController->isConnected(); + const bool linkUp = client->isConnected(); + const int64_t now = esp_timer_get_time(); + + if (linkUp && !forceLogin) { + // Healthy. Keep the fresh-login deadline a grace period out, so a + // dropped socket first gets its own reconnect before it is replaced. + nextLoginUs = now + ORCH_RELOGIN_AFTER_US; + backoffMs = ORCH_RETRY_MIN_MS; + + if (!rosterLoaded && now >= nextRosterUs) { + rosterLoaded = (client->refreshRoster() == ESP_OK); + nextRosterUs = esp_timer_get_time() + ORCH_ROSTER_RETRY_US; + } + } else if (wifiUp && (forceLogin || now >= nextLoginUs)) { + if (forceLogin) { + settings = TransportSettings::load(); + forceLogin = false; + } - // Wait for WiFi before the login POST. Up to 30 s, matching what - // JmriConnectionController's auto-connect task allows. - for (int i = 0; i < 60; ++i) { - if (self->m_wifiController && self->m_wifiController->isConnected()) { - break; + ESP_LOGI(TAG, "Logging in to the orchestrator at %s:%u", + settings.host.c_str(), static_cast(settings.port)); + const esp_err_t err = + client->connect(settings.host, settings.port, settings.username, settings.password); + + if (err == ESP_OK) { + // A REST read that needs the cookie the login just produced, so + // it happens here rather than on the socket's event task. + rosterLoaded = (client->refreshRoster() == ESP_OK); + nextRosterUs = esp_timer_get_time() + ORCH_ROSTER_RETRY_US; + backoffMs = ORCH_RETRY_MIN_MS; + nextLoginUs = esp_timer_get_time() + ORCH_RELOGIN_AFTER_US; + } else { + ESP_LOGW(TAG, "Orchestrator connect failed (%s); retrying in %lu s", + esp_err_to_name(err), static_cast(backoffMs / 1000)); + nextLoginUs = esp_timer_get_time() + static_cast(backoffMs) * 1000; + backoffMs = (backoffMs * 2 > ORCH_RETRY_MAX_MS) ? ORCH_RETRY_MAX_MS : backoffMs * 2; + } } - vTaskDelay(pdMS_TO_TICKS(500)); - } - if (!self->m_wifiController || !self->m_wifiController->isConnected()) { - ESP_LOGW(TAG, "No WiFi; orchestrator connect abandoned"); - self->m_orchestratorTask = nullptr; - vTaskDelete(nullptr); - return; - } - - const TransportSettings& s = self->m_transportSettings; - esp_err_t err = self->m_orchestratorClient->connect(s.host, s.port, s.username, s.password); - - if (err == ESP_OK) { - // The roster is a REST read and needs the session cookie the login just - // produced, so it happens here rather than on the socket's event task. - self->m_orchestratorClient->refreshRoster(); - } else { - ESP_LOGE(TAG, "Orchestrator connect failed: %s", esp_err_to_name(err)); + if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ORCH_SUPERVISE_TICK_MS)) > 0) { + forceLogin = true; + } } - - self->m_orchestratorTask = nullptr; - vTaskDelete(nullptr); } void AppController::startOrchestratorConnectTask() @@ -266,7 +326,8 @@ void AppController::startOrchestratorConnectTask() // Its own task because the login is a blocking HTTP round trip and the // roster is two more. None of that may happen on the LVGL task (F-05). - // 6 KB covers the TLS-capable HTTP client's stack use. + // 6 KB covers the TLS-capable HTTP client's stack use. It runs for the + // life of the application. TaskHandle_t handle = nullptr; BaseType_t ret = xTaskCreate(orchestratorConnectTask, "orch_connect", 6144, this, 5, &handle); if (ret != pdPASS) { diff --git a/main/controller/AppController.h b/main/controller/AppController.h index 0e06cb7..069fb29 100644 --- a/main/controller/AppController.h +++ b/main/controller/AppController.h @@ -38,6 +38,16 @@ class AppController { void showOrchestratorConfigScreen(); void autoConnectJmri(); + /** + * @brief Log in to the orchestrator afresh, with the settings now saved. + * + * Wakes the supervising task rather than connecting on the caller's -- + * the caller is an LVGL event handler, and the supervisor must stay the + * only thing that connects (F-24). No-op unless the orchestrator is the + * selected transport. + */ + void requestOrchestratorReconnect(); + JmriJsonClient* getJmriClient() const; WiThrottleClient* getWiThrottleClient() const; /** Null unless the orchestrator transport is the one selected. */ @@ -50,8 +60,16 @@ class AppController { private: AppController(); - /** Logs in and fetches the roster off the LVGL task (F-05). */ + /** Starts the task that keeps the orchestrator link up (F-24). */ void startOrchestratorConnectTask(); + + /** + * Supervises the orchestrator link for the life of the application: logs + * in once WiFi is up, however long that takes; retries a failed login with + * backoff; and logs in afresh when the socket stays down longer than its + * own reconnect (which reuses the old cookie) gets to recover it. Off the + * LVGL task, because a login is a blocking HTTP round trip (F-05). + */ static void orchestratorConnectTask(void* arg); std::unique_ptr m_mainScreen; diff --git a/main/controller/JmriConnectionController.cpp b/main/controller/JmriConnectionController.cpp index 0663cfb..c92407f 100644 --- a/main/controller/JmriConnectionController.cpp +++ b/main/controller/JmriConnectionController.cpp @@ -3,10 +3,12 @@ #include "../communication/JmriJsonClient.h" #include "../communication/WiThrottleClient.h" #include "esp_log.h" +#include "esp_timer.h" #include "nvs_flash.h" #include "nvs.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" + +#include +#include static const char* TAG = "JmriConnCtrl"; @@ -16,200 +18,380 @@ static const char* NVS_KEY_JSON_PORT = "json_port"; static const char* NVS_KEY_WITHROTTLE_PORT = "wt_port"; static const char* NVS_KEY_POWER_MANAGER = "power_mgr"; +namespace { + +/** Checks the links this often, and at once when woken by a request. */ +constexpr uint32_t WORKER_TICK_MS = 1000; + +/** Settle time after WiFi comes up before the first attempt. */ +constexpr int64_t WIFI_SETTLE_US = 1000 * 1000; + +/** Backoff between reconnect attempts: 5 s, doubling, capped at a minute. */ +constexpr uint32_t RETRY_BASE_S = 5; +constexpr uint32_t RETRY_MAX_S = 60; + +/** The worker connects over TCP and WebSocket, runs the clients' connection + * callbacks -- which repaint the main screen under the LVGL lock -- and writes + * NVS. Sized as the config screen's own connect task was. */ +constexpr uint32_t WORKER_STACK_BYTES = 6144; + +uint16_t parsePort(const char* text, uint16_t fallback) +{ + char* end = nullptr; + const long value = std::strtol(text, &end, 10); + if (end == text || value <= 0 || value > 65535) { + return fallback; + } + return static_cast(value); +} + +} // namespace + JmriConnectionController::JmriConnectionController(JmriJsonClient* jsonClient, WiThrottleClient* wtClient, WiFiController* wifiController) : m_jsonClient(jsonClient) , m_wtClient(wtClient) , m_wifiController(wifiController) - , m_autoReconnectEnabled(false) - , m_savedServerIp() - , m_savedJsonPort(12080) - , m_savedWtPort(12090) - , m_savedPowerMgr("DCC++") - , m_reconnectTaskHandle(nullptr) - , m_autoConnectTaskHandle(nullptr) + , m_mutex(nullptr) + , m_autoReconnect(false) + , m_request(Request::NONE) + , m_discoveredJsonPort(0) + , m_busy(false) + , m_workerTask(nullptr) { + m_mutex = xSemaphoreCreateMutex(); + if (!m_mutex) { + ESP_LOGE(TAG, "Failed to create settings mutex"); + } } -JmriConnectionController::~JmriConnectionController() = default; +JmriConnectionController::~JmriConnectionController() +{ + // Owned by the AppController singleton, so in practice never destroyed + // while the worker runs. + if (m_workerTask) { + vTaskDelete(m_workerTask); + m_workerTask = nullptr; + } + if (m_mutex) { + vSemaphoreDelete(m_mutex); + m_mutex = nullptr; + } +} -void JmriConnectionController::loadSettingsAndAutoConnect() +bool JmriConnectionController::lock() const { - if (!m_wifiController || !m_wifiController->isConnected()) { - ESP_LOGI(TAG, "WiFi not connected, skipping JMRI auto-connect"); - return; + return m_mutex && xSemaphoreTake(m_mutex, pdMS_TO_TICKS(1000)) == pdTRUE; +} + +void JmriConnectionController::unlock() const +{ + if (m_mutex) { + xSemaphoreGive(m_mutex); } +} + +// --- Settings --------------------------------------------------------------- + +JmriConnectionController::Settings JmriConnectionController::loadSettings() +{ + Settings settings; nvs_handle_t handle; - esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle); - if (err != ESP_OK) { - ESP_LOGD(TAG, "No saved JMRI settings for auto-connect"); - return; + if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) { + ESP_LOGD(TAG, "No saved JMRI settings"); + return settings; } - char serverIp[64] = {0}; - char jsonPortStr[8] = "12080"; - char wtPortStr[8] = "12090"; - char powerMgr[64] = "DCC++"; - size_t length; + char text[64]; + size_t length = sizeof(text); + if (nvs_get_str(handle, NVS_KEY_SERVER_IP, text, &length) == ESP_OK) { + settings.serverIp = text; + } - length = sizeof(serverIp); - err = nvs_get_str(handle, NVS_KEY_SERVER_IP, serverIp, &length); - if (err != ESP_OK || serverIp[0] == '\0') { - ESP_LOGD(TAG, "No server IP saved"); - nvs_close(handle); - return; + char port[8]; + length = sizeof(port); + if (nvs_get_str(handle, NVS_KEY_JSON_PORT, port, &length) == ESP_OK) { + settings.jsonPort = parsePort(port, DEFAULT_JSON_PORT); + } + length = sizeof(port); + if (nvs_get_str(handle, NVS_KEY_WITHROTTLE_PORT, port, &length) == ESP_OK) { + settings.wtPort = parsePort(port, DEFAULT_WT_PORT); } - length = sizeof(jsonPortStr); - nvs_get_str(handle, NVS_KEY_JSON_PORT, jsonPortStr, &length); + length = sizeof(text); + if (nvs_get_str(handle, NVS_KEY_POWER_MANAGER, text, &length) == ESP_OK && text[0] != '\0') { + settings.powerManager = text; + } - length = sizeof(wtPortStr); - nvs_get_str(handle, NVS_KEY_WITHROTTLE_PORT, wtPortStr, &length); + nvs_close(handle); + return settings; +} - length = sizeof(powerMgr); - nvs_get_str(handle, NVS_KEY_POWER_MANAGER, powerMgr, &length); +void JmriConnectionController::saveSettings(const Settings& settings) +{ + nvs_handle_t handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open NVS: %s", esp_err_to_name(err)); + return; + } + // Ports stay strings: that is how they have always been stored. + nvs_set_str(handle, NVS_KEY_SERVER_IP, settings.serverIp.c_str()); + nvs_set_str(handle, NVS_KEY_WITHROTTLE_PORT, std::to_string(settings.wtPort).c_str()); + nvs_set_str(handle, NVS_KEY_POWER_MANAGER, settings.powerManager.c_str()); + nvs_commit(handle); nvs_close(handle); - uint16_t jsonPort = static_cast(std::atoi(jsonPortStr)); - uint16_t wtPort = static_cast(std::atoi(wtPortStr)); + ESP_LOGI(TAG, "JMRI settings saved (%s, WiThrottle %u, power manager '%s')", + settings.serverIp.c_str(), settings.wtPort, settings.powerManager.c_str()); +} - if (jsonPort == 0) jsonPort = 12080; - if (wtPort == 0) wtPort = 12090; +void JmriConnectionController::saveJsonPort(uint16_t port) +{ + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + return; + } + nvs_set_str(handle, NVS_KEY_JSON_PORT, std::to_string(port).c_str()); + nvs_commit(handle); + nvs_close(handle); +} - m_savedServerIp = serverIp; - m_savedJsonPort = jsonPort; - m_savedWtPort = wtPort; - m_savedPowerMgr = powerMgr; +void JmriConnectionController::saveTask(void* arg) +{ + Settings* settings = static_cast(arg); + if (settings) { + saveSettings(*settings); + delete settings; + } + vTaskDelete(nullptr); +} - ESP_LOGI(TAG, "Auto-connecting to JMRI: %s (JSON:%d, WiThrottle:%d, Power:%s)", - m_savedServerIp.c_str(), m_savedJsonPort, m_savedWtPort, m_savedPowerMgr.c_str()); +// --- Requests --------------------------------------------------------------- +void JmriConnectionController::start() +{ + if (m_workerTask) { + return; + } if (!m_jsonClient || !m_wtClient) { - ESP_LOGE(TAG, "Clients not initialized"); + ESP_LOGE(TAG, "Clients not initialised; JMRI connection not started"); return; } - m_jsonClient->setConfiguredPowerName(m_savedPowerMgr); - - err = m_jsonClient->connect(m_savedServerIp.c_str(), m_savedJsonPort); - if (err != ESP_OK) { - ESP_LOGW(TAG, "JSON client auto-connect failed (will remain disconnected)"); + // An NVS read, but start() runs from AppController::initialise(), not from + // an LVGL event handler. + const Settings saved = loadSettings(); + if (lock()) { + m_settings = saved; + m_autoReconnect = !saved.serverIp.empty(); + unlock(); } - err = m_wtClient->connect(m_savedServerIp.c_str(), m_savedWtPort); - if (err != ESP_OK) { - ESP_LOGW(TAG, "WiThrottle client auto-connect failed (will remain disconnected)"); + // PW announces the JSON server's port. The receive task only records it: + // connecting the JSON client from there raced the reconnect task into a + // double destroy of the WebSocket client (F-34). + m_wtClient->setWebPortCallback([this](uint16_t port) { + m_discoveredJsonPort = port; + wakeWorker(); + }); + + if (xTaskCreate(workerTask, "jmri_conn", WORKER_STACK_BYTES, this, 4, &m_workerTask) != pdPASS) { + ESP_LOGE(TAG, "Failed to create JMRI connection task"); + m_workerTask = nullptr; + return; } - enableAutoReconnect(true); + if (saved.serverIp.empty()) { + ESP_LOGI(TAG, "No JMRI server saved; waiting for one from the config screen"); + } else { + ESP_LOGI(TAG, "JMRI: %s (JSON %u, WiThrottle %u, power manager '%s'); connecting once WiFi is up", + saved.serverIp.c_str(), saved.jsonPort, saved.wtPort, saved.powerManager.c_str()); + } } -void JmriConnectionController::enableAutoReconnect(bool enable) +void JmriConnectionController::requestConnect(const std::string& serverIp, + uint16_t wtPort, + const std::string& powerManager) { - m_autoReconnectEnabled = enable; - if (enable && m_reconnectTaskHandle == nullptr) { - startReconnectTask(); + Settings settings; + settings.serverIp = serverIp; + settings.wtPort = (wtPort != 0) ? wtPort : DEFAULT_WT_PORT; + settings.powerManager = powerManager.empty() ? DEFAULT_POWER_MANAGER : powerManager; + + if (!m_workerTask) { + // The orchestrator is the selected transport, and the device must not + // sit retrying a server the operator did not choose. Keep the settings + // for when WiThrottle is selected. Saved off the calling task, which is + // the LVGL task (F-05). + ESP_LOGW(TAG, "WiThrottle is not the selected transport; saving JMRI settings only"); + auto* copy = new Settings(settings); + if (xTaskCreate(saveTask, "jmri_save", 3072, copy, 4, nullptr) != pdPASS) { + ESP_LOGE(TAG, "Failed to create JMRI settings save task"); + delete copy; + } + return; + } + + if (!lock()) { + ESP_LOGW(TAG, "Could not lock to request a JMRI connect"); + return; } + // Keep the JSON port learned from PW; the new server's PW corrects it. + settings.jsonPort = m_settings.jsonPort; + m_requestedSettings = settings; + m_request = Request::CONNECT; + unlock(); + + m_busy = true; + wakeWorker(); } -void JmriConnectionController::startAutoConnectTask() +void JmriConnectionController::requestDisconnect() { - if (m_autoConnectTaskHandle != nullptr) { + if (!m_workerTask) { + ESP_LOGI(TAG, "JMRI connection not running; nothing to disconnect"); return; } - xTaskCreate([](void* arg) { - auto* controller = static_cast(arg); - if (!controller) { - vTaskDelete(NULL); - return; - } - - for (int i = 0; i < 60; i++) { - if (controller->m_wifiController && controller->m_wifiController->isConnected()) { - ESP_LOGI(TAG, "WiFi connected, attempting JMRI auto-connect"); - vTaskDelay(pdMS_TO_TICKS(1000)); - controller->loadSettingsAndAutoConnect(); - break; - } - vTaskDelay(pdMS_TO_TICKS(500)); - } + if (!lock()) { + ESP_LOGW(TAG, "Could not lock to request a JMRI disconnect"); + return; + } + m_request = Request::DISCONNECT; + unlock(); - controller->m_autoConnectTaskHandle = nullptr; - vTaskDelete(NULL); - }, "jmri_autoconn", 4096, this, 5, reinterpret_cast(&m_autoConnectTaskHandle)); + m_busy = true; + wakeWorker(); } -void JmriConnectionController::startReconnectTask() +void JmriConnectionController::wakeWorker() { - xTaskCreate(reconnectTask, "jmri_reconnect", 3072, this, 4, reinterpret_cast(&m_reconnectTaskHandle)); + if (m_workerTask) { + xTaskNotifyGive(m_workerTask); + } } -void JmriConnectionController::reconnectTask(void* arg) -{ - auto* controller = static_cast(arg); - if (!controller) return; - - ESP_LOGI(TAG, "Auto-reconnect task started"); - - int failedAttempts = 0; - const int maxBackoff = 60; +// --- Worker ----------------------------------------------------------------- - while (true) { - vTaskDelay(pdMS_TO_TICKS(5000)); - - if (!controller->m_autoReconnectEnabled || - !controller->m_wifiController || - !controller->m_wifiController->isConnected()) { - failedAttempts = 0; - continue; - } +void JmriConnectionController::workerTask(void* arg) +{ + static_cast(arg)->runWorker(); +} - if (!controller->m_jsonClient || !controller->m_wtClient) { - continue; +void JmriConnectionController::runWorker() +{ + uint32_t failedAttempts = 0; + int64_t nextAttemptUs = 0; + bool wifiWasUp = false; + + for (;;) { + Request request = Request::NONE; + Settings requested; + if (lock()) { + request = m_request; + m_request = Request::NONE; + requested = m_requestedSettings; + unlock(); } - bool jsonConnected = controller->m_jsonClient->isConnected(); - bool wtConnected = controller->m_wtClient->isConnected(); - - if (jsonConnected && wtConnected) { - if (failedAttempts > 0) { - ESP_LOGI(TAG, "Connection restored"); - failedAttempts = 0; + if (request == Request::DISCONNECT) { + // Sticks: nothing reconnects until the operator asks again. + if (lock()) { + m_autoReconnect = false; + unlock(); } - continue; + ESP_LOGI(TAG, "Disconnecting from JMRI on request; not reconnecting until asked"); + m_jsonClient->disconnect(); + m_wtClient->disconnect(); + } else if (request == Request::CONNECT) { + saveSettings(requested); + if (lock()) { + m_settings = requested; + m_autoReconnect = true; + unlock(); + } + ESP_LOGI(TAG, "Connecting to JMRI at %s on request", requested.serverIp.c_str()); + // Start clean: it may be a different server. + m_jsonClient->disconnect(); + m_wtClient->disconnect(); + failedAttempts = 0; + nextAttemptUs = 0; } - int backoffDelay = 5 * (1 << failedAttempts); - if (backoffDelay > maxBackoff) { - backoffDelay = maxBackoff; + Settings settings; + bool autoReconnect = false; + if (lock()) { + settings = m_settings; + autoReconnect = m_autoReconnect; + unlock(); } - ESP_LOGW(TAG, "JMRI disconnected (attempt %d, next retry in %ds)", - failedAttempts + 1, backoffDelay); + const bool wifiUp = m_wifiController && m_wifiController->isConnected(); + const int64_t now = esp_timer_get_time(); + if (wifiUp && !wifiWasUp) { + // Waits for WiFi as long as it takes. The old auto-connect gave up + // after 30 s and never started reconnecting (F-24). + nextAttemptUs = now + WIFI_SETTLE_US; + failedAttempts = 0; + } + wifiWasUp = wifiUp; + + if (autoReconnect && wifiUp && !settings.serverIp.empty()) { + // A JSON port learned from PW: kept for the next boot, and the JSON + // client moved to it. + const uint16_t discovered = m_discoveredJsonPort.exchange(0); + if (discovered != 0 && discovered != settings.jsonPort) { + ESP_LOGI(TAG, "JMRI announced JSON port %u (was %u)", discovered, settings.jsonPort); + saveJsonPort(discovered); + settings.jsonPort = discovered; + if (lock()) { + m_settings.jsonPort = discovered; + unlock(); + } + m_jsonClient->disconnect(); + } - vTaskDelay(pdMS_TO_TICKS(backoffDelay * 1000)); + const bool wtUp = m_wtClient->isConnected(); + const bool jsonUp = m_jsonClient->isConnected(); - if (!jsonConnected && !controller->m_savedServerIp.empty()) { - ESP_LOGI(TAG, "Attempting to reconnect JSON client..."); - controller->m_jsonClient->setConfiguredPowerName(controller->m_savedPowerMgr); - esp_err_t err = controller->m_jsonClient->connect(controller->m_savedServerIp.c_str(), controller->m_savedJsonPort); - if (err == ESP_OK) { - ESP_LOGI(TAG, "JSON client reconnected"); + if (wtUp && jsonUp) { + if (failedAttempts > 0) { + ESP_LOGI(TAG, "JMRI connected: WiThrottle and JSON both up"); + } + failedAttempts = 0; + nextAttemptUs = 0; + } else if (request == Request::CONNECT || esp_timer_get_time() >= nextAttemptUs) { + if (!wtUp) { + ESP_LOGI(TAG, "Connecting WiThrottle to %s:%u", settings.serverIp.c_str(), + settings.wtPort); + m_wtClient->connect(settings.serverIp, settings.wtPort); + } + // Not while its own socket is still coming up: re-creating the + // client then only restarts the attempt. + if (!jsonUp && + m_jsonClient->getState() != JmriJsonClient::ConnectionState::CONNECTING) { + m_jsonClient->setConfiguredPowerName(settings.powerManager); + m_jsonClient->connect(settings.serverIp, settings.jsonPort); + } + + const uint32_t shift = failedAttempts < 4 ? failedAttempts : 4; + uint32_t backoffS = RETRY_BASE_S << shift; + if (backoffS > RETRY_MAX_S) { + backoffS = RETRY_MAX_S; + } + failedAttempts++; + nextAttemptUs = esp_timer_get_time() + static_cast(backoffS) * 1000000; } } - if (!wtConnected && !controller->m_savedServerIp.empty()) { - ESP_LOGI(TAG, "Attempting to reconnect WiThrottle client..."); - esp_err_t err = controller->m_wtClient->connect(controller->m_savedServerIp.c_str(), controller->m_savedWtPort); - if (err == ESP_OK) { - ESP_LOGI(TAG, "WiThrottle client reconnected"); - } + if (request != Request::NONE) { + m_busy = false; } - failedAttempts++; + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(WORKER_TICK_MS)); } } diff --git a/main/controller/JmriConnectionController.h b/main/controller/JmriConnectionController.h index a991add..20632ba 100644 --- a/main/controller/JmriConnectionController.h +++ b/main/controller/JmriConnectionController.h @@ -1,13 +1,30 @@ #pragma once +#include +#include #include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + class JmriJsonClient; class WiThrottleClient; class WiFiController; /** - * @brief Manages JMRI connection settings and auto-reconnect. + * @brief Owns the JMRI connection: its saved settings, its reconnection, and + * the one task that connects and disconnects both clients. + * + * Every connect and disconnect -- at boot, after an outage, and from the config + * screen -- happens on the `jmri_conn` worker task (F-34). Before, the screen + * connected on a task of its own and disconnected on the LVGL task, the JSON + * client was connected from the WiThrottle receive task, and all of it raced a + * separate reconnect task, which also undid a manual disconnect within five + * seconds. + * + * WiThrottle transport only: under the orchestrator the worker is never + * started, and the screen's Connect only saves settings for later. */ class JmriConnectionController { public: @@ -17,23 +34,73 @@ class JmriConnectionController { JmriConnectionController(const JmriConnectionController&) = delete; JmriConnectionController& operator=(const JmriConnectionController&) = delete; - void loadSettingsAndAutoConnect(); - void enableAutoReconnect(bool enable); - void startAutoConnectTask(); + /** + * @brief Load the saved settings and start keeping both clients connected. + * + * Waits for WiFi for as long as it takes rather than giving up after 30 s, + * and retries with backoff for as long as the settings exist (F-24). + * Idempotent. + */ + void start(); + + /** + * @brief Save these settings and (re)connect both clients to them. + * + * Returns at once; the save and the connect happen on the worker. When the + * worker is not running -- the orchestrator is the selected transport -- + * the settings are saved and nothing connects. + */ + void requestConnect(const std::string& serverIp, uint16_t wtPort, const std::string& powerManager); + + /** + * @brief Disconnect both clients and stop reconnecting until the next + * requestConnect() or reboot. Returns at once. + */ + void requestDisconnect(); + + /** True while the worker is carrying out a connect or disconnect request. */ + bool isBusy() const { return m_busy; } private: - void startReconnectTask(); - static void reconnectTask(void* arg); + struct Settings { + std::string serverIp; + uint16_t jsonPort = DEFAULT_JSON_PORT; + uint16_t wtPort = DEFAULT_WT_PORT; + std::string powerManager = DEFAULT_POWER_MANAGER; + }; + + enum class Request { NONE, CONNECT, DISCONNECT }; + + static constexpr uint16_t DEFAULT_JSON_PORT = 12080; + static constexpr uint16_t DEFAULT_WT_PORT = 12090; + static constexpr const char* DEFAULT_POWER_MANAGER = "DCC++"; + + static Settings loadSettings(); + static void saveSettings(const Settings& settings); + static void saveJsonPort(uint16_t port); + static void saveTask(void* arg); + + static void workerTask(void* arg); + void runWorker(); + void wakeWorker(); + + bool lock() const; + void unlock() const; JmriJsonClient* m_jsonClient; WiThrottleClient* m_wtClient; WiFiController* m_wifiController; - bool m_autoReconnectEnabled; - std::string m_savedServerIp; - uint16_t m_savedJsonPort; - uint16_t m_savedWtPort; - std::string m_savedPowerMgr; - void* m_reconnectTaskHandle; - void* m_autoConnectTaskHandle; + // Guarded by m_mutex: written by the requesting task, read by the worker. + mutable SemaphoreHandle_t m_mutex; + Settings m_settings; + bool m_autoReconnect; + Request m_request; + Settings m_requestedSettings; + + /** Web port learned from WiThrottle's PW line, 0 when none is pending. + * Set on the receive task, which must not connect anything itself. */ + std::atomic m_discoveredJsonPort; + std::atomic m_busy; + TaskHandle_t m_workerTask; }; diff --git a/main/ui/JmriConfigScreen.cpp b/main/ui/JmriConfigScreen.cpp index c5c8c1c..9a7d892 100644 --- a/main/ui/JmriConfigScreen.cpp +++ b/main/ui/JmriConfigScreen.cpp @@ -3,38 +3,16 @@ #include "wrappers/main_screen_wrapper.h" #include "wrappers/settings_wrapper.h" #include "esp_log.h" -#include "../controller/ThrottleController.h" -#include "../controller/WiFiController.h" -#include "../hardware/RotaryEncoderHal.h" -#include "esp_app_desc.h" -#include "esp_chip_info.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" +#include "../controller/JmriConnectionController.h" #include "nvs_flash.h" #include "nvs.h" -#include "lvgl_port.h" +#include #include -#include - -namespace { -struct JmriConnectTaskArgs { - JmriConfigScreen* screen; - JmriJsonClient* jsonClient; - WiThrottleClient* wiThrottleClient; - std::string serverIp; - std::string powerManager; - uint16_t wiThrottlePort; -}; -} - -extern "C" { - bool lvgl_port_lock(int timeout_ms); - void lvgl_port_unlock(void); -} static const char* TAG = "JmriConfigScreen"; -// NVS keys for JMRI settings +// NVS keys for JMRI settings. Read here to fill the form; written only by +// JmriConnectionController. static const char* NVS_NAMESPACE = "jmri"; static const char* NVS_KEY_SERVER_IP = "server_ip"; static const char* NVS_KEY_WITHROTTLE_PORT = "wt_port"; @@ -42,9 +20,7 @@ static const char* NVS_KEY_POWER_MANAGER = "power_mgr"; JmriConfigScreen::JmriConfigScreen(JmriJsonClient& jsonClient, WiThrottleClient& wiThrottleClient, - WiFiController* wifiController, - RotaryEncoderHal* encoderHal, - ThrottleController* throttleController) + JmriConnectionController* connection) : m_screen(nullptr) , m_serverIpInput(nullptr) , m_wiThrottlePortInput(nullptr) @@ -57,12 +33,9 @@ JmriConfigScreen::JmriConfigScreen(JmriJsonClient& jsonClient, , m_keyboard(nullptr) , m_keyboardLabel(nullptr) , m_statusTimer(nullptr) - , m_connectInProgress(false) , m_jsonClient(jsonClient) , m_wiThrottleClient(wiThrottleClient) - , m_wifiController(wifiController) - , m_encoderHal(encoderHal) - , m_throttleController(throttleController) + , m_connection(connection) { } @@ -357,7 +330,8 @@ void JmriConfigScreen::updateStatus() auto jsonState = m_jsonClient.getState(); auto wiThrottleState = m_wiThrottleClient.getState(); - bool isConnecting = m_connectInProgress.load() || + const bool busy = m_connection && m_connection->isBusy(); + bool isConnecting = busy || wiThrottleState == WiThrottleClient::ConnectionState::CONNECTING || jsonState == JmriJsonClient::ConnectionState::CONNECTING; bool isConnected = wiThrottleState == WiThrottleClient::ConnectionState::CONNECTED || @@ -390,7 +364,7 @@ void JmriConfigScreen::updateStatus() break; case WiThrottleClient::ConnectionState::DISCONNECTED: default: - if (m_connectInProgress.load()) { + if (busy) { wiThrottleText = "Connecting..."; } break; @@ -410,7 +384,7 @@ void JmriConfigScreen::updateStatus() break; case JmriJsonClient::ConnectionState::DISCONNECTED: default: - if (m_connectInProgress.load()) { + if (busy) { jsonText = "Waiting..."; } break; @@ -422,120 +396,45 @@ void JmriConfigScreen::updateStatus() void JmriConfigScreen::connectToJmri() { - if (m_connectInProgress.exchange(true)) { - ESP_LOGW(TAG, "JMRI connection already in progress"); + if (!m_connection) { + ESP_LOGE(TAG, "No JMRI connection controller"); return; } - std::string serverIp = getServerIpText(); - std::string wtPortStr = getWiThrottlePortText(); - std::string powerMgr = getPowerManagerText(); - + const std::string serverIp = getServerIpText(); if (serverIp.empty()) { - m_connectInProgress.store(false); ESP_LOGW(TAG, "Server IP is empty"); return; } - - // Parse WiThrottle port - uint16_t wtPort = wtPortStr.empty() ? 12090 : std::atoi(wtPortStr.c_str()); - - // Set power manager name (use default if empty) - if (powerMgr.empty()) { - powerMgr = "DCC++"; - } - - saveSettings(); - - auto* args = new JmriConnectTaskArgs{ - this, - &m_jsonClient, - &m_wiThrottleClient, - serverIp, - powerMgr, - wtPort, - }; - - if (xTaskCreate(connectTask, "jmri_connect", 6144, args, 4, nullptr) != pdPASS) { - ESP_LOGE(TAG, "Failed to start JMRI connect task"); - delete args; - m_connectInProgress.store(false); - } - - updateStatus(); -} - -void JmriConfigScreen::connectTask(void* arg) -{ - std::unique_ptr args(static_cast(arg)); - - args->jsonClient->setConfiguredPowerName(args->powerManager); - - ESP_LOGI(TAG, "Connecting to JMRI server: %s (WiThrottle:%d, Power:%s)", - args->serverIp.c_str(), args->wiThrottlePort, args->powerManager.c_str()); - - esp_err_t err = args->wiThrottleClient->connect(args->serverIp, args->wiThrottlePort); - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to connect WiThrottle client"); - } else { - args->wiThrottleClient->setWebPortCallback([ - jsonClient = args->jsonClient, - serverIp = args->serverIp - ](uint16_t jsonPort) { - ESP_LOGI(TAG, "Auto-connecting JSON client to port %d", jsonPort); - esp_err_t jsonErr = jsonClient->connect(serverIp, jsonPort); - if (jsonErr != ESP_OK) { - ESP_LOGE(TAG, "Failed to connect JSON client"); - } - }); - } - args->screen->m_connectInProgress.store(false); - if (lvgl_port_lock(200)) { - if (args->screen->m_screen) { - args->screen->updateStatus(); + // Anything that is not a valid port falls back to the WiThrottle default + // rather than being truncated into a different one. + const std::string wtPortText = getWiThrottlePortText(); + uint16_t wtPort = 12090; + if (!wtPortText.empty()) { + const long parsed = std::strtol(wtPortText.c_str(), nullptr, 10); + if (parsed > 0 && parsed <= 65535) { + wtPort = static_cast(parsed); } - lvgl_port_unlock(); } - vTaskDelete(nullptr); + // Saved and connected on the controller's task, never here: this is an + // LVGL event handler (F-05, F-34). The status timer shows the result. + m_connection->requestConnect(serverIp, wtPort, getPowerManagerText()); + updateStatus(); } void JmriConfigScreen::disconnectFromJmri() { ESP_LOGI(TAG, "Disconnecting from JMRI server"); - - m_jsonClient.disconnect(); - m_wiThrottleClient.disconnect(); - - updateStatus(); -} -void JmriConfigScreen::saveSettings() -{ - nvs_handle_t handle; - esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to open NVS: %s", esp_err_to_name(err)); - return; + // Carried out on the controller's task: disconnecting waits for the + // receive task to exit, which must not freeze the screen (F-34). It also + // stops the reconnecting, so it sticks. + if (m_connection) { + m_connection->requestDisconnect(); } - - std::string serverIp = getServerIpText(); - std::string wtPort = getWiThrottlePortText(); - std::string powerMgr = getPowerManagerText(); - - nvs_set_str(handle, NVS_KEY_SERVER_IP, serverIp.c_str()); - nvs_set_str(handle, NVS_KEY_WITHROTTLE_PORT, wtPort.c_str()); - nvs_set_str(handle, NVS_KEY_POWER_MANAGER, powerMgr.c_str()); - - nvs_commit(handle); - nvs_close(handle); - - if (m_throttleController) { - m_throttleController->reloadSpeedStepsFromNvs(); - } - - ESP_LOGI(TAG, "JMRI settings saved (Power Manager: %s)", powerMgr.c_str()); + updateStatus(); } void JmriConfigScreen::loadSettings() diff --git a/main/ui/JmriConfigScreen.h b/main/ui/JmriConfigScreen.h index ea99cbd..73da093 100644 --- a/main/ui/JmriConfigScreen.h +++ b/main/ui/JmriConfigScreen.h @@ -4,7 +4,6 @@ #include "../communication/JmriJsonClient.h" #include "../communication/WiThrottleClient.h" #include "../controller/TransportSettings.h" -#include #include /** @@ -17,19 +16,17 @@ * - View connection status * - Connect/Disconnect from server * - * Settings are persisted in NVS and auto-connect at startup + * Connecting, disconnecting and saving are all JmriConnectionController's: + * this screen asks and shows the result, and never blocks the LVGL task on the + * network or NVS (F-34). */ -class WiFiController; -class RotaryEncoderHal; -class ThrottleController; +class JmriConnectionController; class JmriConfigScreen { public: - explicit JmriConfigScreen(JmriJsonClient& jsonClient, - WiThrottleClient& wiThrottleClient, - WiFiController* wifiController, - RotaryEncoderHal* encoderHal, - ThrottleController* throttleController); + JmriConfigScreen(JmriJsonClient& jsonClient, + WiThrottleClient& wiThrottleClient, + JmriConnectionController* connection); ~JmriConfigScreen(); // Delete copy/move @@ -61,7 +58,6 @@ class JmriConfigScreen { void connectToJmri(); void disconnectFromJmri(); - void saveSettings(); void loadSettings(); void clearUiPointers(); @@ -75,7 +71,6 @@ class JmriConfigScreen { static void onBackButtonClicked(lv_event_t* e); static void onTextAreaFocused(lv_event_t* e); static void onTextAreaDefocused(lv_event_t* e); - static void connectTask(void* arg); /** * Status is polled on an LVGL timer, not pushed through either client's @@ -99,14 +94,11 @@ class JmriConfigScreen { lv_obj_t* m_keyboard; lv_obj_t* m_keyboardLabel; lv_timer_t* m_statusTimer; - std::atomic m_connectInProgress; // Client references JmriJsonClient& m_jsonClient; WiThrottleClient& m_wiThrottleClient; - WiFiController* m_wifiController; - RotaryEncoderHal* m_encoderHal; - ThrottleController* m_throttleController; + JmriConnectionController* m_connection; // Constants static constexpr int SCREEN_WIDTH = 800; diff --git a/main/ui/OrchestratorConfigScreen.cpp b/main/ui/OrchestratorConfigScreen.cpp index fcd88a3..538f492 100644 --- a/main/ui/OrchestratorConfigScreen.cpp +++ b/main/ui/OrchestratorConfigScreen.cpp @@ -28,7 +28,6 @@ OrchestratorConfigScreen::OrchestratorConfigScreen(OrchestratorClient* client, , m_connectButton(nullptr) , m_keyboard(nullptr) , m_statusTimer(nullptr) - , m_connectInProgress(false) , m_client(client) , m_wifiController(wifiController) { @@ -355,25 +354,6 @@ void OrchestratorConfigScreen::onSaveClicked(lv_event_t* e) ESP_LOGI(TAG, "Orchestrator settings saved"); } -void OrchestratorConfigScreen::connectTask(void* arg) -{ - auto* self = static_cast(arg); - - if (self->m_client) { - const TransportSettings settings = TransportSettings::load(); - esp_err_t err = self->m_client->connect(settings.host, settings.port, - settings.username, settings.password); - if (err == ESP_OK) { - self->m_client->refreshRoster(); - } - } - - self->m_connectInProgress = false; - - // The status timer picks the result up; nothing to paint from this task. - vTaskDelete(nullptr); -} - void OrchestratorConfigScreen::onConnectClicked(lv_event_t* e) { auto* self = static_cast(lv_event_get_user_data(e)); @@ -389,17 +369,10 @@ void OrchestratorConfigScreen::onConnectClicked(lv_event_t* e) return; } - if (self->m_connectInProgress.exchange(true)) { - ESP_LOGW(TAG, "Connect already in progress"); - return; - } - - // Off the LVGL task: the login is a blocking HTTP round trip and the roster - // is two more. Blocking here would freeze every throttle at once (F-05). - if (xTaskCreate(connectTask, "orch_ui_conn", 6144, self, 5, nullptr) != pdPASS) { - ESP_LOGE(TAG, "Failed to create connect task"); - self->m_connectInProgress = false; - } + // The supervising task logs in with the settings just saved. Connecting + // from a task of this screen's own raced that supervisor for the same + // client (F-24, F-26); the status timer shows the result. + AppController::instance().requestOrchestratorReconnect(); } void OrchestratorConfigScreen::onBackClicked(lv_event_t* e) diff --git a/main/ui/OrchestratorConfigScreen.h b/main/ui/OrchestratorConfigScreen.h index 06614db..b1ee9a2 100644 --- a/main/ui/OrchestratorConfigScreen.h +++ b/main/ui/OrchestratorConfigScreen.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include "lvgl.h" @@ -53,7 +52,6 @@ class OrchestratorConfigScreen { static void onTextAreaDefocused(lv_event_t* e); /** Login is a blocking HTTP round trip, so it never runs on the LVGL task (F-05). */ - static void connectTask(void* arg); static void statusTimerCb(lv_timer_t* timer); void stopStatusTimer(); @@ -68,7 +66,6 @@ class OrchestratorConfigScreen { lv_obj_t* m_keyboard; lv_timer_t* m_statusTimer; - std::atomic m_connectInProgress; OrchestratorClient* m_client; WiFiController* m_wifiController; diff --git a/main/ui/WiFiConfigScreen.cpp b/main/ui/WiFiConfigScreen.cpp index 10e1ce0..e2a10f1 100644 --- a/main/ui/WiFiConfigScreen.cpp +++ b/main/ui/WiFiConfigScreen.cpp @@ -278,8 +278,10 @@ void WiFiConfigScreen::updateStatus() lv_label_set_text(m_statusLabel, "Status: Connection Failed"); lv_label_set_text(m_ipLabel, "IP: Not connected"); lv_obj_clear_state(m_connectButton, LV_STATE_DISABLED); // Enable connect to retry - lv_obj_add_state(m_disconnectButton, LV_STATE_DISABLED); // Disable disconnect when failed - lv_obj_add_state(m_forgetButton, LV_STATE_DISABLED); // Disable forget when failed + // WiFiManager keeps retrying in the background after a failure + // (F-24), so these are how the operator stops it. + lv_obj_clear_state(m_disconnectButton, LV_STATE_DISABLED); + lv_obj_clear_state(m_forgetButton, LV_STATE_DISABLED); break; } } From 83007d48991d4e217b07a051736335e4824a10fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:09:50 +0000 Subject: [PATCH 05/12] docs: record that MainScreen is never rebuilt and the UI never takes a client's slot Both are rules the batch 1 fixes depend on and that look like oversights to a later reader: rebuilding the main screen reintroduces F-21's use-after-free, and a screen registering on a client's connection callback cuts the backend's knob gating off from the link (F-30). The Copilot instructions' example showed exactly the rebuild pattern, so it changes with CLAUDE.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- .github/copilot-instructions.md | 12 ++++++++---- CLAUDE.md | 5 +++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2da3828..b4eb0f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -74,12 +74,16 @@ class MainScreen { ThrottleController* m_throttleController; // Not owned! }; -// UI can be deleted/recreated without state loss -delete g_mainScreen; -g_mainScreen = new MainScreen(); -g_mainScreen->create(..., g_throttleController); +// Config screens can be deleted/recreated without state loss +m_settingsScreen = std::make_unique(...); +m_settingsScreen->create(); ``` +**Exception: `MainScreen` is built once and never destroyed** (F-21) — re-show it with +`show()`. Destroying it from a Back handler freed it under a task already waiting on the LVGL +lock to repaint it. And no UI class registers on a client's connection callback: those single +slots belong to the active backend, so screens poll on an LVGL timer. + ### ⚠️ CRITICAL: LVGL Thread Safety **LVGL is NOT thread-safe!** Lock before UI access from network tasks: diff --git a/CLAUDE.md b/CLAUDE.md index c2e654d..e1d39f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,11 @@ Things that look like bugs or oversights and are not. One line each. - **`getThrottle` / `getKnob` bypass `m_stateMutex` and are guarded by `CONFIG_THROTTLE_TESTS`** — deliberately unsafe test-only accessors (F-06 removed the unguarded ones). They must never gain a non-test caller. +- **`MainScreen` is built once and never destroyed** (F-21). Rebuilding it freed it under a + task already waiting on the LVGL lock to repaint it. Constraint 4 is about where state + lives, not a licence to rebuild this screen. +- **No UI class registers on a client's connection callback.** Those are single slots owned by + the active backend; config screens poll on an LVGL timer instead (F-21, F-30). - **`JmriJsonClient` parses JSON by substring search** (`extractJsonString` / `extractJsonInt`) rather than with a parser. Adequate for the narrow JMRI subset it reads; **not** adequate for orchestrator payloads. `OrchestratorClient` uses cJSON and From 13408f531ea019df7e73398c2f308f4520d524e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:18:36 +0000 Subject: [PATCH 06/12] docs: link each second-pass finding to its GitHub issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-19…F-44 now have issues #22…#47 as a permanent record outside the tree; the tracker gains an Issue column. Also brings three plans (F-22, F-23, F-24) and F-44's description into line with what was built. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- docs/REVIEW_REMEDIATION_PLAN_2.md | 79 +++++++++++++++++-------------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index dd0b0d3..e22735c 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -10,6 +10,9 @@ on the board. IDs continue from the first pass (`docs/REVIEW_REMEDIATION_PLAN.md`, F-01…F-18), so an `F-nn` reference in a code comment is unambiguous across both documents. +Each finding also has a GitHub issue — `F-nn` is `bazauto/esp-layout-controller#(nn+3)`, #22 to +#47 — as a permanent record outside the tree. This document stays the place its status is kept. + Severity is weighed the way CLAUDE.md asks: by whether a train moves when nobody commanded it, whether the operator can lose the ability to stop one, or whether the device can crash or freeze while one is moving. @@ -18,34 +21,34 @@ freeze while one is moving. ## Progress Tracker -| ID | Finding | Severity | Effort | Batch | Status | -|----|---------|----------|--------|-------|--------| -| F-19 | [Unbounded encoder delta](#f-19-unbounded-encoder-delta) | HIGH | Small | 1 | Code done | -| F-20 | [Orchestrator acquire starts from speed 0](#f-20-orchestrator-acquire-starts-from-speed-0) | HIGH | Medium | 1 | Code done | -| F-21 | [Use-after-free on return to the main screen](#f-21-use-after-free-on-return-to-the-main-screen) | HIGH | Medium | 1 | Code done | -| F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Code done | -| F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Code done | -| F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Code done | -| F-25 | [Physical knobs not gated; optimistic update outlives a failed send](#f-25-physical-knobs-not-gated-optimistic-update-outlives-a-failed-send) | MEDIUM | Small | 4 | Open | -| F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Concurrent connect removed (batch 3); handle lock: open | -| F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Open | -| F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Open | -| F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Open | -| F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Slots: code done; sync: open | -| F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Code done | -| F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | -| F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Open | -| F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Code done | -| F-35 | [Orchestrator roster refused above ~35 locos](#f-35-orchestrator-roster-refused-above-35-locos) | MEDIUM | Small | 5 | Open | -| F-36 | [Operator credential and session cookie in cleartext](#f-36-operator-credential-and-session-cookie-in-cleartext) | MEDIUM | Small (decision) | 5 | Open | -| F-37 | [WiThrottle updates unvalidated](#f-37-withrottle-updates-unvalidated) | LOW | Small | 5 | Open | -| F-38 | [Hot-path logging and repaint cost](#f-38-hot-path-logging-and-repaint-cost) | LOW | Small | 5 | Open | -| F-39 | [NVS writes on the LVGL task](#f-39-nvs-writes-on-the-lvgl-task) | LOW | Small | 5 | JMRI settings: done (batch 3); rest: open | -| F-40 | [WiFi credential save and reboot-on-error](#f-40-wifi-credential-save-and-reboot-on-error) | LOW | Small | 3 | Code done | -| F-41 | [Seesaw read timing](#f-41-seesaw-read-timing) | LOW | Small | 5 | Open | -| F-42 | [Protocol hygiene odds and ends](#f-42-protocol-hygiene-odds-and-ends) | LOW | Small | 5 | Open | -| F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Open | -| F-44 | [Threading-model task table drift](#f-44-threading-model-task-table-drift) | LOW | Small | 5 | Open | +| ID | Finding | Severity | Effort | Batch | Status | Issue | +|----|---------|----------|--------|-------|--------|-------| +| F-19 | [Unbounded encoder delta](#f-19-unbounded-encoder-delta) | HIGH | Small | 1 | Code done | #22 | +| F-20 | [Orchestrator acquire starts from speed 0](#f-20-orchestrator-acquire-starts-from-speed-0) | HIGH | Medium | 1 | Code done | #23 | +| F-21 | [Use-after-free on return to the main screen](#f-21-use-after-free-on-return-to-the-main-screen) | HIGH | Medium | 1 | Code done | #24 | +| F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Code done | #25 | +| F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Code done | #26 | +| F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Code done | #27 | +| F-25 | [Physical knobs not gated; optimistic update outlives a failed send](#f-25-physical-knobs-not-gated-optimistic-update-outlives-a-failed-send) | MEDIUM | Small | 4 | Open | #28 | +| F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Concurrent connect removed (batch 3); handle lock: open | #29 | +| F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Open | #30 | +| F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Open | #31 | +| F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Open | #32 | +| F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Slots: code done; sync: open | #33 | +| F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Code done | #34 | +| F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | #35 | +| F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Open | #36 | +| F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Code done | #37 | +| F-35 | [Orchestrator roster refused above ~35 locos](#f-35-orchestrator-roster-refused-above-35-locos) | MEDIUM | Small | 5 | Open | #38 | +| F-36 | [Operator credential and session cookie in cleartext](#f-36-operator-credential-and-session-cookie-in-cleartext) | MEDIUM | Small (decision) | 5 | Open | #39 | +| F-37 | [WiThrottle updates unvalidated](#f-37-withrottle-updates-unvalidated) | LOW | Small | 5 | Open | #40 | +| F-38 | [Hot-path logging and repaint cost](#f-38-hot-path-logging-and-repaint-cost) | LOW | Small | 5 | Open | #41 | +| F-39 | [NVS writes on the LVGL task](#f-39-nvs-writes-on-the-lvgl-task) | LOW | Small | 5 | JMRI settings: done (batch 3); rest: open | #42 | +| F-40 | [WiFi credential save and reboot-on-error](#f-40-wifi-credential-save-and-reboot-on-error) | LOW | Small | 3 | Code done | #43 | +| F-41 | [Seesaw read timing](#f-41-seesaw-read-timing) | LOW | Small | 5 | Open | #44 | +| F-42 | [Protocol hygiene odds and ends](#f-42-protocol-hygiene-odds-and-ends) | LOW | Small | 5 | Open | #45 | +| F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Open | #46 | +| F-44 | [Threading-model task table drift](#f-44-threading-model-task-table-drift) | LOW | Small | 5 | Open | #47 | **Status key:** *Open* — not started. *Code done* — implemented and compiled, bench criteria still unticked. *Done* — bench criteria confirmed on the board. @@ -231,8 +234,10 @@ whatever the command station last refreshed. 2. On a fresh session, re-acquire every loco that was acquired on the old one (the client already records address and type per throttle). JMRI then restates speed and direction, which re-seeds the display. -3. Keep `m_throttleStates` consistent with what the server actually has: cleared on an explicit - `disconnect()`, replayed on reconnect. +3. Keep `m_throttleStates` consistent with what the UI shows as allocated, not with the TCP + session: it survives any disconnect, explicit or not, and is replayed on every new + session. Only a release removes an entry — even while disconnected, so a loco the operator + let go is not re-acquired. **Files:** `WiThrottleClient.h/.cpp`, possibly `JmriConnectionController.cpp` @@ -265,8 +270,9 @@ off. The device also sends `HESP32-S3`, where the protocol expects `HU` (a stable per-device id) and `N` on connect. -2. Parse the server's `*` announcement; if non-zero, send `*+` and run a heartbeat - from a small dedicated task at half that interval. Stop it before the socket closes. +2. Parse the server's `*` announcement; if non-zero, send `*+` and then `*` at half + that interval. Sent from the receive task, which wakes at least once a second anyway, so + the heartbeat needs no task of its own and ends with the session. **Files:** `WiThrottleClient.h/.cpp` @@ -303,8 +309,8 @@ In each case the operator has no control until they fix it by hand in settings. 2. Orchestrator: a supervising task that retries login and socket with backoff until connected, and re-logs in (rather than relying on the WebSocket's cookie-reusing auto-reconnect) when the socket stays down. -3. JMRI: the auto-connect task waits for WiFi indefinitely and always leaves auto-reconnect - enabled once settings exist. +3. JMRI: wait for WiFi indefinitely and keep reconnecting once settings exist — done by the + single `jmri_conn` task F-34 introduces. **Files:** `WiFiManager.h/.cpp`, `AppController.h/.cpp`, `JmriConnectionController.cpp` @@ -690,5 +696,6 @@ No `permissions:` block, actions pinned by tag rather than SHA, and **Severity:** LOW | **Effort:** Small | **Batch:** 5 -`THREADING_MODEL.md`'s table is missing `jmri_connect` (6 KB) and `wifi_scan` (4 KB), and will -need the measured figures from F-33. +`THREADING_MODEL.md`'s table is missing `wifi_scan` (4 KB), and will need the measured figures +from F-33. (It was also missing `jmri_connect`; batch 3 removed that task, folding it into +`jmri_conn`, which the table now lists.) From 1b8767b2189d1dd4c9ca4c34c9cbe27e0fc73c9f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:27:28 +0000 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20gate=20knobs,=20add=20an=20emergen?= =?UTF-8?q?cy=20stop,=20latch=20orchestrator=20functions=20(F-25=E2=80=A6F?= =?UTF-8?q?-29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-25: the physical encoders reached ThrottleController without passing the UI's link gate, and a speed the transport refused stayed on the display, so the first click afterwards jumped from it. The controller now refuses knob input while the link is down and rolls a failed send back (unless a transport report landed since). The orchestrator refuses with an ERROR rather than a failed send, so any ERROR now re-seeds every assigned throttle from the layout's last reported state. F-26: a re-login stopped and destroyed the WebSocket handle while a sender could be inside it. The handle now has its own mutex, held for each send and across stop/destroy; senders only take it once the link is known up, so they never queue behind a re-login. F-27: there was no emergency stop, and the power toggle turned power ON from an unknown state. The port gains emergencyStop() -- EMERGENCY_STOP halts the layout; WiThrottle sends X to every held loco -- behind an E-STOP button that fires on press, in the one saturated colour. Unknown power now turns power off, and under WiThrottle an OFF goes over PPA0 when the JSON link is down. F-28: FUNCTION_COMMAND sets state outright, so press-on/release-off made every orchestrator function momentary. A port capability now tells the controller to toggle on press there, and keep press/release for JMRI. F-29: the lock order was documented backwards in CLAUDE.md, the threading model and the review agent. Corrected to what the code has always done: the LVGL lock before m_stateMutex. Compiled for the app and the test configuration; the E-STOP layout and the bench checks in docs/REVIEW_REMEDIATION_PLAN_2.md need the board. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- .claude/agents/firmware-review.md | 7 +- CLAUDE.md | 13 +- docs/REVIEW_REMEDIATION_PLAN_2.md | 63 +++++++-- docs/architecture/THREADING_MODEL.md | 12 +- docs/components/COMMUNICATION_LAYER.md | 18 +++ docs/components/CONTROLLER_LAYER.md | 4 + docs/components/UI_LAYER.md | 6 +- docs/flows/FUNCTION_CONTROL.md | 4 +- docs/flows/TRACK_POWER.md | 18 ++- main/communication/OrchestratorBackend.cpp | 32 +++++ main/communication/OrchestratorBackend.h | 15 +++ main/communication/OrchestratorClient.cpp | 101 ++++++++++++-- main/communication/OrchestratorClient.h | 23 +++- main/communication/ThrottleBackend.h | 23 ++++ main/communication/WiThrottleBackend.cpp | 30 ++++- main/communication/WiThrottleBackend.h | 4 + main/communication/WiThrottleClient.cpp | 36 +++++ main/communication/WiThrottleClient.h | 8 ++ main/controller/ThrottleController.cpp | 146 ++++++++++++++++++--- main/controller/ThrottleController.h | 36 ++++- main/tests/OrchestratorProtocolTests.cpp | 38 ++++++ main/tests/ThrottleControllerTests.cpp | 128 +++++++++++++++++- main/ui/MainScreen.cpp | 34 ++++- main/ui/MainScreen.h | 1 + main/ui/UiTheme.h | 6 + main/ui/components/PowerStatusBar.cpp | 8 +- 26 files changed, 742 insertions(+), 72 deletions(-) diff --git a/.claude/agents/firmware-review.md b/.claude/agents/firmware-review.md index 609979a..fa858c9 100644 --- a/.claude/agents/firmware-review.md +++ b/.claude/agents/firmware-review.md @@ -36,9 +36,10 @@ exhaustive list. - Shared containers read and written from different tasks without a lock (F-07). - Accessors that hand out raw pointers to mutex-protected state (F-06). In this repo the surviving ones are guarded by `CONFIG_THROTTLE_TESTS` and must stay that way. -- **Lock ordering: `m_stateMutex` before `lvgl_port_lock`, never the reverse.** Any path - that takes them the other way is a deadlock, and it will present as a frozen screen with - a train still rolling. +- **Lock ordering: `lvgl_port_lock` before `m_stateMutex`, never the reverse.** The UI holds + the LVGL lock while it reads the controller, so any controller path that calls the UI + (`updateUI()`, the power callback) while holding `m_stateMutex` is a deadlock, and it will + present as a frozen screen with a train still rolling. (Documented backwards until F-29.) **LVGL** - An LVGL call from a network task, timer, or the encoder task without `lvgl_port_lock` diff --git a/CLAUDE.md b/CLAUDE.md index e1d39f4..f45817a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,9 +69,11 @@ skipped (speed, direction — the next one will land), `-1` only for critical on updates. F-14 removed an infinite lock from a frequently-called path; do not reintroduce that shape. -**3. Lock ordering is fixed: `m_stateMutex` before `lvgl_port_lock`, never the reverse.** -`ThrottleController` guards its own state with a mutex separate from the LVGL port lock. -Taking them in the other order deadlocks. +**3. Lock ordering is fixed: `lvgl_port_lock` before `m_stateMutex`, never the reverse.** +`ThrottleController` guards its own state with a mutex separate from the LVGL port lock. The +UI takes the LVGL lock and then reads the controller — every event handler, every repaint — +so the controller must never call out to the UI (`updateUI()`, the power callback) while +holding its mutex. Documented the other way round until F-29. **4. State lives at the application layer, never in the UI.** `AppController` owns the clients and `ThrottleController`; `ThrottleController` owns the `Throttle` / `Knob` / @@ -196,8 +198,9 @@ Things that look like bugs or oversights and are not. One line each. (F-18), not an oversight. - **`json_port` is normally discovered from the WiThrottle `PW` message**, not configured, which is why it has a default and no prominent UI field. -- **K1/K2 are disabled while WiThrottle is disconnected** (472a955) — a knob that still - turned would move a model that no longer tracks anything real. +- **K1/K2 are disabled while the transport is disconnected** (472a955, F-25) — a knob that + still turned would move a model that no longer tracks anything real. Enforced in + `ThrottleController`, because the physical encoders never pass through the UI. - **`sdkconfig.tests` is tracked despite appearing in `.gitignore`** — it predates the ignore rule. `sdkconfig.test.defaults` is the file that actually matters. - **`CONFIG_WS_BUFFER_SIZE=16384` is not oversizing.** It sizes the WebSocket *handshake* diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index e22735c..582da54 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -1,7 +1,7 @@ # Review Remediation Plan — Second Pass **Created:** 2026-09-24 -**Status:** In progress — Batches 1–3 (every HIGH) code done, awaiting their bench cycles +**Status:** In progress — Batches 1–4 code done (every HIGH and the operator-facing MEDIUMs), awaiting their bench cycles **Source:** Static review of the whole firmware (`main/`, build config, CI), with the orchestrator-facing behaviour checked against `bazauto/layout-orchestration`. Nothing was flashed during the review: every "bench" criterion below is unverified until someone runs it @@ -29,11 +29,11 @@ freeze while one is moving. | F-22 | [WiThrottle reconnect leaks a socket and loses the session](#f-22-withrottle-reconnect-leaks-a-socket-and-loses-the-session) | HIGH | Medium | 2 | Code done | #25 | | F-23 | [WiThrottle heartbeat never armed](#f-23-withrottle-heartbeat-never-armed) | HIGH | Small | 2 | Code done | #26 | | F-24 | [No automatic recovery after an ordinary outage](#f-24-no-automatic-recovery-after-an-ordinary-outage) | HIGH | Medium | 3 | Code done | #27 | -| F-25 | [Physical knobs not gated; optimistic update outlives a failed send](#f-25-physical-knobs-not-gated-optimistic-update-outlives-a-failed-send) | MEDIUM | Small | 4 | Open | #28 | -| F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Concurrent connect removed (batch 3); handle lock: open | #29 | -| F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Open | #30 | -| F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Open | #31 | -| F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Open | #32 | +| F-25 | [Physical knobs not gated; optimistic update outlives a failed send](#f-25-physical-knobs-not-gated-optimistic-update-outlives-a-failed-send) | MEDIUM | Small | 4 | Code done | #28 | +| F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Code done | #29 | +| F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Code done | #30 | +| F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Code done | #31 | +| F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Code done | #32 | | F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Slots: code done; sync: open | #33 | | F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Code done | #34 | | F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | #35 | @@ -351,8 +351,17 @@ command leaves the display ahead of the loco. The orchestrator refuses manual co #### Acceptance Criteria -- [ ] A knob turn while disconnected changes nothing. -- [ ] A refused send leaves the model where it was. +- [x] A knob turn or press while disconnected changes nothing (`knobInputAllowed`, in the + controller, so the physical encoders are covered). +- [x] A failed send rolls the model back — unless a transport report has landed since. +- [x] The orchestrator's case is a *refused* command, not a failed send: it answers `ERROR`. + `OrchestratorBackend` now re-seeds every assigned throttle from the layout's last + reported state on any `ERROR` (the F-20 cache makes that possible). +- [ ] Unit tests `test_controller_knob_ignored_while_disconnected`, + `test_controller_failed_send_rolls_back` and + `test_orch_backend_refusal_reseeds_from_layout_state` pass on the board (they compile). +- [ ] Bench: with the orchestrator `offline`, turn a knob; the display returns to the layout's + speed. Pull the WiFi and turn a physical knob; nothing moves on screen. --- @@ -382,7 +391,11 @@ remains. #### Acceptance Criteria -- [ ] No path reads `m_client` without the handle mutex. +- [x] No path reads `m_client` without the handle mutex: sends take it (bounded, and only + once the link is known up), `openSocket` publishes under it, and `disconnect()` holds it + across stop and destroy. +- [x] The connecting check need not be atomic any more: the supervisor is the only caller of + `connect()` (batch 3). - [ ] Bench: press Connect repeatedly while spinning a knob; no crash. --- @@ -404,10 +417,25 @@ when that link is down. 1. Add `emergencyStop()` to the `ThrottleBackend` port (orchestrator: `EMERGENCY_STOP`; WiThrottle: `X` on every acquired throttle) and a dedicated on-screen control. 2. An unknown power state turns power off, not on. +3. Under WiThrottle, an OFF goes over `PPA0` when the JSON link is down. **Files:** `ThrottleBackend.h`, both backends, `ThrottleController`, `MainScreen`, `PowerStatusBar.cpp` +#### Acceptance Criteria + +- [x] `ThrottleBackend::emergencyStop()`; `ThrottleController::emergencyStop()` shows the + throttles stopped only once it was sent. +- [x] An **E-STOP** button in the main screen's bottom row, in `UiTheme::BUTTON_EMERGENCY` — + the one saturated button — firing on press rather than on click. +- [x] A press on an unknown power state turns power off. +- [x] Under WiThrottle, OFF falls back to `PPA0` when the JSON link is down; ON still waits for + it. The button shows WiThrottle's `PPA` state when JSON has none. +- [ ] Unit test `test_controller_emergency_stop` passes on the board (it compiles). +- [ ] Bench: E-STOP under each transport; check the button's placement against the carousel + and the icon buttons (laid out without a screen to look at). +- [ ] Bench: stop JMRI's web server, press the power button; power goes off over WiThrottle. + --- ### F-28: Every function is momentary under the orchestrator @@ -428,6 +456,17 @@ toggles from the known state on press for state-style backends and ignores the r **Files:** `ThrottleBackend.h`, both backends, `ThrottleController`, `MainScreen.cpp`, tests +#### Acceptance Criteria + +- [x] `ThrottleBackend::functionCommandIsButtonEvent()`: true for WiThrottle, false for the + orchestrator. +- [x] The UI reports the button to `ThrottleController::onFunctionButton()`, which toggles on + press for a state-style transport and ignores the release. +- [ ] Unit tests `test_controller_function_press_release_for_event_backend`, + `test_controller_function_toggles_for_state_backend` and + `test_orch_backend_function_commands_are_states` pass on the board (they compile). +- [ ] Bench: under the orchestrator, tap F0; the headlight stays on until tapped again. + --- ### F-29: Documented lock order is the reverse of the code's @@ -449,6 +488,12 @@ claims to prevent. Correct CLAUDE.md, `THREADING_MODEL.md` (text and diagram), and any other copy of the rule to "LVGL lock before `m_stateMutex`; the controller never calls the UI while holding its mutex". +#### Acceptance Criteria + +- [x] CLAUDE.md hard constraint 3, `THREADING_MODEL.md` (text and sequence diagram), + `.claude/agents/firmware-review.md` and `CONTROLLER_LAYER.md` all state the order the code + uses. No copy of the old rule remains. + --- ### F-30: Callback slots unsynchronised and clobbered diff --git a/docs/architecture/THREADING_MODEL.md b/docs/architecture/THREADING_MODEL.md index 0849a5b..27edb19 100644 --- a/docs/architecture/THREADING_MODEL.md +++ b/docs/architecture/THREADING_MODEL.md @@ -101,16 +101,22 @@ sequenceDiagram activate TC Note over TC: xSemaphoreTake(m_stateMutex) TC->>TC: Update Throttle/Knob model + Note over TC: xSemaphoreGive(m_stateMutex) TC->>WT: setSpeed(throttleId, speed) TC->>UI: uiUpdateCallback() + deactivate TC Note over UI: lvgl_port_lock(200) + UI->>TC: getThrottleSnapshot() — takes m_stateMutex UI->>UI: updateAllThrottles() Note over UI: lvgl_port_unlock() - Note over TC: xSemaphoreGive(m_stateMutex) - deactivate TC ``` -**Lock ordering:** Always acquire `m_stateMutex` before `lvgl_port_lock` — never the reverse — to prevent deadlocks. +**Lock ordering:** `lvgl_port_lock` before `m_stateMutex`, never the reverse. The UI takes the +LVGL lock and then reads the controller — every LVGL event handler and every repaint does — so +the controller releases its mutex before it sends or calls the UI (`updateUI()`, the +track-power callback). A controller path that called the UI while holding its mutex would +deadlock against any event handler. This was documented the other way round until F-29; the +code has always done it this way. --- diff --git a/docs/components/COMMUNICATION_LAYER.md b/docs/components/COMMUNICATION_LAYER.md index c0cc342..b14765d 100644 --- a/docs/components/COMMUNICATION_LAYER.md +++ b/docs/components/COMMUNICATION_LAYER.md @@ -26,8 +26,26 @@ and that difference surfaces here rather than as a fake session. | `providesRoster()` | No selectable roster — the controller must not offer loco selection | | `providesFunctionLabels()` | UI falls back to `F0`…`F28` | | `requiresPolling()` | State arrives unprompted; no `throttle_poll` task is created | +| `functionCommandIsButtonEvent()` | `setFunction()` sets a state outright (orchestrator), so the controller toggles on press and ignores the release (F-28). True for WiThrottle, where JMRI applies latching to press and release | | `supportsTrackPower()` | The power button is **hidden**, not left dead | +### Emergency stop + +`emergencyStop()` stops everything the transport can, each in its own terms (F-27): the +orchestrator's `EMERGENCY_STOP` halts the **whole layout**; WiThrottle has no layout-wide +command, so it sends `X` to every loco this device holds. The controller shows the throttles +as stopped only once the stop has actually been sent. + +### Refused commands (orchestrator) + +The orchestrator answers a refused command — one sent while the system is `offline`, say — +with an `ERROR` that names no command. By then the controller has already shown what was asked +for, so `OrchestratorBackend` re-seeds every assigned throttle from the layout's last reported +state (F-25). The send itself succeeded, so rolling back on a failed send cannot catch this. + +`OrchestratorClient::m_client` is guarded by its own mutex, held across each send and across a +re-login's stop and destroy, so a re-login cannot free the handle under a sender (F-26). + ### Track power lives on this port too Strictly a layout command rather than a throttle one, but it rides the same diff --git a/docs/components/CONTROLLER_LAYER.md b/docs/components/CONTROLLER_LAYER.md index f83eed3..f7ede24 100644 --- a/docs/components/CONTROLLER_LAYER.md +++ b/docs/components/CONTROLLER_LAYER.md @@ -140,6 +140,10 @@ Fired after any state change. `MainScreen` registers this and calls `updateAllTh All public methods acquire `m_stateMutex` before accessing throttle/knob state. The LVGL port lock is **not** acquired inside `ThrottleController` — that's the UI's responsibility. +The order is `lvgl_port_lock`, then `m_stateMutex`: the UI reads the controller while holding the LVGL lock, so the controller always releases its mutex before it sends a command or calls the UI (F-29). + +Knob input is refused in the controller while the backend reports the link down, because the physical encoders call `onKnobRotation` / `onKnobPress` directly (F-25). A speed or stop command that fails is rolled back in the model, unless a transport report has landed since. + ### Polling Task `throttle_poll` (a FreeRTOS task, 4 KB, priority 3 — not an `esp_timer`) wakes every 10 s and diff --git a/docs/components/UI_LAYER.md b/docs/components/UI_LAYER.md index 0e93a0b..50f801a 100644 --- a/docs/components/UI_LAYER.md +++ b/docs/components/UI_LAYER.md @@ -81,7 +81,8 @@ the bottom. Every screen follows it. | `onReleaseButtonClicked` | "Release" button | `TC::onThrottleRelease()` | | `onVirtualEncoderRotation` | Virtual encoder ±buttons | `TC::onKnobRotation()` | | `onVirtualEncoderPress` | Virtual encoder press | `TC::onKnobPress()` | -| `onFunctionButtonClicked` | Function toggle | `TC::setFunction()` — through the port, not a client | +| `onFunctionButtonClicked` | Function button press / release | `TC::onFunctionButton()` — reports the button; the controller decides press/release or toggle for the transport (F-28) | +| `onEmergencyStopPressed` | **E-STOP** (bottom row, on press) | `TC::emergencyStop()` (F-27) | | `onSettingsButtonClicked` | Settings gear icon | Navigate to WiFiConfigScreen | | `onJmriButtonClicked` | Settings icon | Navigate to SettingsScreen | @@ -193,7 +194,8 @@ did nothing and the label read "Disconnected" while the layout was in fact conne - A transport answering `supportsTrackPower() == false` gets the button **hidden**, not left dead for the operator to press and wonder about. -- `TrackPower::UNKNOWN` renders as its own state ("Power ?"), not as off. +- `TrackPower::UNKNOWN` renders as its own state ("Power ?"), not as off — and a press on it + turns power **off**, not on. Only a known OFF turns it on (F-27). - The press returns immediately: the orchestrator's power command is a blocking HTTP round trip, so the write happens on a short-lived task (F-05). The button repaints when the layout says power changed, not when we asked. diff --git a/docs/flows/FUNCTION_CONTROL.md b/docs/flows/FUNCTION_CONTROL.md index f7fe278..f030b1c 100644 --- a/docs/flows/FUNCTION_CONTROL.md +++ b/docs/flows/FUNCTION_CONTROL.md @@ -72,6 +72,8 @@ sequenceDiagram ## Notes -- **Momentary vs latching:** The current implementation sends function ON on press and OFF on release (momentary behaviour). Some functions (e.g. headlight) may need latching — this depends on the JMRI/decoder configuration. +- **Momentary vs latching depends on the transport** (F-28). The UI reports the *button*, via `ThrottleController::onFunctionButton(throttle, fn, pressed)`, and the controller asks the port's `functionCommandIsButtonEvent()`: + - **WiThrottle** (true): ON at press, OFF at release. JMRI applies each function's own latching setting to that pair. + - **Orchestrator** (false): `FUNCTION_COMMAND` stores the state it is sent, so press-on/release-off made every function momentary. A press sends the opposite of the known state; the release sends nothing. The display follows the `LOCO_STATE` the orchestrator publishes after it. - **Scroll guard:** `FunctionPanel::isScrolling()` prevents accidental button presses while scrolling through the function list. - **Maximum functions:** 29 (F0–F28), matching DCC standard. diff --git a/docs/flows/TRACK_POWER.md b/docs/flows/TRACK_POWER.md index e756898..645ce1b 100644 --- a/docs/flows/TRACK_POWER.md +++ b/docs/flows/TRACK_POWER.md @@ -16,7 +16,7 @@ sequenceDiagram participant JMRI as JMRI Server User->>PSB: Press power button - PSB->>PSB: Toggle desired state + PSB->>PSB: OFF → ON; ON or UNKNOWN → OFF PSB->>JC: setPower(true) JC->>JMRI: Power command @@ -37,9 +37,23 @@ sequenceDiagram | `4` | OFF | Red/grey button | | `0` | UNKNOWN | Amber/warning | +## What a press does + +Only a known OFF turns power on. ON **and UNKNOWN** turn it off: the press may be a panic, and +energising rails nobody has reported on is the wrong way to fail (F-27). A second press, once +the state is known, turns it on. + ## Connection Dependency -Track power control requires the JMRI JSON WebSocket connection to be active. The `PowerStatusBar` displays the connection status alongside the power button. If the WebSocket is disconnected, the power button is non-functional. +Under WiThrottle, power normally goes over the JMRI JSON WebSocket, to the configured power +manager. When that link is down: + +- **OFF** still goes out, over WiThrottle's own `PPA0`, which switches off all power rather + than the named district. That is the safe direction to widen (F-27). +- **ON** waits for the JSON link, so it keeps meaning the configured district only. +- The button shows WiThrottle's last `PPA` state when the JSON link has none. + +For a stop that does not depend on power at all, use **E-STOP** on the main screen. ## Configured Power Manager diff --git a/main/communication/OrchestratorBackend.cpp b/main/communication/OrchestratorBackend.cpp index 263bd12..5d3a752 100644 --- a/main/communication/OrchestratorBackend.cpp +++ b/main/communication/OrchestratorBackend.cpp @@ -44,6 +44,10 @@ OrchestratorBackend::OrchestratorBackend(OrchestratorClient* client) [this](const OrchestratorClient::LocoState& state) { this->onLocoState(state); }); + m_client->setCommandRefusedCallback( + [this](const std::string& /*message*/) { + this->onCommandRefused(); + }); } OrchestratorBackend::~OrchestratorBackend() @@ -52,6 +56,7 @@ OrchestratorBackend::~OrchestratorBackend() // frame arriving mid-teardown lands in a half-destroyed callback. if (m_client) { m_client->setLocoStateCallback(nullptr); + m_client->setCommandRefusedCallback(nullptr); m_client->setConnectionStateCallback(nullptr); m_client->setTrackPowerCallback(nullptr); } @@ -277,6 +282,33 @@ esp_err_t OrchestratorBackend::refreshThrottleState(int /*throttleId*/) return ESP_OK; } +esp_err_t OrchestratorBackend::emergencyStop() +{ + if (!m_client) { + return ESP_ERR_INVALID_STATE; + } + return m_client->sendEmergencyStop(); +} + +void OrchestratorBackend::onCommandRefused() +{ + if (!m_client) { + return; + } + const ThrottleStateCallback callback = copyThrottleStateCallback(); + + for (int throttleId = 0; throttleId < MAX_THROTTLES; ++throttleId) { + const int address = addressFor(throttleId); + if (address == 0) { + continue; + } + OrchestratorClient::LocoState known; + if (m_client->getLastLocoState(address, known)) { + publishToThrottle(throttleId, known, callback); + } + } +} + size_t OrchestratorBackend::getRosterSize() const { return m_client ? m_client->getRosterSize() : 0; diff --git a/main/communication/OrchestratorBackend.h b/main/communication/OrchestratorBackend.h index 33b661b..2ff97a6 100644 --- a/main/communication/OrchestratorBackend.h +++ b/main/communication/OrchestratorBackend.h @@ -41,6 +41,9 @@ class OrchestratorBackend : public ThrottleBackend { /** State arrives unprompted as LOCO_STATE, so no polling task is created. */ bool requiresPolling() const override { return false; } + /** FUNCTION_COMMAND sets the function's state outright (F-28). */ + bool functionCommandIsButtonEvent() const override { return false; } + bool isConnected() const override; ConnectionState getState() const override; @@ -52,6 +55,9 @@ class OrchestratorBackend : public ThrottleBackend { esp_err_t setFunction(int throttleId, int function, bool state) override; esp_err_t refreshThrottleState(int throttleId) override; + /** EMERGENCY_STOP: halts the whole layout, not just this device's locos. */ + esp_err_t emergencyStop() override; + size_t getRosterSize() const override; bool getRosterEntry(int index, RosterEntry& outEntry) const override; @@ -88,6 +94,15 @@ class OrchestratorBackend : public ThrottleBackend { /** Routes an incoming LocoState to whichever throttles hold that address. */ void onLocoState(const OrchestratorClient::LocoState& state); + /** + * @brief Puts every throttle back to what the layout last reported. + * + * Called on an ERROR frame. The orchestrator does not say which command it + * refused, and the controller has already shown what was asked for, so the + * display is re-seeded from the layout's own state (F-25). + */ + void onCommandRefused(); + /** * @brief Applies a LocoState to one throttle: shadow first, then display. * diff --git a/main/communication/OrchestratorClient.cpp b/main/communication/OrchestratorClient.cpp index 432caad..dac516f 100644 --- a/main/communication/OrchestratorClient.cpp +++ b/main/communication/OrchestratorClient.cpp @@ -11,6 +11,13 @@ static const char* TAG = "OrchestratorClient"; namespace { +/** How long a sender waits for a re-login to finish with the socket handle. + * A send itself times out after 1 s, so this covers one in progress. */ +constexpr int CLIENT_LOCK_WAIT_MS = 1500; + +/** How long disconnect() waits for a send in progress to let go of the handle. */ +constexpr int CLIENT_LOCK_TEARDOWN_WAIT_MS = 5000; + /** Login and roster reads are small; this caps a hostile or broken response. */ constexpr int HTTP_RESPONSE_LIMIT = 8192; @@ -120,6 +127,7 @@ bool wireToSystemStatus(const std::string& wire, OrchestratorClient::SystemStatu OrchestratorClient::OrchestratorClient() : m_client(nullptr) + , m_clientMutex(nullptr) , m_state(ConnectionState::DISCONNECTED) , m_port(DEFAULT_PORT) , m_systemStatus(SystemStatus::UNKNOWN) @@ -136,6 +144,10 @@ OrchestratorClient::~OrchestratorClient() vSemaphoreDelete(m_stateMutex); m_stateMutex = nullptr; } + if (m_clientMutex) { + vSemaphoreDelete(m_clientMutex); + m_clientMutex = nullptr; + } } esp_err_t OrchestratorClient::initialize() @@ -147,6 +159,13 @@ esp_err_t OrchestratorClient::initialize() return ESP_ERR_NO_MEM; } } + if (!m_clientMutex) { + m_clientMutex = xSemaphoreCreateMutex(); + if (!m_clientMutex) { + ESP_LOGE(TAG, "Failed to create socket handle mutex"); + return ESP_ERR_NO_MEM; + } + } ESP_LOGI(TAG, "Orchestrator client initialised"); return ESP_OK; } @@ -402,32 +421,54 @@ esp_err_t OrchestratorClient::openSocket(const std::string& host, uint16_t port) // across several events; m_rxBuffer reassembles it. cfg.buffer_size = 4096; - m_client = esp_websocket_client_init(&cfg); - if (!m_client) { + esp_websocket_client_handle_t client = esp_websocket_client_init(&cfg); + if (!client) { ESP_LOGE(TAG, "Failed to create WebSocket client"); return ESP_FAIL; } - esp_websocket_register_events(m_client, WEBSOCKET_EVENT_ANY, websocketEventHandler, this); + esp_websocket_register_events(client, WEBSOCKET_EVENT_ANY, websocketEventHandler, this); ESP_LOGI(TAG, "Opening control plane at %s", m_uri.c_str()); - esp_err_t err = esp_websocket_client_start(m_client); + esp_err_t err = esp_websocket_client_start(client); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to start WebSocket client: %s", esp_err_to_name(err)); - esp_websocket_client_destroy(m_client); - m_client = nullptr; + esp_websocket_client_destroy(client); return err; } + + // Published only once it is running, and under the handle mutex (F-26). + if (m_clientMutex) { + xSemaphoreTake(m_clientMutex, portMAX_DELAY); + } + m_client = client; + if (m_clientMutex) { + xSemaphoreGive(m_clientMutex); + } return ESP_OK; } void OrchestratorClient::disconnect() { - if (m_client) { + // Held across stop and destroy, so no sender can be inside the handle + // while it is freed (F-26). Bounded: a sender holds it for at most one + // send, which times out after a second. + const bool haveClientLock = + m_clientMutex && + xSemaphoreTake(m_clientMutex, pdMS_TO_TICKS(CLIENT_LOCK_TEARDOWN_WAIT_MS)) == pdTRUE; + if (m_clientMutex && !haveClientLock) { + ESP_LOGE(TAG, "Socket handle still in use after %d ms; tearing down anyway", + CLIENT_LOCK_TEARDOWN_WAIT_MS); + } + esp_websocket_client_handle_t client = m_client; + m_client = nullptr; + if (client) { ESP_LOGI(TAG, "Disconnecting from orchestrator"); - esp_websocket_client_stop(m_client); - esp_websocket_client_destroy(m_client); - m_client = nullptr; + esp_websocket_client_stop(client); + esp_websocket_client_destroy(client); + } + if (haveClientLock) { + xSemaphoreGive(m_clientMutex); } if (lockState(pdMS_TO_TICKS(200))) { @@ -561,6 +602,15 @@ void OrchestratorClient::handleMessage(const std::string& json) } else { ESP_LOGE(TAG, "Orchestrator reported an error with no message"); } + + CommandRefusedCallback callback; + if (lockState(pdMS_TO_TICKS(50))) { + callback = m_commandRefusedCallback; + unlockState(); + } + if (callback) { + callback(message); + } } else { // Blocks, points, routes, sensors and faults are all real messages this // device has no use for. Not an error -- just not ours. @@ -938,13 +988,28 @@ void OrchestratorClient::testHandleMessage(const std::string& json) esp_err_t OrchestratorClient::sendJson(const std::string& json) { - if (!m_client || !isConnected()) { + // Checked first, without the handle mutex: while a re-login is under way + // the link is down, so senders leave here rather than queue behind it. + if (!isConnected()) { + ESP_LOGW(TAG, "Not connected to the orchestrator"); + return ESP_ERR_INVALID_STATE; + } + + if (!m_clientMutex || + xSemaphoreTake(m_clientMutex, pdMS_TO_TICKS(CLIENT_LOCK_WAIT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "Socket handle busy; frame not sent"); + return ESP_ERR_TIMEOUT; + } + esp_websocket_client_handle_t client = m_client; + if (!client) { + xSemaphoreGive(m_clientMutex); ESP_LOGW(TAG, "Not connected to the orchestrator"); return ESP_ERR_INVALID_STATE; } const int sent = esp_websocket_client_send_text( - m_client, json.c_str(), static_cast(json.size()), pdMS_TO_TICKS(1000)); + client, json.c_str(), static_cast(json.size()), pdMS_TO_TICKS(1000)); + xSemaphoreGive(m_clientMutex); if (sent < 0) { ESP_LOGE(TAG, "Failed to send frame"); @@ -1023,8 +1088,8 @@ esp_err_t OrchestratorClient::sendFunctionCommand(int locoAddress, int function, esp_err_t OrchestratorClient::sendEmergencyStop() { - // No payload by contract. Sent even when the link looks unhealthy -- the - // one command where trying and failing beats not trying. + // No payload by contract. With the socket down there is nothing to send + // it on; the caller reports that, and must not show the layout as stopped. ESP_LOGW(TAG, "Sending EMERGENCY_STOP"); return sendJson("{\"type\":\"EMERGENCY_STOP\"}"); } @@ -1267,6 +1332,14 @@ void OrchestratorClient::setRosterCallback(RosterCallback callback) } } +void OrchestratorClient::setCommandRefusedCallback(CommandRefusedCallback callback) +{ + if (lockState(pdMS_TO_TICKS(100))) { + m_commandRefusedCallback = std::move(callback); + unlockState(); + } +} + void OrchestratorClient::setTrackPowerCallback(TrackPowerCallback callback) { if (lockState(pdMS_TO_TICKS(100))) { diff --git a/main/communication/OrchestratorClient.h b/main/communication/OrchestratorClient.h index e839550..7142902 100644 --- a/main/communication/OrchestratorClient.h +++ b/main/communication/OrchestratorClient.h @@ -96,6 +96,8 @@ class OrchestratorClient { using SystemStatusCallback = std::function; using RosterCallback = std::function& roster)>; using TrackPowerCallback = std::function; + /** An ERROR frame: the orchestrator refused a command. Not tied to any one. */ + using CommandRefusedCallback = std::function; OrchestratorClient(); ~OrchestratorClient(); @@ -142,7 +144,7 @@ class OrchestratorClient { * @brief EMERGENCY_STOP. * * Deliberately the one command with no loco address: it halts the layout, - * not a loco. + * not a loco. Every role may send it. */ esp_err_t sendEmergencyStop(); @@ -201,6 +203,16 @@ class OrchestratorClient { void setRosterCallback(RosterCallback callback); void setTrackPowerCallback(TrackPowerCallback callback); + /** + * @brief Notified of every ERROR frame. + * + * The orchestrator answers a refused command -- one sent while the system + * is offline, say -- with an ERROR that names no command. The display may + * already show what was asked for, so the listener's job is to put back + * what the layout last reported (F-25). + */ + void setCommandRefusedCallback(CommandRefusedCallback callback); + /** Seconds since the last message of any kind. Large means a stale link. */ uint32_t secondsSinceLastMessage() const; @@ -279,7 +291,15 @@ class OrchestratorClient { int32_t eventId, void* eventData); + /** + * The WebSocket handle. Read by senders on the encoder and LVGL tasks, and + * stopped and destroyed by a re-login on the supervisor's -- so every use + * of it holds m_clientMutex, or a re-login frees it under a send (F-26). + * Nothing on the WebSocket's own task may take that mutex: disconnect() + * holds it while waiting for that task to stop. + */ esp_websocket_client_handle_t m_client; + mutable SemaphoreHandle_t m_clientMutex; ConnectionState m_state; std::string m_host; @@ -304,6 +324,7 @@ class OrchestratorClient { SystemStatusCallback m_systemStatusCallback; RosterCallback m_rosterCallback; TrackPowerCallback m_trackPowerCallback; + CommandRefusedCallback m_commandRefusedCallback; mutable SemaphoreHandle_t m_stateMutex; }; diff --git a/main/communication/ThrottleBackend.h b/main/communication/ThrottleBackend.h index 6207640..2013d89 100644 --- a/main/communication/ThrottleBackend.h +++ b/main/communication/ThrottleBackend.h @@ -124,6 +124,18 @@ class ThrottleBackend { */ virtual bool requiresPolling() const = 0; + /** + * @brief Whether setFunction() reports a button, or sets a state. + * + * True for WiThrottle: the UI sends on at press and off at release, and + * JMRI applies each function's own latching setting. False for a transport + * that stores what it is sent as the function's new state -- the + * orchestrator does -- where press-on/release-off makes every function + * momentary. There the controller toggles on press and ignores the release + * (F-28). + */ + virtual bool functionCommandIsButtonEvent() const = 0; + // --- Connection -------------------------------------------------------- virtual bool isConnected() const = 0; @@ -173,6 +185,17 @@ class ThrottleBackend { */ virtual esp_err_t refreshThrottleState(int throttleId) = 0; + /** + * @brief Stop everything this transport can, at once. + * + * Each transport answers in its own terms: the orchestrator's + * EMERGENCY_STOP halts the whole layout; WiThrottle has no layout-wide + * command, so it e-stops every loco this device has acquired. Either way + * it is the strongest stop available, which is what the button is for + * (F-27). + */ + virtual esp_err_t emergencyStop() = 0; + // --- Roster ------------------------------------------------------------ virtual size_t getRosterSize() const = 0; diff --git a/main/communication/WiThrottleBackend.cpp b/main/communication/WiThrottleBackend.cpp index 3f0c5be..e843882 100644 --- a/main/communication/WiThrottleBackend.cpp +++ b/main/communication/WiThrottleBackend.cpp @@ -42,6 +42,15 @@ esp_err_t WiThrottleBackend::setTrackPower(bool on) if (!m_jsonClient) { return ESP_ERR_NOT_SUPPORTED; } + + // An OFF must not depend on the JSON link. When that link is down it goes + // over WiThrottle's own PPA0 instead -- all power rather than the named + // district, which is the safe direction to widen. ON still waits for the + // JSON link, so it keeps meaning the configured district only (F-27). + if (!on && !m_jsonClient->isConnected() && m_client && m_client->isConnected()) { + ESP_LOGW(TAG, "JMRI JSON link down; sending track power OFF over WiThrottle (PPA0)"); + return m_client->setTrackPower("main", false); + } return m_jsonClient->setPower(on); } @@ -54,8 +63,19 @@ ThrottleBackend::TrackPower WiThrottleBackend::getTrackPower() const switch (m_jsonClient->getPower()) { case JmriJsonClient::PowerState::ON: return TrackPower::ON; case JmriJsonClient::PowerState::OFF: return TrackPower::OFF; - default: return TrackPower::UNKNOWN; + default: break; } + + // Nothing from the JSON link: fall back to what WiThrottle's PPA lines + // last said, rather than showing unknown while JMRI has told us. + if (m_client) { + switch (m_client->getTrackPower("main")) { + case WiThrottleClient::PowerState::ON: return TrackPower::ON; + case WiThrottleClient::PowerState::OFF: return TrackPower::OFF; + default: break; + } + } + return TrackPower::UNKNOWN; } void WiThrottleBackend::setTrackPowerCallback(TrackPowerCallback callback) @@ -158,6 +178,14 @@ esp_err_t WiThrottleBackend::refreshThrottleState(int throttleId) return (speedErr != ESP_OK) ? speedErr : dirErr; } +esp_err_t WiThrottleBackend::emergencyStop() +{ + if (!m_client) { + return ESP_ERR_INVALID_STATE; + } + return m_client->emergencyStopAll(); +} + size_t WiThrottleBackend::getRosterSize() const { return m_client ? m_client->getRosterSize() : 0; diff --git a/main/communication/WiThrottleBackend.h b/main/communication/WiThrottleBackend.h index 3dacc32..c3bf0a0 100644 --- a/main/communication/WiThrottleBackend.h +++ b/main/communication/WiThrottleBackend.h @@ -38,6 +38,9 @@ class WiThrottleBackend : public ThrottleBackend { bool providesFunctionLabels() const override { return true; } bool requiresPolling() const override { return true; } + /** JMRI applies each function's latching setting to a press and release. */ + bool functionCommandIsButtonEvent() const override { return true; } + bool isConnected() const override; ConnectionState getState() const override; @@ -47,6 +50,7 @@ class WiThrottleBackend : public ThrottleBackend { esp_err_t setDirection(int throttleId, bool forward) override; esp_err_t setFunction(int throttleId, int function, bool state) override; esp_err_t refreshThrottleState(int throttleId) override; + esp_err_t emergencyStop() override; size_t getRosterSize() const override; bool getRosterEntry(int index, RosterEntry& outEntry) const override; diff --git a/main/communication/WiThrottleClient.cpp b/main/communication/WiThrottleClient.cpp index d719818..406094b 100644 --- a/main/communication/WiThrottleClient.cpp +++ b/main/communication/WiThrottleClient.cpp @@ -482,6 +482,42 @@ esp_err_t WiThrottleClient::setFunction(char throttleId, int function, bool stat return sendCommand(command); } +esp_err_t WiThrottleClient::emergencyStopAll() +{ + if (!isConnected()) { + ESP_LOGW(TAG, "Not connected to server; emergency stop not sent"); + return ESP_ERR_INVALID_STATE; + } + + std::vector> held; + if (!lockState(pdMS_TO_TICKS(100))) { + ESP_LOGE(TAG, "Failed to lock state for emergency stop"); + return ESP_ERR_TIMEOUT; + } + for (const auto& entry : m_throttleStates) { + if (entry.second.acquired) { + held.emplace_back(entry.first, entry.second); + } + } + unlockState(); + + // Every loco is tried even if one fails: a stop that reaches three of four + // locos is better than one that gives up at the first. + esp_err_t result = ESP_OK; + for (const auto& entry : held) { + const std::string command = "M" + std::string(1, entry.first) + "A" + + std::string(1, entry.second.addressType) + + std::to_string(entry.second.address) + "<;>X"; + ESP_LOGW(TAG, "EMERGENCY STOP throttle %c (loco %c%d)", entry.first, + entry.second.addressType, entry.second.address); + const esp_err_t err = sendCommand(command); + if (err != ESP_OK && result == ESP_OK) { + result = err; + } + } + return result; +} + esp_err_t WiThrottleClient::querySpeed(char throttleId) { if (!isConnected()) { diff --git a/main/communication/WiThrottleClient.h b/main/communication/WiThrottleClient.h index 49e094e..cfcc4c9 100644 --- a/main/communication/WiThrottleClient.h +++ b/main/communication/WiThrottleClient.h @@ -194,6 +194,14 @@ class WiThrottleClient { * @return ESP_OK on success */ esp_err_t setFunction(char throttleId, int function, bool state); + + /** + * @brief Emergency-stop every loco this client holds (`MA<;>X`). + * + * WiThrottle has no layout-wide stop, so this is the strongest one it has. + * @return ESP_OK when every held loco was sent the stop (or none is held). + */ + esp_err_t emergencyStopAll(); /** * @brief Query locomotive speed diff --git a/main/controller/ThrottleController.cpp b/main/controller/ThrottleController.cpp index ecb5a0f..60d0acc 100644 --- a/main/controller/ThrottleController.cpp +++ b/main/controller/ThrottleController.cpp @@ -179,6 +179,10 @@ void ThrottleController::onKnobRotation(int knobId, int delta) if (delta > MAX_ROTATION_DELTA) delta = MAX_ROTATION_DELTA; if (delta < -MAX_ROTATION_DELTA) delta = -MAX_ROTATION_DELTA; + if (!knobInputAllowed(knobId)) { + return; + } + if (!lockState(pdMS_TO_TICKS(50))) { ESP_LOGW(TAG, "Failed to lock state for knob rotation"); return; @@ -245,13 +249,24 @@ void ThrottleController::onKnobRotation(int knobId, int delta) unlockState(); if (shouldSendSpeed && throttleId >= 0) { + esp_err_t err; if (shouldSendDirection) { // One movement, not two. Sending the new speed against the old // direction first would command the loco faster the way it was // already going, and only then reverse it. - sendSpeedAndDirectionCommand(throttleId, newSpeed, newDirection); + err = sendSpeedAndDirectionCommand(throttleId, newSpeed, newDirection); } else { - sendSpeedCommand(throttleId, newSpeed); + err = sendSpeedCommand(throttleId, newSpeed); + } + + // The model was updated before the send, for a responsive display. A + // send that failed must not leave it ahead of the loco: the next click + // would be computed from a speed the loco never had (F-25). + if (err != ESP_OK) { + ESP_LOGW(TAG, "Speed command for throttle %d failed (%s); display rolled back", + throttleId, esp_err_to_name(err)); + rollBackOptimisticUpdate(throttleId, newSpeed, newDirection, currentSpeed, + currentDirection); } ESP_LOGI(TAG, "Knob %d changed throttle %d speed: %d -> %d (dir: %s -> %s, steps: %d, optimistic + polling)", @@ -274,6 +289,10 @@ void ThrottleController::onKnobPress(int knobId) { if (knobId < 0 || knobId >= NUM_KNOBS) return; + if (!knobInputAllowed(knobId)) { + return; + } + if (!lockState(pdMS_TO_TICKS(50))) { ESP_LOGW(TAG, "Failed to lock state for knob press"); return; @@ -313,9 +332,13 @@ void ThrottleController::onKnobPress(int knobId) } else if (knob->getState() == Knob::State::CONTROLLING) { // Normal stop (set speed to 0 with optimistic UI update) int throttleId = knob->getAssignedThrottleId(); + int previousSpeed = 0; + bool previousForward = true; if (throttleId >= 0) { Throttle* throttle = m_throttles[throttleId].get(); if (throttle) { + previousSpeed = throttle->getCurrentSpeed(); + previousForward = throttle->getDirection(); throttle->setSpeed(0); } } @@ -323,8 +346,17 @@ void ThrottleController::onKnobPress(int knobId) unlockState(); if (throttleId >= 0) { - sendSpeedCommand(throttleId, 0); - ESP_LOGI(TAG, "Knob %d stop on throttle %d", knobId, throttleId); + const esp_err_t err = sendSpeedCommand(throttleId, 0); + if (err != ESP_OK) { + // Showing a stop that never reached the loco is the worst + // thing the display could do here. + ESP_LOGE(TAG, "Stop for throttle %d was not sent (%s)", throttleId, + esp_err_to_name(err)); + rollBackOptimisticUpdate(throttleId, 0, previousForward, previousSpeed, + previousForward); + } else { + ESP_LOGI(TAG, "Knob %d stop on throttle %d", knobId, throttleId); + } updateUI(); } return; @@ -372,6 +404,92 @@ void ThrottleController::onThrottleFunctions(int throttleId) ESP_LOGI(TAG, "Functions button pressed for throttle %d", throttleId); } +void ThrottleController::onFunctionButton(int throttleId, int functionNumber, bool pressed) +{ + if (!m_backend || throttleId < 0 || throttleId >= NUM_THROTTLES) { + return; + } + + if (m_backend->functionCommandIsButtonEvent()) { + setFunction(throttleId, functionNumber, pressed); + return; + } + + // The transport stores what it is sent, so the press decides the new + // state and the release means nothing. Sending "off" on release made every + // function momentary: a headlight lit only while a finger held it (F-28). + if (!pressed) { + return; + } + bool current = false; // A function nothing has reported reads as off. + getFunctionState(throttleId, functionNumber, current); + setFunction(throttleId, functionNumber, !current); +} + +void ThrottleController::emergencyStop() +{ + if (!m_backend) { + return; + } + + ESP_LOGW(TAG, "EMERGENCY STOP requested"); + const esp_err_t err = m_backend->emergencyStop(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Emergency stop could not be sent: %s", esp_err_to_name(err)); + return; + } + + // Sent: show it. The transport's own reports follow and confirm it. + if (lockState(pdMS_TO_TICKS(100))) { + for (auto& throttle : m_throttles) { + if (throttle->hasLocomotive()) { + throttle->setSpeed(0); + } + } + unlockState(); + } + updateUI(); +} + +bool ThrottleController::knobInputAllowed(int knobId) +{ + if (isConnected()) { + return true; + } + + // The touch UI greys the knob pips out while the link is down, but the + // physical encoders come straight here. A knob that still turned would move + // a model that no longer tracks anything real, and the first click after + // reconnecting would jump from it (F-25). + static int64_t lastWarnMs = 0; + const int64_t nowMs = esp_log_timestamp(); + if (nowMs - lastWarnMs > 1000) { + ESP_LOGW(TAG, "Ignoring knob %d while the transport is disconnected", knobId); + lastWarnMs = nowMs; + } + return false; +} + +void ThrottleController::rollBackOptimisticUpdate(int throttleId, int attemptedSpeed, + bool attemptedForward, int previousSpeed, + bool previousForward) +{ + if (throttleId < 0 || throttleId >= NUM_THROTTLES) { + return; + } + if (!lockState(pdMS_TO_TICKS(50))) { + ESP_LOGW(TAG, "Failed to lock state to roll back throttle %d", throttleId); + return; + } + Throttle* throttle = m_throttles[throttleId].get(); + if (throttle->getCurrentSpeed() == attemptedSpeed && + throttle->getDirection() == attemptedForward) { + throttle->setSpeed(previousSpeed); + throttle->setDirection(previousForward); + } + unlockState(); +} + #if CONFIG_THROTTLE_TESTS Throttle* ThrottleController::getThrottle(int throttleId) { @@ -614,28 +732,20 @@ bool ThrottleController::getFunctionState(int throttleId, int functionNumber, bo return false; } -void ThrottleController::sendSpeedCommand(int throttleId, int speed) -{ - if (!m_backend) { - return; - } - m_backend->setSpeed(throttleId, speed); -} - -void ThrottleController::sendDirectionCommand(int throttleId, bool forward) +esp_err_t ThrottleController::sendSpeedCommand(int throttleId, int speed) { if (!m_backend) { - return; + return ESP_ERR_INVALID_STATE; } - m_backend->setDirection(throttleId, forward); + return m_backend->setSpeed(throttleId, speed); } -void ThrottleController::sendSpeedAndDirectionCommand(int throttleId, int speed, bool forward) +esp_err_t ThrottleController::sendSpeedAndDirectionCommand(int throttleId, int speed, bool forward) { if (!m_backend) { - return; + return ESP_ERR_INVALID_STATE; } - m_backend->setSpeedAndDirection(throttleId, speed, forward); + return m_backend->setSpeedAndDirection(throttleId, speed, forward); } std::unique_ptr ThrottleController::createLocomotiveFromRoster(const ThrottleBackend::RosterEntry& rosterEntry) diff --git a/main/controller/ThrottleController.h b/main/controller/ThrottleController.h index f4fb37c..4528e2d 100644 --- a/main/controller/ThrottleController.h +++ b/main/controller/ThrottleController.h @@ -92,6 +92,25 @@ class ThrottleController * @param throttleId Throttle ID (0-3) */ void onThrottleFunctions(int throttleId); + + /** + * @brief A function button was pressed or released. + * + * What that means depends on the transport, so the UI reports the button + * and this decides. Press/release goes straight through where the + * transport applies latching itself (WiThrottle); where it stores the + * state it is sent (the orchestrator), a press toggles and a release does + * nothing (F-28). + */ + void onFunctionButton(int throttleId, int functionNumber, bool pressed); + + /** + * @brief Stop everything the transport can, at once (F-27). + * + * Every throttle shows speed 0 only once the stop has been sent: showing a + * stop that never left the device would be worse than showing nothing. + */ + void emergencyStop(); #if CONFIG_THROTTLE_TESTS /** @@ -201,10 +220,21 @@ class ThrottleController void unlockState() const; void updateUI(); - void sendSpeedCommand(int throttleId, int speed); - void sendDirectionCommand(int throttleId, bool forward); + esp_err_t sendSpeedCommand(int throttleId, int speed); /** Used whenever both change at once — see ThrottleBackend for why. */ - void sendSpeedAndDirectionCommand(int throttleId, int speed, bool forward); + esp_err_t sendSpeedAndDirectionCommand(int throttleId, int speed, bool forward); + + /** + * @brief Undo an optimistic speed/direction change whose command failed. + * + * Only when the model still holds what we set: a transport report that + * landed in between is newer than either value and stays (F-25). + */ + void rollBackOptimisticUpdate(int throttleId, int attemptedSpeed, bool attemptedForward, + int previousSpeed, bool previousForward); + + /** Knob input is refused while the link is down; says so, at most once a second. */ + bool knobInputAllowed(int knobId); std::unique_ptr createLocomotiveFromRoster(const ThrottleBackend::RosterEntry& rosterEntry); // Backend callback handlers diff --git a/main/tests/OrchestratorProtocolTests.cpp b/main/tests/OrchestratorProtocolTests.cpp index 2c94328..d5966e9 100644 --- a/main/tests/OrchestratorProtocolTests.cpp +++ b/main/tests/OrchestratorProtocolTests.cpp @@ -460,6 +460,42 @@ static void test_orch_snapshot_replaces_cached_state(void) TEST_ASSERT_FALSE(client.getLastLocoState(4472, state)); } +static void test_orch_backend_refusal_reseeds_from_layout_state(void) +{ + OrchestratorClient client; + client.initialize(); + OrchestratorBackend backend(&client); + + std::vector updates; + backend.setThrottleStateCallback( + [&updates](const ThrottleBackend::ThrottleUpdate& u) { updates.push_back(u); }); + + client.testHandleMessage(SNAPSHOT_WITH_4472_RUNNING); + backend.acquireLocomotive(1, 4472, true); + updates.clear(); + + // An ERROR names no command, but the display may already show what was + // asked for. It is put back to what the layout last reported (F-25). + client.testHandleMessage( + "{\"type\":\"ERROR\",\"payload\":{\"message\":\"Cannot issue command: system is offline\"}}"); + + TEST_ASSERT_GREATER_THAN_INT(0, (int)updates.size()); + TEST_ASSERT_EQUAL_INT(1, updates[0].throttleId); + TEST_ASSERT_EQUAL_INT(60, updates[0].speed); + TEST_ASSERT_EQUAL_INT(0, updates[0].direction); +} + +static void test_orch_backend_function_commands_are_states(void) +{ + OrchestratorClient client; + client.initialize(); + OrchestratorBackend backend(&client); + + // FUNCTION_COMMAND sets the state outright, so the controller must toggle + // rather than send press and release (F-28). + TEST_ASSERT_FALSE(backend.functionCommandIsButtonEvent()); +} + extern "C" void register_orchestrator_tests(void) { RUN_TEST(test_orch_cookie_extracted_from_set_cookie); @@ -486,4 +522,6 @@ extern "C" void register_orchestrator_tests(void) RUN_TEST(test_orch_backend_acquire_seeds_from_layout_state); RUN_TEST(test_orch_backend_acquire_of_unreported_loco_shows_nothing); RUN_TEST(test_orch_snapshot_replaces_cached_state); + RUN_TEST(test_orch_backend_refusal_reseeds_from_layout_state); + RUN_TEST(test_orch_backend_function_commands_are_states); } diff --git a/main/tests/ThrottleControllerTests.cpp b/main/tests/ThrottleControllerTests.cpp index 41adb77..96390ba 100644 --- a/main/tests/ThrottleControllerTests.cpp +++ b/main/tests/ThrottleControllerTests.cpp @@ -34,7 +34,13 @@ namespace { bool rosterProvided = true; bool labelsProvided = true; bool pollingRequired = true; + bool functionButtonEvents = true; bool connected = true; + + // What the commands report back, so a test can make one fail. + esp_err_t speedResult = ESP_OK; + esp_err_t emergencyResult = ESP_OK; + int emergencyStops = 0; std::vector roster; std::vector acquires; @@ -49,6 +55,7 @@ namespace { bool providesRoster() const override { return rosterProvided; } bool providesFunctionLabels() const override { return labelsProvided; } bool requiresPolling() const override { return pollingRequired; } + bool functionCommandIsButtonEvent() const override { return functionButtonEvents; } bool isConnected() const override { return connected; } ConnectionState getState() const override { @@ -65,7 +72,7 @@ namespace { } esp_err_t setSpeed(int throttleId, int speed) override { speeds.push_back({throttleId, speed, false}); - return ESP_OK; + return speedResult; } esp_err_t setDirection(int throttleId, bool forward) override { directions.push_back({throttleId, 0, forward}); @@ -75,7 +82,7 @@ namespace { // ones; the port's default would show up as a direction then a speed. esp_err_t setSpeedAndDirection(int throttleId, int speed, bool forward) override { pairedCommands.push_back({throttleId, speed, forward}); - return ESP_OK; + return speedResult; } esp_err_t setFunction(int throttleId, int function, bool state) override { functions.push_back({throttleId, function, state}); @@ -85,6 +92,10 @@ namespace { refreshes.push_back({throttleId, 0, false}); return ESP_OK; } + esp_err_t emergencyStop() override { + emergencyStops++; + return emergencyResult; + } size_t getRosterSize() const override { return roster.size(); } bool getRosterEntry(int index, RosterEntry& outEntry) const override { @@ -580,6 +591,114 @@ static void test_controller_extreme_delta_is_well_defined(void) TEST_ASSERT_EQUAL_INT(1, (int)(backend.speeds.size() + backend.pairedCommands.size())); } +static void test_controller_knob_ignored_while_disconnected(void) +{ + FakeThrottleBackend backend; + ThrottleController controller(&backend); + + setupThrottleWithLoco(controller, 0, 0, "LocoL", 12); + Throttle* throttle = controller.getThrottle(0); + TEST_ASSERT_NOT_NULL(throttle); + throttle->setSpeed(20); + + // The physical encoders reach the controller directly, so the gate has to + // be here, not only on the touch pips (F-25). + backend.connected = false; + controller.onKnobRotation(0, 3); + controller.onKnobPress(0); + + TEST_ASSERT_EQUAL_INT(20, throttle->getCurrentSpeed()); + TEST_ASSERT_EQUAL_INT(0, (int)(backend.speeds.size() + backend.pairedCommands.size())); +} + +static void test_controller_failed_send_rolls_back(void) +{ + FakeThrottleBackend backend; + ThrottleController controller(&backend); + + setupThrottleWithLoco(controller, 0, 0, "LocoM", 13); + Throttle* throttle = controller.getThrottle(0); + TEST_ASSERT_NOT_NULL(throttle); + throttle->setSpeed(20); + throttle->setDirection(true); + + // A refused command must not leave the display ahead of the loco: the + // next click would be computed from a speed it never had (F-25). + backend.speedResult = ESP_FAIL; + controller.onKnobRotation(0, 2); + TEST_ASSERT_EQUAL_INT(20, throttle->getCurrentSpeed()); + TEST_ASSERT_TRUE(throttle->getDirection()); + + // Nor a stop that never left the device. + controller.onKnobPress(0); + TEST_ASSERT_EQUAL_INT(20, throttle->getCurrentSpeed()); +} + +static void test_controller_function_press_release_for_event_backend(void) +{ + FakeThrottleBackend backend; + backend.functionButtonEvents = true; + ThrottleController controller(&backend); + setupThrottleWithLoco(controller, 0, 0, "LocoN", 14); + + // WiThrottle: press and release go through; JMRI applies latching. + controller.onFunctionButton(0, 2, true); + controller.onFunctionButton(0, 2, false); + + TEST_ASSERT_EQUAL_INT(2, (int)backend.functions.size()); + TEST_ASSERT_TRUE(backend.functions[0].boolArg); + TEST_ASSERT_FALSE(backend.functions[1].boolArg); +} + +static void test_controller_function_toggles_for_state_backend(void) +{ + FakeThrottleBackend backend; + backend.functionButtonEvents = false; + ThrottleController controller(&backend); + setupThrottleWithLoco(controller, 0, 0, "LocoO", 15); + + // The orchestrator stores what it is sent: a press toggles, a release is + // nothing. Press-on/release-off made every function momentary (F-28). + controller.onFunctionButton(0, 0, true); + controller.onFunctionButton(0, 0, false); + TEST_ASSERT_EQUAL_INT(1, (int)backend.functions.size()); + TEST_ASSERT_TRUE(backend.functions[0].boolArg); + + // Once the layout reports it on, the next press turns it off. + ThrottleBackend::ThrottleUpdate update; + update.throttleId = 0; + update.address = 15; + update.function = 0; + update.functionState = true; + backend.emitThrottleUpdate(update); + + controller.onFunctionButton(0, 0, true); + TEST_ASSERT_EQUAL_INT(2, (int)backend.functions.size()); + TEST_ASSERT_FALSE(backend.functions[1].boolArg); +} + +static void test_controller_emergency_stop(void) +{ + FakeThrottleBackend backend; + ThrottleController controller(&backend); + setupThrottleWithLoco(controller, 0, 0, "LocoP", 16); + Throttle* throttle = controller.getThrottle(0); + TEST_ASSERT_NOT_NULL(throttle); + + // Not sent: the display must not claim a stop (F-27). + throttle->setSpeed(50); + backend.emergencyResult = ESP_FAIL; + controller.emergencyStop(); + TEST_ASSERT_EQUAL_INT(1, backend.emergencyStops); + TEST_ASSERT_EQUAL_INT(50, throttle->getCurrentSpeed()); + + // Sent: every allocated throttle shows stopped. + backend.emergencyResult = ESP_OK; + controller.emergencyStop(); + TEST_ASSERT_EQUAL_INT(2, backend.emergencyStops); + TEST_ASSERT_EQUAL_INT(0, throttle->getCurrentSpeed()); +} + extern "C" void register_controller_tests(void) { RUN_TEST(test_controller_assign_knob_to_unallocated); @@ -603,4 +722,9 @@ extern "C" void register_controller_tests(void) RUN_TEST(test_controller_hides_track_power_when_unsupported); RUN_TEST(test_encoder_implausible_delta_is_refused); RUN_TEST(test_controller_extreme_delta_is_well_defined); + RUN_TEST(test_controller_knob_ignored_while_disconnected); + RUN_TEST(test_controller_failed_send_rolls_back); + RUN_TEST(test_controller_function_press_release_for_event_backend); + RUN_TEST(test_controller_function_toggles_for_state_backend); + RUN_TEST(test_controller_emergency_stop); } diff --git a/main/ui/MainScreen.cpp b/main/ui/MainScreen.cpp index 8134eda..7c46df9 100644 --- a/main/ui/MainScreen.cpp +++ b/main/ui/MainScreen.cpp @@ -222,6 +222,32 @@ void MainScreen::createSettingsButton() lv_label_set_text(jmriLabel, LV_SYMBOL_SETTINGS); lv_obj_center(jmriLabel); + // Emergency stop, in the same bottom row, filling the right half's space + // to the left of the two icon buttons (F-27). Always enabled: a stop that + // cannot be sent says so in the log rather than hiding the button. + lv_obj_t* stopButton = lv_btn_create(m_screen); + lv_obj_set_size(stopButton, 220, 44); + lv_obj_align(stopButton, LV_ALIGN_BOTTOM_MID, 120, -8); + lv_obj_set_style_bg_color(stopButton, UiTheme::colour(UiTheme::BUTTON_EMERGENCY), 0); + // On press, not on click: a click needs the release to land on the + // button too, and a hand in a hurry slides off it. + lv_obj_add_event_cb(stopButton, onEmergencyStopPressed, LV_EVENT_PRESSED, this); + + lv_obj_t* stopLabel = lv_label_create(stopButton); + lv_label_set_text(stopLabel, LV_SYMBOL_STOP " E-STOP"); + lv_obj_set_style_text_font(stopLabel, &lv_font_montserrat_20, 0); + lv_obj_center(stopLabel); +} + +void MainScreen::onEmergencyStopPressed(lv_event_t* e) +{ + MainScreen* screen = static_cast(lv_event_get_user_data(e)); + if (!screen || !screen->m_throttleController) { + return; + } + // Sends at once; the backend's send is bounded, so this does not stall the + // LVGL task the way a connect or an HTTP call would. + screen->m_throttleController->emergencyStop(); } void MainScreen::onJmriButtonClicked(lv_event_t* e) @@ -431,10 +457,10 @@ void MainScreen::onFunctionButtonClicked(lv_event_t* e) return; } - // Through the controller, so the press reaches whichever transport is in - // use. Reaching into WiThrottleClient here sent function presses to a - // client that was not even connected under the orchestrator transport. - screen->m_throttleController->setFunction(throttleId, functionNumber, newState); + // The button, not a state: the controller knows whether this transport + // wants press and release or a toggle (F-28). Through the controller also + // means it reaches whichever transport is in use. + screen->m_throttleController->onFunctionButton(throttleId, functionNumber, newState); // Wait for transport updates to drive UI state } diff --git a/main/ui/MainScreen.h b/main/ui/MainScreen.h index e19c810..c176a17 100644 --- a/main/ui/MainScreen.h +++ b/main/ui/MainScreen.h @@ -72,6 +72,7 @@ class MainScreen { // Event handlers static void onSettingsButtonClicked(lv_event_t* e); static void onJmriButtonClicked(lv_event_t* e); + static void onEmergencyStopPressed(lv_event_t* e); // LVGL UI components lv_obj_t* m_screen; diff --git a/main/ui/UiTheme.h b/main/ui/UiTheme.h index 8eaeca3..2a83e28 100644 --- a/main/ui/UiTheme.h +++ b/main/ui/UiTheme.h @@ -49,6 +49,12 @@ constexpr uint32_t BUTTON_CAUTION = 0x8a5a24; /** Back, cancel, and anything inactive. */ constexpr uint32_t BUTTON_NEUTRAL = 0x555555; +/** + * Emergency stop. The one saturated button, deliberately: everything else is + * muted so that this is the thing a hand finds without looking. + */ +constexpr uint32_t BUTTON_EMERGENCY = 0xd62828; + // --- State indicators ------------------------------------------------------ // // Used on small widgets that signal state rather than invite a press: knob diff --git a/main/ui/components/PowerStatusBar.cpp b/main/ui/components/PowerStatusBar.cpp index a052d3b..12949b2 100644 --- a/main/ui/components/PowerStatusBar.cpp +++ b/main/ui/components/PowerStatusBar.cpp @@ -103,11 +103,11 @@ void PowerStatusBar::onTrackPowerClicked(lv_event_t* e) return; } - // UNKNOWN turns power on: the useful thing to do when nobody has said what - // the rails are doing is to energise them, and the operator can press - // again to turn it off once the state is known. + // Only a known OFF turns power on. UNKNOWN turns it off: the press may be + // a panic, and energising rails nobody has reported on is the wrong way to + // fail. A second press, once the state is known, turns it on (F-27). const ThrottleBackend::TrackPower current = bar->m_throttleController->getTrackPower(); - const bool newState = (current != ThrottleBackend::TrackPower::ON); + const bool newState = (current == ThrottleBackend::TrackPower::OFF); ESP_LOGI(TAG, "Toggling track power: %s", newState ? "ON" : "OFF"); From 1537db5e54a78f14c3c35d330da8cf397aa5f9aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:27:56 +0000 Subject: [PATCH 08/12] docs: F-26 progress note and F-29 status follow batch 4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- docs/REVIEW_REMEDIATION_PLAN_2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index 582da54..06f1777 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -33,7 +33,7 @@ freeze while one is moving. | F-26 | [`OrchestratorClient::m_client` destroyed under a sender](#f-26-orchestratorclientm_client-destroyed-under-a-sender) | MEDIUM | Medium | 4 | Code done | #29 | | F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Code done | #30 | | F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Code done | #31 | -| F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Code done | #32 | +| F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Code done (docs only; no bench check) | #32 | | F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Slots: code done; sync: open | #33 | | F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Code done | #34 | | F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | #35 | @@ -384,8 +384,8 @@ bounded at 1 s), and make the connecting check atomic. **Progress:** Batch 3 made the `orch_connect` supervisor the only caller of `connect()` — the config screen now wakes it rather than connecting on a task of its own — so two connects can -no longer race. A re-login still destroys the handle under a concurrent sender; that half -remains. +no longer race. Batch 4 added the handle mutex, so a re-login can no longer free the handle +under a sender. **Files:** `OrchestratorClient.h/.cpp` From 29b54d268db153c11b3331ded4f458807bf65542 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:56:05 +0000 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20synchronise=20callbacks,=20stream?= =?UTF-8?q?=20the=20roster,=20write=20NVS=20off=20the=20LVGL=20task=20(F-3?= =?UTF-8?q?0=E2=80=A6F-42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 5 of the second review pass: the remaining MEDIUM and LOW findings. - F-30: every callback slot on the clients, both backends and the controller is a CallbackSlot, set under its own lock and invoked as a copy with the lock released. The controller's function-and-userData pairs become one closure each, so neither half can be replaced alone. - F-33: CONFIG_THROTTLE_STACK_REPORT (off by default) logs each task's stack high-water mark for sizing on the bench. Nothing resized blind. - F-35: the orchestrator roster is streamed through JsonArraySplitter and parsed a record at a time, so an 8 KB buffer no longer refuses any roster above about 35 locos. Capped at 128 locos and 4 KB a record. - F-37: a transport report is applied only to the throttle that holds the loco it names; speeds over 126 and functions over F28 are dropped. - F-38: per-line and per-tick logs are DEBUG; the power bar refreshes once per repaint, and unchanged widgets are not restyled. - F-39: SettingsWriter, one task and a queue, carries out every NVS write the UI asks for, in order. It replaces the one-shot jmri_save task. - F-41: Seesaw reads are request, 500 us wait, read, done once, instead of a combined write-read done twice, which lost rotation between reads. - F-42: OrchestratorClient keeps its link, system and power states as atomics and writes no shared string unlocked; WebSocket continuation frames are reassembled; the layout id is dropped on every login. requestPowerList is gone, the JMRI power name is locked, and the WiThrottle roster warning no longer reads past the end. - F-44: the threading model's task table is brought up to date. Compiled for the app and test configurations; nothing here has run on the board. The Seesaw change in particular needs a bench check before merging. Refs #33, #36, #38, #40, #41, #42, #44, #45, #47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- docs/architecture/NVS_STORAGE.md | 21 +- docs/architecture/THREADING_MODEL.md | 37 +- docs/components/COMMUNICATION_LAYER.md | 36 +- docs/components/CONTROLLER_LAYER.md | 41 +- docs/components/HARDWARE_LAYER.md | 6 + docs/components/UI_LAYER.md | 8 +- docs/flows/CONNECTION_FLOW.md | 2 +- main/CMakeLists.txt | 5 + main/Kconfig.projbuild | 11 + main/communication/JmriJsonClient.cpp | 70 ++-- main/communication/JmriJsonClient.h | 23 +- main/communication/JsonArraySplitter.cpp | 138 +++++++ main/communication/JsonArraySplitter.h | 73 ++++ main/communication/OrchestratorBackend.cpp | 47 +-- main/communication/OrchestratorBackend.h | 10 +- main/communication/OrchestratorClient.cpp | 404 +++++++++---------- main/communication/OrchestratorClient.h | 31 +- main/communication/WiFiManager.cpp | 9 +- main/communication/WiFiManager.h | 4 +- main/communication/WiThrottleBackend.cpp | 35 +- main/communication/WiThrottleBackend.h | 10 +- main/communication/WiThrottleClient.cpp | 46 +-- main/communication/WiThrottleClient.h | 27 +- main/controller/AppController.cpp | 30 +- main/controller/AppController.h | 4 + main/controller/JmriConnectionController.cpp | 25 +- main/controller/JmriConnectionController.h | 6 +- main/controller/SettingsWriter.cpp | 88 ++++ main/controller/SettingsWriter.h | 55 +++ main/controller/ThrottleController.cpp | 71 ++-- main/controller/ThrottleController.h | 11 +- main/hardware/RotaryEncoderHal.cpp | 31 +- main/tests/OrchestratorProtocolTests.cpp | 123 ++++++ main/tests/ThrottleControllerTests.cpp | 72 ++++ main/ui/MainScreen.cpp | 14 +- main/ui/OrchestratorConfigScreen.cpp | 50 ++- main/ui/OrchestratorConfigScreen.h | 6 +- main/ui/SettingsScreen.cpp | 49 ++- main/ui/SettingsScreen.h | 6 +- main/ui/WiFiConfigScreen.cpp | 16 +- main/ui/WiFiConfigScreen.h | 6 +- main/ui/components/PowerStatusBar.cpp | 12 + main/ui/components/PowerStatusBar.h | 5 + main/ui/components/ThrottleMeter.cpp | 16 +- main/ui/components/ThrottleMeter.h | 3 + main/utils/CallbackSlot.h | 78 ++++ main/utils/StackReport.cpp | 75 ++++ main/utils/StackReport.h | 23 ++ 48 files changed, 1467 insertions(+), 502 deletions(-) create mode 100644 main/communication/JsonArraySplitter.cpp create mode 100644 main/communication/JsonArraySplitter.h create mode 100644 main/controller/SettingsWriter.cpp create mode 100644 main/controller/SettingsWriter.h create mode 100644 main/utils/CallbackSlot.h create mode 100644 main/utils/StackReport.cpp create mode 100644 main/utils/StackReport.h diff --git a/docs/architecture/NVS_STORAGE.md b/docs/architecture/NVS_STORAGE.md index 63aa385..8f06faa 100644 --- a/docs/architecture/NVS_STORAGE.md +++ b/docs/architecture/NVS_STORAGE.md @@ -12,15 +12,24 @@ All persistent configuration is stored in ESP-IDF's NVS (Non-Volatile Storage). | `jmri` | `wt_port` | string | `"12090"` | `JmriConnectionController` | `JmriConnectionController`, `JmriConfigScreen` | | `jmri` | `json_port` | string | `"12080"` | `JmriConnectionController` | `JmriConnectionController` | | `jmri` | `power_mgr` | string | `"DCC++"` | `JmriConnectionController` | `JmriConnectionController`, `JmriConfigScreen` | -| `jmri` | `speed_steps` | i32 | `4` | `JmriConfigScreen` | `ThrottleController` | -| `orch` | `transport` | u8 | `0` (WiThrottle) | `JmriConfigScreen` | `AppController` | -| `orch` | `host` | string | — | `OrchestratorConfigScreen` | `AppController` | -| `orch` | `port` | u16 | `3000` | `OrchestratorConfigScreen` | `AppController` | -| `orch` | `user` | string | — | `OrchestratorConfigScreen` | `AppController` | -| `orch` | `pass` | string | — | `OrchestratorConfigScreen` | `AppController` | +| `jmri` | `speed_steps` | i32 | `4` | `SettingsScreen`, through `SettingsWriter` | `ThrottleController` | +| `orch` | `transport` | u8 | `0` (WiThrottle) | `SettingsScreen`, through `SettingsWriter` | `AppController` | +| `orch` | `host` | string | — | `OrchestratorConfigScreen`, through `SettingsWriter` | `AppController` | +| `orch` | `port` | u16 | `3000` | `OrchestratorConfigScreen`, through `SettingsWriter` | `AppController` | +| `orch` | `user` | string | — | `OrchestratorConfigScreen`, through `SettingsWriter` | `AppController` | +| `orch` | `pass` | string | — | `OrchestratorConfigScreen`, through `SettingsWriter` | `AppController` | ## Notes +### Who writes, and on which task + +Nothing in the UI writes NVS on the LVGL task (F-05, F-39). Screens hand each write to +`SettingsWriter`, whose one task carries them out in the order they were asked for. The `orch` +keys are written as a read-modify-write on that task, so a transport choice and an +orchestrator Save made moments apart cannot overwrite each other with stale copies. WiFi +credentials are written by `WiFiManager` on the event loop, once they have produced an IP +address (F-40); Forget goes through the writer. + ### The `orch` namespace `transport` selects which transport drives locomotives: `0` = WiThrottle, `1` = the layout diff --git a/docs/architecture/THREADING_MODEL.md b/docs/architecture/THREADING_MODEL.md index 27edb19..36aaef0 100644 --- a/docs/architecture/THREADING_MODEL.md +++ b/docs/architecture/THREADING_MODEL.md @@ -10,16 +10,47 @@ The ESP32-S3 is dual-core. LVGL rendering runs on a dedicated task; network I/O | Task Name | Stack | Priority | Purpose | Creates | |-----------|-------|----------|---------|---------| -| `LVGL timer` | 6 KB | 2 | LVGL rendering + event handling | `lvgl_port.c` | +| `lvgl` | 6 KB | 2 | LVGL rendering + event handling | `lvgl_port.c` | | `withrottle_rx` | 4 KB | 5 | WiThrottle TCP receive loop; also sends the `*` heartbeat | `WiThrottleClient::connect()` | | `jmri_heartbeat` | 3 KB | 5 | JSON WebSocket ping every 30 s; stopped cooperatively, never deleted mid-send | `JmriJsonClient::startHeartbeat()` | | `jmri_conn` | 6 KB | 4 | Every JMRI connect and disconnect: waits for WiFi, keeps both links up with backoff, carries out the config screen's requests | `JmriConnectionController::start()` | -| `jmri_save` | 3 KB | 4 | One-shot: saves JMRI settings when the orchestrator is the transport | `JmriConnectionController::requestConnect()` | | `rotary_enc` | 3 KB | 4 | I2C encoder polling every 100 ms | `RotaryEncoderHal::startPollingTask()` | | `throttle_poll` | 4 KB | 3 | Refresh speed/direction every 10 s, for allocated throttles only | `ThrottleController::initialize()` | | `orch_connect` | 6 KB | 5 | Supervises the orchestrator link for the life of the app: login once WiFi is up, retries with backoff, fresh login when the socket stays down; woken by the config screen's Connect | `AppController::startOrchestratorConnectTask()` | -| `websocket_task` | 6 KB | — | Orchestrator control-plane receive loop (owned by `esp_websocket_client`) | `OrchestratorClient::connect()` | +| `websocket_task` | 6 KB (orchestrator), 4 KB (JMRI JSON) | — | WebSocket receive loop, owned by `esp_websocket_client`. One per client; only the selected transport's exists | `OrchestratorClient::connect()`, `JmriJsonClient::connect()` | +| `settings_writer` | 4 KB | 3 | Every NVS write the UI asks for, in the order asked (F-39) | `AppController::initialise()` | | `track_power` | 4 KB | 5 | One-shot track-power write | `ThrottleController::requestTrackPower()` | +| `wifi_scan` | 4 KB | 4 | One-shot: runs a WiFi scan for the config screen and fills its list | `WiFiConfigScreen` Scan button | +| `stack_report` | 3 KB | 1 | Diagnostics only, and only with `CONFIG_THROTTLE_STACK_REPORT` (F-33) | `AppController::initialise()` | + +Callbacks of ours also run on ESP-IDF's own tasks, whose stacks are set in `sdkconfig`: +WiFi and IP events on `sys_evt` (2304 bytes), the WiFi retry timer on `esp_timer` +(3584), and `app_main` on `main` (3584). + +**The sizes above are allocations, not measurements.** `rotary_enc` and `withrottle_rx` +both run the main screen's whole repaint on their own stacks, which is what made them the +worry in F-33. FreeRTOS's end-of-stack canary (`CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY`, +on by default) turns most overflows into a panic naming the task. It checks only at a +context switch, though, and an overflow can corrupt its neighbours before then. To measure, +build with `CONFIG_THROTTLE_STACK_REPORT=y` (menuconfig → ESP-Layout-Controller +Configuration → Diagnostics). Then drive under load: all four throttles, screen changes, a +reconnect, a WiFi scan and a power toggle. Record each task's lowest "bytes never used" +figure here, and resize anything left with less than about 25% of its stack. + +| Task | Allocated | Least free, measured on the bench | +|------|-----------|-----------------------------------| +| `lvgl` | 6144 | not yet measured | +| `rotary_enc` | 3072 | not yet measured | +| `withrottle_rx` | 4096 | not yet measured | +| `jmri_heartbeat` | 3072 | not yet measured | +| `jmri_conn` | 6144 | not yet measured | +| `orch_connect` | 6144 | not yet measured | +| `websocket_task` | 6144 / 4096 | not yet measured | +| `throttle_poll` | 4096 | not yet measured | +| `settings_writer` | 4096 | not yet measured | +| `track_power` | 4096 | not yet measured | +| `wifi_scan` | 4096 | not yet measured | +| `sys_evt` | 2304 | not yet measured | `throttle_poll` is created **only when the active `ThrottleBackend` reports `requiresPolling()`**. WiThrottle does, because it answers queries rather than volunteering diff --git a/docs/components/COMMUNICATION_LAYER.md b/docs/components/COMMUNICATION_LAYER.md index b14765d..bd42069 100644 --- a/docs/components/COMMUNICATION_LAYER.md +++ b/docs/components/COMMUNICATION_LAYER.md @@ -68,6 +68,12 @@ from the polling task, so every method must be safe on more than one task. Callb whichever task the transport receives on — never assume the LVGL task, and take `lvgl_port_lock` before touching a widget from one. +Every callback slot, on the backends and on the clients beneath them, is a `CallbackSlot` +(`main/utils/CallbackSlot.h`). It is set under its own lock, and the callback is invoked as a +copy with that lock released (F-30). Clearing a slot does not wait for a call already in +flight, so whatever a callback reaches must outlive the clear — one reason `MainScreen` is +never destroyed. + --- ## WiThrottleBackend @@ -151,6 +157,11 @@ Refused, each with a log line and no callback: Within a `STATE_SNAPSHOT`, one bad loco entry is skipped without costing the rest. +A message split across frames (a text frame without FIN, then continuations) is reassembled +before it is parsed, up to 24 KB. The receive buffer used to be cleared at every frame's +start, which parsed each fragment alone and refused them all (F-42). The connection, system +and track-power states are atomics, so no lock timeout can lose a change. + ### Roster and track power are REST, not WebSocket The `ClientMessage` union has no track-power member and the snapshot carries loco state keyed @@ -161,8 +172,26 @@ by address but no names, so both are HTTP: | Roster | `GET /api/layouts/{id}/locos` | | Track power | `POST /api/layouts/{id}/dcc-link/power` with `{"on": bool}` | -The layout id comes from `GET /api/layouts`, fetched once and cached. The roster is built -aside and swapped in, so a partly-built roster is never visible to the carousel. +The layout id comes from `GET /api/layouts`, fetched once per login and cached; a new host +never inherits the old one's id. The orchestrator runs one layout, and the first is used — +with a warning if there is ever more than one (F-42). + +The roster is **streamed**. `JsonArraySplitter` hands over each loco record as its closing +brace arrives, and each is parsed with cJSON on its own, so memory is bounded by one record +rather than by a response buffer. The old 8 KB buffer refused any roster above about 35 locos +whole (F-35). The limits are 4 KB per record, with room for the function labels the +orchestrator is gaining, and 128 locos: + +| What arrives | What happens | +|--------------|--------------| +| A record over 4 KB | That loco is skipped, with a warning | +| A record with no positive address | That loco is skipped, as before | +| More than 128 locos | The first 128 are kept, with a warning | +| A record cJSON cannot parse, or a stream that is not an array of objects | The whole roster is refused | +| A stream that stops before its closing `]` | The whole roster is refused | + +The roster is built aside and swapped in, so a partly-built roster is never visible to the +carousel. The power POST's **reply body is deliberately ignored**. The `DCC_LINK` event pushed the moment it lands is what tells us the truth — that is the route's own contract, not our @@ -360,6 +389,8 @@ off (F-23). descriptor lwIP has already handed to another socket. - `m_stateMutex`: protects internal `m_throttleStates` map. - All callbacks fire from the receive task — callers must handle their own locking. +- Each received line is logged at DEBUG, not INFO: a console write per line blocked this + task (F-38). ### Protocol Messages Parsed @@ -398,7 +429,6 @@ Same as WiThrottleClient: `DISCONNECTED → CONNECTING → CONNECTED / FAILED`. | `disconnect()` | Close WebSocket | | `setPower(bool on)` | Send power command for configured power manager | | `getPower()` | Request current power state | -| `requestPowerList()` | Request all power managers | | `startHeartbeat()` | Spawn heartbeat task (ping every 30 s) | | `stopHeartbeat()` | Stop heartbeat task and wait for it to exit (join, then delete). Up to about a second when the task is mid-send (F-31) | | `setConfiguredPowerName(name)` | Set power manager name (e.g. `"DCC++"`) | diff --git a/docs/components/CONTROLLER_LAYER.md b/docs/components/CONTROLLER_LAYER.md index f7ede24..2e82e5e 100644 --- a/docs/components/CONTROLLER_LAYER.md +++ b/docs/components/CONTROLLER_LAYER.md @@ -12,6 +12,7 @@ Singleton (Meyer's pattern) that owns all shared services and manages screen lif | Object | Type | Purpose | |--------|------|---------| +| `m_settingsWriter` | `unique_ptr` | Every NVS write the UI asks for, off the LVGL task. Created first | | `m_wifiController` | `unique_ptr` | WiFi lifecycle | | `m_wiThrottleClient` | `unique_ptr` | WiThrottle protocol | | `m_throttleBackend` | `unique_ptr` | The transport in use — held as the port, not the concrete adapter | @@ -32,6 +33,31 @@ strand locos mid-command — so a transport change takes effect on restart. --- +## SettingsWriter + +**File:** `main/controller/SettingsWriter.cpp/h` + +Carries out the UI's NVS writes on its own task, `settings_writer`, in the order they were +asked for (F-39). An LVGL event handler must not block (F-05), and an NVS write can block +when NVS has to erase a page first. + +It is one task and a queue, not a task per write. Two saves of one setting made in quick +succession then land in the order they were made. For the same reason, work that must follow +a save goes onto the same queue. The orchestrator screen's Connect, for example, posts the +supervisor's wake-up after its save, so the login reads what was typed. + +| Caller | What it posts | +|--------|---------------| +| `SettingsScreen` | Speed steps per click; the transport choice (as a read-modify-write, on the writer) | +| `OrchestratorConfigScreen` | Host, port and credential (read-modify-write); Connect's wake-up | +| `WiFiConfigScreen` | Forget | +| `JmriConnectionController` | The JMRI settings, when the orchestrator is the transport | + +`post()` runs the work on the caller's task, with a warning, if the queue is full or the +writer never started. A stalled frame is better than a lost save. + +--- + ## TransportSettings **File:** `main/controller/TransportSettings.cpp/h` @@ -136,6 +162,11 @@ void setUIUpdateCallback(void (*callback)(void*), void* userData); Fired after any state change. `MainScreen` registers this and calls `updateAllThrottles()` inside an LVGL lock. +The function and its `userData` are held together in a `CallbackSlot` (`main/utils/`), set on +the LVGL task and invoked from network and encoder tasks. The slot copies under its own lock +and calls the copy with the lock released, so a caller never sees half of a pair being +replaced (F-30). Every client callback slot works the same way. + ### Thread Safety All public methods acquire `m_stateMutex` before accessing throttle/knob state. The LVGL port lock is **not** acquired inside `ThrottleController` — that's the UI's responsibility. @@ -144,6 +175,10 @@ The order is `lvgl_port_lock`, then `m_stateMutex`: the UI reads the controller Knob input is refused in the controller while the backend reports the link down, because the physical encoders call `onKnobRotation` / `onKnobPress` directly (F-25). A speed or stop command that fails is rolled back in the model, unless a transport report has landed since. +A transport report is applied only if it names the loco the throttle now holds. A late reply +about the previous loco would otherwise set the new one's baseline. Speeds above 126 and +function numbers above 28 are dropped too (F-37). + ### Polling Task `throttle_poll` (a FreeRTOS task, 4 KB, priority 3 — not an `esp_timer`) wakes every 10 s and @@ -185,7 +220,8 @@ Manages JMRI connection persistence (NVS settings) and automatic reconnection wi ### Constructor ```cpp -JmriConnectionController(JmriJsonClient* json, WiThrottleClient* wt, WiFiController* wifi) +JmriConnectionController(JmriJsonClient* json, WiThrottleClient* wt, WiFiController* wifi, + SettingsWriter* writer) ``` ### NVS Settings (namespace: `jmri`) @@ -202,13 +238,12 @@ JmriConnectionController(JmriJsonClient* json, WiThrottleClient* wt, WiFiControl | Task | Stack | Purpose | |------|-------|---------| | `jmri_conn` | 6 KB | Every JMRI connect and disconnect: waits for WiFi indefinitely, connects both clients, reconnects with backoff (5 s → 60 s cap), carries out the config screen's requests | -| `jmri_save` | 3 KB | One-shot: saves the JMRI screen's settings when the orchestrator is the transport and nothing may connect | ### Key Methods | Method | Description | |--------|-------------| | `start()` | Read NVS and start `jmri_conn`. Idempotent. WiThrottle transport only | -| `requestConnect(ip, wtPort, powerMgr)` | Save these settings and (re)connect, on the worker. Returns at once | +| `requestConnect(ip, wtPort, powerMgr)` | Save these settings and (re)connect, on the worker. Returns at once. Under the orchestrator it only saves, through the `SettingsWriter` | | `requestDisconnect()` | Disconnect both and stop reconnecting until the next connect. Returns at once | | `isBusy()` | True while a request is being carried out, for the screen's buttons | diff --git a/docs/components/HARDWARE_LAYER.md b/docs/components/HARDWARE_LAYER.md index 9ded884..62ef003 100644 --- a/docs/components/HARDWARE_LAYER.md +++ b/docs/components/HARDWARE_LAYER.md @@ -25,6 +25,12 @@ HAL for 2× Adafruit I2C QT Rotary Encoders using the Seesaw protocol over I2C. | Encoder delta | `0x11` | `0x40` | Read as int32, auto-resets on read | | GPIO bulk | `0x01` | `0x04` | Read 4 bytes, bit 24 = button (active low) | +Each read is two I²C transactions: write the base and offset, wait 500 µs, then read. That is +the sequence the Seesaw expects, and the one Adafruit's own library uses (with 250 µs). The +HAL used to send a combined write-read with no gap, which left the Seesaw no time to prepare +its answer, and made up for it by reading every register twice. Because the delta register +resets on each read, any rotation between the two reads was lost (F-41). + ### API | Method | Description | diff --git a/docs/components/UI_LAYER.md b/docs/components/UI_LAYER.md index 50f801a..1bef367 100644 --- a/docs/components/UI_LAYER.md +++ b/docs/components/UI_LAYER.md @@ -103,7 +103,8 @@ the bottom. Every screen follows it. - SSID/password input with LVGL keyboard - Connect / disconnect / forget network - Status display (IP address, connection state) -- Credentials saved to NVS via `WiFiManager` +- Credentials saved to NVS by `WiFiManager` once they have produced an IP address; Forget + goes through the `SettingsWriter`, off the LVGL task **Navigation:** Back button → `close_wifi_config_screen()` → `show_main_screen()` @@ -171,8 +172,9 @@ transports are peers, and this one needs four fields including a credential. The password field is masked on screen. It is still plaintext in NVS — the accepted F-18 risk. -**Connect flow:** Save & Connect writes NVS, then wakes the `orch_connect` supervisor, which -logs in with the saved settings and fetches the roster. The login is a blocking HTTP round +**Connect flow:** Save & Connect queues the save on the `SettingsWriter`, then queues the +wake-up of the `orch_connect` supervisor behind it (F-39). The supervisor logs in with the +saved settings and fetches the roster. The login is a blocking HTTP round trip; running it on the LVGL task would freeze every throttle at once (F-05). The screen used to run its own connect task, which raced the supervisor for the same client. diff --git a/docs/flows/CONNECTION_FLOW.md b/docs/flows/CONNECTION_FLOW.md index 5c9be6b..c9c02d7 100644 --- a/docs/flows/CONNECTION_FLOW.md +++ b/docs/flows/CONNECTION_FLOW.md @@ -169,7 +169,7 @@ flowchart TD worker saves it as `json_port` and moves the JSON client if it changed. Connecting the JSON client from the receive task raced the reconnect task (F-34). - **Under the orchestrator** the task is never started, and Connect on the JMRI screen only - saves the settings, on a one-shot `jmri_save` task. + saves the settings, through the `SettingsWriter` (F-39). ## WiFi Config Screen diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index aa7be02..4935503 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -6,6 +6,9 @@ set(APP_SRCS # Application entry (C) "main.c" + # Utilities (C++) + "utils/StackReport.cpp" + # Model layer (C++) "model/Locomotive.cpp" "model/Throttle.cpp" @@ -20,12 +23,14 @@ set(APP_SRCS "controller/WiFiController.cpp" "controller/JmriConnectionController.cpp" "controller/TransportSettings.cpp" + "controller/SettingsWriter.cpp" # Communication layer (C++) "communication/WiFiManager.cpp" "communication/WiThrottleClient.cpp" "communication/WiThrottleBackend.cpp" "communication/OrchestratorClient.cpp" + "communication/JsonArraySplitter.cpp" "communication/OrchestratorBackend.cpp" "communication/JmriJsonClient.cpp" diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index b950c19..cea04a1 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -123,4 +123,15 @@ menu "ESP-Layout-Controller Configuration" Build and run Unity-based tests for the throttle/knob state machines instead of launching the UI. Intended for on-device validation. endmenu + + menu "Diagnostics" + config THROTTLE_STACK_REPORT + bool "Log task stack headroom" + default n + help + Every 10 s, log the least free stack each application task has had + so far; one-shot tasks log theirs as they exit. For sizing the + stacks in docs/architecture/THREADING_MODEL.md on the bench (F-33). + Leave off in normal use. + endmenu endmenu diff --git a/main/communication/JmriJsonClient.cpp b/main/communication/JmriJsonClient.cpp index e7c9d3f..cce7fdf 100644 --- a/main/communication/JmriJsonClient.cpp +++ b/main/communication/JmriJsonClient.cpp @@ -61,8 +61,6 @@ JmriJsonClient::JmriJsonClient() , m_heartbeatRunning(false) , m_configuredPowerName("DCC++") , m_powerMutex(nullptr) - , m_powerCallback(nullptr) - , m_connectionCallback(nullptr) { m_powerMutex = xSemaphoreCreateMutex(); if (!m_powerMutex) { @@ -196,7 +194,8 @@ esp_err_t JmriJsonClient::setPower(bool on) return ESP_ERR_INVALID_STATE; } - if (m_configuredPowerName.empty()) { + const std::string powerName = getConfiguredPowerName(); + if (powerName.empty()) { ESP_LOGW(TAG, "No power manager configured"); return ESP_ERR_INVALID_STATE; } @@ -206,13 +205,33 @@ esp_err_t JmriJsonClient::setPower(bool on) // state: 0=unknown, 1=on, 2=off int state = on ? 2 : 4; // JMRI uses 2=ON, 4=OFF - std::string data = "{\"name\":\"" + escapeJson(m_configuredPowerName) + "\",\"state\":" + std::to_string(state) + "}"; + std::string data = "{\"name\":\"" + escapeJson(powerName) + "\",\"state\":" + std::to_string(state) + "}"; - ESP_LOGI(TAG, "Setting power '%s': %s", m_configuredPowerName.c_str(), on ? "ON" : "OFF"); + ESP_LOGI(TAG, "Setting power '%s': %s", powerName.c_str(), on ? "ON" : "OFF"); return sendJsonCommand("power", data); } +void JmriJsonClient::setConfiguredPowerName(const std::string& powerName) +{ + if (m_powerMutex && xSemaphoreTake(m_powerMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + m_configuredPowerName = powerName; + xSemaphoreGive(m_powerMutex); + } else { + ESP_LOGE(TAG, "Could not lock to set the power manager name"); + } +} + +std::string JmriJsonClient::getConfiguredPowerName() const +{ + std::string name; + if (m_powerMutex && xSemaphoreTake(m_powerMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + name = m_configuredPowerName; + xSemaphoreGive(m_powerMutex); + } + return name; +} + JmriJsonClient::PowerState JmriJsonClient::getPower() const { PowerState result = PowerState::UNKNOWN; @@ -226,34 +245,6 @@ JmriJsonClient::PowerState JmriJsonClient::getPower() const return result; } -esp_err_t JmriJsonClient::requestPowerList() -{ - if (!isConnected()) { - ESP_LOGW(TAG, "Not connected to server"); - return ESP_ERR_INVALID_STATE; - } - - if (!m_client) { - ESP_LOGE(TAG, "WebSocket client is null"); - return ESP_ERR_INVALID_STATE; - } - - // Request list of all power managers - // {"type":"power","method":"list"} - std::string message = "{\"type\":\"power\",\"method\":\"list\"}"; - - ESP_LOGD(TAG, "Sending power list request: %s", message.c_str()); - - esp_err_t err = esp_websocket_client_send_text(m_client, message.c_str(), message.length(), portMAX_DELAY); - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to send power list request: %s (%d)", esp_err_to_name(err), err); - return err; - } - - ESP_LOGI(TAG, "Power list request sent successfully"); - return ESP_OK; -} - void JmriJsonClient::sendHeartbeat() { if (isConnected()) { @@ -330,10 +321,11 @@ void JmriJsonClient::processMessage(const std::string& message) // Small delay to ensure WebSocket is fully ready for bidirectional communication vTaskDelay(pdMS_TO_TICKS(200)); // Subscribe to power state updates for our configured power manager + const std::string powerName = getConfiguredPowerName(); std::string subscribeMsg = "{\"type\":\"power\",\"data\":{\"name\":\"" + - escapeJson(m_configuredPowerName) + "\"},\"method\":\"get\"}"; + escapeJson(powerName) + "\"},\"method\":\"get\"}"; esp_websocket_client_send_text(m_client, subscribeMsg.c_str(), subscribeMsg.length(), pdMS_TO_TICKS(1000)); - ESP_LOGI(TAG, "Subscribed to power updates for '%s'", m_configuredPowerName.c_str()); + ESP_LOGI(TAG, "Subscribed to power updates for '%s'", powerName.c_str()); } } @@ -366,17 +358,19 @@ void JmriJsonClient::handlePowerMessage(const std::string& type, const std::stri // Update cached state bool stateChanged = false; + bool isConfigured = false; if (m_powerMutex && xSemaphoreTake(m_powerMutex, pdMS_TO_TICKS(100)) == pdTRUE) { auto it = m_powerStates.find(name); if (it == m_powerStates.end() || it->second != newState) { m_powerStates[name] = newState; stateChanged = true; } + isConfigured = (name == m_configuredPowerName); xSemaphoreGive(m_powerMutex); } // Only notify callback for the configured power manager - if (stateChanged && m_powerCallback && name == m_configuredPowerName) { + if (stateChanged && isConfigured) { m_powerCallback(name, newState); } } @@ -387,9 +381,7 @@ void JmriJsonClient::setState(ConnectionState newState) m_state = newState; ESP_LOGI(TAG, "Connection state changed: %d", (int)newState); - if (m_connectionCallback) { - m_connectionCallback(newState); - } + m_connectionCallback(newState); } } diff --git a/main/communication/JmriJsonClient.h b/main/communication/JmriJsonClient.h index 9644871..47bfe56 100644 --- a/main/communication/JmriJsonClient.h +++ b/main/communication/JmriJsonClient.h @@ -4,12 +4,14 @@ #include #include #include +#include #include "esp_err.h" #include "esp_websocket_client.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #include "sdkconfig.h" +#include "CallbackSlot.h" /** * @brief JMRI JSON Protocol Client @@ -94,12 +96,12 @@ class JmriJsonClient { * @brief Set the configured power manager name to control * @param powerName Name of the power manager (e.g., "DCC++", "main") */ - void setConfiguredPowerName(const std::string& powerName) { m_configuredPowerName = powerName; } + void setConfiguredPowerName(const std::string& powerName); /** * @brief Get the configured power manager name */ - std::string getConfiguredPowerName() const { return m_configuredPowerName; } + std::string getConfiguredPowerName() const; /** * @brief Set power state for the configured power district @@ -114,22 +116,16 @@ class JmriJsonClient { */ PowerState getPower() const; - /** - * @brief Request list of available power districts - * @return ESP_OK on success - */ - esp_err_t requestPowerList(); - /** * @brief Set power state change callback * Only called for the configured power manager */ - void setPowerStateCallback(PowerStateCallback callback) { m_powerCallback = callback; } + void setPowerStateCallback(PowerStateCallback callback) { m_powerCallback.set(std::move(callback)); } /** * @brief Set connection state change callback */ - void setConnectionStateCallback(ConnectionStateCallback callback) { m_connectionCallback = callback; } + void setConnectionStateCallback(ConnectionStateCallback callback) { m_connectionCallback.set(std::move(callback)); } /** * @brief Send heartbeat (keep-alive) @@ -181,12 +177,15 @@ class JmriJsonClient { std::atomic m_heartbeatRunning; // Configured power manager name to control and monitor + /** Set on the LVGL and jmri_conn tasks, read on the WebSocket task: + * guarded by m_powerMutex (F-42). */ std::string m_configuredPowerName; // Power states for known districts std::map m_powerStates; SemaphoreHandle_t m_powerMutex; - PowerStateCallback m_powerCallback; - ConnectionStateCallback m_connectionCallback; + // Set on the LVGL or main task, invoked on the WebSocket task (F-30). + CallbackSlot m_powerCallback; + CallbackSlot m_connectionCallback; }; diff --git a/main/communication/JsonArraySplitter.cpp b/main/communication/JsonArraySplitter.cpp new file mode 100644 index 0000000..968407a --- /dev/null +++ b/main/communication/JsonArraySplitter.cpp @@ -0,0 +1,138 @@ +#include "JsonArraySplitter.h" + +JsonArraySplitter::JsonArraySplitter(size_t maxElementBytes) + : m_maxElementBytes(maxElementBytes) + , m_state(State::BEFORE_ARRAY) + , m_elementBytes(0) + , m_depth(0) + , m_inString(false) + , m_escaped(false) + , m_skipping(false) + , m_oversized(0) +{ +} + +bool JsonArraySplitter::isWhitespace(char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; +} + +void JsonArraySplitter::startElement(char opener) +{ + m_element.clear(); + m_element.push_back(opener); + m_elementBytes = 1; + m_depth = 1; + m_inString = false; + m_escaped = false; + m_skipping = false; + m_state = State::IN_ELEMENT; +} + +bool JsonArraySplitter::consumeElementChar(char c, const ElementCallback& onElement) +{ + ++m_elementBytes; + if (!m_skipping) { + if (m_elementBytes > m_maxElementBytes) { + // Scanned to its end, but not kept: one oversized record costs a + // loco, not the roster. + m_skipping = true; + std::string().swap(m_element); + } else { + m_element.push_back(c); + } + } + + if (m_inString) { + if (m_escaped) { + m_escaped = false; + } else if (c == '\\') { + m_escaped = true; + } else if (c == '"') { + m_inString = false; + } + return true; + } + + switch (c) { + case '"': + m_inString = true; + break; + case '{': + case '[': + ++m_depth; + break; + case '}': + case ']': + if (--m_depth == 0) { + m_state = State::AFTER_ELEMENT; + if (m_skipping) { + ++m_oversized; + } else if (onElement && !onElement(m_element)) { + return false; + } + std::string().swap(m_element); + } + break; + default: + break; + } + return true; +} + +bool JsonArraySplitter::feed(const char* data, size_t length, const ElementCallback& onElement) +{ + if (!data) { + return m_state != State::FAILED; + } + + for (size_t i = 0; i < length && m_state != State::FAILED; ++i) { + const char c = data[i]; + switch (m_state) { + case State::BEFORE_ARRAY: + if (c == '[') { + m_state = State::BEFORE_FIRST_ELEMENT; + } else if (!isWhitespace(c)) { + m_state = State::FAILED; + } + break; + + case State::BEFORE_FIRST_ELEMENT: + case State::BEFORE_ELEMENT: + if (c == '{' || c == '[') { + startElement(c); + } else if (c == ']' && m_state == State::BEFORE_FIRST_ELEMENT) { + m_state = State::DONE; + } else if (!isWhitespace(c)) { + m_state = State::FAILED; + } + break; + + case State::IN_ELEMENT: + if (!consumeElementChar(c, onElement)) { + m_state = State::FAILED; + } + break; + + case State::AFTER_ELEMENT: + if (c == ',') { + m_state = State::BEFORE_ELEMENT; + } else if (c == ']') { + m_state = State::DONE; + } else if (!isWhitespace(c)) { + m_state = State::FAILED; + } + break; + + case State::DONE: + if (!isWhitespace(c)) { + m_state = State::FAILED; + } + break; + + case State::FAILED: + break; + } + } + return m_state != State::FAILED; +} diff --git a/main/communication/JsonArraySplitter.h b/main/communication/JsonArraySplitter.h new file mode 100644 index 0000000..6ad7955 --- /dev/null +++ b/main/communication/JsonArraySplitter.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +/** + * @brief Splits a JSON array arriving in chunks into its top-level elements. + * + * The orchestrator's roster is an array of loco records of a few hundred bytes + * each. Buffering the whole response capped the roster at what fitted in the + * buffer, and a response over the cap was refused whole: above about 35 locos + * nothing was selectable (F-35). This hands each element over as soon as it + * closes, so memory is bounded by one element however long the array is. + * + * It finds element boundaries and nothing more: brackets and braces outside + * strings, and the commas between elements. What is inside an element is not + * checked here -- the caller parses each one, and cJSON refuses anything + * malformed. Elements must be objects or arrays; an array of bare numbers or + * strings is reported as malformed. + * + * No ESP-IDF dependency, so it can move into a host-side test build as it is. + */ +class JsonArraySplitter { +public: + /** Receives one complete element. Returning false fails the stream. */ + using ElementCallback = std::function; + + /** @param maxElementBytes Elements longer than this are skipped, not + * buffered; see oversizedElements(). */ + explicit JsonArraySplitter(size_t maxElementBytes); + + /** + * @brief Consumes the next chunk of the stream. + * @return false once the stream is malformed or the callback has refused an + * element. Everything fed after that is ignored. + */ + bool feed(const char* data, size_t length, const ElementCallback& onElement); + + /** True once the array's closing bracket has been seen. */ + bool complete() const { return m_state == State::DONE; } + + bool failed() const { return m_state == State::FAILED; } + + /** Elements skipped for exceeding maxElementBytes. */ + size_t oversizedElements() const { return m_oversized; } + +private: + enum class State { + BEFORE_ARRAY, ///< waiting for '[' + BEFORE_FIRST_ELEMENT, ///< after '[': an element or ']' + BEFORE_ELEMENT, ///< after ',': an element and only an element + IN_ELEMENT, + AFTER_ELEMENT, ///< ',' or ']' + DONE, ///< only whitespace may follow + FAILED, + }; + + static bool isWhitespace(char c); + + void startElement(char opener); + bool consumeElementChar(char c, const ElementCallback& onElement); + + size_t m_maxElementBytes; + State m_state; + std::string m_element; + size_t m_elementBytes; + int m_depth; + bool m_inString; + bool m_escaped; + bool m_skipping; + size_t m_oversized; +}; diff --git a/main/communication/OrchestratorBackend.cpp b/main/communication/OrchestratorBackend.cpp index 5d3a752..2d904ce 100644 --- a/main/communication/OrchestratorBackend.cpp +++ b/main/communication/OrchestratorBackend.cpp @@ -135,7 +135,7 @@ esp_err_t OrchestratorBackend::acquireLocomotive(int throttleId, int address, bo // older value on screen. OrchestratorClient::LocoState known; if (m_client && m_client->getLastLocoState(address, known)) { - const ThrottleStateCallback callback = copyThrottleStateCallback(); + const ThrottleStateCallback callback = m_throttleStateCallback.get(); publishToThrottle(throttleId, known, callback); OrchestratorClient::LocoState latest; @@ -295,7 +295,7 @@ void OrchestratorBackend::onCommandRefused() if (!m_client) { return; } - const ThrottleStateCallback callback = copyThrottleStateCallback(); + const ThrottleStateCallback callback = m_throttleStateCallback.get(); for (int throttleId = 0; throttleId < MAX_THROTTLES; ++throttleId) { const int address = addressFor(throttleId); @@ -333,21 +333,9 @@ bool OrchestratorBackend::getRosterEntry(int index, RosterEntry& outEntry) const return true; } -OrchestratorBackend::ThrottleStateCallback OrchestratorBackend::copyThrottleStateCallback() const -{ - ThrottleStateCallback callback; - if (lock(pdMS_TO_TICKS(50))) { - callback = m_throttleStateCallback; - unlock(); - } else { - callback = m_throttleStateCallback; - } - return callback; -} - void OrchestratorBackend::onLocoState(const OrchestratorClient::LocoState& state) { - const ThrottleStateCallback callback = copyThrottleStateCallback(); + const ThrottleStateCallback callback = m_throttleStateCallback.get(); // A loco can legitimately sit on more than one of this device's throttles, // so this is a loop rather than a lookup that stops at the first match. @@ -412,37 +400,26 @@ void OrchestratorBackend::publishToThrottle(int throttleId, void OrchestratorBackend::setThrottleStateCallback(ThrottleStateCallback callback) { - if (lock(pdMS_TO_TICKS(100))) { - m_throttleStateCallback = std::move(callback); - unlock(); - } else { - m_throttleStateCallback = std::move(callback); - } + m_throttleStateCallback.set(std::move(callback)); } void OrchestratorBackend::setConnectionStateCallback(ConnectionStateCallback callback) { - if (lock(pdMS_TO_TICKS(100))) { - m_connectionStateCallback = std::move(callback); - unlock(); - } else { - m_connectionStateCallback = std::move(callback); - } + const bool clearing = !callback; + m_connectionStateCallback.set(std::move(callback)); if (!m_client) { return; } - if (!m_connectionStateCallback) { + if (clearing) { m_client->setConnectionStateCallback(nullptr); return; } m_client->setConnectionStateCallback( [this](OrchestratorClient::ConnectionState state) { - if (m_connectionStateCallback) { - m_connectionStateCallback(toPortState(state)); - } + m_connectionStateCallback(toPortState(state)); }); } @@ -469,22 +446,20 @@ ThrottleBackend::TrackPower OrchestratorBackend::getTrackPower() const void OrchestratorBackend::setTrackPowerCallback(TrackPowerCallback callback) { - m_trackPowerCallback = std::move(callback); + const bool clearing = !callback; + m_trackPowerCallback.set(std::move(callback)); if (!m_client) { return; } - if (!m_trackPowerCallback) { + if (clearing) { m_client->setTrackPowerCallback(nullptr); return; } m_client->setTrackPowerCallback( [this](OrchestratorClient::TrackPower state) { - if (!m_trackPowerCallback) { - return; - } switch (state) { case OrchestratorClient::TrackPower::ON: m_trackPowerCallback(TrackPower::ON); diff --git a/main/communication/OrchestratorBackend.h b/main/communication/OrchestratorBackend.h index 2ff97a6..fc1f640 100644 --- a/main/communication/OrchestratorBackend.h +++ b/main/communication/OrchestratorBackend.h @@ -2,6 +2,7 @@ #include +#include "CallbackSlot.h" #include "OrchestratorClient.h" #include "ThrottleBackend.h" #include "freertos/FreeRTOS.h" @@ -113,17 +114,16 @@ class OrchestratorBackend : public ThrottleBackend { const OrchestratorClient::LocoState& state, const ThrottleStateCallback& callback); - ThrottleStateCallback copyThrottleStateCallback() const; - bool lock(TickType_t timeout) const; void unlock() const; OrchestratorClient* m_client; std::array m_assignments; - ThrottleStateCallback m_throttleStateCallback; - ConnectionStateCallback m_connectionStateCallback; - TrackPowerCallback m_trackPowerCallback; + // Set on the main task, invoked on the client's tasks (F-30). + CallbackSlot m_throttleStateCallback; + CallbackSlot m_connectionStateCallback; + CallbackSlot m_trackPowerCallback; mutable SemaphoreHandle_t m_mutex; }; diff --git a/main/communication/OrchestratorClient.cpp b/main/communication/OrchestratorClient.cpp index dac516f..381a902 100644 --- a/main/communication/OrchestratorClient.cpp +++ b/main/communication/OrchestratorClient.cpp @@ -2,6 +2,8 @@ #include +#include "JsonArraySplitter.h" + #include "cJSON.h" #include "esp_http_client.h" #include "esp_log.h" @@ -18,9 +20,19 @@ constexpr int CLIENT_LOCK_WAIT_MS = 1500; /** How long disconnect() waits for a send in progress to let go of the handle. */ constexpr int CLIENT_LOCK_TEARDOWN_WAIT_MS = 5000; -/** Login and roster reads are small; this caps a hostile or broken response. */ +/** Login and layout-list reads are small; this caps a hostile or broken + * response. The roster is streamed instead, so it has no such cap (F-35). */ constexpr int HTTP_RESPONSE_LIMIT = 8192; +/** One loco record is a few hundred bytes today. The headroom is for the + * operator-authored function labels the orchestrator is gaining; a record past + * it is skipped, not the whole roster. */ +constexpr size_t MAX_ROSTER_RECORD_BYTES = 4096; + +/** Bounds the roster's RAM, and the carousel's length, whatever the server + * sends. Matches the loco state cache. */ +constexpr size_t MAX_ROSTER_ENTRIES = 128; + /** A STATE_SNAPSHOT carrying a whole layout is the largest frame we expect. */ constexpr size_t MAX_FRAME_BYTES = 24576; @@ -66,6 +78,49 @@ esp_err_t httpEventHandler(esp_http_client_event_t* evt) return ESP_OK; } +/** Parses one roster record. Returns false when it is not a JSON object. */ +bool parseRosterRecord(const std::string& element, OrchestratorClient::RosterEntry& out, + bool& usable); + +/** Accumulates the roster as the response streams in (F-35). */ +struct RosterStream { + JsonArraySplitter splitter{MAX_ROSTER_RECORD_BYTES}; + std::vector roster; + size_t dropped = 0; +}; + +esp_err_t rosterEventHandler(esp_http_client_event_t* evt) +{ + auto* stream = static_cast(evt->user_data); + if (!stream || evt->event_id != HTTP_EVENT_ON_DATA || evt->data_len <= 0 || !evt->data) { + return ESP_OK; + } + // Only a 200's body is a roster; an error page is judged by its status. + if (esp_http_client_get_status_code(evt->client) != 200) { + return ESP_OK; + } + + stream->splitter.feed( + static_cast(evt->data), static_cast(evt->data_len), + [stream](const std::string& element) { + OrchestratorClient::RosterEntry entry; + bool usable = false; + if (!parseRosterRecord(element, entry, usable)) { + return false; + } + if (!usable) { + return true; + } + if (stream->roster.size() >= MAX_ROSTER_ENTRIES) { + ++stream->dropped; + return true; + } + stream->roster.push_back(std::move(entry)); + return true; + }); + return ESP_OK; +} + /** Reads a required string field. Returns false when absent or not a string. */ bool jsonString(const cJSON* obj, const char* key, std::string& out) { @@ -97,6 +152,26 @@ bool jsonInt(const cJSON* obj, const char* key, int& out) return true; } +bool parseRosterRecord(const std::string& element, OrchestratorClient::RosterEntry& out, + bool& usable) +{ + cJSON* record = cJSON_Parse(element.c_str()); + if (!cJSON_IsObject(record)) { + ESP_LOGE(TAG, "Refusing roster: a record is not a JSON object"); + if (record) cJSON_Delete(record); + return false; + } + + // A record with no usable address is skipped, as before: it cannot be + // driven, but it says nothing about the rest. + usable = jsonInt(record, "address", out.address) && out.address > 0; + if (usable && (!jsonString(record, "name", out.name) || out.name.empty())) { + out.name = "Loco " + std::to_string(out.address); + } + cJSON_Delete(record); + return true; +} + const char* directionToWire(OrchestratorClient::Direction direction) { switch (direction) { @@ -130,6 +205,7 @@ OrchestratorClient::OrchestratorClient() , m_clientMutex(nullptr) , m_state(ConnectionState::DISCONNECTED) , m_port(DEFAULT_PORT) + , m_rxInMessage(false) , m_systemStatus(SystemStatus::UNKNOWN) , m_trackPower(TrackPower::UNKNOWN) , m_lastMessageUs(0) @@ -184,27 +260,9 @@ void OrchestratorClient::unlockState() const void OrchestratorClient::setState(ConnectionState newState) { - ConnectionStateCallback callback; - bool changed = false; - - if (lockState(pdMS_TO_TICKS(100))) { - changed = (m_state != newState); - m_state = newState; - callback = m_connectionCallback; - unlockState(); - } else { - // Never let a lock timeout lose a state change: the UI's view of - // whether the link is up gates whether knobs are live. - m_state = newState; - callback = m_connectionCallback; - changed = true; - } - - if (changed) { + if (m_state.exchange(newState) != newState) { ESP_LOGI(TAG, "Connection state: %s", stateName(newState)); - if (callback) { - callback(newState); - } + m_connectionCallback(newState); } } @@ -215,24 +273,12 @@ bool OrchestratorClient::isConnected() const OrchestratorClient::ConnectionState OrchestratorClient::getState() const { - ConnectionState state = ConnectionState::DISCONNECTED; - if (lockState(pdMS_TO_TICKS(50))) { - state = m_state; - unlockState(); - } else { - state = m_state; - } - return state; + return m_state.load(); } OrchestratorClient::SystemStatus OrchestratorClient::getSystemStatus() const { - SystemStatus status = SystemStatus::UNKNOWN; - if (lockState(pdMS_TO_TICKS(50))) { - status = m_systemStatus; - unlockState(); - } - return status; + return m_systemStatus.load(); } uint32_t OrchestratorClient::secondsSinceLastMessage() const @@ -272,8 +318,18 @@ esp_err_t OrchestratorClient::connect(const std::string& host, disconnect(); + // Under the lock: the power and roster requests read these on other + // tasks. The layout id is forgotten on every login, so a new host -- or a + // layout recreated on the same one -- is never addressed by a stale id + // (F-42). It costs one GET per login. + if (!lockState(pdMS_TO_TICKS(1000))) { + ESP_LOGE(TAG, "Could not lock to set the host"); + return ESP_ERR_TIMEOUT; + } m_host = host; m_port = port; + m_layoutId.clear(); + unlockState(); setState(ConnectionState::AUTHENTICATING); esp_err_t err = authenticate(host, port, username, password); @@ -361,12 +417,14 @@ esp_err_t OrchestratorClient::authenticate(const std::string& host, return ESP_ERR_NOT_FOUND; } - if (lockState(pdMS_TO_TICKS(200))) { - m_sessionToken = token; - unlockState(); - } else { - m_sessionToken = token; + // Not written unlocked on a timeout (F-42): the power and roster + // requests read it from other tasks. The supervisor just logs in again. + if (!lockState(pdMS_TO_TICKS(200))) { + ESP_LOGE(TAG, "Could not lock to store the session cookie"); + return ESP_ERR_TIMEOUT; } + m_sessionToken = token; + unlockState(); ESP_LOGI(TAG, "Authenticated; session cookie acquired"); return ESP_OK; @@ -471,13 +529,17 @@ void OrchestratorClient::disconnect() xSemaphoreGive(m_clientMutex); } - if (lockState(pdMS_TO_TICKS(200))) { - m_rxBuffer.clear(); + // The WebSocket task is gone, so the reassembly state has no other user. + m_rxBuffer.clear(); + m_rxInMessage = false; + + // A cookie that outlives a failed clear is harmless: the next connect() + // logs in again and overwrites it. Writing it unlocked is not (F-42). + if (lockState(pdMS_TO_TICKS(1000))) { m_sessionToken.clear(); unlockState(); } else { - m_rxBuffer.clear(); - m_sessionToken.clear(); + ESP_LOGE(TAG, "Could not lock to clear the session cookie"); } // What the layout told us is no longer current once we stop listening. @@ -507,6 +569,9 @@ void OrchestratorClient::websocketEventHandler(void* handlerArgs, case WEBSOCKET_EVENT_DISCONNECTED: ESP_LOGW(TAG, "Control plane disconnected"); + // A message cut off by the drop never gets its FIN. + self->m_rxBuffer.clear(); + self->m_rxInMessage = false; // Stale from here on; the snapshot that opens the next connection // refills it. self->clearLocoStates(); @@ -519,39 +584,58 @@ void OrchestratorClient::websocketEventHandler(void* handlerArgs, break; case WEBSOCKET_EVENT_DATA: { - // Opcode 1 is text. Ignore ping/pong/close frames, and ignore - // binary: the control plane is JSON text only. - if (data->op_code != 0x01 && data->op_code != 0x00) { - break; - } - if (data->data_len <= 0 || !data->data_ptr) { + // Text (1) opens a message and continuation (0) extends one. Ping, + // pong, close and binary frames are not ours: the control plane is + // JSON text only. + const bool isText = data->op_code == 0x01; + const bool isContinuation = data->op_code == 0x00; + if (!isText && !isContinuation) { break; } - // A frame larger than the receive buffer arrives in several events, - // with payload_offset telling us where each piece belongs. + // Two kinds of splitting. A frame larger than the receive buffer + // arrives in several events, payload_offset saying where each + // piece sits in its frame. A message may also span several frames: + // text without FIN, then continuations, the last carrying FIN. + // The buffer used to be cleared at every frame's start, which + // parsed each fragment alone and refused them all (F-42). if (data->payload_offset == 0) { - self->m_rxBuffer.clear(); + if (isText) { + self->m_rxBuffer.clear(); + self->m_rxInMessage = true; + } else if (!self->m_rxInMessage) { + ESP_LOGW(TAG, "Dropping a continuation frame with no message open"); + break; + } } - - if (self->m_rxBuffer.size() + data->data_len > MAX_FRAME_BYTES) { - ESP_LOGW(TAG, "Dropping oversized frame (>%u bytes)", - static_cast(MAX_FRAME_BYTES)); - self->m_rxBuffer.clear(); - break; + if (!self->m_rxInMessage) { + break; // the rest of a message already dropped } - self->m_rxBuffer.append(data->data_ptr, data->data_len); + if (data->data_len > 0 && data->data_ptr) { + if (self->m_rxBuffer.size() + data->data_len > MAX_FRAME_BYTES) { + ESP_LOGW(TAG, "Dropping oversized message (>%u bytes)", + static_cast(MAX_FRAME_BYTES)); + self->m_rxBuffer.clear(); + self->m_rxInMessage = false; + break; + } + self->m_rxBuffer.append(data->data_ptr, data->data_len); + } - // Only parse once the whole payload has arrived. - if (data->payload_len > 0 && - self->m_rxBuffer.size() < static_cast(data->payload_len)) { + // Only parse once this frame is whole and it is the message's last. + const bool frameComplete = + data->payload_offset + data->data_len >= data->payload_len; + if (!frameComplete || !data->fin) { break; } - std::string frame; - frame.swap(self->m_rxBuffer); - self->handleMessage(frame); + self->m_rxInMessage = false; + std::string message; + message.swap(self->m_rxBuffer); + if (!message.empty()) { + self->handleMessage(message); + } break; } @@ -603,14 +687,7 @@ void OrchestratorClient::handleMessage(const std::string& json) ESP_LOGE(TAG, "Orchestrator reported an error with no message"); } - CommandRefusedCallback callback; - if (lockState(pdMS_TO_TICKS(50))) { - callback = m_commandRefusedCallback; - unlockState(); - } - if (callback) { - callback(message); - } + m_commandRefusedCallback(message); } else { // Blocks, points, routes, sensors and faults are all real messages this // device has no use for. Not an error -- just not ours. @@ -639,15 +716,8 @@ void OrchestratorClient::handleStateSnapshot(const void* payloadPtr) reason = reasonItem->valuestring; } - SystemStatusCallback callback; - if (lockState(pdMS_TO_TICKS(50))) { - m_systemStatus = status; - callback = m_systemStatusCallback; - unlockState(); - } - if (callback) { - callback(status, reason); - } + m_systemStatus = status; + m_systemStatusCallback(status, reason); } } @@ -734,17 +804,7 @@ void OrchestratorClient::handleLocoState(const void* payloadPtr) // starts from this state even if it is not assigned anywhere yet (F-20). cacheLocoState(state); - LocoStateCallback callback; - if (lockState(pdMS_TO_TICKS(50))) { - callback = m_locoStateCallback; - unlockState(); - } else { - callback = m_locoStateCallback; - } - - if (callback) { - callback(state); - } + m_locoStateCallback(state); } void OrchestratorClient::handleDccLink(const void* payloadPtr) @@ -768,37 +828,17 @@ void OrchestratorClient::handleDccLink(const void* payloadPtr) return; } - TrackPowerCallback callback; - bool changed = false; - if (lockState(pdMS_TO_TICKS(50))) { - changed = (m_trackPower != power); - m_trackPower = power; - callback = m_trackPowerCallback; - unlockState(); - } else { - changed = (m_trackPower != power); - m_trackPower = power; - callback = m_trackPowerCallback; - } - - if (changed) { + if (m_trackPower.exchange(power) != power) { ESP_LOGI(TAG, "Track power: %s", power == TrackPower::ON ? "on" : power == TrackPower::OFF ? "off" : "unknown"); - if (callback) { - callback(power); - } + m_trackPowerCallback(power); } } OrchestratorClient::TrackPower OrchestratorClient::getTrackPower() const { - TrackPower power = TrackPower::UNKNOWN; - if (lockState(pdMS_TO_TICKS(50))) { - power = m_trackPower; - unlockState(); - } - return power; + return m_trackPower.load(); } esp_err_t OrchestratorClient::setTrackPower(bool on) @@ -925,9 +965,17 @@ std::string OrchestratorClient::getLayoutId() return std::string(); } + // The first layout, as it always has been: the orchestrator runs one. Said + // aloud when that stops being true, rather than driving the wrong one + // silently. + const int layoutCount = cJSON_GetArraySize(layouts); std::string layoutId; const bool haveId = jsonString(cJSON_GetArrayItem(layouts, 0), "id", layoutId); cJSON_Delete(layouts); + if (layoutCount > 1) { + ESP_LOGW(TAG, "Orchestrator has %d layouts; using the first (%s)", layoutCount, + layoutId.c_str()); + } if (!haveId || layoutId.empty()) { ESP_LOGE(TAG, "Refusing layout list: first entry has no id"); return std::string(); @@ -962,19 +1010,12 @@ void OrchestratorClient::handleSystemStatus(const void* payloadPtr) reason = reasonItem->valuestring; } - SystemStatusCallback callback; - if (lockState(pdMS_TO_TICKS(50))) { - m_systemStatus = status; - callback = m_systemStatusCallback; - unlockState(); - } + m_systemStatus = status; ESP_LOGI(TAG, "System status: %s%s%s", statusWire.c_str(), reason.empty() ? "" : " -- ", reason.c_str()); - if (callback) { - callback(status, reason); - } + m_systemStatusCallback(status, reason); } #if CONFIG_THROTTLE_TESTS @@ -1118,86 +1159,65 @@ esp_err_t OrchestratorClient::refreshRoster() // The control plane carries loco state keyed by address but no names, so // the roster is a REST read. It needs a layout id, which getLayoutId() - // fetches once and caches -- the power POST needs the same id. + // fetches once per login and caches -- the power POST needs the same id. const std::string layoutId = getLayoutId(); if (layoutId.empty()) { return ESP_ERR_INVALID_RESPONSE; } - esp_http_client_handle_t client = nullptr; - esp_err_t err = ESP_OK; - int status = 0; - - HttpCapture locosCapture; + // Streamed: each record is parsed as it arrives, so the roster is bounded + // by MAX_ROSTER_ENTRIES rather than by a response buffer (F-35). + RosterStream stream; const std::string locosUrl = base + "/api/layouts/" + layoutId + "/locos"; esp_http_client_config_t locosCfg = {}; locosCfg.url = locosUrl.c_str(); locosCfg.method = HTTP_METHOD_GET; locosCfg.timeout_ms = HTTP_TIMEOUT_MS; - locosCfg.event_handler = httpEventHandler; - locosCfg.user_data = &locosCapture; + locosCfg.event_handler = rosterEventHandler; + locosCfg.user_data = &stream; - client = esp_http_client_init(&locosCfg); + esp_http_client_handle_t client = esp_http_client_init(&locosCfg); if (!client) { return ESP_FAIL; } esp_http_client_set_header(client, "Cookie", cookie.c_str()); - err = esp_http_client_perform(client); - status = esp_http_client_get_status_code(client); + const esp_err_t err = esp_http_client_perform(client); + const int status = esp_http_client_get_status_code(client); esp_http_client_cleanup(client); if (err != ESP_OK || status != 200) { ESP_LOGE(TAG, "Failed to fetch roster (HTTP %d)", status); return (err != ESP_OK) ? err : ESP_FAIL; } - if (locosCapture.truncated) { - ESP_LOGE(TAG, "Refusing truncated roster response"); - return ESP_ERR_INVALID_SIZE; - } - - cJSON* locos = cJSON_Parse(locosCapture.body.c_str()); - if (!cJSON_IsArray(locos)) { - ESP_LOGE(TAG, "Refusing roster: not an array"); - if (locos) cJSON_Delete(locos); + // A record cJSON could not parse, or a stream that is not an array or + // stops short of its closing bracket: refused whole, never half-applied. + if (stream.splitter.failed() || !stream.splitter.complete()) { + ESP_LOGE(TAG, "Refusing roster: %s", + stream.splitter.failed() ? "malformed" : "truncated"); return ESP_ERR_INVALID_RESPONSE; } + if (stream.splitter.oversizedElements() > 0) { + ESP_LOGW(TAG, "Skipped %u roster records over %u bytes", + static_cast(stream.splitter.oversizedElements()), + static_cast(MAX_ROSTER_RECORD_BYTES)); + } + if (stream.dropped > 0) { + ESP_LOGW(TAG, "Roster has %u more locos than the %u kept", + static_cast(stream.dropped), + static_cast(MAX_ROSTER_ENTRIES)); + } // Built aside and swapped in, so a partly-built roster is never visible to // the carousel. - std::vector roster; - const cJSON* item = nullptr; - cJSON_ArrayForEach(item, locos) { - RosterEntry entry; - if (!jsonInt(item, "address", entry.address) || entry.address <= 0) { - continue; - } - if (!jsonString(item, "name", entry.name) || entry.name.empty()) { - entry.name = "Loco " + std::to_string(entry.address); - } - roster.push_back(entry); - } - cJSON_Delete(locos); - - RosterCallback callback; - if (lockState(pdMS_TO_TICKS(200))) { - m_roster.swap(roster); - callback = m_rosterCallback; - unlockState(); - } else { + if (!lockState(pdMS_TO_TICKS(200))) { ESP_LOGW(TAG, "Could not lock to publish roster"); return ESP_ERR_TIMEOUT; } + m_roster = stream.roster; + unlockState(); - ESP_LOGI(TAG, "Roster loaded: %u locomotives", static_cast(getRosterSize())); - - if (callback) { - std::vector copy; - if (lockState(pdMS_TO_TICKS(100))) { - copy = m_roster; - unlockState(); - } - callback(copy); - } + ESP_LOGI(TAG, "Roster loaded: %u locomotives", static_cast(stream.roster.size())); + m_rosterCallback(stream.roster); return ESP_OK; } @@ -1302,52 +1322,32 @@ bool OrchestratorClient::getLastLocoState(int address, LocoState& outState) cons void OrchestratorClient::setConnectionStateCallback(ConnectionStateCallback callback) { - if (lockState(pdMS_TO_TICKS(100))) { - m_connectionCallback = std::move(callback); - unlockState(); - } + m_connectionCallback.set(std::move(callback)); } void OrchestratorClient::setLocoStateCallback(LocoStateCallback callback) { - if (lockState(pdMS_TO_TICKS(100))) { - m_locoStateCallback = std::move(callback); - unlockState(); - } + m_locoStateCallback.set(std::move(callback)); } void OrchestratorClient::setSystemStatusCallback(SystemStatusCallback callback) { - if (lockState(pdMS_TO_TICKS(100))) { - m_systemStatusCallback = std::move(callback); - unlockState(); - } + m_systemStatusCallback.set(std::move(callback)); } void OrchestratorClient::setRosterCallback(RosterCallback callback) { - if (lockState(pdMS_TO_TICKS(100))) { - m_rosterCallback = std::move(callback); - unlockState(); - } + m_rosterCallback.set(std::move(callback)); } void OrchestratorClient::setCommandRefusedCallback(CommandRefusedCallback callback) { - if (lockState(pdMS_TO_TICKS(100))) { - m_commandRefusedCallback = std::move(callback); - unlockState(); - } + m_commandRefusedCallback.set(std::move(callback)); } void OrchestratorClient::setTrackPowerCallback(TrackPowerCallback callback) { - if (lockState(pdMS_TO_TICKS(100))) { - m_trackPowerCallback = std::move(callback); - unlockState(); - } else { - m_trackPowerCallback = std::move(callback); - } + m_trackPowerCallback.set(std::move(callback)); } const char* OrchestratorClient::stateName(ConnectionState state) diff --git a/main/communication/OrchestratorClient.h b/main/communication/OrchestratorClient.h index 7142902..80cf0ec 100644 --- a/main/communication/OrchestratorClient.h +++ b/main/communication/OrchestratorClient.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,6 +10,7 @@ #include "esp_websocket_client.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "CallbackSlot.h" /** * @brief Client for the layout orchestrator's WebSocket control plane. @@ -300,7 +302,10 @@ class OrchestratorClient { */ esp_websocket_client_handle_t m_client; mutable SemaphoreHandle_t m_clientMutex; - ConnectionState m_state; + + /** Atomic rather than under the state mutex, so no lock timeout can lose + * a change: whether the link is up gates the knobs (F-42). */ + std::atomic m_state; std::string m_host; uint16_t m_port; @@ -309,22 +314,28 @@ class OrchestratorClient { std::string m_cookieHeader; std::string m_uri; - /** Reassembly buffer for frames split across events. */ + /** Reassembly buffer for a message split across events or frames. + * Touched only on the WebSocket task, and by disconnect() once that task + * has been destroyed. */ std::string m_rxBuffer; + /** A text frame has opened a message that its FIN frame has not closed. */ + bool m_rxInMessage; - SystemStatus m_systemStatus; - TrackPower m_trackPower; + std::atomic m_systemStatus; + std::atomic m_trackPower; std::string m_layoutId; std::vector m_roster; std::map m_locoStates; int64_t m_lastMessageUs; - ConnectionStateCallback m_connectionCallback; - LocoStateCallback m_locoStateCallback; - SystemStatusCallback m_systemStatusCallback; - RosterCallback m_rosterCallback; - TrackPowerCallback m_trackPowerCallback; - CommandRefusedCallback m_commandRefusedCallback; + // Set on the main or LVGL task, invoked on the WebSocket and supervisor + // tasks. Each copies under its own lock and calls the copy (F-30). + CallbackSlot m_connectionCallback; + CallbackSlot m_locoStateCallback; + CallbackSlot m_systemStatusCallback; + CallbackSlot&)> m_rosterCallback; + CallbackSlot m_trackPowerCallback; + CallbackSlot m_commandRefusedCallback; mutable SemaphoreHandle_t m_stateMutex; }; diff --git a/main/communication/WiFiManager.cpp b/main/communication/WiFiManager.cpp index 52c0716..8b03909 100644 --- a/main/communication/WiFiManager.cpp +++ b/main/communication/WiFiManager.cpp @@ -4,10 +4,10 @@ #include "nvs_flash.h" #include "nvs.h" #include +#include WiFiManager::WiFiManager() : m_state(State::DISCONNECTED) - , m_stateCallback(nullptr) , m_retryCount(0) , m_slowRetryCount(0) , m_initialized(false) @@ -275,7 +275,7 @@ void WiFiManager::clearStoredCredentials() void WiFiManager::setStateCallback(StateCallback callback) { - m_stateCallback = callback; + m_stateCallback.set(std::move(callback)); } esp_err_t WiFiManager::startScan() @@ -449,9 +449,10 @@ void WiFiManager::setState(State newState) if (m_state != newState) { m_state = newState; - if (m_stateCallback) { + StateCallback callback = m_stateCallback.get(); + if (callback) { std::string ip = (newState == State::CONNECTED) ? getIpAddress() : ""; - m_stateCallback(newState, ip); + callback(newState, ip); } } } diff --git a/main/communication/WiFiManager.h b/main/communication/WiFiManager.h index dfb4229..0cfeb97 100644 --- a/main/communication/WiFiManager.h +++ b/main/communication/WiFiManager.h @@ -3,6 +3,7 @@ #include #include #include +#include "CallbackSlot.h" #include "esp_wifi.h" #include "esp_event.h" #include "esp_timer.h" @@ -143,7 +144,8 @@ class WiFiManager { static constexpr uint32_t SLOW_RETRY_MAX_MS = 60000; State m_state; - StateCallback m_stateCallback; + /** Set on the LVGL or main task, invoked on the event loop task (F-30). */ + CallbackSlot m_stateCallback; std::atomic m_retryCount; int m_slowRetryCount; bool m_initialized; diff --git a/main/communication/WiThrottleBackend.cpp b/main/communication/WiThrottleBackend.cpp index e843882..966aaba 100644 --- a/main/communication/WiThrottleBackend.cpp +++ b/main/communication/WiThrottleBackend.cpp @@ -80,22 +80,20 @@ ThrottleBackend::TrackPower WiThrottleBackend::getTrackPower() const void WiThrottleBackend::setTrackPowerCallback(TrackPowerCallback callback) { - m_trackPowerCallback = std::move(callback); + const bool clearing = !callback; + m_trackPowerCallback.set(std::move(callback)); if (!m_jsonClient) { return; } - if (!m_trackPowerCallback) { + if (clearing) { m_jsonClient->setPowerStateCallback(nullptr); return; } m_jsonClient->setPowerStateCallback( [this](const std::string& /*powerName*/, JmriJsonClient::PowerState state) { - if (!m_trackPowerCallback) { - return; - } switch (state) { case JmriJsonClient::PowerState::ON: m_trackPowerCallback(TrackPower::ON); @@ -210,23 +208,20 @@ bool WiThrottleBackend::getRosterEntry(int index, RosterEntry& outEntry) const void WiThrottleBackend::setThrottleStateCallback(ThrottleStateCallback callback) { - m_throttleStateCallback = std::move(callback); + const bool clearing = !callback; + m_throttleStateCallback.set(std::move(callback)); if (!m_client) { return; } - if (!m_throttleStateCallback) { + if (clearing) { m_client->setThrottleStateCallback(nullptr); return; } m_client->setThrottleStateCallback( [this](const WiThrottleClient::ThrottleUpdate& update) { - if (!m_throttleStateCallback) { - return; - } - ThrottleUpdate ported; ported.throttleId = update.throttleId - '0'; ported.address = update.address; @@ -250,44 +245,40 @@ void WiThrottleBackend::setThrottleStateCallback(ThrottleStateCallback callback) void WiThrottleBackend::setConnectionStateCallback(ConnectionStateCallback callback) { - m_connectionStateCallback = std::move(callback); + const bool clearing = !callback; + m_connectionStateCallback.set(std::move(callback)); if (!m_client) { return; } - if (!m_connectionStateCallback) { + if (clearing) { m_client->setConnectionStateCallback(nullptr); return; } m_client->setConnectionStateCallback( [this](WiThrottleClient::ConnectionState state) { - if (m_connectionStateCallback) { - m_connectionStateCallback(toPortState(state)); - } + m_connectionStateCallback(toPortState(state)); }); } void WiThrottleBackend::setFunctionLabelsCallback(FunctionLabelsCallback callback) { - m_functionLabelsCallback = std::move(callback); + const bool clearing = !callback; + m_functionLabelsCallback.set(std::move(callback)); if (!m_client) { return; } - if (!m_functionLabelsCallback) { + if (clearing) { m_client->setFunctionLabelsCallback(nullptr); return; } m_client->setFunctionLabelsCallback( [this](char throttleIdChar, const std::vector& labels) { - if (!m_functionLabelsCallback) { - return; - } - const int throttleId = throttleIdChar - '0'; if (!isValidThrottle(throttleId)) { ESP_LOGW(TAG, "Dropping function labels for out-of-range throttle id '%c'", diff --git a/main/communication/WiThrottleBackend.h b/main/communication/WiThrottleBackend.h index c3bf0a0..cfe9b06 100644 --- a/main/communication/WiThrottleBackend.h +++ b/main/communication/WiThrottleBackend.h @@ -1,5 +1,6 @@ #pragma once +#include "CallbackSlot.h" #include "JmriJsonClient.h" #include "ThrottleBackend.h" #include "WiThrottleClient.h" @@ -77,8 +78,9 @@ class WiThrottleBackend : public ThrottleBackend { // Held so the lambdas registered on the client stay valid, and so a second // setThrottleStateCallback replaces the first rather than stacking. - ThrottleStateCallback m_throttleStateCallback; - FunctionLabelsCallback m_functionLabelsCallback; - ConnectionStateCallback m_connectionStateCallback; - TrackPowerCallback m_trackPowerCallback; + // Set on the main task, invoked on the clients' tasks (F-30). + CallbackSlot m_throttleStateCallback; + CallbackSlot&)> m_functionLabelsCallback; + CallbackSlot m_connectionStateCallback; + CallbackSlot m_trackPowerCallback; }; diff --git a/main/communication/WiThrottleClient.cpp b/main/communication/WiThrottleClient.cpp index 406094b..cd97016 100644 --- a/main/communication/WiThrottleClient.cpp +++ b/main/communication/WiThrottleClient.cpp @@ -40,11 +40,6 @@ WiThrottleClient::WiThrottleClient() , m_mainTrackPower(PowerState::UNKNOWN) , m_progTrackPower(PowerState::UNKNOWN) , m_webPort(0) - , m_powerCallback(nullptr) - , m_connectionCallback(nullptr) - , m_rosterCallback(nullptr) - , m_webPortCallback(nullptr) - , m_functionLabelsCallback(nullptr) , m_stateMutex(nullptr) , m_sendMutex(nullptr) , m_taskExitSemaphore(nullptr) @@ -436,7 +431,7 @@ esp_err_t WiThrottleClient::setDirection(char throttleId, bool forward) "A" + std::string(1, state.addressType) + std::to_string(state.address) + "<;>R" + (forward ? "1" : "0"); - ESP_LOGI(TAG, "Setting throttle %c direction: %s", throttleId, forward ? "FORWARD" : "REVERSE"); + ESP_LOGD(TAG, "Setting throttle %c direction: %s", throttleId, forward ? "FORWARD" : "REVERSE"); return sendCommand(command); } @@ -720,7 +715,7 @@ void WiThrottleClient::receiveTask(void* arg) void WiThrottleClient::processMessage(const std::string& message) { // Log at debug level for normal operation - ESP_LOGI(TAG, "RX: %s", message.c_str()); + ESP_LOGD(TAG, "RX: %s", message.c_str()); if (message.empty()) { return; @@ -736,9 +731,7 @@ void WiThrottleClient::processMessage(const std::string& message) if (message.length() > 2) { m_webPort = std::atoi(message.substr(2).c_str()); ESP_LOGI(TAG, "Discovered JSON web server port: %d", m_webPort); - if (m_webPortCallback) { - m_webPortCallback(m_webPort); - } + m_webPortCallback(m_webPort); } } else if (message.length() > 1 && message[1] == 'P') { // Power message (PPA) @@ -816,13 +809,11 @@ void WiThrottleClient::handlePowerMessage(const std::string& message) m_progTrackPower = newState; // Notify callbacks - if (m_powerCallback) { - if (mainChanged) { - m_powerCallback("main", newState); - } - if (progChanged) { - m_powerCallback("prog", newState); - } + if (mainChanged) { + m_powerCallback("main", newState); + } + if (progChanged) { + m_powerCallback("prog", newState); } } @@ -884,9 +875,7 @@ void WiThrottleClient::setState(ConnectionState newState) m_state = newState; ESP_LOGI(TAG, "Connection state changed: %d", (int)newState); - if (m_connectionCallback) { - m_connectionCallback(newState); - } + m_connectionCallback(newState); } } @@ -963,8 +952,9 @@ void WiThrottleClient::handleRosterMessage(const std::string& message) // Expect ]\[ delimiter (3 characters: ], \, [) if (pos + 2 >= message.length() || message[pos] != '\\' || message[pos + 1] != '[') { - ESP_LOGW(TAG, "Expected \\[ at position %d (got '%c%c')", - pos, message[pos], message[pos+1]); + // Not message[pos + 1]: pos can already be at the end (F-42). + ESP_LOGW(TAG, "Roster entry %d has no \\[ at offset %u", + i + 1, static_cast(pos)); break; } pos += 2; // Skip the \[ @@ -1017,9 +1007,7 @@ void WiThrottleClient::handleRosterMessage(const std::string& message) } // Notify callback with snapshot - if (m_rosterCallback) { - m_rosterCallback(newRoster); - } + m_rosterCallback(newRoster); } void WiThrottleClient::handleThrottleMessage(const std::string& message) @@ -1071,9 +1059,7 @@ void WiThrottleClient::handleThrottleMessage(const std::string& message) labels.resize(29); } - if (m_functionLabelsCallback) { - m_functionLabelsCallback(throttleId, labels); - } + m_functionLabelsCallback(throttleId, labels); return; } @@ -1149,7 +1135,5 @@ void WiThrottleClient::handleThrottleMessage(const std::string& message) } // Notify callback - if (m_throttleCallback) { - m_throttleCallback(update); - } + m_throttleCallback(update); } diff --git a/main/communication/WiThrottleClient.h b/main/communication/WiThrottleClient.h index cfcc4c9..3b46f54 100644 --- a/main/communication/WiThrottleClient.h +++ b/main/communication/WiThrottleClient.h @@ -4,11 +4,13 @@ #include #include #include +#include #include "esp_err.h" #include "sdkconfig.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" +#include "CallbackSlot.h" /** * @brief WiThrottle Protocol Client for JMRI @@ -220,32 +222,32 @@ class WiThrottleClient { /** * @brief Set power state change callback */ - void setPowerStateCallback(PowerStateCallback callback) { m_powerCallback = callback; } + void setPowerStateCallback(PowerStateCallback callback) { m_powerCallback.set(std::move(callback)); } /** * @brief Set connection state change callback */ - void setConnectionStateCallback(ConnectionStateCallback callback) { m_connectionCallback = callback; } + void setConnectionStateCallback(ConnectionStateCallback callback) { m_connectionCallback.set(std::move(callback)); } /** * @brief Set roster update callback */ - void setRosterCallback(RosterCallback callback) { m_rosterCallback = callback; } + void setRosterCallback(RosterCallback callback) { m_rosterCallback.set(std::move(callback)); } /** * @brief Set web port discovery callback */ - void setWebPortCallback(WebPortCallback callback) { m_webPortCallback = callback; } + void setWebPortCallback(WebPortCallback callback) { m_webPortCallback.set(std::move(callback)); } /** * @brief Set throttle state change callback */ - void setThrottleStateCallback(ThrottleStateCallback callback) { m_throttleCallback = callback; } + void setThrottleStateCallback(ThrottleStateCallback callback) { m_throttleCallback.set(std::move(callback)); } /** * @brief Set function labels callback */ - void setFunctionLabelsCallback(FunctionLabelsCallback callback) { m_functionLabelsCallback = callback; } + void setFunctionLabelsCallback(FunctionLabelsCallback callback) { m_functionLabelsCallback.set(std::move(callback)); } /** * @brief Get a copy of the current roster (thread-safe) @@ -344,12 +346,13 @@ class WiThrottleClient { std::vector m_roster; uint16_t m_webPort; - PowerStateCallback m_powerCallback; - ConnectionStateCallback m_connectionCallback; - RosterCallback m_rosterCallback; - WebPortCallback m_webPortCallback; - FunctionLabelsCallback m_functionLabelsCallback; - ThrottleStateCallback m_throttleCallback; + // Set on the LVGL or main task, invoked on the receive task (F-30). + CallbackSlot m_powerCallback; + CallbackSlot m_connectionCallback; + CallbackSlot&)> m_rosterCallback; + CallbackSlot m_webPortCallback; + CallbackSlot&)> m_functionLabelsCallback; + CallbackSlot m_throttleCallback; mutable SemaphoreHandle_t m_stateMutex; SemaphoreHandle_t m_sendMutex; diff --git a/main/controller/AppController.cpp b/main/controller/AppController.cpp index eaee18f..f7e2d61 100644 --- a/main/controller/AppController.cpp +++ b/main/controller/AppController.cpp @@ -12,6 +12,8 @@ #include "ThrottleController.h" #include "WiFiController.h" #include "JmriConnectionController.h" +#include "SettingsWriter.h" +#include "../utils/StackReport.h" #include "../hardware/RotaryEncoderHal.h" #include "esp_log.h" #include "esp_timer.h" @@ -47,7 +49,8 @@ AppController& AppController::instance() } AppController::AppController() - : m_mainScreen(nullptr) + : m_settingsWriter(nullptr) + , m_mainScreen(nullptr) , m_wifiConfigScreen(nullptr) , m_jmriConfigScreen(nullptr) , m_wiThrottleClient(nullptr) @@ -68,6 +71,12 @@ void AppController::initialise() return; } + // First: every screen and the JMRI controller hand their NVS writes to it. + if (!m_settingsWriter) { + m_settingsWriter = std::make_unique(); + m_settingsWriter->start(); + } + if (!m_wifiController) { m_wifiController = std::make_unique(); m_wifiController->autoConnect(); @@ -93,7 +102,8 @@ void AppController::initialise() m_jmriConnectionController = std::make_unique( m_jmriClient.get(), m_wiThrottleClient.get(), - m_wifiController.get()); + m_wifiController.get(), + m_settingsWriter.get()); } // Only the selected transport's stack is brought up. Auto-connecting @@ -152,6 +162,9 @@ void AppController::initialise() m_rotaryEncoderHal->startPollingTask(); } + // Nothing unless CONFIG_THROTTLE_STACK_REPORT is set (F-33). + StackReport::start(); + m_initialised = true; } @@ -183,7 +196,8 @@ void AppController::showWiFiConfigScreen() } if (!m_wifiConfigScreen) { - m_wifiConfigScreen = std::make_unique(*manager); + m_wifiConfigScreen = std::make_unique(*manager, + m_settingsWriter.get()); } m_wifiConfigScreen->create(); } @@ -198,7 +212,8 @@ void AppController::showSettingsScreen() m_wifiController.get(), m_rotaryEncoderHal.get(), m_jmriClient.get(), - m_wiThrottleClient.get()); + m_wiThrottleClient.get(), + m_settingsWriter.get()); m_settingsScreen->create(); } @@ -223,7 +238,7 @@ void AppController::showOrchestratorConfigScreen() // The screen handles that and still lets the settings be edited, so a // switch can be configured before it is switched to. m_orchestratorConfigScreen = std::make_unique( - m_orchestratorClient.get(), m_wifiController.get()); + m_orchestratorClient.get(), m_wifiController.get(), m_settingsWriter.get()); } m_orchestratorConfigScreen->create(); } @@ -367,3 +382,8 @@ RotaryEncoderHal* AppController::getRotaryEncoderHal() const { return m_rotaryEncoderHal.get(); } + +SettingsWriter* AppController::getSettingsWriter() const +{ + return m_settingsWriter.get(); +} diff --git a/main/controller/AppController.h b/main/controller/AppController.h index 069fb29..29e258d 100644 --- a/main/controller/AppController.h +++ b/main/controller/AppController.h @@ -17,6 +17,7 @@ class OrchestratorConfigScreen; class WiFiController; class JmriConnectionController; class RotaryEncoderHal; +class SettingsWriter; /** * @brief Application-level controller that owns shared state and services. @@ -56,6 +57,7 @@ class AppController { WiFiController* getWiFiController() const; JmriConnectionController* getJmriConnectionController() const; RotaryEncoderHal* getRotaryEncoderHal() const; + SettingsWriter* getSettingsWriter() const; private: AppController(); @@ -72,6 +74,8 @@ class AppController { */ static void orchestratorConnectTask(void* arg); + /** Declared first, so it outlives everything holding a pointer to it. */ + std::unique_ptr m_settingsWriter; std::unique_ptr m_mainScreen; std::unique_ptr m_wifiConfigScreen; std::unique_ptr m_settingsScreen; diff --git a/main/controller/JmriConnectionController.cpp b/main/controller/JmriConnectionController.cpp index c92407f..be0541a 100644 --- a/main/controller/JmriConnectionController.cpp +++ b/main/controller/JmriConnectionController.cpp @@ -1,4 +1,5 @@ #include "JmriConnectionController.h" +#include "SettingsWriter.h" #include "WiFiController.h" #include "../communication/JmriJsonClient.h" #include "../communication/WiThrottleClient.h" @@ -49,10 +50,12 @@ uint16_t parsePort(const char* text, uint16_t fallback) JmriConnectionController::JmriConnectionController(JmriJsonClient* jsonClient, WiThrottleClient* wtClient, - WiFiController* wifiController) + WiFiController* wifiController, + SettingsWriter* settingsWriter) : m_jsonClient(jsonClient) , m_wtClient(wtClient) , m_wifiController(wifiController) + , m_settingsWriter(settingsWriter) , m_mutex(nullptr) , m_autoReconnect(false) , m_request(Request::NONE) @@ -160,16 +163,6 @@ void JmriConnectionController::saveJsonPort(uint16_t port) nvs_close(handle); } -void JmriConnectionController::saveTask(void* arg) -{ - Settings* settings = static_cast(arg); - if (settings) { - saveSettings(*settings); - delete settings; - } - vTaskDelete(nullptr); -} - // --- Requests --------------------------------------------------------------- void JmriConnectionController::start() @@ -226,12 +219,12 @@ void JmriConnectionController::requestConnect(const std::string& serverIp, // The orchestrator is the selected transport, and the device must not // sit retrying a server the operator did not choose. Keep the settings // for when WiThrottle is selected. Saved off the calling task, which is - // the LVGL task (F-05). + // the LVGL task (F-05), and in order with every other UI save (F-39). ESP_LOGW(TAG, "WiThrottle is not the selected transport; saving JMRI settings only"); - auto* copy = new Settings(settings); - if (xTaskCreate(saveTask, "jmri_save", 3072, copy, 4, nullptr) != pdPASS) { - ESP_LOGE(TAG, "Failed to create JMRI settings save task"); - delete copy; + if (m_settingsWriter) { + m_settingsWriter->post([settings]() { saveSettings(settings); }); + } else { + ESP_LOGE(TAG, "No settings writer; JMRI settings not saved"); } return; } diff --git a/main/controller/JmriConnectionController.h b/main/controller/JmriConnectionController.h index 20632ba..ed54f4e 100644 --- a/main/controller/JmriConnectionController.h +++ b/main/controller/JmriConnectionController.h @@ -11,6 +11,7 @@ class JmriJsonClient; class WiThrottleClient; class WiFiController; +class SettingsWriter; /** * @brief Owns the JMRI connection: its saved settings, its reconnection, and @@ -28,7 +29,8 @@ class WiFiController; */ class JmriConnectionController { public: - JmriConnectionController(JmriJsonClient* jsonClient, WiThrottleClient* wtClient, WiFiController* wifiController); + JmriConnectionController(JmriJsonClient* jsonClient, WiThrottleClient* wtClient, + WiFiController* wifiController, SettingsWriter* settingsWriter); ~JmriConnectionController(); JmriConnectionController(const JmriConnectionController&) = delete; @@ -78,7 +80,6 @@ class JmriConnectionController { static Settings loadSettings(); static void saveSettings(const Settings& settings); static void saveJsonPort(uint16_t port); - static void saveTask(void* arg); static void workerTask(void* arg); void runWorker(); @@ -90,6 +91,7 @@ class JmriConnectionController { JmriJsonClient* m_jsonClient; WiThrottleClient* m_wtClient; WiFiController* m_wifiController; + SettingsWriter* m_settingsWriter; // Guarded by m_mutex: written by the requesting task, read by the worker. mutable SemaphoreHandle_t m_mutex; diff --git a/main/controller/SettingsWriter.cpp b/main/controller/SettingsWriter.cpp new file mode 100644 index 0000000..6545350 --- /dev/null +++ b/main/controller/SettingsWriter.cpp @@ -0,0 +1,88 @@ +#include "SettingsWriter.h" + +#include + +#include "esp_log.h" + +static const char* TAG = "SettingsWriter"; + +SettingsWriter::SettingsWriter() + : m_queue(nullptr) + , m_task(nullptr) +{ +} + +SettingsWriter::~SettingsWriter() +{ + // Owned by the AppController singleton, which is never destroyed; this is + // for completeness. Queued work still pending is dropped. + if (m_task) { + vTaskDelete(m_task); + m_task = nullptr; + } + if (m_queue) { + Work* pending = nullptr; + while (xQueueReceive(m_queue, &pending, 0) == pdTRUE) { + delete pending; + } + vQueueDelete(m_queue); + m_queue = nullptr; + } +} + +esp_err_t SettingsWriter::start() +{ + if (m_task) { + return ESP_OK; + } + + // Holds pointers: a std::function is not trivially copyable, and a queue + // copies its items byte for byte. + m_queue = xQueueCreate(QUEUE_DEPTH, sizeof(Work*)); + if (!m_queue) { + ESP_LOGE(TAG, "Failed to create queue"); + return ESP_ERR_NO_MEM; + } + + if (xTaskCreate(taskFunc, "settings_writer", STACK_BYTES, this, PRIORITY, &m_task) != pdPASS) { + ESP_LOGE(TAG, "Failed to create task"); + vQueueDelete(m_queue); + m_queue = nullptr; + m_task = nullptr; + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +void SettingsWriter::post(Work work) +{ + if (!work) { + return; + } + + if (m_queue) { + auto* queued = new Work(std::move(work)); + if (xQueueSend(m_queue, &queued, 0) == pdTRUE) { + return; + } + ESP_LOGW(TAG, "Queue full; writing on the caller's task"); + work = std::move(*queued); + delete queued; + } else { + ESP_LOGW(TAG, "Not started; writing on the caller's task"); + } + work(); +} + +void SettingsWriter::taskFunc(void* arg) +{ + auto* self = static_cast(arg); + for (;;) { + Work* work = nullptr; + if (xQueueReceive(self->m_queue, &work, portMAX_DELAY) != pdTRUE || !work) { + continue; + } + (*work)(); + delete work; + } +} diff --git a/main/controller/SettingsWriter.h b/main/controller/SettingsWriter.h new file mode 100644 index 0000000..d5454ac --- /dev/null +++ b/main/controller/SettingsWriter.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "esp_err.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +/** + * @brief Carries out the UI's NVS writes off the LVGL task, in the order asked. + * + * An LVGL event handler must not block (F-05), and an NVS write can when it + * has to erase a page first. The screens hand their writes here instead + * (F-39). + * + * One task and a queue rather than a task per write: two saves of the same + * setting, made in quick succession, then land in the order they were made. + * Work that must follow a save -- waking the orchestrator supervisor to log + * in with it -- is posted after the save for the same reason. + * + * The task's stack is internal RAM, as it must be: flash writes disable the + * cache, and PSRAM with it. + */ +class SettingsWriter { +public: + using Work = std::function; + + SettingsWriter(); + ~SettingsWriter(); + + SettingsWriter(const SettingsWriter&) = delete; + SettingsWriter& operator=(const SettingsWriter&) = delete; + + /** Creates the queue and the task. Idempotent. */ + esp_err_t start(); + + /** + * @brief Queues work to run on the writer task. + * + * If the writer never started or its queue is full, the work runs here + * instead, with a warning: a stalled frame is better than a lost save. + */ + void post(Work work); + +private: + static void taskFunc(void* arg); + + static constexpr int QUEUE_DEPTH = 8; + static constexpr uint32_t STACK_BYTES = 4096; + static constexpr UBaseType_t PRIORITY = 3; + + QueueHandle_t m_queue; + TaskHandle_t m_task; +}; diff --git a/main/controller/ThrottleController.cpp b/main/controller/ThrottleController.cpp index 60d0acc..a1b3c39 100644 --- a/main/controller/ThrottleController.cpp +++ b/main/controller/ThrottleController.cpp @@ -1,4 +1,5 @@ #include "ThrottleController.h" +#include "StackReport.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "nvs_flash.h" @@ -13,6 +14,12 @@ static const char* NVS_KEY_SPEED_STEPS = "speed_steps"; // multiplication that would overflow (F-19). static constexpr int MAX_ROTATION_DELTA = 126; +/** The top of the 128-step range every transport here speaks. */ +static constexpr int MAX_SPEED = 126; + +/** F0-F28: what the function panel and the model's arrays hold. */ +static constexpr int MAX_FUNCTION_NUMBER = 28; + // The port validates throttle ids against its own bound, so a divergence here // would let an id this controller thinks is valid be refused by every backend. static_assert(ThrottleController::NUM_THROTTLES == ThrottleBackend::MAX_THROTTLES, @@ -21,10 +28,6 @@ static_assert(ThrottleController::NUM_THROTTLES == ThrottleBackend::MAX_THROTTLE ThrottleController::ThrottleController(ThrottleBackend* backend) : m_backend(backend) , m_stateMutex(nullptr) - , m_uiUpdateCallback(nullptr) - , m_uiUpdateUserData(nullptr) - , m_trackPowerCallback(nullptr) - , m_trackPowerUserData(nullptr) , m_pollingTask(nullptr) , m_pollingRunning(false) , m_cachedSpeedSteps(4) @@ -55,9 +58,7 @@ ThrottleController::ThrottleController(ThrottleBackend* backend) ); m_backend->setTrackPowerCallback( [this](ThrottleBackend::TrackPower state) { - if (m_trackPowerCallback) { - m_trackPowerCallback(m_trackPowerUserData, state); - } + m_trackPowerCallback(state); } ); if (m_backend->providesFunctionLabels()) { @@ -269,7 +270,7 @@ void ThrottleController::onKnobRotation(int knobId, int delta) currentDirection); } - ESP_LOGI(TAG, "Knob %d changed throttle %d speed: %d -> %d (dir: %s -> %s, steps: %d, optimistic + polling)", + ESP_LOGD(TAG, "Knob %d changed throttle %d speed: %d -> %d (dir: %s -> %s, steps: %d, optimistic + polling)", knobId, throttleId, currentSpeed, @@ -524,8 +525,13 @@ ThrottleBackend::TrackPower ThrottleController::getTrackPower() const void ThrottleController::setTrackPowerCallback( void (*callback)(void*, ThrottleBackend::TrackPower), void* userData) { - m_trackPowerCallback = callback; - m_trackPowerUserData = userData; + if (!callback) { + m_trackPowerCallback.clear(); + return; + } + m_trackPowerCallback.set([callback, userData](ThrottleBackend::TrackPower state) { + callback(userData, state); + }); } void ThrottleController::trackPowerTaskFunc(void* arg) @@ -535,6 +541,7 @@ void ThrottleController::trackPowerTaskFunc(void* arg) request->controller->m_backend->setTrackPower(request->on); } delete request; + StackReport::logSelf(); vTaskDelete(nullptr); } @@ -589,15 +596,16 @@ bool ThrottleController::getLocoAtRosterIndex(int index, ThrottleBackend::Roster void ThrottleController::setUIUpdateCallback(void (*callback)(void*), void* userData) { - m_uiUpdateCallback = callback; - m_uiUpdateUserData = userData; + if (!callback) { + m_uiUpdateCallback.clear(); + return; + } + m_uiUpdateCallback.set([callback, userData]() { callback(userData); }); } void ThrottleController::updateUI() { - if (m_uiUpdateCallback) { - m_uiUpdateCallback(m_uiUpdateUserData); - } + m_uiUpdateCallback(); } bool ThrottleController::lockState(TickType_t timeout) const @@ -773,22 +781,39 @@ void ThrottleController::onThrottleStateChanged(const ThrottleBackend::ThrottleU Throttle* throttle = m_throttles[throttleId].get(); - // Update speed if present - if (update.speed >= 0) { + // A report for a loco this throttle no longer holds is a late reply about + // the previous one; applied, it would set the new loco's baseline from the + // old loco's state (F-37). Address 0 means the transport named none. + const Locomotive* loco = throttle->getLocomotive(); + if (update.address > 0 && (!loco || loco->getAddress() != update.address)) { + const int heldAddress = loco ? loco->getAddress() : 0; + unlockState(); + ESP_LOGD(TAG, "Ignoring update for loco %d: throttle %d holds %d", + update.address, throttleId, heldAddress); + return; + } + + // Per-update logs are DEBUG: at INFO the console write blocks the + // receive task on every report (F-38). + if (update.speed > MAX_SPEED) { + ESP_LOGW(TAG, "Ignoring out-of-range speed %d for throttle %d", update.speed, throttleId); + } else if (update.speed >= 0) { throttle->setSpeed(update.speed); - ESP_LOGI(TAG, "Throttle %d speed updated: %d", throttleId, update.speed); + ESP_LOGD(TAG, "Throttle %d speed updated: %d", throttleId, update.speed); } - // Update direction if present if (update.direction >= 0) { throttle->setDirection(update.direction == 1); - ESP_LOGI(TAG, "Throttle %d direction updated: %s", throttleId, update.direction ? "forward" : "reverse"); + ESP_LOGD(TAG, "Throttle %d direction updated: %s", throttleId, update.direction ? "forward" : "reverse"); } - // Update function if present - if (update.function >= 0) { + // Bounded: each new number grows the throttle's function list and the + // panel, and the UI has F0-F28 (F-37). + if (update.function > MAX_FUNCTION_NUMBER) { + ESP_LOGD(TAG, "Ignoring function %d for throttle %d", update.function, throttleId); + } else if (update.function >= 0) { throttle->setFunctionState(update.function, update.functionState); - ESP_LOGI(TAG, "Throttle %d function %d: %s", throttleId, update.function, update.functionState ? "on" : "off"); + ESP_LOGD(TAG, "Throttle %d function %d: %s", throttleId, update.function, update.functionState ? "on" : "off"); } unlockState(); diff --git a/main/controller/ThrottleController.h b/main/controller/ThrottleController.h index 4528e2d..a56ff99 100644 --- a/main/controller/ThrottleController.h +++ b/main/controller/ThrottleController.h @@ -2,6 +2,7 @@ #include "Knob.h" #include "Throttle.h" +#include "CallbackSlot.h" #include "ThrottleBackend.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -260,11 +261,11 @@ class ThrottleController mutable SemaphoreHandle_t m_stateMutex; - void (*m_uiUpdateCallback)(void*); - void* m_uiUpdateUserData; - - void (*m_trackPowerCallback)(void*, ThrottleBackend::TrackPower); - void* m_trackPowerUserData; + // Set by the UI on the LVGL task, invoked from network and encoder tasks. + // Each slot holds the function and its userData as one closure, so a + // caller can never see one half of a pair being replaced (F-30). + CallbackSlot m_uiUpdateCallback; + CallbackSlot m_trackPowerCallback; TaskHandle_t m_pollingTask; bool m_pollingRunning; diff --git a/main/hardware/RotaryEncoderHal.cpp b/main/hardware/RotaryEncoderHal.cpp index c8122d3..8590738 100644 --- a/main/hardware/RotaryEncoderHal.cpp +++ b/main/hardware/RotaryEncoderHal.cpp @@ -1,5 +1,6 @@ #include "RotaryEncoderHal.h" #include "esp_log.h" +#include "esp_rom_sys.h" #include "waveshare_rgb_lcd_port.h" static const char* TAG = "RotaryEncoderHal"; @@ -8,6 +9,9 @@ namespace { constexpr int ENCODER_I2C_TIMEOUT_MS = 20; constexpr int ENCODER_POLL_MS = 100; constexpr int ENCODER_READ_RETRY_DELAY_MS = 5; + /** Between a register request and the read of its answer. Adafruit's own + * Seesaw library waits 250 us; this leaves some margin. */ + constexpr uint32_t SEESAW_RESPONSE_DELAY_US = 500; constexpr uint8_t SEESAW_GPIO_BASE = 0x01; constexpr uint8_t SEESAW_GPIO_DIRCLR_BULK = 0x03; constexpr uint8_t SEESAW_GPIO_BULK = 0x04; @@ -147,9 +151,20 @@ bool RotaryEncoderHal::probeAddress(uint8_t address) bool RotaryEncoderHal::readBytes(uint8_t address, uint8_t base, uint8_t reg, uint8_t* data, size_t length) { - uint8_t cmd[2] = {base, reg}; - esp_err_t err = i2c_master_write_read_device(m_port, address, cmd, sizeof(cmd), data, length, - pdMS_TO_TICKS(ENCODER_I2C_TIMEOUT_MS)); + // Request, wait, then read, as two transactions: the sequence the Seesaw + // expects, and the one Adafruit's library uses. A combined write-read gave + // it no time to prepare the answer, which the old code covered by reading + // everything twice. The delta register clears on every read, so rotation + // between those two reads was lost (F-41). + const uint8_t cmd[2] = {base, reg}; + esp_err_t err = i2c_master_write_to_device(m_port, address, cmd, sizeof(cmd), + pdMS_TO_TICKS(ENCODER_I2C_TIMEOUT_MS)); + if (err != ESP_OK) { + return false; + } + esp_rom_delay_us(SEESAW_RESPONSE_DELAY_US); + err = i2c_master_read_from_device(m_port, address, data, length, + pdMS_TO_TICKS(ENCODER_I2C_TIMEOUT_MS)); return err == ESP_OK; } @@ -168,11 +183,7 @@ bool RotaryEncoderHal::writeBytes(uint8_t address, uint8_t base, uint8_t reg, co bool RotaryEncoderHal::readEncoderDelta(uint8_t address, int32_t& outDelta) { uint8_t data[4] = {0}; - // Read twice to mirror observed hardware behaviour (second read yields current delta) - if (!readBytes(address, SEESAW_ENCODER_BASE, SEESAW_ENCODER_DELTA, data, sizeof(data))) { - return false; - } - vTaskDelay(pdMS_TO_TICKS(ENCODER_READ_RETRY_DELAY_MS)); + // Once: readBytes now waits for the answer (F-41). if (!readBytes(address, SEESAW_ENCODER_BASE, SEESAW_ENCODER_DELTA, data, sizeof(data))) { return false; } @@ -190,10 +201,6 @@ bool RotaryEncoderHal::readButtonPressed(uint8_t address, bool& outPressed) if (!readBytes(address, SEESAW_GPIO_BASE, SEESAW_GPIO_BULK, data, sizeof(data))) { return false; } - vTaskDelay(pdMS_TO_TICKS(ENCODER_READ_RETRY_DELAY_MS)); - if (!readBytes(address, SEESAW_GPIO_BASE, SEESAW_GPIO_BULK, data, sizeof(data))) { - return false; - } uint32_t gpio = (static_cast(data[0]) << 24) | (static_cast(data[1]) << 16) | diff --git a/main/tests/OrchestratorProtocolTests.cpp b/main/tests/OrchestratorProtocolTests.cpp index d5966e9..74db73e 100644 --- a/main/tests/OrchestratorProtocolTests.cpp +++ b/main/tests/OrchestratorProtocolTests.cpp @@ -3,6 +3,7 @@ #include #include +#include "JsonArraySplitter.h" #include "OrchestratorBackend.h" #include "OrchestratorClient.h" @@ -496,6 +497,121 @@ static void test_orch_backend_function_commands_are_states(void) TEST_ASSERT_FALSE(backend.functionCommandIsButtonEvent()); } +// --- Roster streaming (F-35) ------------------------------------------------- + +namespace { + +/** Feeds text through a splitter one byte at a time, so every element + * straddles a chunk boundary, and collects what it hands over. */ +bool splitBytewise(JsonArraySplitter& splitter, const std::string& text, + std::vector& elements) +{ + bool ok = true; + for (char c : text) { + ok = splitter.feed(&c, 1, [&elements](const std::string& element) { + elements.push_back(element); + return true; + }); + } + return ok; +} + +} // namespace + +static void test_roster_splitter_yields_each_record(void) +{ + JsonArraySplitter splitter(4096); + std::vector elements; + const std::string roster = + " [ {\"address\":3,\"name\":\"Shunter\"} ,\n" + "{\"address\":4472,\"name\":\"Flying Scotsman\",\"tags\":[1,2]} ] "; + + TEST_ASSERT_TRUE(splitBytewise(splitter, roster, elements)); + TEST_ASSERT_TRUE(splitter.complete()); + TEST_ASSERT_EQUAL_INT(2, (int)elements.size()); + TEST_ASSERT_EQUAL_STRING("{\"address\":3,\"name\":\"Shunter\"}", elements[0].c_str()); + TEST_ASSERT_EQUAL_STRING( + "{\"address\":4472,\"name\":\"Flying Scotsman\",\"tags\":[1,2]}", elements[1].c_str()); +} + +static void test_roster_splitter_ignores_brackets_inside_strings(void) +{ + JsonArraySplitter splitter(4096); + std::vector elements; + // Braces, brackets and an escaped quote inside a name must not end the record. + const std::string roster = "[{\"name\":\"A } ] \\\" {\",\"address\":7}]"; + + TEST_ASSERT_TRUE(splitBytewise(splitter, roster, elements)); + TEST_ASSERT_TRUE(splitter.complete()); + TEST_ASSERT_EQUAL_INT(1, (int)elements.size()); + TEST_ASSERT_EQUAL_STRING("{\"name\":\"A } ] \\\" {\",\"address\":7}", elements[0].c_str()); +} + +static void test_roster_splitter_empty_array(void) +{ + JsonArraySplitter splitter(4096); + std::vector elements; + TEST_ASSERT_TRUE(splitBytewise(splitter, "[]", elements)); + TEST_ASSERT_TRUE(splitter.complete()); + TEST_ASSERT_EQUAL_INT(0, (int)elements.size()); +} + +static void test_roster_splitter_truncated_is_not_complete(void) +{ + JsonArraySplitter splitter(4096); + std::vector elements; + // Cut off mid-record: the caller must refuse this, not keep the first loco. + TEST_ASSERT_TRUE(splitBytewise(splitter, "[{\"address\":3},{\"addr", elements)); + TEST_ASSERT_FALSE(splitter.complete()); + TEST_ASSERT_FALSE(splitter.failed()); +} + +static void test_roster_splitter_refuses_malformed_streams(void) +{ + const char* malformed[] = { + "{\"address\":3}", // not an array + "[1,2,3]", // bare values, not records + "[{\"a\":1},]", // trailing comma + "[{\"a\":1}{\"a\":2}]", // missing comma + "[{\"a\":1}] x", // trailing garbage + }; + for (const char* text : malformed) { + JsonArraySplitter splitter(4096); + std::vector elements; + TEST_ASSERT_FALSE_MESSAGE(splitBytewise(splitter, text, elements), text); + TEST_ASSERT_TRUE_MESSAGE(splitter.failed(), text); + } +} + +static void test_roster_splitter_skips_oversized_record(void) +{ + JsonArraySplitter splitter(32); + std::vector elements; + const std::string big = "{\"address\":5,\"name\":\"" + std::string(64, 'x') + "\"}"; + const std::string roster = "[{\"address\":3}," + big + ",{\"address\":4}]"; + + // One record too long costs that loco, not the roster. + TEST_ASSERT_TRUE(splitBytewise(splitter, roster, elements)); + TEST_ASSERT_TRUE(splitter.complete()); + TEST_ASSERT_EQUAL_INT(2, (int)elements.size()); + TEST_ASSERT_EQUAL_INT(1, (int)splitter.oversizedElements()); + TEST_ASSERT_EQUAL_STRING("{\"address\":4}", elements[1].c_str()); +} + +static void test_roster_splitter_callback_refusal_fails_stream(void) +{ + JsonArraySplitter splitter(4096); + const std::string roster = "[{\"a\":1},{\"a\":2}]"; + int seen = 0; + const bool ok = splitter.feed(roster.data(), roster.size(), [&seen](const std::string&) { + ++seen; + return false; + }); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_TRUE(splitter.failed()); + TEST_ASSERT_EQUAL_INT(1, seen); +} + extern "C" void register_orchestrator_tests(void) { RUN_TEST(test_orch_cookie_extracted_from_set_cookie); @@ -524,4 +640,11 @@ extern "C" void register_orchestrator_tests(void) RUN_TEST(test_orch_snapshot_replaces_cached_state); RUN_TEST(test_orch_backend_refusal_reseeds_from_layout_state); RUN_TEST(test_orch_backend_function_commands_are_states); + RUN_TEST(test_roster_splitter_yields_each_record); + RUN_TEST(test_roster_splitter_ignores_brackets_inside_strings); + RUN_TEST(test_roster_splitter_empty_array); + RUN_TEST(test_roster_splitter_truncated_is_not_complete); + RUN_TEST(test_roster_splitter_refuses_malformed_streams); + RUN_TEST(test_roster_splitter_skips_oversized_record); + RUN_TEST(test_roster_splitter_callback_refusal_fails_stream); } diff --git a/main/tests/ThrottleControllerTests.cpp b/main/tests/ThrottleControllerTests.cpp index 96390ba..f85d1d6 100644 --- a/main/tests/ThrottleControllerTests.cpp +++ b/main/tests/ThrottleControllerTests.cpp @@ -1,4 +1,6 @@ #include "unity.h" + +#include "CallbackSlot.h" #include "ThrottleController.h" #include "ThrottleBackend.h" #include "Locomotive.h" @@ -396,6 +398,73 @@ static void test_controller_applies_backend_throttle_update(void) TEST_ASSERT_FALSE(throttle->getDirection()); } +static void test_controller_ignores_update_for_other_loco(void) +{ + FakeThrottleBackend backend; + ThrottleController controller(&backend); + setupThrottleWithLoco(controller, 1, 0, "LocoQ", 90); + Throttle* throttle = controller.getThrottle(1); + TEST_ASSERT_NOT_NULL(throttle); + throttle->setSpeed(10); + + // A late reply about the loco this throttle held before must not become + // the new loco's baseline (F-37). + ThrottleBackend::ThrottleUpdate update; + update.throttleId = 1; + update.address = 55; + update.speed = 80; + backend.emitThrottleUpdate(update); + TEST_ASSERT_EQUAL_INT(10, throttle->getCurrentSpeed()); + + update.address = 90; + backend.emitThrottleUpdate(update); + TEST_ASSERT_EQUAL_INT(80, throttle->getCurrentSpeed()); +} + +static void test_controller_bounds_reported_speed_and_function(void) +{ + FakeThrottleBackend backend; + ThrottleController controller(&backend); + setupThrottleWithLoco(controller, 2, 0, "LocoR", 91); + Throttle* throttle = controller.getThrottle(2); + TEST_ASSERT_NOT_NULL(throttle); + throttle->setSpeed(20); + const size_t functionsBefore = throttle->getFunctions().size(); + + ThrottleBackend::ThrottleUpdate update; + update.throttleId = 2; + update.address = 91; + update.speed = 500; + backend.emitThrottleUpdate(update); + TEST_ASSERT_EQUAL_INT(20, throttle->getCurrentSpeed()); + + // Each new function number grows the list and the panel (F-37). + update.speed = -1; + update.function = 9999; + update.functionState = true; + backend.emitThrottleUpdate(update); + TEST_ASSERT_EQUAL_INT((int)functionsBefore, (int)throttle->getFunctions().size()); +} + +static void test_callback_slot_invokes_a_copy(void) +{ + CallbackSlot slot; + int total = 0; + + slot(1); // unset: nothing happens + slot.set([&total](int value) { total += value; }); + slot(2); + + // The copy survives the slot being cleared under it (F-30). + auto copy = slot.get(); + slot.clear(); + slot(4); + TEST_ASSERT_TRUE(static_cast(copy)); + copy(8); + + TEST_ASSERT_EQUAL_INT(10, total); +} + static void test_controller_roster_comes_from_backend(void) { FakeThrottleBackend backend; @@ -727,4 +796,7 @@ extern "C" void register_controller_tests(void) RUN_TEST(test_controller_function_press_release_for_event_backend); RUN_TEST(test_controller_function_toggles_for_state_backend); RUN_TEST(test_controller_emergency_stop); + RUN_TEST(test_controller_ignores_update_for_other_loco); + RUN_TEST(test_controller_bounds_reported_speed_and_function); + RUN_TEST(test_callback_slot_invokes_a_copy); } diff --git a/main/ui/MainScreen.cpp b/main/ui/MainScreen.cpp index 7c46df9..303bd5c 100644 --- a/main/ui/MainScreen.cpp +++ b/main/ui/MainScreen.cpp @@ -302,12 +302,6 @@ void MainScreen::updateThrottle(int throttleId) // every knob dead. bool transportConnected = m_throttleController && m_throttleController->isConnected(); - // Repainted here too, so the status label follows the same link state the - // knobs do rather than a separate client's. - if (m_powerStatusBar) { - m_powerStatusBar->refresh(); - } - // Hide function panel when entering roster selection if (snapshot.state == Throttle::State::SELECTING && m_functionPanel && m_functionPanel->isVisible()) { m_functionPanel->hide(); @@ -336,6 +330,12 @@ void MainScreen::updateAllThrottles() updateThrottle(i); } + // Once per repaint rather than once per throttle (F-38), and from the + // same link state the knobs are gated on rather than a separate client's. + if (m_powerStatusBar) { + m_powerStatusBar->refresh(); + } + if (m_rosterCarousel) { m_rosterCarousel->update(m_throttleController); } @@ -516,7 +516,7 @@ void MainScreen::onVirtualEncoderRotation(void* userData, int knobId, int delta) MainScreen* screen = static_cast(userData); if (!screen->m_throttleController) return; - ESP_LOGI(TAG, "Virtual encoder: knob %d rotated %+d", knobId, delta); + ESP_LOGD(TAG, "Virtual encoder: knob %d rotated %+d", knobId, delta); screen->m_throttleController->onKnobRotation(knobId, delta); } diff --git a/main/ui/OrchestratorConfigScreen.cpp b/main/ui/OrchestratorConfigScreen.cpp index 538f492..7d23b43 100644 --- a/main/ui/OrchestratorConfigScreen.cpp +++ b/main/ui/OrchestratorConfigScreen.cpp @@ -3,6 +3,7 @@ #include "../communication/OrchestratorClient.h" #include "../controller/AppController.h" +#include "../controller/SettingsWriter.h" #include "../controller/WiFiController.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -18,7 +19,8 @@ constexpr int SCREEN_HEIGHT = 480; } // namespace OrchestratorConfigScreen::OrchestratorConfigScreen(OrchestratorClient* client, - WiFiController* wifiController) + WiFiController* wifiController, + SettingsWriter* settingsWriter) : m_screen(nullptr) , m_hostInput(nullptr) , m_portInput(nullptr) @@ -30,6 +32,7 @@ OrchestratorConfigScreen::OrchestratorConfigScreen(OrchestratorClient* client, , m_statusTimer(nullptr) , m_client(client) , m_wifiController(wifiController) + , m_settingsWriter(settingsWriter) { } @@ -280,23 +283,36 @@ void OrchestratorConfigScreen::loadSettings() void OrchestratorConfigScreen::saveSettings() { - // Read-modify-write: the transport *choice* is owned by the settings - // screen, so loading first stops saving here from silently reverting it. - TransportSettings settings = TransportSettings::load(); - - settings.host = textOf(m_hostInput); - settings.username = textOf(m_usernameInput); - settings.password = textOf(m_passwordInput); + // Read here, on the LVGL task: the widgets are LVGL's. + const std::string host = textOf(m_hostInput); + const std::string username = textOf(m_usernameInput); + const std::string password = textOf(m_passwordInput); + long port = 0; const std::string portText = textOf(m_portInput); if (!portText.empty()) { - const long port = strtol(portText.c_str(), nullptr, 10); + port = strtol(portText.c_str(), nullptr, 10); + } + + if (!m_settingsWriter) { + ESP_LOGE(TAG, "No settings writer; orchestrator settings not saved"); + return; + } + + // Written off the LVGL task (F-39). Read-modify-write, and all of it on + // the writer: the transport *choice* is owned by the settings screen, and + // loading at the moment of writing stops this reverting it. + m_settingsWriter->post([host, username, password, port]() { + TransportSettings settings = TransportSettings::load(); + settings.host = host; + settings.username = username; + settings.password = password; if (port > 0 && port <= 65535) { settings.port = static_cast(port); } - } - - settings.save(); + settings.save(); + ESP_LOGI(TAG, "Orchestrator settings saved"); + }); } void OrchestratorConfigScreen::updateStatus() @@ -351,7 +367,6 @@ void OrchestratorConfigScreen::onSaveClicked(lv_event_t* e) } self->hideKeyboard(); self->saveSettings(); - ESP_LOGI(TAG, "Orchestrator settings saved"); } void OrchestratorConfigScreen::onConnectClicked(lv_event_t* e) @@ -371,8 +386,13 @@ void OrchestratorConfigScreen::onConnectClicked(lv_event_t* e) // The supervising task logs in with the settings just saved. Connecting // from a task of this screen's own raced that supervisor for the same - // client (F-24, F-26); the status timer shows the result. - AppController::instance().requestOrchestratorReconnect(); + // client (F-24, F-26); the status timer shows the result. Posted behind + // the save, so the login reads what was typed rather than what was there. + if (self->m_settingsWriter) { + self->m_settingsWriter->post([]() { + AppController::instance().requestOrchestratorReconnect(); + }); + } } void OrchestratorConfigScreen::onBackClicked(lv_event_t* e) diff --git a/main/ui/OrchestratorConfigScreen.h b/main/ui/OrchestratorConfigScreen.h index b1ee9a2..dc28db1 100644 --- a/main/ui/OrchestratorConfigScreen.h +++ b/main/ui/OrchestratorConfigScreen.h @@ -7,6 +7,7 @@ class OrchestratorClient; class WiFiController; +class SettingsWriter; /** * @brief Layout orchestrator connection settings. @@ -21,7 +22,8 @@ class WiFiController; */ class OrchestratorConfigScreen { public: - OrchestratorConfigScreen(OrchestratorClient* client, WiFiController* wifiController); + OrchestratorConfigScreen(OrchestratorClient* client, WiFiController* wifiController, + SettingsWriter* settingsWriter); ~OrchestratorConfigScreen(); OrchestratorConfigScreen(const OrchestratorConfigScreen&) = delete; @@ -69,6 +71,8 @@ class OrchestratorConfigScreen { OrchestratorClient* m_client; WiFiController* m_wifiController; + /** NVS writes go here, never onto the LVGL task (F-39). */ + SettingsWriter* m_settingsWriter; static constexpr int PADDING = 10; static constexpr int BUTTON_HEIGHT = 50; diff --git a/main/ui/SettingsScreen.cpp b/main/ui/SettingsScreen.cpp index 9d3f4cf..be232c9 100644 --- a/main/ui/SettingsScreen.cpp +++ b/main/ui/SettingsScreen.cpp @@ -6,6 +6,7 @@ #include "../communication/JmriJsonClient.h" #include "../communication/WiThrottleClient.h" +#include "../controller/SettingsWriter.h" #include "../controller/ThrottleController.h" #include "../controller/WiFiController.h" #include "../hardware/RotaryEncoderHal.h" @@ -33,7 +34,8 @@ SettingsScreen::SettingsScreen(ThrottleController* throttleController, WiFiController* wifiController, RotaryEncoderHal* encoderHal, JmriJsonClient* jsonClient, - WiThrottleClient* wiThrottleClient) + WiThrottleClient* wiThrottleClient, + SettingsWriter* settingsWriter) : m_screen(nullptr) , m_transportDropdown(nullptr) , m_transportNoteLabel(nullptr) @@ -53,6 +55,7 @@ SettingsScreen::SettingsScreen(ThrottleController* throttleController, , m_encoderHal(encoderHal) , m_jsonClient(jsonClient) , m_wiThrottleClient(wiThrottleClient) + , m_settingsWriter(settingsWriter) , m_shownTransport(ThrottleTransport::WITHROTTLE) { } @@ -322,19 +325,29 @@ void SettingsScreen::saveSpeedSteps() if (speedSteps < 1) speedSteps = 1; if (speedSteps > 20) speedSteps = 20; - nvs_handle_t handle; - if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { - ESP_LOGE(TAG, "Failed to open NVS to save speed steps"); + if (!m_settingsWriter) { + ESP_LOGE(TAG, "No settings writer; speed steps not saved"); return; } - nvs_set_i32(handle, NVS_KEY_SPEED_STEPS, speedSteps); - nvs_commit(handle); - nvs_close(handle); - if (m_throttleController) { - m_throttleController->reloadSpeedStepsFromNvs(); - } - ESP_LOGI(TAG, "Speed steps saved: %d", speedSteps); + // Written off the LVGL task (F-39). The controller re-reads it once it is + // written; it outlives every screen, so the pointer is safe to capture. + ThrottleController* controller = m_throttleController; + m_settingsWriter->post([controller, speedSteps]() { + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + ESP_LOGE(TAG, "Failed to open NVS to save speed steps"); + return; + } + nvs_set_i32(handle, NVS_KEY_SPEED_STEPS, speedSteps); + nvs_commit(handle); + nvs_close(handle); + + if (controller) { + controller->reloadSpeedStepsFromNvs(); + } + ESP_LOGI(TAG, "Speed steps saved: %d", speedSteps); + }); } void SettingsScreen::updateStatus() @@ -477,8 +490,18 @@ void SettingsScreen::onTransportChanged(lv_event_t* e) return; } - settings.transport = chosen; - settings.save(); + // The read-modify-write itself happens on the writer, after any save + // already queued there -- an orchestrator Save made moments ago would + // otherwise be overwritten by the copy loaded above (F-39). + if (self->m_settingsWriter) { + self->m_settingsWriter->post([chosen]() { + TransportSettings latest = TransportSettings::load(); + latest.transport = chosen; + latest.save(); + }); + } else { + ESP_LOGE(TAG, "No settings writer; transport choice not saved"); + } if (self->m_transportNoteLabel) { // The backend is chosen once, during initialise(), because swapping it diff --git a/main/ui/SettingsScreen.h b/main/ui/SettingsScreen.h index 991c2da..0920763 100644 --- a/main/ui/SettingsScreen.h +++ b/main/ui/SettingsScreen.h @@ -10,6 +10,7 @@ class WiFiController; class RotaryEncoderHal; class JmriJsonClient; class WiThrottleClient; +class SettingsWriter; /** * @brief Device settings, and the front door to each transport's own config. @@ -34,7 +35,8 @@ class SettingsScreen { WiFiController* wifiController, RotaryEncoderHal* encoderHal, JmriJsonClient* jsonClient, - WiThrottleClient* wiThrottleClient); + WiThrottleClient* wiThrottleClient, + SettingsWriter* settingsWriter); ~SettingsScreen(); SettingsScreen(const SettingsScreen&) = delete; @@ -93,6 +95,8 @@ class SettingsScreen { RotaryEncoderHal* m_encoderHal; JmriJsonClient* m_jsonClient; WiThrottleClient* m_wiThrottleClient; + /** NVS writes go here, never onto the LVGL task (F-39). */ + SettingsWriter* m_settingsWriter; /** Captured at create() so the rows built match the rows updated. */ ThrottleTransport m_shownTransport; diff --git a/main/ui/WiFiConfigScreen.cpp b/main/ui/WiFiConfigScreen.cpp index e2a10f1..2e4ad36 100644 --- a/main/ui/WiFiConfigScreen.cpp +++ b/main/ui/WiFiConfigScreen.cpp @@ -1,5 +1,7 @@ #include "WiFiConfigScreen.h" #include "UiTheme.h" +#include "../controller/SettingsWriter.h" +#include "../utils/StackReport.h" #include "wrappers/wifi_config_wrapper.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -20,7 +22,7 @@ static const int SCREEN_HEIGHT = 480; static const int PADDING = 10; static const int BUTTON_HEIGHT = 50; -WiFiConfigScreen::WiFiConfigScreen(WiFiManager& wifiManager) +WiFiConfigScreen::WiFiConfigScreen(WiFiManager& wifiManager, SettingsWriter* settingsWriter) : m_screen(nullptr) , m_statusLabel(nullptr) , m_ssidLabel(nullptr) @@ -36,6 +38,7 @@ WiFiConfigScreen::WiFiConfigScreen(WiFiManager& wifiManager) , m_keyboard(nullptr) , m_keyboardLabel(nullptr) , m_wifiManager(wifiManager) + , m_settingsWriter(settingsWriter) , m_scanInProgress(false) { } @@ -423,6 +426,7 @@ void WiFiConfigScreen::scanTask(void* arg) } lvgl_port_unlock(); + StackReport::logSelf(); vTaskDelete(nullptr); return; } @@ -430,6 +434,7 @@ void WiFiConfigScreen::scanTask(void* arg) } screen->m_scanInProgress.store(false); + StackReport::logSelf(); vTaskDelete(nullptr); } @@ -495,7 +500,14 @@ void WiFiConfigScreen::disconnectWiFi() void WiFiConfigScreen::forgetWiFi() { ESP_LOGI(TAG, "Forgetting WiFi network and disconnecting"); - m_wifiManager.forgetNetwork(); + // Erases NVS keys, so off the LVGL task (F-39). The status label follows + // the manager's state callback once the disconnect lands. + WiFiManager* manager = &m_wifiManager; + if (m_settingsWriter) { + m_settingsWriter->post([manager]() { manager->forgetNetwork(); }); + } else { + manager->forgetNetwork(); + } updateStatus(); } diff --git a/main/ui/WiFiConfigScreen.h b/main/ui/WiFiConfigScreen.h index e3a6c06..ebc05b8 100644 --- a/main/ui/WiFiConfigScreen.h +++ b/main/ui/WiFiConfigScreen.h @@ -6,6 +6,8 @@ #include #include +class SettingsWriter; + /** * @brief WiFi configuration screen for network setup * @@ -19,7 +21,7 @@ */ class WiFiConfigScreen { public: - WiFiConfigScreen(WiFiManager& wifiManager); + WiFiConfigScreen(WiFiManager& wifiManager, SettingsWriter* settingsWriter); ~WiFiConfigScreen(); // Delete copy/move constructors @@ -61,6 +63,8 @@ class WiFiConfigScreen { // WiFi manager reference WiFiManager& m_wifiManager; + /** NVS writes go here, never onto the LVGL task (F-39). */ + SettingsWriter* m_settingsWriter; // Cached scan results std::vector m_scanResults; diff --git a/main/ui/components/PowerStatusBar.cpp b/main/ui/components/PowerStatusBar.cpp index 12949b2..b3a9842 100644 --- a/main/ui/components/PowerStatusBar.cpp +++ b/main/ui/components/PowerStatusBar.cpp @@ -19,6 +19,8 @@ PowerStatusBar::PowerStatusBar() , m_trackPowerButton(nullptr) , m_connectionStatusLabel(nullptr) , m_throttleController(nullptr) + , m_drawnTrackPower(-1) + , m_drawnConnected(-1) { } @@ -125,6 +127,11 @@ void PowerStatusBar::updateTrackPowerButton(ThrottleBackend::TrackPower state) lv_obj_t* label = lv_obj_get_child(m_trackPowerButton, 0); if (!label) return; + if (static_cast(state) == m_drawnTrackPower) { + return; + } + m_drawnTrackPower = static_cast(state); + uint32_t color; const char* stateText; @@ -153,6 +160,11 @@ void PowerStatusBar::updateConnectionStatus(bool connected) { if (!m_connectionStatusLabel) return; + if (static_cast(connected) == m_drawnConnected) { + return; + } + m_drawnConnected = static_cast(connected); + const char* icon = connected ? LV_SYMBOL_OK : LV_SYMBOL_CLOSE; const char* text = connected ? " Connected" : " Disconnected"; const uint32_t color = connected ? UiTheme::TEXT_OK : UiTheme::TEXT_MUTED; diff --git a/main/ui/components/PowerStatusBar.h b/main/ui/components/PowerStatusBar.h index 1e9b389..c1f36ee 100644 --- a/main/ui/components/PowerStatusBar.h +++ b/main/ui/components/PowerStatusBar.h @@ -45,4 +45,9 @@ class PowerStatusBar { lv_obj_t* m_connectionStatusLabel; ThrottleController* m_throttleController; + + /** What is on screen, so an unchanged repaint rewrites nothing (F-38). + * -1 until first drawn. */ + int m_drawnTrackPower; + int m_drawnConnected; }; diff --git a/main/ui/components/ThrottleMeter.cpp b/main/ui/components/ThrottleMeter.cpp index 7b61fb6..d61a181 100644 --- a/main/ui/components/ThrottleMeter.cpp +++ b/main/ui/components/ThrottleMeter.cpp @@ -25,6 +25,8 @@ ThrottleMeter::ThrottleMeter(lv_obj_t* parent, float scale) m_directionIndicator = nullptr; m_knobAvailable[0] = true; m_knobAvailable[1] = true; + m_drawnIndicatorLook[0] = -1; + m_drawnIndicatorLook[1] = -1; m_forwardDirection = true; m_lastDisplayedValue = m_value; m_lastUnitText.clear(); @@ -283,14 +285,24 @@ void ThrottleMeter::createButtons() void ThrottleMeter::updateKnobIndicators() { + // Called three times per throttle on every repaint. Setting a style + // invalidates the object even when the value is unchanged (F-38). + enum Look { UNAVAILABLE = 0, ACTIVE = 1, AVAILABLE = 2 }; for (int i = 0; i < 2; i++) { if (!m_knobIndicators[i]) continue; - if (!m_knobAvailable[i]) { + const int look = !m_knobAvailable[i] ? UNAVAILABLE + : (m_assignedKnob == i) ? ACTIVE : AVAILABLE; + if (look == m_drawnIndicatorLook[i]) { + continue; + } + m_drawnIndicatorLook[i] = look; + + if (look == UNAVAILABLE) { // Unavailable (other knob is active) - gray out lv_obj_set_style_bg_color(m_knobIndicators[i], UiTheme::colour(UiTheme::STATE_INACTIVE), 0); lv_obj_add_state(m_knobIndicators[i], LV_STATE_DISABLED); - } else if (m_assignedKnob == i) { + } else if (look == ACTIVE) { // Active knob - highlight lv_obj_set_style_bg_color(m_knobIndicators[i], UiTheme::colour(UiTheme::STATE_ACTIVE), 0); lv_obj_clear_state(m_knobIndicators[i], LV_STATE_DISABLED); diff --git a/main/ui/components/ThrottleMeter.h b/main/ui/components/ThrottleMeter.h index 3ab4617..9f2279f 100644 --- a/main/ui/components/ThrottleMeter.h +++ b/main/ui/components/ThrottleMeter.h @@ -156,6 +156,9 @@ class ThrottleMeter { bool m_animRunning; int m_assignedKnob; // -1, 0, or 1 bool m_knobAvailable[2]; // Availability for each knob + /** What each indicator last showed, so a repaint that changes nothing + * sets no style (F-38). -1 until first drawn. */ + int m_drawnIndicatorLook[2]; bool m_forwardDirection; // true=forward, false=reverse void* m_userData; // User data for callbacks diff --git a/main/utils/CallbackSlot.h b/main/utils/CallbackSlot.h new file mode 100644 index 0000000..efccdcc --- /dev/null +++ b/main/utils/CallbackSlot.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +template +class CallbackSlot; + +/** + * @brief A callback set on one task and invoked from another (F-30). + * + * The slots on the clients and the controller are assigned on the LVGL or + * main task and called from network and encoder tasks. A bare std::function + * assigned while another task is inside operator() can tear or be destroyed + * mid-call. This slot copies the function under its own mutex and invokes the + * copy with the mutex released -- the pattern OrchestratorClient already used. + * + * The mutex is never held across the call itself. A callback that takes the + * LVGL lock would otherwise order this mutex before LVGL's on one task while + * the LVGL task, setting the slot, orders them the other way round. + * + * What copying does not give: clearing a slot does not wait for an invocation + * already in flight. Whatever a callback reaches must outlive the slot being + * cleared, which is why MainScreen is never destroyed (F-21). + */ +template +class CallbackSlot { +public: + using Function = std::function; + + CallbackSlot() + : m_mutex(xSemaphoreCreateMutexStatic(&m_mutexBuffer)) + { + } + + ~CallbackSlot() { vSemaphoreDelete(m_mutex); } + + CallbackSlot(const CallbackSlot&) = delete; + CallbackSlot& operator=(const CallbackSlot&) = delete; + + void set(Function function) + { + // The old closure is destroyed outside the lock: its destructor is + // arbitrary code. + xSemaphoreTake(m_mutex, portMAX_DELAY); + std::swap(m_function, function); + xSemaphoreGive(m_mutex); + } + + void clear() { set(Function()); } + + /** @return a copy, safe to call with no lock held. Empty when unset. */ + Function get() const + { + xSemaphoreTake(m_mutex, portMAX_DELAY); + Function copy = m_function; + xSemaphoreGive(m_mutex); + return copy; + } + + /** Invokes a copy if one is set. Returns nothing, so R is discarded. */ + template + void operator()(CallArgs&&... args) const + { + Function function = get(); + if (function) { + function(std::forward(args)...); + } + } + +private: + StaticSemaphore_t m_mutexBuffer; + SemaphoreHandle_t m_mutex; + Function m_function; +}; diff --git a/main/utils/StackReport.cpp b/main/utils/StackReport.cpp new file mode 100644 index 0000000..decddfb --- /dev/null +++ b/main/utils/StackReport.cpp @@ -0,0 +1,75 @@ +#include "StackReport.h" + +#include "sdkconfig.h" + +#if CONFIG_THROTTLE_STACK_REPORT + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char* TAG = "StackReport"; + +namespace { + +constexpr uint32_t REPORT_PERIOD_MS = 10000; + +/** + * Every long-lived task this application creates or runs code on. The IDF + * ones are here because callbacks of ours execute on them: WiFi events on + * sys_evt, the WiFi retry timer on esp_timer, both WebSocket clients on + * websocket_task. A name not currently running is skipped. + */ +const char* const TASK_NAMES[] = { + "main", "lvgl", "rotary_enc", "withrottle_rx", + "jmri_heartbeat", "jmri_conn", "throttle_poll", "orch_connect", + "websocket_task", "settings_writer", "stack_report", "sys_evt", + "esp_timer", "tiT", +}; + +void reportTask(void*) +{ + for (;;) { + vTaskDelay(pdMS_TO_TICKS(REPORT_PERIOD_MS)); + for (const char* name : TASK_NAMES) { + TaskHandle_t handle = xTaskGetHandle(name); + if (!handle) { + continue; + } + // Bytes, not words, on ESP-IDF: the least free stack the task has + // ever had. + ESP_LOGI(TAG, "%-16s %5u bytes never used", name, + static_cast(uxTaskGetStackHighWaterMark(handle))); + } + } +} + +} // namespace + +void StackReport::start() +{ + static bool started = false; + if (started) { + return; + } + if (xTaskCreate(reportTask, "stack_report", 3072, nullptr, 1, nullptr) == pdPASS) { + started = true; + ESP_LOGW(TAG, "Stack report on (CONFIG_THROTTLE_STACK_REPORT)"); + } else { + ESP_LOGE(TAG, "Failed to create stack report task"); + } +} + +void StackReport::logSelf() +{ + ESP_LOGI(TAG, "%-16s %5u bytes never used (one-shot)", pcTaskGetName(nullptr), + static_cast(uxTaskGetStackHighWaterMark(nullptr))); +} + +#else + +void StackReport::start() {} + +void StackReport::logSelf() {} + +#endif diff --git a/main/utils/StackReport.h b/main/utils/StackReport.h new file mode 100644 index 0000000..a4bb790 --- /dev/null +++ b/main/utils/StackReport.h @@ -0,0 +1,23 @@ +#pragma once + +/** + * @brief Logs how close each task has come to the end of its stack (F-33). + * + * A stack that is too small fails as corruption, not as an error, so the + * sizes in THREADING_MODEL.md need measuring on the bench under load: driving + * all four throttles, flicking between screens, a reconnect or two. + * + * Compiled out unless CONFIG_THROTTLE_STACK_REPORT is set (Diagnostics in + * menuconfig); both calls are then empty. + */ +class StackReport { +public: + StackReport() = delete; + + /** Starts a task that logs every known task's high-water mark every 10 s. */ + static void start(); + + /** For one-shot tasks, which the periodic report rarely catches: logs the + * calling task's high-water mark. Call just before it deletes itself. */ + static void logSelf(); +}; From c472b03a047d3df0ddcce34e60bf34663e90e98a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:56:29 +0000 Subject: [PATCH 10/12] ci: read-only token, actions pinned by commit, WebSocket client pinned (F-43) - permissions: contents: read. The job checks out, builds and uploads an artifact; it needs nothing more, and the repository default may grant write. - actions/checkout, espressif/esp-idf-ci-action and actions/upload-artifact are pinned by commit, each with its release in a comment. A tag can be moved to other code after review. esp-idf-ci-action's v1 is a branch, not a tag, and is pinned at the head that @v1 was running. - espressif/esp_websocket_client goes from '*' to '~1.8.0', the series dependencies.lock holds. The lock's manifest_hash is recomputed with the component manager so CI does not re-solve. Refs #46 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- .github/workflows/ci.yml | 17 ++++++++++++++--- dependencies.lock | 2 +- main/idf_component.yml | 5 ++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba4f800..8db546c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,26 +5,37 @@ on: branches: [main] pull_request: +# Read-only: the job checks out, builds and uploads an artifact, and needs no +# more than that. Without this block the token gets the repository's default, +# which may include write (F-43). +permissions: + contents: read + jobs: build: name: firmware build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + # Actions are pinned by commit, not tag: a tag can be moved to other code + # after review (F-43). The comment says which release each commit is; + # bump both together. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # ESP-IDF version and target both come from dependencies.lock, which the # component manager writes. Keep them in step with it — a mismatch here # builds something the bench never runs. - name: Build firmware - uses: espressif/esp-idf-ci-action@v1 + # `v1` is a branch in this repository, not a tag; this is its head as of + # 2026-09-25, which is what `@v1` was running. + uses: espressif/esp-idf-ci-action@9d38657f3d789ca759b2b37aaf5ceffbc42c4f0d # v1 with: esp_idf_version: v5.5.2 target: esp32s3 path: '.' - name: Upload firmware - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: esp-layout-controller-firmware path: | diff --git a/dependencies.lock b/dependencies.lock index 879079d..51c3d91 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -50,6 +50,6 @@ direct_dependencies: - espressif/esp_websocket_client - idf - lvgl/lvgl -manifest_hash: 58434bdfbf7f1a655631fa5921acdfd8740417f13c01f0b7cb73ef06db9d993a +manifest_hash: 10a41b0c21730f80db071115f4d17bc807ad458d2d3d58864cffa67d5184d40f target: esp32s3 version: 2.0.0 diff --git a/main/idf_component.yml b/main/idf_component.yml index a85e254..b9e2111 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -16,4 +16,7 @@ dependencies: espressif/esp_lcd_touch_gt911: '^1' - espressif/esp_websocket_client: '*' + # Pinned to the 1.8.x series that dependencies.lock holds. '*' let a fresh + # resolve take any major version of the library both WebSocket clients sit on + # (F-43). + espressif/esp_websocket_client: '~1.8.0' From bf65c156d60749c0bd8318c27fbee3387c01fde3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:56:29 +0000 Subject: [PATCH 11/12] docs: batch 5 status, the cleartext question, utils in the index (F-33, F-36) - The plan records batch 5: acceptance criteria for every finding in it, and a tracker in which every finding is now at least code done. F-33's measurements and F-36's decision are the two left open. - F-36: the measured facts for the cleartext decision. mbedTLS (about 117 KB) is already linked by esp_websocket_client, so TLS would cost little flash, and staying cleartext saves none; the cost is heap per connection and a certificate the orchestrator would have to serve. CLAUDE.md's Open limits names the question; the decision is the owner's. - CLAUDE.md, the Copilot instructions and main/utils/README.md list utils/ and SettingsWriter. Refs #36, #39 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- .github/copilot-instructions.md | 5 +- CLAUDE.md | 16 ++- docs/REVIEW_REMEDIATION_PLAN_2.md | 158 +++++++++++++++++++++++++++--- main/utils/README.md | 9 +- 4 files changed, 165 insertions(+), 23 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b4eb0f8..c4cc83d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -26,8 +26,9 @@ main/ ├── hardware/ # HAL (encoders, I2C) ├── communication/ # ThrottleBackend port + transports (WiThrottle; orchestrator WebSocket) ├── model/ # Data (Loco, Throttle, Knob) - ├── controller/ # Business logic - └── ui/ # LVGL screens; colours from UiTheme.h, never raw hex + ├── controller/ # Business logic; SettingsWriter takes the UI's NVS writes + ├── ui/ # LVGL screens; colours from UiTheme.h, never raw hex + └── utils/ # CallbackSlot for any callback set on one task and called on another ``` ### Class Structure diff --git a/CLAUDE.md b/CLAUDE.md index f45817a..ea6ec4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,10 +92,12 @@ main/ ├── communication/ ThrottleBackend (port), WiThrottleBackend, OrchestratorBackend, │ WiFiManager, WiThrottleClient (TCP), JmriJsonClient (WebSocket), │ OrchestratorClient (WebSocket control plane) -├── controller/ AppController, ThrottleController, WiFiController, JmriConnectionController -└── ui/ SettingsScreen (transport choice, device settings, status), - JmriConfigScreen and OrchestratorConfigScreen (one per - transport), MainScreen, components +├── controller/ AppController, ThrottleController, WiFiController, JmriConnectionController, +│ SettingsWriter (the UI's NVS writes, off the LVGL task) +├── ui/ SettingsScreen (transport choice, device settings, status), +│ JmriConfigScreen and OrchestratorConfigScreen (one per +│ transport), MainScreen, components +└── utils/ CallbackSlot (cross-task callbacks), StackReport (diagnostics) ``` Dependency direction is one way: `ui` → `controller` → `communication` / `model`. A model @@ -220,3 +222,9 @@ Things that look like bugs or oversights and are not. One line each. orchestrator's `locos` table stores no labels, so a WebSocket-connected device would show `F0`…`F28`. Being fixed orchestrator-side: labels become operator-authored per loco. - **No OTA.** See the flash budget above. +- **The orchestrator link is cleartext, and whether to keep it so is undecided (F-36).** + The login, the 30-day session cookie and the control plane all cross the layout WiFi in + the clear. WiThrottle is no better: it has no authentication at all. TLS costs almost no + flash, because `esp_websocket_client` already links mbedTLS, but it does cost heap per + connection, and the orchestrator would have to serve it. The measured figures and the + options are in `docs/REVIEW_REMEDIATION_PLAN_2.md`. diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index 06f1777..2e755c6 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -1,7 +1,7 @@ # Review Remediation Plan — Second Pass **Created:** 2026-09-24 -**Status:** In progress — Batches 1–4 code done (every HIGH and the operator-facing MEDIUMs), awaiting their bench cycles +**Status:** In progress — code done for every finding (Batches 1–5), awaiting the bench cycles; F-33's measurements and F-36's decision are still open **Source:** Static review of the whole firmware (`main/`, build config, CI), with the orchestrator-facing behaviour checked against `bazauto/layout-orchestration`. Nothing was flashed during the review: every "bench" criterion below is unverified until someone runs it @@ -34,21 +34,21 @@ freeze while one is moving. | F-27 | [No emergency stop; power button fails the wrong way](#f-27-no-emergency-stop-power-button-fails-the-wrong-way) | MEDIUM | Medium | 4 | Code done | #30 | | F-28 | [Every function is momentary under the orchestrator](#f-28-every-function-is-momentary-under-the-orchestrator) | MEDIUM | Small | 4 | Code done | #31 | | F-29 | [Documented lock order is the reverse of the code's](#f-29-documented-lock-order-is-the-reverse-of-the-codes) | MEDIUM | Small | 4 | Code done (docs only; no bench check) | #32 | -| F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Slots: code done; sync: open | #33 | +| F-30 | [Callback slots unsynchronised and clobbered](#f-30-callback-slots-unsynchronised-and-clobbered) | MEDIUM | Medium | 1 (part), 5 | Code done | #33 | | F-31 | [JMRI heartbeat task deleted from outside](#f-31-jmri-heartbeat-task-deleted-from-outside) | MEDIUM | Small | 2 | Code done | #34 | | F-32 | [Main screen LVGL tree leaks on every return](#f-32-main-screen-lvgl-tree-leaks-on-every-return) | MEDIUM | Small | 1 | Code done | #35 | -| F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Open | #36 | +| F-33 | [Task stack headroom unmeasured](#f-33-task-stack-headroom-unmeasured) | MEDIUM | Small | 5 | Instrumented; measurements open | #36 | | F-34 | [JMRI config screen connect/disconnect faults](#f-34-jmri-config-screen-connectdisconnect-faults) | MEDIUM | Medium | 3 | Code done | #37 | -| F-35 | [Orchestrator roster refused above ~35 locos](#f-35-orchestrator-roster-refused-above-35-locos) | MEDIUM | Small | 5 | Open | #38 | -| F-36 | [Operator credential and session cookie in cleartext](#f-36-operator-credential-and-session-cookie-in-cleartext) | MEDIUM | Small (decision) | 5 | Open | #39 | -| F-37 | [WiThrottle updates unvalidated](#f-37-withrottle-updates-unvalidated) | LOW | Small | 5 | Open | #40 | -| F-38 | [Hot-path logging and repaint cost](#f-38-hot-path-logging-and-repaint-cost) | LOW | Small | 5 | Open | #41 | -| F-39 | [NVS writes on the LVGL task](#f-39-nvs-writes-on-the-lvgl-task) | LOW | Small | 5 | JMRI settings: done (batch 3); rest: open | #42 | +| F-35 | [Orchestrator roster refused above ~35 locos](#f-35-orchestrator-roster-refused-above-35-locos) | MEDIUM | Small | 5 | Code done | #38 | +| F-36 | [Operator credential and session cookie in cleartext](#f-36-operator-credential-and-session-cookie-in-cleartext) | MEDIUM | Small (decision) | 5 | Facts recorded; decision open | #39 | +| F-37 | [WiThrottle updates unvalidated](#f-37-withrottle-updates-unvalidated) | LOW | Small | 5 | Code done | #40 | +| F-38 | [Hot-path logging and repaint cost](#f-38-hot-path-logging-and-repaint-cost) | LOW | Small | 5 | Code done | #41 | +| F-39 | [NVS writes on the LVGL task](#f-39-nvs-writes-on-the-lvgl-task) | LOW | Small | 5 | Code done | #42 | | F-40 | [WiFi credential save and reboot-on-error](#f-40-wifi-credential-save-and-reboot-on-error) | LOW | Small | 3 | Code done | #43 | -| F-41 | [Seesaw read timing](#f-41-seesaw-read-timing) | LOW | Small | 5 | Open | #44 | -| F-42 | [Protocol hygiene odds and ends](#f-42-protocol-hygiene-odds-and-ends) | LOW | Small | 5 | Open | #45 | -| F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Open | #46 | -| F-44 | [Threading-model task table drift](#f-44-threading-model-task-table-drift) | LOW | Small | 5 | Open | #47 | +| F-41 | [Seesaw read timing](#f-41-seesaw-read-timing) | LOW | Small | 5 | Code done | #44 | +| F-42 | [Protocol hygiene odds and ends](#f-42-protocol-hygiene-odds-and-ends) | LOW | Small | 5 | Code done | #45 | +| F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Code done | #46 | +| F-44 | [Threading-model task table drift](#f-44-threading-model-task-table-drift) | LOW | Small | 5 | Code done | #47 | **Status key:** *Open* — not started. *Code done* — implemented and compiled, bench criteria still unticked. *Done* — bench criteria confirmed on the board. @@ -515,6 +515,17 @@ the pattern to follow. 1. Batch 1: stop the UI taking backend-owned slots (see F-21). 2. Batch 5: copy-under-lock for every remaining slot. +#### Acceptance Criteria + +- [x] No UI class takes a client's connection slot (batch 1). +- [x] Every slot on `WiThrottleClient`, `JmriJsonClient`, `WiFiManager`, `OrchestratorClient`, + both backends and `ThrottleController` is a `CallbackSlot` (`main/utils/CallbackSlot.h`): + set under its own lock, invoked as a copy with the lock released. The controller's + function-pointer-and-`userData` pairs are one closure each, so they cannot tear. +- [x] `OrchestratorClient`'s lock-timeout fallbacks that read a slot unlocked are gone with the + slots they read. +- [ ] Unit test `test_callback_slot_invokes_a_copy` passes on the board (it compiles). + --- ### F-31: JMRI heartbeat task deleted from outside @@ -582,6 +593,15 @@ on their own stacks; the receive task also ran `JmriJsonClient::connect` from th Log `uxTaskGetStackHighWaterMark` for every application task on the bench under load, record the results in `THREADING_MODEL.md`, and resize with a margin. +#### Acceptance Criteria + +- [x] `CONFIG_THROTTLE_STACK_REPORT` (menuconfig → Diagnostics, off by default) logs every + application task's high-water mark every 10 s, and the one-shot tasks log theirs as + they exit. Compiled out when off; the option builds. +- [x] `THREADING_MODEL.md` has a table for the measurements and the procedure. +- [ ] Bench: build with the option on, drive under load, fill in the table, and resize any + task left with less than about a quarter of its stack. Nothing was resized blind. + --- ### F-34: JMRI config screen connect/disconnect faults @@ -613,7 +633,8 @@ the saved settings, the auto-reconnect flag and the one task that connects. - [x] The `PW` callback is registered once, before any connect, and only records the port; the worker saves it as `json_port` and moves the JSON client. - [x] A manual connect updates the settings the worker reconnects to. -- [x] Under the orchestrator, Connect saves only (one-shot `jmri_save` task). +- [x] Under the orchestrator, Connect saves only (a one-shot `jmri_save` task; the shared + `SettingsWriter` since batch 5, F-39). - [ ] Bench: Connect, Disconnect (stays down past ten seconds), Connect again; change server address and confirm reconnects go to the new one. @@ -633,6 +654,18 @@ fields). A truncated response is refused whole, so a larger roster leaves nothin Raise the limit for the roster read (allocating from PSRAM), or parse incrementally keeping only address and name. +#### Acceptance Criteria + +- [x] The roster is streamed. `JsonArraySplitter` hands each record over as it closes, and each + is parsed alone with cJSON, so memory is bounded by one record (4 KB cap) rather than + by the response. +- [x] A record over the cap, or with no usable address, costs that loco only. A record cJSON + refuses, a stream that is not an array of objects, or one cut short, refuses the whole + roster, as before. +- [x] At most 128 locos are kept, matching the loco state cache, with a warning past that. +- [ ] Unit tests `test_roster_splitter_*` (seven) pass on the board (they compile). +- [ ] Bench: a roster of more than 35 locos loads whole. + --- ### F-36: Operator credential and session cookie in cleartext @@ -651,6 +684,31 @@ records a decision about cleartext on the wire. Record the decision in CLAUDE.md's transport section. If TLS is wanted, size it with `idf.py size` first against the flash budget. +#### What batch 5 measured + +- **Flash is not the obstacle.** The image already carries mbedTLS: `libmbedcrypto` 79 KB, + `libmbedtls` 30 KB and `libmbedx509` 9 KB, pulled in by `esp_websocket_client`'s SSL + transport and `esp_http_client`. A build with `CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS=n` was + only 528 bytes smaller, because the WebSocket client links it anyway. So `https`/`wss` + would add little beyond a CA or pinned certificate — and, conversely, staying cleartext + gives none of that flash back. +- **Heap is the device-side cost.** With this build's mbedTLS settings (16 KB input record + buffer, dynamic buffers off), each open TLS connection holds roughly 20–40 KB of heap. The + socket is always open, and a power or roster request briefly adds a second connection. +- **The orchestrator has to serve it.** On a LAN host that means a reverse proxy or its own + certificate, and the device then needs that certificate's CA, or a pin, in flash or NVS. +- **Cleartext is the status quo on both transports.** WiThrottle has no authentication at all: + anyone on the layout WiFi can drive through JMRI. That puts the real boundary at the WiFi + password. Under that model, the orchestrator cookie's 30-day sliding lifetime is the part + that widens the exposure. + +#### Acceptance Criteria + +- [x] The facts above are recorded, and CLAUDE.md's Open limits names the question. +- [ ] **Decision (owner):** accept cleartext on the layout WiFi as the boundary, as F-18 + did for NVS; or shorten the operator session lifetime orchestrator-side; or add TLS + on both ends. Record the outcome in CLAUDE.md's transport section. + --- ### F-37: WiThrottle updates unvalidated @@ -662,6 +720,15 @@ Record the decision in CLAUDE.md's transport section. If TLS is wanted, size it - Function numbers from the server are unbounded, so `Throttle::setFunctionState` and the function panel grow without limit. +#### Acceptance Criteria + +- [x] `ThrottleController` applies a transport report only if it names the loco the + throttle holds (address 0 meaning "not said"). The check sits in the controller, so it + covers both transports. +- [x] Speeds above 126 and function numbers above 28 are dropped. +- [ ] Unit tests `test_controller_ignores_update_for_other_loco` and + `test_controller_bounds_reported_speed_and_function` pass on the board (they compile). + --- ### F-38: Hot-path logging and repaint cost @@ -673,6 +740,14 @@ Record the decision in CLAUDE.md's transport section. If TLS is wanted, size it - `PowerStatusBar::refresh()` runs four times per repaint, and labels are rewritten (and so invalidated) even when unchanged. +#### Acceptance Criteria + +- [x] Per-line, per-tick and per-update logs are DEBUG: WiThrottle `RX`, direction commands, + the controller's knob and update lines, the virtual encoder. +- [x] `PowerStatusBar::refresh()` runs once per repaint, and it and the knob indicators skip + any widget whose look has not changed. (`ThrottleMeter` already did for its labels.) +- [ ] Bench: spin a knob hard with the console attached; the display keeps up. + --- ### F-39: NVS writes on the LVGL task @@ -683,8 +758,21 @@ Record the decision in CLAUDE.md's transport section. If TLS is wanted, size it write NVS from event handlers, against F-05's rule. **Progress:** the JMRI settings are now saved by `JmriConnectionController` on its own task -(batch 3, F-34). The orchestrator screen's save, `saveSpeedSteps`, and the WiFi screen's -Forget remain. +(batch 3, F-34). Batch 5 moved the rest. + +#### Acceptance Criteria + +- [x] `SettingsWriter` — one task, `settings_writer`, and a queue — carries out every NVS + write the UI asks for, in order: speed steps, the transport choice, the orchestrator + settings, WiFi Forget, and the JMRI settings under the orchestrator (replacing the + one-shot `jmri_save` task). +- [x] The `orch` read-modify-writes happen on the writer, so a transport change and an + orchestrator Save moments apart cannot overwrite each other with stale copies (the + transport choice's own save was a fourth NVS write on the LVGL task, not listed above). +- [x] The orchestrator screen's Connect posts the supervisor's wake-up behind its save, so + the login reads what was just typed. +- [ ] Bench: change speed steps and leave the screen at once; the new value applies. Save + orchestrator settings, then Connect; the login uses them. Forget WiFi. --- @@ -713,6 +801,15 @@ The double read compensates for a missing write → delay → read sequence (the to prepare a response). Rotation during the 5 ms between reads is lost. Split each transaction into a write, a short delay and a read, and drop the double read — bench-verify before merging. +#### Acceptance Criteria + +- [x] Every Seesaw read is a write, a 500 µs wait (Adafruit's library uses 250 µs), and a + read, each its own transaction. Every register is read once. +- [ ] **Bench, before merging:** slow single clicks each register exactly one step, a fast + spin tracks without lost steps, and the button still presses and releases. If reads + fail or return garbage, the F-19 bound drops them, so the knob stays still rather than + jumping. + --- ### F-42: Protocol hygiene odds and ends @@ -726,6 +823,18 @@ into a write, a short delay and a read, and drop the double read — bench-verif - `getLayoutId` always uses the first layout and is not cleared when the host changes. - `handleRosterMessage`'s warning can read past the end of a malformed message. +#### Acceptance Criteria + +- [x] `requestPowerList` removed. +- [x] `OrchestratorClient`: connection, system and track-power states are atomics, so no lock + timeout can lose a change. The session cookie, host and port are written only under + the lock; a timeout fails the login rather than writing unlocked. +- [x] A message split across frames is reassembled up to FIN before it is parsed. +- [x] The layout id is forgotten on every login, and more than one layout is logged. +- [x] `JmriJsonClient`'s power-manager name, written on two tasks and read on a third, is + under the power mutex. +- [x] The roster warning no longer indexes past the end. + --- ### F-43: CI hardening @@ -735,6 +844,18 @@ into a write, a short delay and a read, and drop the double read — bench-verif No `permissions:` block, actions pinned by tag rather than SHA, and `espressif/esp_websocket_client: '*'` in the manifest (the lock file pins it). +#### Acceptance Criteria + +- [x] `permissions: contents: read` at the top of `ci.yml`. +- [x] `actions/checkout`, `espressif/esp-idf-ci-action` and `actions/upload-artifact` are + pinned by commit, each with its release in a comment. `esp-idf-ci-action@v1` turned out + to be a branch; it is pinned at the head `@v1` was running. +- [x] `esp_websocket_client` is `~1.8.0`, and `dependencies.lock`'s `manifest_hash` is + recomputed to match, so CI does not re-solve. +- [ ] CI passes on the PR with the pins. +- Pinned actions no longer update themselves. Dependabot's `github-actions` ecosystem would + raise the bumps, if wanted. + --- ### F-44: Threading-model task table drift @@ -744,3 +865,10 @@ No `permissions:` block, actions pinned by tag rather than SHA, and `THREADING_MODEL.md`'s table is missing `wifi_scan` (4 KB), and will need the measured figures from F-33. (It was also missing `jmri_connect`; batch 3 removed that task, folding it into `jmri_conn`, which the table now lists.) + +#### Acceptance Criteria + +- [x] The table lists `wifi_scan`, `settings_writer` and `stack_report`, both WebSocket tasks + with their sizes, and the IDF tasks our callbacks run on. `jmri_save` is gone, and the + LVGL task carries its real name, `lvgl`. +- [ ] The measured column is filled in (F-33). diff --git a/main/utils/README.md b/main/utils/README.md index 4fd9372..c42dff0 100644 --- a/main/utils/README.md +++ b/main/utils/README.md @@ -1,5 +1,10 @@ # Utilities Layer -Generic helpers and utilities used across the application. +Generic helpers used across the layers. No dependency on any layer above. -Currently empty — utility functions (string parsing, speed scaling, etc.) are implemented inline within the classes that need them. Extract here if reuse becomes warranted. +| File | What it is | +|------|------------| +| `CallbackSlot.h` | A callback set on one task and invoked from another: set under its own lock, called as a copy with the lock released (F-30). Every client and controller callback slot uses it. | +| `StackReport.h/.cpp` | Logs each task's stack high-water mark when `CONFIG_THROTTLE_STACK_REPORT` is set; compiled out otherwise (F-33). | + +Utility functions used by one class only (string parsing, speed scaling) stay in that class. From 081d4e4eec4e0ace1adcc2fc413487b25b0bfead Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:01:51 +0000 Subject: [PATCH 12/12] docs: F-43 done, CI green with the pinned actions CI on bf65c15 downloaded the three pinned commits and took dependencies.lock as it is, without re-solving. Refs #46 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n --- docs/REVIEW_REMEDIATION_PLAN_2.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md index 2e755c6..2ac71e8 100644 --- a/docs/REVIEW_REMEDIATION_PLAN_2.md +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -47,7 +47,7 @@ freeze while one is moving. | F-40 | [WiFi credential save and reboot-on-error](#f-40-wifi-credential-save-and-reboot-on-error) | LOW | Small | 3 | Code done | #43 | | F-41 | [Seesaw read timing](#f-41-seesaw-read-timing) | LOW | Small | 5 | Code done | #44 | | F-42 | [Protocol hygiene odds and ends](#f-42-protocol-hygiene-odds-and-ends) | LOW | Small | 5 | Code done | #45 | -| F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Code done | #46 | +| F-43 | [CI hardening](#f-43-ci-hardening) | LOW | Small | 5 | Done | #46 | | F-44 | [Threading-model task table drift](#f-44-threading-model-task-table-drift) | LOW | Small | 5 | Code done | #47 | **Status key:** *Open* — not started. *Code done* — implemented and compiled, bench criteria @@ -852,7 +852,8 @@ No `permissions:` block, actions pinned by tag rather than SHA, and to be a branch; it is pinned at the head `@v1` was running. - [x] `esp_websocket_client` is `~1.8.0`, and `dependencies.lock`'s `manifest_hash` is recomputed to match, so CI does not re-solve. -- [ ] CI passes on the PR with the pins. +- [x] CI passes on the PR with the pins (`bf65c15`). The log shows the three pinned commits + downloaded, and the component manager taking the lock as it is, with no re-solve. - Pinned actions no longer update themselves. Dependabot's `github-actions` ecosystem would raise the bumps, if wanted.