Repository navigation
Conversation
Records the findings of a full static review of the firmware, numbered on from the first pass so an F-nn reference in a comment stays unambiguous. Each finding carries a severity weighed by whether a train can move uncommanded, an operator can lose the ability to stop one, or the device can crash while one is moving; a batch sized to one flash-and-test cycle; and bench criteria that stay unticked until run on the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
…creen (F-19, F-20, F-21) F-19: one corrupted I2C read reached the controller as a delta and was clamped to full speed in one direction or the other. The encoder HAL now drops anything beyond a full revolution per poll as a failed read; the controller clamps before multiplying (that multiply overflowed), and the knob's roster wrap is modular rather than a loop that could spin for millions of iterations holding the state mutex. F-20: taking over a loco on the orchestrator zeroed the model and the shadow, so the first click on a loco doing 60 commanded speed 4, or a reversal. OrchestratorClient now keeps each loco's last reported state (snapshot replaces it; LOCO_STATE updates it; link loss clears it), and acquire replays it to the new throttle as a LOCO_STATE would arrive. Display path only: nothing is sent on acquire. F-21 (and F-32): showMainScreen() destroyed the old MainScreen inside an LVGL handler while an encoder or network task could already hold its pointer and be waiting on the LVGL lock to repaint it. The main screen is now built once and re-shown, which also stops the whole LVGL tree leaking on every return. That made the screen's registration on the WiThrottle client's single connection slot a lasting problem -- JmriConfigScreen nulled it on Back and nothing restored it -- so neither screen takes that slot any more: MainScreen gets link changes through the controller, and JmriConfigScreen polls on a timer like the other config screens. Compiled for the app and the test configuration; the new Unity tests and the bench checks in docs/REVIEW_REMEDIATION_PLAN_2.md still need the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
… the JMRI ping task (F-22, F-23, F-31) F-22: when JMRI dropped the link the receive task exited but nothing closed its socket, and the reconnect task's connect() overwrote it -- one of lwIP's ten sockets lost per JMRI restart or long WiFi drop. connect() now reaps any previous session first. Teardown clears m_socket under the send mutex before closing and the receive task reads its own copy, so no send or recv can land on a descriptor lwIP has handed to another socket. Worse, the new session held no locos while the UI still showed every throttle live, so JMRI ignored the knob and the stop press alike. The acquisition record now mirrors the UI rather than the TCP session: it survives a disconnect, every connect() re-acquires from it (JMRI's reply re-seeds the display), and only a release removes an entry. F-23: the client never sent "*+", so JMRI's heartbeat monitoring -- its dead-man stop for a crashed or half-open device -- was never on. It now arms it when the server announces a non-zero interval and sends "*" at half that from the receive task, which wakes every second anyway. It also sends HU<station MAC> in place of the malformed "HESP32-S3". F-31: stopHeartbeat() vTaskDelete()d the ping task from outside, possibly mid-send holding the WebSocket client's lock. The task is now woken to exit, signals and suspends, and is deleted only after that signal, under a mutex shared with startHeartbeat(). Stack 2 KB -> 3 KB. Compiled for the app and the test configuration; bench checks are listed in docs/REVIEW_REMEDIATION_PLAN_2.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
…(F-24, F-34, F-40) F-24: after an ordinary outage the device stayed offline, every throttle dead, until someone went into settings. - WiFi gave up for good after five immediate retries -- shorter than a router reboot. It now reports FAILED but keeps retrying on a one-shot esp_timer (5 s doubling to 60 s) until Disconnect or Forget, which the WiFi screen now offers in that state. The timer only calls esp_wifi_connect(), which does not block the esp_timer task. - The orchestrator login was tried once at boot. orch_connect now supervises the link for the life of the app: waits for WiFi however long, retries logins with backoff (under the server's 5/min limit), logs in afresh when the socket's own cookie-reusing reconnect has not recovered in 30 s, and retries a failed roster read. The config screen's Connect wakes it instead of racing it with a task of its own. - JMRI auto-connect waited 30 s for WiFi, then never reconnected. F-34: JMRI connects happened on three tasks and disconnect on the LVGL task (blocking it for seconds), the JSON client was connected from the WiThrottle receive task, a manual disconnect was undone within five seconds, and a manual connect never updated the address reconnects used. JmriConnectionController now owns one jmri_conn task that does every connect, reconnect and disconnect, saves the settings, and learns the JSON port from PW via the receive task without connecting anything there. The screen only asks. Under the orchestrator, Connect just saves. F-40: WiFi credentials are saved once they produce an IP, not before, so a typo no longer replaces a good password; and a refused esp_wifi_set_config reports FAILED instead of rebooting the device. Compiled for the app and the test configuration; bench checks are listed in docs/REVIEW_REMEDIATION_PLAN_2.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
…a client's slot Both are rules the batch 1 fixes depend on and that look like oversights to a later reader: rebuilding the main screen reintroduces F-21's use-after-free, and a screen registering on a client's connection callback cuts the backend's knob gating off from the link (F-30). The Copilot instructions' example showed exactly the rebuild pattern, so it changes with CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
This was referenced Sep 25, 2026
F-19…F-44 now have issues #22…#47 as a permanent record outside the tree; the tracker gains an Issue column. Also brings three plans (F-22, F-23, F-24) and F-44's description into line with what was built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
…(F-25…F-29) F-25: the physical encoders reached ThrottleController without passing the UI's link gate, and a speed the transport refused stayed on the display, so the first click afterwards jumped from it. The controller now refuses knob input while the link is down and rolls a failed send back (unless a transport report landed since). The orchestrator refuses with an ERROR rather than a failed send, so any ERROR now re-seeds every assigned throttle from the layout's last reported state. F-26: a re-login stopped and destroyed the WebSocket handle while a sender could be inside it. The handle now has its own mutex, held for each send and across stop/destroy; senders only take it once the link is known up, so they never queue behind a re-login. F-27: there was no emergency stop, and the power toggle turned power ON from an unknown state. The port gains emergencyStop() -- EMERGENCY_STOP halts the layout; WiThrottle sends X to every held loco -- behind an E-STOP button that fires on press, in the one saturated colour. Unknown power now turns power off, and under WiThrottle an OFF goes over PPA0 when the JSON link is down. F-28: FUNCTION_COMMAND sets state outright, so press-on/release-off made every orchestrator function momentary. A port capability now tells the controller to toggle on press there, and keep press/release for JMRI. F-29: the lock order was documented backwards in CLAUDE.md, the threading model and the review agent. Corrected to what the code has always done: the LVGL lock before m_stateMutex. Compiled for the app and the test configuration; the E-STOP layout and the bench checks in docs/REVIEW_REMEDIATION_PLAN_2.md need the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
This was referenced Sep 25, 2026
… task (F-30…F-42) Batch 5 of the second review pass: the remaining MEDIUM and LOW findings. - F-30: every callback slot on the clients, both backends and the controller is a CallbackSlot, set under its own lock and invoked as a copy with the lock released. The controller's function-and-userData pairs become one closure each, so neither half can be replaced alone. - F-33: CONFIG_THROTTLE_STACK_REPORT (off by default) logs each task's stack high-water mark for sizing on the bench. Nothing resized blind. - F-35: the orchestrator roster is streamed through JsonArraySplitter and parsed a record at a time, so an 8 KB buffer no longer refuses any roster above about 35 locos. Capped at 128 locos and 4 KB a record. - F-37: a transport report is applied only to the throttle that holds the loco it names; speeds over 126 and functions over F28 are dropped. - F-38: per-line and per-tick logs are DEBUG; the power bar refreshes once per repaint, and unchanged widgets are not restyled. - F-39: SettingsWriter, one task and a queue, carries out every NVS write the UI asks for, in order. It replaces the one-shot jmri_save task. - F-41: Seesaw reads are request, 500 us wait, read, done once, instead of a combined write-read done twice, which lost rotation between reads. - F-42: OrchestratorClient keeps its link, system and power states as atomics and writes no shared string unlocked; WebSocket continuation frames are reassembled; the layout id is dropped on every login. requestPowerList is gone, the JMRI power name is locked, and the WiThrottle roster warning no longer reads past the end. - F-44: the threading model's task table is brought up to date. Compiled for the app and test configurations; nothing here has run on the board. The Seesaw change in particular needs a bench check before merging. Refs #33, #36, #38, #40, #41, #42, #44, #45, #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
…d (F-43) - permissions: contents: read. The job checks out, builds and uploads an artifact; it needs nothing more, and the repository default may grant write. - actions/checkout, espressif/esp-idf-ci-action and actions/upload-artifact are pinned by commit, each with its release in a comment. A tag can be moved to other code after review. esp-idf-ci-action's v1 is a branch, not a tag, and is pinned at the head that @v1 was running. - espressif/esp_websocket_client goes from '*' to '~1.8.0', the series dependencies.lock holds. The lock's manifest_hash is recomputed with the component manager so CI does not re-solve. Refs #46 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
…3, F-36) - The plan records batch 5: acceptance criteria for every finding in it, and a tracker in which every finding is now at least code done. F-33's measurements and F-36's decision are the two left open. - F-36: the measured facts for the cleartext decision. mbedTLS (about 117 KB) is already linked by esp_websocket_client, so TLS would cost little flash, and staying cleartext saves none; the cost is heap per connection and a certificate the orchestrator would have to serve. CLAUDE.md's Open limits names the question; the decision is the owner's. - CLAUDE.md, the Copilot instructions and main/utils/README.md list utils/ and SettingsWriter. Refs #36, #39 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
This was referenced Sep 25, 2026
CI on bf65c15 downloaded the three pinned commits and took dependencies.lock as it is, without re-solving. Refs #46 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A full static review of the firmware found 26 defects, recorded as F-19…F-44 in
docs/REVIEW_REMEDIATION_PLAN_2.md. The numbering continues from the first pass, so anF-nnin a code comment is unambiguous across both plans. Each finding also has an issue: F-nn is #(nn+3), so #22 to #47.This PR adds the plan and the code for every finding. Two things are left open deliberately:
Each batch is sized to one flash-and-test cycle.
Opened as a draft: it compiles, but nothing here has run on the board. It should come out of draft once the bench checks below pass.
Issues addressed
These are referenced, not closed: each issue should close when its bench check passes, not on merge.
Batch 1 —
7c980f9OrchestratorClientnow keeps each loco's last reported state, and acquire replays it to the display. The first click now moves from the loco's real speed, instead of sending speed 4 or a reversal. Nothing is sent on acquire.MainScreenis now built once and re-shown. NeitherMainScreennorJmriConfigScreentakes the WiThrottle client's connection-callback slot any more, which the knob gating depends on.Batch 2 —
68452a5connect()now reaps the previous session first, and every new session re-acquires the locos the UI still shows as allocated.*+and heartbeats at half the server's announced interval, so JMRI e-stops this device's locos if it goes silent. It also sendsHU<MAC>in place of the malformedHESP32-S3.Batch 3 —
b35b84corch_connecttask supervises the orchestrator login and socket.jmri_conntask now carries out every JMRI connect and disconnect. Disconnect no longer blocks the LVGL task and no longer gets undone. The JSON port is learned fromPWwithout the old race. A manual connect now updates the address used for reconnects.esp_wifi_set_configno longer reboots the device.Batch 4 —
1b8767bThrottleController, so it now covers the encoders as well as the touch path. A failed send rolls the model back, unless a transport report has landed since. An orchestratorERRORre-seeds every assigned throttle from the layout's last reported state.OrchestratorClientnow guards its handle with its own mutex, held across send and across stop and destroy. Sends wait a bounded time, and only once the link is up.emergencyStop()is now part of the backend port:EMERGENCY_STOPon the orchestrator (layout-wide), andXon each held loco under WiThrottle.PPA0when the JSON link is down.THREADING_MODEL.md, the firmware-review agent and the controller docs now all say LVGL lock beforem_stateMutex, which is what the code does.Batch 5 —
29b54d2,c472b03,bf65c15CallbackSlot. It is set under its own lock, and the callback runs as a copy with that lock released.CONFIG_THROTTLE_STACK_REPORT(off by default) makes every task log its stack high-water mark.THREADING_MODEL.mdnow has a table for the figures, and describes how to take them.SettingsWriter, with one task and a queue, now carries out every NVS write the UI asks for, in order. It replaces the one-shotjmri_savetask.OrchestratorClientkeeps its link, system and power states as atomics, and writes no shared string unlocked.requestPowerListis gone, and the JMRI power-manager name is now locked.ci.ymlnow haspermissions: contents: read.esp-idf-ci-action@v1turned out to be a branch; it is pinned at the head@v1was running.esp_websocket_clientis pinned to~1.8.0, with the lock'smanifest_hashrecomputed so CI does not re-solve.Docs —
9ddbb4f,83007d4,13408f5,1537db5,081d4e4MainScreenis never rebuilt, and UI code never takes a client's connection callback.Decision needed: F-36 (#39)
The login, the 30-day session cookie and the control plane all cross the layout WiFi in cleartext. What the numbers say:
esp_websocket_client. A build with HTTPS turned off inesp_http_clientwas only 528 bytes smaller. So TLS would cost little flash, and staying cleartext saves none.The options:
Behaviour changes to expect on the bench
Verification
CONFIG_THROTTLE_TESTSconfigurations build with ESP-IDF v5.5.2 and no new warnings. So does the app withCONFIG_THROTTLE_STACK_REPORT=y. The Espressif component registry was unreachable from the build environment, so the local build used GitHub copies of the pinned component versions (LVGL 8.4.0, esp_websocket_client 1.8.0, esp_lcd_touch and GT911 1.2.1). CI on this PR is the registry-based build, and it passes with the pinned actions, taking the lock file as it is.Bench checks (full criteria in the plan and each issue)
offline, turn a knob; the display should return to the layout's speed.CONFIG_THROTTLE_STACK_REPORT=y, drive under load, and record the figures inTHREADING_MODEL.md(F-33: Task stack headroom unmeasured #36).🤖 Generated with Claude Code
https://claude.ai/code/session_01Sphir3ie1RKbPNUWePK49n