Skip to content

Sensor Systems

How the firmware drives, samples, validates, and publishes data from the five on-board sensor chips: ADS1115 (4-channel ADC), INA228 (battery monitor), DS18B20 (alternator temperature), BMP388 (ambient temp + pressure), LSM6DSOX (IMU). External protocols (VE.Direct, NMEA2K, NMEA0183) are covered in Communication Interfaces.

Every sample that passes validation calls MARK_FRESH(IDX_*). See System Overview → Data Freshness for the staleness contract.


ADS1115 — 4-Channel 16-Bit ADC (I²C 0x48)

PGA ±6.144 V, 860 SPS, single-shot triggered mode. The library wrapper (ADS1115_lite) is only used for setup and setMux()/triggerConversion(). Conversion-register reads are done as a raw Wire.beginTransmission/requestFrom pair so that the firmware can skip adc.getConversion()'s blocking poll and detect bus stalls in microseconds.

State machine — _ReadAnalogInputs_inner()

State Action Exit
ADS_IDLE adsCurrentChannel → MUX, triggerConversion(), capture adsStateEntered after the I²C write completes ADS_WAIT
ADS_WAIT Time-based ready check. ADS_CONVERSION_MS = 3 (vs 1.16 ms theoretical at 860 SPS — 3 ms gives millis() granularity margin). ADS_TIMEOUT_MS = 10 retries from IDLE on the same channel ADS_READ_RESULT if ready, → ADS_IDLE on timeout
ADS_READ_RESULT Raw I²C read of conversion register. Microsecond timing on endTransmission + requestFrom for the slow-read counter. 5-consecutive-failure threshold sets ADS1115Disconnected = 1. On success: channel-specific scaling, sanity-band check, MARK_FRESH. Advance adsSeqIdx, attempt back-to-back trigger if ≥ 2 ms elapsed ADS_WAIT (back-to-back) or ADS_IDLE

Sample sequence

static const uint8_t adsSeq[] = { 1, 0, 1, 2, 1, 3 };  // 6 steps

CH1 (alternator current) fires 3 of 6 steps. Full cycle ≈ 14 ms, CH1 interval ≈ 4.7 ms (~213 Hz), CH0/CH2/CH3 each refresh every ~14 ms (~71 Hz).

Per-channel scaling

Ch Variable Source Equation
0 BatteryV (V) 1 MΩ / 49.9 kΩ divider Raw / 32768 × 6.144 × 21.0401. Sanity: 5 < V < 70 → MARK_FRESH(IDX_BATTERY_V)
1 MeasuredAmps (A) QNHCK1-21 Hall clamp, 2.5 V center, divider 768 k/768 k Raw / 32768 × 6.144 × 2.0Channel1V; then (Channel1V − 2.5) × kAmpScale[AmpSensorRange] where scale ∈ {100, 150, 250} A/V for ranges 0/1/2. Sign flip if InvertAltAmps == 1. Subtract AlternatorCOffset; subtract DynamicAltCurrentZero if AutoAltCurrentZero == 1. Sanity bands {250, 370, 600} A → MARK_FRESH(IDX_MEASURED_AMPS) and ch1FreshFlag = true
2 RPM LM2907 frequency-to-voltage converter (see LM2907 Resistor Mod) Raw / 32768 × 2 × 6.144 × RPMScalingFactorChannel2VRPM. < 100 RPM forced to 0; valid range 0–10000 → MARK_FRESH(IDX_RPM)
3 temperatureThermistor (°F) NTC thermistor via op-amp signal chain Raw / 32768 × 6.144 × 833 × 2Channel3V; then thermistorTempC(Channel3V) × 1.8 + 32. Sanity: −58 < °F < 392 → MARK_FRESH(IDX_THERMISTOR_TEMP)

Thermistor calculation

int thermistorTempC(float V_thermistor) {
  float Vcc = 5.0;
  float R_thermistor = R_fixed * (V_thermistor / (Vcc - V_thermistor));
  float T0_K = T0_C + 273.15;
  float tempK = 1.0 / ((1.0 / T0_K) + (1.0 / Beta) * log(R_thermistor / R0));
  return (int)(tempK - 273.15);
}

