Skip to content

Safeties and Protections — Xregulator

This document covers what happens when voltage, current, or temperature exceed safe limits. It is written for a technical reader and distinguishes between magic numbers (hardcoded in firmware) and user-adjustable settings (persisted to Flash Memory (LittleFS)). Timing is discussed in detail because the layered protection system has staggered response latencies, and knowing the order matters when several layers trip simultaneously.


Voltage Sources and Notation

Two voltage sensors are active:

  • BatteryV — ADS1115 (external ADC on I²C). Sampled ~16 Hz via a 4-state state machine. Freshness is flagged by ch1FreshFlag.
  • IBV — INA228 bus voltage. Read asynchronously as part of the INA228 task block, typically every ~200 ms in steady state.

The active voltage used by most protection logic is returned by getBatteryVoltage(), which used to have options, but now is hardcoded to ADS1115. The INA228 hardware ALERT mechanism always uses IBV directly and is independent.


Protection Layers — Overvoltage

Five distinct layers handle overvoltage, ordered here from fastest/hardest to slowest/softest. They can stack: multiple layers may fire on the same event.


Layer 0 — Fast OV Supervisor (Software Setpoint Cap)

What it does: Reduces the commanded current setpoint ceiling before voltage reaches the spike threshold. Does not cut the field. Only active during CV-controlled stages (absorption, float, TargetVoltageMode).

Why not in Bulk? In Bulk, the system is constant-current and battery voltage rising toward BulkVoltage is normal charging behavior, not overshoot. Applying this supervisor at BulkVoltage + 0.08V would prematurely cap current in the final approach to absorption — exactly when full current is wanted. The supervisor only makes sense when there is an active voltage regulation target to protect. Layer 1 (Voltage Spike) already catches gross overshoot in all stages including Bulk.

Trigger voltage (relative to ChargingVoltageTarget):

Zone Threshold above target Constant type
Soft cap +0.08 V Magic number (V_SOFT)
Hard cap +0.15 V Magic number (V_HARD)
Hysteresis lock +0.08 V raw (no dvdt) Magic number (HARD_CLAMP_HYST)
Prediction guard Only fires when BatteryV > target − 0.06 V Magic number (PRED_GUARD)

How it works: A derivative of BatteryV (dvdt) is computed each time a fresh ADS1115 sample arrives, then passed through an exponential moving average with α = 0.08 (heavily filtered, ~12-sample time constant ≈ 750 ms). A predicted voltage is computed as:

Vpred = BatteryV + 0.08 s × dvdt

The 80 ms prediction horizon (TD_PRED) is a magic number. If Vpred exceeds V_SOFT or V_HARD, the ceiling on the current setpoint (fastOvCurrentCap) is reduced proportionally:

Soft cap:  fastOvCurrentCap -= K_SOFT × (Vpred − V_SOFT)   [K_SOFT = 12, magic]
Hard cap:  fastOvCurrentCap -= K_HARD × (Vpred − V_HARD)   [K_HARD = 35, magic]

The hysteresis zone (raw BatteryV > target + 0.08 V) applies the hard cap without needing dvdt, to keep the governor bypass latched during descent.

Timing: Runs every loop iteration before the ADS1115 freshness gate, so effectively every main loop cycle (sub-millisecond cadence). dvdt is only updated when a fresh voltage sample arrives (~16 Hz).

Side effect: When the supervisor is active (fastOvClampActive), the duty slew governor switches to GOV_BYPASS_SLEW, removing the normal 80%/s duty slew rate limit so the inner PID can collapse the field faster.

Recovery: When BatteryV drops back to ChargingVoltageTarget and Vpred ≤ V_SOFT, the cap releases and seeds cv_I for smooth handoff.

Modes where active: AUTO only (requires voltageControlActive = true). Not active in MANUAL, LIMP HOME, or DISABLED.


Layer 1 — Voltage Spike Warning → Controlled Ramp-Down + 30 s Lockout

What it does: Detects sustained overvoltage above the bulk target plus a margin; initiates a controlled field ramp-down, cuts GPIO4, then imposes a lockout before allowing restart.

Trigger:

getBatteryVoltage() > BulkVoltage + VoltageSpikeMargin
  • BulkVoltage — User adjustable (default: 14.5 V for a 12 V system).
  • VoltageSpikeMargin — User adjustable (default: 0.3 V). Stored in Flash Memory (LittleFS). For a 12 V system with defaults, this fires at 14.8 V.

Detection latency: AdjustFieldLearnMode() runs the control path on fresh ADS1115 samples (~16 Hz, ~62 ms between samples). selectFieldControlMode() checks this condition every control tick. Worst-case detection after the voltage crosses threshold is one ADS sample period (~62 ms) plus any loop jitter.

Shutdown sequence (non-critical fault path):

