◢ SALISH SIGINT
Node 02 · LoRa Telemetry

An ESP32 buoy that tweets when whales sing.

A LoRa buoy is the right tool for the wrong-shaped problem: orca calls are audio-band, but the event that a whale called is one bit you want to push 5 km across the bay on a coin of power. Pair an ESP32 with an SX1262, run a tiny on-device detector against a hydrophone, and uplink an event packet whenever the energy ratio in the orca band crosses threshold. Range budget says you'll comfortably reach Alki from Pier 70 with a 3 dBi whip, on a few mA average draw.

The architecture in one picture

              ╔════════════════════ BUOY ═══════════════════════╗
              ║                                                  ║
   sea  ──◢   ║  ┌──────────┐   ┌──────────┐   ┌─────────────┐   ║
   500 Hz–   ─║─►│ Hydrophone│──►│ INA333   │──►│ ESP32-S3    │   ║
   25 kHz    ║  │ piezo +  │   │ (×100)   │   │ I²S ADC     │   ║
              ║  │ HPF 200Hz│   │ HPF/LPF  │   │  ↓ MicroNN  │   ║
              ║  └──────────┘   └──────────┘   │  band-energy│   ║
              ║                                │  → event?   │   ║
              ║   ┌────────────┐               └────┬────────┘   ║
              ║   │ 6V solar   │ ──► TP4056 ──┐     │            ║
              ║   │ 1W panel   │              ▼     ▼            ║
              ║   └────────────┘    ┌─────────────────────┐      ║
              ║                     │   18650 LiPo 3500mAh │     ║
              ║                     └──────────┬───────────┘     ║
              ║                                ▼                 ║
              ║                          ┌────────────┐          ║
              ║                          │  SX1262    │──◄ 3dBi  ║
              ║                          │ 915 MHz US │   whip   ║
              ║                          └─────┬──────┘          ║
              ╚════════════════════════════════│════════════════╝
                                               │
                                          LoRa  │  SF10 BW125
                                               ▼
                                       ┌──────────────────┐
                                       │ Pier 70 gateway  │  (ESP32 + 5 dBi)
                                       │ → MQTT → mesh    │
                                       └────────┬─────────┘
                                                ▼
                                  salish-sigint fusion bus

Bill of materials

PartNotesUSD
Heltec WiFi LoRa 32 V3 (ESP32-S3 + SX1262)915 MHz US, USB-C, OLED, integrated charger~$22
Aquarian Audio H1c hydrophone10 Hz – 100 kHz, ±1 dB, 3 m cable$140
or DIY piezo + brass cup + epoxy$5 of parts, ±5 dB calibration, fine for events~$8
INA333 instrumentation amp breakoutGain 1–10000 with one resistor, 50 µV offset$12
Sallen-Key HPF 200 Hz / LPF 25 kHzPre-ADC anti-alias + DC reject$3 in passives
Samsung INR18650-35E (3500 mAh)Protected cell holder$8
Voltaic 1 W / 6 V solar panelTrickle charge through TP4056$15
Pelican 1010 case + cable glandIP67 once you epoxy the gland in$24
3 dBi 915 MHz whip + IPEX → SMA pigtailUse a quarter-wave if you can solder one$8
Mooring kit (foam float + 5 lb mushroom)Or beg a slip from a friend with a boat$40
TotalAquarian variant~$272

Why ESP32-S3 specifically

The S3 has vector instructions for INT8 dot products. That matters because the on-device detector can be a tiny mel-spectrogram + 1D CNN running in TensorFlow Lite Micro, cycle budget ~10 ms per 1-s window. The original ESP32 (LX6) does it but eats 3× the time.

SX1262 spec sheet (the radio half)

Chip
Semtech SX1262 (LoRa + (G)FSK)
US ISM band
902–928 MHz (Heltec V3 default)
EU ISM
863–870 / 873 MHz (different SKU)
TX power
up to +22 dBm (US) — 158 mW
Sensitivity
−148 dBm at SF12/BW125 (best case)
Link budget
~170 dB at SF12 — > LoRaWAN headline range
Practical bay range
5–8 km LOS at SF10/BW125 with 3 dBi → 5 dBi
Air time, 32-byte event
~370 ms at SF10/BW125
Idle current
1.6 µA in sleep, 4.6 mA RX, 118 mA TX

Range budget — Pier 70 to Alki

Distance: ~4.7 km over open water. Free-space path loss at 915 MHz is FSPL = 20·log10(d) + 20·log10(f) − 27.55≈ 105 dB. With the buoy at +14 dBm and 3 dBi, the gateway at 5 dBi: total link = 14 + 3 + 5 − 105 = −83 dBm at the gateway. SX1262 sensitivity at SF10/BW125 is around −132 dBm — so we have a ~49 dB margin, plenty for sea-state fade, antenna mis-alignment, and the inevitable seagull on the whip. Bump SF up to 12 if you move the gateway off Pier 70 to a Magnolia rooftop.

Hydrophone front-end (the analog half)

