Safeties and Protections¶
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 read a snapshot of the system state (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 cut 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 failed current sensor can't hold the field drive at its last high value), 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 takes priority over everything below it:
- INA228 hardware over-voltage latch: the battery monitor's own alert fired.
- Hard overcurrent: measured alternator current over the electronic-fuse trip, on a fresh sample. Over-voltage while the field is still energized. The software absolute ceiling and the timed over-voltage cut tiers are also at this rung, above the switch-off, for as long as the field-enable line is high. A shutdown ramp is not a cut, so an over-voltage that arrives during one still cuts the field. Once the line is low, the switch-off reason takes priority again.
- Charging disabled: the user On/Off switch or the ignition is off. Three external holds are 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), the BMS on/off input withholding permission (REASON_BMS_DISABLED), and a finished charge cycle resting in the idle stage (REASON_CHARGE_COMPLETE_IDLE). - Fast over-voltage: live voltage over the software absolute ceiling. This rung is above Manual mode and is not gated by the test switch. Current-sensor zero hold. Not a fault: a requested alternator-current zero measurement holds the field at 0 % with the enable line up. It is above Manual so the measurement can run there too. Regulator drive temperature lost, in Manual mode or the test. While Manual mode is on or the field resistance test runs, the drive-temperature check from rung 15 also runs just above both of them, so a lost INA228 die reading ramps the field off there too. In Auto mode it keeps its place at rung 15. Field resistance test. The test has its own rung just above manual: below everything listed above, and above the RPM gate, since the engine is off by the test's own precondition. It holds its own duty for each hold and ends itself on a stale voltage reading or on its own checks (Field resistance test).
- Manual mode: the user has taken manual control. Everything below this rung is bypassed in Manual mode.
- Main battery voltage stale: no valid INA228 reading (Sensor faults). Immediate cut. It is below Manual on purpose: Manual mode is allowed to run with the chip failed, and then has no over-voltage protection at all.
- 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 arms only during automatic voltage-hold control and during a shutdown ramp, so this rung is below manual by construction; when a tier's timer expires the cut is immediate.
- Two or more required sensors faulted: immediate cut (Sensor faults). It is above the RPM gate so a failed ADS1115, which reads as a stopped engine, is reported as the chip fault it is.
- RPM gate: engine speed below the field minimum.
- Tach-lie plausibility: the tach reading shows the engine running while a field driven at high duty produces no output.
- Current-data stale and critical over-temperature: both immediate cuts, ranked above the battery-temperature ramps below so a ramp in progress cannot hide them.
- Cold-charge lockout: battery too cold to charge.
- Hot-charge lockout: battery too hot to charge (opt-in, and only from a measured battery temperature).
- Battery temperature lost, then battery temperature wait: an expected battery temperature with no reading ramps the field off as a fault; one that has not delivered its first reading yet holds the field off with no lockout or alarm (Where the battery temperature comes from).
- Critical conditions: temperature-data stale, regulator drive temperature lost (no valid INA228 die reading for 10 s; see the field ceiling), second battery voltage stale, voltage implausible, voltage-sensor disagreement (critical).
- Warning conditions: voltage-sensor disagreement (warning), over-temperature (warning / sustained).
- Cooldown lockout: a prior fault's lockout is still counting down.
- Commissioning idle rest: the guided wizard is holding the field low between steps (not a fault).
- 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 mode; everything from rung 6 down is Auto mode only. The drive-temperature check under rung 4 also covers Manual mode. A rung that depends on a sensor is skipped while the owner runs without that sensor under Run Without Failed Sensors (reasonSuppressedByOverride()), and fixed duty with every sensor ignored skips all of them (Mode-by-mode active protections).
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 voltage measurement: it is used by the cross-sensor disagreement check and by the implausibility and near-zero/not-a-number checks below. When the owner runs without the main reading (Main battery voltage under Run Without Failed Sensors), BatteryV takes its place as the battery voltage for the software absolute cut and for the fixed-duty cap. 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, main battery voltage stale, two or more sensors faulted, critical temperature; with the main reading ignored, also second battery voltage stale and voltage implausible |
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, battery temperature lost |
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, regulator drive temperature lost, second battery voltage stale, voltage implausible, voltage disagreement critical |
Disabled / lockout ramp (MODE_DISABLED_RAMP, MODE_LOCKOUT_RAMP) |
Ramp to zero and hold. The disabled ramp serves the user On/Off switch, the external holds on its rung, and the battery-temperature wait (no cooldown). The lockout ramp runs while an already-armed cooldown counts down. | User charging Off, solar pause, BMS, finished-cycle idle, battery-temperature wait; cooldown countdown |
The ramp shape is user-tunable (DutyRampRate, default 40 %/s; ShutdownPhase2HoldMs, 500 ms; ShutdownRampTauS, 4 s; DutySlowRampRate, 0.5 %/s; SettleTimeBeforeCut, 1000 ms). The two duty rates are 12 V-system values, scaled by Field Winding Voltage over system voltage like the other field-duty settings: 10 and 0.125 %/s for a 12 V winding on a 48 V system, unchanged for a winding rated for the bank. 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):
- Phase 1 drops the field at
DutyRampRateto the keep-alive floor for the current engine speed (the most field that still makes about no current), then holds it forShutdownPhase2HoldMs. - Phase 3 takes the field from the floor to 0. Above the alternator's output point the battery limits the stator swing, so the field can fall fast there without disturbing the tach. Below it the swing shrinks with the field, and the LM2907 input's 20 µF coupling capacitor re-centers through 100 kΩ in about 2.1 s. A swing that shrinks faster than that leaves the input off-center, and engine speed reads zero for about 4.6 s (LM2907 recovery). Phase 3 therefore removes a fixed share of the remaining field each second, with time constant
ShutdownRampTauS.DutySlowRampRateis the slowest it may go, so the tail reaches 0. From a 27 % floor that is about 14 s; from 5 %, about 8 s. Two things override the slow decay. If the keep-alive floor drops below the field (the engine sped up), the field follows it down atDutyRampRate. If alternator current stays aboveNoOutputAmpsfor 0.3 s (output while the owner has switched off), the field falls atDutyRampRateuntil the output stops. That check is skipped while the alternator current reading is stale or ignored. - Phase 4 holds 0 % for
SettleTimeBeforeCut, then drops the field-enable line.
Mode and reason are decided separately, and where they differ the reason takes priority: 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 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 minimum →
RPM_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 else →
FIELD_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 period of 60 s with no fire resets it to 0.5 s. It is held 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: bulk, absorption, float, and the voltage-holding override modes; off in the idle stage). 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 caused 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 sets 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 disarm (Group 0, Group 5 and tach-lie), the layer's own toggle is the only switch that disarms it, apart from Run Without Failed Sensors, where ignoring Alternator current removes all three. 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 is now used only as the load-dump input and as the separate BattCurrentLimitA limit.
Overvoltage protections¶
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 turn the field off. The INA228 hardware alert is the final backup that works even if firmware hangs.
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 (
IBVplus the lookahead horizonTdPredtimes the smoothed rate-of-rise) exceeds the active target byOvPredMarginV, onceIBVis withinPRED_GUARDof the target. - Action: lowers
fastOvCurrentCapproportionally to the projected excess (slopeKHard, amps per volt). - Settings:
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 main 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 setting.
- Trigger:
ibvFiltabove the target byOvMeasMarginV(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 already returning toward target — the current is already being shed and the bus is dropping on its own, so re-clamping it every tick would only toggle the field drive 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
fastOvCurrentCapproportionally to the filtered excess (slopeKHard), and increments the lifetime soft-cap counter. - Settings:
OvGroup2Enable,OvMeasMarginV,KHard.
Timed over-voltage cut tiers (LOW and MID)¶
Two dwell-debounced field cuts placed between the proportional shed and the absolute rungs. They watch the same filtered voltage as Group 2 and handle 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 persisted 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:
ibvFiltheld abovetarget + margincontinuously 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 lowers the trip lines gradually with it. - Defaults (12 V lithium, everything × class/12): LOW tier at
target + 0.10 Vheld 0.4 s (OvTierLoMarginV/OvTierLoDwellMs); MID tier attarget + 0.20 Vheld 0.15 s (OvTierMidMarginV/OvTierMidDwellMs). The LOW tier deliberately uses the Group 2 shed threshold, 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 (voltage-hold control in Auto mode), plus any shutdown ramp while the field-enable line is still high. A ramp has no voltage loop, so its stand-down is "alternator current at or below
NoOutputAmps" in place of the zero-output latch. 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 — is 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 detecting 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 constantIExcessTau(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 atIExcessFloorA(5 A) and ceilinged atIExcessCeilA(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 whenIExcessKBleedis 0, otherwise bled proportionally — lowersfastOvCurrentCap, 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 draincv_Itwice. - 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 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 exceeded 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, set
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. A battery that disconnects while charging drives battery current toward zero instead, and the overvoltage protections catch that event as the bus climbs. 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) withLoadDumpN1(1 sample),LoadDumpDtThresh(5000 A/s) withLoadDumpN2(2 samples),LoadDumpDtThresh3(5000 A/s) withLoadDumpN3(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 thresholds are set above the slope noise measured on the shunt, and the multi-sample tiers also need the rise to hold across adjacent readings, which one noisy reading cannot supply. A sample is one INA228 reading, and both the counters anddBcur/dtadvance with every reading, about every 6 ms (up to about 18 ms worst case). Each tier's time to act is its count of readings plus the wait for the next control update, which cuts the output. Control updates come about every 13 ms (up to about 30 ms worst case). At the shipped counts that is about 6 to 19 ms for tier one (up to about 48 ms worst case) and 12 to 25 ms for tier two (up to about 66 ms). The dashboard shows each tier's figure under its 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.
- Settings:
LoadDumpDtThresh1,LoadDumpDtThresh,LoadDumpDtThresh3(amps per second) andLoadDumpN1–N3(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 Auto and Manual mode alike. Only running with no trusted battery voltage at all turns it off: fixed duty with every sensor ignored, or the main reading ignored while the second one is ignored or not reading. With only the main reading ignored it runs on the second reading (BatteryV). There is no warning-ramp path (the mode selector nominally returns the warning-ramp mode, but the reason forces the immediate cut).
The cut zeroes the PWM and drops the field-enable line on the same tick at any exceedance, so there is no slew stage to shorten.
The threshold (AlternatorHardShutdownV, "Alternator Hard Shutdown Voltage") is an absolute, persisted user setting, one rung below the hardware shutdown so the software cut always acts first:
- Lithium default 14.2 V on a 12 V bank — 0.1 V below the hardware shutdown, which itself is set 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 have a maximum 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 created 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 is 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 (theSLOW_ALERTbit), 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.5 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 are implemented 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 — 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 setsinaOvervoltageLatched. 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 check at 3 s for transient spikes, a final check 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 brief pin assertion that clears before that read still cuts the field electrically but may not be logged. - Threshold upkeep: a chip that resets comes back with its over-voltage limit at the power-on maximum, which turns the hardware cut off. The firmware therefore re-applies the threshold whenever the chip answers again after a run of I²C errors, and reads it back every 30 s, re-applying it on a mismatch.
- 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. The pin acts with no firmware involvement in Auto mode, Manual mode and fixed duty alike. The software latch is skipped only while the owner runs without the main battery voltage, or in fixed duty with every sensor ignored.
When both fire¶
The software hard cut is set 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.5 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 detects that 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 actions of the charging loop's derivative term, each time it corrects a voltage near the target that is moving faster than its deadband (actions under 1 s apart count once), rather than protection trips, and is recorded here alongside the others.
It is stored 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 is also included in the daily cloud configuration snapshot, so a copy of the history is kept in the cloud.
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 difficult part of a protection is recovery: if current returns too fast, voltage rises straight back into the same protection; if it returns too slowly, the battery voltage sags for seconds while charging current stays low. The Group 1–2 trims, both current-excess supervisors and load dump share one recovery handler, which runs once the last of them has released.
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) with a battery shunt, ReseedFracNoShunt (0.5) without one. Smart Reseed (reseedCorrEnable, on by default) refines that: with a shunt, any house load the shunt measured leaving the bus at the fire is subtracted first, and with or without one, a protection that fires again within 4 s of release reseeds at 0.7 of the previous seed. The result is a modest restart, so the field does not step 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 closes, raising the current gradually to a ceiling:
- The ceiling is the current that was holding the target before the trip. It is the larger of a slow average of the loop's command measured during clean operation near the target (
cvSteadyHoldEma) and the command just before the trip, less any house load the shunt measured leaving at the fire. The slow average matters because 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 commanding more can only overshoot back into the protection that just fired. Near the end of the rise toward target the ceiling is lowered further — the arrival flare: insidecvRecovFlareBandV(0.35 V per 12 V of bank) of the target it tapers from the full holding current down tocvRecovFlareFrac(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 normal integration rate. - No separate error cap. Because the refill uses 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 recovery stays bounded without a dedicated voltage-error cap.
- The exits: the refill ends when the deficit is closed and the integrator has climbed back to the ceiling, or when the bus has held at the target for about a second. If the pre-trip current no longer holds the target (voltage stuck low and not rising with the full ceiling already commanded), the ceiling rises at a bounded rate rather than holding the field too low, and the refill ends once it reaches the alternator's own current ceiling. A new protection fire or voltage control exiting ends it immediately.
Turning it off tests the loop on its own: with it off, recovery is the bare loop plus the reseed and the normal anti-windup bleed, so the gains must be tuned to recover from 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 current-excess hold has lasted long enough for the field coil to bleed out its stored energy (the measured field-drain time at the engine speed when the episode began, from 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 Group 1–2 trim, which have no hold to release.
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, Auto and Manual mode, + adaptive lockout |
| Chip-averaged voltage over the hardware limit (lithium 14.3 V, flooded/AGM 16 V) | INA228 hardware alert | Physical field cut |
| 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.5 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 Auto mode only (they are below the manual branch).
- Disagreement warning — the two sensors differ by more than
VoltageDisagreeThreshold(0.15 V on a 12 V bank) continuously forVoltageDisagreeTimeout(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(). - Implausibility — both 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. With either reading ignored under Run Without Failed Sensors, the disagreement checks are off and plausibility is judged on the other reading alone.
Sensor faults¶
There is no automatic fallback. A required sensor that stops reading takes the field off through its own rung of the ladder, the alarm sounds (with Alarm on General Fault Condition on, as shipped), and charging stays off until the reading returns or the owner chooses to run without that sensor under Setup → System → Run Without Failed Sensors. The header line names the sensor either way. The alarm itself stays silent at rest, with the engine stopped and the ignition or the On/Off switch off.
- Main battery voltage stale (
REASON_VOLTAGE_STALE): no valid INA228 read, counted as ten failed reads in a row or 1 s without a good one while the field is on, and three reads or 5 s while it is off. Immediate cut, Auto mode only. - Second battery voltage stale (
REASON_VOLTAGE2_STALE): four failed ADS1115 battery-voltage reads in a row, 3 s without a good one, or the ADS1115 gone. Critical ramp + 30 s lockout. - Alternator current stale (
REASON_CURRENT_STALE): no in-range reading for 10 s. Immediate cut. - Alternator temperature stale: T5 under Temperature protections.
- Engine speed: a dead ADS1115 or a tach channel that stops updating reads as a stopped engine, which the RPM gate acts on. A tach that reads while the alternator makes nothing is the tach-lie cut.
- Regulator drive temperature lost (
REASON_DIE_TEMP_LOST): no valid INA228 die reading for 10 s. Critical ramp in Auto and Manual mode. The reading comes from the same chip as the main battery voltage, so running without the main battery voltage is its only override. - Battery temperature lost (
REASON_BATT_TEMP_LOST): see Where the battery temperature comes from. - Two or more at once (
REASON_MULTI_SENSOR_FAULT): two or more required sensors faulted together cut the field immediately with the 30 s lockout. A dead chip counts as every reading it carries, and the multi-fault hold clears 10 s after the count drops back to one or none.
Each sensor on the Run Without Failed Sensors screen can be ignored on its own, and the screen lists what that costs before Apply is enabled. Ignoring a sensor skips every rung that depends on it. Ignoring Alternator current switches to voltage-only regulation under a field duty cap. Ignoring Main battery voltage switches to a fixed field duty, held at 0 % whenever the second battery voltage reads above the Bulk Voltage setting, or plain fixed duty when the second reading is ignored too. Battery temperature cannot be ignored; a boat without one sets its source to Not fitted.
Temperature protections¶
All temperature protections act on TempToUse — the alternator temperature probe by default, or the thermistor input if selected (TempSource). Running without the alternator temperature (Alternator temperature under Run Without Failed Sensors, 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. Auto mode only. It ramps the field duty down to zero but leaves the field-enable line up.
T2 — sustained warning¶
The T1 condition held continuously for TempSustainedTimeout (2 minutes): the field is cut. The escalation exists because repeatedly entering 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. Auto mode only — it is at the critical rung, below the manual branch, so taking manual control bypasses it. In Manual mode an overheating alternator is not cut by T3; the protections that stay live in Manual mode are the over-voltage and over-current trips above the manual branch and the drive-temperature check.
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 stops updating its heartbeat. This raises the buzzer condition; the actual field cut comes from T5, which fires when the readings stop. 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, the battery-health resistance rows and capacity trend, and the first-start state-of-charge estimate all read the same number: battTempActiveF, produced once per control tick by batteryTempF() (6_functions.ino) and mirrored into the tick snapshot. The state-of-charge estimate runs before the first tick, so it calls batteryTempF() itself. With no source qualifying, each of them runs with no battery temperature: no lockout, a flat 1.0 derate, no 25 °C column on the resistance rows, no temperature correction on the capacity trend, and an uncorrected resistance term in the state-of-charge estimate. The regulator's own board temperature is never used in their place. (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 (Automatic by default), and the source 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 falls back to neutral, and charging stops unless the source is Not fitted (below) |
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 Automatic the chain is walked in exactly that order and the first qualifying source is used. Setting battTempSource to 1–4 pins one source (and reports 0 when that one source is unavailable rather than silently substituting another); 6 is Not fitted, and a stored 5 from an older firmware loads as Automatic. 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.
Every setting except Not fitted expects a reading, and a missing one is a fault. Until the source delivers its first reading (the first 90 s after boot or after a source change), the field is held off with no lockout and no alarm (REASON_BATT_TEMP_WAIT), so charging starts the tick the reading arrives. After that, a source with no reading ramps the field off with the 30 s lockout and the battery-temperature alarm (REASON_BATT_TEMP_LOST) until a reading returns or the source is set to Not fitted. Both are Auto mode only. Not fitted declares that the boat has no battery temperature: the lockouts and the voltage-loop gain derate stay off and nothing stops charging.
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) belowMinChargeTempF(default 40 °F). - Action: the field ramps to zero on the warning ramp 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. - Missing reading: a lockout already in force stays latched while the reading is missing. It clears only on a real reading past the hysteresis, when the source is set to Not fitted, or when the lockout is turned off. A reading that never arrives is the battery-temperature-lost fault above, which stops charging on its own.
- Scope: Auto mode only (it is below the manual branch).
- Default ON (
coldChargeLockoutEnable = true). A lead-acid-only installation may turn it off. - No sensor: an installation with no battery-temperature source sets the source to Not fitted and then has no lockout. To use the lockout, 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:
battTempActiveFaboveMaxChargeTempF(default 122 °F), with the active source one of codes 1–4. - Action: identical to the cold lockout — warning 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: Auto 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 the boat has a battery temperature from any source; without one 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 Auto mode and Manual mode. It trips only on a fresh sample, so a dead sensor's last value cannot hold it. The global test switch cannot disarm it; its Group 0 toggle (HardOCEnable, default on) can, and so does running without the alternator current under Run Without Failed Sensors.
Current alarms (buzzer only)¶
Two thresholds drive the buzzer but never affect 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, withIgnoreRPMoff) cuts the field — there is no reason to excite the field on a stopped or stalling engine. Cutting an energized field takes two low readings in a row, the same rule as the spin-down cut below: the field is cut on the second one, about 40 ms after the first low reading (up to about 80 ms with the field on, 160 ms at most if no reading arrives). A field that is already off is held off from the first low reading. A lone jump from near zero to above 1000 RPM is held back at the input (see Sensors), which repeats the previous low reading in its place. Whether the gate counts that repeat as the second low reading depends on the tach-lie cut below. While the tach-lie cut is armed (TachLieEnableon, none of its exemptions in force, the loop commanding more thanNoOutputAmps, and applied duty at or above its arm bar), the gate does not count it. A single false reading under 100 RPM at cruising speed makes the input repeat that false reading in place of the next real reading, so counting that repeat would let one false reading cut a charging field. On a stopped engine whose second reading is such a jump, the cut then comes with the next reading if it is also low, or at the 160 ms limit if no reading arrives first. If that next reading is at or above the minimum, the gate treats the engine as turning, and a false reading that keeps the field driven with no output is left to the tach-lie cut below, whose arm bar then follows the speed the false reading shows. While the tach-lie cut is not armed, nothing else would stop that false reading, so the gate counts the repeat, as the spin-down cut does. The field is then cut when the repeat arrives, about 40 ms after the first low reading, or at 160 ms if no new reading arrives. In that state a single false reading at cruising speed can cut a charging field. The gate is below the manual branch, so it is Auto mode only (it does not fire in Manual mode). 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 aboveMinRPMForField, and above zero, continuously forRPM_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. SettingMinRPMForFieldto 0 removes the below-minimum cuts (this gate and the spin-down cut below) and nothing else: the engine-stopped fast cut still takes the field off at a zero reading, and the restart confirmation then holds it off until engine speed reads above zero for 2 s. Running without a working tach is whatIgnoreRPMis for. - Engine-stopped fast cut. When RPM is held at exactly 0 for
RPM_ZERO_CUT_MS(200 ms) and a second, newer reading in that time is also 0, the field is cut immediately (pre-gate, forced reasonREASON_RPM_TOO_LOW), overriding any graceful shutdown ramp in progress. RPM refreshes about every 40 ms, so the second reading arrives well inside the 200 ms. If no newer reading arrives, the cut fires 300 ms after the first zero reading (RPM_ZERO_MAX_WAIT_MS), longer than the slowest field-on read interval measured (283 ms), so a single zero reading never cuts while the tach is still reporting. WithMinRPMForFieldabove 0, the basic gate or the spin-down cut has already taken the field off by then, and the confirmed zero arms the restart confirmation. It fires in Auto mode. The field resistance test is exempt because it runs on a stopped engine by design, and Manual mode is exempt: the priority ladder puts the manual rung above the RPM gate, so the cut and the manual path alternated control of the field 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 takes priority over 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, the cut is finished at once rather than slow-ramping. The staged ramp serves no purpose below the field minimum, and an energized field only produces false tach readings. The cut needs two low readings in a row and comes on the second one, so one false low reading cannot cut the field mid-ramp. RPM refreshes about every 40 ms, so the cut comes about 40 ms after the first low reading (up to about 80 ms with the field on). A lone jump above 1000 RPM right after a near-zero reading is held back at the input (see Sensors), so it counts as the second low reading and the cut comes on it. If no new reading arrives at all, as when the ADS1115 stops answering, the cut fires anyway 160 ms after the first low reading (
RPM_BELOWMIN_MAX_WAIT_MS), twice the slowest field-on read interval measured. -
Tach-lie plausibility cut. The tach reading shows the engine running, the field is being driven at high duty, 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 thanNoOutputAmps(default 2 A), applied duty at or above the arm bar, and alternator current magnitude belowNoOutputAmps, all held forTACH_LIE_DWELL_MS(3 s). A lone reading belowMinRPMForFielddoes not restart the 3 s count. Only two low readings in a row do, by the rule and timing the basic gate uses while this cut is armed, and outside the grace mask described below the basic gate cuts the field on that same reading. A phantom that dips below the minimum now and then is therefore still cut within about 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 plusTACH_LIE_HEADROOM_FRAC(15 %) of the remaining span up to the field ceiling, because duty held 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. With engine speed ignored (IgnoreRPM) the detector still runs: the ignition input replaces engine speed as the engine-running condition, the arm bar starts from the largest row of the minimum-field table, and it is then the cut that stops the field when the engine stalls with the key on; only the lockout tier running out releases it. Manual mode, the field resistance test and running without the alternator current 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 abrupt field collapse leaves the tach input off-center for about 4.6 s and a false spike in that window could read as a rev-up, then speed must hold above 1.5× the latched trip speed forTACH_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 false readings the tach front end produces afterwards are 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 a registered task stops feeding it for sixteen seconds (WDT_TIMEOUT_MS). The main loop and the HTTPS upload task are registered (the temperature task is not); 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 block, as designed, 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 control 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 state of charge from the battery shunt or a battery monitor, with SoC Info Available on) |
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 last three rows, and the other faults with no threshold of their own (a required sensor that stops reading, a battery shunt or VE.Direct feed that stops reporting, a lost ADS1115 or INA228), sound only while GeneralFaultAlarm is on, as shipped. With it on, 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). The cold and hot charge lockouts sound regardless of GeneralFaultAlarm.
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 task-hang and hardware-overvoltage conditions, which are raised even while disarmed.
Dashboard controls¶
The Alarms panel exposes all of the above:

