Skip to content

Repair orphaned P1AM firmware test harness and gate it in CI - #4043

Closed
dieterolson wants to merge 7 commits into
mainfrom
scada/firmware-harness-ci
Closed

Repair orphaned P1AM firmware test harness and gate it in CI#4043
dieterolson wants to merge 7 commits into
mainfrom
scada/firmware-harness-ci

Conversation

@dieterolson

Copy link
Copy Markdown
Collaborator

Closes #3995.

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.

What was already there, and why it stopped working

A host-side harness existed at tests/p1am_control_system/firmware/: a Makefile that builds the real firmware sources against a fake HardwareInterface, with assertions covering scaling, anti-windup, trip latching and NaN soft-fail. It sits inside a directory CI does collect (ci-standard.yml:701 adds tests/p1am_control_system/) but holds no .py files, so pytest walked straight past it.

Never being run, it rotted until it no longer compiled:

  • MockHardware did not implement WriteHeaterRelay, so the class was abstract and every MockHardware hw; was a hard error.
  • The StorageManager round-trip still used the pre-lolo/hihi 4-argument Save/Load; the real signature took 6 once InterlockConfigData grew to four limits (kMagic bumped 0xDC51 → 0xDC52).
  • TestSignalBroker asserted 350 °C → 35.0 %, an expectation left over from a 1000 °C full scale. The firmware moved to 1400 °C, making the correct answer 25.0 %.

That last one is the interesting failure: the suite was not merely unbuilt, it encoded a stale physical constant. This is the class of drift the harness exists to catch.

Changes

Harness repair

DRY fix that makes the stale assertion impossible to repeat

CI

  • New .github/workflows/p1am-firmware.yml, path-filtered to the firmware and its tests, pinned to [d-sorg-fleet, Linux] (the pool is heterogeneous and the Makefile needs a POSIX toolchain):
    • firmware-unit-tests — plain g++, no board toolchain, runs in seconds. This is the TDD vehicle for the rest of the firmware cluster.
    • firmware-compilearduino-cli against the real P1AM-100:samd package, catching library and signature breakage the host harness cannot see (it never includes P1AM.h or Ethernet.h).
  • The unit-test job greps the binary's own success line rather than trusting the exit code, so a build that silently tested nothing still fails.
  • Toolchain versions are written to the job summary each run, so a firmware binary can be traced to the toolchain that produced it.

Housekeeping

  • .gitignore for *.o, test_dcs, and config.bin (StorageManager persists to it when exercised on the host), so a local make test cannot dirty the tree.

Verification

Built and run under WSL Ubuntu-22.04, g++ 11.4:

=== DCS CORE FIRMWARE TDD TEST RUNNER ===
TestSignalBroker PASSED!
TestPIDController PASSED!
TestSafetyInterlock PASSED!
TestStorageManager PASSED!
TestSoftFailRuntimeContracts PASSED!
All C++ Core Firmware Tests Passed Successfully!

No Python or frontend code is touched.

Follow-on

This PR deliberately does not fix any firmware behaviour — it only makes the firmware testable and gated. The defects the harness now protects against are next, each with a failing test written first:

One gap this PR exposes and does not close: SafetyInterlock::Evaluate reads only high_limits_/low_limits_, and the harness only ever exercised those — nothing tests the HIHI/LOLO tier because nothing evaluates it. That is #4001's job.

🤖 Generated with Claude Code

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

@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: 50f7535794

ℹ️ 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".

Comment on lines +93 to +94
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh \
| BINDIR="$HOME/.local/bin" sh

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 Verify the installer before running it

When the firmware compile job runs, it downloads install.sh from the mutable master branch and pipes it directly into a shell on a self-hosted fleet runner. An upstream compromise or unexpected branch change can therefore execute arbitrary commands under the runner account; install a versioned Arduino CLI artifact and verify its checksum before execution.

Useful? React with 👍 / 👎.

Comment on lines +45 to +46
const float kExpectedPct =
kTempC * (100.0f / SignalBroker::kThermocoupleFullScaleC);

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 Keep the scaling expectation independent

