You are viewing archived documentation for v0.35. Go to latest →

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 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 (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} 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-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 MqttAdmin service 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 %u username 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 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.