Handheld lightning sensing and ranging device — a PlatformIO port of gokux's original Instructables build.
Pairs a Seeed Studio XIAO ESP32-C3 or ESP32-C6 with a Seeed Studio
Round Display (1.28", GC9A01, 240×240, v1.1 with the KE switch) and the
Grove Lightning Sensor (AS3935) to show strike distance, per-strike energy,
a short history of the last strikes, and a big red SHELTER warning when a
strike lands within ~10 km.
- Original project, mechanical design, and photos: https://www.instructables.com/Flash-Bee-Handheld-Lighting-Sensing-and-Ranging-De/
- AS3935 sensor: Seeed Grove Lightning Sensor AS3935
- Display stack: Seeed_GFX (TFT_eSPI fork) and Seeed_Arduino_RoundDisplay
| Part | Role |
|---|---|
| Seeed XIAO ESP32-C3 or ESP32-C6 | MCU (two PIO envs — C6 via pioarduino) |
| Seeed Round Display v1.1 (GC9A01, 240×240) | UI (SPI) + capacitive touch + battery |
| Seeed Grove AS3935 | Lightning detection (I²C, addr 0x03) |
Pin assignments use the XIAO silkscreen labels (D0…D10), which map to
different underlying GPIOs on C3 vs C6 but sit in the same physical
positions on the board. SDA = D4, SCL = D5; SPI for the display is
configured via Setup501 in Seeed_GFX.
The Instructables assembly already asks you to cut the Grove connector
off the module and solder direct bridges for 3V3, GND, SDA and SCL.
Add one more wire: from the AS3935 module's INT pad to XIAO
D2 (GPIO4). This lets the firmware measure the antenna's LC-tank
resonance and pick the correct TUN_CAP value automatically — without
it, every distance reading is biased. Override the pin with
-DAS3935_INT_PIN=<n> if D2 isn't convenient for your wiring.
Per the Seeed wiki, Round Display v1.1 added a 2-bit DIP switch ("KE") in the middle of the microSD-slot area. Its two positions mean:
- KE ON — A0 connects to the onboard battery-voltage divider
(R28/R29 → VBAT/2), and D6 connects to the backlight MOSFET
gate. This is the configuration the firmware is built around:
the settings screen shows
BAT x.xx V NN%, and the inactivity timeout cuts the backlight fully. - KE OFF — A0 and D6 are electrically disconnected from the
display module's circuits and become plain XIAO GPIOs for the
user to wire as they like. With the switch in this position the
backlight stays on permanently regardless of firmware state
(still gets
DISPOFF + SLPINso the pixels go black), and the battery reading showsBAT --.
Firmware never drives A0 (it's an analog input when KE is on, and
the user's free GPIO when KE is off — either way, actively driving
it would be wrong). D6 is driven for backlight. Override with
-DTFT_BL_PIN=<n> if you wire it differently.
Safety disclaimer. This is a hobby device. Do not use it as your sole basis for deciding whether it is safe to be outdoors. The AS3935 is a statistical single-antenna detector with ±1 bin distance uncertainty and well-known disturber/false-positive behavior. When in doubt, follow the NWS 30/30 rule and official weather advisories.
Requires PlatformIO Core.
pio run -e seeed_xiao_esp32c3 # compile
pio run -e seeed_xiao_esp32c3 -t upload # flash via USB-C
pio device monitor -b 115200 # serial logThe first build fetches Seeed_GFX and Seeed_Arduino_RoundDisplay from
GitHub into .pio/libdeps/.
Seeed_GFX's Dynamic_Setup.h selects the pinout/driver via the
BOARD_SCREEN_COMBO macro. The canonical Arduino-IDE workflow drops a
driver.h into the sketch folder, which Seeed_GFX picks up with
__has_include("driver.h"). Under PlatformIO the project's include/
directory is not on the include path when library source files
(inside Seeed_GFX/) are compiled, so that __has_include silently
returns false and the library falls through to combo 666 — a CI stub
with the wrong pinout for the round display. The build still succeeds
but the display stays dark on hardware. Defining the macro via
build_flags makes it visible to every compilation unit uniformly.
platformio.ini # board, libs, build_flags
src/main.ino # single-file firmware
Main screen:
- Big yellow number — estimated distance in km, or
OVERHEAD/>40/-- distance unknown. - Arc gauge — last-strike energy (0–21 bit AS3935 word) mapped 0–100 %.
- Inward-pulsing concentric rings — "listening" indicator. Purely radial (the AS3935 is non-directional — any rotating element would falsely imply bearing capability). Green-teal normally, red-amber during the shelter window.
- Strikes / Energy — running count and last-strike energy.
- Energy history — 20-slot ring-buffer bar chart. Labelled
(stale)once the last strike is more than 5 min old. - Status line under the title —
OUT/IN WD:n SR:n(current AFE mode- filter levels). Turns amber when filters have tightened beyond
mid-range, red
ENV TOO NOISYwhen the noise floor has hit the hardware ceiling and the chip is no longer operating within spec.
- filter levels). Turns amber when filters have tightened beyond
mid-range, red
!! SHELTER !!overlay — blinks red/amber when a strike is detected within ~10 km (close threshold per NWS 30/30 rule). The right-side footer swaps toSHELTER m:sscounting up from the last close strike. Clears automatically 30 minutes after the last close strike.SENSOR LOSToverlay — shown when I²C has failed 8+ times in a row; the firmware keeps retryinginitAS3935()every 3 s until the sensor comes back.
Settings screen (swipe horizontally):
- INDOOR / OUTDOOR — AFE gain toggle, persisted to NVS.
- SCREEN — backlight inactivity timeout (30 s … NEVER).
- SLEEP — light-sleep inactivity timeout (5 min … NEVER). Wakes on strike, disturber (silent), or any touch.
- RESET FILTERS — restores NF/WD/SR to defaults, clears AS3935
lightning statistics via the
CL_STATtoggle. Does not clear the shelter timer (safety: no UI-level "pretend it's safe" button). - Footer —
BAT x.xx V NN%live battery readout (Round Display v1.1 KE switch ON) andNF WD SRcurrent AS3935 filter state.
Three tiers, all individually configurable from the settings screen and persisted across power cycles:
- Interrupt-driven AS3935.
D2receives a hardware IRQ from the module (see wiring section above). The firmware never polls — it reacts in microseconds and the I²C bus is quiet between events. - Backlight off (Tier 2). After
SCREENminutes of no touches and no strikes,D6goes low and the GC9A01 entersDISPOFF+SLPIN. Disturbers / noise-floor events don't reset this timer — they still get processed silently, they just don't wake the display. - CPU light sleep (Tier 3). After
SLEEPminutes more, the ESP32 entersesp_light_sleep_start(). Wake sources: AS3935 INT rising (strike/disturber) or touch going low. A strike wakes the display; a plain disturber processes silently and goes back to sleep. USB-CDC drops during sleep and re-enumerates on wake.
Estimated current draw on the Trustfire 10440 (300 mAh):
| State | ~Current | Runtime on 280 mAh usable |
|---|---|---|
| Main UI active | 50 mA | 5.5 h |
| Screen off | 28 mA | 10 h |
| Light sleep | 3–5 mA | 55–90 h |
After the first flash, open the serial monitor before plugging in (or hit the reset button after plugging in) and watch for:
[tune] no saved antenna calibration.
[tune] send 'tune' on serial to calibrate now.
Type tune and hit enter within the 5-second window. The firmware:
- Issues
PRESET_DEFAULT+CALIB_RCOto the AS3935. - Sets
DISP_LCObit andLCO_FDIV = ÷128— routes the LC-tank oscillator to the AS3935INTpin, divided down to ~3906 Hz. - Sweeps
TUN_CAPfrom 0 to 15, counting rising edges on the XIAO GPIO for 200 ms per step. - Picks the value closest to 3906 Hz, saves it to NVS (via
Preferences), and continues into normal detector mode with thatTUN_CAPapplied.
Serial output looks like:
TUN_CAP= 0 -> 3863 Hz (-43)
TUN_CAP= 1 -> 3885 Hz (-21)
TUN_CAP= 2 -> 3903 Hz (-3)
TUN_CAP= 3 -> 3921 Hz (+15)
...
best: TUN_CAP=2 @ 3903 Hz (dev -3, WITHIN TOL)
The screen shows a live progress bar during the sweep and a summary
panel at the end (TUNED green, or OUT OF RANGE red if nothing on
the sweep lands within ±3.5 %).
To re-calibrate later: on any subsequent boot there's a 1.5-second
window right after the AS3935 init screen where tune will retrigger
the sweep. Hit reset, type tune on serial, done.
If the sweep fails with NO SIGNAL / check INT wire: the
firmware saw zero edges on AS3935_INT_PIN. Either the jumper from
the AS3935 module's INT pad isn't connected, or it's on a different
GPIO than D2. Without that wire the tune cannot run and the firmware
falls back to the compile-time default (AS3935_TUN_CAP, 0 unless
overridden) — the detector still works, but distance estimates will
be whatever the out-of-the-box LC tank produces.
The original sketch was reviewed twice (once by Claude, once by Codex)
against the AS3935 datasheet (rev 1.07 §8.10–§8.11). The import commit
is kept intact at 7707720 so the deltas are traceable. Fix commit
107898d addresses the following:
- AFE gain byte was encoded wrong.
AFE_GBlives inREG0x00[5:1], so the 5-bit field value has to be shifted one bit up before being written to the register. The original wrote0b00010010(0x12) when aiming for the indoor encoding0b10010— which placed0b01001= 9 into the field, outside the datasheet's valid{14 outdoor, 18 indoor}pair. Fix: use(0b01110 << 1) = 0x1Cfor outdoor (new default — this is a handheld) or(0b10010 << 1) = 0x24for indoor. Override at build time with-DAS3935_AFE_GB=AFE_GB_INDOOR. PRESET_DEFAULT+CALIB_RCOnever ran. The original skipped both direct commands, so the internal RCO timebase used to measure strike-pulse energy was uncalibrated and distance estimates were therefore unspecified.initAS3935()now issues both on every init and verifiesTRCO_CALIB_DONE/SRCO_CALIB_DONE(bit 7) plus the corresponding_NOKbit (bit 6) in regs 0x3A/0x3B before proceeding.- Antenna
TUN_CAPwas never set. Reg 0x08 [3:0] controls the LC tank trim; the factory-tuned value is board-specific. The firmware now runs a built-in sweep (see First-time setup — antenna calibration) and stores the result in NVS. - Distance value
0x00was coerced toOVERHEAD. The datasheet only defines0x01(overhead) and0x3F(out of range);0x00is not a valid distance output. The original displayed the scariest-possible reading for an undefined input. Now it shows-- distance unknown.
- Noise-floor ratchet only went up.
INT_NHcausedNFto increment (stricter) but nothing ever decremented it, so a minute of nearby EMI could permanently deafen the detector for the rest of the session.lowerNoiseFloor()now decaysNFby one step every 60 s of quiet. - High-noise fault was hidden. Datasheet:
INT_NHmeans the device cannot operate properly under the current input noise. The original treated it as "turn up the filter and keep going". WhenNFreaches the hardware maximum andINT_NHstill fires, the UI now showsENV TOO NOISYin red so the user knows the readings are no longer trustworthy. - No I²C error detection. All reads blindly returned
0xFFon failure, and0xFF & 0x0F == 0x0Fdidn't match any of the three interrupt branches — so a wedged bus caused silent permanent deafness. I²C calls now propagate aboolresult; after 8 consecutive failures the sensor is marked lost and theSENSOR LOSToverlay appears.Wire.setTimeOut(50)prevents bus hangs from stalling the MCU during ESD events. - No stall watchdog. If the AS3935 wedged (ESD, brownout), nothing noticed. A re-init fires automatically after 10 min of zero interrupts of any kind.
increaseSensitivity()/decreaseSensitivity()were named opposite to what they actually did relative to the datasheet (higherWDTH/SREJ= less sensitive per §8.4). Renamed totightenFilters()/loosenFilters().- Every AS3935 register, field, and bit now has a named symbol;
no magic
0x01/0x02/0x03scattered through the logic. - Reg
0x02writes now preserveCL_STAT_EN/CL_STATin bits [7:6]; the original blind full-byte write clobbered them.
All of the above datasheet-correctness fixes have been bench-tested on a real XIAO ESP32-C6 + Seeed Round Display v1.1 + Grove AS3935 module. What works on real silicon:
- AS3935 responds to I²C at
0x03,PRESET_DEFAULT+CALIB_RCOcomplete successfully, TRCO/SRCO done-bits verify clean. - Antenna auto-tune sweep runs end-to-end and picks a valid
TUN_CAPwithin the ±3.5 % tolerance window. One tested module landed atTUN_CAP=9 @ 3900 Hz(0.15 % off target) — the datasheet factory default ofTUN_CAP=0would have been 2.8 % off, which is why the sweep matters. - Interrupt-driven event handling catches
INT_L,INT_D, andINT_NHwith no missed events under normal load. - I²C fault-streak detection trips
SENSOR LOSTafter 8 consecutive failures; re-init retry at 3 s cadence recovers. - Touch + swipe gesture classification, NVS persistence of AFE mode / tune cap / timeouts, backlight off at inactivity, and light-sleep wake on touch or AS3935 INT have all been exercised.
What is still open:
- No real CG strike observed yet. All live data so far is
indoor EMI (pellet heater ignition, PV inverter, LED drivers,
etc.), which the AS3935 classifies as a mix of disturbers and
"close strikes" with
d = 0x01(overhead). This is expected indoor behaviour per the datasheet — the sensor is designed for outdoor open-air operation. The device needs a real thunderstorm before the distance readout can be cross-checked against Blitzortung. - The reviewer critique stands that a single-antenna detector
produces statistical distance to the head of the storm,
not range to the individual strike that triggered the IRQ.
Do not treat the displayed number as a ranging result you
would stake a decision on. The
!! SHELTER !!overlay is the trustworthy output; the km number underneath is a best-effort estimate. - Battery runtime estimates in the Power management section are calculated, not yet measured against the real 10440 cell.
Same spirit as the Instructables source — hobby / educational.