The sensor path
For the commissioning tech. This is the path a temperature or pressure reading takes from a sensor (or a bench potentiometer standing in for one) to the fleet dashboard, and every place it can be stopped for being wrong.
The chain
Section titled “The chain”[pot / real sensor] Arduino Mega 2560 ModBerry CM4 EMQX broker shaft = state of world (all engineering units) │ │ ▼ ┌──────────────────────────┐ ▼ SIM_MODE only: │ PRODUCTION PATH │ parse frame ── CRC / seq check shaft_fraction │ (identical SIM & REAL) │ │ (bad → drop, count) → world_temp ═══════▶ │ raw ─ fault band check │ linearize(raw, calib_id) → R_ntc (β model) │ (§ fault bands) │ │ → v_node (10k pull-up) │ raw ─ classify → Fault │ invariant gate ── cross-channel → synth_adc ─────────▶│ serial: raw + fault ONLY │ │ impossible → drop REAL_MODE: │ no engineering units │ publish: divider → ADC pin ──────────────▶│ │ ok → telemetry (QoS1) └──────────────────────────┘ drop → status (raw attached)The pot is not the sensor. Its shaft angle is the state of the world. In
SIM_MODE the firmware synthesizes the electrical response a real sensor would
produce in that world, then feeds that synthetic signal through the exact same
linearization and validation code that runs against real hardware. Nothing below
the “PRODUCTION PATH” line knows whether it was simulated.
The Mega sends raw ADC counts and fault codes only — never engineering units.
Every value in the system is computed once, on the CM4, and tagged with the
calib_id it was computed under, so any consumer can recompute it from
(raw, calib_id).
Channel map — hydronic profile
Section titled “Channel map — hydronic profile”| Pin | Signal | Element (emulated) | Range | Front-end |
|---|---|---|---|---|
| A0 | Outdoor temp | 10 kΩ NTC, β=3950 | −10 → 110 °F | pull-up divider |
| A1 | Supply water | 10 kΩ NTC, β=3950 | 60 → 210 °F | pull-up divider |
| A2 | Return water | 10 kΩ NTC, β=3950 | 60 → 190 °F | pull-up divider |
| A3 | System pressure | 4–20 mA loop, 0–30 psi | 0 → 30 psi | 220 Ω sense |
Steam profile (RISER_PROFILE=steam) remaps A1→header pressure (0–3 psi),
A2→vapor temp (60–230 °F), A3→LWCO level. Same architecture, different invariants.
Fault bands — checked on raw, before linearization, never clamped
Section titled “Fault bands — checked on raw, before linearization, never clamped”A clamped open-circuit NTC reads as extreme cold, and extreme cold is exactly the input that drives an outdoor-reset curve to command maximum fire. Clamping turns a detectable sensor failure into an undetectable overfire. So we don’t clamp — we emit the fault, publish the raw count, and leave the value unread.
| Channel | Condition (on raw counts) | Fault | Means |
|---|---|---|---|
| NTC | raw ≥ 1015 | OpenCircuit |
sensor disconnected |
| NTC | raw ≤ 8 | ShortCircuit |
sensor / wiring shorted |
| 4–20 mA | < 3.8 mA equivalent | UnderRange |
loop broken |
| 4–20 mA | > 20.5 mA equivalent | OverRange |
loop saturated / short-to-hi |
| any | no calibration record | Uncalibrated |
never a default coefficient |
On the bench: turn a pot fully one way → ShortCircuit; fully the other →
OpenCircuit. The extremes of pot travel are reserved as electrical faults, not
as extreme weather.
Invariant gate — cross-channel impossibility (runs on the CM4, before publish)
Section titled “Invariant gate — cross-channel impossibility (runs on the CM4, before publish)”Per-channel electrical validity is the Mega’s job. A combination that cannot
physically occur is the CM4’s, and it is checked before signing and
publishing. A frame that fails is dropped, and a riser.invariant_violation is
published to the status topic with the raw counts attached — a dropped frame
is louder than a published one, so the dashboard can show a device producing valid
electrical readings in an impossible combination (which is exactly what a swapped
supply/return sensor pair looks like on commissioning day).
Hydronic:
- return hotter than supply —
rwt > swt + 1.0 °F. Impossible in a heat-emitting loop. - negative ΔT —
swt − rwt < 0after (1) passes (the tolerance band). - staleness — any reading older than 2× the publish interval.
- pressure above relief —
psi > 30 psi. The sensor is lying or the relief has failed; either way it is not a measurement. - impossible OAT rate — outdoor temp moving faster than 20 °F/min (evaluated over a 10 s window so ADC quantization noise doesn’t false-trip). Thermal mass makes a real outdoor sensor incapable of this; it’s a wiring fault.
Steam: no ΔT. Instead — header pressure > 2.0 psi sustained > 60 s is a cut-out-failure; vapor temp < 200 °F while header > 1.0 psi means steam is not reaching the far riser.
Derived values published alongside the readings (flags only, never control
actions): delta_t, condensing_risk (rwt < 130 °F), flow_anomaly
(ΔT < 5 °F dead loop, or > 40 °F low flow / air-bound).
Nothing in this path actuates anything. No reset curve, no relay. Setpoints and
firing commands arrive on the cmd topic or not at all.
Serial line protocol
Section titled “Serial line protocol”ASCII, one frame per 250 ms, CRC-16/CCITT-FALSE over the payload between $ and
*. Readable on a scope or a screen session:
$RISER,1,<seq>,<uptime_ms>,<ch>:<raw>:<fault>,<ch>:<raw>:<fault>,...*<crc>\r\nFault codes on the wire: N none · O open · S short · V over-range ·
U under-range · C uncalibrated. Frame loss, CRC failure, and seq gaps are all
normal — the reader counts them and drops the frame; it never crashes and never
publishes a bad frame.
MQTT topics
Section titled “MQTT topics”riser/v1/{tenant}/{site}/{device}/telemetry QoS 1, not retained (rich: raw+value+fault+calib_id+derived)riser/v1/{tenant}/{site}/{device}/status QoS 1, retained, LWT (invariant violations land here)riser/v1/{tenant}/{site}/{device}/cmd QoS 1, subscribe onlyiOS compatibility. Alongside each rich frame, the edge also emits the flat frame the current operator app already decodes, so the app lights up with no code change:
riser/{tenant}/{site}/{device}/telemetry {"type":"reading","ts":<epoch s>,"supply_c":<°C>}The app’s viewer ACL is riser/neos/{franklin,mesa}/#, so deploy with tenant=neos
and site=franklin/mesa. Set emit_legacy=False on the Pipeline to turn it off
once a consumer speaks the rich /v1/ shape. The legacy frame is only sent when the
supply-water channel is trustworthy that frame — a dropped or faulted frame sends
nothing, so the app never shows a number the invariant gate rejected.
Getting the data onto the operator dashboard
Section titled “Getting the data onto the operator dashboard”The dashboard reads telemetry over REST from the backend, not from MQTT. A small
bridge closes that gap: it subscribes the rich riser/v1/.../telemetry topic, maps
each frame onto the backend’s ingest shape (Celsius keys the dashboard already
renders — supply_temp_c, return_temp_c, pressure_psi, plus delta_t_c), and
POSTs it as the device. Faulted channels are omitted (a blank beats a guessed
number); per-channel fault codes, seq, profile, and calib_ids ride along in
health. Storage, alert evaluation, and the REST read all light up unchanged.
BRIDGE_KEYMAP=keymap.json MQTT_HOST=... MQTT_CA=... MQTT_CERT=... MQTT_KEY=... make run-bridgekeymap.json maps each device id to its backend API key: {"riser-01": "rk_..."}.
Mapping logic: backend/app/services/mqtt_bridge.py (pure, unit-tested). Runner:
backend/scripts/run_bridge.py. Invariant violations are not yet forwarded as
backend alerts — they remain on the retained status topic.
Prove it with no hardware
Section titled “Prove it with no hardware”make firmware-test # golden vectors + fault bands + CRC, on the host (clang++)make edge-test # the edge package under pytestmake sensor-demo # replay a recorded capture: prints published + every drop