hub: fix camera listing, heartbeat parse, and legacy-id migration
Build (Dev) / build (push) Successful in 10s
CI / quality (push) Successful in 10s
CI / quality (pull_request) Successful in 11s

Three bugs surfaced once the camera reported in:

- ListCameras LEFT JOIN returns NULL status columns for a camera with no
  status rows yet, which failed scanning into non-nullable int/time fields
  (recording_state, online, recorded_at) and emptied the whole list.
  COALESCE them (recorded_at falls back to the camera's created_at).
- handleHeartbeat rejected every heartbeat ("cannot unmarshal number into
  string") because the node sends a numeric millis() timestamp. The handler
  doesn't use it, so drop the Timestamp field and let it be ignored.
- handleAnnounce kept a stale cam-NNN row registered by MAC under the old
  (pre-self-id) scheme, so self-id status inserts hit a FOREIGN KEY error.
  When a MAC is known under a different id than the node's self-id, migrate:
  drop the old row and re-register under the self-id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Joshua King
2026-06-05 14:24:20 -04:00
parent 5239346eaa
commit e00c8dce85
2 changed files with 17 additions and 9 deletions
+14 -6
View File
@@ -282,10 +282,12 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
// ── Heartbeat handler ───────────────────────────────────────────────────
type heartbeatPayload struct {
CameraID string `json:"camera_id"`
Timestamp string `json:"timestamp"`
UptimeSec *int `json:"uptime_sec"`
FreeHeap *int `json:"free_heap"`
CameraID string `json:"camera_id"`
// No Timestamp field: the node sends a numeric millis() value and the
// handler doesn't use it; omitting the field lets it be ignored instead
// of failing JSON unmarshal (number into string).
UptimeSec *int `json:"uptime_sec"`
FreeHeap *int `json:"free_heap"`
}
func (s *Subscriber) handleHeartbeat(cameraID string, payload []byte) {
@@ -360,8 +362,8 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
).Scan(&existingID)
if err == nil {
// Already registered — just update friendly_name
if err == nil && existingID == cameraID {
// Same self-id re-connecting — just refresh friendly_name.
_, err = s.db.Exec(
"UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?",
ap.FriendlyName, existingID,
@@ -372,6 +374,12 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
}
log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName)
} else {
// MAC known under a different id (legacy cam-NNN from before self-IDs)
// → drop the old row so we re-register under the node's self-id.
if err == nil && existingID != cameraID {
s.db.Exec("DELETE FROM cameras WHERE camera_id = ?", existingID)
log.Printf("MQTT announce: migrating %s -> %s (%s)", existingID, cameraID, ap.FriendlyName)
}
// 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)