Phase 1:  Ramp field duty down to rpmMinDuty (governed by DutyRampRate)
Phase 3:  Slow ramp from rpmMinDuty to 0% (governed by DutySlowRampRate)
Phase 4:  Hold at 0% duty for SettleTimeBeforeCut (1000 ms, magic number),
          then pull GPIO4 LOW

ShutdownPhase2HoldMs defaults to 0 (Phase 2 is skipped). Both ramp rates (DutyRampRate, DutySlowRampRate) and SettleTimeBeforeCut are declared as globals but not currently persisted to Flash Memory (LittleFS) — treat them as effectively hardcoded unless changed in firmware.

After GPIO4 cut: FIELD_COLLAPSE_DELAY lockout begins (hardcoded: 30 s, magic number). No charging attempt during lockout. After 30 s, the system re-evaluates and may resume if the condition cleared.

Slew bypass for major OV: If voltage is far above the spike threshold, the slew governor is independently bypassed:

BatteryV > BulkVoltage + VoltageSpikeMargin + 0.5 V  →  GOV_BYPASS_SLEW

The 0.5 V here is a magic number (separate from VoltageSpikeMargin). For default 12 V settings: > 15.1 V bypasses slew. This allows the duty to collapse faster without the normal rate limit.

Alarm: GPIO21 (buzzer) is driven by CheckAlarms(), which runs every 250 ms. If VoltageAlarmHigh is set and AlarmActivate = 1, the alarm fires as soon as voltage exceeds VoltageAlarmHigh (user adjustable, separate from VoltageSpikeMargin). The alarm and the field shutdown are parallel actions.

Modes where active: - AUTO: Full protection applies. - MANUAL: NOT active. Manual mode (ManualFieldToggle = 1) has priority 2 in selectFieldControlMode(), above the warning check at priority 4. Voltage spike will not trigger a ramp-down in manual mode. - LIMP HOME: NOT active (LimpHome bypasses the entire selectFieldControlMode() path).


Layer 2 — INA228 Hardware Overvoltage (ALERT Pin → Physical GPIO4 Cut)

This is the fastest and most direct protection. It operates at the hardware level independent of firmware execution time.

Threshold:

VoltageHardwareLimit = BulkVoltage + 0.1 V

This is computed at startup and updated via updateINA228OvervoltageThreshold(). It is not directly user-adjustable (the variable is read-only from the UI; the comment in firmware says "could make this a setting later"). It can be indirectly changed by changing BulkVoltage. For a 12 V system with BulkVoltage = 14.5 V, the hardware limit is 14.6 V.

Filter mode: The INA228 is configured in SLOW_ALERT mode, which means the threshold comparison uses the chip's internal averaged value (~4-sample average at the configured conversion time, approximately 33 ms of filtering). A single instantaneous spike will not assert the ALERT pin; the averaged voltage must exceed the threshold. This is intentional noise rejection — but it means the hardware response is not truly instantaneous; it is delayed by roughly one averaging window (~33 ms).

Note on a comment inconsistency in firmware: The comment in updateINA228OvervoltageThreshold() (line 1930) says "Compare on instantaneous (non-averaged) readings for immediate response," which contradicts what SLOW_ALERT actually does. The comment in CheckAlarms() is correct: SLOW_ALERT uses the averaged value.

Hardware mechanism:

IBV (averaged) > VoltageHardwareLimit
  → INA228 ALERT pin asserts (open-drain, active LOW)
  → GPIO4 pulled LOW physically
  → Gate driver loses power
  → Alternator field collapses

This happens entirely in hardware. The firmware does not need to run. GPIO4 is pulled LOW before any software layer can respond.

Software latch (in CheckAlarms(), runs every 250 ms):

The firmware must separately detect that the ALERT pin fired and engage a software latch — otherwise the field would re-enable immediately the next time AdjustFieldLearnMode() runs and calls digitalWrite(4, HIGH).

Timing Action
Hardware ALERT fires GPIO4 pulled LOW physically (hardware, ~33 ms latency from threshold crossing)
Up to 5 s later CheckAlarms() polls INA228 ALERT register (new event detection is throttled to every 5 s)
When detected inaOvervoltageLatched = true, alarm fires on GPIO21
+3 s after latch Early check: reads BUSOL bit. If cleared → transient spike, latch released. If still set → wait for 10 s.
+10 s after latch Definitive check: if cleared → latch released. If still set → reset timer, recheck in 10 s.
While latched GPIO4 stays LOW (enforced by pre-gate check in AdjustFieldLearnMode()). Alarm buzzer active.
After latch releases INA_OV_DISAGREE_SUPPRESS_MS (10 s, magic number) of voltage disagreement suppression to prevent false Layer 3/4 triggers as sensors resettle.