Deriving kExpectedPct from the same firmware constant used by ReadHardwareInputs makes this assertion pass for any full-scale value, even if someone changes the firmware constant without updating the backend's independent 1400 °C default in backend/temperature_models.py:63-66. In that scenario every reported temperature is scaled incorrectly while this new firmware gate remains green, so assert the contractual 1400 °C value or an independently calculated 25% result instead.

Useful? React with 👍 / 👎.

…ints (#4044)

Four safety defects in the P1AM firmware, each with a failing host test written
first (the harness repaired in the parent PR is what makes that possible).

#3999 -- no dead-man timer on the SCADA link. The heater relay command is a
coil read and the analog outputs are driven from broker tags; both held their
last value forever once the host died. The host cannot cover this case because
the host is the thing that died. New CommsWatchdog drives all AOs to 0%, opens
the heater relay, asserts Inhibit and holds the PID loops after 2 s of silence.
Two independent activity signals, because each misses a case the other catches:
a live Modbus TCP connection (covers host power loss, killed backend, pulled
cable) and a host heartbeat register at 560 (covers a wedged backend holding an
idle socket open). Deliberately Arduino-free so the rollover behaviour is
testable -- millis() wraps every ~49.7 days and a naive comparison would disarm
the watchdog for another 49 days at the wrap.

#4001 -- the trip fired on the low/high band, which is the SCADA layer's
severity-1 *warning* tier, and evaluated all 32 tags including unrouted ones
sitting at 0.0. The stock config writes low=5.0 to every tag, so deploying it
latched the plant off; and since ClearTrip() had no callers and coil 1 was never
read, the latch was unrecoverable short of a power cycle -- after which the
flash-saved config tripped it again on the first scan. Evaluate() now trips on
hihi/lolo and skips unrouted tags (SignalBroker::IsTagRouted -- the broker owns
routing, so the predicate lives there). Coil 1 clears the latch and is written
back to 0 as a pulse acknowledgement.

#4002 -- SetSetpoint did not touch the integrator, and Compute() ran even while
tripped. Zeroing the setpoints is the only part of the host E-stop that reaches
the plant, so a wound-up integral held the AO at 100% of its 4-20 mA span for
tens of seconds after the operator commanded a stop. SetSetpoint now clears
integral/derivative history on a change (bumpless transfer, which is also the
right behaviour for ordinary retargeting), and Hold()/Release() freeze the loops
while tripped or blind so recovery starts clean.

#4032 -- broker tags are clamped to [0,100], so any limit above 100 was
unreachable and its trip silently dead. An operator entering 900 for 900 degC on
a percent-scaled tag disabled the interlock with no indication.
SafetyInterlock::IsLimitEffective distinguishes a deliberate +/-99999 "never
trip" sentinel from an unreachable entry; Evaluate() skips both rather than
comparing against a threshold that cannot be crossed, and the host can use the
same predicate to reject the configuration at the API boundary.

Also folds in the firmware half of #4009: the scan integrated against a
hardcoded 0.1 s while the real period runs well past that (~300 register reads,
SPI thermocouple reads, and a blocking flash write on config deploy). It now
uses the measured interval, bounded to [1 ms, 1 s].

The pre-existing TestSafetyInterlock encoded the old warning-band-trips
behaviour and is updated to the corrected contract.

All 12 host suites pass under g++ 11.4. The sketch itself is covered by the
arduino-cli gate.

Refs #3999 #4001 #4002 #4032 #4009
@dieterolson
dieterolson enabled auto-merge (squash) August 1, 2026 14:50
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Performance Benchmark Results

No benchmark results available.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Performance Benchmark Results

No benchmark results available.

@dieterolson

Copy link
Copy Markdown
Collaborator Author

Deliberately NOT included in the consolidated P1AM safety batch (#4448). Left open — please do not merge as-is.

The other eleven PRs in that batch merged. This one is held back because the second commit (aaff7a7, "comms watchdog, correct trip tier, bumpless setpoints") inverts the interlock in both directions against the config this repo actually ships — it re-creates the exact failure it says it fixes, and simultaneously disables the over-temperature trip.

The first commit (50f7535, harness repair + CI gate) is good and should land on its own.

The blocker, verified against the working tree rather than inferred

backend/defaults.py:43 ships, for all 32 tags:

lolo_limit=0.0, low_limit=5.0, high_limit=95.0, hihi_limit=100.0

and firmware.ino's SyncModbusToDCS copies registers 300..555 into the interlock every scan, so those are the live limits, not a placeholder.

This commit adds:

if (IsLimitEffective(hihi_limits_[i]) && val >= hihi_limits_[i]) {
if (IsLimitEffective(lolo_limits_[i]) && val <= lolo_limits_[i]) {

with IsLimitEffective accepting the closed interval (return limit >= 0.0f && limit <= 100.0f;) — and the PR's own test asserts that closure explicitly:

assert(SafetyInterlock::IsLimitEffective(0.0f));
assert(SafetyInterlock::IsLimitEffective(100.0f));

Two consequences, in opposite directions:

  1. Spurious, unclearable trip. lolo = 0.0 is "effective", and val <= 0.0 is true for a tag sitting at exactly 0.0. The routed AO tags TAG_10/TAG_11 and the routed AI tags TAG_4/TAG_5 all read exactly 0.0 whenever the plant is idle, so the interlock trips on the first scan after the stock config is deployed. Worse, the trip action itself forces the routed output tags to 0.0 (SafetyInterlock.cpp:46-48), so coil-1 / ClearTrip() can never stick — a permanent latch; the plant cannot be started. That is precisely the Default interlock config permanently trips the PLC, and there is no path to reset the trip #4001 symptom this commit's message says it fixes. IsTagRouted removes the unrouted tags from the failure; the routed ones still trip.
  2. No over-temperature protection. hihi = 100.0 sits exactly on SetTag's clamp ceiling, so val >= 100.0 is reachable only at ≥ 1400 °C — above the Type-K span (≈1372 °C ≈ 98.0 % of the 1400 °C full scale in SignalBroker.h). The trip that previously fired at high = 95.0 (≈1330 °C) becomes effectively unreachable.

Root cause: the closed comparisons are degenerate at the clamp boundaries (<= at the floor always trips when idle; < would never trip), and the shipped limit set is meaningless as percent-of-1400 °C. Fixing this needs IsLimitEffective to exclude the clamp boundaries and defaults.py to ship real percent limits. The host-side rejection the message promises is not implemented — #4066 does not add it either.

Other findings, in rough priority

  • The de-energize path has zero executable coverage. Everything that de-energizes lives in firmware.ino's loop() (hw.WriteHeaterRelay(false), hw.WriteInhibit(true), and the new && !comms_lost term). The host harness cannot compile .inoSRCS in the Makefile does not include it — and arduino-cli only compiles. MockHardware gained GetAnalogOutputMaxSeen(), GetHeaterRelayOn() and GetHeaterRelayWriteCount() specifically to assert "was never energized", and no test calls any of them. Extracting the safe-state decision into a testable free function would fix both the coverage gap and the dead getters.
  • One of the two advertised watchdog legs does not exist. The register map and commit message say register 560 is bumped by the backend every scan, covering a wedged-but-connected host. Nothing writes 560 in this PR, and the only live re-arm is ethServer.available() → RecordActivity, which cannot detect a wedged backend. (P1AM SCADA safety batch: de-energize on fault (consolidates 11 PRs, 46 issues) #4448 does merge fix(p1am): de-energize on E-stop and on shutdown; kill dead write seams #4060's write_heartbeat seam and SCADA: fix poll-loop data integrity, cadence and backpressure (#4004 #4008 #4009 #4023 #4024) #4064's per-scan stroke, so once this lands the leg exists — but it did not at the time this PR was written.) Note also that 560 sits inside the configured holding-register window on an unauthenticated Modbus TCP port, so any device on the plant VLAN can hold the watchdog armed.
  • Safe state is re-derived each scan rather than latched. Order in loop() is pids → interlock.Evaluate() → comms_lost block → relay write. When not tripped, Evaluate writes WriteInhibit(false) and re-drives the AOs from the broker tags, and only afterwards does the comms block re-assert Inhibit and zero the AOs — so while the host is dead, every scan deasserts Inhibit and re-writes the analog outputs before zeroing them again: contactor chatter and a brief re-energize pulse per 100 ms. Evaluate comms loss first and latch it.
  • Auto-restart on comms restore with no operator ack. commsLostLatched is used only for logging and README.md states "Control resumes automatically when the link returns." For a gasifier heater that is a restart-on-restoration hazard; the watchdog should latch like the interlock.
  • "Bumpless" setpoints silently disable integral action. SetSetpoint clears integral and derivative history on any change, and firmware.ino:88 calls it every scan whenever the register differs — so any host-driven ramp, or 1-LSB float jitter through the register round-trip, clears the integrator every scan and the loop runs P+D only, never closing the steady-state offset. It cannot mask a trip (trips read the PV) but it can leave the loop permanently short of setpoint with no indication. TestPidResetsIntegralOnSetpointChange only does a single step to 0, so this is uncovered. Reset on a change exceeding a deadband, or only on an explicit host signal.
  • Watchdog timeout placement. 2000 ms (20 nominal scans) is a sane dead-man value, and the rollover handling and fail-safe-on-equality are correct. But expiry is only evaluated inside the now - lastScanTime >= kScanIntervalMs block, so a scan overrun (the blocking flash write on config deploy) delays detection by the overrun, and dt is clamped to 1 s so a 5 s stall integrates as 1 s.
  • CI gate is real but needs hardening before it lands. It runs make test and greps the binary's own banner, with no continue-on-error, no || true, not manual-only, and #error under NDEBUG closes the assert-compiled-away hole — all good. But: arduino-cli core install / lib install are unpinned (the header claims "Pinned toolchain versions"; the versions are only recorded to the job summary), --warnings all surfaces warnings without failing, and most seriously curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh runs on runs-on: [d-sorg-fleet, Linux] from a pull_request trigger. On a public repo with fork PRs enabled that is remote code execution on the runner fleet. Pin the installer to a tag or SHA and gate the compile job to non-fork PRs.

Suggested path

  1. Land 50f7535 (harness repair + CI gate) separately, after pinning the arduino-cli installer and gating the compile job against fork PRs.
  2. Rework aaff7a7: exclude the clamp boundaries from IsLimitEffective, ship meaningful percent limits in defaults.py, latch the comms-lost safe state ahead of Evaluate, and add a host test that asserts the de-energize path using the GetHeaterRelayOn() / GetAnalogOutputMaxSeen() getters this PR already added.

Note that #3995 (firmware never compiled or tested in CI) is therefore not carried by #4448 and stays open — it is this PR's issue.

⚠️ Auto-merge (squash) is currently armed on this PR. If its required checks go green it will merge itself with the trip-band inversion unresolved. It should be disabled until reworked.

@dieterolson
dieterolson disabled auto-merge August 14, 2026 07:25
@dieterolson

Copy link
Copy Markdown
Collaborator Author

Closing — excluded from the P1AM SCADA consolidation (#4448) on safety grounds, not superseded.

Against the shipped defaults.py, the firmware watchdog this PR gates in CI creates a permanent,
unclearable trip
:

  • lolo = 0.0 combined with a val <= lolo comparison trips every idle tag on scan 1.
  • The trip action forces outputs to 0.0, which keeps the tripped condition true, so ClearTrip can
    never stick.

That is precisely the symptom described in #4001, which this PR claims to fix — so as written it
reproduces the defect rather than repairing it. In the same configuration hihi = 100.0 sits on the
clamp ceiling, which makes a genuine over-temperature condition unreachable: the guard cannot fire
when it matters and cannot be cleared when it fires spuriously.

Commit 1 is good and should land on its own — the orphaned firmware test harness genuinely needs
repairing and gating in CI. It is only the watchdog threshold semantics that are unsafe.

Why closed rather than left open: auto-merge (SQUASH) was armed on this PR, and in this repo
disarming does not hold — automation re-arms it. Left open, this would have merged itself. Closing is
the only durable state; the branch is untouched and this can be reopened after re-cutting commit 1
separately and fixing the threshold comparisons against the shipped defaults.

Context: part of the 2026-08-13 consolidation drive. The other 11 P1AM PRs are consolidated in #4448.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P1AM firmware is never compiled or tested in CI, and its host-side test harness is orphaned

1 participant