MQTT Protocol
All satellite-to-hub communication runs over MQTT. The broker is Mosquitto 2.x with the built-in Dynamic Security Plugin. Pi and mobile satellites both connect; the protocol surface is the same.
Topic Structure
birdnet/{tenant_id}/{satellite_id}/{channel}
Channels
| Channel | Direction | QoS | Purpose |
|---|---|---|---|
audio |
Satellite → Hub | 1 | 3 s WAV chunk upload (base64) |
telemetry |
Satellite → Hub | 1 | Battery, storage, CPU, GPS, periodic |
heartbeat |
Satellite → Hub | 1 | Lightweight keepalive, default every 30 s. Carries filter and outbox stats |
config-request |
Satellite → Hub | 1 | Empty-overrides ping on connect to pull current effective config |
config |
Hub → Satellite | 2 | Recording profile, filter settings, outbox caps, excluded categories |
ack |
Hub → Satellite | 1 | Chunk acknowledgment, flips outbox row to acked |
update |
Hub → Satellite | 1 | Triggers birdnet-update.sh on the satellite |
detection |
Hub → Satellite | 1 | New-detection notification, used by mobile DetectionsFeed (vhub-0.32.4+) |
live-start / live-stop |
Hub → Satellite | 1 | Start/stop a live-audio session |
live-audio |
Satellite → Hub | 0 | Live-audio chunks during an active session (fire-and-forget) |
log-request / log-response |
Hub → Satellite / Satellite → Hub | 1 | Remote log fetch |
The single-source-of-truth helper is mqttTopic(tenantId, satelliteId, channel) in @birdnet-ng/shared. The channel argument is a TypeScript union; extending the protocol means extending that type.
Hub mode and topic responsibilities
The hub runs in one of three modes: full, dispatcher, api.
- The MQTT ingester (subscribes to
audio/telemetry/heartbeat/config-request/live-audio/log-response) runs indispatcherandfull. - The DetectionPublisher (publishes
detection) runs indispatcherandfull. It subscribes to the in-processeventBusand republishes eachDetectionEventso subscribers see a live feed without polling REST. - The MqttConfigPusher (publishes
config,update,live-start,live-stop,log-request) runs inapiandfull. In split mode the api uses the dispatcher's connection via the ingester'spublishhelper.
Message Formats
Audio Chunk (Satellite → Hub)
{
"chunkId": "uuid",
"satelliteId": "uuid",
"tenantId": "uuid",
"recordedAt": "2026-03-22T10:30:00Z",
"durationMs": 3000,
"sampleRate": 48000,
"latitude": 43.5659,
"longitude": 3.905,
"audio": "<base64 WAV>"
}
3 s WAV at 48 kHz mono, base64-encoded. The ingester stores the WAV in MinIO, inserts an audio_chunks row, and enqueues a BullMQ inference job.
Telemetry (Satellite → Hub)
{
"satelliteId": "uuid",
"tenantId": "uuid",
"batteryLevel": 85,
"storageFreeBytes": 1073741824,
"cpuTemp": 42.5,
"networkLatencyMs": null,
"packetLossPct": null,
"uptimeSeconds": 3600,
"timestamp": "2026-03-22T10:30:00Z"
}
batteryLevel and cpuTemp are nullable (mobile reads battery via Capacitor; Pi reads cpuTemp from sysfs). storageFreeBytes comes from statfs on the data directory.
Heartbeat (Satellite → Hub)
{
"satelliteId": "uuid",
"tenantId": "uuid",
"uptimeSeconds": 3600,
"state": "recording",
"noiseFloorRms": 0.0012,
"version": "1.0.3",
"stream": "satellite",
"timestamp": "2026-03-22T10:30:00Z",
"filterStats": {
"totalProcessed": 12940,
"totalSent": 380,
"rejectedSilence": 9412,
"rejectedYamnet": 2845,
"rejectedAmphibian": 14,
"rejectedInsect": 158,
"rejectedAnthropogenic": 121,
"rejectedHumanVoice": 10,
"rejectedOtherAnimal": 0,
"rejectedOther": 0
},
"outboxStats": {
"totalBytes": 39845120,
"ackedBytes": 38821376,
"unackedBytes": 1023744,
"rowCount": 142,
"unackedCount": 4,
"diskBytes": 40073584
}
}
stream is "satellite" (Pi) or "mobile" (Android); pre-v0.28 satellites omit it and fall back to the hub stream when comparing version. filterStats counters persist across Pi restarts (vsat-1.0.3+ via ${dataDir}/filter-stats.json). outboxStats.diskBytes is the real ${dataDir}/audio/ directory size including orphan WAVs (vsat-1.0.2+), so the hub UI shows disk truth even before the next orphan sweep reconciles.
| State | Meaning |
|---|---|
recording |
Actively capturing |
paused |
User paused (mobile) |
scheduled_off |
Outside the active recording window |
error |
Capture or connection error |
Config-Request (Satellite → Hub)
{
"satelliteId": "uuid",
"tenantId": "uuid",
"overrides": {}
}
Sent on every (re)connect with an empty overrides object. The hub replies on the config topic with the resolved effective config (tenant defaults merged with per-satellite overrides). Satellites can also send non-empty overrides to update their own per-satellite settings.
Config Push (Hub → Satellite)
{
"satelliteId": "uuid",
"tenantId": "uuid",
"recordingProfile": {
"type": "dawn_chorus",
"schedule": [
{ "start": "-30", "end": "120", "reference": "sunrise" }
],
"sampleRate": 48000,
"chunkDurationMs": 3000,
"overlapMs": 0,
"gain": 1.0
},
"heartbeatIntervalSec": 30,
"filterEnabled": true,
"filterMinRms": 0.003,
"yamnetMinBirdProb": 0.05,
"excludedYamnetCategories": ["anthropogenic", "human_voice", "amphibian", "insect", "other_animal"],
"outboxSoftSizeMb": 5000,
"outboxHardSizeMb": 8000,
"outboxMaxAgeHours": 720
}
schedule[].reference is sunrise, sunset, or absolute. For sunrise/sunset, start and end are minutes offset (negative = before). For absolute, both are HH:MM 24-hour. The satellite resolves sun events locally from its GPS coordinates using the NOAA solar algorithm.
excludedYamnetCategories is computed by the hub from tenant_settings.drop_*_at_satellite (default true for all five) merged with satellite_config_overrides. Pre-v0.19.1 the protocol carried peakSnr, birdBandCheck, noiseFloorAlpha; those heuristics were removed when YAMNet subsumed them, and the corresponding fields are gone.
The hub pushes config on tenant-settings change, satellite-overrides change, and in response to a config-request.
Acknowledgment (Hub → Satellite)
{
"chunkId": "uuid",
"status": "stored"
}
status is stored or error (the latter carries an error field). The satellite outbox flips the row to acked on receipt, which makes it the soft-cap eviction target.
Detection (Hub → Satellite, vhub-0.32.4+)
{
"id": "uuid",
"tenantId": "uuid",
"satelliteId": "uuid",
"satelliteName": "Garden Pi",
"speciesCode": "frincoe1",
"commonName": "Common Chaffinch",
"scientificName": "Fringilla coelebs",
"confidence": 0.78,
"isRare": false,
"isFirstOfDay": true,
"isFirstOfSeason": false,
"rareReason": null,
"detectedAt": "2026-03-22T10:30:00Z",
"audioChunkId": "uuid"
}
Published per new detection so satellite-side clients (mobile DetectionsFeed, future Pi UI) can show a live ticker without polling REST. confidence is the raw model output; the calibration curve is not bulk-loaded at watcher time, so clients needing calibrated confidence call GET /api/detections/:id or the list endpoint, which decorates each row.
Update (Hub → Satellite)
{
"satelliteId": "uuid",
"tenantId": "uuid",
"targetVersion": "1.0.3"
}
Triggered by POST /api/satellites/:id/update from a tenant admin. The Pi runs birdnet-update.sh, which checks out the latest vsat-* tag, rebuilds, and restarts the systemd service. The hub stamps update_status='updating' immediately; on next heartbeat with version === target the status clears.
Live Audio
live-start and live-stop are one-shot signals (empty body or { "sessionId": "..." }). While a session is active, the satellite publishes raw 3 s WAV chunks (base64) on live-audio at QoS 0. The hub's LiveAudioManager relays them to the WebSocket clients watching that satellite. Sessions auto-expire after LIVE_AUDIO_TIMEOUT_MS (60 s) of no keepalive from the hub side.
Log Request / Response
log-request carries { "requestId": "uuid", "tail": 500 }. The satellite responds on log-response with newline-separated journal output. Used by the Satellite detail page's "Get logs" button.
Connection
| Client | Protocol | URL |
|---|---|---|
| Pi satellite | WSS | wss://mqtt.example.com (HTTPS :443) |
| Mobile app | WSS | wss://mqtt.example.com |
- WSS over :443 so satellites work behind restrictive firewalls without extra configuration.
- Client ID:
satellite-{satelliteId}ormobile-{satelliteId}. - MQTT auth: username = satellite UUID, password = random 32-byte hex string minted at registration time and shown once. Rotated on subsequent registrations of the same
device_id.
HTTP auth from the satellite (REST calls back to the hub) is independent. Pre-vhub-0.32.5 the satellite used the registering user's session JWT (7-day expiry, silent failure mode); from vhub-0.32.5+ a satellite-scoped api key is minted at registration (POST /api/satellites returns apiKey) and rotated via POST /api/satellites/:id/rotate-api-key. The auth resolver populates request.auth.satelliteId for those keys.
Security
- Mosquitto Dynamic Security manages per-satellite credentials. The hub
MqttAdminservice provisions them at registration and revokes on satellite deletion. - ACL: each satellite can only publish/subscribe to topics under its own
{tenant_id}/{satellite_id}/prefix (via%uusername substitution in the role definition). - The hub ingester subscribes to
birdnet/+/+/audio,birdnet/+/+/telemetry,birdnet/+/+/heartbeat,birdnet/+/+/config-request,birdnet/+/+/live-audio,birdnet/+/+/log-response.
Offline Behavior
SatelliteMonitor(hub-side, runs in dispatcher / full) checks every 60 s for satellites whoselast_seen_atis older than the configured offline threshold (default 5 minutes from tenant settings). It marks them offline and dispatches an alert via the rules engine if asatellite_offlinesystem rule is wired up.- The heartbeat keeps a satellite "online" even when every captured chunk is filtered out, so silence does not look like a network drop.
- When the broker is unreachable, the satellite buffers chunks in its local sqlite outbox. On reconnect it drains them oldest-first, respecting the outbox caps. See satellite-rpi.md and satellite-android.md for outbox sizing.