2026-05-18 17:48:58 -04:00
|
|
|
// Package api provides HTTP handlers for camera operations.
|
|
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"database/sql"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"log"
|
|
|
|
|
"net/http"
|
2026-05-23 08:50:21 -04:00
|
|
|
"strings"
|
2026-05-18 17:48:58 -04:00
|
|
|
|
|
|
|
|
"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)
|
2026-05-23 08:50:21 -04:00
|
|
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
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)
|
2026-05-23 08:50:21 -04:00
|
|
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
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"`
|
|
|
|
|
}
|
2026-05-23 08:50:21 -04:00
|
|
|
if !decodeJSONBody(w, r, &req) {
|
2026-05-18 17:48:58 -04:00
|
|
|
return
|
|
|
|
|
}
|
2026-05-23 08:50:21 -04:00
|
|
|
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
|
2026-05-18 17:48:58 -04:00
|
|
|
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 {
|
2026-05-23 08:50:21 -04:00
|
|
|
if isUniqueConstraintErr(err) {
|
|
|
|
|
respondError(w, http.StatusConflict, "camera already registered", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
log.Printf("Error registering camera: %v", err)
|
2026-05-23 08:50:21 -04:00
|
|
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
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")
|
2026-05-23 08:50:21 -04:00
|
|
|
if !validateCameraID(w, cameraID) {
|
2026-05-18 17:48:58 -04:00
|
|
|
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 {
|
2026-05-23 08:50:21 -04:00
|
|
|
respondError(w, http.StatusNotFound, "camera not found", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Error querying camera: %v", err)
|
2026-05-23 08:50:21 -04:00
|
|
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
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)
|
2026-05-23 08:50:21 -04:00
|
|
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
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)
|
2026-05-23 08:50:21 -04:00
|
|
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
2026-05-18 17:48:58 -04:00
|
|
|
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{}{
|
2026-05-23 08:50:21 -04:00
|
|
|
"camera": c,
|
2026-05-18 17:48:58 -04:00
|
|
|
"last_status": sl,
|
|
|
|
|
"history": history,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 08:50:21 -04:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 17:48:58 -04:00
|
|
|
// 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)
|
|
|
|
|
}
|