Lightning Locator#

Design proposal — not deployed

This is a hardware project design, not a running service. Nothing below exists on the network yet; it lives under Proposals until a box is built.

Overview#

A weekend hardware project: locate lightning strikes by the direction of the radio pulse each strike radiates (the sferic), using a self-contained box no bigger than a 1 ft cube and roughly $20 of parts on a breadboard.

The build has two stages, and this doc covers both:

  • Stage 1 — single-station direction finder. One box gives a good bearing to each strike (which way the storm is) plus a crude range (how far). It plots a strike as a point on the map by itself. This fits the 1 ft cube and is a Saturday’s work.
  • Stage 2 — two-site triangulation. Build a second identical box, put it at a separated location (Lafayette and Bedford are ~100 km apart), and cross the two bearings for a genuinely triangulated fix. Same hardware; Stage 2 just adds a peer and the crossing math.

It plugs into the existing homelab the same way every other IoT device does: the ESP32 talks to the ESPHome dashboard/builder on ereshkigal (192.168.0.20) and surfaces its readings in Home Assistant.

Note: If you just want strike detection with a rough distance and none of the direction-finding, the AS3935 sensor is the easy-mode option — ESPHome supports it natively (a sensor: platform: as3935 block) and it’s a 20-line config. The build below is the harder, more interesting thing: telling you which way the strike was, which the AS3935 cannot do.

How lightning locating actually works#

A lightning return stroke radiates a broadband electromagnetic pulse whose energy peaks in the VLF band (3–30 kHz) and extends up past 500 kHz. That pulse is a radio wave: it travels at the speed of light, which for our purposes is a handy ~1 foot per nanosecond. Everything below follows from that one number.

There are three ways to locate the source, and only two of them fit in a box on your bench.

Why “triangulate inside one box” (TDOA) can’t work here#

The intuitive idea — put a few antennas in the cube, measure which one the pulse hits first, and back out the direction — is time-difference-of-arrival (TDOA). It’s how GPS and Blitzortung work. It does not work at this scale, and it’s worth being precise about why, because it’s a hard physical wall, not a tuning problem:

  • Across a 1 ft baseline, the entire possible arrival-time difference between two antennas (pulse arriving edge-on) is 1 ft ÷ (1 ft/ns) = 1 ns.
  • To extract a bearing you must resolve a fraction of that 1 ns — sub-nanosecond timing.
  • The fastest clock on the bench doesn’t come close. An ESP32 at 240 MHz ticks every ~4.2 ns — already 4× coarser than the whole 1 ns window. Even a cheap FPGA (~100–500 MHz) lands at 2–10 ns. Nothing you’ll breadboard for $20 resolves sub-nanosecond arrival differences.

So intra-box TDOA yields essentially zero bearing information. This isn’t solved by a faster microcontroller — you’d need a picosecond time-to-digital converter and a bigger baseline.

How the real networks get away with it: Blitzortung and WWLLN don’t shrink the timing problem, they enlarge the baseline. Their stations sit 50–250 km apart and timestamp each sferic to ±1 microsecond using GPS-disciplined clocks. Over a 250 km baseline the arrival spread is up to ~830 µs — hundreds of times their timing resolution — so µs-class clocks are plenty. It’s the ratio of baseline to timing resolution that produces a fix, and theirs is ~10⁵–10⁶× more favorable than a 1 ft cube’s. You cannot close that gap on a breadboard.

The method that does fit a 1 ft cube#

Drop timing entirely and measure amplitudes instead. Clock rate becomes irrelevant.

Method What it needs What it gives Fits a 1 ft cube?
Magnetic direction finding (MDF) — crossed loops Two orthogonal loop antennas, amplitude ratio Bearing (azimuth) ✅ yes
E/H ratio — add a vertical E-field whip One more antenna, amplitude ratio Crude range ✅ yes
TDOA / time-of-arrival Huge baseline + GPS µs clocks Full fix ❌ needs 50–250 km

