P1AM SCADA safety batch: de-energize on fault (consolidates 11 PRs, 46 issues) - #4448
P1AM SCADA safety batch: de-energize on fault (consolidates 11 PRs, 46 issues)#4448dieterolson wants to merge 87 commits into
Conversation
…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>
…d 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>
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>
…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>
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>
…ven 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>
- #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>
…sn'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>
`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>
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>
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>
…back 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
…aftText (#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>
…consolidated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md
…nsolidated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/modbus_client.py
…solidated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/modbus_client.py
…idated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/main.py # src/p1am_control_system/backend/modbus_client.py
…idated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/modbus_client.py # src/p1am_control_system/backend/models.py # src/p1am_control_system/backend/tests/test_database.py
…consolidated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/modbus_client.py # src/p1am_control_system/backend/power_supply_integration.py
…ated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/modbus_client.py # src/p1am_control_system/frontend/src/hooks/useTelemetryStream.test.ts # src/p1am_control_system/frontend/src/hooks/useTelemetryStream.ts
…solidated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/modbus_client.py
…nto consolidated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/main.py # src/p1am_control_system/backend/modbus_client.py # src/p1am_control_system/backend/settings.py # src/p1am_control_system/backend/tests/test_settings.py
…olyfill' into consolidated/p1am-scada-safety-2026-08-13 # Conflicts: # SPEC.md # src/p1am_control_system/backend/modbus_client.py # src/p1am_control_system/frontend/src/test/setup.ts
… consolidated/p1am-scada-safety-2026-08-13 # Conflicts: # .gitignore # SPEC.md # src/p1am_control_system/backend/modbus_client.py # src/p1am_control_system/backend/tests/test_database.py
…itlinks 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>
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>
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>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 441b196797
ℹ️ 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".
| override = os.environ.get("P1AM_DB_PATH", "").strip() | ||
| if override: | ||
| return str(Path(override).expanduser().resolve()) | ||
| return str(Path(__file__).resolve().parent / DB_FILENAME) |
There was a problem hiding this comment.
Point the container historian at the mounted volume
In the Docker deployment, this fallback resolves to /app/dcs_scada.db because the backend is copied to /app, but docker-compose.yml now mounts dcs_db_data at /data and does not set P1AM_DB_PATH. Consequently, the historian is written to the container layer instead of the named volume, and existing history under /data/dcs_scada.db appears missing after this upgrade while new history is lost on container recreation. Set the container override to a path under /data or make the fallback deployment-aware.
AGENTS.md reference: AGENTS.md:L147-L151
Useful? React with 👍 / 👎.
| try: | ||
| with session_factory() as session: | ||
| session.add(event) | ||
| session.commit() |
There was a problem hiding this comment.
Move audit persistence off the control event loop
Every audited POST/PUT/PATCH/DELETE reaches this synchronous SQLite commit from AuditMiddleware.__call__ on the main ASGI event loop. Under SQLite contention or slow SD-card I/O, the configured 5-second busy timeout can therefore stall PLC scans, heartbeat writes, WebSocket updates, and subsequent E-stop requests; rejected requests are audited too, so an unauthenticated client can repeatedly trigger this path. Queue or dispatch audit writes to a worker instead of committing inline.
AGENTS.md reference: AGENTS.md:L230-L238
Useful? React with 👍 / 👎.
| if [ -r "$ENV_FILE" ]; then | ||
| api_key="$(sed -n 's/^P1AM_API_KEY=//p' "$ENV_FILE" | head -n1)" | ||
| if [ -n "${api_key:-}" ]; then | ||
| HMI_URL="${HMI_URL}/#apikey=${api_key}" |
There was a problem hiding this comment.
Keep the API key out of the browser command line
When the kiosk launcher finds a credential, embedding it in HMI_URL causes the later Chromium/Firefox exec to retain the operator API key in the browser's process arguments for its entire lifetime. Stripping the fragment from the address bar does not remove it from /proc/<pid>/cmdline, process listings, diagnostics, or crash collection, allowing another local account or process to recover and replay the credential. Seed the browser through a channel that does not expose the secret in argv.
AGENTS.md reference: AGENTS.md:L15-L20
Useful? React with 👍 / 👎.
| # PLC drivers that are themselves simulated. Only against one of these may the | ||
| # gas subsystem be simulated too; pairing a real PLC with mock MFCs lets an | ||
| # operator "establish" a purge that does not exist (issue #4031). | ||
| SIMULATED_PLC_DRIVERS = frozenset({"simulator", "simulated"}) |
There was a problem hiding this comment.
Classify the neural PLC as simulated for MFC setup
With P1AM_PLC_DRIVER=neural, PLCFactory constructs NeuralSimulatorClient and settings.is_simulated_driver() returns true, but this second classifier omits neural. The default mock Alicat configuration is consequently rejected by create_default_manager, leaving /api/alicats empty whenever the supported neural simulator is selected. Reuse the settings classifier or include every simulator driver in one shared predicate.
AGENTS.md reference: AGENTS.md:L122-L127
Useful? React with 👍 / 👎.
`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>
…-2026-08-13 `main` moved under this branch when CONS-A1 landed, and the PR went `mergeable_state=dirty`. With `strict_required_status_checks_policy` true and `allow_update_branch` false, auto-merge cannot land a behind-or-conflicting branch — it would have sat silently forever. One conflict: SPEC.md. Both sides insert into the §12 change-log table directly under its header. Resolved by rebuilding deterministically rather than by hand: new main's SPEC.md, plus this batch's eleven §3 P1AM subsections inserted once each, plus its single §12 row. CONS-A1's three rows are preserved because they are already in new main. Verified: the diff against `origin/main` is +403/-0 — purely additive — with one `## 3. Goals & Non-Goals`, one `## 12. Change Log`, eleven `### 2026-07-31 P1AM` headings and exactly one added table row. `.gitignore` auto-merged. Note that its diff against main is deliberately **+9/-4, not zero**: CONS-A1's `.codex-worktrees/` block is present and intact (this branch never edited it), while the remaining delta is #4068's own change — three anchored `src/p1am_control_system/backend/dcs_scada.db*` patterns replaced by unanchored equivalents plus the comment explaining why. A zero diff here would mean #4068's fix had been dropped. The four `pdf_renamer` files, `scripts/test_assertion_allowlist.txt` and the two workflow files CONS-A1 touched come across cleanly and now match `origin/main` byte-for-byte. This branch had restored them to the previous main during the churn strip, so its side was unchanged from the merge base and git took theirs without a conflict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…da-safety-2026-08-13
…rder 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>
Consolidates the P1AM SCADA production-readiness remediation for the Raspberry Pi 5 gasifier control system. The original review's finding was that nothing de-energizes on fault; this branch is the batch that fixes that.
Supersedes #4045, #4053, #4057, #4058, #4059, #4060, #4062, #4064, #4066, #4067, #4068
Two of the thirteen candidate PRs are deliberately NOT merged — #3959 and #4043 — because each introduces a safety inversion. Details and required rework are in the verdict table and the section below. They stay open.
Per-PR verdict
HEATER_RELAY_COILfirst and hard-errors on a missing ack; the old 64-registerTAG_VALUE_BASEwrite (a provable no-op the firmware overwrites every scan) is gone; shutdown safes the plant before joining tasks, on the exception path too, under a deadline belowTimeoutStopSec. Dead write seams now raise instead of returningTrue.HH_TEMP— a degC threshold — reachable at all. Absent/non-finite feedback latchesSENSOR_FAULTinstead of substituting0.0. Deglitch filters are built per channel so an open TC is no longer accepted as a real reading.t28 = θ + τ/3,t63 = θ + τ⇒τ = 1.5(t63 − t28)), and the DMC change to solving for Δu from a zero origin is right —free_responsealready carries the effect of holdinglast_cv, so optimising over absolute CV double-counted the current input. Stops reporting a clamped-to-zero open-loop controller asstatus="success".trusted_tags(control, alarms, historian) fromdisplay_tags(HMI only); a failed read yieldsHELD/FAULTand the alarm engine is not run, so a link flap can no longer clear an active HiHi. Scan/trip cadence pinned topoll_interval_s— previously the default LIGHTWEIGHT mode ran the whole loop at 2.0 s, making E-stop re-assert 20× slower. Bounded queues; backpressure drops samples but never alarm events.VACUUM) on the event loop, freezing the control loop, the broadcast and every HTTP endpoint including E-stop. Nowasyncio.to_thread+ boundedincremental_vacuum. Also fixes UTC drift via aUtcDateTimedecorator and bounds Data Explorer memory by row count before reading rows.require_read_authdefaults true). Its final commit had deleted 24 authz-matrix rows as "stale"; 14 of those routes are mounted unconditionally, sotest_every_route_is_classifiedfailed on the merged tree. Restored — see below.cmd_teardowncommands AOs to zero and reads them back before releasing routing, andmain()zeroes outputs onSystemExit/KeyboardInterrupt. Ack correctly never clears a still-active condition. One blocking defect fixed — see below.populateGlobal. Combined with the competing implementation already on the branch — see conflicts.P1AM_DB_PATHoverride,as_posix()so a Windows drive path does not become escapes, and unanchored.gitignorepatterns. Well tested.burnout_low), the first sample after a restart is accepted as good, and the cross-check now compares two channels sharing one supply, conditioner family and analog card, soTC_DISAGREEcannot fire. The PR's own tests encode this:test_controls_on_analog_k_tagfeeds the broken-loop signature and assertsrelay_on is True, andtest_thm_other_sensor_not_used_on_analog_pathassertsHH_TEMP not in tripswith the card R at 1400 °C. Separately,SignalBroker::kNumInputsgoes 6 → 8, wideningConfigStructby 8 bytes, without bumpingStorageManager::kMagic— the file's own comment states that rule; a retained config would load misaligned and publish AI2/AI3 onto the AO command tags. Alsocounts * (20.0/8191.0)assumes 0 counts = 0 mA while the module README describes a 4–20 mA range; if wrong it under-reads by ~190 °C, and the bench evidence cannot distinguish the two mappings. Needs rework, not merging.defaults.pyshipslolo=0.0, low=5.0, high=95.0, hihi=100.0for all 32 tags andfirmware.inocopies regs 300..555 into the interlock every scan. WithIsLimitEffectiveaccepting the closed interval (limit >= 0 && limit <= 100— its own test assertsIsLimitEffective(0.0f)and(100.0f)are true) and the new trip beingval <= lolo, any idle tag sitting at exactly 0.0 trips on the first scan; the trip action itself forces routed output tags to 0.0, soClearTrip()can never stick — a permanent, unclearable latch, which is precisely the #4001 symptom the commit claims to fix. Meanwhilehihi = 100.0sits onSetTag's clamp ceiling, so the over-temperature trip that previously fired athigh = 95.0(≈1330 °C) becomes unreachable. Verified against the working tree, not inferred. Land commit 1 separately after pinning thearduino-cliinstaller (currentlycurl … master/install.sh | shon a self-hosted runner from apull_requesttrigger) and gating the compile job against fork PRs.Defects fixed during integration
Four fail-open defects, three of which defeat the guarantee their own slice was written to provide. All four have regression tests.
backend/state.py)._restore_engine_alarmscalledupdate_tag(tag, float(entry.get("value", 0.0))).get_active_alarms()is only contracted to agree between the Rusttools_core.scadaengine and the Python fallback on the keys the restore path reads — not on carryingvalueat all — and where it is absent that substituted0.0resolved the tag to Normal.apply_configclearsactive_alarmsfirst and runs on every routing deploy and every reconnect-time_publish_active_config. Composed with SCADA: fix poll-loop data integrity, cadence and backpressure (#4004 #4008 #4009 #4023 #4024) #4064 this is worse than either alone: the poll loop then refuses to re-evaluate alarms while the link is down, so a HiHi cleared by the reconnect stays cleared for the whole outage. A record with no finite value now retains its snapshotted state and never touches the engine.desktop/alarm_state.py).classify_valuedecides the band with four comparisons and every comparison against NaN is False, so NaN fell through toreturn None= inside the normal band, andevaluateemittedclearedand dropped the tag from both sets — silencing a live High-High on the heater.json.loadsaccepts bareNaN, so it was reachable off the wire._require_numbernow rejects non-finite input and_evaluate_alarmscatches it alongsideTypeError; skipping the tag is fail-safe becauseevaluatevalidates before it clears anything.desktop/header.py).set_alarms_stateunconditionally set_flash_state = Trueand_refresh_annunciatorcalls it on every telemetry frame, so the timer's OFF phase was overwritten within ~100 ms. The phase now restarts only on a transition into unacknowledged.test_route_authz_matrix.py). 14 restored rows are the DC supply's and heater's own command surface. The 9 Data Explorer rows genuinely are conditional (that router mounts only when its numeric stack imports), so they now register from the live app rather than being deleted — satisfying both the exhaustiveness and no-stale-rows checks in either environment. 51 tests with 1 failure → 84 passing.Plus one broken test repaired rather than deleted:
test_connect_loop_wakes_immediately_on_shutdownreused the import-time module-levelshutdown_event, which asyncio binds to a loop on first await, so it raised "bound to a different event loop" before reaching its assertion.Two further defects surfaced by CI, one of them serious
Both were found while driving
tests (3.11)green. In each case the cheap fix would have cemented a real defect.1. A safety event log could show a trip above its own acknowledgement
This is the more serious of the two.
EventLogger.fetch_logsordered bytimestamp DESCalone.datetime.now()reports microseconds, but the underlying clock does not — ~15.6 ms granularity on Windows — so a burst of events (an alarm trip and the acknowledgement that answers it; a setpoint change and the trip it causes) share one timestamp string, and SQLite is then free to return them in any order.The visible symptom was a flaky test. The actual consequence is that an operator reviewing the event log could see an alarm trip listed above the acknowledgement that answered it — misleading evidence in a safety record, during exactly the review where ordering carries the meaning.
Fixed with
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_firstpins it by writing an explicit identical timestamp so the tie is forced rather than hoped for; verified as a real regression test, since with the tiebreaker removed it fails and returns['first', 'second', 'third'].Note on provenance: the test that exposed this (
tests/p1am_control_system/test_desktop.py::test_event_logger_basic) is byte-identical tomain— this branch never touched it. The flake is pre-existing onmainand only surfaced here becauseevent_logger.pyis in this diff, so the source-keyed selection ran it. It failed roughly one local run in three.2. The authz matrix disagreed with itself about 19 routes
test_table_has_no_stale_rowsreported all 19 power-supply / temperature / tuning rows as unserved. They are served, proven from inside the same failing run: single process (-n 0), the 19 matchingtest_gated_route_rejects_anonymous_callercases drove those exact paths throughTestClient(app)— the same module-levelappthe enumeration walks — and got 401/403._DENIED = (401, 403), and a route the app does not serve answers 404, which would have failed.Deleting the rows (which an earlier slice had done, and which this PR reverted) would have made the check green while the authz matrix quietly agreed that
acknowledge_trip,permissive,setpoint,burnout_mode,tc_typeand the whole PID tuning lifecycle did not exist — the plant's entire control surface, in the batch whose review theme is "nothing de-energizes".test_table_has_no_stale_rowsnow asksRoute.matches(), the function Starlette itself calls during dispatch, so a check phrased as "would a request reach a handler" cannot disagree with a request. Verified it still discriminates: real and parameterised routes match, a bogus path does not, a wrong method on a real path does not.The underlying divergence is not root-caused and is not reproducible locally (CI's exact 101-file selection with CI's exact flags passes 1949/1949, three times). Filed as #4476, including the part that matters most:
test_every_route_is_classifiedis deliberately left enumeration-based because it guards the ungated direction, which means it is still exposed to the same under-reporting — a route the enumerator misses is a route it can never demand an authz row for. Latent today (every current route is classified and gated with passing enforcement tests); the risk is the next route added. Not fixed here, by design.Conflicts resolved, and which side won
18 conflicts across 11 merges.
SPEC.md(all 11 merges) — every slice prepends at the same anchor; kept all 11 sections, once each. The first pass concatenated whole-document hunks and duplicated the spec 11 times (26 548 lines vs main's 2 401); rebuilt so the diff is purely additive, +403/−0.backend/modbus_client.py(8 merges) — kept HEAD. Each older slice carried the pre-fix(p1am): de-energize on E-stop and on shutdown; kill dead write seams #4060trigger_estoptail, which git tried to reinstate inside the newwrite_heartbeatbody. fix(p1am): de-energize on E-stop and on shutdown; kill dead write seams #4060's heater-relay-first sequence and the removal of the no-op tag-block write are the safer behaviour and none of those slices intentionally edit this file.backend/main.py— keptimport shutdown_safety(fix(p1am): de-energize on E-stop and on shutdown; kill dead write seams #4060, still used at the lifespan teardown) and adopted P1AM SCADA: safe calibration shutdown, real alarm ack, settings-driven MFCs #4062's settings-drivencreate_default_managerover the hardcodedconnection_type="mock"devices; droppedimport historian(SCADA: fix poll-loop data integrity, cadence and backpressure (#4004 #4008 #4009 #4023 #4024) #4064 removed its last use) and P1AM SCADA: safe calibration shutdown, real alarm ack, settings-driven MFCs #4062's duplicatePOLL_FAILURE_*constants (SCADA: fix poll-loop data integrity, cadence and backpressure (#4004 #4008 #4009 #4023 #4024) #4064 moved them intopoll_runtime).backend/models.py— union: SCADA: fix poll-loop data integrity, cadence and backpressure (#4004 #4008 #4009 #4023 #4024) #4064'sDataSourceprovenance enum and fix(p1am/historian): unblock E-stop during retention, fix UTC drift, bound explorer loads #4058'sensure_utc/UtcDateTime; bothTagLogdocstring paragraphs kept.backend/tests/test_database.py— took fix(p1am/historian): unblock E-stop during retention, fix UTC drift, bound explorer loads #4058's_taglog_index_nameshelper calling the real_migrate_historian_indexesover HEAD's hand-copied duplicate of the SQL (a copy passes even if the function is deleted), plus fix(p1am): anchor historian DB to package dir; ignore stray dcs_scada.db #4068's three new tests with the file's typed signatures.backend/settings.py/tests/test_settings.py— union; no duplicate names.frontend/hooks/useTelemetryStream.ts(+ test) — whole-file conflict with no anchor. Took fix(p1am-hmi): report data age, not a boolean; stop silent alarm loss #4053's data-age rewrite and re-applied Harden P1AM SCADA deployment: the authz was fine, the deployment wasn't #4066's four security changes onto it (first-frame auth instead of anapi_keyquery parameter, 1008 re-prompt,authHeaders()on/api/snapshot,sendon the mock socket).frontend/test/setup.ts— two independent implementations of the same fix. Combined: fix(p1am-frontend): install conforming localStorage in test setup #4067's spec-shaped unconditionalStorage(its probe-free install avoids the--localstorage-filewarning in every worker) installed on bothglobalThisandwindowper HEAD, since under jsdom a barelocalStorageresolves onwindow..gitignore— took fix(p1am): anchor historian DB to package dir; ignore stray dcs_scada.db #4068's unanchored patterns, a strict superset of P1AM: scale the power-supply thermocouple, fault on missing feedback, one units contract #4059's/dcs_scada.db.Also removed from the merged result
ruff==0.14.10CI and.pre-commit-config.yamlnow use. 293 Python files outside the P1AM set are restored toorigin/mainexactly; 256 were provably pure formatting, the rest sit in ruff-excluded trees main has never had formatted. Left in, each would have been aquality-gateformat failure for a change nobody made..codex-worktrees/*gitlinks (git mode 160000). No.gitmodulesdeclares them,origin/mainhas none, and the commits they name exist on no remote — a fresh clone would carry six gitlinks pointing at objects the server does not have.deploy/launch-hmi.shwould fail at exec on the Pi withbad interpreter: /bin/bash^M, so the operator's HMI launcher simply would not start;backend/Dockerfilewas also converted. Only*.pyis covered by.gitattributes.Gates run locally
ruff check(0.14.10, CI exclude list applied, 81 changed files) — cleanruff format --check— cleangit grep "<<<<<<< HEAD"— empty-n 0, as CI runs it) — 1388 passed, 6 skipped-m "not live_simulation" --import-mode=importlib -n 0), three consecutive runs: 1949 passed, 6 skipped, 0 failedmaintwice (CONS-A1 CONS-A1: CI/infra consolidation (file-size-budget checkout, pdf_renamer SQLite leak, fleet-load fixes) #4445, CONS-B1 CONS-B1: Palette & Bolt Suite (form submission, accessibility, SVG & CSV optimizations) + movement-optimizer motion-view extraction #4438); anchoredgit grep -n '^from datetime import UTC' -- '*.py'empty, so CONS-B1: Palette & Bolt Suite (form submission, accessibility, SVG & CSV optimizations) + movement-optimizer motion-view extraction #4438's py3.10 revert is intactvitest run— 459 passed across 53 files;tsc --noEmitcleanfrom datetime import UTCsites keep theirtry/except ImportErrorguard with# noqa: UP017correctly placedIssues carried by this PR (46)
#3996, #3997, #3998, #3999, #4000, #4003, #4004, #4005, #4006, #4007, #4008, #4009, #4010, #4011, #4012, #4013, #4014, #4015, #4016, #4017, #4018, #4019, #4020, #4021, #4022, #4023, #4024, #4025, #4026, #4027, #4028, #4029, #4030, #4031, #4032, #4033, #4034, #4035, #4036, #4037, #4038, #4039, #4040, #4041, #4042, #4061
Not carried: #3995 (firmware never compiled or tested in CI) stays open — it was #4043's, which is excluded. #4001 and #4002 are already closed, and #4043's commit 2 would have re-opened #4001 in practice.
Follow-ups (reviewed, deliberately out of scope)
Real findings that are pre-existing or non-blocking, kept out to avoid unreviewed behaviour change on a safety branch:
_resolve_sourcein the poll loop keys provenance onNoneonly, so a dict containing NaN still classifies asLIVE. SCADA: fix poll-loop data integrity, cadence and backpressure (#4004 #4008 #4009 #4023 #4024) #4064 closes theNonecase, not this one.plc.read_tags()raising (rather than returningNone) unwinds_poll_oncebefore the write-seam interlocks are armed and before anyDATA_QUALITYevent is emitted.0.0stampedliveon the display path.deploy/launch-hmi.shpasses the operator credential in--app=…#apikey=…, i.e. in/proc/<pid>/cmdline, readable by any local account.AuditMiddleware._buffer_bodybounds only the declaredcontent-length, so a chunked body is unbounded pre-auth.AlarmsHeaderdowngrades critical to warning when any entry is dropped;TemperatureConfigPanelcarries an unfixedparseFloat("")→ NaN intohh_limit_c.REAL_PLC_DRIVERStreats unknown as simulated;SIMULATED_PLC_DRIVERStreats unknown as real hardware) should be one predicate.update_setpointreturnsFalse); fail-safe, but the gas path is non-functional on the real rig.🤖 Generated with Claude Code