Files
remote-rig/internal/api/cameras.go
T
Joshua King e00c8dce85
Build (Dev) / build (push) Successful in 10s
CI / quality (push) Successful in 10s
CI / quality (pull_request) Successful in 11s
hub: fix camera listing, heartbeat parse, and legacy-id migration
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>
2026-06-05 14:24:20 -04:00

224 lines
6.4 KiB
Go

// Package api provides HTTP handlers for camera operations.
package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"strings"
"github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/pkg/models"
"github.com/go-chi/chi/v5"
)
// ListCameras returns a handler for GET /cameras.
// Returns all cameras with their latest status from status_logs.
func ListCameras(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Query: join cameras with their most recent status_logs row
rows, err := database.QueryContext(ctx, `
SELECT
c.camera_id,
c.friendly_name,
s.battery_pct,
s.video_remaining_sec,
COALESCE(s.recording_state, 0),
s.mode,
s.resolution,
s.fps,
COALESCE(s.online, 0),
COALESCE(s.recorded_at, c.created_at)
FROM cameras c
LEFT JOIN (
SELECT camera_id, battery_pct, video_remaining_sec, recording_state,
mode, resolution, fps, online, recorded_at,
ROW_NUMBER() OVER (PARTITION BY camera_id ORDER BY recorded_at DESC) as rn
FROM status_logs
) s ON c.camera_id = s.camera_id AND s.rn = 1
ORDER BY c.camera_id
`)
if err != nil {
log.Printf("Error querying cameras: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
defer rows.Close()
var statuses []models.CameraStatus
for rows.Next() {
var sl models.StatusLog
var c models.Camera
if err := rows.Scan(
&c.CameraID, &c.FriendlyName,
&sl.BatteryPct, &sl.VideoRemainingSec,
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
&sl.Online, &sl.RecordedAt,
); err != nil {
log.Printf("Error scanning camera row: %v", err)
continue
}
statuses = append(statuses, models.NewCameraStatus(c, sl))
}
if err := rows.Err(); err != nil {
log.Printf("Error iterating camera rows: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if statuses == nil {
statuses = []models.CameraStatus{}
}
respondJSON(w, http.StatusOK, statuses)
}
}
// RegisterCamera returns a handler for POST /cameras.
func RegisterCamera(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress *string `json:"mac_address,omitempty"`
}
if !decodeJSONBody(w, r, &req) {
return
}
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
return
}
_, err := database.ExecContext(r.Context(), `
INSERT INTO cameras (camera_id, friendly_name, mac_address)
VALUES (?, ?, ?)
`, req.CameraID, req.FriendlyName, req.MacAddress)
if err != nil {
if isUniqueConstraintErr(err) {
respondError(w, http.StatusConflict, "camera already registered", err.Error())
return
}
log.Printf("Error registering camera: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
log.Printf("Registered camera %s (%s)", req.CameraID, req.FriendlyName)
resp := map[string]interface{}{
"camera_id": req.CameraID,
"friendly_name": req.FriendlyName,
}
if req.MacAddress != nil {
resp["mac_address"] = *req.MacAddress
}
respondJSON(w, http.StatusCreated, resp)
}
}
// GetCameraDetail returns a handler for GET /cameras/{id}.
func GetCameraDetail(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if !validateCameraID(w, cameraID) {
return
}
// Get camera info
var c models.Camera
err := database.QueryRowContext(r.Context(), `
SELECT camera_id, friendly_name, mac_address, created_at, updated_at
FROM cameras WHERE camera_id = ?
`, cameraID).Scan(
&c.CameraID, &c.FriendlyName, &c.MacAddress,
&c.CreatedAt, &c.UpdatedAt,
)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "camera not found", err.Error())
return
}
if err != nil {
log.Printf("Error querying camera: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
// Get latest status
var sl models.StatusLog
err = database.QueryRowContext(r.Context(), `
SELECT id, camera_id, recorded_at, battery_pct, video_remaining_sec,
recording_state, mode, resolution, fps, online, raw_battery_pct
FROM status_logs
WHERE camera_id = ?
ORDER BY recorded_at DESC
LIMIT 1
`, cameraID).Scan(
&sl.ID, &sl.CameraID, &sl.RecordedAt,
&sl.BatteryPct, &sl.VideoRemainingSec,
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
&sl.Online, &sl.RawBatteryPct,
)
if err != nil && err != sql.ErrNoRows {
log.Printf("Error querying latest status: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
// Get 24h history
historyRows, err := database.QueryContext(r.Context(), `
SELECT id, camera_id, recorded_at, battery_pct, video_remaining_sec,
recording_state, mode, resolution, fps, online, raw_battery_pct
FROM status_logs
WHERE camera_id = ? AND recorded_at >= datetime('now', '-24 hours')
ORDER BY recorded_at DESC
LIMIT 100
`, cameraID)
if err != nil {
log.Printf("Error querying history: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
defer historyRows.Close()
var history []models.StatusLog
for historyRows.Next() {
var h models.StatusLog
if err := historyRows.Scan(
&h.ID, &h.CameraID, &h.RecordedAt,
&h.BatteryPct, &h.VideoRemainingSec,
&h.RecordingState, &h.Mode, &h.Resolution, &h.FPS,
&h.Online, &h.RawBatteryPct,
); err != nil {
continue
}
history = append(history, h)
}
if history == nil {
history = []models.StatusLog{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"camera": c,
"last_status": sl,
"history": history,
})
}
}
// isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation.
func isUniqueConstraintErr(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "UNIQUE constraint failed")
}
// respondJSON writes a JSON response with the given status code.
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}