Crossed-loop MDF is the workhorse. Two vertical loop antennas mounted at right angles (call them NS and EW) each pick up the strike’s magnetic field. The voltage induced in a loop varies with the cosine of the angle between the loop and the strike, so:

  • NS loop signal ∝ cos(bearing)
  • EW loop signal ∝ sin(bearing)
  • bearing = atan2(EW, NS)

That’s an amplitude ratio taken at one instant — no fast clock anywhere. This is exactly the principle the AS3935 chip uses internally, and what commercial single-site “storm tracker” units (e.g. Boltek) do.

Adding a short vertical whip for the electric field gives two more things: the E/H amplitude ratio approximates the wave impedance and therefore a rough distance, and the E-field’s sign resolves the front/back ambiguity that a magnetic-only loop can’t (a bare crossed loop can’t tell a strike ahead from one directly behind).

Stage 1 — the single-station box#

Bill of materials (~$20)#

Part Role Notes / cost
ESP32 dev board ADC + Wi-Fi + brains You already have spares (see ESPHome) — $0
2× loop antennas NS + EW magnetic pickup Hand-wound. Air-core (~15–20 cm, tens of turns of magnet wire) or two ferrite rods. Salvage/DIY — ~$3
1× vertical whip E-field / range + ambiguity A stiff wire, 10–20 cm. ~$0
Dual/quad op-amp (TL072 / MCP6002) Loop preamps + buffers ~$1–2
Peak detector / sample-and-hold parts Catch each channel’s peak at the strike instant Diodes, small caps, a analog switch or S/H IC — ~$4
Comparator (LM393) Fire a trigger when a sferic arrives ~$1
Passives, protoboard, wire ~$5

$20 is tight but achievable because the antennas are DIY — wound loops and a wire whip cost almost nothing. If you’d rather buy a nicer front end, budget grows fast; the point of this build is that the expensive part (direction finding) is free physics.

Antenna geometry#

Inside the ≤1 ft cube, mount the two loops vertically and at 90° to each other (one facing magnetic north-south, one east-west). Put the whip vertical in the middle. Keep the loops identical (same turns, same area) — the whole method is a ratio, so matched channels matter far more than absolute calibration.

Analog front end#

Per magnetic loop: loop → band-limited amplifier (roughly a few kHz to ~500 kHz, the sferic band) → peak detector / sample-and-hold. The E-field whip gets its own high-impedance buffer and peak detector. The three peaks (NS, EW, E) must be captured at the same strike instant and then read out — that simultaneity is what makes the amplitude ratio meaningful, and it’s why we latch peaks in analog rather than trying to sample the fast waveform in software.

   NS loop ─▶ preamp ─▶ band-pass ─▶ peak/S&H ─┐
   EW loop ─▶ preamp ─▶ band-pass ─▶ peak/S&H ─┼─▶ ESP32 ADC (read on trigger)
   E whip  ─▶ buffer ─▶ band-pass ─▶ peak/S&H ─┘
                                 │
   any channel ─▶ comparator ────┴─▶ ESP32 IRQ (a sferic arrived)
   ESP32 GPIO  ─────────────────────▶ reset S&H after readout

ESP32 wiring#

  • 3 ADC inputs — the NS, EW, and E peak-hold outputs.
  • 1 interrupt input — from the comparator; goes high when a sferic crosses threshold.
  • 1 GPIO output — discharges/resets the sample-and-hold caps after the ESP32 has read the three ADCs, arming for the next strike.

Use the ADC1 pins (GPIO32–39); ADC2 is unusable while Wi-Fi is on.

Firmware reality (this is custom, not a stock platform)#

There is no native ESPHome platform that reads three peak-held ADCs on a trigger and computes a bearing. Don’t go looking for one. You write a small custom C++ lambda (or a tiny external_components component) that, on the interrupt, reads the three channels, computes the bearing and a range proxy, and publishes them as ordinary ESPHome sensors. Everything downstream — the dashboard, the HA integration, OTA — then works exactly like any other device.