Defaults: R_fixed = R0 = 10000 Ω, Beta = 3950, T0_C = 25 °C. All four are LittleFS-persisted user settings.

CH1 side effects (every confirmed sample)

CH1 is the heartbeat of the control loop. On a valid CH1 sample:

  1. ch1_record(millis()) pushes the value into ch1Ring (5000-entry PSRAM ring → 2-min bucketed history via ch1_compute_stats()).
  2. EMA filters update: MeasuredAmps_filtered (TC = InputFilterTC) for the iExcess supervisor; g_pidI_filtered (TC = OutputPIDFilterTC) for the inner PID input.
  3. iAmpRing[] (I_RING_SIZE = 10) updated. Moving averages g_iMA_N (length IExcessMA_N) and g_pidMA_N (length OutputPIDMA_N) recomputed.
  4. cvLog_tick(millis()) writes a row to the CV log (pinned to actual sample arrival, not the control tick).
  5. Watermarks MeasuredAmpsMax (session) and MeasuredAmpsMax_AllTime updated; both bump lastElectricalRecordMs which inhibits NVS commits for 5 s.
  6. ch1FreshFlag = trueAdjustFieldLearnMode() consumes this flag in its CH1 gate, divides by PidSampleDivisor to support PID downsampling.

Performance counters (CSV2)

adsI2CErrorCount, adsSlowReadCount (any > 5 ms I²C operation), adsLastSlowEndTxUs, adsLastSlowReqFromUs. Reset to 0 only on "Reset Peak Values" press.


INA228 — Battery Voltage / Current Monitor (I²C 0x40)

20-bit Δ-Σ ADC. The chip drives the hardware overvoltage backup (ALERT pin wired to GPIO4 via open-drain). Software only configures the chip; the hardware ALERT path operates without firmware involvement.

Two-mode configuration

Field state AVG register BV/SV conv-time register Single update Interval used in firmware Purpose
ON (!gpio4IsLow) 1 (4 samples) 4 (540 µs) 4 × 1080 µs ≈ 4.3 ms INA_FAST_INTERVAL_MS = 5 Fast voltage loop, dvdt, Groups 1/2 OV
OFF (gpio4IsLow) 4 (128 samples) 7 (4120 µs) 128 × 8240 µs ≈ 1054 ms INA_SLOW_INTERVAL_MS = 1100 Idle monitoring, ALERT averaging

Mode is set by inaFastModeActive. Switching writes two I²C registers only on the transition edge; no per-tick cost. On the fast-mode rising edge, IBV_filtered is reseeded to IBV so the CV loop's EMA starts clean. resetINA228IntervalWindows() resets cadence stats on the same edge.

Per-read pipeline (inside try { } catch { } because INA228 library can throw)

IBV = INA.getBusVoltage();                // V
ShuntVoltage_mV = INA.getShuntVoltage() * 1000;  // mV
if (!isnan(IBV) && IBV > 5.0 && IBV < 70.0 && !isnan(ShuntVoltage_mV)) {
    Bcur = ShuntVoltage_mV * 1000.0f / ShuntResistanceMicroOhm;
    Bcur += BatteryCOffset;
    if (InvertBattAmps == 1) Bcur = -Bcur;
    if (AutoShuntGainCorrection == 1) Bcur *= DynamicShuntGainFactor;
    MARK_FRESH(IDX_IBV); MARK_FRESH(IDX_BCUR);
}

ShuntResistanceMicroOhm is the user-settable shunt resistance in µΩ — drives the V→A conversion. BatteryCOffset is a user calibration offset; DynamicShuntGainFactor and DynamicAltCurrentZero come from the auto-zero / gain-correction system.

Per-read derived quantities

  • IBV_filtered — EMA with α = dt / (VoltageFilterTC + dt) (dt-aware so the time-constant stays constant across cadence jitter). Used by getFiltV() and the CV loop error term.
  • g_dBcur_dt — battery-current slew rate (A/s) computed across consecutive valid reads with 3 ms ≤ dt < 2000 ms. Feeds the load-dump three-tier detection in Safeties.
  • WatermarksIBVMax/PeakVoltage_AllTime/MinVoltage/MinVoltage_AllTime, plus wmIgnUpdate(wmIgn_IBV, …) and wmIgnUpdate(wmIgn_Bcur, …) for ignition-cycle hi/lo tracking. Each record bump touches lastElectricalRecordMs to inhibit NVS commits.

