No description
  • Kotlin 38.5%
  • Python 28.6%
  • C++ 24.5%
  • Nix 6.3%
  • Shell 2.1%
Find a file
M Virts dc862834d3 Add top-level README
Covers the project layout, firmware architecture, gateway CLI + web UI,
the new pi-image build/provision flow (including the symlink-safety
rationale in the provisioner), Android app scope, wire protocol byte-map,
per-subproject dev shells, and the Claude Code collaboration conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 14:19:07 -06:00
.claude Add Claude Code project data (MEMORY.md + scrubbed session) 2026-07-07 14:10:39 -06:00
android Add PDOP, VDOP, ground speed to GPS measurements 2026-06-29 09:22:05 -06:00
gateway Add PDOP, VDOP, ground speed to GPS measurements 2026-06-29 09:22:05 -06:00
meshtastic Add PDOP, VDOP, ground speed to GPS measurements 2026-06-29 09:22:05 -06:00
pi-image pi-image: NixOS SD image for Raspberry Pi wetnet gateway 2026-07-07 14:10:28 -06:00
scripts Wire wetnet into meshtastic module system, add meshtastic CLI to devshell 2026-06-18 12:45:27 -06:00
superset Threaded sensor architecture, USB flash, GPS position, batch protocol 2026-06-28 14:41:24 -06:00
.gitignore Threaded sensor architecture, USB flash, GPS position, batch protocol 2026-06-28 14:41:24 -06:00
flake.lock Wire wetnet into meshtastic module system, add meshtastic CLI to devshell 2026-06-18 12:45:27 -06:00
flake.nix Wire wetnet into meshtastic module system, add meshtastic CLI to devshell 2026-06-18 12:45:27 -06:00
README.md Add top-level README 2026-07-07 14:19:07 -06:00

wetnet

A LoRa mesh network for transporting scientific data across remote wetlands.

wetnet nodes are Heltec V3 boards (ESP32-S3 + SX1262) running a customized Meshtastic firmware. Each node samples one or more environmental sensors (water level, SDI-12 recorders, GPS, sonar) and forwards batched readings over the mesh to a gateway. The gateway decodes them, stores them in SQLite, and serves a live web UI. An optional Raspberry Pi image bakes the gateway into a stand-alone appliance you can drop at a site with only an SD card, a USB-attached radio node, and wifi.

Repository layout

Path What it is
flake.nix Root dev shell (meshtastic CLI, platformio, esptool)
meshtastic/ Custom Meshtastic firmware for Heltec V3, sensor drivers, wire protocol
gateway/ Python collector (MQTT + serial + web UI) and its NixOS module
pi-image/ NixOS aarch64 SD image for Pi 3B+ / Zero 2 W + a provisioning CLI
android/ Kotlin app for flashing, BLE control, live monitoring, USB log capture
superset/ Optional Apache Superset environment for exploring the collector database
scripts/ One-off setup scripts (e.g. configuring a stock node as an MQTT bridge)
.claude/ Claude Code project data (memory + session transcript) — see the end of README

Firmware (meshtastic/)