The block below is an illustrative sketch, not a turnkey config — the analog thresholds, calibration constants, and trigger wiring are yours to fill in:

# lightning-locator.yaml — SKETCH. The as3935-style bearing/range logic lives in
# a custom lambda; tune the constants to your build.
esphome:
  name: lightning-locator
  friendly_name: Lightning Locator

esp32:
  board: esp32dev
  framework:
    type: arduino

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Lightning-Locator Fallback"

api:
  encryption:
    key: !secret api_encryption_key   # reuses the esphome/ sops key
ota:
  - platform: esphome
    password: !secret ota_password

# Raw peak-held channels (ADC1 pins). These feed the lambda; you can keep them
# internal: true once things work.
sensor:
  - platform: adc
    id: ns_peak
    pin: GPIO32
    attenuation: 12db
    internal: true
  - platform: adc
    id: ew_peak
    pin: GPIO33
    attenuation: 12db
    internal: true
  - platform: adc
    id: e_peak
    pin: GPIO34
    attenuation: 12db
    internal: true

  # Derived, published values.
  - platform: template
    name: "Strike Bearing"
    id: strike_bearing
    unit_of_measurement: "°"
    accuracy_decimals: 0
  - platform: template
    name: "Strike Range (rough)"
    id: strike_range
    unit_of_measurement: "km"
    accuracy_decimals: 0

  - platform: template
    name: "Strike Count"
    id: strike_count
    accuracy_decimals: 0
    lambda: 'return id(strike_total);'

# Global counter + calibration constants.
globals:
  - id: strike_total
    type: int
    restore_value: no
    initial_value: '0'

# The comparator trigger fires this. Read peaks, compute bearing/range, publish.
binary_sensor:
  - platform: gpio
    pin: GPIO27
    id: sferic_trigger
    internal: true
    on_press:
      then:
        - lambda: |-
            // Peaks were latched in analog at the strike instant.
            float ns = id(ns_peak).state;
            float ew = id(ew_peak).state;
            float e  = id(e_peak).state;

            // Bearing from the crossed loops: atan2(EW, NS).
            float bearing = atan2f(ew, ns) * 180.0f / M_PI;
            if (bearing < 0) bearing += 360.0f;

            // Front/back disambiguation from the E-field sign would go here
            // (needs a signed E sample, not just a magnitude peak).

            // Crude range from the E/H (electric/magnetic) amplitude ratio.
            // K is a site constant you fit against known strikes.
            const float K = 40.0f;
            float h = sqrtf(ns*ns + ew*ew) + 1e-6f;
            float range_km = K * (e / h);

            id(strike_bearing).publish_state(bearing);
            id(strike_range).publish_state(range_km);
            id(strike_total) += 1;
        # Reset the sample-and-hold for the next strike.
        - output.turn_on: sh_reset
        - delay: 2ms
        - output.turn_off: sh_reset

output:
  - platform: gpio
    pin: GPIO25
    id: sh_reset

Home Assistant#

Once it publishes Strike Bearing, Strike Range, and Strike Count, Home Assistant sees them automatically via the ESPHome integration. A template sensor or a compass-style Lovelace card turns bearing + range into “storm ~12 km to the SW.” Wire an automation off Strike Count increasing to push a notification when activity picks up.

Calibration and the 180° ambiguity#

  • Front/back: a bare crossed loop has a two-fold (really four-fold) ambiguity — it finds the line to the strike, not the direction along it. The vertical E-field whip resolves it: the sign of E relative to the loop signals picks the correct half. (Capturing E’s sign, not just its peak magnitude, is the one place the sketch above cheats — you’ll want a signed sample here.)
  • Orient once: rotate the box so the NS loop actually faces magnetic north, then apply your site’s magnetic declination to report true bearing.
  • Ground truth: install the Blitzortung integration in Home Assistant — it overlays free, crowd-sourced, properly-triangulated strikes near your coordinates. Comparing your box’s bearing against Blitzortung’s known strikes is both your calibration procedure and a live accuracy scorecard.

