diff --git a/internal/api/cameras.go b/internal/api/cameras.go new file mode 100644 index 0000000..f8d987b --- /dev/null +++ b/internal/api/cameras.go @@ -0,0 +1,218 @@ +// Package api provides HTTP handlers for camera operations. +package api + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + + "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, + s.recording_state, + s.mode, + s.resolution, + s.fps, + s.online, + s.recorded_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) + respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database 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) + respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database 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 err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if req.CameraID == "" || req.FriendlyName == "" { + respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"}) + 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 err.Error() == "UNIQUE constraint failed: cameras.mac_address" { + respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"}) + return + } + log.Printf("Error registering camera: %v", err) + respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database 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 cameraID == "" { + respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"}) + 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 { + respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"}) + return + } + if err != nil { + log.Printf("Error querying camera: %v", err) + respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database 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) + respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database 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) + respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database 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, + }) + } +} + +// 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) +}