Software action (shouldImmediatelyCutGPIO4()):

REASON_INA_OVERVOLTAGE → shouldImmediatelyCutGPIO4() = true

applyImmediateCut() is called, which: sets GPIO4 LOW in software (hardware may have already done this), zeros PWM, resets PID integrator, sets sysMode = SYS_MODE_FAULT, logs event including whether hardware or software caught it first.

Modes where active: - AUTO: Full protection — hardware and software latch both active. - MANUAL: Hardware ALERT pin fires regardless (physical). Software latch also fires: selectFieldEventReason() checks inaOvervoltageLatched at Priority 1, before the manual mode check. The pre-gate cut in AdjustFieldLearnMode() (lines 506–510) runs before the manual mode path. Manual mode does NOT bypass Layer 2. - LIMP HOME: Hardware ALERT pin fires regardless. LimpHome code explicitly checks !inaOvervoltageLatched before calling digitalWrite(4, HIGH). Additionally, the pre-gate cut at lines 506–510 runs before the LimpHome block at line 637 — so if the latch is active, applyImmediateCut() fires and the function returns before reaching the LimpHome path. Despite the comment "BYPASSES ALL SAFETY SYSTEMS," LIMP HOME does not bypass Layer 2. - INA228 disconnected (INADisconnected = 1): The software latch section is skipped entirely. The hardware ALERT mechanism is still wired physically, but if the INA228 is truly disconnected, no threshold comparison occurs.


Layer 3 — Voltage Sensor Disagreement Warning → Ramp-Down + Lockout

What it does: Detects a sustained divergence between ADS1115 (BatteryV) and INA228 (IBV) and treats it as a fault condition, since field control decisions cannot be trusted if the two voltage sources disagree too much for too long.

Trigger:

|BatteryV − IBV| > VoltageDisagreeThreshold  sustained for VoltageDisagreeTimeout
  • VoltageDisagreeThreshold — User adjustable (default: 0.15 V, stored in Flash Memory (LittleFS)).
  • VoltageDisagreeTimeoutMagic number, hardcoded 10 s. Not user adjustable.

Action: MODE_WARNING_RAMP_AND_LOCKOUT — same ramp-down sequence as Layer 1 (Phase 1 → Phase 3 → Phase 4 settle → GPIO4 LOW → 30 s lockout).

Suppression: This check is suppressed during active INA228 OV events and for 10 s after the latch clears (INA_OV_DISAGREE_SUPPRESS_MS). When the ALERT pin cuts the field, ADS1115 and INA228 naturally diverge as the field collapses (ADS responds faster), which would otherwise trigger a false disagreement.

Modes where active: AUTO only. Manual mode priority overrides this check.


Layer 4 — Voltage Sensor Disagreement Critical → Immediate Ramp-Down, No Recovery Hold

What it does: Handles a much larger disagreement between sensors, implying complete sensor failure rather than a transient.

Trigger:

|BatteryV − IBV| > critical_threshold  sustained for VoltageDisagreeCriticalTimeoutMs (3 s)

Critical threshold scales with system voltage (all magic numbers):

System Critical threshold
12 V (BulkVoltage < 18) 1.0 V
24 V (BulkVoltage < 36) 2.0 V
48 V (BulkVoltage ≥ 36) 4.0 V
  • VoltageDisagreeCriticalTimeoutMsMagic number, hardcoded 3 s.

Action: MODE_CRITICAL_RAMP with isCriticalFault = true. The shutdown sequence skips Phases 1 and 3 entirely and jumps directly to Phase 4 (hold at 0% duty, settle for SettleTimeBeforeCut, cut GPIO4). No lockout timer is imposed — the system assumes sensor data cannot be trusted and does not attempt automatic restart.

Modes where active: AUTO only.


Layer 5 — Voltage Sensor Implausibility → Immediate Phase 4

What it does: If both voltage sensors read completely out of range (likely a wiring or hardware failure rather than actual voltage), the system cannot make any safe field control decision.

Trigger: isVoltageSensorPlausible() returns false when both BatteryV and IBV are outside the plausible range. The range is auto-derived from BulkVoltage with a fixed 0.5 V buffer (magic number):

System Min plausible Max plausible
12 V 4.5 V 15.5 V
24 V 9.0 V 30.5 V
48 V 18.0 V 60.5 V

If at least one sensor reads within range, voltagePlausible = true. If both are out of range: voltagePlausible = false → MODE_CRITICAL_RAMP, isCriticalFault=true → immediate Phase 4 → GPIO4 cut.


Summary: Voltage OV Threshold Cascade (12 V System, Default Settings)