Stage 2 — two-site triangulation#

A single box gives a bearing and a crude range. Two boxes give a real fix.

Build a second identical box and place it at a separated site — Lafayette and Bedford (~100 km apart) are a natural pair, or two ends of the property for near-field storms. Each box independently computes and publishes its own bearing. A small helper (an HA automation, a Node-RED flow, or a few lines of Python) crosses the two bearing rays and drops a marker where they intersect.

flowchart LR A["Box A (Lafayette)\nknown lat/lon"] -- "bearing 040°" --> X(("fix")) B["Box B (Bedford)\nknown lat/lon"] -- "bearing 310°" --> X

The crossing math is the intersection of two rays. From station i at (latᵢ, lonᵢ) with bearing θᵢ, form a line and solve for the intersection point. In a local flat (east-north) approximation good over ~100 km:

  • Convert each station’s bearing to a direction vector (sin θ, cos θ).
  • Solve the 2×2 system for the point lying on both rays.
  • Convert the east-north offset back to lat/lon and place the marker.

Time sync — the good news: correlating “same strike” across the two sites only needs their timestamps to agree within the gap between strikes (milliseconds to seconds). Ordinary NTP is plenty. For dense storms where several strikes fall in one NTP tick, match on bearing plausibility and waveform shape rather than time. To be explicit, because it’s the whole reason this is buildable: bearing crossing needs no GPS-disciplined fast clocks — that requirement belongs to TDOA, which we’re not doing.

Accuracy depends on geometry. The fix is sharpest when the strike is broadside to the baseline between the two boxes (the bearings cross near 90°). Strikes roughly in line with the baseline give two nearly-parallel rays and a smeared, unreliable intersection. A ~100 km baseline gives good crossing angles for regional storms; a short property-scale baseline is only useful for very near strikes.

Integrating into the flake#

When the hardware exists, add the device the same way the module handles every other ESPHome config (see modules/nixos/containers/esphome/default.nix):

  1. Drop lightning-locator.yaml into modules/nixos/containers/esphome/.
  2. Add an install line to the esphome-config-sync service (device configs are copied read-only into /config, not bind-mounted — the dashboard rejects /nix/store paths):
install -D -m0444 ${./lightning-locator.yaml} ${cfg.configDir}/lightning-locator.yaml
  1. Secrets come free: the config’s !secret wifi_ssid / api_encryption_key / ota_password resolve from the existing esphome/* sops keys already rendered into /config/secrets.yaml by the module — no new secrets needed unless you add per-device ones.
  2. nixos-rebuild switch on ereshkigal, then compile/flash from the dashboard.

Note: ESPHome on ereshkigal reaches IoT-VLAN devices only over the Tailscale-accepted subnet route, not the LAN gateway. If the locator lives on the IoT VLAN, that path already works for the other devices — see ESPHome for the network and build/flash/OTA details.

This doc is the design; no lightning-locator.yaml is committed yet.

Expectations and limits#

  • Bearing: good — within a few degrees once calibrated against Blitzortung and corrected for declination.
  • Single-station range: crude — expect ±30–50%. It’s a storm-scale hint, not a survey.
  • Two-site fix: genuinely good when strikes fall broadside to the baseline; poor when they’re near-collinear with it.
  • Not a Blitzortung replacement for distant storms — the professional networks win at long range through sheer station count and GPS timing. This build is for your storms, computed on your hardware, and it’s a real direction finder, not a toy.
  • Why not just make the box bigger / the clock faster? Re-read the TDOA section: at these baselines the arrival-time spread is ~1 ns, below any clock you’ll breadboard. Direction finding here is an amplitude problem, not a timing one — which is exactly why $20 of op-amps and hand-wound loops can do it.

References#