From 7929d1d9690c6269e4e19f0175b1a54db5fc82b5 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Fri, 5 Jun 2026 12:14:00 -0400 Subject: [PATCH] registration: self-assigned camera IDs (Option B) + tolerate clockless status Auto-registration never completed: the firmware announced on the wrong topic, the hub never replied, and an unregistered node couldn't receive a reply anyway. Switch to self-assigned IDs: firmware (esp32-mqtt-bridge.cpp): - camera_id defaults to the device id (clientID, e.g. rig-86d978) - always subscribe to /command; announce on the contract topic remoterig/cameras//announce (was the unmatched announce- form) - drop the bogus numeric timestamp from status (node has no clock) hub (subscriber.go): - handleAnnounce registers new cameras under the node's self-assigned id (no cam-NNN, no registered reply) - handleStatus tolerates an empty/invalid timestamp and stamps server-side (previously rejected the status outright) docs/MQTT_CONTRACT.md updated to match. Co-Authored-By: Claude Opus 4.8 --- docs/MQTT_CONTRACT.md | 16 ++++++++++---- firmware/src/esp32-mqtt-bridge.cpp | 24 +++++++++++---------- internal/mqtt/subscriber.go | 34 ++++++++++-------------------- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/docs/MQTT_CONTRACT.md b/docs/MQTT_CONTRACT.md index 487efa6..8f37568 100644 --- a/docs/MQTT_CONTRACT.md +++ b/docs/MQTT_CONTRACT.md @@ -176,11 +176,19 @@ Published once on ESP32 first boot (or factory reset). Used for auto-registratio | `capabilities` | string[] | Supported features | | `friendly_name` | string | Default human-readable name | -**Hub behavior on first announce:** +**Camera IDs (self-assigned — "Option B"):** the node uses a stable +device-derived id (`rig-`, e.g. `rig-86d978`) as its +`camera_id` from first boot, and uses it for all topics +(`announce`/`status`/`heartbeat`/`command`). There is no hub-assigned +`cam-NNN` and no `registered` reply handshake. + +**Hub behavior on 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-"` (zero-padded sequential) -3. Respond by publishing: `remoterig/cameras//command` with `command: "registered"` payload containing the assigned `camera_id` -4. Broadcast via SSE that a new camera appeared +2. If new MAC → insert the camera using the node's self-assigned `camera_id` +3. Broadcast via SSE that a new camera appeared + +> Note: nodes have no real-time clock, so `timestamp` may be absent; the hub +> stamps received-time server-side. ### Topic: `remoterig/hub/status` diff --git a/firmware/src/esp32-mqtt-bridge.cpp b/firmware/src/esp32-mqtt-bridge.cpp index 320f94b..dc31bdb 100644 --- a/firmware/src/esp32-mqtt-bridge.cpp +++ b/firmware/src/esp32-mqtt-bridge.cpp @@ -311,23 +311,25 @@ bool connectMQTT() { Serial.println("[MQTT] Connected"); - // Subscribe to commands (if registered) - if (cfg.camera_id.length() > 0) { - mqtt.subscribe(mqttTopic("command").c_str(), 2); + // Option B: self-assigned, stable camera_id derived from the device id. + if (cfg.camera_id.length() == 0) { + cfg.camera_id = clientID(); // e.g. "rig-86d978" } - // Announce if new - if (cfg.camera_id.length() == 0) { + // Subscribe to our command topic. + mqtt.subscribe(mqttTopic("command").c_str(), 2); + + // Announce (retained) on the contract topic so the hub registers/tracks us. + { JsonDocument doc; doc["mac_address"] = WiFi.macAddress(); - doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge"; - doc["friendly_name"] = "Cam-" + clientID(); + doc["firmware_version"] = "0.4.0-esp32-mqtt-bridge"; + doc["friendly_name"] = "Cam-" + cfg.camera_id; JsonArray caps = doc["capabilities"].to(); caps.add("start_stop"); caps.add("status"); String payload; serializeJson(doc, payload); - String announceTopic = "remoterig/cameras/announce-" + clientID(); - mqtt.publish(announceTopic.c_str(), payload.c_str(), true); - Serial.println("[MQTT] Announced for registration"); + mqtt.publish(mqttTopic("announce").c_str(), payload.c_str(), true); + Serial.printf("[MQTT] Announced as %s\n", cfg.camera_id.c_str()); } return true; @@ -489,7 +491,7 @@ void loop() { // Build the MQTT status payload per contract JsonDocument mqttDoc; mqttDoc["camera_id"] = cfg.camera_id; - mqttDoc["timestamp"] = millis(); + // No timestamp: the node has no real clock; the hub stamps on receipt. mqttDoc["battery_raw"] = dispBatteryRaw; int pct = batteryPct(dispBatteryRaw); if (pct >= 0) mqttDoc["battery_pct"] = pct; // omit when uncalibrated diff --git a/internal/mqtt/subscriber.go b/internal/mqtt/subscriber.go index 3d1cb99..7c274d0 100644 --- a/internal/mqtt/subscriber.go +++ b/internal/mqtt/subscriber.go @@ -151,22 +151,20 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) { } // Validate required fields - if sp.CameraID == "" || sp.Timestamp == "" { - log.Printf("MQTT status missing required fields (camera_id, timestamp) from %s", cameraID) + if sp.CameraID == "" { + log.Printf("MQTT status missing camera_id from %s", cameraID) return } - // Validate timestamp sanity (reject >5min future, >24h past) + // Nodes have no real clock, so tolerate an empty/invalid timestamp by + // stamping server-side. Still clamp obviously-bad supplied times below. + now := time.Now() ts, err := time.Parse(time.RFC3339, sp.Timestamp) if err != nil { - // Try ISO8601 without timezone - ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp) - if err != nil { - log.Printf("MQTT status invalid timestamp %q from %s", sp.Timestamp, cameraID) - return + if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil { + ts = now } } - now := time.Now() if ts.After(now.Add(5 * time.Minute)) { log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID) ts = now @@ -374,30 +372,20 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) { } log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName) } else { - // New camera — generate sequential cam-NNN ID - var maxID string - s.db.QueryRow("SELECT MAX(camera_id) FROM cameras").Scan(&maxID) - - seq := 1 - if maxID != "" { - fmt.Sscanf(maxID, "cam-%d", &seq) - seq++ - } - - newID := fmt.Sprintf("cam-%03d", seq) + // Option B: the node self-assigns its camera_id (the announce topic id). _, err = s.db.Exec(` INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now')) - `, newID, ap.FriendlyName, ap.MacAddress) + `, cameraID, ap.FriendlyName, ap.MacAddress) if err != nil { log.Printf("MQTT announce insert error for %s: %v", ap.MacAddress, err) return } - log.Printf("MQTT announce: new camera registered as %s (%s)", newID, ap.FriendlyName) + log.Printf("MQTT announce: new camera registered as %s (%s)", cameraID, ap.FriendlyName) // Broadcast new camera via SSE - cam, err := getCamera(s.db, newID) + cam, err := getCamera(s.db, cameraID) if err == nil { s.hub.Broadcast("camera_registered", cam) }