Voltage Layer Type Action
ChargingVoltageTarget + 0.08 V 0 — Fast OV Soft SW, every loop Begins reducing current setpoint ceiling
ChargingVoltageTarget + 0.15 V 0 — Fast OV Hard SW, every loop Aggressively caps setpoint; governor bypass
14.6 V (BulkVoltage + 0.1) 2 — INA228 HW ALERT HW physical GPIO4 pulled LOW immediately (hardware)
14.8 V (BulkVoltage + 0.3) 1 — Voltage Spike SW, ~62 ms Field ramp-down → GPIO4 cut → 30 s lockout
15.1 V (BulkVoltage + 0.8) Slew bypass SW GOV_BYPASS_SLEW — field can collapse faster
15.5 V 5 — Implausibility SW Both sensors OOR → immediate GPIO4 cut

Note: Layers 0 and 1 use ChargingVoltageTarget (the active CV target, which may be less than BulkVoltage during float) while Layers 1 and 2's thresholds reference BulkVoltage. They are not the same number.


What Happens When Multiple Layers Fire Simultaneously

This is the common case during a real OV event.

Typical sequence for a real transient spike (e.g., load dump):

  1. t = 0 ms: IBV averaged value crosses VoltageHardwareLimit (14.6 V) after ~33 ms averaging window.
  2. t ≈ 33 ms: INA228 ALERT pin asserts → GPIO4 pulled LOW physically → gate driver off → field starts collapsing.
  3. t ≈ 62 ms: ADS1115 fresh sample arrives; BatteryV > 14.8 V detected → selectFieldControlMode() → MODE_WARNING_RAMP_AND_LOCKOUT. applyImmediateCut() is also called via the pre-gate check since inaOvervoltageLatched may not be set yet (software latch lag). If GPIO4 was already cut by hardware, applyImmediateCut() detects this (alreadyCut = true) and skips redundant state writes to avoid log spam.
  4. t = 0–5000 ms: CheckAlarms() running every 250 ms. New event detection polls every 5 s → inaOvervoltageLatched = true set at some point within 5 s. GPIO21 alarm fires. Console message logged with "HW ALERT pin fired first; SW latch active."
  5. t = latch + 3000 ms: Early clear check. If IBV has dropped below VoltageHardwareLimit: latch released. 10 s disagreement suppression begins (to prevent Layer 3/4 from firing on the divergent sensors).
  6. t = latch + 10000 ms: Definitive check. If still elevated: latch holds, rechecks in another 10 s.

While Layer 2 latch is held:

  • GPIO4 stays LOW (enforced by pre-gate check every control tick).
  • Layer 1 ramp-down sequence is moot — GPIO4 is already LOW.
  • Layer 3/4 disagreement checks are suppressed.
  • Layer 0 fast OV supervisor is irrelevant (GPIO4 is LOW, no field current).
  • Alarm buzzer active.

If Layer 1 fires before Layer 2 catches up (software before hardware):

The ramp-down sequence (Phases 1, 3, 4) runs. If voltage is still high when Phase 4 completes and SettleTimeBeforeCut has elapsed, shouldCutGPIO4AfterSettle() returns true and GPIO4 is cut in software. Meanwhile, the INA228 averaged value continues rising and eventually the hardware ALERT may fire as well. At that point applyImmediateCut() is called but detects alreadyCut = true and only sets the software latch state.

If both Layer 1 and the INA228 latch are active when voltage clears:

Layer 1 re-evaluation: after the 30 s lockout, selectFieldControlMode() re-runs. If inaOvervoltageLatched is still true, REASON_INA_OVERVOLTAGE is at Priority 1 and shouldImmediatelyCutGPIO4() keeps GPIO4 LOW regardless of the lockout clearing. The 30 s lockout timer runs in parallel but does not gate on the INA228 latch. Charging will not resume until the INA228 latch releases (at the 3 s or 10 s check).


Per-Mode Behavior Summary

Layers at a Glance

