Defines topic hierarchy, payload schemas, QoS levels, heartbeat protocol, camera auto-discovery via announce topic, offline buffering strategy, and command/response flow for start/stop. Architecture: travel router subnet (192.168.4.x), Pi Zero 2 W runs Mosquitto + Go backend, ESP32s dual-STA to GoPro AP + travel router. No internet dependency. Closes CUB-238.
11 KiB
MQTT Message Format Contract — RemoteRig
Version: 1.0.0 | Status: Draft | Blocks: CUB-232 (MQTT subscriber), CUB-174 (ESP32 firmware) Last updated: 2026-05-21
Network Architecture
┌──────────────────────────────────┐
│ Travel Router (192.168.4.1) │
│ DHCP: .100-.200 │
└──────┬──────────┬──────────┬──────┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
│ 192.168.4.101│ │ 192.168.4.102│ │ 192.168.4.10 │
│ │ │ │ │ │
│ STA→GoPro AP │ │ STA→GoPro AP │ │ Mosquitto │
│ STA→Router │ │ STA→Router │ │ Go backend │
└──────┬───────┘ └──────┬───────┘ │ React UI │
│ │ └──────────────┘
▼ ▼
┌──────────────┐ ┌──────────────┐
│ GoPro Hero 3 │ │ GoPro Hero 3 │
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │
└──────────────┘ └──────────────┘
- Travel router: Self-contained, no internet. DHCP pool:
192.168.4.100-200 - Pi Zero 2 W: Static IP
192.168.4.10. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI - ESP32s: DHCP from router. Each has dual STA: one to GoPro AP, one to router
- User device: Connects to router, opens
http://192.168.4.10:8080for dashboard
MQTT Broker
- Host:
192.168.4.10(Pi Zero 2 W) - Port:
1883(default MQTT, no TLS — closed network) - Auth: None (closed network, no external access)
- Client ID format:
remoterig-<esp32_mac_last6>(e.g.,remoterig-a1b2c3) - QoS: 1 (at least once) for status/heartbeat. 2 (exactly once) for commands.
- Retain: Status messages use
retain: trueso new subscribers get latest state immediately
Topic Hierarchy
remoterig/
├── cameras/
│ └── <camera_id>/
│ ├── status ← ESP32 publishes (retained, QoS 1)
│ ├── heartbeat ← ESP32 publishes (QoS 1, not retained)
│ ├── command → Hub publishes (QoS 2)
│ └── announce ← ESP32 publishes on first boot (QoS 2, retained)
└── hub/
└── status ← Hub publishes (retained, QoS 1)
Topic: remoterig/cameras/<camera_id>/status
Direction: ESP32 → Hub
QoS: 1 | Retain: true | Interval: 30 seconds
Published by the ESP32 every 30s with the latest GoPro status.
{
"camera_id": "cam-001",
"timestamp": "2026-05-21T18:30:00Z",
"battery_pct": 85,
"battery_raw": 217,
"video_remaining_sec": 3420,
"recording": true,
"mode": "video",
"resolution": "1080p",
"fps": 30,
"online": true,
"rssi": -52,
"uptime_sec": 1247
}
| Field | Type | Required | Description |
|---|---|---|---|
camera_id |
string | ✅ | Unique camera identifier (set during registration) |
timestamp |
ISO 8601 | ✅ | ESP32 clock time when status was read |
battery_pct |
int 0-100 | ✅ | Calibrated battery percentage (null if uncalibrated → omit) |
battery_raw |
int 0-255 | — | Raw byte from GoPro status offset 57 |
video_remaining_sec |
int | — | Estimated remaining recording seconds (null if unavailable) |
recording |
bool | ✅ | Whether camera is currently recording |
mode |
string | — | Current mode (e.g., "video", "photo", "burst") |
resolution |
string | — | Current resolution string |
fps |
int | — | Current frames per second |
online |
bool | ✅ | ESP32 can reach the GoPro (false if GoPro AP unreachable) |
rssi |
int | — | Wi-Fi RSSI to GoPro AP (dBm, negative) |
uptime_sec |
int | — | ESP32 uptime in seconds |
Topic: remoterig/cameras/<camera_id>/heartbeat
Direction: ESP32 → Hub
QoS: 1 | Retain: false | Interval: 60 seconds
Lightweight keepalive so the hub can detect dead ESP32s.
{
"camera_id": "cam-001",
"timestamp": "2026-05-21T18:31:00Z",
"uptime_sec": 1307,
"free_heap": 28672
}
| Field | Type | Description |
|---|---|---|
camera_id |
string | Camera identifier |
timestamp |
ISO 8601 | Current ESP32 time |
uptime_sec |
int | ESP32 uptime |
free_heap |
int | Free heap in bytes (diagnostic) |
Hub behavior: If no heartbeat for 120 seconds, mark camera as offline (online: false in SSE broadcast).
Topic: remoterig/cameras/<camera_id>/command
Direction: Hub → ESP32
QoS: 2 | Retain: false
Commands sent from the dashboard to individual cameras.
{
"command": "start_recording",
"request_id": "req-abc123",
"timestamp": "2026-05-21T18:32:00Z"
}
Supported commands:
command |
Description | Response topic |
|---|---|---|
start_recording |
Start GoPro recording | status (updated on next poll) |
stop_recording |
Stop GoPro recording | status (updated on next poll) |
reboot |
Reboot the ESP32 | — (ESP32 reconnects after boot) |
ESP32 behavior:
- On receipt, execute command against GoPro
- Next status publish will reflect the new state
- If command fails (GoPro unreachable), publish status with
online: false
Topic: remoterig/cameras/<camera_id>/announce
Direction: ESP32 → Hub
QoS: 2 | Retain: true
Published once on ESP32 first boot (or factory reset). Used for auto-registration.
{
"mac_address": "AA:BB:CC:DD:EE:FF",
"firmware_version": "0.1.0",
"capabilities": ["start_stop", "status"],
"friendly_name": "ESP32-AA-BB-CC"
}
| Field | Type | Description |
|---|---|---|
mac_address |
string | ESP32 Wi-Fi MAC address |
firmware_version |
string | ESP32 firmware semver |
capabilities |
string[] | Supported features |
friendly_name |
string | Default human-readable name |
Hub behavior on first announce:
- Check if MAC already registered → if yes, update
friendly_nameand log - If new MAC → create camera with auto-generated
camera_id = "cam-<NNN>"(zero-padded sequential) - Respond by publishing:
remoterig/cameras/<camera_id>/commandwithcommand: "registered"payload containing the assignedcamera_id - Broadcast via SSE that a new camera appeared
Topic: remoterig/hub/status
Direction: Hub → All
QoS: 1 | Retain: true | Interval: 30 seconds
Hub health status broadcast.
{
"version": "0.2.0",
"uptime_sec": 86400,
"cameras_online": 3,
"cameras_total": 4,
"mqtt_connected": true,
"db_size_bytes": 1048576
}
Message Validation Rules
Hub-side (incoming from ESP32)
- Required fields:
camera_idandtimestampmust be present in all messages - Timestamp sanity: Reject if timestamp is > 5 minutes in the future or > 24 hours in the past
- Duplicate detection: Status messages with same
(camera_id, timestamp)are ignored (idempotent) - Schema validation: Unknown fields are ignored (forward-compatible), missing required fields → log warning + reject
- battery_pct bounds: If present, must be 0–100. Out of range → clamp to [0,100] with warning
ESP32-side (incoming from hub)
- Acknowledge commands: After processing a command, the next status publish reflects the new state
- Unknown commands: Log and ignore
- Malformed JSON: Log error, ignore message
Session Lifecycle
ESP32 boots
│
├── Connects to travel router Wi-Fi
├── Connects to MQTT broker (192.168.4.10:1883)
├── Publishes announce (retained) on cameras/<id>/announce
│
▼
┌─────────────────────────────────────────┐
│ Main loop (every 30s): │
│ 1. HTTP GET GoPro status (10.5.5.1) │
│ 2. Parse 60-byte status blob │
│ 3. Publish status (retained) │
│ 4. Every 60s: publish heartbeat │
└─────────────────────────────────────────┘
│
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
├── On GoPro unreachable → publish status with online: false
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
│
▼
ESP32 shutdown / watchdog reboot
Data Flow: Start Recording Example
1. User clicks "Start" on dashboard
2. Browser → HTTP POST /api/v1/cameras/cam-001/start → Go backend
3. Go backend → MQTT publish remoterig/cameras/cam-001/command {command: "start_recording"}
4. ESP32 receives command, sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
5. GoPro starts recording
6. Next 30s poll: ESP32 publishes status with recording: true
7. Go backend receives status, updates SQLite, fans out via SSE
8. Dashboard updates with pulsing REC indicator
Offline Buffering (future — CUB-230)
When ESP32 loses connection to travel router:
- Buffer: Store status snapshots in SPIFFS (LittleFS), max 100 entries (~6KB)
- Eviction: FIFO — oldest dropped when buffer full
- Replay: On MQTT reconnect, publish buffered messages in chronological order with original timestamps
- Dedup: Hub ignores duplicates via
(camera_id, recorded_at)unique constraint in status_logs
Backward Compatibility
- Adding fields: Safe — unknown fields ignored by both sides
- Removing fields: Mark as optional first, remove in next major version
- Changing field types: New topic path (e.g.,
status/v2) or new field name - New topics: Add freely — old clients ignore unknown topics
Open Questions
- NTP/time sync: How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. Recommendation: Pi runs NTPd, ESP32s use SNTP from
192.168.4.10. - Camera naming: Should
friendly_namebe configurable from dashboard after auto-registration? Recommendation: Yes — allow rename via UI, stored in cameras table. - Firmware OTA: Should ESP32 firmware updates be possible over this network? Recommendation: Yes but out of scope for MVP.