Sensors¶
How the firmware drives, samples, validates, and publishes data from the five on-board sensor chips: the ADS1115 (four-channel analog-to-digital converter), the INA228 (battery monitor), the DS18B20 temperature probes, the BMP388 (barometric pressure and board temperature), and the LSM6DSOX (inertial measurement unit, IMU). External data sources (VE.Direct, NMEA2000, NMEA0183) are covered elsewhere.
The shared I²C bus runs at standard Fast-mode speed (400 kHz) with a short bus timeout, so a wedged device costs milliseconds, not a hung loop. Every sample that passes validation stamps a freshness timestamp (MARK_FRESH(IDX_*)); consumers check staleness against those stamps rather than trusting the last value forever.
ADS1115 — Four-Channel Analog Converter (I²C 0x48)¶
Configured for a wide input range and its maximum sample rate, in single-shot triggered mode. The library wrapper is used only for setup and conversion triggering; conversion-register reads are done as raw two-phase I²C transactions so the firmware can avoid the library's blocking poll and time bus stalls in microseconds. Search 5_functions.ino for _ReadAnalogInputs_inner.
Non-blocking state machine¶
A three-state machine drives each conversion without blocking the loop:
- Idle — select the next channel, trigger a conversion, note the time.
- Wait — a time-based ready check (a few milliseconds, with margin over the theoretical conversion time); a timeout retries the same channel.
- Read result — raw register read with microsecond timing, channel-specific scaling, a sanity-band check, then the freshness stamp. Repeated consecutive failures set
ADS1115Disconnectedso a dead chip cannot stall the system.
Sample sequence¶
The channel order interleaves alternator current between every other measurement:
adsSeq = { CH1, CH0, CH1, CH2, CH1, CH3 }
CH1 (alternator current) gets half the slots, since it feeds the fastest control loop; battery voltage, RPM, and the thermistor each get one slot per cycle.
Each step waits ADS_CONVERSION_MS (3 ms), and an absolute per-slot grid (ADS_SLOT_US, 4.5 ms) paces the sequence, so one full six-slot cycle is about 27 ms. CH1 holds three of those slots and therefore refreshes roughly every 10 ms; the other three channels refresh once per cycle, so treat them as fresh within ~30 ms rather than assuming the theoretical figure. The firmware measures CH1's real rate at run time (ch1SampleHz() in 7_functions.ino — about 96 Hz on the bench, 9–13 ms typical with occasional 24 ms stretches when loop timing bunches) and treats it as the hard ceiling on what any swept-sine measurement can resolve: sweepAliasLimitHz() caps sweeps at a third of it (~32 Hz), because oversampling a held value adds no information.
What each channel measures¶
| Ch | Variable | Source | Notes |
|---|---|---|---|
| 0 | BatteryV (V) |
High-ratio resistor divider | Used only for the cross-sensor checks on the safeties page — disagreement and plausibility — never for control |
| 1 | MeasuredAmps (A) |
Hall-effect current clamp, mid-rail centered | Scale selected by AmpSensorRange; optional sign flip (InvertAltAmps); user offset (AlternatorCOffset) and temperature-compensated zero-correction offset (DynamicAltCurrentZero) subtracted |
| 2 | RPM |
LM2907 frequency-to-voltage converter (see LM2907 front end) | Scaled by RPMScalingFactor; very low readings forced to zero |
| 3 | temperatureThermistor (°F) |
NTC thermistor via op-amp chain | Converted by the Beta-equation helper — search 5_functions.ino for thermistorTempC. The thermistor constants (R_fixed, R0, Beta, T0_C) are user settings persisted in NVS |
Each channel has its own plausibility band; readings outside it are discarded and never stamped fresh.
Why CH1 is special¶
CH1 is the heartbeat of the control loop. Every confirmed sample:
- Lands in a large PSRAM ring for history and statistics (
ch1_recordin 7_functions.ino). - Updates the smoothing filters consumed by the current-excess supervisor and the current loop (exponential and moving-average variants, so the user-selectable signal sources are always populated).
- Updates session and lifetime peak-current watermarks.
- Sets a freshness flag that gates one pass of the control function — the control loop runs per fresh sample, not on a wall-clock timer.
Performance counters (I²C error count, slow-read count, worst-case timings) are published to the dashboard so a marginal bus shows up as numbers rather than unexplained stalls.
INA228 — Battery Voltage and Current Monitor (I²C 0x40)¶
A high-resolution delta-sigma converter measuring battery bus voltage (IBV) and shunt current (Bcur). It is the single source of truth for all control and safety voltage decisions, and it also drives the hardware overvoltage backup: its alert pin physically pulls the field enable line down with no firmware involvement (see Safeties and Protections).
Two sampling modes¶
The chip is reconfigured on the field-state edge (inaFastModeActive):
- Field on — fast mode: 4-sample averaging with 540 µs conversions (~4.3 ms update), polled every few milliseconds. Feeds the voltage loop, the rate-of-rise prediction, and load-dump detection — and, because the hardware alert compares against the chip's averaged value, it sets the alert's filter depth too.
- Field off — slow mode: 128-sample averaging with 4120 µs conversions (~1054 ms update), polled about once a second. Quiet idle monitoring; the hardware alert's comparison rides the same deep average, academic with the field already off — the pin simply holds the enable line low while the condition persists.
Reconfiguration costs two register writes on the transition edge only. On the switch to fast mode, the smoothed-voltage filter is reseeded so the voltage loop starts from reality, not a stale average.
Per-read pipeline¶
Each read fetches bus voltage and shunt voltage, validates both (range and not-a-number checks), then derives current from the user's configured shunt resistance (ShuntResistanceMicroOhm), applies the calibration offset (BatteryCOffset), optional inversion (InvertBattAmps), and the auto-learned gain factor (DynamicShuntGainFactor). Only validated reads stamp freshness. Derived per read:
IBV_filtered— a time-constant-aware exponential smooth used for display and the voltage-loop error term (getFiltV()).g_dBcur_dt— battery-current slew rate in amps per second, the input to load-dump detection.- Watermarks — session and lifetime voltage extremes, plus per-ignition-cycle high/low tracking.
The hardware overvoltage threshold (VoltageHardwareLimit, the Hardware Shutdown Voltage) is its own absolute, persisted user setting — not derived from the chemistry or the bulk target. It is reprogrammed into the chip when the setting itself is changed and when a system-voltage-class change rescales it. How to place it is covered on Protection Timing; search 5_functions.ino for updateINA228OvervoltageThreshold.
Accessor convention¶
getBatteryVoltage() → IBV (control + safety source of truth)
getBatteryCurrent() → Bcur, or the Victron value if the user opts in (display/accounting only)
getFiltV() → smoothed IBV (display, never safety)
BatteryV from the ADS1115 is never a control or safety signal — it exists only so two independent sensors can cross-check each other.
DS18B20 — Temperature Probes (OneWire)¶
Runs entirely in its own task (TempTask) on the second CPU core, because a full conversion takes most of a second — running it in the main loop would disrupt control timing. The task feeds a heartbeat that the main core watches (the T4 protection on the safeties page).
The per-iteration sequence: heartbeat, a few cooperative gates (over-the-air-update in progress, hardware-absent flag, core-busy flag), re-enumeration if a probe was lost, an adaptive poll cadence (slower after success, faster retry after failure, and one forced cycle the moment the engine starts), a single Skip-ROM convert command that every probe on the bus obeys at once, a non-blocking conversion wait, then one scratchpad read per probe.
One bus, up to six probes, three roles¶
The 1-Wire bus (GPIO13) is multi-drop. owEnumerate() searches it, keeps every DS18B20 with a valid ROM code in a small registry (owProbes, six entries), and binds probes to roles — alternator, battery, extra — by stored ROM code rather than by position, because the search order is by serial number and would otherwise shuffle when a probe is added. The bindings persist in NVS (owAddrAlt / owAddrBatt / owAddrExtra); the dashboard reads the registry from /tempSensors and assigns roles under Setup → Temperature → Temperature Sensors. A single-probe device binds that probe to the alternator role automatically, so nothing has to be assigned by hand to keep working — but only when the alternator's Temp Source is the digital probe and both the battery and extra probe enables are off. On a thermistor install, or once either of those enables is on, a lone probe is left unassigned: it is presumed to be a battery or extra probe still waiting for its role.
Only the alternator role feeds AlternatorTemperatureF and the thermal protections; a failed read on the battery or extra probe never touches the alternator staleness cut. The task never writes flash itself — an auto-bind is queued and persisted from the main loop's field-off block.
Five-check validation¶
Every raw reading must pass all five before it is accepted:
| Check | What it catches | Failure counter |
|---|---|---|
| CRC of the scratchpad (one immediate retry) | Bus noise, marginal pull-up | tempCrcFailCount (tempCrcRecoveredCount on retry success) |
| All-bytes-0xFF | Sensor physically disconnected | tempAllFFCount |
| Power-on reset signature | Sensor rebooted and is returning its placeholder value | tempPowerOn85Count |
| Resolution-config drift | Sensor lost its configured resolution; firmware rewrites it and re-converts | tempResolutionFixCount |
| Sanity range | Impossible temperatures | tempOutOfRangeCount |
A passing read on the alternator probe updates AlternatorTemperatureF, the session and lifetime maxima, and the freshness stamp; a passing read on the battery or extra probe updates BatteryTempProbeF / ExtraTempF and its own freshness stamp, but only while that role's enable toggle is on. Persistent failures queue a throttled console warning.
Every counter above is exposed under Live Data → ESP32 — a marginal crimp or noisy bus shows its specific failure mode rather than a generic "temperature stale." A rising recovered-CRC count with a steady temperature is an early noise warning before anything goes stale.
BMP388 — Board Temperature and Barometric Pressure (I²C)¶
The barometer (a BMP388; the pin-compatible BMP390 fits the same footprint, which is why the hardware page carries both names) runs heavy pressure oversampling with infinite-impulse-response filtering, in forced one-shot mode inside the same non-blocking state machine as the ADS1115: trigger a conversion every four seconds, poll for completion, validate plausible pressure and temperature ranges, convert, stamp fresh. The cycle is sized against the freshness timeout — at four seconds a single missed read still leaves the value eight seconds old, inside the ten-second window that consumers treat as stale. The first sample after boot is discarded, and a timeout with a throttled warning covers a wedged conversion.
Use sites: ambientTemp (displayed as board temperature — the sensor sits on the powered board, so it reads warmer than true ambient) feeds the alternator cooling model and cold-weather logic; baroPressure is logged for trip analytics and weather correlation.
LSM6DSOX — Six-Axis IMU (I²C)¶
Accelerometer and gyroscope in hardware-FIFO mode, drained from the main loop after the control function so an IMU bus stall can never delay control-critical reads.
Initialization (imuInit() in 4_functions.ino)¶
Hard-fails — sets imuEnabled = false rather than limping — on any of: no I²C acknowledge at the address, a wrong chip-identity register (WHO_AM_I), or any failure configuring sensor enables, output data rates, FIFO batching rates, or continuous-FIFO mode. The accelerometer runs at roughly a hundred samples per second (margin over the tens-of-milliseconds slam pulses it must catch); the gyro at about half that, sufficient for hull roll and pitch dynamics. After configuration the init reads a few FIFO entries and prints their decoded sensor tags as an empirical self-check.
Draining (drainIMUFifo())¶
Polled on a short interval, pulling a bounded handful of FIFO entries per poll (MAX_FIFO_DRAIN_PER_POLL) so the cost per loop pass is capped. Each entry carries a tag identifying accelerometer, gyro, or temperature data; samples land in a PSRAM ring with separate accel and gyro pointers. An error tracker (imuRecordI2CError()) counts errors inside a 60-second window and auto-disables the IMU at ten, with a console message.
Windowed motion metrics (updateAccelMetrics())¶
Aggregated over a configurable window when motion sensing is enabled (on enable, stale FIFO backlog is flushed first): attitude, slam/capsize/pitchpole event counting, comfort and seasickness estimates, and deviation statistics. What each metric means and how it is derived is covered under IMU Analytics. The event thresholds (CAPSIZE_THRESHOLD_DEG, PITCHPOLE_THRESHOLD_DEG, SLAM_THRESHOLD_G) are ordinary NVS settings loaded by InitSystemSettings(), with the counters themselves persisted in NVS.
These feed both the live tipping display and the slower summary statistics on the dashboard.
Data Freshness (the DataIndex enum)¶
Every sensor source has an index into a timestamp table; MARK_FRESH stamps it and IS_STALE compares against a default timeout, with safety-critical consumers (current and temperature staleness protections) applying their own stricter windows. The roster, in Xregulator.ino near the DataIndex enum:
| Group | Indices | Use |
|---|---|---|
| Electrical | battery voltage (both sensors), battery current, alternator current | Control and safety; stale alternator current is itself a field-cut reason |
| Engine | RPM | Ceiling table, RPM gate |
| Thermal | alternator probe, thermistor, battery probe, extra probe | Whichever alternator source is selected (TempSource) drives derating and the staleness protection; the battery probe is a candidate in the battery-temperature source chain, the extra probe feeds its own alarms |
| Environment | barometric pressure, board temperature | Analytics, cooling model |
| Motion | IMU | Tipping and comfort metrics |
| Derived | duty cycle, field telemetry, state of charge, charge-time estimates, and similar | Dashboard display |
| External | NMEA 2000 position/wind/speed/depth fields, received battery status, Victron voltage, current and battery temperature, RV-C battery temperature, charge-limit follow, NMEA 0183 | Dashboard display, greyed out when stale; the three battery-temperature feeds are candidates in the battery-temperature source chain |
Disconnect Flags¶
Each device has a runtime "disconnected" flag so a hardware failure degrades gracefully instead of crashing the loop:
| Flag | Set by | Effect |
|---|---|---|
ADS1115Disconnected |
Failed init at boot, or five consecutive bus failures at run time | The whole ADS block is skipped |
INADisconnected |
Failed init at boot (INA.begin() did not answer) |
INA reads skipped; values freeze, hardware alert disabled |
BMP388Disconnected |
Failed init at boot — begin() is retried once after a short delay to ride out a transient bus NAK, and only a second failure sets the flag (with a console warning) |
Conversions are never triggered, so board temperature and barometric pressure stop updating; boot continues normally |
imuEnabled = false |
Init failure, or the error-rate trip (ten I²C errors inside one 60-second window) | FIFO drain early-returns |
| Temperature task | Self-recovers via re-enumeration retries | The staleness protection cuts the field if readings stop |
All five are set by the firmware itself — none is exposed as a settings parameter, so clearing one takes a reboot after the wiring is fixed. For bench work the fake-data injection path (ReadAnalogInputs_Fake) covers most development scenarios more cleanly.