Layer Sensor Check Frequency Trigger Threshold Action
0 — Fast OV Supervisor ADS1115 BatteryV + dvdt prediction Every main loop (sub-ms) Vpred > ChargingVoltageTarget + 0.08 V (soft) or + 0.15 V (hard) Reduces current setpoint ceiling; enables GOV_BYPASS_SLEW. No field cut.
1 — Voltage Spike ADS1115 BatteryV ~62 ms (16 Hz ADS) BatteryV > BulkVoltage + VoltageSpikeMargin (default 14.8 V) Phase 1 → 3 → 4 ramp-down → GPIO4 LOW → 30 s lockout
2 — INA228 HW ALERT INA228 IBV (4-sample avg, ~33 ms) Continuous hardware IBV(avg) > BulkVoltage + 0.1 V (default 14.6 V) GPIO4 pulled LOW physically by ALERT pin. No firmware required.
2 — INA228 SW latch INA228 ALERT register Every 5 s (new event detect); every 250 ms (latch mgmt) BUSOL bit set applyImmediateCut(): GPIO4 LOW, PWM 0, PID reset. 3 s / 10 s clear checks.
3 — Disagree Warning BatteryV vs IBV ~62 ms (control tick) \|BatteryV − IBV\| > 0.15 V sustained 10 s Phase 1 → 3 → 4 ramp-down → GPIO4 LOW → 30 s lockout
4 — Disagree Critical BatteryV vs IBV ~62 ms (control tick) \|BatteryV − IBV\| > 1.0 V (12 V sys) sustained 3 s Phase 4 only (no ramp) → GPIO4 LOW. No auto-restart.
5 — Implausibility BatteryV and IBV ~62 ms (control tick) Both sensors out of plausible range (e.g. > 15.5 V or < 4.5 V for 12 V sys) Phase 4 only → GPIO4 LOW. No auto-restart.
Alarm buzzer getBatteryVoltage() Every 250 ms (CheckAlarms) BatteryV > VoltageAlarmHigh (if set) GPIO21 HIGH. Parallel to field shutdown; does not cut field on its own.

Mode Matrix

Protection Layer AUTO MANUAL LIMP HOME
Layer 0 — Fast OV Supervisor Active (CV stages only) Not active Not active
Layer 1 — Voltage Spike Ramp Active Bypassed Bypassed
Layer 2 — INA228 HW ALERT (physical) Active Active Active
Layer 2 — INA228 SW latch Active Active (Priority 1) Active (checked inline + pre-gate)
Layer 3 — Disagree Warning Active Bypassed Bypassed
Layer 4 — Disagree Critical Active Bypassed Bypassed
Layer 5 — Implausibility Active Bypassed Bypassed
Alarm buzzer (GPIO21) If AlarmActivate=1 If AlarmActivate=1 If AlarmActivate=1

Manual mode note: "Bypassed" means selectFieldControlMode() returns MODE_NORMAL_MANUAL at Priority 2, before voltage checks at Priorities 3–4. The user accepts full responsibility for field control. The INA228 hardware and its software latch remain active because selectFieldEventReason() evaluates inaOvervoltageLatched at Priority 1, before the manual mode check at Priority 2.

Limp home note: Limp Home does not bypass Layer 2. The pre-gate cut check in AdjustFieldLearnMode() runs at lines 506–510, before the LimpHome block at line 637. If inaOvervoltageLatched is true, applyImmediateCut() fires and the function returns early, never reaching the LimpHome path.



Protection Layers — Temperature

Temperature protection is the most layered set of safeties in the system. There are five distinct mechanisms that escalate in severity, from a gradual current reduction all the way to an immediate GPIO4 cut. They interact with each other and with the voltage protections described above.

Temperature Sources

Two sensors are supported, selected by TempSource:

  • TempSource = 0 (default): OneWire DS18B20 digital sensor, read by TempTask running on Core 0. Result lands in AlternatorTemperatureF.
  • TempSource = 1: Thermistor on ADS1115 channel 3 (CH3), filtered inside tempPID_tick(). Result lands in temperatureThermistor.

The active reading is mirrored to TempToUse each control tick via buildTickSnapshot(). If IgnoreTemperature = 1, all software temperature protections (Layers T0 through T3 below) are disabled. The hardware watchdog and TempTask failure alarm are unaffected by this flag.


Layer T0 — Thermal PID (Outer Loop Current Derate)

What it does: Continuously and smoothly reduces the current setpoint as temperature approaches the limit. This is not a fault response — it is the normal steady-state thermal management path. It activates before any alarm or shutdown threshold is reached.

Trigger: The outer thermal PID targets TemperatureLimitF − TempPIDMarginF. It starts derating current whenever temperature climbs above that setpoint.

  • TemperatureLimitF — User adjustable (default: 150 °F). Note: the sensor is typically mounted on the alternator case exterior; actual internal winding temperature may be 40–50 °F higher than this reading.
  • TempPIDMarginF — User adjustable (default: 15 °F). The PID setpoint is TemperatureLimitF − 15 °F = 135 °F by default. Derating begins when temperature crosses this setpoint, not at the limit itself.

How it works: tempPID_tick() runs every inner loop call (~16 Hz via ADS1115 CH1 gate) but only computes a new penalty every TempPIDIntervalMs (user adjustable, default 5 s). The penalty (thermalPenaltyAmps) is subtracted from the RPM-table current cap before it reaches the inner PID:

I_cap = getCapCurrentForRPM(RPM)          ← RPM-table ceiling
I_cmd = I_cap − thermalPenaltyAmps        ← after thermal derate
uTargetAmps = clamp(I_cmd, 0, MaxTableValue)

PID tuning (all user adjustable):

Parameter Default Notes
TempPIDKp 3.0 A/°F Proportional gain
TempPIDKi 0.025 A/°F/s Integral gain
TempPIDKd 0.0 Internal derivative (disabled by default)
TempPIDKdExternal 200.0 A·s/°F External D term using a 20-s ring buffer (6 × 5 s samples). Provides predictive feel — starts derating before temp reaches setpoint if rising fast. Magic multiplier.
TempPIDFilterAlpha 0.2 IIR smoothing on temp input (α = 0.2 → ~4-sample effective window)
TempPIDIntervalMs 5000 ms Outer loop compute cadence

Slew limiting (magic numbers): The penalty output is asymmetrically slew-limited to prevent sudden current steps. Rise rate (increasing penalty = more derate) and fall rate (decreasing penalty = recovering) are governed by ThermalPenaltyRiseRate and ThermalPenaltyFallRate.

Stale temp handling: If the temperature reading is older than TempPIDStaleMs (user adjustable, default 15 s) or is NaN/out of range, the PID halts and holds the last valid penalty. It does not zero the penalty — it holds it. This prevents a sensor dropout from suddenly releasing a thermal derate that was actively protecting the alternator.

Modes where active: AUTO only. MANUAL and LIMP HOME bypass this layer entirely.


Layer T1 — Temperature Warning → Ramp-Down + 30 s Lockout

What it does: When temperature exceeds the limit by more than a warning margin, the field is ramped down and the system enters a 30 s lockout.

Trigger:

TempToUse > TemperatureLimitF + TempWarnExcess

  • TempWarnExcess — User adjustable (default: 10 °F). For default settings: fires at 160 °F.

Detection latency: selectFieldControlMode() evaluates this every control tick (~62 ms ADS1115 cadence).

Action: MODE_WARNING_RAMP_AND_LOCKOUT — same Phase 1 → 3 → 4 ramp-down sequence as the voltage spike, ending in GPIO4 LOW and a 30 s lockout. After the lockout, if temperature has dropped below the threshold, charging resumes. If still above, the cycle repeats.

Modes where active: AUTO only.


Layer T2 — Temperature Sustained Warning → GPIO4 Cut, No Auto-Restart

What it does: If temperature has been continuously above the warning threshold for longer than a timeout, it escalates from a lockout-and-retry to a hard GPIO4 cut with no automatic recovery.

Trigger:

TempToUse > TemperatureLimitF + TempWarnExcess  sustained for TempSustainedTimeout

  • TempSustainedTimeoutMagic number, hardcoded 120 s (2 minutes). Not user adjustable.
  • Timer resets if temperature drops below the threshold at any point.

Action: REASON_TEMP_SUSTAINEDshouldCutGPIO4AfterSettle() returns true after Phase 4 settle. GPIO4 cut, no lockout timer. Field stays off until the system is manually re-enabled or rebooted.

Modes where active: AUTO only.


Layer T3 — Temperature Critical → Immediate GPIO4 Cut

What it does: Skips all ramp-down phases and cuts GPIO4 immediately.

Trigger:

TempToUse > TemperatureLimitF + TempCritExcess

  • TempCritExcess — User adjustable (default: 30 °F). For default settings: fires at 180 °F.

Detection latency: ~62 ms (next ADS1115 control tick). Same selectFieldEventReason() priority 3 check.

Action: REASON_TEMP_CRITICALshouldImmediatelyCutGPIO4() returns true → applyImmediateCut(): GPIO4 LOW immediately, PWM zeroed, PID reset. No ramp. No lockout timer.

Modes where active: AUTO only. Note: this check sits at Priority 3 in selectFieldEventReason(), which is evaluated after INA228 OV (Priority 1) and hard OC (Priority 1), but before manual mode (Priority 2 in selectFieldControlMode()). CRITICAL: Manual mode does not bypass REASON_TEMP_CRITICAL. The pre-gate cut in AdjustFieldLearnMode() evaluates selectFieldEventReason() before the manual path, so even in manual mode, a critical temperature will cut GPIO4 immediately.


Layer T4 — TempTask Failure Alarm

What it does: Detects that the OneWire temperature reading task (TempTask) has stopped responding and triggers an independent safety response.

How it works: TempTask runs on Core 0 and updates lastTempTaskHeartbeat with each read cycle. checkTempTaskHealth() runs every 5 s on the main loop and compares millis() against lastTempTaskHeartbeat.

Trigger: millis() − lastTempTaskHeartbeat > TEMP_TASK_TIMEOUT - TEMP_TASK_TIMEOUTMagic number, hardcoded 20 s.

Action (if IgnoreTemperature = 0): 1. tempTaskHealthy = false, tempTaskAlarm = true 2. GPIO4 pulled LOW immediately (digitalWrite(4, 0)) 3. dutyCycle reset to MinDuty 4. Alarm buzzer fires via CheckAlarms() regardless of AlarmActivate setting — tempTaskAlarm bypasses the AlarmActivate gate

