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/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2da3828..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 @@ -74,12 +75,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/.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/CLAUDE.md b/CLAUDE.md index a1cbd87..ea6ec4e 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, @@ -68,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` / @@ -89,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 @@ -177,6 +182,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 @@ -190,8 +200,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* @@ -211,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/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/docs/REVIEW_REMEDIATION_PLAN_2.md b/docs/REVIEW_REMEDIATION_PLAN_2.md new file mode 100644 index 0000000..2ac71e8 --- /dev/null +++ b/docs/REVIEW_REMEDIATION_PLAN_2.md @@ -0,0 +1,875 @@ +# Review Remediation Plan — Second Pass + +**Created:** 2026-09-24 +**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 +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. + +--- + +## Progress Tracker + +| 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 | 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 (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 | 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 | 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 | 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 | 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 | 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. + +--- + +## 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 + +- [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. + +--- + +### 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 + +- [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. + +--- + +### 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 + +- [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 + `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 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` + +#### 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. +- [ ] 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 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` + +#### Acceptance Criteria + +- [x] `*+` is sent once per session when the server announces a non-zero interval. +- [x] `*` is sent at half the announced interval for the life of the session, from the + receive task (which now wakes every second). +- [x] `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. + +--- + +### 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: 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` + +#### 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. + +--- + +### 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 + +- [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. + +--- + +### 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. + +**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. Batch 4 added the handle mutex, so a re-login can no longer free the handle +under a sender. + +**Files:** `OrchestratorClient.h/.cpp` + +#### Acceptance Criteria + +- [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. + +--- + +### 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. +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 + +**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 + +#### 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 + +**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". + +#### 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 + +**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. + +#### 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 + +**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. + +#### 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 + +**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. + +#### 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 + +**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. + +#### 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 (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. + +--- + +### 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. + +#### 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 + +**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. + +#### 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 + +**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. + +#### 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 + +**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. + +#### 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 + +**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. + +**Progress:** the JMRI settings are now saved by `JmriConnectionController` on its own task +(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. + +--- + +### 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. + +#### 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 + +**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. + +#### 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 + +**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. + +#### 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 + +**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). + +#### 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. +- [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. + +--- + +### F-44: Threading-model task table drift + +**Severity:** LOW | **Effort:** Small | **Batch:** 5 + +`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/docs/architecture/NVS_STORAGE.md b/docs/architecture/NVS_STORAGE.md index 5fadfe4..8f06faa 100644 --- a/docs/architecture/NVS_STORAGE.md +++ b/docs/architecture/NVS_STORAGE.md @@ -8,19 +8,28 @@ 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` | `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` | `power_mgr` | string | `"DCC++"` | `JmriConnectionController` | `JmriConnectionController`, `JmriConfigScreen` | +| `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 @@ -40,6 +49,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 25c0013..36aaef0 100644 --- a/docs/architecture/THREADING_MODEL.md +++ b/docs/architecture/THREADING_MODEL.md @@ -10,17 +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` | -| `withrottle_rx` | 4 KB | 5 | WiThrottle TCP receive loop | `WiThrottleClient::connect()` | -| `jmri_heartbeat` | 2 KB | 5 | JSON WebSocket ping every 30 s | `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()` | +| `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()` | | `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()` | -| `websocket_task` | 6 KB | — | Orchestrator control-plane receive loop (owned by `esp_websocket_client`) | `OrchestratorClient::connect()` | +| `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), 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 @@ -32,10 +62,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). --- @@ -100,16 +132,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. --- @@ -120,8 +158,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 +175,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 f85a516..bd42069 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 @@ -50,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 @@ -101,7 +125,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()`). | @@ -133,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 @@ -143,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 @@ -187,6 +234,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 @@ -209,9 +263,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 @@ -220,8 +277,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 | @@ -230,6 +287,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` @@ -260,10 +329,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 @@ -291,9 +382,15 @@ 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. +- Each received line is logged at DEBUG, not INFO: a console write per line blocked this + task (F-38). ### Protocol Messages Parsed @@ -307,7 +404,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 `*+` | --- @@ -332,9 +429,8 @@ 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 | +| `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/docs/components/CONTROLLER_LAYER.md b/docs/components/CONTROLLER_LAYER.md index be53c41..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 | @@ -25,13 +26,38 @@ 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. --- +## 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` @@ -45,7 +71,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 @@ -136,10 +162,23 @@ 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. +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. + +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 @@ -181,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`) @@ -197,13 +237,13 @@ 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 | ### 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. 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 2ef5810..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 | @@ -32,7 +38,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..1bef367 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 | @@ -79,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 | @@ -100,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()` @@ -122,7 +126,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()` @@ -165,9 +172,11 @@ 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 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. **Navigation:** Back button → `show_settings_screen()` @@ -187,7 +196,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/CONNECTION_FLOW.md b/docs/flows/CONNECTION_FLOW.md index cc2ed1b..c9c02d7 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, through the `SettingsWriter` (F-39). + ## 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/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/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/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/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/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 ccda31d..cce7fdf 100644 --- a/main/communication/JmriJsonClient.cpp +++ b/main/communication/JmriJsonClient.cpp @@ -39,21 +39,38 @@ 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) - , m_connectionCallback(nullptr) { m_powerMutex = xSemaphoreCreateMutex(); 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 +81,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() @@ -169,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; } @@ -179,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; @@ -199,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()) { @@ -303,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()); } } @@ -339,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); } } @@ -360,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); } } @@ -397,36 +416,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 +481,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 +494,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..47bfe56 100644 --- a/main/communication/JmriJsonClient.h +++ b/main/communication/JmriJsonClient.h @@ -1,13 +1,17 @@ #pragma once +#include #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 @@ -92,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 @@ -112,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) @@ -142,7 +140,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,16 +169,23 @@ 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 + /** 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 30c1f70..2d904ce 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); } @@ -111,15 +116,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 = m_throttleStateCallback.get(); + 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; } @@ -256,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 = m_throttleStateCallback.get(); + + 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; @@ -282,101 +335,91 @@ bool OrchestratorBackend::getRosterEntry(int index, RosterEntry& outEntry) const void OrchestratorBackend::onLocoState(const OrchestratorClient::LocoState& state) { - ThrottleStateCallback callback; - if (lock(pdMS_TO_TICKS(50))) { - callback = m_throttleStateCallback; - unlock(); - } else { - callback = m_throttleStateCallback; + 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. + for (int throttleId = 0; throttleId < MAX_THROTTLES; ++throttleId) { + publishToThrottle(throttleId, state, callback); } +} - if (!callback) { +void OrchestratorBackend::publishToThrottle(int throttleId, + const OrchestratorClient::LocoState& state, + const ThrottleStateCallback& callback) +{ + if (!isValidThrottle(throttleId)) { return; } - // 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); - } + 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); } } 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)); }); } @@ -403,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 83746df..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" @@ -41,6 +42,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 +56,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,15 +95,35 @@ 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. + * + * 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); + 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 e6cd0f5..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" @@ -11,9 +13,26 @@ static const char* TAG = "OrchestratorClient"; namespace { -/** Login and roster reads are small; this caps a hostile or broken response. */ +/** 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 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; @@ -59,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) { @@ -90,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) { @@ -120,8 +202,10 @@ 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_rxInMessage(false) , m_systemStatus(SystemStatus::UNKNOWN) , m_trackPower(TrackPower::UNKNOWN) , m_lastMessageUs(0) @@ -136,6 +220,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 +235,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; } @@ -165,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); } } @@ -196,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 @@ -253,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); @@ -342,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; @@ -402,43 +479,72 @@ 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))) { - 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. + clearLocoStates(); + setState(ConnectionState::DISCONNECTED); } @@ -463,6 +569,12 @@ 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(); self->setState(ConnectionState::DISCONNECTED); break; @@ -472,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; } @@ -555,6 +686,8 @@ void OrchestratorClient::handleMessage(const std::string& json) } else { ESP_LOGE(TAG, "Orchestrator reported an error with no 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. @@ -583,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); } } @@ -602,6 +728,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,17 +800,11 @@ void OrchestratorClient::handleLocoState(const void* payloadPtr) } } - LocoStateCallback callback; - if (lockState(pdMS_TO_TICKS(50))) { - callback = m_locoStateCallback; - unlockState(); - } else { - callback = m_locoStateCallback; - } + // 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); - if (callback) { - callback(state); - } + m_locoStateCallback(state); } void OrchestratorClient::handleDccLink(const void* payloadPtr) @@ -704,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) @@ -861,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(); @@ -898,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 @@ -924,13 +1029,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"); @@ -1009,8 +1129,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\"}"); } @@ -1039,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; } @@ -1145,48 +1244,110 @@ bool OrchestratorClient::getRosterEntry(int index, RosterEntry& outEntry) const return found; } -// --- Callbacks ------------------------------------------------------------- +// --- Last reported loco state ---------------------------------------------- -void OrchestratorClient::setConnectionStateCallback(ConnectionStateCallback callback) +void OrchestratorClient::cacheLocoState(const LocoState& state) { - if (lockState(pdMS_TO_TICKS(100))) { - m_connectionCallback = std::move(callback); - unlockState(); + 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::setLocoStateCallback(LocoStateCallback callback) +void OrchestratorClient::clearLocoStates() { - if (lockState(pdMS_TO_TICKS(100))) { - m_locoStateCallback = std::move(callback); + if (lockState(pdMS_TO_TICKS(200))) { + m_locoStates.clear(); unlockState(); + } else { + ESP_LOGW(TAG, "Could not lock to clear the loco state cache"); } } -void OrchestratorClient::setSystemStatusCallback(SystemStatusCallback callback) +bool OrchestratorClient::getLastLocoState(int address, LocoState& outState) const { - if (lockState(pdMS_TO_TICKS(100))) { - m_systemStatusCallback = std::move(callback); + 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) +{ + m_connectionCallback.set(std::move(callback)); +} + +void OrchestratorClient::setLocoStateCallback(LocoStateCallback callback) +{ + m_locoStateCallback.set(std::move(callback)); +} + +void OrchestratorClient::setSystemStatusCallback(SystemStatusCallback callback) +{ + 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) +{ + 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 9cf0958..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. @@ -96,6 +98,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 +146,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(); @@ -176,6 +180,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); @@ -184,6 +205,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; @@ -235,6 +266,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, @@ -242,8 +293,19 @@ 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; - ConnectionState m_state; + mutable SemaphoreHandle_t m_clientMutex; + + /** 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; @@ -252,20 +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; + // 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/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/WiFiManager.cpp b/main/communication/WiFiManager.cpp index 66f55bd..8b03909 100644 --- a/main/communication/WiFiManager.cpp +++ b/main/communication/WiFiManager.cpp @@ -4,17 +4,26 @@ #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) + , 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; @@ -188,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() @@ -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); } } @@ -332,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 57c3691..0cfeb97 100644 --- a/main/communication/WiFiManager.h +++ b/main/communication/WiFiManager.h @@ -1,9 +1,12 @@ #pragma once +#include #include #include +#include "CallbackSlot.h" #include "esp_wifi.h" #include "esp_event.h" +#include "esp_timer.h" /** * @brief Manages WiFi connection with configuration support. @@ -11,7 +14,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 +58,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 +139,32 @@ 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; + /** 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; + /** 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/communication/WiThrottleBackend.cpp b/main/communication/WiThrottleBackend.cpp index 3f0c5be..966aaba 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,28 +63,37 @@ 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) { - 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); @@ -158,6 +176,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; @@ -182,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; @@ -222,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 3dacc32..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" @@ -38,6 +39,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 +51,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; @@ -73,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 363170e..cd97016 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) @@ -18,16 +40,13 @@ 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) , m_receiveTaskHandle(nullptr) , m_running(false) + , m_heartbeatPeriodMs(0) + , m_lastHeartbeatUs(0) { m_stateMutex = xSemaphoreCreateMutex(); if (!m_stateMutex) { @@ -72,6 +91,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 +111,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 +146,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 +340,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 +362,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) @@ -331,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); } @@ -377,6 +477,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()) { @@ -515,9 +651,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) { @@ -571,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; @@ -587,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) @@ -617,8 +759,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) @@ -667,25 +809,73 @@ 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); + } +} + +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) { m_state = newState; ESP_LOGI(TAG, "Connection state changed: %d", (int)newState); - if (m_connectionCallback) { - m_connectionCallback(newState); - } + m_connectionCallback(newState); } } @@ -701,9 +891,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); @@ -751,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 \[ @@ -805,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) @@ -859,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; } @@ -937,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 d75b750..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 @@ -194,6 +196,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 @@ -212,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) @@ -269,10 +279,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 +302,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); @@ -309,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; @@ -322,4 +360,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/controller/AppController.cpp b/main/controller/AppController.cpp index efb3588..f7e2d61 100644 --- a/main/controller/AppController.cpp +++ b/main/controller/AppController.cpp @@ -12,13 +12,36 @@ #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" #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; @@ -26,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) @@ -47,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(); @@ -72,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 @@ -81,7 +112,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"); } @@ -131,6 +162,9 @@ void AppController::initialise() m_rotaryEncoderHal->startPollingTask(); } + // Nothing unless CONFIG_THROTTLE_STACK_REPORT is set (F-33). + StackReport::start(); + m_initialised = true; } @@ -138,12 +172,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() @@ -156,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(); } @@ -171,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(); } @@ -181,10 +223,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(); } @@ -198,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(); } @@ -206,8 +246,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"); } } @@ -219,36 +270,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() @@ -260,7 +341,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) { @@ -300,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 0e06cb7..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. @@ -38,6 +39,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. */ @@ -46,14 +57,25 @@ class AppController { WiFiController* getWiFiController() const; JmriConnectionController* getJmriConnectionController() const; RotaryEncoderHal* getRotaryEncoderHal() const; + SettingsWriter* getSettingsWriter() const; 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); + /** 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 0663cfb..be0541a 100644 --- a/main/controller/JmriConnectionController.cpp +++ b/main/controller/JmriConnectionController.cpp @@ -1,12 +1,15 @@ #include "JmriConnectionController.h" +#include "SettingsWriter.h" #include "WiFiController.h" #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 +19,372 @@ 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) + WiFiController* wifiController, + SettingsWriter* settingsWriter) : 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_settingsWriter(settingsWriter) + , 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() +{ + // 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; + } } -JmriConnectionController::~JmriConnectionController() = default; +bool JmriConnectionController::lock() const +{ + return m_mutex && xSemaphoreTake(m_mutex, pdMS_TO_TICKS(1000)) == pdTRUE; +} -void JmriConnectionController::loadSettingsAndAutoConnect() +void JmriConnectionController::unlock() const { - if (!m_wifiController || !m_wifiController->isConnected()) { - ESP_LOGI(TAG, "WiFi not connected, skipping JMRI auto-connect"); - return; + 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)); - - if (jsonPort == 0) jsonPort = 12080; - if (wtPort == 0) wtPort = 12090; + ESP_LOGI(TAG, "JMRI settings saved (%s, WiThrottle %u, power manager '%s')", + settings.serverIp.c_str(), settings.wtPort, settings.powerManager.c_str()); +} - m_savedServerIp = serverIp; - m_savedJsonPort = jsonPort; - m_savedWtPort = wtPort; - m_savedPowerMgr = powerMgr; +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); +} - 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), and in order with every other UI save (F-39). + ESP_LOGW(TAG, "WiThrottle is not the selected transport; saving JMRI settings only"); + if (m_settingsWriter) { + m_settingsWriter->post([settings]() { saveSettings(settings); }); + } else { + ESP_LOGE(TAG, "No settings writer; JMRI settings not saved"); + } + 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..ed54f4e 100644 --- a/main/controller/JmriConnectionController.h +++ b/main/controller/JmriConnectionController.h @@ -1,39 +1,108 @@ #pragma once +#include +#include #include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + class JmriJsonClient; class WiThrottleClient; class WiFiController; +class SettingsWriter; /** - * @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: - JmriConnectionController(JmriJsonClient* jsonClient, WiThrottleClient* wtClient, WiFiController* wifiController); + JmriConnectionController(JmriJsonClient* jsonClient, WiThrottleClient* wtClient, + WiFiController* wifiController, SettingsWriter* settingsWriter); ~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 workerTask(void* arg); + void runWorker(); + void wakeWorker(); + + bool lock() const; + void unlock() const; 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; + Settings m_settings; + bool m_autoReconnect; + Request m_request; + Settings m_requestedSettings; - 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; + /** 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/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 98cf129..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" @@ -8,6 +9,17 @@ 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 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, @@ -16,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) @@ -50,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()) { @@ -171,6 +177,13 @@ 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 (!knobInputAllowed(knobId)) { + return; + } + if (!lockState(pdMS_TO_TICKS(50))) { ESP_LOGW(TAG, "Failed to lock state for knob rotation"); return; @@ -237,16 +250,27 @@ 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); } - ESP_LOGI(TAG, "Knob %d changed throttle %d speed: %d -> %d (dir: %s -> %s, steps: %d, optimistic + polling)", + // 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_LOGD(TAG, "Knob %d changed throttle %d speed: %d -> %d (dir: %s -> %s, steps: %d, optimistic + polling)", knobId, throttleId, currentSpeed, @@ -266,6 +290,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; @@ -305,9 +333,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); } } @@ -315,8 +347,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; @@ -364,6 +405,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) { @@ -398,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) @@ -409,6 +541,7 @@ void ThrottleController::trackPowerTaskFunc(void* arg) request->controller->m_backend->setTrackPower(request->on); } delete request; + StackReport::logSelf(); vTaskDelete(nullptr); } @@ -463,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 @@ -606,28 +740,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) @@ -655,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 f4fb37c..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" @@ -92,6 +93,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 +221,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 @@ -230,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 03b855d..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; @@ -107,7 +111,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)); @@ -140,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; } @@ -161,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; } @@ -183,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/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/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' 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..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" @@ -373,6 +374,244 @@ 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)); +} + +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()); +} + +// --- 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); @@ -396,4 +635,16 @@ 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); + 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/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); } diff --git a/main/tests/ThrottleControllerTests.cpp b/main/tests/ThrottleControllerTests.cpp index 33375a8..f85d1d6 100644 --- a/main/tests/ThrottleControllerTests.cpp +++ b/main/tests/ThrottleControllerTests.cpp @@ -1,7 +1,12 @@ #include "unity.h" + +#include "CallbackSlot.h" #include "ThrottleController.h" #include "ThrottleBackend.h" #include "Locomotive.h" +#include "RotaryEncoderHal.h" + +#include namespace { struct UiCallbackState { @@ -31,7 +36,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; @@ -46,6 +57,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 { @@ -62,7 +74,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}); @@ -72,7 +84,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}); @@ -82,6 +94,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 { @@ -382,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; @@ -542,6 +625,149 @@ 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())); +} + +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); @@ -563,4 +789,14 @@ 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); + 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); + 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/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..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) @@ -56,20 +32,34 @@ JmriConfigScreen::JmriConfigScreen(JmriJsonClient& jsonClient, , m_backButton(nullptr) , m_keyboard(nullptr) , m_keyboardLabel(nullptr) - , m_connectInProgress(false) + , m_statusTimer(nullptr) , m_jsonClient(jsonClient) , m_wiThrottleClient(wiThrottleClient) - , m_wifiController(wifiController) - , m_encoderHal(encoderHal) - , m_throttleController(throttleController) + , m_connection(connection) { } 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 +95,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(); @@ -349,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 || @@ -382,7 +364,7 @@ void JmriConfigScreen::updateStatus() break; case WiThrottleClient::ConnectionState::DISCONNECTED: default: - if (m_connectInProgress.load()) { + if (busy) { wiThrottleText = "Connecting..."; } break; @@ -402,7 +384,7 @@ void JmriConfigScreen::updateStatus() break; case JmriJsonClient::ConnectionState::DISCONNECTED: default: - if (m_connectInProgress.load()) { + if (busy) { jsonText = "Waiting..."; } break; @@ -414,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; - } - - 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(); + // 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(); } - - ESP_LOGI(TAG, "JMRI settings saved (Power Manager: %s)", powerMgr.c_str()); + updateStatus(); } void JmriConfigScreen::loadSettings() @@ -612,8 +519,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..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,15 @@ 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 + * 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,14 +93,12 @@ class JmriConfigScreen { lv_obj_t* m_backButton; lv_obj_t* m_keyboard; lv_obj_t* m_keyboardLabel; - std::atomic m_connectInProgress; + lv_timer_t* m_statusTimer; // 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/MainScreen.cpp b/main/ui/MainScreen.cpp index bdcc7d7..303bd5c 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). +} + +void MainScreen::show() +{ + if (!m_screen) { + return; + } + lv_scr_load(m_screen); + updateAllThrottles(); } -lv_obj_t* MainScreen::create(WiThrottleClient* wiThrottleClient, JmriJsonClient* jmriClient, ThrottleController* throttleController) +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); @@ -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) @@ -276,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(); @@ -310,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); } @@ -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 } @@ -490,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/MainScreen.h b/main/ui/MainScreen.h index b4ce050..c176a17 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 @@ -64,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; @@ -85,10 +94,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); diff --git a/main/ui/OrchestratorConfigScreen.cpp b/main/ui/OrchestratorConfigScreen.cpp index fcd88a3..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) @@ -28,9 +30,9 @@ OrchestratorConfigScreen::OrchestratorConfigScreen(OrchestratorClient* client, , m_connectButton(nullptr) , m_keyboard(nullptr) , m_statusTimer(nullptr) - , m_connectInProgress(false) , m_client(client) , m_wifiController(wifiController) + , m_settingsWriter(settingsWriter) { } @@ -281,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() @@ -352,26 +367,6 @@ void OrchestratorConfigScreen::onSaveClicked(lv_event_t* e) } self->hideKeyboard(); self->saveSettings(); - 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) @@ -389,16 +384,14 @@ 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. 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(); + }); } } diff --git a/main/ui/OrchestratorConfigScreen.h b/main/ui/OrchestratorConfigScreen.h index 06614db..dc28db1 100644 --- a/main/ui/OrchestratorConfigScreen.h +++ b/main/ui/OrchestratorConfigScreen.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include "lvgl.h" @@ -8,6 +7,7 @@ class OrchestratorClient; class WiFiController; +class SettingsWriter; /** * @brief Layout orchestrator connection settings. @@ -22,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; @@ -53,7 +54,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,10 +68,11 @@ class OrchestratorConfigScreen { lv_obj_t* m_keyboard; lv_timer_t* m_statusTimer; - std::atomic m_connectInProgress; 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/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/WiFiConfigScreen.cpp b/main/ui/WiFiConfigScreen.cpp index 10e1ce0..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) { } @@ -278,8 +281,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; } } @@ -421,6 +426,7 @@ void WiFiConfigScreen::scanTask(void* arg) } lvgl_port_unlock(); + StackReport::logSelf(); vTaskDelete(nullptr); return; } @@ -428,6 +434,7 @@ void WiFiConfigScreen::scanTask(void* arg) } screen->m_scanInProgress.store(false); + StackReport::logSelf(); vTaskDelete(nullptr); } @@ -493,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 a052d3b..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) { } @@ -103,11 +105,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"); @@ -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/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. 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(); +};