Hardware overvoltage ALERT — VoltageHardwareLimit

Driven by updateINA228OvervoltageThreshold() — recomputed whenever BulkVoltage changes:

VoltageHardwareLimit = BulkVoltage + 0.3f;  // V
uint16_t thresholdLSB = (uint16_t)(VoltageHardwareLimit / 0.003125f);
INA.setBusOvervoltageTH(thresholdLSB);
INA.setDiagnoseAlertBit(INA228_DIAG_BUS_OVER_LIMIT);  // SLOW_ALERT mode

The chip's averaged bus voltage is compared against this threshold internally; the ALERT pin (active LOW, open-drain) physically pulls GPIO4 LOW when tripped. CheckAlarms() polls readINA228AlertRegister() every 250 ms and sets the software latch inaOvervoltageLatched. See Safeties → INA228 hardware ALERT for the full latch / recheck / suppress sequence.

Calling helpers

float getBatteryVoltage();  // returns IBV (INA228 — single source of truth for control)
float getBatteryCurrent();  // returns Bcur (INA228) or VictronCurrent (BatteryCurrentSource==3)
float getFiltV();           // returns IBV_filtered — display only, never safety
float getFiltI();           // returns MeasuredAmps_filtered

BatteryV (ADS1115) is never the safety signal — it exists only for the cross-sensor disagreement check in Safeties → Voltage Sensor Failure.


DS18B20 — Alternator Temperature (OneWire, GPIO13)

Runs entirely on Core 0 in TempTask (4 KB stack, priority 1). The full conversion takes ~750 ms at 12-bit resolution; doing it on Core 1 would destroy loop() timing and trip the watchdog.

Init pattern

sensors.begin(), setWaitForConversion(false), setCheckForConversion(true), getAddress(tempDeviceAddress, 0), setResolution(resolution). The target is 12-bit (0x7F config byte). If enumeration fails, sensorEnumerated stays false and the task retries every 5 s.

Per-iteration sequence

  1. HeartbeatlastTempTaskHeartbeat = now. Core 1's checkTempTaskHealth() watches this; 20 s silent fires the T4 buzzer-only alarm.
  2. OTA gate — if otaInProgress, the task self-deletes with vTaskDelete(NULL) so OTA gets exclusive Core 0 access.
  3. hardwarePresent == 0 — sleep 5 s, retry.
  4. Enumeration — if not yet enumerated, run sensors.begin() etc., tempEnumerateFailCount++ on failure.
  5. core0Busy gate — if set (an HTTPS request is in flight on Core 0), skip with tempCoreBusySkipCount++ and 1 s delay.
  6. Adaptive poll cadence — 5 s after a successful read, 1 s after a failure (lastReadWasSuccess branch). Skipped reads count tempStaleSkipCount.
  7. isConnected(tempDeviceAddress) check — if false, tempConnectedFailCount++, force re-enumeration on next pass.
  8. requestTemperaturesByAddress() — failure → tempRequestFailCount++, goto cleanup.
  9. Non-blocking conversion poll — loop with vTaskDelay(10 ms) until isConversionComplete(), timeout = sensors.millisToWaitForConversion(resolution) + 50 ms. Heartbeat updated inside the loop. OTA gate checked inside the loop.
  10. readScratchPad() — failure → tempReadFailCount++.

Five-check validation gauntlet

Check What Counter on fail
1 — CRC8 OneWire::crc8(scratchPad, 8) == scratchPad[8] with one immediate retry tempCrcFailCount (tempCrcRecoveredCount on retry-success)
2 — All-0xFF All 9 bytes 0xFF means sensor disconnected tempAllFFCount
3 — Power-on signature raw == 0x0550 means sensor reset and hasn't completed a conversion yet (returns 85 °C placeholder) tempPowerOn85Count
4 — Resolution drift scratchPad[4] != DS18B20_CFG_BYTE triggers setResolution(), requests a fresh conversion, re-reads tempResolutionFixCount, tempResolutionFixCrcFailCount, tempRereadFailCount
5 — Sanity range −50 °F < tempF < 300 °F tempOutOfRangeCount