Recovery: When TempTask resumes heartbeating, tempTaskHealthy = true, tempTaskAlarm = false. The field is not automatically re-enabled — this is noted as a known gap in the firmware (// Later, does the field need to be re-enabled here??? Fix later).

Modes where active: All modes (including MANUAL) when IgnoreTemperature = 0. This is purely a task health monitor — it does not go through selectFieldEventReason().


Layer T5 — Temperature Data Staleness → Critical Ramp

What it does: Detects that the temperature data timestamp is too old and treats it the same as a critical fault, because it cannot be known whether the alternator is dangerously hot.

Trigger:

tempDataVeryStale = true

Staleness is determined in buildTickSnapshot(): - If dataTimestamps[tempTimestampIdx] == 0 and millis() > 60 s → stale (sensor never produced a reading) - If millis() − tempTimestamp > 30000 → stale - If temp value is NaN or outside −50 °F to 400 °F → treated as stale - 30 s staleness timeout is a magic number

Action: REASON_TEMP_STALEMODE_CRITICAL_RAMP, isCriticalFault = true → Phase 4 immediately (no ramp), GPIO4 cut after settle. No auto-restart.

Modes where active: AUTO only (manual mode takes priority 2 over this priority 3 check).


Temperature Protection — Summary Table

Layer Check Frequency Trigger Action AUTO MANUAL
T0 — Thermal PID derate Every 5 s (compute); ~16 Hz (slew) Temp > TemperatureLimitF − TempPIDMarginF (default 135 °F) Reduces current setpoint gradually Active Not active
T1 — Warning ramp ~62 ms Temp > TemperatureLimitF + TempWarnExcess (default 160 °F) Phase 1→3→4 ramp → GPIO4 LOW → 30 s lockout Active Not active
T2 — Sustained warning ~62 ms (timer started at T1) T1 condition sustained > 120 s GPIO4 cut, no auto-restart Active Not active
T3 — Critical cut ~62 ms Temp > TemperatureLimitF + TempCritExcess (default 180 °F) Immediate GPIO4 cut Active Active
T4 — TempTask failure Every 5 s No heartbeat for > 20 s GPIO4 LOW, alarm unconditional Active Active
T5 — Data staleness ~62 ms Temp data > 30 s old or invalid Phase 4 → GPIO4 cut, no auto-restart Active Not active
Alarm buzzer Every 250 ms Temp > TempAlarm (user, default 190 °F) GPIO21 HIGH. Field unaffected. If AlarmActivate=1 If AlarmActivate=1

Temperature: User-Adjustable vs. Magic Numbers

Parameter Type Default Notes
TemperatureLimitF User adjustable 150 °F Base limit; all thresholds are relative to this
TempPIDMarginF User adjustable 15 °F PID starts derating at limit − 15 °F
TempWarnExcess User adjustable 10 °F Layer T1 trigger above limit
TempCritExcess User adjustable 30 °F Layer T3 immediate cut above limit
TempAlarm User adjustable 190 °F Buzzer only, no field action
TempPIDKp/Ki/Kd User adjustable 3.0 / 0.025 / 0.0 Outer PID gains
TempPIDIntervalMs User adjustable 5000 ms Outer loop compute period
TempPIDStaleMs User adjustable 15000 ms Hold-last threshold
TempSustainedTimeout Magic number 120 s Layer T2 escalation timer
TEMP_TASK_TIMEOUT Magic number 20 s TempTask heartbeat deadline
Staleness timeout Magic number 30 s Age before temp data treated as stale
TempPIDKdExternal User adjustable 200 External 20-s derivative gain

Protection Layers — Overcurrent

Two independent current protection mechanisms exist: a hard overcurrent trip that acts like a fuse, and an alarm-only path that notifies without cutting the field.


Layer OC1 — Hard Overcurrent Trip → Immediate GPIO4 Cut

What it does: Acts as an electronic fuse for the alternator output current. If measured alternator amps exceed a trip threshold for longer than a debounce window, GPIO4 is cut immediately — no ramp, no phase sequence.

Sensor: MeasuredAmps from ADS1115 channel 1 (CH1), the alternator shunt reading. Note: this is alternator output current, not battery current.

Trigger:

MeasuredAmps > HardOCTripAmps  for at least HardOCDebounceMs

  • HardOCTripAmpsMagic number, hardcoded 180 A. Not user adjustable.
  • HardOCDebounceMsMagic number, hardcoded 20 ms. Prevents single-sample noise spikes from tripping the fuse. At the ~16 Hz ADS1115 rate, a single sample is ~62 ms — so this debounce is effectively always satisfied by the first sample above threshold. It exists primarily to guard against sub-loop noise.

Detection: Evaluated in both selectFieldEventReason() (priority 1, same level as INA228 OV) and at the pre-gate check in AdjustFieldLearnMode() before the CH1 freshness gate. This means it is checked on every loop iteration.

Action: REASON_HARD_OVERCURRENTshouldImmediatelyCutGPIO4() returns true → applyImmediateCut(): GPIO4 LOW, PWM zeroed, PID integrator reset, sysMode = SYS_MODE_FAULT.

After the immediate cut, shouldCutGPIO4AfterSettle() also returns true for REASON_HARD_OVERCURRENT, meaning GPIO4 stays LOW through the Phase 4 settle period (1 s). No automatic restart.

Modes where active: ALL modes including MANUAL. Hard OC is Priority 1 in selectFieldEventReason(), evaluated before the manual mode check. Manual mode does not bypass this.


Layer OC2 — Alarm-Only Current Limits (No Field Action)

Two current alarm thresholds exist that trigger the buzzer but do not cut or ramp the field:

Alternator current alarm: - MeasuredAmps > CurrentAlarmHigh - CurrentAlarmHigh — User adjustable (default: 100 A). - Fires buzzer via CheckAlarms() if AlarmActivate = 1. No field action.

Battery current alarm: - abs(Bcur) > MaximumAllowedBatteryAmps - MaximumAllowedBatteryAmps — User adjustable (default: 150 A). - Bcur is from the INA228 shunt — measures net battery charge/discharge current, not alternator output. - Fires buzzer only. No field action.

Both alarms are throttled to one console message every 30 s to avoid log spam.

Check frequency: Every 250 ms (CheckAlarms() cadence).


Overcurrent Summary Table

Layer Sensor Check Frequency Trigger Action AUTO MANUAL
OC1 — Hard OC trip ADS1115 MeasuredAmps Every loop (~sub-ms pre-gate) MeasuredAmps > 180 A for ≥ 20 ms Immediate GPIO4 cut, no ramp Active Active
OC2 — Alt current alarm ADS1115 MeasuredAmps Every 250 ms MeasuredAmps > CurrentAlarmHigh (default 100 A) Buzzer only If AlarmActivate=1 If AlarmActivate=1
OC2 — Battery current alarm INA228 Bcur Every 250 ms \|Bcur\| > MaximumAllowedBatteryAmps (default 150 A) Buzzer only If AlarmActivate=1 If AlarmActivate=1

Overcurrent: User-Adjustable vs. Magic Numbers

Parameter Type Default Notes
CurrentAlarmHigh User adjustable 100 A Alternator current alarm threshold (buzzer only)
MaximumAllowedBatteryAmps User adjustable 150 A Battery current alarm threshold (buzzer only)
HardOCTripAmps Magic number 180 A Hard OC trip — immediate GPIO4 cut
HardOCDebounceMs Magic number 20 ms Debounce before hard OC fires

System-Level Safeties

Watchdog Timer

The ESP32 main loop is protected by a hardware watchdog timer. If the main loop hangs for any reason, the watchdog forces a full system reset.

  • Timeout: 16 s (magic number, hardcoded in setup)
  • Feed point: esp_task_wdt_reset() is called at the start of every loop() iteration and at several points during long operations (OTA, cloud upload)
  • On timeout: trigger_panic = true → full system reboot. On reboot, all GPIO pins reset LOW, which cuts the field driver

The watchdog monitors the main task (Core 1) only. Core 0 (WiFi/TempTask) is not added to the watchdog (idle_core_mask = 0).

Alarm Buzzer System (GPIO21)

The alarm buzzer is driven by CheckAlarms() running every 250 ms. It aggregates all alarm conditions into a single GPIO21 output. Key behaviors:

  • AlarmActivate gate: most conditions only fire the buzzer if this is enabled. Exception: tempTaskAlarm fires regardless.
  • AlarmLatchEnabled: if enabled, the buzzer stays on after the condition clears until manually reset via ResetAlarmLatch.
  • Alarm test: 2-second buzzer test available from the UI.
  • The buzzer reflects alarm conditions only — it has no direct authority over the field or GPIO4.

Conditions that trigger the buzzer (summary):

Condition Threshold Gated by AlarmActivate?
High alternator temperature TempAlarm (default 190 °F) Yes
High battery voltage VoltageAlarmHigh Yes
Low battery voltage VoltageAlarmLow (> 8 V floor) Yes
High alternator current CurrentAlarmHigh (default 100 A) Yes
High battery current MaximumAllowedBatteryAmps (default 150 A) Yes
INA228 hardware OV latch Any INA OV event Yes
TempTask failure TempTask heartbeat > 20 s No — always fires
Efficiency anomaly effAnomalyAlarmActive Yes

User-Adjustable vs. Magic Numbers — Quick Reference