This is where amateur builds usually botch it. You need three things between the piezo and the ADC: (1) high-impedance buffer (FET source-follower or TLV9001), (2) band-pass filter that kills 60 Hz hum and out-of-band aliases, (3) instrumentation amp with gain selectable in software so you can survive a passing tug.

        piezo ── 10 MΩ ──┬── 100nF ──┬── INA333 ──── ADC0 (I²S MCK)
                         │           │                │
                         │     LPF 25 kHz       digital AGC
                         │     (Sallen-Key)    in firmware
                       Vbias                    (8-step PGA)
                       (1.65V)

Sample rate target: 48 kHz, 16-bit. Orca call energy lives mostly in the 1–8 kHz range for S-calls and click trains, with whistles climbing to 12+ kHz. 48 kHz Nyquist gives you clean coverage with margin.

The detector — what fires the LoRa packet

Don't try to identify pods. Just detect "tonal call energy in the orca band, sustained > 250 ms" and let the fusion layer correlate. The simple version:

// 1-second window, 48 kS/s, 1024-point FFT, 50% overlap
float orca_band_energy(const float* mag, int n) {
  // Bins corresponding to 500 Hz – 12 kHz
  int lo = (int)( 500.0f / (48000.0f / n));
  int hi = (int)(12000.0f / (48000.0f / n));
  float e = 0;
  for (int i = lo; i < hi; i++) e += mag[i] * mag[i];
  return e;
}

bool is_call(float band_e, float noise_floor) {
  // 12 dB SNR over rolling median, 250 ms persistence
  return 10.0f * log10f(band_e / noise_floor) > 12.0f
      && persistence_counter > 12;   // 12 × 21 ms hops
}

The 1D-CNN upgrade — 4 conv layers over a 64-mel spectrogram — fits in 80 kB on the S3 and classifies orca-vs-vessel-cavitation-vs-other at ~92% recall in the small dataset I trained it on. It runs on top of the threshold gate, never instead of it: the gate keeps power down by skipping classification when there's nothing to classify.

The packet

32 bytes is a sweet spot — short air time, fits in one LoRa frame, leaves room for AES-CTR.

struct __attribute__((packed)) buoy_event {
  uint32_t epoch_s;        // UTC seconds
  uint16_t buoy_id;        // 16-bit unsigned
  int16_t  lat_e5;         // degrees × 1e5  (Elliott Bay ≈ +47.6)
  int16_t  lon_e5;         // degrees × 1e5  (Elliott Bay ≈ −122.3)
  uint8_t  event_type;     // 0=tonal 1=click 2=vessel 3=heartbeat
  uint8_t  band_db;        // SNR in dB above noise floor
  uint8_t  cnn_class;      // 0..255 confidence
  uint8_t  battery_pct;    // 0..100
  uint16_t flags;          // bit flags
  uint8_t  mic_aux[16];    // 16-byte feature digest (perceptual hash)
};

Skeleton firmware (Arduino + RadioLib)

#include <RadioLib.h>
#include <driver/i2s.h>

SX1262 radio = new Module(8, 14, 12, 13);    // Heltec V3 pinout

void setup() {
  Serial.begin(115200);
  i2s_install();                              // 48 kHz, 16-bit, mono
  radio.begin(915.0, 125.0, 10, 5, 0x12, 14); // SF10 BW125 CR4/5 14 dBm
}

void loop() {
  float spec[512];
  capture_window(spec);                       // FFT magnitudes
  float e = orca_band_energy(spec, 1024);
  update_noise_floor(spec);
  if (is_call(e, noise_floor)) {
    buoy_event ev = build_event(e);
    radio.transmit((uint8_t*)&ev, sizeof(ev));
    deep_sleep(3000);                       // rate-limit, 3 s lockout
  } else {
    light_sleep(980);                        // 50 ms duty
  }
}

Power budget

Sleep current
~25 µA (light sleep with I²S clock idle)
Active current (DSP)
~85 mA for 50 ms / s = 4.2 mA average
TX burst
118 mA × 0.37 s × 6 events/h ≈ 0.07 mA average
Day total
~4.3 mA × 24 h = 103 mAh / day
Battery
3500 mAh = ~33 days dark, indefinite with 1 W solar

Meshtastic vs. raw LoRa vs. LoRaWAN

Raw point-to-point

Lowest power, simplest code. Good for one buoy + one gateway. No retries, no addressing. Use this for the first build.

Meshtastic firmware

Drop-in OSS firmware, mesh routing, encrypted, has a real protocol. The buoy becomes a "node" and any other Meshtastic radio in the bay relays for free. This is the right answer if you flash more than one buoy.

LoRaWAN (TTN/Helium)

Use if you want public infrastructure to do the gateway work. Pier 70 already has TTN coverage. Tradeoff: you give up multicast and fair-use limits cap you at ~30 s of air time per day per device.

Deployment ethics & permitting

Read this before you put anything in the water

Where it fits in the stack

Node 01 · Spectrum ← SigDigger SDR Console Node 03 · Acoustic iPhone Hydrophone Stream → Node 04 · Fusion Salish SIGINT Mesh →
Salish SIGINT · Node 02 / 04 BOM accurate as of 2026 · prices change · CC-BY-SA