A successful pass updates AlternatorTemperatureF, tempLastGoodF, tempLastSuccessMillis, the session/all-time max trackers, the ignition-cycle watermark, sets tempTaskHealthy = true, and calls MARK_FRESH(IDX_ALTERNATOR_TEMP).

Persistent-failure alert

5 consecutive non-success reads → "DS18B20 WARNING: 5+ consecutive read failures — check sensor connection" queued to the console, throttled to one per 60 s.

Why these counters matter

The Stats panel exposes every counter above so a flaky sensor (corroded crimp, marginal pull-up, EMI) shows its specific failure mode rather than just "temperature stale." tempCrcRecoveredCount rising faster than tempCrcFailCount is a noisy-bus signal even if the user-facing temperature never goes stale.


BMP388 — Ambient Temp + Barometric Pressure (I²C 0x76)

Configuration: OVERSAMPLING_X32 pressure, OVERSAMPLING_X2 temperature, IIR_FILTER_32. Forced-mode (one-shot) non-blocking state machine inside _ReadAnalogInputs_inner(). 8-second cycle — well inside the 10-second DATA_TIMEOUT.

State Action
BMP_IDLE If 8 s since last cycle, bmp388.startForcedConversion(), capture bmpTriggerMs, → BMP_WAIT_READY
BMP_WAIT_READY bmp388.getMeasurements() returns 0 until data is ready. On data, validate ranges (800 < hPa < 1100, −40 °C < temp < 85 °C), convert °C → °F for ambientTemp, MARK_FRESH(IDX_BARO_PRESSURE) / IDX_AMBIENT_TEMP. Discard first sample after boot. 120 ms timeout safety net (conversion should complete in ~16 ms) — queues a console warning throttled to one per 60 s

Use sites

  • ambientTemp feeds the alternator cooling thermal model and the heating-mode auto-fault for cold weather.
  • baroPressure is logged for cloud trip analytics (weather correlation).

LSM6DSOX — 6-Axis IMU (I²C, LSM6DSOX_ADDR)

Accel + gyro, FIFO mode, drained from Core 1 in drainIMUFifo() after AdjustFieldLearnMode() so a Wire stall on the IMU cannot delay control-critical reads.

Init (imuInit())

Hard-fails (sets imuEnabled = false) on any of:

  1. i2cProbe8bit(LSM6DSOX_ADDR) — no I²C ACK.
  2. WHO_AM_I (0x0F) != 0x6C — wrong chip on the bus.
  3. Library imu.begin() failure.
  4. Enable_X() / Enable_G() failure.
  5. Set_X_ODR(104) / Set_G_ODR(52) failure — Output Data Rates.
  6. Set_FIFO_X_BDR(104) / Set_FIFO_G_BDR(52) failure — FIFO batching rates must match ODRs.
  7. Set_FIFO_Mode(6) failure — continuous mode.

Accel at 104 Hz (~9.6 ms sample spacing) gives ≥ 2× margin on a 20–50 ms slam pulse. Gyro at 52 Hz is enough for sub-2 Hz roll/pitch dynamics. After config, the init reads 3 sample tags from the FIFO and prints their decoded sensor type to the serial console as an empirical verification.

Drain (drainIMUFifo())

Skipped if !imuEnabled or fewer than IMU_POLL_INTERVAL ms since last poll. Calls Get_FIFO_Num_Samples(), then Get_FIFO_Sample() for up to MAX_FIFO_DRAIN_PER_POLL = 6 samples at a time. Each 7-byte sample carries a tag (raw_tag >> 3) distinguishing TAG_SENSOR_ACCEL, TAG_SENSOR_GYRO, TAG_SENSOR_TEMP. Samples land in imuRingBuffer (PSRAM, ~30 KB total — separate accel and gyro head/tail pointers).

imuRecordI2CError() increments imu_i2c_error_count and tracks a 60-second sliding window. ≥ 10 errors in 60 s auto-disables imuEnabled with a console message.

Windowed metrics (updateAccelMetrics())

