Skip to content

fix(p1am-firmware): recover the comms watchdog and bumpless setpoints stranded off main (#3999, #4002) - #4463

Open
dieterolson wants to merge 6 commits into
mainfrom
fix/p1am-firmware-comms-watchdog-recovery
Open

fix(p1am-firmware): recover the comms watchdog and bumpless setpoints stranded off main (#3999, #4002)#4463
dieterolson wants to merge 6 commits into
mainfrom
fix/p1am-firmware-comms-watchdog-recovery

Conversation

@dieterolson

@dieterolson dieterolson commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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::SetTag still clamps to [0.0, 100.0]; the only thing touching the issue is an IsLimitEffective docstring asserting a host-side rejection that is implemented nowhere.

Why this PR exists

PR #4044 was squash-merged onto origin/scada/firmware-harness-ci, not main. Its commit aaff7a76e is on no other ref, and that branch then stalled. #3999, #4001, #4002 and #4032 were all closed completed against it on 2026-08-02.

Verified by content, not ancestry: src/p1am_control_system/firmware/CommsWatchdog.cpp and .h do not exist on main, and the branch carries 10 changed files / 333 insertions under firmware/ that never shipped.

What ships

Change Issue
CommsWatchdog.cpp/.h — dead-man timer on the SCADA link #3999
firmware.ino watchdog wiring: re-arm on client accept and on a change to holding register 560, expiry drives AOs to 0, opens the heater relay, asserts Inhibit #3999
PIDController::Hold/Release/IsHeld + integral reset on setpoint change #4002
Measured-dt scan integration, bounded to [1 ms, 1 s] #4009 (partial)
Harness repair + p1am-firmware.yml CI gate (from 50f753579)

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_heartbeat plus a per-scan stroke), so without this PR the backend strokes a heartbeat register no firmware reads.

Two hardening changes on top of 50f753579:

  • The arduino-cli installer is pinned to v1.5.1 instead of tracking master. 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.
  • Both jobs are now gated against fork pull requests. firmware-compile pipes that installer into a shell; firmware-unit-tests compiles and executes contributor-authored code via make test. Both run on d-sorg-fleet — the owner's own machines — with default_workflow_permissions=WRITE and can_approve_pull_request_reviews=true, while the repo is public with forking enabled and fork-PR approval set to first_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:

  • All of SafetyInterlock.cpp/.h. The trip-tier change makes lolo = 0.0 "effective" (IsLimitEffective accepts the closed [0, 100] interval, and its own test asserts IsLimitEffective(0.0f) is true) and trips on val <= lolo. With the shipped defaults.py (lolo=0.0 on all 32 tags), every routed tag sitting at exactly 0.0 — TAG_10/11, the analog outputs, plus TAG_4/5 — trips on the first scan. The trip action then re-writes 0.0 to the routed output tags, so the condition stays true and ClearTrip() 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. Separately hihi = 100.0 sits exactly on SetTag's clamp ceiling, so the over-temperature trip that previously fired at high = 95.0 (≈1330 °C) becomes unreachable. The IsTagRouted guard narrows evaluation from 32 tags to the 8 routed ones but does not rescue it.
  • SignalBroker::IsTagRouted() — used only by Evaluate, so it belongs with the part that is not shipping.
  • The coil-1 → ClearTrip() pulse in firmware.ino. 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. Note main currently 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.
  • Four tests: TestInterlockTripsOnHihiLoloNotHighLow, TestInterlockIgnoresUnroutedTags, TestInterlockTripIsRecoverable, TestInterlockLimitDomain. The last asserts IsLimitEffective(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.

  1. The comms-lost block writes into the tags the interlock trips on. On expiry it does broker.SetTag(tag_id, 0.0f) for each routed output tag, then zeroes the hardware. That 0.0 is exactly what main's Evaluate treats as below low = 5.0, so it will trip the interlock on the following scan. On main'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 let Evaluate re-drive the AOs from the last host value.
  2. The #4002 reset is scoped to the zeroed case, deliberately. The recovered commit reset the integral on next != setpoint_, i.e. any change. Since SyncModbusToDCS (firmware.ino:108) calls SetSetpoint on 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 to next == 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 TestSafetyInterlock against main's unchanged interlock — which is what demonstrates the separation is real:

TestSignalBroker / TestPIDController / TestSafetyInterlock / TestStorageManager
TestSoftFailRuntimeContracts / TestPidResetsIntegralOnSetpointZeroed
TestPidKeepsIntegralAcrossNonZeroSetpointChange
TestPidDoesNotIntegrateWhileTripped / TestCommsWatchdog          -> all 9 PASSED (exit 0)

Built locally with MSVC 14.50 because this box has no POSIX toolchain; firmware-unit-tests re-runs the same sources under g++ with -Wall -Wextra. firmware-compile is the first real arduino-cli compile of firmware.ino in CI, so treat it as the gate on the sketch itself.

TestPidKeepsIntegralAcrossNonZeroSetpointChange was verified to be a real regression test, not a tautology: with the broad next != 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 current main (73fd3d49); expects a re-sync after #4448 lands.

🤖 Generated with Claude Code

dieterolson and others added 2 commits August 14, 2026 01:26
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +142 to +149
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
@dieterolson
dieterolson enabled auto-merge (squash) August 14, 2026 09:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment