Skip to content

Safeties and Protections

What the firmware does when voltage, current, temperature, or engine speed leaves the safe range. Each protection below lists its trigger, the action taken, and the variable names to grep for; user-adjustable thresholds persist in non-volatile storage (NVS, via settingWrite() / settingRead()), while a few are deliberately hardcoded or derived.

The protections share a small set of mechanisms — a set of field-drive ramps, an immediate cut, and a cooldown lockout — defined once below, then referenced by name. Everything is decided in 6_functions.ino.


How a protection reaches the field

Every control tick, two pure functions look at a snapshot of the world (TickSnapshot, built by buildTickSnapshot()) and decide what should happen. They must always agree, so they are written to mirror each other:

  • selectFieldControlMode() returns the control mode — which ramp or normal-loop behavior to run.
  • selectFieldEventReason() returns the reason — the named cause, used for telemetry (the field-off banner) and to decide whether the reason warrants an instant cut.

A third function, shouldImmediatelyCutGPIO4(), marks the reasons that chop the field the same tick instead of ramping it down. When a reason is on that list, the field-enable line (GPIO4) goes low immediately via applyImmediateCut(); otherwise the mode's ramp runs in runShutdownPath().

The reason check runs twice: once before the current-sensor freshness gate (the "pre-gate" pass, so a dead current sensor can't freeze the field high), and once on the main path. Both call the same selectFieldEventReason(), so they short-circuit identically.

The priority ladder

Both decision functions walk the same ordered list and return on the first match. Order is the safety model — anything above a given rung wins over everything below it:

  1. INA228 hardware over-voltage latch — the battery monitor's own alert fired.
  2. Hard overcurrent — measured alternator current over the electronic-fuse trip.
  3. Charging disabled — the user On/Off switch (or an external disable) is off. Two external holds land on this same rung but report their own reasons so the dashboard shows why rather than a generic disable: a solar-forecast pause (REASON_SOLAR_PAUSE, see Defer to Solar) and the BMS on/off input withholding permission (REASON_BMS_DISABLED).
  4. Fast over-voltage — live voltage over the software absolute ceiling. This rung sits above manual mode and is not gated by the test switch.
  5. Manual mode — the user has taken manual control. Everything below this rung is bypassed in manual.
  6. Timed over-voltage cut tiers — the filtered voltage held above a tier's trip line continuously for that tier's time to act. The detector itself arms only during automatic voltage-hold control, so this rung sits just below manual by construction; when a tier's timer expires the cut is immediate.
  7. RPM gate — engine speed below the field minimum.
  8. Tach-lie plausibility — the tach claims the engine is running while a hard-driven field produces no output.
  9. Cold-charge lockout — battery too cold to charge.
  10. Hot-charge lockout — battery too hot to charge (opt-in, and only from a measured battery temperature).
  11. Critical conditions — temperature-data stale, current-data stale, voltage implausible, voltage-sensor disagreement (critical), critical over-temperature.
  12. Warning conditions — voltage-sensor disagreement (warning), over-temperature (warning / sustained).
  13. Cooldown lockout — a prior fault's lockout is still counting down.
  14. Commissioning idle rest — the guided wizard is holding the field low between steps (not a fault).
  15. Normal automatic control.

The ungated, above-manual rungs (1, 2, and 4) are why the hardware alert, hard overcurrent, and the software absolute-OV trip fire even in manual and Limp Home; everything from the timed tiers down is automatic-mode only.


Notation and shared mechanisms

Two voltage sensors

  • BatteryV — measured by the ADS1115 analog converter.
  • IBV — measured by the INA228 battery monitor. This is the signal for all software voltage protections; getBatteryVoltage() returns it.

BatteryV is the second opinion: it is used by the cross-sensor disagreement check and by the implausibility and near-zero/not-a-number checks below. The INA228 also has a hardware alert pin that operates on the chip's own internally averaged voltage, independent of firmware.

The field-drive paths

Path Behavior Used for
Immediate cut (applyImmediateCut()) Field-enable line low within one tick, PWM zeroed, control loop reset, system drops to fault state. Held while the condition persists, then resumes automatically when it clears. Hardware-OV latch, software absolute-OV trip, the timed OV cut tiers (their dwell is served in the detector; the cut itself is immediate), hard overcurrent, RPM below minimum (the basic gate and the engine-stopped fast cut), tach-lie plausibility, current-data stale, critical temperature
Warning ramp (MODE_WARNING_RAMP_AND_LOCKOUT) Staged ramp-down: slew duty to the tach-keepalive floor at the normal ramp rate, optional hold, then a slow ramp to zero, then a settle period before the enable line is cut. Arms the cooldown lockout. Sensor-disagreement warning, temperature warning / sustained, cold-charge lockout, hot-charge lockout
Critical ramp (MODE_CRITICAL_RAMP) For a critical voltage-sensor fault (disagreement-critical or implausible) it jumps straight to the final settle-and-cut; every other critical reason (notably temperature-data stale) runs the full staged ramp. Arms the cooldown. Temperature-data stale, voltage implausible, voltage disagreement critical
Disabled / lockout ramp (MODE_DISABLED_RAMP, MODE_LOCKOUT_RAMP) Ramp to zero and hold. The disabled ramp is the user On/Off switch (no cooldown). The lockout ramp runs while an already-armed cooldown counts down. User charging Off; cooldown countdown

The ramp shape is user-tunable (DutyRampRate, default 40 %/s; DutySlowRampRate, 0.5 %/s; ShutdownPhase2HoldMs, 500 ms; SettleTimeBeforeCut, 1000 ms). See runShutdownPath(). The staged ramp runs phases 1 → 3 → 4 (there is no phase 2 — the "phase-2 hold" is a timer inside phase 1).

Mode and reason are decided separately, and where they disagree the reason wins: RPM-below-minimum, tach-lie and critical temperature all select the critical-ramp mode, but their reason is on the immediate-cut list, so the ramp never actually runs. The mode value still matters — on the following tick, with the enable line already low, it is what arms the cooldown lockout.

The cooldown lockout

After a warning- or critical-ramp shutdown, charging is locked out for a cooldown period held in activeCollapseDelay, which is chosen by reason:

  • RPM below minimumRPM_RECOVERY_DELAY (500 ms), so charging resumes quickly on restart.
  • Software absolute over-voltage and the timed OV cut tiers → the adaptive ladder (below).
  • Tach-lie plausibility → its own, longer escalating ladder (described with the tach-lie cut).
  • Everything elseFIELD_COLLAPSE_DELAY (30 s).

When the delay elapses the system retries; if the fault persists a new lockout begins.

The adaptive over-voltage ladder (nextFastOvLockoutMs()) starts at 0.5 s and doubles every third fire — 0.5 s, 1 s, 2 s, 4 s, 8 s — capped at 10 s. A clean stretch of 60 s with no fire resets it to 0.5 s. It lives in volatile RAM, so a reboot restarts the ladder. All three over-voltage cut rungs — both timed tiers and the absolute software cut — share this one ladder, so a persistent cause escalates the lockout regardless of which rung catches each event.

Arming scope of the throttling protections

The overvoltage current-trims (Groups 1–2), the timed OV cut tiers, the current-excess supervisors (near-target and below-target), and load-dump detection arm only while the voltage-holding loop is engaged (voltageControlActive — absorption, float, and the voltage-holding override modes). Three extra conditions narrow this:

  • Group 1 additionally waits until measured voltage is within a small guard band of the target (PRED_GUARD, hardcoded at 0.06 V per 12 V of bank) so it cannot fire during normal ramp-up far below target.
  • The current-excess supervisors additionally disarm in zero-current float (MaintainMode / zeroFloatActive), where the command carries no meaning to compare current against.
  • Zero-output stand-down (altZeroOutput) disarms Groups 1–2, the timed OV cut tiers, and the near-target current-excess supervisor whenever the alternator is delivering essentially nothing into a bus that is nonetheless above target. A protection whose actuator cannot move the protected variable must not fire: with the alternator at about zero amps, the excess is authored by the battery's own resting voltage or by another charge source (solar, shore, DC-DC), so cutting the field removes nothing — while each no-op fire still costs a tach false-zero, an integrator reseed, an OV-episode derate, and a lockout tier. The zero band scales with the configured current-sensor full scale, entry requires a 2.5 s dwell (so a normal decay through target cannot latch it), and the exits on current, voltage-control state, or stale data are single-tick, because an RPM rise at unchanged field regains real output with no warning. The voltage leg alone is hysteretic: entry is strict on raw voltage (above target), but from the first tick of the entry dwell onward the leg reads the Group-2 filtered voltage against a looser bar (target − about 50 mV per 12 V of bank). The wind-down glide parks the target roughly 20 mV under the bus, so a strict raw comparison chatters at ripple rate — the dwell never completed, and a completed latch would have chattered the same way. The absolute layers — the software hard cut, the INA228 alert, hard overcurrent and load dump — stay armed throughout. While it holds, the dashboard limiter code reads 7 ("battery above target").

A dashboard test switch (testProtectionsEnabled) can disarm the trim layers, the timed OV cut tiers, and the current-excess layers for plant-characterization tests; load-dump detection and the absolute cuts (software hard cut, INA228 alert, hard overcurrent) are not affected by it.

Separately, each group carries its own persistent Enable toggle on the Setup → Alternator → Protections page — HardOCEnable (Group 0), OvGroup1Enable, OvGroup2Enable, IExcessEnable (Group 3, gating both current-excess supervisors), BattLimitEnable (Group 4), and LoadDumpEnable (Group 5). The tach-lie plausibility cut carries the same kind of switch, TachLieEnable (see Tach-lie plausibility cut). All of these are independent of the test switch, and for the three layers the test switch deliberately cannot reach — Group 0, Group 5 and tach-lie — the layer's own toggle is the only way to disarm it, so turning one off removes the last over-current, load-dump or phantom-RPM backstop respectively.

The layers are split by the signal they watch: Groups 1–2 watch battery voltage; both current-excess supervisors watch alternator current; load-dump watches battery current. There is no battery-current over-current supervisor — an earlier battery-current variant was removed, and battery current now survives only as the load-dump input and as the separate BattCurrentLimitA limit.


Overvoltage protections

Several distinct mechanisms, from gentle to absolute. The voltage and current trims are throttling protections: they keep charging but lower the current setpoint cap (fastOvCurrentCap) so voltage stops rising. Load dump and the hard cuts take the field down. The INA228 hardware alert is the final backup that works even if firmware hangs.

Overvoltage defense in depth: proportional shed first, timed cut tiers next, absolute rungs last, hardware backstop independent of firmware

Whenever any trim engages, the duty slew limit is removed (GOV_BYPASS_SLEW) so the current loop can collapse the field at full speed, and the inner current-PID integrator is reset so duty falls within a cycle or two.

Group 1 — prediction-based trim

Projects where voltage will be a fraction of a second from now and trims the current cap if the projection overshoots. Off by default (OvGroup1Enable = false) — on a belt-driven alternator the derivative term amplifies belt ripple.

  • Trigger: projected voltage (IBV plus the lookahead horizon TdPred times the smoothed rate-of-rise) exceeds the active target by OvPredMarginV, once IBV is within PRED_GUARD of the target.
  • Action: lowers fastOvCurrentCap proportionally to the projected excess (slope KHard, amps per volt).
  • Knobs: OvGroup1Enable, TdPred (0.045 s), OvPredMarginV (0.15 V per 12 V), DvdtTC (rate-of-rise smoothing time constant, 58 ms), KHard (35 A/V, shared with Group 2). No hold latch — it re-evaluates every tick.

Group 2 — filtered-voltage trim

The workhorse voltage layer (OvGroup2Enable = true). It fires on a filtered voltage, not the raw reading: ibvFilt is a smoothed IBV whose time constant is derived from the commissioned plant response (one-third of the plant time constant, clamped 10–80 ms). Raw IBV false-fires on belt ripple; the filter is never a user knob.

  • Trigger: ibvFilt above the target by OvMeasMarginV (0.1 V per 12 V) and still rising (the filtered rate-of-rise positive). It is a rising-gated comparator, not a hysteretic hold: a falling bus above the line is a receding danger — the current is already being shed and the bus is dropping on its own, so re-clamping it every tick would only flicker the field and compound the recovery reseed. Every real event crosses the line while rising, so the first fire is never missed, and a persistent overdrive re-arms on each fresh rise.
  • Action: lowers fastOvCurrentCap proportionally to the filtered excess (slope KHard), and increments the lifetime soft-cap counter.
  • Knobs: OvGroup2Enable, OvMeasMarginV, KHard.

Timed over-voltage cut tiers (LOW and MID)

Two dwell-debounced field cuts sitting between the proportional shed and the absolute rungs. They watch the same filtered voltage as Group 2 and answer the case Group 2 cannot: a bus that gets stuck above the shed line and stays there. Before these tiers existed, a sustained small excess simply sat under the current cap indefinitely; now it is resolved by a field cut within a bounded time — fast enough that the XREG-010 always acts before a lithium battery's own protection circuit (BMS) could open its charge path under load. The timing budget behind the defaults is documented on Protection Timing.

  • Trigger: ibvFilt held above target + margin continuously for the tier's time to act — any single tick back under the line resets that tier's clock. No hysteresis: a ripple crest that dips across the line momentarily restarts the timer rather than accumulating, and because the reference is the slewed charging target, a commanded target drop glides the trip lines down with it.
  • Defaults (12 V lithium, everything × class/12): LOW tier at target + 0.10 V held 0.4 s (OvTierLoMarginV / OvTierLoDwellMs); MID tier at target + 0.20 V held 0.15 s (OvTierMidMarginV / OvTierMidDwellMs). The LOW tier deliberately rides the Group 2 shed line, so the proportional shed always gets the whole LOW dwell to resolve the excursion without a cut — most transients end that way, with nothing felt at the engine. A dwell of 0 disables that tier.
  • Action: immediate field cut (REASON_OV_TIER_LOW / REASON_OV_TIER_MID), arming the same adaptive over-voltage lockout ladder as the software hard cut. Both timers run independently; whichever expires first names the cut.
  • Arming: identical to Groups 1–2 — automatic voltage-hold control only, disarmed by the test switch, tuning/characterization runs, and the zero-output stand-down. Manual mode keeps instant-only protection: the absolute rungs below stay armed everywhere.
  • UI: the whole cut ladder — both tiers' margins and times to act, the software hard shutdown, and the hardware shutdown — lives in one Over-Voltage Ladder card under Setup → Alternator → Protections → Detection, in rung order.

Group 3 — current-excess supervisor near the target (iExcess)

Catches overshoot before it shows in voltage, by noticing that measured alternator current is running well above what the regulator is commanding. The detector works on the time-averaged excess, so it is invariant to disturbance frequency, amplitude, and alternator size.

  • Signal: a dt-aware exponential average of (MeasuredAmps − command), time constant IExcessTau (75 ms). Averaging first means any zero-mean fluctuation cancels before the threshold is checked, so a brief spike cannot trip it. Near the target the command is the voltage-loop setpoint (setpointLimited).
  • Trigger: the averaged excess crosses an affine threshold E = clamp(IExcessFrac × command + IExcessBaseA, IExcessFloorA, IExcessCeilA) — a fraction of the command (IExcessFrac, 0.031) plus a fixed intercept (IExcessBaseA, 5.8 A), floored at IExcessFloorA (5 A) and ceilinged at IExcessCeilA (20 A).
  • Arm gate: voltageControlActive && MaintainMode == 0 && !zeroFloatActive && IBV > target − IExcessArmMarginV (the "Strict Over-Current Band", IExcessArmMarginV = 0.1 V).
  • Action: resets the inner current-PID integrator, drains the voltage-loop integrator (cv_I) — snapped to zero when IExcessKBleed is 0, otherwise bled proportionally — lowers fastOvCurrentCap, and latches.
  • Release hysteresis: stays latched until the averaged excess falls below E × IExcessRelFrac (0.5), preventing chatter as current settles. A double-penalty guard holds the average at zero during and just after a clamp so the field-current lag on release cannot re-fire the detector and drain cv_I twice.
  • Live view: Setup → Alternator → Protections shows a per-frame sparkline of the averaged excess against E, with a marker on each real fire, for tuning the thresholds.

Bulk current-excess supervisor below the target (iExcess)

The exact complement of Group 3, and the layer that actually occupies the code's second detector slot. It watches the same alternator current, but below the target, against the mechanical current ceiling instead of the voltage setpoint — catching a field that has run ahead of the RPM/thermal limit before voltage even begins to rise.

  • Signal: the average of (MeasuredAmps − ceiling), where the ceiling (i_ceiling_pre_ov) is the RPM/thermal/user current limit.
  • Trigger: a threshold parallel to Group 3's, sitting IExcessCcOffsetA (4 A) above it: E = clamp(IExcessFracBulk × ceiling + IExcessBaseA + IExcessCcOffsetA, IExcessFloorA, IExcessCeilA). Floor and ceiling are shared with Group 3 — there are no separate battery-specific values.
  • Arm gate: the strict complement, IBV <= target − IExcessArmMarginV, so exactly one of the two current-excess detectors is armed at any moment, with no gap or overlap.
  • Action and release: identical to Group 3, but the clamp it applies is ceiling-relative.

Load-dump detection

A three-tier rate-of-change detector on battery current (dBcur/dt, computed from consecutive INA228 samples) — it catches the current-slope spike when a big load is suddenly switched off. Unlike the over-current supervisors, load-dump detection stays armed even when testProtectionsEnabled is off.

  • Gate: voltage loop engaged, the battery monitor in its fast sampling mode, and a battery shunt present (HAS_BATT_SHUNT). With no shunt the slope signal is noise, and the fast dV/dt path is the backstop instead.
  • Tiers: each pairs a threshold with a consecutive-sample count, and both halves are user-settable — LoadDumpDtThresh1 (7000 A/s) with LoadDumpN1 (1 sample), LoadDumpDtThresh (5000 A/s) with LoadDumpN2 (2 samples), LoadDumpDtThresh3 (5000 A/s) with LoadDumpN3 (3 samples). A tier fires when its count of consecutive samples all exceed its threshold; a single sample below resets that tier's run. The consecutive-sample requirement rejects the alternating-sign measurement noise of the current sensor. Each tier's effective time to act is its count times the ~5 ms fast sampling interval, and the dashboard shows it under each count. (At the shipped thresholds tier three shares tier two's threshold while requiring more samples, so tier two always fires first — tier three becomes meaningful only with a lower threshold of its own. Deliberate; documented rather than "fixed".)
  • Action: integrator, setpoint, and current cap all collapse to zero at once. It has no hold latch — it re-asserts every tick the danger is present; recovery is the normal reseed and anti-windup bleed once it clears.
  • Knobs: LoadDumpDtThresh1, LoadDumpDtThresh, LoadDumpDtThresh3 (amps per second) and LoadDumpN1N3 (samples), all user-settable.

Software hard cut (absolute trip)

The software-layer absolute overvoltage trip: IBV above AlternatorHardShutdownV is an immediate field cut (field-enable line low on GPIO4), reported as REASON_FAST_OVERVOLTAGE, followed by the adaptive lockout ladder. It is evaluated above the manual-mode branch and is not gated by testProtectionsEnabled, so any exceedance cuts the field instantly in every mode — automatic, manual, and Limp Home. There is no warning-ramp path (the mode selector nominally returns the warning-ramp mode, but the reason forces the immediate cut).

For a large exceedance — more than 0.5 V per 12 V of bank above the trip — the duty slew limit is additionally removed so the field collapses at full speed. This hardcoded accelerator tier is the one exception to "any amount over the trip is an instant cut": below it the cut is instant but slew-shaped, above it the collapse is un-slewed.

The threshold (AlternatorHardShutdownV, "Alternator Hard Shutdown Voltage") is an absolute, persisted user setting, one rung below the hardware shutdown so software always gets first shot:

  • Lithium default 14.2 V on a 12 V bank — 0.1 V below the hardware shutdown, which itself sits 0.2 V below the surveyed floor of lithium BMS charge-disconnect voltages. The hazard to place it against is the battery's own protection circuit (BMS) disconnecting under charge; the full chain is derived on Protection Timing.
  • Flooded and AGM lead-acid default 15.9 V (12 V bank), 0.1 V below their 16 V hardware ceiling. Every lead-acid damage mechanism accumulates over minutes to years, so a brief bounded excursion above bulk does the battery no measurable harm; at that level the cut protects the connected DC loads (whose published continuous ratings top out near 16 V) rather than the battery.
  • The chemistry-specific values arrive from Recommend Initial Charging Settings; the first-boot seed chains from the hardware shutdown's own seed (hardware limit minus 0.1 V × class/12), so the pair is born in order.

All values scale with the configured system voltage class: changing the class rescales both rungs by the ratio (applyNominalVoltageChange()), preserving the chain. The firmware also enforces the order on every write and at boot — a software cut submitted at or above the hardware shutdown is clamped back below it.

INA228 hardware alert (hardware backstop)

The most independent protection — it operates with no firmware involvement, and it is deliberately the top rung of the ladder: every software layer sits below it, so in a healthy system it should never be the rung that fires.

  • Trigger: the chip's internally averaged bus voltage exceeds a programmed limit — VoltageHardwareLimit, the "Hardware Shutdown Voltage", an absolute, persisted user setting exposed in the Over-Voltage Ladder card (lithium default 14.3 V per 12 V of class; flooded/AGM 16 V). The comparison runs on the averaged conversion result rather than instantaneous samples (the SLOW_ALERT bit), but the averaging depth is not fixed — the firmware reconfigures the chip on the field-state edge, so the alert's filter is only a few milliseconds deep (~4.3 ms) whenever the field-enable line is high, deepening to about a second only once the field is already off (figures under Sensors → Two sampling modes). During charging it is therefore a near-instant electrical cut. It can never be a timed layer: the chip's averaging depth applies to every input at once, so slowing the voltage compare would slow the current register to the same cadence and starve the load-dump detector — which is why the timed tiers live in software and the pin stays instant.
  • Setting it: 0.2 V (× class/12) below your battery's BMS charge-disconnect voltage — its highest-cell trip voltage times the cell count. BMS comparators trip on the highest cell while the regulator sees pack voltage, so the pack-referred margin must cover cell imbalance; that is why the guidance is 0.2 V rather than 0.1. For lead-acid and AGM there is no BMS in the picture — place it where the connected DC equipment becomes the limiting factor.
  • Action: the chip's alert pin (active-low, open-drain) physically pulls the field-enable line down — the gate driver loses its enable and the field collapses regardless of firmware state.
  • Software latch: CheckAlarms() polls the chip's alert register and sets inaOvervoltageLatched. While latched, the priority ladder reports the overvoltage reason at the top and the immediate cut runs every tick. The latch is re-checked on a slow cadence — an early look at 3 s for transient spikes, a definitive look at 10 s — and releases automatically once the chip reports clear. New events are detected by reading the chip's alert register every 5 s, so a pin blip that clears before that read still cuts the field electrically but may not be logged.
  • Post-event suppression: for 10 s after the latch clears (INA_OV_DISAGREE_SUPPRESS_MS), the sensor-disagreement check is suppressed — the two sensors legitimately diverge while the field collapses.
  • Active in all modes, including manual and Limp Home.

When both fire

The software hard cut sits 0.1 V (× class/12) below the hardware limit, so on any rise the software path is designed to act first, and the hardware pin exists for exactly one case: a hung, crashed, or mid-update firmware that never gets to act. Both layers are fast — the software path compares the raw per-tick IBV; the hardware comparator runs continuously against its own ~4.3 ms average while the field is live — so the ordering comes from the thresholds, not from one layer being slow. A nonzero hardware-cut count therefore means software failed to protect, and is worth reporting. When both fire, the immediate cut notices the enable line is already low and records state without acting twice.

Overvoltage history (lifetime record)

The device keeps a lifetime record of every excursion above the Bulk target, from any charge source (alternator, solar, or shore — it accumulates even with the field off):

  • A histogram of raw bus voltage above Bulk — fine 0.2 V bands from Bulk up to 3.6 V above it, coarser 1 V bands to 15.6 V above, plus an overflow band — recording, per band, how many times the voltage entered it and the total time spent there. Everything scales with system voltage class. Each band is measured as distance above the Bulk target in effect at that moment, not as a fixed voltage, so the record follows the target wherever it is set. The web panel prints its band labels using the current Bulk setting, and lists the bands with activity plus the first empty band above them.
  • Six lifetime counters: soft-cap engagements (Group 2), timed LOW-tier cuts, timed MID-tier cuts, software hard cuts, hardware (INA228) cuts, and voltage-derivative (D-term) engagements. The last counts saves made by the charging loop's derivative term rather than protection trips, and is recorded here alongside the others.

It lives in a small always-powered memory region (RTC RAM), so it survives restarts, watchdog resets, and firmware updates. It is zeroed only on a true power-down (battery disconnect) or a change to the record's own layout, or via a guarded reset. View it under Live Data → Diag → Alternator → Overvoltage History, which offers a guarded "Reset Lifetime OV History" — separate from the per-session protection counters on Live Data → Protection. The record also rides the daily cloud configuration snapshot, so history survives beyond the device.

The lowest bands accumulate large event counts from normal regulation ripple crossing band edges; total time per band is the better severity measure there.


Post-protection recovery

The easy part of a protection is the cut. The hard part is the comeback: come back too fast and you slam straight back into the same protection; come back too slow and the battery voltage sags for seconds while the charger tiptoes. Overvoltage and current-excess trips share one recovery handler; load dump is deliberately excluded (it keeps re-asserting and has no single moment of release to walk back from).

Post-protection recovery: reseed the loop humbly, then refill the integrator at a boosted rate until the current reaches its true pre-trip holding level

The always-on part — the reseed. When every protection clears, the voltage-loop integrator is set back to a fraction of its pre-trip value (ReseedFrac, 0.95) — a modest restart so the field does not leap straight back to full drive. This happens on every release regardless of the setting below.

The switchable part — the integrator refill (cvRecovEnable, on by default). While enabled, on release the voltage-loop integrator's up-integration runs at a boosted rate — VoltageKi_active times a multiplier that starts at cvRecovKiMax (default 5×) and tapers linearly to 1× as the reseed deficit heals — walking the current back up to a ceiling:

  • The ceiling is the current that was actually holding the target before the trip. It is taken from a slow average of the loop's command measured during clean operation near the target (cvSteadyHoldEma), not sampled at the instant of the cut — a real overvoltage event flickers on and off, and each brief gap would otherwise sample a half-drained value and cap the field far below what it needs, sagging the bus. The window can never command more than this ceiling, because asking for more can only overshoot back into the protection that just fired. Over the last stretch of the climb the ceiling is pulled lower still — the arrival flare: inside cvRecovFlareBandV (0.35 V per 12 V of bank) of the target it tapers from the full holding current down to cvRecovFlareFrac (0.85) of it at the target, so the loop arrives carrying almost no surplus rather than the whole pre-trip current. Arriving with the full value is what re-fired the protection on a battery whose surface charge has not yet relaxed; whatever the load genuinely needs, the integrator re-adds afterwards at its own pace.
  • No separate error cap. Because the refill rides the loop's normal integration path, the existing gates pace it — the anti-windup saturation freeze, the integrator cap, the D-term freeze, and the battery-current ceiling all apply — so the comeback stays bounded without a dedicated voltage-error muzzle.
  • The exits: the refill ends when the deficit is healed and the integrator has climbed back to the ceiling; or when the pre-trip current no longer holds the target — voltage stuck low and not rising with the full ceiling already commanded — which releases the ceiling rather than starving the field; or immediately if a new protection fires or voltage control exits.

Turning it off is the honest test of the loop itself: with it off, recovery is the bare loop plus the reseed and the normal anti-windup bleed, so the gains must be tuned to survive a step on their own. Leaving it on can mask a charging-loop gain problem, which is why the switch exists.

Field-decay early release. Separately, once a cut has been held long enough for the field coil to actually bleed out its stored energy (the measured field-decay time, fieldDecayTauMs), the hold is released early rather than waiting out the slower filtered signals. This can only shorten a cut, never lengthen it, and it never releases a load-dump-owned or predictive-trim cut.


Overvoltage cascade — illustrative 12 V defaults

With factory defaults on a 12 V system, in order of escalation:

Condition Protection Action
Filtered voltage ~0.1 V over target Group 2 Trim current cap
Predicted voltage ~0.15 V over target (if enabled) Group 1 Trim current cap
Alternator current excess near target Group 3 (iExcess) Reset integrators, trim cap
Alternator current excess below target Bulk iExcess Reset integrators, trim cap
Battery-current slope spike (load removed) Load dump Collapse setpoint and cap to zero
Filtered voltage 0.1 V over target held 0.4 s Timed cut, LOW tier Immediate field cut + adaptive lockout
Filtered voltage 0.2 V over target held 0.15 s Timed cut, MID tier Immediate field cut + adaptive lockout
Voltage over the absolute trip (lithium 14.2 V, flooded/AGM 15.9 V) Software hard cut Immediate field cut, all modes, + adaptive lockout
Chip-averaged voltage over the hardware limit (lithium 14.3 V, flooded/AGM 16 V) INA228 hardware alert Physical field cut
Voltage more than 0.5 V over the software trip Slew-bypass accelerator Un-slewed field collapse
Both sensors outside plausible range Implausibility Critical ramp + lockout

Note the reference difference: the trim margins and the timed tiers are relative to the active charging target (bulk, absorption, or float), while the hard-cut thresholds are absolute. The software cut sits one rung below the hardware alert, so on any rise the software layer — logged, lockout-managed, recoverable — acts first, and the firmware-free hardware pin fires only if software never gets to act. Both layers are fast while charging (the chip's averaging window is about 4.3 ms with the field live), so the ordering comes from the thresholds, not from one layer being slow. How the whole ladder's spacing and times were derived is on Protection Timing.


Voltage sensor failure protections

These do not detect a battery problem — they detect that the voltage sensors themselves disagree or read impossible values. All three are active in automatic mode only (they sit below the manual branch).

  • Disagreement warning — the two sensors differ by more than VoltageDisagreeThreshold (0.15 V on a 12 V bank) continuously for VoltageDisagreeTimeout (10 s). Warning ramp + 30 s lockout.
  • Disagreement critical — a much larger difference (1 V on a 12 V bank, 2 V at 24 V, 3 V at 36 V, 4 V at 48 V) sustained for a short debounce (3 s), or either sensor returning not-a-number or near zero (those fire with no debounce). Critical ramp + lockout. See isVoltageDisagreementCritical().
  • Implausibilityboth sensors outside the plausible range for the system class (4.5–15.5 V at 12 V; 9–32.5 V at 24 V; 13.5–46.5 V at 36 V; 18–60.5 V at 48 V). One bad sensor alone does not trip this. Critical ramp + lockout, no debounce. See isVoltageSensorPlausible().

For 10 s after a hardware-OV latch clears, the disagreement checks are suppressed — the two sensors legitimately diverge while the field collapses.


Temperature protections

All temperature protections act on TempToUse — the alternator temperature probe by default, or the thermistor input if selected (TempSource). Setting IgnoreTemperature disables the temperature-limit responses (the derating and the T1–T3 cuts) and the stale-data cut (T5); only the task-health alarm (T4) remains, and because the actual field cut for a hung task comes from T5, a hung task under IgnoreTemperature sounds the buzzer but does not cut the field.

T0 — continuous thermal derating (the thermal PID)

Not a fault response — normal steady-state thermal management, described in full on Charging Control. It subtracts a current penalty (thermalPenaltyAmps) from the ceiling before the limit is ever reached, working from a predicted temperature (present value plus rate-of-rise times a lookahead horizon of 60 s). The damage limit is TemperatureLimitF (175 °F); the derating holds a guard band of about 7 °F under it (see Charging Control for why) so the hard cuts below are rarely reached.

T1 — warning ramp

Temperature exceeds the limit by a small margin (TempWarnExcess, 2 °F): warning ramp. Automatic mode only. It throttles the field down but does not fully cut it.

T2 — sustained warning

The T1 condition held continuously for TempSustainedTimeout (2 minutes): the field is cut. The escalation exists because repeatedly bouncing off the warning ramp means derating has failed. Recovery is the ordinary 30 s cooldown — once temperature falls back below the warning margin the field returns automatically; there is no separate latch requiring a manual re-enable or reboot. Automatic mode only.

T3 — critical immediate cut

Temperature exceeds the limit by a larger margin (TempCritExcess, 10 °F): immediate cut, no ramp. Automatic mode only — it sits at the critical rung, below the manual branch, so taking manual control bypasses it. In manual an overheating alternator is not cut by T3; the protections that stay live in manual are the over-voltage and over-current trips above the manual branch.

T4 — temperature task hang

The temperature probe runs in its own task on the other CPU core (Core 0; the control loop runs on Core 1). A heartbeat monitor (checkTempTaskHealth(), deadline TEMP_TASK_TIMEOUT, 20 s) raises the alarm condition if the task goes silent. This raises the buzzer condition; the actual field cut comes from T5, which fires on the same silence. The flag clears on the next heartbeat.

T5 — temperature data stale

The primary field cut for any temperature-monitoring failure: no validated reading for about twenty seconds, or readings that are not-a-number or wildly out of range (below −50 °F or above 400 °F). Critical ramp + lockout; recovers automatically on the first valid reading after the lockout clears. Covers sensor disconnects, CRC failures, and hung tasks alike. Staleness is deliberately ignored while the engine is stopped (below ~200 RPM, and for 15 s after spin-up), because the temperature task slows its polling when there is nothing to charge.


Where the battery temperature comes from

Both charge lockouts below, the voltage-loop gain derate, and the battery-health resistance record all read one number: battTempActiveF, produced once per control tick by batteryTempF() (6_functions.ino) and mirrored into the tick snapshot. (The battery-temperature field the regulator transmits on NMEA 2000 is deliberately not this number — it carries the Battery-role probe alone, so the regulator never re-broadcasts a value it received as a measured battery temperature.) The source is a user setting, battTempSource (Auto by default), and the source actually in use each tick is reported as battTempActiveSrc:

Code Source Qualifies when
1 1-Wire probe bound to the Battery role battTempProbeEnable on (off by default) and the probe reading is fresh
2 NMEA 2000 battery status (PGN 127508) the selected receive instance is publishing a temperature and it is fresh
3 Victron VE.Direct T field a BMV/SmartShunt with a temperature sensor is connected and fresh
4 RV-C DC_SOURCE_STATUS_2 the followed DC-source instance is publishing a temperature and it is fresh
0 none nothing above qualified — every consumer fails open or falls back to neutral

Every source is a measurement taken at the bank; the regulator's own board temperature is not one of them, and code 5 is retired. In Auto the chain is walked in exactly that order and the first qualifying source wins. Setting battTempSource to 1–4 pins one source (and reports 0 when that one source is unavailable rather than silently substituting another); 6 is None, and a stored 5 from an older firmware loads as Auto. Freshness for the 1-Wire, VE.Direct and RV-C feeds is the same idle-aware window the alternator probe uses — 20 s once the engine has been running, 90 s otherwise, because the temperature task stretches its poll to 60 s with the engine stopped. The NMEA 2000 feed uses the ordinary 10 s staleness timeout every other received value gets.

Settings: Setup → Battery → Charge Settings → Battery Temperature (source), Setup → Temperature → Temperature Sensors (probe role assignment and the probe enables).


Cold-charge lockout

Charging a cold battery damages lithium chemistries (lead-acid tolerates it), so the firmware can lock out charging when the battery is too cold.

  • Trigger: the active battery temperature (battTempActiveF, from the source chain above) below MinChargeTempF (default 40 °F).
  • Action: the field ramps gracefully to zero and locks out, reason REASON_BATTERY_TOO_COLD, with a buzzer alarm ("Battery too cold to charge"). A 2 °F re-arm hysteresis (ColdChargeHysteresisF) prevents chatter at the threshold.
  • Fails open: if no source qualifies (battTempActiveF not-a-number), charging is allowed — a dead sensor never blocks charging.
  • Scope: automatic mode only (it sits below the manual branch).
  • Default ON (coldChargeLockoutEnable = true). A lead-acid-only installation may turn it off.
  • No sensor, no lockout: an installation with no battery-temperature source of any kind gets code 0, so the lockout never fires. Bind one of the kit's ring-lug probes to the Battery role, or feed a battery temperature in from NMEA 2000, VE.Direct, or RV-C.
  • Settings: Setup → Battery → Charge Settings → Battery Temperature (coldChargeLockoutEnable, MinChargeTempF).

Hot-charge lockout

The mirror of the cold lockout, for a bank that has become too hot to accept charge.

  • Trigger: battTempActiveF above MaxChargeTempF (default 122 °F), with the active source one of codes 1–4.
  • Action: identical to the cold lockout — graceful ramp to zero and lockout, reason REASON_BATTERY_TOO_HOT, buzzer alarm ("Battery too hot to charge"), then the field-enable line is cut once the output has settled. A 2 °F re-arm hysteresis (HotChargeHysteresisF) prevents chatter.
  • Scope: automatic mode only, immediately below the cold lockout on the ladder.
  • Default OFF (hotChargeLockoutEnable = 0) — it is opt-in, and with no measured source it can never fire even when enabled. Recommend Initial Charging Settings turns it on for lithium, at 113 °F, when a probe is bound to the Battery role; without a probe it leaves the lockout alone.
  • Settings: Setup → Battery → Charge Settings → Battery Temperature (hotChargeLockoutEnable, MaxChargeTempF).

Overcurrent protections

Hard overcurrent (electronic fuse)

Measured alternator current above HardOCTripAmps for a short debounce (HardOCDebounceMs, 40 ms): immediate cut. The trip point is auto-derived a fixed 10 A above the user's maximum table current (MaxTableValue) and recomputed whenever that changes. It is evaluated near the top of the priority ladder, above the manual branch, so it is active in all modes including manual. The global test switch cannot disarm it; its Group 0 toggle (HardOCEnable, default on) can, and that is the only thing that does.

Current alarms (buzzer only)

Two thresholds drive the buzzer but never touch the field: alternator current above CurrentAlarmHigh (100 A), and net battery-current magnitude above MaximumAllowedBatteryAmps (125 A, and only when a battery shunt is present). Both via CheckAlarms() when alarms are armed.


Engine speed gate (RPM gate)

There are four distinct RPM-driven cuts:

  • The basic gate. Engine speed below MinRPMForField (125 RPM, with IgnoreRPM off) cuts the field — there is no reason to excite the field on a stopped or stalling engine. It sits below the manual branch, so it is automatic-mode only (it does not fire in manual). Recovery is not instant: clearing the gate arms a short recovery delay (RPM_RECOVERY_DELAY, 500 ms) before the field is allowed back. After a confirmed stop (see the next bullet) there is an additional restart confirmation — engine speed must hold at or above MinRPMForField continuously for RPM_RESTART_CONFIRM_MS (2 s) before the gate releases, so a lone noise blip cannot re-energize the field. A real start simply passes through the dwell.
  • Engine-stopped fast cut. When RPM is held at exactly 0 for RPM_ZERO_CUT_MS (200 ms), the field is cut immediately (pre-gate, forced reason REASON_RPM_TOO_LOW), overriding any graceful shutdown ramp in progress. It fires in automatic and Limp Home, but manual mode is exempt — the priority ladder puts the manual rung above the RPM gate, so the cut and the manual path fought each other at loop rate (the field-enable line oscillating), and manual is precisely the mode you use for engine-off wiring and diagnostic tests. On a normal key-off the charging-disabled rung outranks the basic RPM gate, so without this the field would slow-ramp for tens of seconds while still energized — coupling field-PWM noise into the RPM sense input and producing phantom RPM spikes. This path fires at full loop rate regardless of sensor freshness.
  • Shutdown spin-down cut. When charging is already disabled and RPM is below the minimum for RPM_BELOWMIN_CUT_MS (75 ms), the cut is finished immediately rather than slow-ramping — the graceful ramp has nothing to soften below the field minimum, and an energized field only fakes tach readings. The short dwell keeps a lone glitched low reading from chopping the field mid-ramp.

  • Tach-lie plausibility cut. The tach says the engine is running, the field is being driven hard, and the alternator is making nothing. Above cut-in speed that combination is physically impossible, so the cause is one of three: the speed signal is noise (field-PWM coupling into the sense front end), the alternator is dead, or the field drive is open so the commanded field makes no current — an ON/OFF switch left off, a loose field wire, or a failed field-drive transistor (Q3 / gate drive). The first two waste battery through the field; the open-drive case wastes nothing, but the immediate cut is the right response to all three. Trigger: engine speed at or above MinRPMForField, the loop commanding more than TACH_LIE_MAX_AMPS (2 A), applied duty at or above the arm bar, and alternator current magnitude below TACH_LIE_MAX_AMPS (2 A), all held for TACH_LIE_DWELL_MS (3 s). Both bars are relative to the commissioned install, never a flat duty number: the arm bar is the per-RPM minimum-field floor plus TACH_LIE_HEADROOM_FRAC (15 %) of the remaining span up to the field ceiling, because duty parked at that floor with the loop commanding nothing is the normal, correct zero-output state — the floor is defined as the most field that still makes about no current. The commanded-current bar also covers slew lag, where a rev-up drops the floor faster than duty can follow. It cuts immediately (REASON_TACH_IMPLAUSIBLE) and arms its own escalating lockout — 15 s, 30 s, 60 s, then a 120 s cap, reset after 10 minutes with no trip. Manual mode and an ignored tach (IgnoreRPM) are exempt by design, a stale current sensor cannot fake the zero, and the commissioning sweeps that deliberately hold high duty at zero output below the onset knee (field curve/knee, system ID, field-decay drain, protection-actuation tests) are exempt as well. The detector has its own persistent Enable switch, TachLieEnable (default on) — it ignores the global protections test switch, so this is its only disarm, and turning it off also clears an in-flight lockout.

    There is deliberately no release on the speed signal dropping away. A phantom is sustained by field PWM, so the cut itself drives the reading to zero — releasing on that would discard the ladder tier on exactly the fault the ladder exists for. The only early exit is a sustained rev-up, and it is doubly guarded: no release decision at all for TACH_LIE_RELEASE_BLANK_MS (6 s) after the cut, because the field collapse slams the tach front end, then speed must hold above 1.5× the latched trip speed for TACH_LIE_RELEASE_HOLD_MS (300 ms) continuously. Otherwise the tier simply runs out — which for a stopped-then-restarted engine has usually elapsed already.

A grace mask (rpmDropoutGrace) suppresses the gate briefly after protection clamps, after field-cut tests, and after any abrupt protection cut (g_lastFieldCutMs, stamped in applyImmediateCut()), so the sense front-end's post-event dropout cannot be misread as a stall. The abrupt-cut term is a separate stamp because a clamp and a cut are different events — the clamp stamp is never reached on a cut path, and the garbage the tach front end emits afterwards is a non-zero ramp, so neither the clamp term nor a zero test catches it. It is deliberately not applied to the two RPM-derived cuts themselves (masking the gate that just fired would re-energize the field against a real stall), and it only masks while there is something to protect: a commissioning ramp in progress, or charging enabled.


System-level safeties

Hardware watchdog

A task watchdog timer (configured via esp_task_wdt_config_t / esp_task_wdt_reconfigure() in Xregulator.ino, with trigger_panic set) reboots the processor if the main loop stops feeding it for sixteen seconds (WDT_TIMEOUT_MS). Only the main loop task is registered; long blocking work (TLS, file I/O) feeds it inline. On reboot all GPIO pins reset low, which itself cuts the field driver — a hang fails safe. The reboot cause is captured for the reset-forensics record.

The one deliberate exception is an over-the-air update attempt. A single HTTPS call inside it may legally block for the TCP connect, TLS handshake and header wait in sequence with no opportunity to feed, so performOTAUpdateToVersion() widens the panic window to OTA_WDT_WINDOW_MS (5 minutes) for the duration of the attempt and restores the sixteen-second window on every exit path, success or failure (otaSetWdtWindow()). The download loop still feeds inline as well, so a genuinely wedged network stack panic-reboots the (field-off) device within five minutes rather than never.

Alarm buzzer (GPIO21)

Driven by CheckAlarms() (evaluated every 250 ms), aggregating every alarm condition into one steady output on GPIO21. The buzzer is informational only — it has no authority over the field.

Electrical output

The alarm output is a switched 5 V supply driven through a current-limited high-side switch (folds back at roughly a quarter amp, so shorts and oversized loads cannot damage the board) with a built-in flyback clamp diode, so relay coils and magnetic buzzers connect directly. Full circuit details and supported-load table: Warning Buzzer.

Alarm conditions

Condition Threshold setting Default
High alternator temperature TempAlarm 190 °F
Low alternator temperature TempAlarmLow (zero disables) 32 °F
Battery too cold to charge MinChargeTempF (when cold-charge lockout enabled) 40 °F
Battery too hot to charge MaxChargeTempF (when hot-charge lockout enabled, and only on a measured battery source) 122 °F; the lockout itself ships off
Extra-probe high temperature extraTempAlarmHiF (when extraTempAlarmHiEnable) 150 °F; ships off
Extra-probe low temperature extraTempAlarmLoF (when extraTempAlarmLoEnable) 32 °F; ships off
High battery voltage VoltageAlarmHigh 14.8 V (Recommend Initial Charging Settings sets 14.4 V for lithium — above the whole cut ladder, below the BMS floor)
Low battery voltage VoltageAlarmLow (with a lower disconnect-floor guard) 11.9 V
Low battery state of charge SocAlarmLow (needs a battery shunt) 10 %
High alternator current CurrentAlarmHigh 100 A
High battery current MaximumAllowedBatteryAmps (needs a battery shunt) 125 A
Alternator current pulse-pattern fault faAlarmEnable off
Hardware overvoltage latch any INA228 alert event
Temperature sensor not responding staleness window 20 s
Temperature task hung heartbeat deadline 20 s

The temperature-task-hang and hardware-overvoltage conditions are raised regardless of the arm switch (console message and latch), but the physical output stays silent unless alarms are armed (AlarmActivate).

Alarm logic

How the three user toggles, the live condition, and the latch combine:

AlarmTest AlarmActivate AlarmLatchEnabled Condition Latch Output
on any any any ON (auto-clears after the test period)
off off any any any OFF
off on off none OFF
off on off tripped ON
off on on tripped set ON
off on on cleared after a trip still set ON until latch reset
off on on never tripped / latch reset clear OFF

A latched alarm can be silenced by disarming, but returns on re-arm unless the latch is reset (ResetAlarmLatch). New conditions arriving while silenced still set the latch — including the un-gated task-hang and hardware-overvoltage conditions.

Dashboard controls

The Alarms panel exposes all of the above:

Alarm panel in the web dashboard

Dashboard control Firmware toggle
Alarm Enable (Off / Armed) AlarmActivate
Alarm Status indicator Alarm_Status
Temperature / voltage / current thresholds the settings in the conditions table
Alarm Latch Mode (Momentary / Latched) AlarmLatchEnabled
Test Buzzer AlarmTest
Reset Latch ResetAlarmLatch

Mode-by-mode active protections

There are three operating contexts. Manual (ManualFieldToggle) hands the user direct field control, keeping only the protections above the manual rung. Direct is not unbounded: the manual duty request still goes through governor_apply() on the shared path, so the field floor, the per-engine-speed keep-alive floor and the field ceiling all still clamp it. Limp Home (LimpHome) is a distinct mode that forces a fixed 30 % duty to get home on failed sensors; handleLimpHome() itself checks only the user Off switch and the hardware-OV latch, but the pre-gate immediate-cut pass runs before it, so the ungated cuts still evaluate — they interact as re-raise oscillation rather than a clean latch, but they are not silently bypassed.

Protection AUTO MANUAL LIMP HOME
Groups 1 / 2 + current-excess trims Active (voltage loop engaged)
Timed OV cut tiers (LOW / MID) Active (voltage loop engaged)
Load dump Active
Software hard cut (absolute OV) Active Active Active
INA228 hardware alert (physical) Active Active Active
INA228 software latch Active Active Active
Hard overcurrent Active Active Active (via pre-gate)
Sensor disagreement (warning + critical) Active
Voltage implausible Active
Cold-charge lockout Active
Hot-charge lockout Active
T0 thermal derating Active
T1 / T2 temperature warning Active
T3 critical temperature cut Active Active (via pre-gate)
T4 temperature task hang (alarm) Active Active Active
T5 temperature data stale Active
RPM basic gate (below minimum) Active Active (via pre-gate)
RPM engine-stopped fast cut (RPM = 0) Active — (exempt by design) Active (via pre-gate)
Tach-lie plausibility cut Active Active (via pre-gate)
Current-data stale Active Active (via pre-gate)
Watchdog reboot Active Active Active
Buzzer When armed Same Same

"—" means the priority ladder resolves the mode before that protection is evaluated (manual returns at its rung; automatic-only protections sit below it).

Manual keeps the smallest set — the hardware alert, the software absolute-OV trip, hard overcurrent, and the watchdog — because the ladder returns at the manual rung and never reaches anything below. The engine-stopped fast cut is a pre-gate path that would otherwise reach manual, and it is explicitly exempted there for the same reason. Limp Home keeps more: it is not manual mode, so the pre-gate walks the whole ladder, and every reason on the immediate-cut list still fires. What Limp Home drops are the protections that work by ramping (sensor disagreement, cold-charge, hot-charge, temperature warning, temperature-data stale), since it returns before the ramp machinery.

One consequence of "first match wins": a reason that does not cut can mask a lower one that would. Cold-charge lockout sitting true, for example, is returned at its own rung and the critical-temperature cut below it is never reached.