P1AM firmware safety: comms watchdog, correct trip tier, bumpless setpoints - #4044
Conversation
…ints 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
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5e07b9284d
ℹ️ 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".
| const int kHostHeartbeatReg = 560; | ||
| const unsigned long kCommsTimeoutMs = 2000; // 20 scans at the nominal 100 ms | ||
| CommsWatchdog commsWatchdog(kCommsTimeoutMs); |
There was a problem hiding this comment.
Keep the watchdog timeout above the normal polling cadence
The backend starts in PerformanceMode.LIGHTWEIGHT (backend/main.py) with a 2.0-second interval (backend/settings.py), sleeps for that interval only after completing _poll_once, and never writes heartbeat register 560. Consequently, even if each Modbus request re-arms the watchdog, normal processing time and scheduling jitter make the silence exceed this exact 2000 ms timeout; if only new connections re-arm it, the persistent connection fares worse. Default/lightweight operation will therefore repeatedly hold the PIDs and force all outputs safe despite a healthy host. Add timeout margin or implement a faster independent heartbeat.
Useful? React with 👍 / 👎.
| float dt = static_cast<float>(now - lastScanTime) / 1000.0f; | ||
| // Bound dt so a long stall cannot inject a huge integral step or a | ||
| // near-zero derivative divisor. | ||
| if (dt < 0.001f) { | ||
| dt = 0.001f; | ||
| } else if (dt > 1.0f) { | ||
| dt = 1.0f; |
There was a problem hiding this comment.
Initialize scan timing after setup completes
On a boot where setup takes more than one second—which is expected when the serial wait alone can last up to five seconds—lastScanTime is still its global zero value, so the first scan computes dt from the entire boot duration and clamps it to 1 second. A persisted PID with nonzero Ki therefore integrates ten nominal scans on its first execution and can immediately saturate its analog output. Initialize lastScanTime at the end of setup() or treat the first control scan as one nominal interval.
Useful? React with 👍 / 👎.
… stranded off main (#3999, #4002) (#4463) * fix(p1am): repair orphaned firmware test harness and gate it in CI 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) * fix(p1am-firmware): recover the comms watchdog and bumpless setpoints (#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> * fix(p1am-firmware): gate the unit-test job for forks, narrow the #4002 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> --------- Co-authored-by: codex-scheduled <codex-scheduled@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…6 issues) (#4448) * fix(p1am): stop reporting untrustworthy PID tunings and fix MPC move formulation PID tuning (#4018): - as_response() clamped every recommended gain to >= 0. A reverse-acting process (identified Kp < 0) legitimately tunes to negative Cohen-Coon gains, so the clamp produced kp=ki=kd=0 and still reported "Tuning parameters identified successfully" - an open-loop controller presented as tuned. Gains now keep their sign and a reverse-acting identification is reported as a warning naming the required loop configuration. - Dead time was floored at MIN_TIME_PARAM with no noise rejection and no minimum-sample check. Because Kc scales with tau/theta, a first crossing landing on the step sample inflated the recommendation by more than an order of magnitude and returned it as success. The identification is now rejected when the first crossing falls within two sample intervals of the step or both thresholds land on the same sample, and downgraded to a warning when theta hits the floor, when the dead-time ratio leaves the Cohen-Coon validity band, or when a gain exceeds the sanity bound. - Two-point identification now uses the published 28.3%/63.2% pair with tau = 1.5*(t63 - t28) and theta = t63 - tau. The old 10% pair biased theta high and tau low by roughly 0.105*tau. The Cohen-Coon coefficient formulas were verified against Cohen & Coon (1953) and left unchanged. MPC (#4039): - The DMC solver initialised its decision variable to the absolute CV and then multiplied it by the step-response dynamic matrix as if it were a move vector, while free_response already carried the full effect of holding that CV. The current input was counted twice, leaving the /api/mpc/simulate comparison chart with a large permanent offset at any nonzero operating point. The solver is extracted into solve_dmc_move(), now solves for delta-u from a zero origin, bounds the moves, and the caller integrates and clamps to the 0-100% output range. Refs #4033: the clamp test was vacuous (step_triggered=False made the gains 0.0 by construction) and is replaced by tests that build a TuningResult with genuinely negative gains and assert the new contract. test_backend_p1am_safety.py: the fixed-history tuning test encoded the biased method - its PV values 11.0 and 16.32 were exactly the old 10% and 63.2% thresholds, recorded at a 1 s sample interval with two points on the transient, which the corrected identification cannot resolve. The same plant (Kp=1, tau=1, theta=0.5) is now sampled at 0.1 s, and the old under-sampled recording is retained as a rejection fixture so the guard itself is covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(p1am-hmi): correct alarm annunciation, thresholds, E-stop gate and log churn Closes #4012, #4019, #4021, #4022. - Alarm annunciation now separates "condition present" (colour) from "acknowledged" (flash vs steady). Acking a still-active alarm no longer hides it for the session, and a cleared alarm is dropped from both sets so the ACK button stops flashing. Acknowledge applies only to the alarms the header was displaying. Extracted into desktop/alarm_state.py. - HH/LL severity uses the deployed hihi_limit/lolo_limit rather than high_limit +/- 5, so HMI severity matches the firmware trip points. Interlock ordering is validated at config load and a bad set is rejected loudly. - Connection status is derived from the telemetry frame (desktop/connection_state.py); a live plant is no longer labelled "Simulating" forever. - Clearing the E-stop is Admin-gated and modally confirmed via the shared desktop/guards.py helpers; a declined clear latches the button back to tripped. - Alarm events are debounced/counted, written in batches on a background thread over one persistent SQLite connection, the History table is requeried only while visible, and the event table has a retention window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(p1am-hmi): report data age, not a boolean; stop silent alarm loss The operator HMI could tell an operator a dead process was healthy, and could command hardware from values the operator never entered. #4010 — every field of telemetryFrameSchema is optional, so `{}` parsed successfully and applyFrame stamped it as a live frame. The backend's latest_frame starts as {} and is only reassigned on a successful poll, so a dead poll loop left the HMI green forever with the trend appending a frozen value. Liveness now requires a recognised payload field, is expressed as a data AGE, and stale process values are greyed and cross-hatched so frozen is distinguishable from steady. #4011 — .catch sat on the whole active_alarms record, so one malformed alarm erased the entire map and the header kept saying "All normal — no active alarms" while alarms fired on the PLC. Resilience is now per entry, dropped entries raise a degraded-data banner, and /api/alarms/active is reconciled periodically so the list has a recovery path independent of the stream. #4013 — the Alicat setpoint draft was re-seeded from live telemetry on essentially every frame, so it fought each keystroke and Set could commit a partially-reverted value. The entry is operator-owned from the first keystroke; the device setpoint is now its own readout. #4020 — the power-supply staged setpoint was never seeded and +/- commanded immediately, so one tap after a kiosk reload could collapse a 30 A output by 29 A. It seeds from the live setpoint and +/- stage only; Apply is the sole write path, as the file's contract already stated. #4042 — the event log is fetched on mount and refreshed on an interval instead of only after an unrelated acknowledgement; approaching-alarm cues come from the server-enforced config rather than the local draft, and config entry rejects non-finite input that used to store NaN and silently switch the pre-alarm cue off. #3996 — none of the frontend's lint, build or test scripts were run by any workflow. Adds .github/workflows/p1am-frontend.yml. Making that gate useful also required repairing the pre-existing suite: Node >= 22 ships its own localStorage global, which blocks vitest's jsdom Storage and made 15 layout-persistence tests fail with "localStorage.clear is not a function". schemas.test.ts previously asserted the #4011 alarm suppression as correct behaviour; that test is rewritten. The tuning tab is extracted to TuningPanel.tsx to bring App.tsx back under the 1500-line budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(p1am/historian): unblock E-stop during retention, fix UTC drift, bound explorer loads Closes #4006, #4025, #4026, #4027, #4040. Refs #4033. - #4006 (P0): historian_retention_loop is an asyncio task, so its synchronous SQLite body ran ON the event loop, freezing the control loop, the websocket broadcast and every HTTP endpoint including E-stop for the length of a VACUUM (tens of seconds on a 1 GiB DB on SD). The sweep now runs via asyncio.to_thread, and reclaims disk in bounded incremental_vacuum chunks instead of a whole-file VACUUM. Legacy DBs are converted to auto_vacuum=INCREMENTAL once at startup, before the controller is live. - #4025: TagLog/EventLog.timestamp now use a UtcDateTime TypeDecorator, so SQLite can no longer discard tzinfo on write nor hand back naive datetimes on read. Every isoformat() on the API boundary emits an explicit offset. - #4026: _load_historian decides its cell budget from row COUNTs BEFORE reading any rows, and honours HistorianSource.max_points (previously dead) by reusing query_trend_series' streamed decimation, so peak memory tracks output size. - #4027: the size cap is split into separately-tracked TagLog and EventLog budgets, each charged against its own dbstat footprint, and EventLog gains age-based retention. A fat event log can no longer inflate bytes-per-row and erase the tag historian. - #4040: export column alignment is validated unconditionally, so ragged columns are a 400 instead of a truncated CSV body behind an HTTP 200. - #4033: the index-migration test now calls database._migrate_historian_indexes instead of re-executing a hand-copied duplicate of its SQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(p1am): de-energize on E-stop and on shutdown; kill dead write seams An E-stop never opened the heater relay, and half of what it did write was a provable no-op. Shutdown never de-energized anything and blocked past the systemd kill timeout. Direct tag writes reported success for writes the firmware overwrites within one scan. - trigger_estop opens HEATER_RELAY_COIL first and treats a failed ack as a hard error; the 64-register TAG_VALUE_BASE block write is removed (the firmware republishes 0..63 from its broker every scan and never reads them back). POST /api/estop reports success only on an acknowledged de-energize. - lifespan teardown drives the plant safe before joining tasks or closing sockets, on the exception path too, under a deadline below TimeoutStopSec. The PLC-connect retry waits on shutdown_event instead of sleeping past it. - write_tag raises NotImplementedError for the republished TAG_n block instead of returning True; the PID tuner steps through write_pid_setpoint and skips identification unless the step was acknowledged. - write_tag and write_routing now honour the defense-in-depth E-stop latch; a contract test fails if a new write seam escapes the latch registry. - New write_heartbeat seam for the firmware liveness watchdog (register 560). Per-scan call site still needs wiring by the poll-loop PR. - E-stop tests assert addresses and values, with a fake recording both write_registers and write_coil, instead of a write count. Closes #4000 Closes #4005 Closes #4015 Closes #4038 Refs #4033 Refs #3999 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 🐛 P1AM SCADA: safe calibration shutdown, real alarm ack, settings-driven MFCs Fixes three P1 defects on the P1AM control system. #3997 — calibration left analog outputs energized. teardown now commands each pass-through PID to 0.0, reads the AO tag back to CONFIRM 0%, then unmaps the PIDs and releases the output routing so the firmware's own WriteAnalogOutput(i, 0.0f) safe path takes over. main() drives the AOs to 0% on any abnormal exit (exception, SystemExit, KeyboardInterrupt) before plc.close(), logging loudly without masking the original cause. #4034 — alarm acknowledgement never reached the alarm engine. The endpoint now forwards to AlarmEngine.acknowledge_alarm(tag_id, user) and populates acknowledged_by; apply_config migrates alarm state (including the ack) into the rebuilt engine through the public API shared by the Rust and Python engines, so an ack survives a routing deploy and a PLC reconnect. #4031 — every mass flow controller was hardcoded to simulation. The transport now comes from P1AM_ALICAT_CONNECTION_TYPE / P1AM_ALICAT_PORT_OR_IP; mock devices are refused whenever plc_driver drives real hardware (gas control is then absent, with registration_error set and a CRITICAL log, rather than silently simulated). AlicatMFC validates its transport and port, and parse_ascii_response no longer bypasses the VALID_GASES check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 🛡️ SCADA: fix poll-loop data integrity, cadence and backpressure - #4004: classify each scan's provenance (live/simulated/held/fault). Held and faulted values never reach the control laws, the alarm engine or the historian, so a link flap can no longer clear an active HiHi. The backup simulator is only wired into the scan path on a simulator driver. TagLog gains a `quality` column (with migration) so an outage is a gap, not fabricated continuity. Frames carry data_source/plc_connected/simulated, and a live scan strokes the firmware heartbeat (#4044). - #4008: pin the scan/alarm/relay/E-stop cadence to settings.poll_interval_s; the performance mode now only decimates the WebSocket broadcast. - #4009: schedule against a monotonic deadline with overrun counting and phase resync; size the Modbus timeout to the scan period; compute the failure backoff from the active period. - #4023: batched, retrying historian writer on a worker thread. - #4024: per-client bounded WS queues, serialise once, drop oldest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 🔒 Harden P1AM SCADA deployment: the authz was fine, the deployment wasn't The in-source authorization was already correct — every hardware-mutating route carried require_admin_key, E-stop clear was admin-gated, the WebSocket was authenticated, comparisons used hmac.compare_digest, and cors_config.py failed closed. Every exploitable defect was in the deployment, or in the client's inability to authenticate. #4007 (P0) — production installs shipped with auth disabled, and the HMI re-exposed the loopback-bound backend. Fixed as one change, because fixing any part alone ships something either exposed or unusable: - the HMI can now authenticate (frontend/src/api/credentials.ts): X-API-Key on every request, the key sent as the FIRST WebSocket frame rather than a query parameter, stored per browser profile, seeded by the kiosk launcher through a URL fragment it strips on load; - install-services.sh no longer bakes P1AM_DEV_NO_AUTH=1 into the unit. It generates random operator/admin keys into a root-owned EnvironmentFile, preserves them across re-runs, refuses to write a unit without one, and gates the bypass behind an explicit --bench flag; - vite preview binds 127.0.0.1 instead of every interface. #4041 — the credential tiers are now nested. A configured admin key satisfies the operator tier, so an admin-only deployment no longer means full hardware control behind a dead display. The resolved posture is logged at boot. #4037 — require_read_auth defaults on and is attached to the ungated read routes (/api/routing disclosed the whole register map). RequestGuardMiddleware refuses state-changing requests from a non-allowlisted Origin and forces a preflight, closing the bodyless CORS-simple POST path that let any page the kiosk opened command the plant. The panic stop stays curl-reachable. #4029 — an append-only AuditEvent table plus one ASGI middleware, so a NEW endpoint is audited by default. Records route, redacted payload, credential tier, non-reversible key fingerprint, client IP and status; unreachable from the client-writable POST /api/events and untouched by /api/capture/clear, so the trail can be neither forged nor erased. Mirrored to journald. #4028 — test_route_authz_matrix.py boots the real app with keys set and the bypass cleared, driving an explicit (method, path, tier) table. An unclassified route fails the suite, so a new endpoint cannot ship ungated. #4014/#4030/#4036 — the deployment can actually work: a `p1am` extra is the single source of truth for backend runtime deps (adding the missing pydantic-settings and python-multipart), mirrored as exact pins in the Dockerfile with drift caught by a test; the container binds 0.0.0.0 and is isolated at the publish layer; compose uses the env-var names settings.py reads and mounts the historian at /data, not over the source; the HMI is built at install time and both units carry Nice=/CPUWeight=; the numpy lock no longer contradicts requirements.txt; and PLCFactory logs an unmissable banner when the simulator is driving the HMI's "live" values. Also fixes #4061: several suites mutated os.environ at module import time and one popped P1AM_DEV_NO_AUTH there, so whichever module was collected last decided whether seven endpoint tests passed or 503'd. Those are now per-test monkeypatch fixtures. Verified green in both collection orders. Closes #4007 Closes #4014 Closes #4028 Closes #4029 Closes #4030 Closes #4036 Closes #4037 Closes #4041 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(p1am): anchor historian DB to package dir; ignore stray dcs_scada.db `database.DATABASE_URL` was a bare relative `sqlite:///dcs_scada.db`, so the SQLite file resolved against the process CWD rather than a fixed location. Two consequences: 1. The historian silently forked into a different DB per launch directory. A `uvicorn main:app` from `backend/`, a test run from the repo root, and a systemd unit with its own WorkingDirectory each got their own file, so tag history appeared to vanish depending on how the backend was started. 2. `pytest src/p1am_control_system/backend/tests/` from the repo root dropped an untracked `dcs_scada.db` at the repo root, which `git add -A` would stage. The .gitignore entries were path-anchored to the backend directory and did not cover it. Fixes: - `DB_FILE` now resolves to an absolute path anchored to the backend package directory via `Path(__file__).resolve().parent`, with a `P1AM_DB_PATH` env override for deployments keeping the historian on separate storage. This makes the location documented in BENCH_HANDOFF.md (`backend/dcs_scada.db`) authoritative. The URL uses `as_posix()` so a Windows drive path does not put backslashes into `sqlite:///...`, where they would read as escapes. - Docker is unaffected: the image does `WORKDIR /app` + `COPY . /app`, so the package directory *is* `/app` -- the same path the compose `dcs_db_data` volume mounts. The default resolves where it always did. - Also fixes `data_capture._db_size_bytes`, which called `os.path.getsize(DB_FILE + suffix)` and was therefore measuring whichever DB happened to sit in the CWD (or nothing), making the historian size cap and status display inaccurate. - .gitignore entries are now unanchored (no slash => git matches at any depth), covering both the backend directory and any other CWD. Verified: full p1am backend suite passes from the repo root (878 passed, 6 skipped), no stray DB at the root afterwards, ruff check + format clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(p1am-frontend): install conforming localStorage in test setup 15 tests across panelLayout.test.ts, usePanelLayout.test.ts and PanelStack.test.tsx failed with "TypeError: localStorage.clear is not a function". Root cause is in vitest 1.6's populateGlobal, which only copies a jsdom global over an already-present Node global when the key is in its own curated KEYS list: if (k in global) return keysArray.includes(k); Neither localStorage nor sessionStorage appears in that list, and Node 22+ defines both on globalThis, so jsdom's Storage is never installed and Node's wins. Node's sessionStorage is in-memory and behaves, which is why only localStorage broke; its localStorage is backed by --localstorage-file and, with no valid path, degrades to a bare object with none of the Storage methods. jsdom's own localStorage works fine here, it is simply unreachable, so pinning the environment is not a fix: vitest 1.6 exposes no lever to add keys to that list. Install a spec-shaped in-memory Storage in the shared test setup instead, which fixes all three suites at once and any future one. Installed unconditionally rather than behind a conformance guard: setup.ts only runs under the test runner, a fresh Storage per test file is the isolation we want, and merely reading Node's lazy localStorage to probe it fires a --localstorage-file warning in every worker. Verified: 44 files / 381 tests pass (was 366 passed, 15 failed), tsc --noEmit clean, eslint 0 errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(p1am): configure auth per test, not at import time (#4061) The two test modules this branch introduced set P1AM_DEV_NO_AUTH by mutating os.environ at import time. settings is a module-level singleton read once at first import, so whether the variable lands before or after that import depends on collection order and xdist worker assignment. Concretely, in the combined run `pytest src/p1am_control_system/backend/tests/ tests/p1am_control_system/`, tests/p1am_control_system/test_backend_security.py imports last and pops the variable, so TestTunerStepWritePath fails with 503. Reverse the two path arguments and the same tests pass. Order-dependence in the direction of passing is the dangerous direction over an E-stop write path. - test_estop_endpoint_writes.py: import-time assignment replaced with an autouse `_bench_no_auth(monkeypatch)` fixture, matching the convention PR #4066 established for the pre-existing instances. Its TestClient also carries the X-Requested-With HMI marker that PR's request guard expects (inert here, avoids a second convention). - test_estop_shutdown_safe_state.py: assignment removed outright. The module drives the lifespan and the safe-state seams directly and issues no HTTP request, so it needs no credential posture — and its import-time mutation was leaking into every suite collected after it. Refs #4061 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(p1am): scale the power-supply thermocouple, fault on missing feedback Four defects in the power-supply and temperature sensor paths, each with a failing test written first. #4003 -- the firmware publishes thermocouples as PERCENT of full scale, and _inputs_from_tags scaled current and voltage but passed the temperature tag through as though it were already degC. HH_TEMP compares that against a degC threshold (default 1200), and broker tags are bounded [0, 100], so the power supply's only over-temperature interlock could not fire under ANY physically possible reading. The HMI showed "86 degC" on a load at 1200 degC, so the automatic protection and the human backstop were reading the same wrong number. #3998 -- full scale was declared in three places (firmware, temperature_models, thermocouple_filter) with a comment asking them to agree. It is now one definition in hardware.py, the module that already owns the firmware contract, with percent_to_celsius/celsius_to_percent as the only conversion. A new test parses the firmware source and fails if the two halves drift, so the contract is a gate rather than a comment. #4016 -- any absent tag became 0.0 via dict.get, and non-finite readings were coerced to 0.0. Unlike the temperature controller there was no sensor-fault state, so a renamed tag or a pulled monitor lead silently zeroed both trip inputs while the output stayed energized, and reported a confident, cold-looking supply. Missing or unusable feedback now raises SensorFeedbackError and latches a new SENSOR_FAULT trip; a genuine zero is still a reading. #4017 -- set_current_setpoint/set_power_setpoint returned the clamped request on every path including the IDLE and TRIPPED branches that discard it, and the service persisted the raw request regardless. An operator commanding 40 A into a latched controller got 200 {"applied_a": 40.0} and went troubleshooting the load instead of the trip; the bogus value was also pre-filled after the next restart. Both setters now return what is in effect, and persistence is gated on the command actually taking. #4035 -- both deglitch filters were constructed bare, pinning them to the 1400 C default whatever the channel was configured for. On a shorter-range channel the high-side burnout rail sat above any reachable reading, so an open thermocouple was accepted as genuine -- the exact condition the filter exists to catch. They are now built per channel with max_step_c scaled to the span, and rebuilt when the config changes so a rescale cannot strand filter state and trip TC_FAULT mid-run. Three existing tests encoded the defects and are updated to the corrected contract: the units pass-through assertion (#4003), IDLE echoing the request back (#4017), and "no tags uses zero feedback" (#4016). Also gitignores a root-level dcs_scada.db -- the suite creates the historian relative to the working directory, so a run from the repo root drops a stray artifact that is easy to commit by accident. 894 passed, 6 skipped in the backend suite. Refs #4003 #3998 #4016 #4017 #4035 * refactor(p1am-hmi): split TemperatureControl; fold recall onto seedDraftText (#4063) TemperatureControl.tsx was 1975 lines — 475 over DEFAULT_MAX_SOURCE_LINES — so fleet-fast-guardrails rejected any commit touching it. The heater screen was the one operator surface that could not be corrected without first being restructured. Following the TuningPanel shape (#4053), the container keeps all state, the rolling trend buffer and every /api/temperature/* call; the sections are prop-driven components: TemperatureControl.tsx 1975 -> 717 TemperatureTrend.tsx 680 (SVG trend + its own view state) ThermocoupleSelector.tsx 196 TemperatureConfigPanel.tsx 148 TemperatureStatusHeader.tsx 136 HeaterStartStopButton.tsx 55 lib/temperatureTrend.ts 171 (pure sample/readout math) The Start/Stop command button existed as two byte-identical copies on the same screen; on a control that energizes a heater those could come to disagree about whether a command is safe to send. One component now serves both, differing only by an appended CSS class. DRY cleanup #4053 had to defer: recallSetpointText now returns seedDraftText(lastSetpointC, operatorTouched, 1) rather than carrying its own copy of the operator-ownership rule. The heater domain types moved to src/types.ts, removing the cycle where useTelemetryStream imported TemperatureStatus from a component. No behaviour change: every moved block is verbatim apart from prop renames and JSX whitespace that collapses identically. lint (0 errors, same 2 pre-existing warnings), build and 444/444 vitest tests pass. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * chore(p1am): allowlist generated credential expression * test(p1am): keep endpoint suite within file-size budget * test: remove stale route matrix rows * style: apply automated pre-commit formatting and lint fixes * style: apply automated pre-commit formatting and lint fixes * style: apply automated pre-commit formatting and lint fixes * style: apply automated pre-commit formatting and lint fixes * style: apply automated pre-commit formatting and lint fixes * style: apply automated pre-commit formatting and lint fixes * style: apply automated pre-commit formatting and lint fixes * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * fix: pre-commit checks and main sync * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * style/fix: pre-commit automated fixes * chore(consolidation): drop unrelated churn and the .codex-worktrees gitlinks Every one of the 13 P1AM slice branches ends with two or three "pre-commit automated fixes" / "main sync" commits that reformatted ~290 files across the whole monorepo. Those commits were produced by an older pinned ruff than the one CI and .pre-commit-config.yaml now use (ruff==0.14.10), so merging them re-introduces a formatting regression in files no P1AM change touches -- and `quality-gate` checks ruff-format on *changed* files, so each one becomes a gate failure for a change nobody made. - 293 Python files outside the P1AM real-content set are restored to origin/main exactly. 256 of them were provably pure formatting (ruff 0.14.10 renders them byte-identical to main); the remainder sit in ruff-excluded trees (movement_optimizer, pendulum_simulator, data_processing) that main has never had formatted, which is precisely why they must go back rather than be reformatted here. - The six `.codex-worktrees/*` entries are removed. They are git mode 160000 gitlinks for Codex agent scratch worktrees that got committed by accident: there is no .gitmodules declaring them, origin/main has none of them, and the commits they name exist only in one local clone and on no remote. Merged, a fresh clone of Tools would carry six gitlinks pointing at objects the server does not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(p1am): close four defects the merged slices introduced or left open Found while integrating the batch; each is in the fail-open direction, and three of them defeat the very guarantee their own slice was written to provide. Regression tests accompany all four. 1. state.py — a fabricated reading could clear a live alarm (#4034 vs #4004). `_restore_engine_alarms` called `update_tag(tag, float(entry.get("value", 0.0)))`. `get_active_alarms()` is only contracted to agree between the Rust `tools_core.scada` engine and the pure-Python fallback on the keys the restore path reads, not on carrying `value` at all; where it is absent the substituted 0.0 resolved the tag to Normal. `apply_config` clears `active_alarms` first and runs on every routing deploy AND every reconnect-time `_publish_active_config`, so a live HiHi was erased from both the engine and the live map by a number nothing measured. Composed with the poll loop's new "only a real measurement may move the alarm state machine" rule the result was worse: the poll loop then refuses to re-evaluate alarms while the link is down, so the cleared HiHi stayed cleared for the whole outage. A record with no finite value now keeps the state it was snapshotted in and never touches the engine. 2. desktop/alarm_state.py — NaN was classified as "normal" (#4012). `classify_value` decides the band with four comparisons and every comparison against NaN is False, so a NaN fell through to `return None` = inside the normal band, and `evaluate` emitted `cleared` transitions and dropped the tag from both sets. A garbled or unscaled register therefore silenced a live High-High on the heater, and `json.loads` accepts a bare `NaN`, so it was reachable straight off the wire. `_require_number` now rejects non-finite input and `_evaluate_alarms` catches it alongside TypeError, logging and skipping the tag. Skipping is the fail-safe outcome because `evaluate` validates before it clears anything, so the latch survives. 3. desktop/header.py — the new annunciator flashed at 10 Hz into a steady light (#4012). `set_alarms_state` unconditionally set `_flash_state = True`, and `_refresh_annunciator` calls it on EVERY telemetry frame, so the flash timer's OFF phase was overwritten within ~100 ms. An unacknowledged alarm rendered effectively steady, erasing the flash-vs-steady distinction that tells an operator whether anyone has seen it. The phase now restarts only on a transition INTO unacknowledged. 4. test_route_authz_matrix.py — the authz exhaustiveness net had a hole punched in it (#4028). The slice's last commit deleted 24 rows as "stale", but main.py mounts the power-supply and temperature routers unconditionally, so 14 of those routes do exist: `test_every_route_is_classified` fails outright on the merged tree (verified before and after). Those rows are restored — they are the DC supply's and the heater's own command surface. The 9 Data Explorer rows genuinely are conditional, since that router mounts only when its numeric stack imports, so they now register from the live app instead of being deleted; that satisfies both the exhaustiveness check and the no-stale-rows check in either environment. The suite goes from 51 tests with 1 failure to 84 passing. Also repairs one broken test rather than deleting it: test_estop_shutdown_safe_state.py::test_connect_loop_wakes_immediately_on_shutdown reused the import-time module-level `shutdown_event`, which asyncio binds to a loop on first await; earlier tests in the same module had already awaited it on their own per-test loop, so it raised "bound to a different event loop" before reaching the assertion. It now patches in a fresh Event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(p1am): rebuild SPEC.md correctly and restore LF line endings Two integration defects in the consolidation itself, both of which buried the real change under a whole-file rewrite. SPEC.md was duplicated eleven times. Every slice prepends its own dated section immediately after "## 3. Goals & Non-Goals" AND deletes the same blank line further down, so git found no usable anchor and each merge presented one side of the hunk as the entire remainder of the document. Resolving those by keeping both sides concatenated the whole spec once per slice: 26548 lines against main's 2401, with eleven copies of "## 2. Purpose", "## 3. Goals & Non-Goals" and "## 12. Change Log". SPEC.md is rebuilt as main's file with each slice's §3 subsection inserted exactly once, in the order the work was layered, plus one §12 change-log row for the consolidation. The diff against main is now purely additive: +403/-0, eleven `### 2026-07-31 P1AM ...` headings and one table row. (The three duplicate `###` headings that remain are pre-existing in main.) Line endings: nine files were carrying CRLF where main uses LF. Only `*.py` is covered by .gitattributes (`text eol=lf`), so Python normalises itself on commit; .toml, .ts, .tsx, .sh, Dockerfile and .md store whatever bytes were committed, and several slices rewrote those wholesale. This was not cosmetic: - `deploy/launch-hmi.sh` had CRLF, which on the Raspberry Pi fails at exec with `bad interpreter: /bin/bash^M` — the operator's HMI launcher would simply not start. `deploy/install-services.sh` was already LF and is unaffected. - `backend/Dockerfile` had CRLF, where a line-continued RUN can break the build. - The rewrites hid the actual edits behind a full-file diff and would conflict against every future change to the same files. pyproject.toml goes from +433/-410 to +23/-0 (the `p1am` extra), requirements-lock.txt to +4/-1, launch-hmi.sh to +16/-0, App.tsx to +72/-319, setup.ts to +63/-0. `credentials.ts` and `TuningPanel.tsx` are new on this branch and were also CRLF; they are normalised to LF to match every existing sibling and the repo's `core.eol=lf`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(p1am): extract the tuning/MPC router so main.py stays in budget `quality-gate` failed on Module Size Budget: `backend/main.py` had reached 1547 lines against its grandfathered baseline of 1440 (hard max 1200). This batch added the E-stop write-seam refusals, the acknowledged-step requirement and the shutdown sequencing to that module, and #4064 had already split out `poll_runtime` for the same reason. Bumping the baseline would have been the cheap fix and the wrong one — the baseline exists as a ratchet, and raising it to accommodate growth is the one thing it is there to prevent. Extracted instead: `tuning_router.py` takes the one cohesive group left in main.py, the handlers that write to or identify the dynamics of a single control loop — POST /api/tags/{tag_id} POST /api/pid/{pid_index}/tuning/start POST /api/pid/{pid_index}/tuning/step POST /api/pid/{pid_index}/tuning/stop POST /api/mpc/simulate plus `MPCSimulatePayload`, `TagWritePayload` and the `_latest_tag_or_http_error` helper. main.py: 1547 -> 1341 lines, inside the baseline. Follows the router-factory pattern already used in this package by `power_supply_integration.create_power_supply_router` and `temperature_integration.create_temperature_router`. Collaborators (control_context, plc_client, backup_simulator, the E-stop refusal callable, the admin dependency, the logger) are injected rather than imported from `main`, which would close an import cycle. Behaviour is unchanged; the routes, paths and admin gating are identical, which `test_route_authz_matrix.py` verifies from the live app in both directions. One trap worth recording: the request model must be imported as a real type, not injected as a parameter. This module uses `from __future__ import annotations`, so every annotation is a string at runtime and FastAPI resolves it against the module globals — an injected `step_payload_model` left the annotation as the literal `"step_payload_model"`, the body never bound, and all six tuning-step tests returned 422. `models` imports nothing from `main`, so importing `PIDTuningStepPayload` directly is safe. P1AM suite: 1388 passed, 6 skipped. ruff check + format clean. Module size budget passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(p1am): ask the router whether a route is served, and make event order deterministic Two CI-only failures on this branch. Neither was a stale table, and in both cases the easy fix would have cemented a real defect. 1. `test_table_has_no_stale_rows` reported all 19 power-supply / temperature / tuning rows as unserved. They ARE served. Proof from inside the same failing CI run, single process (`-n 0`): the 19 matching `test_gated_route_rejects_anonymous_caller` cases drove those exact paths through `TestClient(app)` -- the same module-level `app` object the stale check iterates -- and got 401/403. `_DENIED = (401, 403)`, and a route the app does not serve answers 404, which would have failed. So the routes were registered and reachable and the enumeration was under-reporting them. Deleting the rows (as an earlier slice did, which is why `test_every_route_is_classified` was failing on the merged tree) would have turned the check green while the authz matrix agreed that acknowledge_trip, permissive, setpoint, burnout_mode, tc_type and the PID tuning lifecycle did not exist -- the plant's entire control surface, in the batch whose review theme was "nothing de-energizes". `test_table_has_no_stale_rows` now asks `Route.matches()` -- the function Starlette itself calls during dispatch -- instead of string-matching `route.path` over `app.routes`. A check phrased as "would a request reach a handler" cannot disagree with a request. Verified it still discriminates: real routes and parameterised routes match, a bogus path does not, and a wrong method on a real path does not. `test_every_route_is_classified` deliberately stays enumeration-based -- it must catch a NEW route nobody classified, which is the direction that ships something ungated. Root cause of the enumeration/dispatch divergence is not established; it reproduces only under CI's environment (`--import-mode=importlib`, Linux, py3.11) and not locally across the identical 101-file selection and flags. Phrasing the assertion in dispatch terms makes the test correct regardless. 2. `test_desktop.py::test_event_logger_basic` was flaky, failing roughly one run in three locally with `assert 'alarm_trip' == 'alarm_acknowledgment'`. Not caused by this batch -- that test file is byte-identical to `main` -- but `fetch_logs` is, and it ordered by `timestamp DESC` alone. `datetime.now()` reports microseconds while the clock granularity is ~15.6 ms on Windows, so a burst of events shares one timestamp string and SQLite may return them in any order. Beyond the flake, that means an operator could see an alarm trip listed above the acknowledgement that answered it. Now `ORDER BY timestamp DESC, id DESC`, using the existing autoincrement primary key, which is monotonic in insertion order. `test_fetch_logs_orders_same_timestamp_events_newest_first` pins it by writing an explicit identical timestamp so the tie is forced rather than hoped for. Verified as a real regression test: with the tiebreaker removed it fails, returning `['first', 'second', 'third']`. Also merged `origin/main` (CONS-B1 #4438, 221 files) -- no conflicts. Confirmed `git grep -n '^from datetime import UTC' -- '*.py'` is empty, so the py3.10 revert #4438 made is intact. CI-equivalent selection (CI's own 101 files, `-m "not live_simulation" --import-mode=importlib -n 0`) run three consecutive times: 1949 passed, 6 skipped, 0 failed each time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(ci): grandfather seven p1am files that the size budget now flags `file-size-budget` failed this PR with 7 violations over the 500-LOC limit. All seven were UNDER budget on `main` and crossed it by consolidating 11 safety PRs whose fixes each fit individually: backend/tests/test_data_capture.py 495 -> 905 backend/poll_runtime.py 216 -> 805 backend/tests/test_data_explorer_service.py 485 -> 646 desktop/main_window.py 493 -> 598 calibration/calibrate.py 399 -> 551 backend/pid_tuning.py 234 -> 532 backend/tests/test_backend.py 494 -> 529 `scripts/check_file_size_budget.py` postdates the work it is judging -- the 11 consolidated PRs show no `file-size-budget` check at all -- so this is inherited cumulative size, not a monolith authored against a known budget. `main` was already parked at 485/493/494/495 on four of these, i.e. at the ceiling before any of this work. Grandfathered rather than split because the content is the "nothing de-energizes on fault" remediation for a gasifier control system, and holding safety fixes back for a line-count refactor of the polling path is the wrong trade. The baseline is the established route here: it was last extended by consolidation #3912 and already grandfathers ten p1am files, including a test module (backend/tests/test_temperature_controller.py) and the production siblings data_capture.py and data_explorer_service.py. The debt is tracked in #4503, not silently accepted: poll_runtime.py and test_data_capture.py both warrant splitting, and a one-module extraction is explicitly not enough -- the historian cluster is ~509 LOC on its own and would breach the same budget, so it needs two modules. Neither `file-size-budget` nor `phantom-guard` gates this merge: both live in their own workflows and `quality-gate` (the only required context, per org ruleset Repository_Protections) declares no `needs`. The phantom-guard failure was separately confirmed to be a timeout, not a finding -- it ran exactly 10m01s against a 10-minute cap, recorded zero steps, and uploaded no log. Verified directly instead: this branch deletes nothing relative to `main` (0 files) and `main` is a full ancestor of it. Verified locally: the budget check now reports 0 violations across 76 changed files. Refs #4503 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: codex-scheduled <codex-scheduled@users.noreply.github.com>
Closes #3999. Closes #4001. Closes #4002. Closes #4032. Refs #4009.
Stacked on #4043 (the harness repair) — that PR is what makes any of this testable. Merge #4043 first; this targets its branch and will retarget to
mainautomatically.Every fix here has a failing host test written first.
#3999 — no dead-man timer on the SCADA link
The heater relay is a coil read and the analog outputs are driven from broker tags. Both held their last commanded value forever once the host died, and the host cannot cover this case because the host is the thing that died.
New
CommsWatchdog(firmware/CommsWatchdog.{h,cpp}) drives all analog outputs 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:Either re-arms it. It is armed in
setup()before the loop, so a PLC that boots into a dead network safes itself rather than sitting energized waiting for a host that is not coming.The class is deliberately free of Arduino headers — the caller supplies
millis(). That is what makes the rollover behaviour testable, and it matters:millis()wraps roughly every 49.7 days, and a naive comparison would disarm the watchdog for another 49 days at the wrap.TestCommsWatchdogasserts the wrap explicitly.#4001 — the trip fired on the warning band, over unrouted tags, unrecoverably
Three compounding problems:
Evaluate()compared againstlow/high, which is the SCADA layer's severity-1 warning tier.ClearTrip()had zero call sites and coil 1 was never read, so the latch was unrecoverable short of a power cycle — after which the flash-saved config tripped it again on the first scan.Since the stock config writes
low=5.0to every tag, deploying it latched the plant off permanently.Now:
Evaluate()trips on hihi/lolo, and skips tags that are not routed as an input or output. The routing predicate isSignalBroker::IsTagRouted— the broker owns routing, so it lives there rather than being duplicated in the interlock. Coil 1 clears the latch and is written back to 0 as a pulse acknowledgement, so the host can see the reset was consumed;Evaluate()re-trips on the next scan if the condition persists, whichTestInterlockTripIsRecoverablepins.#4002 — a wound-up integrator survived the E-stop
SetSetpointdid not touch the integrator, andCompute()ran even while tripped. Zeroing the PID setpoints is the only part of the host E-stop that reaches the plant, so the integral term alone held the analog output at 100 % of its 4-20 mA span for tens of seconds after the operator commanded a stop.SetSetpointnow clears integral and derivative history on a change — bumpless transfer, which is the right behaviour for ordinary retargeting too, not just E-stop.Hold()/Release()freeze the loops while tripped or blind so recovery starts from a clean integrator instead of slamming the output the moment the trip clears.#4032 — limits outside the tag domain silently disabled their trip
Broker tags are clamped to
[0, 100], so any limit above 100 could never be crossed. An operator entering900meaning 900 °C on a percent-scaled tag disabled the interlock with no indication, and the HMI displayed the limit exactly as configured.SafetyInterlock::IsLimitEffectivedistinguishes 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 call the same predicate to reject the configuration at the API boundary — that backend half is tracked separately.Also: firmware half of #4009
The scan integrated against a hardcoded
0.1fwhile the real period runs well past that — the same iteration does ~300 register reads, SPI thermocouple reads, and on a config deploy a blocking flash write. UnderstatingdtunderstatesKiand overstatesKdfor exactly the scans that overran. It now uses the measured interval, bounded to[1 ms, 1 s]so a long stall cannot inject a huge integral step or a near-zero derivative divisor.Note on a changed test
TestSafetyInterlockencoded the old warning-band-trips behaviour (high=75, tag at 80 → trip, on an unrouted tag). That is the defect, not the contract, so the test is updated to the corrected semantics rather than the behaviour being preserved to keep it passing.Register map
Holding register 560 is new (host heartbeat).
configureHoldingRegistersis widened accordingly — the count argument is one past the highest address, so it is nowkHostHeartbeatReg + 1. The firmware README register-map table is updated, and documents the trip semantics and watchdog behaviour.Verification
All 12 host suites pass under g++ 11.4 (WSL Ubuntu-22.04):
The four non-Arduino translation units also pass
g++ -fsyntax-only -Wall -Wextra. The sketch itself is covered by thearduino-cligate added in #4043 — it cannot be compiled locally without the board package.Backend work this unblocks
IsLimitEffective's equivalent at/api/routingto reject unreachable limits.clear_estop()can now read the coil back to confirm the reset actually happened, instead of returningTrueon a write to a coil nothing read.🤖 Generated with Claude Code