Appearance
MQTT Protocol
BirdNET-NG uses MQTT for all satellite-to-hub communication. The broker is Mosquitto 2.x with the built-in Dynamic Security Plugin.
Topic Structure
birdnet/{tenant_id}/{satellite_id}/{channel}Channels
| Channel | Direction | QoS | Description |
|---|---|---|---|
audio | Satellite → Hub | 1 (at-least-once) | Audio chunk upload (base64 WAV) |
telemetry | Satellite → Hub | 1 (at-least-once) | Battery, storage, CPU, GPS |
heartbeat | Satellite → Hub | 1 (at-least-once) | Lightweight keepalive every 30s |
config | Hub → Satellite | 2 (exactly-once) | Recording profile push |
ack | Hub → Satellite | 1 (at-least-once) | Chunk acknowledgment |
Why QoS 1 for telemetry?
Telemetry was originally QoS 0 (fire-and-forget), but lost telemetry packets caused satellites to appear offline. Upgraded to QoS 1 for reliable delivery.
Message Formats
Audio Chunk
json
{
"chunkId": "uuid",
"satelliteId": "uuid",
"tenantId": "uuid",
"recordedAt": "2026-03-22T10:30:00Z",
"durationMs": 3000,
"sampleRate": 48000,
"latitude": 43.5659,
"longitude": 3.905,
"audio": "<base64-encoded WAV>"
}3-second WAV at 48kHz mono, base64-encoded. The hub stores the audio in MinIO, inserts a DB record, and enqueues an inference job.
Telemetry
json
{
"satelliteId": "uuid",
"tenantId": "uuid",
"batteryLevel": 85,
"storageFreeBytes": 1073741824,
"cpuTemp": 42.5,
"networkLatencyMs": null,
"packetLossPct": null,
"uptimeSeconds": 3600,
"timestamp": "2026-03-22T10:30:00Z"
}batteryLevel: percentage (0–100) ornullfor mains-powered devicesstorageFreeBytes: available disk space,nullif unavailablecpuTemp: degrees Celsius,nullon mobile (not accessible)- All fields except
satelliteId,tenantId,timestampare nullable
Heartbeat
json
{
"satelliteId": "uuid",
"tenantId": "uuid",
"uptimeSeconds": 3600,
"state": "recording",
"timestamp": "2026-03-22T10:30:00Z"
}State values:
| State | Meaning |
|---|---|
recording | Actively capturing audio |
paused | User paused recording (mobile) |
scheduled_off | Outside recording window (scheduler) |
error | Capture or connection error |
The hub updates satellites.last_seen_at and satellites.heartbeat_state on each heartbeat. This decouples online status from audio chunk sending — a satellite filtering all chunks (silence) still shows as online.
Sent every 30 seconds by both Pi satellites and mobile app.
Config Push
json
{
"satelliteId": "uuid",
"tenantId": "uuid",
"recordingProfile": {
"type": "dawn_chorus",
"schedule": [
{ "start": "-30", "end": "120", "reference": "sunrise" }
],
"sampleRate": 48000,
"chunkDurationMs": 3000,
"overlapMs": 0,
"gain": 1.0
},
"retentionHours": 48
}Schedule references:
sunrise/sunset: start/end are minutes relative to sun event (negative = before)absolute: start/end areHH:MM24-hour format
The satellite resolves relative times locally using its GPS coordinates and the NOAA solar algorithm.
Acknowledgment
json
{
"chunkId": "uuid",
"status": "stored"
}Status: stored (success) or error (with error field containing message).
Connection
| Client | Protocol | URL |
|---|---|---|
| Pi Satellite | MQTTS | mqtts://mqtt.example.com:8883 |
| Mobile App | WSS | wss://mqtt.example.com |
- MQTTS: TLS terminated by Traefik, forwarded as plain MQTT to Mosquitto
- WSS: WebSocket over TLS for browser/Capacitor compatibility
- Client ID:
satellite-{satelliteId}ormobile-{satelliteId} - Auth: username = satellite ID, password = generated at registration
Security
- Mosquitto Dynamic Security Plugin manages per-satellite credentials
- Each satellite can only publish/subscribe to its own topics (ACL via
%uusername substitution) - Hub ingester subscribes to
birdnet/+/+/audio,birdnet/+/+/telemetry,birdnet/+/+/heartbeat - Credentials are provisioned at registration (
POST /api/satellites) and revoked on deletion
Offline Behavior
- The satellite offline timeout is configurable per tenant (default: 5 minutes)
SatelliteMonitor(hub) checks every 60 seconds for satellites wherelast_seen_atis older than the timeout- Heartbeat ensures the satellite stays online even when no audio passes the pre-filter
- When offline, the satellite queues chunks in its local SQLite outbox and drains them on reconnect