# 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:8080` for 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: true` so 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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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:**
1. Check if MAC already registered → if yes, update `friendly_name` and log
2. If new MAC → create camera with auto-generated `camera_id = "cam-<NNN>"` (zero-padded sequential)
3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
4. 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.

```json
{
  "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)

1. **Required fields:** `camera_id` and `timestamp` must be present in all messages
2. **Timestamp sanity:** Reject if timestamp is > 5 minutes in the future or > 24 hours in the past
3. **Duplicate detection:** Status messages with same `(camera_id, timestamp)` are ignored (idempotent)
4. **Schema validation:** Unknown fields are ignored (forward-compatible), missing required fields → log warning + reject
5. **battery_pct bounds:** If present, must be 0–100. Out of range → clamp to [0,100] with warning

### ESP32-side (incoming from hub)

1. **Acknowledge commands:** After processing a command, the next status publish reflects the new state
2. **Unknown commands:** Log and ignore
3. **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:

1. **Buffer:** Store status snapshots in SPIFFS (LittleFS), max 100 entries (~6KB)
2. **Eviction:** FIFO — oldest dropped when buffer full
3. **Replay:** On MQTT reconnect, publish buffered messages in chronological order with original timestamps
4. **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

1. **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`.
2. **Camera naming:** Should `friendly_name` be configurable from dashboard after auto-registration? **Recommendation:** Yes — allow rename via UI, stored in cameras table.
3. **Firmware OTA:** Should ESP32 firmware updates be possible over this network? **Recommendation:** Yes but out of scope for MVP.
