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.0 → Channel1V; 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 × RPMScalingFactor → Channel2V → RPM. < 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 × 2 → Channel3V; 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:
ch1_record(millis())pushes the value intoch1Ring(5000-entry PSRAM ring → 2-min bucketed history viach1_compute_stats()).- EMA filters update:
MeasuredAmps_filtered(TC =InputFilterTC) for the iExcess supervisor;g_pidI_filtered(TC =OutputPIDFilterTC) for the inner PID input. iAmpRing[](I_RING_SIZE = 10) updated. Moving averagesg_iMA_N(lengthIExcessMA_N) andg_pidMA_N(lengthOutputPIDMA_N) recomputed.cvLog_tick(millis())writes a row to the CV log (pinned to actual sample arrival, not the control tick).- Watermarks
MeasuredAmpsMax(session) andMeasuredAmpsMax_AllTimeupdated; both bumplastElectricalRecordMswhich inhibits NVS commits for 5 s. ch1FreshFlag = true—AdjustFieldLearnMode()consumes this flag in its CH1 gate, divides byPidSampleDivisorto 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 bygetFiltV()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.- Watermarks —
IBVMax/PeakVoltage_AllTime/MinVoltage/MinVoltage_AllTime, pluswmIgnUpdate(wmIgn_IBV, …)andwmIgnUpdate(wmIgn_Bcur, …)for ignition-cycle hi/lo tracking. Each record bump toucheslastElectricalRecordMsto 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¶
- Heartbeat —
lastTempTaskHeartbeat = now. Core 1'scheckTempTaskHealth()watches this; 20 s silent fires the T4 buzzer-only alarm. - OTA gate — if
otaInProgress, the task self-deletes withvTaskDelete(NULL)so OTA gets exclusive Core 0 access. hardwarePresent == 0— sleep 5 s, retry.- Enumeration — if not yet enumerated, run
sensors.begin()etc.,tempEnumerateFailCount++on failure. core0Busygate — if set (an HTTPS request is in flight on Core 0), skip withtempCoreBusySkipCount++and 1 s delay.- Adaptive poll cadence — 5 s after a successful read, 1 s after a failure (
lastReadWasSuccessbranch). Skipped reads counttempStaleSkipCount. isConnected(tempDeviceAddress)check — if false,tempConnectedFailCount++, force re-enumeration on next pass.requestTemperaturesByAddress()— failure →tempRequestFailCount++, goto cleanup.- Non-blocking conversion poll — loop with
vTaskDelay(10 ms)untilisConversionComplete(), timeout =sensors.millisToWaitForConversion(resolution) + 50 ms. Heartbeat updated inside the loop. OTA gate checked inside the loop. 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¶
ambientTempfeeds the alternator cooling thermal model and the heating-mode auto-fault for cold weather.baroPressureis 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:
i2cProbe8bit(LSM6DSOX_ADDR)— no I²C ACK.WHO_AM_I (0x0F) != 0x6C— wrong chip on the bus.- Library
imu.begin()failure. Enable_X()/Enable_G()failure.Set_X_ODR(104)/Set_G_ODR(52)failure — Output Data Rates.Set_FIFO_X_BDR(104)/Set_FIFO_G_BDR(52)failure — FIFO batching rates must match ODRs.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 counters —
imu_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.