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
| Part | Notes | USD |
|---|---|---|
| Heltec WiFi LoRa 32 V3 (ESP32-S3 + SX1262) | 915 MHz US, USB-C, OLED, integrated charger | ~$22 |
| Aquarian Audio H1c hydrophone | 10 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 breakout | Gain 1–10000 with one resistor, 50 µV offset | $12 |
| Sallen-Key HPF 200 Hz / LPF 25 kHz | Pre-ADC anti-alias + DC reject | $3 in passives |
| Samsung INR18650-35E (3500 mAh) | Protected cell holder | $8 |
| Voltaic 1 W / 6 V solar panel | Trickle charge through TP4056 | $15 |
| Pelican 1010 case + cable gland | IP67 once you epoxy the gland in | $24 |
| 3 dBi 915 MHz whip + IPEX → SMA pigtail | Use 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 |
| Total | Aquarian 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
- Do not deploy on federal property. NOAA's Olympic Coast National Marine Sanctuary, Magnuson Park's swim-line buoys, and Coast Guard nav aids are all off limits.
- SRKWs are endangered. Southern Resident Killer Whales (J/K/L pods) are ESA-listed. Passive listening from a private dock is fine; active acoustic (pingers, sonar) is not — and the FCC/Coast Guard will care.
- Get a slip / dock-owner permission. The right play is a friend's dock at Shilshole, Eagle Harbor, or Don Armeni Boat Ramp. The buoy mass is small enough to look like a marker.
- Mark it. Yellow float, reflective tape, name + phone. Otherwise it's marine debris and the harbor patrol will pull it.
- Don't claim science. This is citizen monitoring. Real science wants calibrated hydrophones (B&K 8104, ±1 dB) and IRB-equivalent protocols. Be honest in the data.