| Dashboard control | Firmware toggle |
|---|---|
| Alarm Enable (Off / Armed) | AlarmActivate |
| Alarm Status indicator | Alarm_Status |
| Alarm on General Fault Condition | GeneralFaultAlarm |
| Temperature / voltage / current thresholds | the settings in the conditions table |
| Alarm Latch Mode (Momentary / Latched) | AlarmLatchEnabled |
| Test Buzzer (2s) | AlarmTest |
| Reset Latch | ResetAlarmLatch |
| Silence (mutes the fault that is sounding) | SilenceAlarm |
Mode-by-mode active protections¶
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. Fixed duty, ignore every sensor (LimpHome, the last row of Run Without Failed Sensors) holds the field at the Field duty cap entered on that screen (LimpDutyPct, default 30 % at 12 V, scaled by Field Winding Voltage over system voltage) with no sensor consulted. It still responds to the On/Off switch, the ignition and the BMS input, and it runs behind the same pre-gate as Auto mode, but every sensor-dependent reason is suppressed (reasonSuppressedByOverride()).
| Protection | Auto | Manual | Fixed duty |
|---|---|---|---|
| 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 | — |
| INA228 hardware alert (physical) | Active | Active | Active |
| INA228 software latch | Active | Active | — |
| Hard overcurrent | Active | Active | — |
| Sensor disagreement (warning + critical) | Active | — | — |
| Voltage implausible | Active | — | — |
| Main / second battery voltage stale | Active | — | — |
| Two or more sensors faulted | Active | — | — |
| Cold-charge lockout | Active | — | — |
| Hot-charge lockout | Active | — | — |
| Battery temperature lost / wait | Active | — | — |
| T0 thermal derating | Active | — | — |
| T1 / T2 temperature warning | Active | — | — |
| T3 critical temperature cut | Active | — | — |
| T4 temperature task hang (alarm) | Active | Active | Active |
| T5 temperature data stale | Active | — | — |
| Regulator drive temperature lost | Active | Active | — |
| RPM basic gate (below minimum) | Active | — | — |
| RPM engine-stopped fast cut (RPM = 0) | Active | — (exempt by design) | — |
| Tach-lie plausibility cut | Active | — | — |
| Current-data stale | Active | — | — |
| 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 are below it). In the fixed-duty column it means the protection depends on a sensor and is suppressed, or belongs to the closed-loop control that fixed duty replaces.
Manual keeps only what is above its rung (the hardware alert, the software absolute-OV trip, hard overcurrent, the drive-temperature cut, 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 mode, and it is explicitly exempted there for the same reason. The drive-temperature cut stays because manual duty is still clamped by the field-drive heat ceiling, which cannot follow the regulator's temperature without the die reading, so it is checked just above the manual rung. Running without the INA228 (Main battery voltage under Run Without Failed Sensors) turns it off in Manual mode as in every other mode. Fixed duty keeps the least: the INA228's physical alert pin, the temperature-task alarm and the watchdog. The screen asks for an explicit acknowledgement before it applies.
One consequence of "first match wins": a reason that does not cut can mask a lower one that would. The ladder therefore ranks every immediate-cut sensor fault above the ramp reasons (current-data stale and critical temperature are above the battery-temperature lockouts), so a current sensor that dies while a battery-temperature ramp is running still cuts the field at once.