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 hub 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 in dispatcher and full.
  • The DetectionPublisher (publishes detection) runs in dispatcher and full. It subscribes to the in-process eventBus and republishes each DetectionEvent so subscribers see a live feed without polling REST.
  • The MqttConfigPusher (publishes config, update, live-start, live-stop, log-request) runs in api and full. In split mode the api uses the dispatcher's connection via the ingester's publish helper.

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 (Pi 1.0.3 via ${dataDir}/filter-stats.json). outboxStats.diskBytes is the real ${dataDir}/audio/ directory size including orphan WAVs Pi 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,
  "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.

Every setting field is resolved by the hub from the settings registry: the satellite's own value, else its team's, else the hub's, else the built-in default, a lock at the hub or the team ending the walk there. excludedYamnetCategories lists the drop switches that are on (all five by default). filterEnabled: false turns off both gates before upload, the silence gate and YAMNet with its category drops, on phones and Pis alike Pi 1.6.5: every chunk goes on to BirdNET. A field a message leaves out, or any field before the first message, takes the registry's default (DEFAULT_SATELLITE_CONFIG in @birdnet-ng/shared; phone 1.27+, Pi 1.6.4). 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, hub 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} or mobile-{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-hub 0.32.5 the satellite used the registering user's session JWT (7-day expiry, silent failure mode); from hub 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 MqttAdmin service provisions them at registration and revokes on satellite deletion.
  • ACL: each satellite's login holds its own role, sat-{tenant_id}-{satellite_id}, whose ACLs name that exact prefix, so it can only publish/subscribe under its own {tenant_id}/{satellite_id}/ topics (hub 0.62+; the shared %u role that came before was deleted from the broker on 2026-09-27 with the last logins holding it, all for deleted satellites).
  • 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 whose last_seen_at is 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 a satellite_offline system 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.