Boomslang

A 3-channel ESP32-S3 pyrotechnic/model-rocket ignition controller. Hardware
(KiCad project, schematic, gerbers) is under Boomslang/; firmware is under
firmware/ (PlatformIO, Arduino framework).
⚠️ Disclaimer — use at your own risk
This project fires pyrotechnic devices and/or model rocket motors. Incorrect
wiring, misconfiguration, a firmware bug, a hardware fault, or plain
operator error can cause unintended ignition, fire, injury, or property
damage. This is a hobbyist project, provided AS-IS, with NO WARRANTY OF
ANY KIND — express or implied, including no warranty of merchantability,
fitness for a particular purpose, or that it is safe, correct, reliable, or
free of defects.
By building, modifying, or using this hardware or firmware, you accept full
responsibility for:
- Verifying correct operation yourself, on your own hardware, before relying
on any of it — including every safety feature described in this README.
- Complying with all applicable laws, regulations, and permitting
requirements for pyrotechnics, explosives, and/or model rocketry in your
jurisdiction.
- Following standard range-safety practice: safe distances, no bystanders
in range, appropriate PPE, a real mechanical arm switch you trust, and
never treating any software feature here as a substitute for that.
The author(s) and contributors accept no liability for any injury,
death, damage, or loss arising from building, modifying, or using this
project, to the fullest extent permitted by law.
License
Dual-licensed by design, since this repo mixes source code and hardware
design files:
- Firmware (
firmware/) is licensed under the MIT License.
- Hardware design files (
Boomslang/ — KiCad schematic, PCB, gerbers)
are licensed under CC BY-SA 4.0.
Both are permissive and allow commercial use, but neither one waives the
disclaimer above — "you can use/modify/redistribute this" is a separate
question from "this is safe or fit for any particular purpose."
WiFi access
The board hosts its own WiFi AP (no router needed) — SSID and password are
persisted settings (wifiSsid/wifiPassword, settings page), defaulting to
boomslang / liftoff!. The password is this device's only access
control — there's no separate login on top of it, so anyone who has it can
arm and fire. Change it from the default before using this anywhere it
matters. A password can also be left blank for an open, unencrypted network,
but that isn't recommended for the same reason.
Changes take effect only after the device is power-cycled or reset (the AP
isn't re-initialized at runtime) — you'll need to reconnect using the new
name/password afterward. SSID must be 1-32 characters; password must be
empty or 8-63 characters (the minimum WPA2-PSK allows).
The control pages are served over plain HTTP, not HTTPS — WPA2 already
encrypts the WiFi link itself, and adding TLS on top would mean a
self-signed certificate that a phone would flag as untrusted on every visit,
plus added latency on the page's frequent status polling, for a narrower
additional threat (another authenticated client on the same AP sniffing
traffic) than what WPA2 already covers.
Relay (station) mode (wifiStationMode, default off): instead of
hosting its own AP, the device can join wifiSsid/wifiPassword as a
client of an existing router, to relay through it when the operator needs
more distance from the pyrotechnics than the device's own AP range allows.
If it can't connect within 15 seconds (wrong password, out of range, router
down), it falls back to hosting its own AP instead — so a bad relay network
never leaves you locked out of the device with no way to disarm or abort.
This setting is deliberately not a checkbox on the settings page:
enabling it means anyone who can reach that external network can also reach
this device, so it requires typing the literal word relay into a text
box rather than a single accidental tap — blank or anything else both mean
"stay off, host my own AP" (the safe default), including if the field is
missing from the request entirely.
Before you enable this, secure that router/network yourself — it's now
part of your access control, not just Boomslang's own password. In its
own AP mode, WPA2 on the device is the entire perimeter. In relay mode,
that perimeter is whatever the router enforces: WPA2/WPA3 with a strong
password of its own, no other untrusted devices or guests on it, and
ideally a network used for nothing but this. A relay network that's open,
weakly secured, or shared with devices you don't control gives anyone on
it the same access as someone standing next to the device with its own
password — arming and firing included.
Either way — hosting an AP or joined as a station — the device also starts
an mDNS responder, so http://boomslang.local works regardless of which
mode ended up active or what IP a router's DHCP server happened to assign.
Web UI pages
Four pages, all polling /status.json and posting to the same handful of
endpoints — the split below is purely how the browser organizes things, not
a backend/data-model split. Each has a small nav row (Main · Timing · Stats
· Settings) so it's obvious how to get back to the others.
/ (Main) — the operational screen, meant for actual field use:
arm/fault/lockout status, battery voltage, each channel's checkbox + live
continuity/firing state, and TRIGGER/ABORT/PANIC. Deliberately lean —
timing setup and post-fire review live elsewhere so this screen stays fast
to read and tap through during an actual arm/fire sequence.
/timing — setup screen: the same channel checkboxes as Main (same
/select endpoint — selection can be changed from either page) plus each
channel's offset/duration in milliseconds and optional PWM cycling
(Hz/duty%, see Trigger sequence below). No live operational state and no
TRIGGER/ABORT/PANIC here.
/stats — read-only review: peak and average current per channel
from its most recent pulse (see Trigger sequence below).
/config (Settings) — every persisted setting described throughout
this README.
Overcurrent fault protection
Each channel's IRLZ44N MOSFET has its own analog overcurrent comparator
(transistors Q5/Q10/Q16, sensing the 0.05Ω source shunt) that pulls that
channel's own gate to GND in hardware, in nanoseconds, with no firmware
involved at all. That's the actual protection for the transistor. All
three channels' comparators also pull a single shared, open-collector,
active-LOW FAULT line (GPIO1) — since it's wired-OR across all three,
firmware can tell that a channel faulted, but not which one.
The 0.05Ω figure above is just the schematic default (R1/R17/R31) — the
per-channel shunt resistance (senseOhms, settings page) is a runtime
setting used for the firing-current math (readCurrentA()), independently
of the comparator's own fixed hardware trip point. Valid range is
0.01Ω–100Ω, to accommodate a field-replaced shunt of a different value.
The fault ISR (onFaultISR in firmware/src/main.cpp) is a level-1
attachInterrupt on FAULT, IRAM_ATTR, and deliberately minimal: it does
exactly two things, a single register write forcing all three TRIGGER
outputs HIGH at once (not just the channel that faulted — the shared line
can't tell which one anyway, so every channel is shut down for consistency)
and setting a sticky faultLatched flag. Nothing else runs in the ISR
itself — no Serial, no heap, and critically no analogRead(), since the
ESP-IDF ADC driver takes a FreeRTOS mutex internally that loop() is
already holding on a regular basis; taking it again from ISR context would
be undefined behavior.
Sampling SENSE for diagnostics (which channel was drawing how much
current right as the fault hit) therefore happens in a separate,
dedicated FreeRTOS task (faultSampleTask), woken by the ISR via
vTaskNotifyGiveFromISR/portYIELD_FROM_ISR so it runs essentially the
instant the ISR returns, ahead of loop() or anything else. It's pinned to
the core opposite the Arduino loop/web server, at a priority high enough to
preempt normal code but below WiFi's own internal tasks. This snapshot is
best-effort — the hardware protection may have already cut current before
it runs — and is exposed as fault_snapshot_a in /status.json and shown
in the FAULT banner on the control page. If the banner reads "FAULT —
overcurrent detected" with no (X.XXA Y.YYA Z.ZZA) after it, that means
fault_snapshot_a was never populated for this event — either
writeTriggerPinBlanked()'s own fallback latched the fault directly
(covered below; it takes its own snapshot too, since it's normal task
context rather than ISR context and can safely call analogRead()
itself, unlike onFaultISR()), or a fault was latched some other way that
doesn't notify faultSampleTask (the boot-time check, for example).
Clearing a fault: faultLatched is a software latch, not
self-clearing. It clears automatically — no button, no endpoint — the
instant the device is DISARMED and the hardware FAULT line re-checks
clear (several reads over ~2ms, so a line chattering right at the trip
point doesn't get cleared into a still-live fault; see loop()'s
DISARMED-only fault-clearing pass in main.cpp). It's tied specifically
to being disarmed — never while armed, so a fault mid-sequence can't clear
itself out from under an operator who hasn't actually disarmed — which
matches the fact that a disarm+rearm is already required before
retriggering anyway (see faultLockedOut() below); there was no reason to
also require a separate manual "clear fault" button on top of that. A
reboot doesn't clear a persistent fault either: FAULT is checked at
boot, before the ISR is even attached, and latches immediately if still
asserted.
Fault lockout (faultLockedOut()/notifyFaultOccurred(),
arm_state.cpp): independent of faultLatched above — that flag only
tracks whether a fault is currently asserted/unacknowledged, and clears
automatically as soon as it's genuinely resolved and the device is
disarmed. This is a separate, sticky flag: once any fault has occurred, it
stays set — same shape as the PANIC lockout — unconditionally requiring a
fresh disarm+rearm before another TRIGGER is accepted, regardless of
requireRearmAfterFire, even after the fault itself has already cleared.
Cleared at the same disarm→rearm transition point every other lockout is.
Live FAULT line indicator: the main control page also shows a
FAULT line: clear/ASSERTED readout (fault_pin_active in
/status.json, a plain digitalRead(PIN_FAULT) taken fresh on every
poll), separate from the latched fault flag above. Since faultLatched
only clears automatically while disarmed, this raw reading is what tells
you whether the hardware comparators are asserting the line right now —
useful for telling "latched from a past event, line's gone high again"
apart from "still actively faulted."
Leading-edge blanking on trigger-pin switching: on real hardware,
pressing TRIGGER with no igniter/load connected was found to produce an
immediate FAULT with a 0A current snapshot — a switching transient at the
moment a trigger pin changes state, not a real overcurrent event (confirmed
by walking the actual shunt/comparator/snubber netlist: the RC snubber ties
drain-to-GND directly, not through the sense shunt, so there's no real
current path for a genuine fault with nothing connected). firmware/src/ main.cpp's writeTriggerPinBlanked() now wraps the two deliberate
trigger-pin writes (fire-start in startFirePulse(), and the normal
pulse-end release in loop()) in a short critical section
(FAULT_BLANKING_US, config.h, default 10µs — tuned down from an
initial 30µs guess after bench testing confirmed the real switching
transient clears well within that window): interrupts are disabled
for that window, the pin is written, and FAULT is checked once directly
afterward — if it's already resolved, that reading is trusted and the
event is treated as filtered, not a fault.
Two subtleties this required getting right, since a plain
delayMicroseconds()-based wait would not have been safe here:
noInterrupts()/interrupts() (not detachInterrupt()/
attachInterrupt() + a bare delay) bounds the window deterministically —
a plain busy-wait doesn't block task preemption, so a higher-priority
task/ISR becoming ready mid-wait could otherwise extend the real gap well
past FAULT_BLANKING_US. delayMicroseconds() itself is safe to call
with interrupts disabled: it polls micros(), which reads a free-running
hardware counter that keeps advancing regardless of the core's
interrupt-enable state — unlike delay()/vTaskDelay(), which actually
depend on the tick interrupt to wake back up, and would hang here.
- Disabling interrupts at the CPU level only defers servicing — it
doesn't stop the GPIO peripheral's own edge-detector from latching a
FALLING edge into its interrupt-status register during the window. Left
alone, a transient that resolves inside the window would still fire
onFaultISR() retroactively the instant interrupts are re-enabled, for
an edge that's already history. writeTriggerPinBlanked() explicitly
clears that latched status bit (GPIO_STATUS_W1TC_REG) before
re-enabling, so a resolved transient is actually discarded.
None of this touches the real transistor protection described above — the
per-channel analog comparators keep working purely in hardware throughout,
completely independent of whether this interrupt is masked. What's
filtered is only the shared-line firmware echo (forced shutoff + the
sticky latch) reacting to a switching artifact instead of a real fault.
Why FAULT_BLANKING_US isn't 0: it's tempting to think a shorter
window is strictly better (less time other channels go unprotected by the
shared-line echo — the per-channel hardware protection is never affected
either way). Bench testing found a real lower bound, though: at 0
(no wait at all), a genuine fault could be missed by writeTriggerPinBlanked()
entirely rather than filtered — the comparator's own output hadn't
necessarily finished settling into a stable level by the instant this
function checks it, so a real, ongoing fault could read as
"already resolved" and get discarded, only to be caught later by some
other, slower path with no diagnostic snapshot at all. 10µs reliably
avoids that while still filtering the original no-load transient. The
delay also determines where on the fault current's natural rise/decay
curve the diagnostic snapshot below lands: shorter delay samples closer to
the true peak, longer delay samples further into the decay (current
doesn't collapse to zero the instant the comparator trips — the driver
stage and any loop inductance both take some real, if small, additional
time). 10µs was chosen empirically on the bench, not derived
analytically — if the real transient/settling duration ever needs to be
known precisely, DEBUG_FAULT_TIMING/GPIO13 above is there for exactly
that.
writeTriggerPinBlanked()'s fallback and multi-channel shutoff: if
FAULT is still asserted once the blanking window elapses, this function
also samples SENSE for its own diagnostic snapshot (same reasoning as
faultSampleTask above — normal task context, so analogRead() is safe
here too) and forces every trigger pin off via the same atomic register
write onFaultISR() uses, mirroring what the ISR does for a fault that
isn't coincident with a trigger-pin write. This closes a real gap found
during testing: with a real load on one channel and a short on another,
if the short's fault was caught by this fallback (i.e. it happened right
at that other channel's own trigger-pin write) rather than by
onFaultISR(), the loaded channel could keep firing for longer than
intended — this function used to only set the sticky fault flags and rely
on loop()'s later stopSequence() call to shut off other channels,
reached only after that iteration's other work (continuity checks, etc.).
Forcing every pin off directly here, immediately, closes that gap the
same way the ISR always has.
The sampling happens before that forced shutoff, deliberately: forcing
every channel off first, before sampling, was found to reliably collapse
the diagnostic reading to 0A — unlike the local hardware protection, which
reacts in true analog time and was never the bottleneck here,
analogRead() itself is not deterministic (the underlying ESP-IDF driver
power-cycles the whole ADC peripheral and takes an internal mutex on every
single call), so forcing a shutoff immediately beforehand left nothing
meaningful to measure by the time sampling actually ran. Sampling first
trades a small amount of additional, ADC-read-bound latency before other
channels are forced off, for a snapshot that's actually usable — still
bounded well under the IRLZ44N's ~1ms SOA tolerance, just no longer
effectively instantaneous the way the plain register write is on its own.
Why a plain level-1 interrupt is enough: the IRLZ44N's SOA tolerates ~1ms at
full overcurrent, and even level-1 ISR latency on this chip is reliably
single-digit microseconds — comfortably inside that margin, especially
given the hardware analog protection has already started cutting current
before the ISR even runs. A DEBUG_FAULT_TIMING build flag (on by default
in config.h) toggles a spare pin (GPIO13) around ISR entry and the end of
SENSE sampling, so real end-to-end latency can be measured on a scope once
hardware exists.
DEBUG_DISABLE_FAULT_SHUTOFF (config.h, default off): a
bench-debug-only build flag used while diagnosing the switching transient
above. When on, it skips firmware's forced-HIGH/stopSequence() echo on a
fault, letting an in-progress pulse keep running through a FAULT trip
instead of being cut short — useful for observing what's actually
happening with no load connected. faultLatched is still set and still
blocks new triggers until disarmed and genuinely resolved, same as
always; only the forced shutoff of an already-running pulse is skipped.
It does not and cannot
affect the real per-channel hardware protection described above, which
runs in pure analog and has no firmware involvement at all. Must be off
for any real use with igniters/pyrotechnics connected.
Audible / visible arming indicators
The board carries a buzzer output (J6, driven via AUDIBLE/GPIO41) and
onboard white/blue strobe LEDs (driven via VISIBLE/GPIO42). Both are owned
entirely by the firmware's arm state machine (firmware/src/arm_state.cpp)
— there's no manual on/off control for them, since a manual override would
just fight the automatic pattern.
When they run: only while the arm loop (the physical switch at J5) is
closed — i.e. during the COUNTDOWN and READY states. They're silent and
dark whenever the device is DISARMED; there's nothing to warn about when
the loop is open.
Why two different patterns: so the state can be told apart by ear or by
eye alone, without needing to look at the web UI:
| State | Tone | Pulse rate |
|---|
COUNTDOWN | 440 Hz | ~1 Hz (slow, 500ms on/off) |
READY | 1000 Hz | ~2.5 Hz (fast, 200ms on/off) |
A low, slow pulse means the countdown is still running and firing is not
yet permitted (giving whoever closed the arm switch time to clear the area).
A higher, faster pulse means the countdown has elapsed and the device is
ready to fire.
Tone stability: updateArmState() runs every loop() iteration
(unthrottled, so potentially thousands of times per second), but only
touches the buzzer's LEDC output on an actual change — turning on, turning
off, the frequency changing, or the volume changing — rather than every
iteration. Arduino's tone()/noTone() aren't used here: tone()
reprograms the ESP32's LEDC PWM peripheral and resets phase on every call
(producing an audible click if called continuously — the original cause of
a choppy/glitchy buzzer sound), and it always drives a fixed 50% duty
cycle, which rules it out for volume control (below) regardless. Frequency
and duty are set independently instead, via ledcChangeFrequency()/
ledcWrite() (AUDIBLE_LEDC_CHANNEL, config.h).
Volume: speakerVolume (settings page, 0-10, default 10) maps
linearly to 0-50% PWM duty cycle — 50% is as loud as a square-wave drive
gets (maximum RMS), so 10 reproduces the same loudness tone() always
used before this was configurable. Recomputed every updateArmState()
call, so changing it takes effect immediately, live, with no disarm/rearm
needed.
Settings: each channel can be silenced independently via the settings
page (/config on the web UI):
visibleWhenArmed — strobe LEDs flash during COUNTDOWN/READY (default on)
audibleWhenArmed — buzzer sounds during COUNTDOWN/READY (default on)
speakerVolume — buzzer loudness, 0-10 (default 10, see above)
All are persisted to flash (survive power cycles) and, like all settings
changes, can only be saved while the device is disarmed.
Trigger sequence
Firing isn't one button per channel — the main control page lets you select
which channels to fire and, per channel, how long after pressing TRIGGER
that channel should fire, then fires the whole configured sequence off a
single press.
Per channel:
- A checkbox, on both the Main and Timing pages, to include that channel in
the next trigger. Not persisted — it resets on every page load, so a
stale "everything selected" state from a previous session can never carry
over silently. It also resets on every device reboot, which the page
detects on its own:
/status.json's uptime_ms resets to a small value
on every boot, and if a poll ever sees it drop compared to the previous
poll, that means the device restarted since then — the page then rebuilds
its channel list from scratch on the next poll, so a browser tab left
open across a power cycle can't keep showing checked boxes that no longer
match the device's actual (cleared) selection.
- An offset in milliseconds (0-60000, default 0), on the Timing page,
from the TRIGGER press to that channel's trigger output going low.
- A duration in milliseconds (0-30000, default 500), also on the Timing
page, that the trigger output then stays low.
Offset and duration are both persisted settings
(channelDelayMs/channelDurationMs), saved to flash the moment you change
either one — same as every other setting, they survive a power cycle, so a
fixed show sequence doesn't need retyping every session. They're plain
milliseconds rather than seconds specifically for finer-grained control over
short pulses. Also same as every other setting: saving to flash requires
the device to be disarmed — a change made while armed still applies
immediately in RAM for that session, but won't survive a reboot until you
disarm and it actually gets persisted.
PWM cycling (channelPwmHz/channelPwmDutyPercent, Timing page, per
channel): instead of being continuously on for the whole duration, a
channel can instead cycle on/off within that same duration window — for a
lower average heating rate than a full-on pulse (e.g. slow-heating
nichrome/resistive element rather than an instant e-match). channelPwmHz
(0-300Hz, default 10Hz, 0 disables PWM — continuously on for the
whole duration, exactly as before) sets the cycle rate; channelPwmDutyPercent
(0-100%, default 100%) sets how much of each cycle is on. 100% duty
(the default) is treated the same as PWM being disabled — continuously on,
no cycling — so a fresh board fires exactly as it always did until duty is
deliberately lowered; 0% duty delivers no power at all for the whole
duration. This is a plain software timer in loop(), not hardware PWM.
300Hz is a real, bench-measured ceiling, not just "doesn't need to be
precise." The toggle logic can't act faster than loop() itself
iterates: at a PWM period comparable to one loop() iteration, both the
on-phase and off-phase end up bottlenecked by the same iteration time
instead of the requested durations, and duty accuracy collapses — 1000Hz
was measured converging toward ~50% regardless of configured duty,
regardless of how fast loop() was made to run. loop()'s own rate was
itself a real bottleneck worth fixing along the way: WebServer:: handleClient() calls a flat delay(1) whenever no HTTP client is
actively mid-request (i.e. most of the time), which is vTaskDelay()-
backed and rounds up to at least one full FreeRTOS tick (1ms, this
project's CONFIG_FREERTOS_HZ=1000) — capping loop() at ~1kHz
regardless of how much other per-iteration work was reduced. main.cpp's
setup() now calls server.enableDelay(false) to remove that floor,
which alone raised the measured rate from ~1kHz to ~3kHz. At that rate,
300Hz (30 iterations/period) keeps duty tracking usable down to about 10%
duty; a DEBUG_LOOP_RATE build flag (config.h, on by default) logs the
actual measured rate over Serial once a second if this ever needs
re-checking on different hardware.
Every PWM edge gets the same protection as a single trigger-on event:
each on/off transition, not just the first, goes through
writeTriggerPinBlanked() (see Overcurrent fault protection above), so a
fault at any point during a PWM burst is caught exactly the same way it
would be for a plain single-shot fire. Turning off is always immediate;
before turning back on at each cycle, firmware re-checks fault/armed/
continuity — the same belt-and-suspenders re-check described below for the
initial fire-time check, just repeated at every cycle rather than once,
since a PWM burst can run for a while. A failed re-check stops that
channel's remaining cycles for the rest of the sequence rather than
retrying.
Pressing TRIGGER is only possible when arm_state is READY, there's no
active fault, no sequence is already running, and (if
requireRearmAfterFire is on) the device hasn't fired since it was last
armed. It schedules every selected channel's trigger output to go low at
now + its configured offset, for its configured duration, then
immediately blocks further TRIGGER presses until that sequence finishes
(you can never have two sequences in flight at once, regardless of
settings).
At each channel's actual fire moment — not just when TRIGGER was pressed
— the firmware re-checks armed state, fault, and continuity. An offset can
be long enough for something to change, so a channel that fails this
recheck is skipped (and logged) rather than aborting the rest of the
sequence. If the arm switch is opened mid-sequence, the gate drivers lose
power outright regardless of what firmware does — this recheck is
belt-and-suspenders, not the actual safety mechanism.
requireRearmAfterFire (settings page, default on): once a trigger
sequence has run, no further TRIGGER is accepted until the arm switch is
opened and closed again (a fresh disarm + countdown + READY). With it off,
another TRIGGER is accepted as soon as the current sequence finishes, no
rearm needed.
Peak and average current, per channel, for its most recent pulse: while
a channel's trigger output is low, firmware/src/main.cpp's loop()
samples that channel's current sense on every iteration, tracking the
highest single sample (peak) and the running mean of all samples (average).
Both are reset at the start of each pulse and then simply held afterward —
so they remain readable (peak_current_a/avg_current_a in
/status.json, shown per channel on the /stats page) for the rest of the
session, until that channel fires again. Sampling continues every
iteration regardless of PWM on/off phase, so the average comes out
duty-cycle-weighted automatically — off-phase samples read ~0A and pull it
down proportionally — with no special-case math needed. The main control
page's live "FIRING" status shows this running average rather than the
instantaneous reading while a channel is actively firing, since the
instantaneous value is sampled at an arbitrary instant relative to the PWM
cycle (the page polls every 500ms) and would otherwise often show ~0A
simply by catching an off-phase, even with real current flowing during
on-phases.
ABORT and PANIC buttons
Two more buttons sit below TRIGGER on the main control page, both backed by
a shared stopSequence() helper (firmware/src/main.cpp) that stops
things immediately — any channel mid-pulse has its trigger pin forced
back HIGH right away rather than waiting out the rest of the fire pulse,
and anything still scheduled (waiting on its configured delay) is
cancelled. Neither has a confirm dialog on the frontend, and neither is
gated on any precondition — an abort/panic button should always just work,
the instant it's tapped, like a real E-stop.
ABORT (/abort) just stops the sequence. It does not prevent
retriggering — if the device is still armed and ready afterward, TRIGGER is
available again immediately. Only enabled on the control page while a
sequence is actually running, since it has nothing to do otherwise.
PANIC (/panic) does the same immediate stop, and additionally
always sets a new lockout (panicLockedOut() in arm_state) requiring a
full disarm and rearm before another TRIGGER is accepted — unconditionally,
independent of requireRearmAfterFire, and regardless of whether anything
was actually firing when it was pressed. Always enabled on the control
page, since pressing it has an effect either way.
Arm timeout (auto-lockout)
Guards against "armed and walked away": if the device sits in READY —
armed, past the countdown, but never actually triggered — for longer than a
configurable timeout, TRIGGER is refused until a fresh disarm and rearm,
even though the arm switch (J5) is still physically closed.
armTimeoutSec (settings page, default 600s / 10 minutes, 0
disables it): the clock starts the instant COUNTDOWN transitions to
READY, not when the switch was first closed, so a long
armCountdownSec doesn't eat into the READY window. Once it elapses, the
control page shows "Arm timeout elapsed — disarm & rearm required before
triggering again" and TRIGGER is disabled, the same way it is for
panicLockedOut() — cleared at the same point every other lockout is: the
arm switch observed open, then closed again.
This is a software-only lockout, same family as PANIC and the
continuity/rearm lockouts above: it cannot re-power the gate drivers on its
own, and it cannot cut them either. It only blocks the firmware's own
/trigger handler. The hardware arm switch remains the actual power
interlock regardless of this setting — the buzzer/strobe pattern keeps
following READY normally throughout, since electrically the device really
is still armed.
Continuity checks
CONTINUITY1/2/3 sense the same way SENSEFAILSAFE does: ~1mA through a red
LED, whose forward voltage exceeds LED_CLAMP_ARM_VOLTS (1.25V,
firmware/src/config.h) when the loop (the igniter, in this case) is
actually closed. That threshold (CONTINUITY_OK_RAW in the same file) is
shared with the arm-loop sense, since it's the identical circuit at the
identical target voltage.
Beyond the per-channel status dot, continuity gates channel selection and
triggering in two independent ways:
Arm-time continuity monitor (checkContinuityOnArm, settings page,
default on): while armed (COUNTDOWN or READY), continuously verifies
that every selected channel still has continuity. If one doesn't, the
control page shows an error and TRIGGER is refused — but this is live, not
latched: it clears itself the instant continuity is restored, or the setting
is turned off, no disarm needed. While this setting is on, channel selection
is also locked while armed (the /select endpoint refuses changes), since
the set of channels being monitored can't shift out from under a live check
— disarm to change which channels are selected.
This monitor reads channelContinuityCached rather than sampling fresh
every loop() iteration — main.cpp's loop() round-robins one
analogRead() per iteration across battery voltage and each channel's
continuity (slowSampleStep, cycling through NUM_CHANNELS + 1 steps)
instead of sampling all four every time, so each value is at most a few
iterations stale (comfortably fast relative to what actually reacts to
them — this monitor is already explicitly best-effort/self-clearing, and
the low-voltage lockout below already debounces over 2 seconds). This was
one of several fixes to a real, measured bottleneck: each analogRead()
call was found to carry real, non-trivial overhead (the underlying
ESP-IDF driver power-cycles the whole ADC peripheral and takes an internal
lock on every call), and loop() was doing up to 9 of them per iteration
in the fully-armed-and-monitoring case. The pre-trigger check and the
per-channel fire-time recheck below both still call hasContinuity()
directly for a fresh, uncached read — this round-robin cache is only used
for the informational background monitor, never for a safety-relevant
check made right before actually energizing a channel.
Pre-trigger continuity check (checkContinuityBeforeTrigger, settings
page, default on): at the instant TRIGGER is pressed, re-verifies
continuity for every selected channel. If any fail, nothing fires, the
control page shows an error, and — unlike the live monitor above — this is a
hard lockout: a full disarm and rearm is required before another TRIGGER is
accepted, regardless of requireRearmAfterFire (that setting is about the
post-successful-fire case; this is a separate, always-strict rule for this
specific failure).
Both checks are additional to, not a replacement for, the per-channel
recheck already described under Trigger sequence above (which happens at
each channel's actual fire moment, not at the TRIGGER press, and only skips
that one channel rather than refusing the whole sequence).
Battery voltage
The 12V supply is sensed on GPIO2 through a fixed 1:11 resistor divider
(BATTERY_DIVIDER_RATIO in firmware/src/config.h — a PCB-fixed ratio, not
a runtime setting like the current-sense shunts), after passing through a
series diode ahead of the divider. readBatteryVoltage() (main.cpp) adds
back that diode's forward drop (BATTERY_DIODE_DROP_V, 0.7V, also a
compile-time constant — it's a fixed part characteristic, not field-
adjustable) so battery_v reflects true battery voltage, not the
diode-reduced rail. The reading is shown on the main control page as
battery_v.
The low-voltage lockout decision (below) uses this exact same reading —
updateArmState() takes battery_v as a parameter rather than sampling
PIN_BATTERY a second time independently, which it originally did. The
two readings need to agree: they briefly didn't (a since-fixed bug), and
the lockout could compare an uncorrected value against the threshold while
the banner showed the corrected battery_v, so the displayed voltage in a
"Cannot arm — battery too low" message could be well above the threshold
it had supposedly tripped. Sharing one reading both fixes that class of
bug structurally (there's only one value to ever disagree with itself) and
avoids a redundant analogRead() every loop() iteration — see the
round-robin sampling note under Continuity checks below, which this same
mechanism also covers.
Low-battery warning: below lowBatteryThresholdV (settings page,
default 11.5V), the battery readout on the control page turns orange and
bold and appends "LOW BATTERY". This is just a warning — on its own it
doesn't stop you from arming or triggering.
Low-voltage lockout (lowVoltageLockoutEnabled, default on): reuses
the same threshold to actually block operation, independent of the warning
display:
- If the arm switch closes while
battery_v is under threshold, the device
stays DISARMED instead of entering COUNTDOWN.
- If voltage drops below threshold while already in
COUNTDOWN or
READY, the device is forced back to DISARMED immediately — the same
path as the arm switch opening, including aborting any in-flight trigger
sequence.
Both cases are live, not latched: checked continuously, not just at the
moment the switch closes. If the switch is still closed when voltage drops
too low, the control page shows "Cannot arm — battery too low"; the instant
voltage recovers above threshold, arming proceeds normally on its own,
with no need to open and reclose the switch. Turn the lockout off in
settings to arm anyway regardless of voltage (e.g. bench testing on a
supply that's intentionally below threshold) while keeping the warning
display active.
Debounce and hysteresis on the voltage check itself (arm_state.cpp),
addressing two failure modes a single raw comparison would otherwise have:
- Boundary flapping — a reading oscillating right at the threshold (ADC
noise, supply ripple) could otherwise flap the lockout in and out rather
than settling. Once locked out, voltage must recover past
lowBatteryThresholdV + 0.3V (not just past the threshold itself) before
the lockout clears.
- Self-induced false trip — firing an igniter draws real current through
the same 12V rail being sensed, so a fire pulse can sag
battery_v
momentarily. The low-voltage condition must hold steady for 2 seconds
(comfortably longer than a typical channelDurationMs pulse, or a short
multi-channel burst) before the lockout actually engages, so a firing
event's own transient sag isn't mistaken for a real low battery.
This is the same debounce shape SENSEFAILSAFE already uses for the arm
switch itself, just with a longer window and added hysteresis suited to a
noisier, slower-moving analog signal rather than a mechanical switch.
Factory reset
PIN_FACTORY_RESET (GPIO14) is pulled up internally and checked once,
early in setup(), before settings are loaded. Ground it — a jumper or
button to GND — before powering the board up, and every setting resets to
its compiled-in default and is immediately persisted to flash, overwriting
whatever was there. It's checked with a few reads over a couple of
milliseconds rather than a single instantaneous one, so a pin still
settling right after pinMode() doesn't trigger a reset by accident.
This only runs at boot, from a floating/undriven starting point — there's
no runtime "hold this pin for 10 seconds" monitor. GPIO14 has no strapping
or other special function on the ESP32-S3 (unlike GPIO12 on the original
ESP32, a common source of outdated advice online — that specific gotcha
doesn't carry over to the S3), and is otherwise unconnected on the PCB.