Vendored Meshtastic upstream is pinned via flake.nix. Wetnet code lives in src/custom/ and is overlaid into the upstream tree at build time:

  • Sensor architecture (WetnetSensor*.h) — each sensor implements either a polled read() or a threaded run() (own FreeRTOS task). Threaded sensors timestamp readings in interrupt/UART callbacks and push into a shared FreeRTOS queue. WetnetSensorManager drains the queue on its own thread and publishes readings as batched mesh packets (up to 20 readings per packet, SENSOR_BATCH wire type 0x06).
  • Wire formats (WetnetSensorTypes.h) — one-byte message type prefix on every PRIVATE_APP payload: 0x01 reading, 0x02 config, 0x03 error, 0x04 status, 0x05 bt-pin, 0x06 batch, 0x07 GNSS raw observations.
  • Built-in sensors (src/custom/sensors/) — JSN-SR04T ultrasonic (UART mode), SDI-12 recorder + slave, VL53L0X ToF, counter, GPS position (observes Meshtastic's gpsStatus->onNewStatus), GNSS raw (reserved for PPK).
  • Config over the meshSENSOR_CONFIG packets can enable/disable individual sensors and change measurement intervals on any node (or all nodes) without a flash.
  • RTC gating — readings whose timestamp would be 0 (no time source) are suppressed to avoid polluting the DB.
  • Power — sleep/wake observers suspend sensor FreeRTOS tasks so the Meshtastic power FSM can enter light-sleep / DARK modes.

Build and flash from the meshtastic subshell: nix run .#build (impure — PlatformIO fetches libraries) and nix run .#flash for the Heltec V3 target.

Gateway (gateway/)

The wetnet-collector Python app is one CLI with several modes:

Subcommand What it does
(default) Subscribes to a Meshtastic MQTT topic, decrypts the channel PSK, decodes wetnet payloads.
gateway Reads directly from a USB-attached node over serial, optionally republishes to MQTT.
history Prints stored sensor readings, filterable by node, sensor, or timestamp.
health Node-by-node summary — last seen, battery, reading counts.

Data lands in a SQLite database. The gateway subcommand can also start a Flask web UI (--web 8080) with:

  • Per-sensor time-series charts (Chart.js + zoom/pan)
  • Filter persistence via localStorage, per-chart maximize
  • A Leaflet map panel of node location history
  • Auto-scaling row limits so 7-day views actually cover 7 days

A NixOS module (gateway/module.nix) exposes services.wetnet-gateway.{enable, mqtt, mosquitto, collector} for turnkey deployments.

Pi image (pi-image/)

nix build .#packages.aarch64-linux.sdImage produces a compressed SD image that boots straight into a running wetnet gateway on a Pi 3B+ or Pi Zero 2 W. Building from an x86_64 host uses QEMU-user emulation (boot.binfmt.emulatedSystems = [ "aarch64-linux" ]; on the builder).

The image bakes in:

  • services.wetnet-gateway in serial-gateway mode with the web UI on port 8080
  • wpa_supplicant with imperative networks
  • sshd, a wetnet sysuser with sudo, zramSwap (Zero 2 W has only 512 MB)
  • A oneshot wetnet-provision.service that runs before every boot

Everything site-specific (hostname, wifi SSID/PSK, user password, ssh keys, MQTT/gateway overrides) is read from /etc/wetnet/provision.json. That file gets written before flashing to the SD card, by the wetnet-provision CLI in the same subproject:

zstd -d --keep result/sd-image/*.img.zst
sudo nix run .#provision -- \
  --image result/sd-image/*.img \
  --hostname wetnet-pi-alpha \
  --password-prompt \
  --ssh-key @~/.ssh/id_ed25519.pub \
  --wifi-ssid WetlandSite1 --wifi-psk secret --wifi-country US \
  --serial-port /dev/ttyUSB0 --web-port 8080

The provisioner is careful about one specific hazard: when it operates on a mounted image on a NixOS host, most files under /etc are symlinks into the host's /nix/store. Following them would silently mutate the running host instead of the image. Every directory hop uses O_NOFOLLOW | O_DIRECTORY via openat, and any symlink component raises ScopedPathError and aborts. test-provision.sh exercises this against a decoy target to prove the escape refusal.

Android app (android/)

Kotlin app that centralizes wetnet node operations:

  • USB firmware flash — ESP32-S3 stub flasher (v1 stub; v2 crashed on some boards). USB-JTAG-Serial reset, 460800 baud after stub, flashes both the main firmware at 0x0 and the OTA loader partition at 0x5D0000.
  • BLE control — full Meshtastic session handshake (want_configconfig_complete_id), serialized GATT ops, correct FROMNUM UUID. Sends raw byte payloads or WetNet-typed messages (sensor config, PIN config, etc.).
  • USB serial log capture — Console / Packets / Readings tabs; export to a file via FileProvider for offline inspection.
  • Firmware bundlingcopyFirmware Gradle task pulls the latest build out of meshtastic/build/firmware-out so the APK ships ready to flash.

Requires JDK 21 (see CLAUDE.md for the exact nix store path used during development).

Superset (superset/)

Opt-in Apache Superset environment (nix develop + ./setup.sh) that points at the collector's SQLite database. Useful for building bespoke dashboards beyond the collector's built-in web UI.

Wire protocol summary

Refer to meshtastic/src/custom/WetnetSensorTypes.h for the canonical byte-level definitions. Every payload begins with a message-type byte:

Byte Type Size Notes
0x01 SENSOR_READING 12 B single reading, kept for backward compat
0x02 SENSOR_CONFIG 16 B targeted or broadcast sensor reconfiguration
0x03 SENSOR_ERROR 12 B last error + consecutive count
0x04 NODE_STATUS 12 B reset reason, battery, uptime
0x05 BT_PIN_CONFIG 10 B fixed or randomized Bluetooth pairing PIN
0x06 SENSOR_BATCH 3+11×N up to 20 readings per packet
0x07 GNSS_OBS 8+12×N raw phase/pseudorange batch for PPK

Sensor type IDs are one byte, split into built-ins (0x00-0x7F) and host-defined types registered at runtime over serial (0x80-0xFF).

Development

At the repo root:

nix develop            # meshtastic CLI, platformio, esptool

Per-subproject dev shells:

nix develop ./gateway   # python + mosquitto + sqlite
nix develop ./pi-image  # provisioner + zstd/mkpasswd/losetup
nix develop ./superset  # python 3.11 + superset venv

The root project is a nix flake, but individual subprojects are independent flakes so they can be consumed on their own (e.g. the gateway flake exposes nixosModules.wetnet-gateway for use in other NixOS configurations).

Working with Claude Code

Development on this repository has been done in collaboration with Claude Code, Anthropic's CLI. The .claude/ directory vendors two artifacts from the CLI's per-project state at ~/.claude/projects/-home-m-src-wetnet/:

  • .claude/memory/MEMORY.md — the persistent memory index that Claude Code loads at the start of every session. It describes the project layout, sensor architecture, pin assignments, and other facts that are stable enough to remember across sessions.
  • .claude/<session-id>.jsonl and .claude/<session-id>/ — the full transcript and subagent/tool-result cache for the session that produced the current state of the tree. These are frozen snapshots meant as reference, not something to be re-run.

Before committing, the session transcript was scrubbed for embedded secrets: OPENSSH PRIVATE KEY blocks and the Forgejo push token were replaced with [REDACTED ...] placeholders. If you re-vendor updated session data, run the same scrub before pushing.

Commits produced with Claude Code carry a Co-Authored-By: Claude ... trailer.