Runs from loop() when accelEnabled == 1. On the rising edge of accelEnabled, stale FIFO samples are flushed by aligning accel_tail and gyro_tail to their heads (avoids draining 2000 stale samples in one shot). Aggregates over a configurable window:

  • imu_heel_deg, imu_pitch_deg — complementary filter of accel + gyro.
  • imu_yaw_rate_dps — direct from gyro Z.
  • imu_vertical_accel_g, imu_total_accel_g — slam-detection inputs.
  • imu_msi_score — Motion Sickness Index (Lawther & Griffin 1987, frequency-weighted vertical accel RMS; 100 = severe).
  • imu_vomit_pct — estimated % of population vomiting after 2 h (L&G power-law approx).
  • imu_anchorage_comfort — heuristic comfort score 0–100; roll + MSI + slam weighted.
  • imu_heel_deviation_120s, imu_pitch_deviation_120s, imu_heading_swing_120s — peak deviation from rolling 2-min mean.
  • Persistent countersimu_capsize_count (> CAPSIZE_THRESHOLD_DEG = 120° for X axis), imu_pitchpole_count (> PITCHPOLE_THRESHOLD_DEG = 70°), imu_slam_count_lifetime (vertical accel > SLAM_THRESHOLD_G = 2.5 g). NVS-persistent.

All of the above feed CSV1 (for the live tipping diagram) and CSV2 (for the slow summary stats).


Data Freshness Indices (DataIndex enum)

Index Sensor source Use site
IDX_BATTERY_V ADS Ch0 Disagreement check
IDX_IBV, IDX_BCUR INA228 All control / safety
IDX_MEASURED_AMPS ADS Ch1 Output PID input; REASON_CURRENT_STALE if stale > 10 s
IDX_CHANNEL3V ADS Ch3 raw Diagnostics
IDX_RPM ADS Ch2 Field min-duty table, RPM gate
IDX_THERMISTOR_TEMP ADS Ch3 cooked Optional control source (TempSource == 1)
IDX_ALTERNATOR_TEMP DS18B20 (Core 0) Primary control source (TempSource == 0); T5 staleness gate
IDX_BARO_PRESSURE, IDX_AMBIENT_TEMP BMP388 Weather correlation
IDX_IMU LSM6DSOX Tipping/comfort metrics
IDX_DUTY_CYCLE, IDX_FIELD_VOLTS, IDX_FIELD_AMPS Internal (derived) Field telemetry — marked fresh whenever updateFieldTelemetry() runs
IDX_HEADING_NMEA, IDX_LATITUDE_NMEA, IDX_LONGITUDE_NMEA, IDX_SATELLITE_COUNT, IDX_COG_NMEA, IDX_SOG_NMEA, IDX_APPARENT_WIND_*, IDX_TRUE_WIND_*, IDX_LEEWAY, IDX_VMG NMEA2K / NMEA0183 UI display (greyed when stale)
IDX_VICTRON_VOLTAGE, IDX_VICTRON_CURRENT VE.Direct Optional secondary battery source
IDX_SOC_PERCENT, IDX_WIFI_STRENGTH, IDX_DYNAMIC_ALT_CURRENT_ZERO, IDX_CHARGING_MODE, IDX_TIME_TO_FULL_CHARGE, IDX_TIME_TO_FULL_DISCHARGE, IDX_DYNAMIC_SHUNT_GAIN Derived UI display

MAX_DATA_INDICES = 35. DATA_TIMEOUT = 10000 ms is the default; safety-critical use sites (current/temp staleness) check their own per-source thresholds.


Disconnect Flags

Each I²C device has a runtime-settable "disconnected" flag so a hardware failure doesn't crash the loop. Setting any of these makes the corresponding driver block early-return:

Flag Set by Behavior when set
ADS1115Disconnected 5 consecutive Wire failures (auto), or user toggle _ReadAnalogInputs_inner() skips ADS block entirely
INADisconnected User toggle INA228 read block skipped; voltage/current frozen at last value
imuEnabled = false imuInit() failure (auto), or 10+ I²C errors in 60 s (auto), or user toggle drainIMUFifo() early-returns
Temperature task Self-disables on enumeration failure (retries every 5 s) T5 staleness gate fires after 20 s without MARK_FRESH(IDX_ALTERNATOR_TEMP)

User toggles for ADS1115Disconnected and INADisconnected exist mainly for bench testing — fake data injection in ReadAnalogInputs_Fake() covers most dev scenarios cleaner.