- Kotlin 38.5%
- Python 28.6%
- C++ 24.5%
- Nix 6.3%
- Shell 2.1%
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> |
||
|---|---|---|
| .claude | ||
| android | ||
| gateway | ||
| meshtastic | ||
| pi-image | ||
| scripts | ||
| superset | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
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 polledread()or a threadedrun()(own FreeRTOS task). Threaded sensors timestamp readings in interrupt/UART callbacks and push into a shared FreeRTOS queue.WetnetSensorManagerdrains the queue on its own thread and publishes readings as batched mesh packets (up to 20 readings per packet,SENSOR_BATCHwire type0x06). - Wire formats (
WetnetSensorTypes.h) — one-byte message type prefix on everyPRIVATE_APPpayload:0x01reading,0x02config,0x03error,0x04status,0x05bt-pin,0x06batch,0x07GNSS raw observations. - Built-in sensors (
src/custom/sensors/) — JSN-SR04T ultrasonic (UART mode), SDI-12 recorder + slave, VL53L0X ToF, counter, GPS position (observes Meshtastic'sgpsStatus->onNewStatus), GNSS raw (reserved for PPK). - Config over the mesh —
SENSOR_CONFIGpackets 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-gatewayin serial-gateway mode with the web UI on port 8080wpa_supplicantwith imperative networkssshd, awetnetsysuser with sudo,zramSwap(Zero 2 W has only 512 MB)- A oneshot
wetnet-provision.servicethat 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
0x0and the OTA loader partition at0x5D0000. - BLE control — full Meshtastic session handshake (
want_config→config_complete_id), serialized GATT ops, correctFROMNUMUUID. 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 bundling —
copyFirmwareGradle task pulls the latest build out ofmeshtastic/build/firmware-outso 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>.jsonland.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.