fix(p1am-firmware): recover the comms watchdog and bumpless setpoints stranded off main (#3999, #4002) - #4463
fix(p1am-firmware): recover the comms watchdog and bumpless setpoints stranded off main (#3999, #4002)#4463dieterolson wants to merge 6 commits into
Conversation
The P1AM firmware owns the safety interlock, the four PID loops, and the
analog/relay outputs -- it is what keeps the plant safe when the Raspberry Pi
host is gone. Nothing in CI compiled it and nothing ran its tests.
A host-side harness already existed (tests/p1am_control_system/firmware/): a
Makefile that builds the real firmware sources against a fake HardwareInterface,
with assertions on scaling, anti-windup, trip latching and NaN soft-fail. It
sits inside a directory pytest collects but holds no .py files, so it was never
executed -- and it had rotted to the point of not compiling:
- MockHardware did not implement WriteHeaterRelay, so it was abstract.
- The StorageManager round-trip still used the pre-lolo/hihi 4-argument
Save/Load signature.
- TestSignalBroker asserted 350 C -> 35.0%, an expectation left over from a
1000 C full scale; the firmware has since moved to 1400 C.
Repairs, and what they buy:
- MockHardware implements the full interface. It also records the highest
value each analog output was ever commanded to, so a safety test can assert
an output was *never* energized rather than merely reading zero now.
- The StorageManager test round-trips all four interlock tiers and checks an
untouched tag keeps its saved value, so a future struct change cannot
silently drop lolo/hihi the way the last one did.
- kThermocoupleFullScaleC moves from a function-local literal in
SignalBroker.cpp to a public constant in SignalBroker.h. The test derives
its expected percentage from it instead of hardcoding one, which is what
let that assertion go stale. This also gives the backend cross-check in
#3998 a single named definition to read.
- test_dcs.cpp #errors under NDEBUG. Every check is an assert(), so a build
that compiled them away would exit 0 having tested nothing.
CI gains two gates: firmware-unit-tests (plain g++, seconds, the TDD vehicle
for firmware work) and firmware-compile (arduino-cli against the real board
package, catching library and signature breakage the host harness cannot see).
Toolchain versions are written to the job summary so a binary can be traced to
the toolchain that built it.
Refs #3995
(cherry picked from commit 50f7535)
…#3999, #4002) PR #4044 was squash-merged onto a FEATURE branch, not `main`. Its commit `aaff7a76e` exists only on `origin/scada/firmware-harness-ci`, which then stalled. Four issues were closed against it — #3999, #4001, #4002, #4032 — so all four defects are live on `main` today. Verified by content, not ancestry: `CommsWatchdog.cpp`/`.h` do not exist on `main`. That commit mixes safe, needed safety work with a `SafetyInterlock` trip-tier change that must not ship (see below). This carries only the separable part. Recovered here: * `CommsWatchdog` (#3999) — a dead-man timer on the SCADA link with two independent re-arm signals, because each misses what the other catches: a live Modbus TCP client covers host power loss, a killed backend and a pulled cable, while a change on holding register 560 additionally catches a wedged backend holding an idle socket open. On expiry the scan drives both analog outputs to zero, opens the heater relay and asserts Inhibit. Previously the relay and AOs held their last command forever once the host died — and the HMI is exactly what died, so there was no operator visibility either. * `PIDController::Hold`/`Release`/`IsHeld` and the setpoint integral reset (#4002) — a wound-up integral held the AO at 100% for tens of seconds after an E-stop, whose only effect reaching the plant is zeroing these setpoints. Holding a loop now sheds its accumulated integral and derivative so a restored link cannot slam the output. * Measured-`dt` scan integration (#4009, partial) — the scan does ~300 register reads, SPI thermocouple reads and sometimes a blocking flash write, so integrating as if the nominal 100 ms had elapsed understated Ki and overstated Kd whenever it overran. Now measured and bounded to [1 ms, 1 s]. * The harness repair and CI gate from `50f753579`, with two additions: the `arduino-cli` installer is pinned to `v1.5.1` instead of tracking `master` (it is piped into a shell on a self-hosted runner), and `firmware-compile` is gated against fork pull requests because this repository is public. Deliberately NOT carried, and why: * All of `SafetyInterlock.cpp`/`.h`. The trip-tier change makes `lolo = 0.0` "effective" and trips on `val <= lolo`, so with the shipped `defaults.py` every routed tag sitting at exactly 0.0 (TAG_10/11, the AOs; TAG_4/5) trips on the first scan — and the trip action itself re-writes 0.0 to the routed output tags, so the condition stays true and `ClearTrip` can never stick. Separately `hihi = 100.0` sits on `SetTag`'s clamp ceiling, making the over-temperature trip unreachable. Fixing #4001 properly needs new trip semantics AND a real percent-scaled limit set in `defaults.py`; that is a human firmware review, not a recovery PR. * `SignalBroker::IsTagRouted()` — used only by the interlock's `Evaluate`, so it belongs with the part that is not shipping. * The coil-1 -> `ClearTrip()` pulse in `firmware.ino`. It is inert without the tier change (`Evaluate` re-trips on the next scan) and it is interlock behaviour, so it should land with the reviewed fix. * Four tests from the same commit: `TestInterlockTripsOnHihiLoloNotHighLow`, `TestInterlockIgnoresUnroutedTags`, `TestInterlockTripIsRecoverable` and `TestInterlockLimitDomain` — the last asserts `IsLimitEffective(0.0f)` is true, i.e. it encodes the defect as correct. Everything here only ever *reduces* output — `WriteHeaterRelay(false)`, `WriteAnalogOutput(0)`, `WriteInhibit(true)`, shed integral — so it cannot make an already-broken interlock worse. Verified locally: the host harness compiles all six sources and all 8 tests pass with assertions active, including the pre-existing `TestSafetyInterlock` against `main`'s unchanged interlock — which is what demonstrates the separation is real. Built with MSVC 14.50 rather than CI's g++, since this box has no POSIX toolchain; `firmware-unit-tests` re-runs it under g++. Closes #3999 Closes #4002 Does NOT close #4001 or #4032. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42f08aea42
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Sits immediately after the interlock block (300..555) and inside the | ||
| // configured holding-register map -- see configureHoldingRegisters() in setup. | ||
| const int kHostHeartbeatReg = 560; | ||
| const unsigned long kCommsTimeoutMs = 2000; // 20 scans at the nominal 100 ms |
There was a problem hiding this comment.
Keep the timeout above the slowest healthy poll interval
With the backend in its default lightweight mode, settings.py sets the poll interval to 2.0 seconds and main.py:366-430 sleeps for that interval only after completing each scan, so successive Modbus request bursts are necessarily more than 2 seconds apart. This commit contains no backend writer for register 560, leaving those requests as the only recurring activity; consequently this exact 2000 ms timeout expires during normal operation, zeros the output tags, and can make the existing low-limit interlock latch on the following firmware scan. Use a timeout with scheduling margin above every supported healthy cadence or ship the heartbeat writer atomically with this change.
Useful? React with 👍 / 👎.
| name: Firmware host unit tests (g++) | ||
| # The d-sorg-fleet pool spans Linux, Windows and macOS hosts; this job needs | ||
| # a POSIX toolchain for the Makefile, so pin it to the Linux label. | ||
| runs-on: [d-sorg-fleet, Linux] |
There was a problem hiding this comment.
Gate host unit tests against fork pull requests
In the inspected p1am-firmware.yml, this job runs on the self-hosted d-sorg-fleet for every matching pull_request, unlike the compile job's same-repository guard at lines 90-92. A fork can modify the in-scope Makefile or C++ test sources and have make test compile and execute that attacker-controlled code on a persistent fleet machine; apply the same fork condition before assigning this job to the self-hosted runner.
Useful? React with 👍 / 👎.
| const float next = FiniteOrZero(setpoint); | ||
| if (next != setpoint_) { | ||
| // Bumpless transfer: carrying the old integral across a setpoint change | ||
| // means the accumulated term still reflects the previous target. On an | ||
| // E-stop -- whose only effect that reaches the plant is zeroing these | ||
| // setpoints -- a wound-up integral held the analog output at 100% for tens | ||
| // of seconds after the operator commanded a stop (issue #4002). | ||
| ResetDynamicState(); |
There was a problem hiding this comment.
Preserve integral across ordinary setpoint updates
For any nonzero setpoint step or host-driven ramp, this condition clears the accumulated integral and derivative history, so the next output drops the previous I contribution instead of transferring bumplessly; repeated setpoint changes prevent an integrating loop from eliminating steady-state error. The safety requirement in SPEC.md:71-75 is specifically to shed the integral when the setpoint is zeroed, so restrict the reset to that transition or rebalance the state to preserve the current output for ordinary updates.
Useful? React with 👍 / 👎.
…reset Two review findings from the recovery PR. 1. `firmware-unit-tests` was ungated for fork pull requests. `make test` COMPILES AND EXECUTES contributor-authored code, on `d-sorg-fleet` -- the owner's own machines -- with `default_workflow_permissions=WRITE` and `can_approve_pull_request_reviews=true`. The repository is public, forking is enabled, and fork-PR approval is set to `first_time_contributors_new_to_github`, so approval is required only of accounts brand new to GitHub: any pre-existing account could fork, open a PR touching a firmware path, and get arbitrary code execution on the fleet with no approval step. Now gated exactly like `firmware-compile`. Only one other workflow in the repo gates on forks, but a uniformly exposed repo is a reason not to add to the exposure, not a licence to match it. 2. The #4002 integral reset was broader than the issue specifies. The issue is "PID integral is not reset when the setpoint is ZEROED"; the recovered commit reset on `next != setpoint_`, i.e. on any change. `SyncModbusToDCS` (firmware.ino:108) calls `SetSetpoint` on every scan whenever the host register differs, so a host-driven ramp -- or 1-LSB float jitter through the register round-trip -- cleared the integrator every scan and the loop silently ran P+D only, never closing steady-state offset. Narrowed to the `next == 0.0f && setpoint_ != 0.0f` transition, which is what the issue asks for and what makes `Closes #4002` honest: as written it would have closed the issue while introducing a different control defect. If reset-on-change is ever wanted for bumpless transfer on large steps it needs a deadband, not an equality test -- recorded in the header contract and the implementation. `TestPidResetsIntegralOnSetpointChange` is renamed to `TestPidResetsIntegralOnSetpointZeroed` to match the narrowed contract, and `TestPidKeepsIntegralAcrossNonZeroSetpointChange` is added to pin the ramp case. That new test was verified to be a real regression test: with the broad `next != setpoint_` condition restored it fails at the clamp assertion, and it passes with the narrowed condition. Harness is 9/9 green with assertions active, including `TestSafetyInterlock` against main's unchanged interlock. Workflow YAML parses and both jobs carry the fork gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ms-watchdog-recovery
…ms-watchdog-recovery
…ms-watchdog-recovery
Recovers the safe half of a firmware commit that was squash-merged onto a feature branch instead of
main, leaving four safety defects live.Closes #3999 — no Modbus comms watchdog, so the heater relay and analog outputs held their last command forever after host or link loss.
Closes #4002 — PID integral not reset when the setpoint is zeroed, so the AO held full output for tens of seconds after an E-stop.
Does NOT close #4001 — fixing it needs new trip semantics and a real percent-scaled limit set in
defaults.py. The change that claimed to fix it is excluded here; see below.Does NOT close #4032 — nothing in the original commit actually fixes it.
SignalBroker::SetTagstill clamps to[0.0, 100.0]; the only thing touching the issue is anIsLimitEffectivedocstring asserting a host-side rejection that is implemented nowhere.Why this PR exists
PR #4044 was squash-merged onto
origin/scada/firmware-harness-ci, notmain. Its commitaaff7a76eis on no other ref, and that branch then stalled. #3999, #4001, #4002 and #4032 were all closedcompletedagainst it on 2026-08-02.Verified by content, not ancestry:
src/p1am_control_system/firmware/CommsWatchdog.cppand.hdo not exist onmain, and the branch carries 10 changed files / 333 insertions underfirmware/that never shipped.What ships
CommsWatchdog.cpp/.h— dead-man timer on the SCADA linkfirmware.inowatchdog wiring: re-arm on client accept and on a change to holding register 560, expiry drives AOs to 0, opens the heater relay, asserts InhibitPIDController::Hold/Release/IsHeld+ integral reset on setpoint changedtscan integration, bounded to[1 ms, 1 s]p1am-firmware.ymlCI gate (from50f753579)The watchdog uses two independent re-arm signals because each misses what the other catches: a live Modbus TCP client covers host power loss, a killed backend and a pulled cable (all drop the socket), while the heartbeat register additionally catches a wedged backend holding an idle socket open.
Register 560 is the firmware half of a contract with the backend's
HOST_HEARTBEAT_REGISTER. #4448 already ships the host half (write_heartbeatplus a per-scan stroke), so without this PR the backend strokes a heartbeat register no firmware reads.Two hardening changes on top of
50f753579:arduino-cliinstaller is pinned tov1.5.1instead of trackingmaster. It is fetched and piped straight into a shell on a self-hosted runner, so a moving branch means the fleet executes whatever lands on it.firmware-compilepipes that installer into a shell;firmware-unit-testscompiles and executes contributor-authored code viamake test. Both run ond-sorg-fleet— the owner's own machines — withdefault_workflow_permissions=WRITEandcan_approve_pull_request_reviews=true, while the repo is public with forking enabled and fork-PR approval set tofirst_time_contributors_new_to_github(required only of accounts brand new to GitHub). Only one other workflow in the repo gates on forks; a uniformly exposed repo is a reason not to add to the exposure, not a licence to match it.What is deliberately excluded, and why
Everything below is from the same commit and is not in this PR:
SafetyInterlock.cpp/.h. The trip-tier change makeslolo = 0.0"effective" (IsLimitEffectiveaccepts the closed[0, 100]interval, and its own test assertsIsLimitEffective(0.0f)is true) and trips onval <= lolo. With the shippeddefaults.py(lolo=0.0on all 32 tags), every routed tag sitting at exactly0.0— TAG_10/11, the analog outputs, plus TAG_4/5 — trips on the first scan. The trip action then re-writes0.0to the routed output tags, so the condition stays true andClearTrip()can never stick: a permanent, unclearable latch, which is the Default interlock config permanently trips the PLC, and there is no path to reset the trip #4001 symptom the commit claims to fix. Separatelyhihi = 100.0sits exactly onSetTag's clamp ceiling, so the over-temperature trip that previously fired athigh = 95.0(≈1330 °C) becomes unreachable. TheIsTagRoutedguard narrows evaluation from 32 tags to the 8 routed ones but does not rescue it.SignalBroker::IsTagRouted()— used only byEvaluate, so it belongs with the part that is not shipping.ClearTrip()pulse infirmware.ino. Inert without the tier change (Evaluatere-trips on the next scan) and it is interlock behaviour, so it should land with the reviewed fix. Notemaincurrently has no coil-1 wiring at all, so a latched trip is recoverable only by power cycle — that is part of Default interlock config permanently trips the PLC, and there is no path to reset the trip #4001, not something this PR changes.TestInterlockTripsOnHihiLoloNotHighLow,TestInterlockIgnoresUnroutedTags,TestInterlockTripIsRecoverable,TestInterlockLimitDomain. The last assertsIsLimitEffective(0.0f)is true — it encodes the defect as correct.Everything that does ship only ever reduces output —
WriteHeaterRelay(false),WriteAnalogOutput(0),WriteInhibit(true), shed integral — so it cannot make an already-broken interlock worse.Reviewer notes
Two things a firmware reviewer should look at deliberately. Neither blocks this PR.
broker.SetTag(tag_id, 0.0f)for each routed output tag, then zeroes the hardware. That0.0is exactly whatmain'sEvaluatetreats as belowlow = 5.0, so it will trip the interlock on the following scan. Onmain's already-unconditionally-latched interlock this is moot, and the direction is de-energize either way — but it is the one place where this PR's behaviour touches interlock semantics. Clearing the stale command is deliberate: without it, a restored link would letEvaluatere-drive the AOs from the last host value.#4002reset is scoped to the zeroed case, deliberately. The recovered commit reset the integral onnext != setpoint_, i.e. any change. SinceSyncModbusToDCS(firmware.ino:108) callsSetSetpointon every scan whenever the host register differs, that cleared the integrator every scan throughout a host-driven ramp — or on 1-LSB float jitter through the register round-trip — leaving the loop running P+D only with a steady-state offset it could never close, and no indication integral action had been disabled. Narrowed tonext == 0.0f && setpoint_ != 0.0f, which is the condition issue Firmware PID integral is not reset when the setpoint is zeroed, so the AO holds full output for tens of seconds after E-stop #4002 actually specifies. If reset-on-change is ever wanted for bumpless transfer on large steps, it needs a deadband, not an equality test.Verification
The host harness compiles all six sources and all 9 tests pass with assertions active, including the pre-existing
TestSafetyInterlockagainstmain's unchanged interlock — which is what demonstrates the separation is real:Built locally with MSVC 14.50 because this box has no POSIX toolchain;
firmware-unit-testsre-runs the same sources under g++ with-Wall -Wextra.firmware-compileis the first real arduino-cli compile offirmware.inoin CI, so treat it as the gate on the sketch itself.TestPidKeepsIntegralAcrossNonZeroSetpointChangewas verified to be a real regression test, not a tautology: with the broadnext != setpoint_condition restored it fails at the clamp assertion, and it passes with the narrowed condition. Committed and pushed with hooks enabled (no--no-verify). Based on currentmain(73fd3d49); expects a re-sync after #4448 lands.🤖 Generated with Claude Code