generated from CubeCraft-Creations/Tracehound
Dev #26
@@ -0,0 +1,508 @@
|
|||||||
|
# Camera Auto-Discovery and Registration Flow — Design Document
|
||||||
|
|
||||||
|
> **Status:** Draft | **CUB:** 229 | **Date:** 2026-05-23
|
||||||
|
> **Depends on:** MQTT_CONTRACT.md v1.0.0 | **Affects:** CUB-189 (POST /cameras), CUB-232 (MQTT subscriber)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
When a new ESP32 camera node powers on and connects to the travel router, it must self-register with the RemoteRig hub without any manual configuration. This document defines the auto-discovery protocol, message schemas, database extensions, error handling, and retry behavior.
|
||||||
|
|
||||||
|
### Design Goals
|
||||||
|
|
||||||
|
1. **Zero-touch provisioning** — ESP32 node registers itself on first MQTT connect; no dashboard interaction required
|
||||||
|
2. **Re-registration safe** — same node rejoining after a reboot or network blip is recognized and re-associated, not duplicated
|
||||||
|
3. **Idempotent** — replaying an announce due to MQTT retain or offline buffering does not create duplicate cameras
|
||||||
|
4. **Observable** — the dashboard receives real-time SSE events when a camera appears or reconnects
|
||||||
|
5. **Backward compatible** — existing announce format (`MQTT_CONTRACT.md`) is enhanced, not replaced
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ESP32 Announce Message (Registration Request)
|
||||||
|
|
||||||
|
### Topic
|
||||||
|
|
||||||
|
```
|
||||||
|
remoterig/cameras/+/announce
|
||||||
|
```
|
||||||
|
|
||||||
|
**Direction:** ESP32 → Hub | **QoS:** 2 | **Retain:** true
|
||||||
|
|
||||||
|
Published once on ESP32 first boot (or factory reset). Retained so the hub sees it even if it restarts after the ESP32 came online.
|
||||||
|
|
||||||
|
### JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "CameraAnnounce",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["mac_address", "firmware_version", "capabilities"],
|
||||||
|
"properties": {
|
||||||
|
"mac_address": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$",
|
||||||
|
"description": "ESP32 Wi-Fi station MAC address — the stable, globally unique hardware identifier"
|
||||||
|
},
|
||||||
|
"firmware_version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||||
|
"description": "Semver of the ESP32 firmware (e.g. 0.2.0)"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "enum": ["start_stop", "status", "reboot", "heartbeat"] },
|
||||||
|
"minItems": 1,
|
||||||
|
"description": "Supported feature flags. Minimal: [\"status\"]. Full: [\"start_stop\", \"status\", \"reboot\", \"heartbeat\"]"
|
||||||
|
},
|
||||||
|
"friendly_name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"description": "Default human-readable name (e.g. 'ESP32-AA-BB-CC'). If omitted, hub generates one from the MAC."
|
||||||
|
},
|
||||||
|
"device_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["esp32-gopro", "esp32-generic"],
|
||||||
|
"default": "esp32-gopro",
|
||||||
|
"description": "Device class for future multi-type support"
|
||||||
|
},
|
||||||
|
"mqtt_client_id": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"description": "The MQTT client ID the ESP32 connected with (diagnostic)"
|
||||||
|
},
|
||||||
|
"sdk_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ESP-IDF or Arduino SDK version (diagnostic)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example — Minimal
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"firmware_version": "0.1.0",
|
||||||
|
"capabilities": ["status", "heartbeat"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example — Full
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"firmware_version": "0.2.0",
|
||||||
|
"capabilities": ["start_stop", "status", "reboot", "heartbeat"],
|
||||||
|
"friendly_name": "GoPro Hero3 #1",
|
||||||
|
"device_type": "esp32-gopro",
|
||||||
|
"mqtt_client_id": "remoterig-ddeeff",
|
||||||
|
"sdk_version": "ESP-IDF v5.1.4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MAC Address as Identity
|
||||||
|
|
||||||
|
The ESP32's Wi-Fi station MAC is the only stable, globally unique identifier available on a closed network (no cloud, no serial number burned at factory). It is:
|
||||||
|
|
||||||
|
- **Globally unique** — OUI-assigned by Espressif
|
||||||
|
- **Immutable** — persists across firmware flashes and reboots
|
||||||
|
- **Available before MQTT connect** — no dependency on hub-assigned ID
|
||||||
|
|
||||||
|
The hub maps `mac_address → camera_id`. The `camera_id` (e.g. `cam-001`) is a short, human-friendly alias assigned at registration time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Hub Response Protocol
|
||||||
|
|
||||||
|
When the hub processes an announce, it MUST publish a response so the ESP32 knows its registration outcome. The response goes to the **command topic** for the assigned camera.
|
||||||
|
|
||||||
|
### Response Topic
|
||||||
|
|
||||||
|
```
|
||||||
|
remoterig/cameras/<camera_id>/command
|
||||||
|
```
|
||||||
|
|
||||||
|
Direction: Hub → ESP32 | QoS: 2 | Retain: false
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "RegistrationResponse",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["command", "request_id"],
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["registered", "registration_error"],
|
||||||
|
"description": "Outcome of the registration request"
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Echo of the announce message's MAC + timestamp hash for correlation"
|
||||||
|
},
|
||||||
|
"camera_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^cam-\\d{3}$",
|
||||||
|
"description": "Assigned camera ID (present on success only)"
|
||||||
|
},
|
||||||
|
"error_code": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["INVALID_MAC", "CAPABILITY_REQUIRED", "DB_WRITE_FAILED", "RATE_LIMITED"],
|
||||||
|
"description": "Machine-readable error code (present on failure only)"
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable error description (present on failure only)"
|
||||||
|
},
|
||||||
|
"retry_after_sec": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 5,
|
||||||
|
"description": "Suggested retry delay in seconds (present on failure only)"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "ISO 8601 — hub clock time of the response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "registered",
|
||||||
|
"request_id": "req-AABBCCDDEEFF-1684771200",
|
||||||
|
"camera_id": "cam-004",
|
||||||
|
"timestamp": "2026-05-23T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
| error_code | Meaning | retry_after_sec | ESP32 action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `INVALID_MAC` | MAC address absent or malformed | — (fatal) | Log error, halt registration |
|
||||||
|
| `CAPABILITY_REQUIRED` | No valid capabilities specified | — (fatal) | Log error, halt registration |
|
||||||
|
| `DB_WRITE_FAILED` | Hub database is unavailable (disk full, etc.) | 60 | Retry after delay |
|
||||||
|
| `RATE_LIMITED` | Too many registration attempts in a window | 30 | Retry after delay |
|
||||||
|
|
||||||
|
Example error response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "registration_error",
|
||||||
|
"request_id": "req-AABBCCDDEEFF-1684771200",
|
||||||
|
"error_code": "DB_WRITE_FAILED",
|
||||||
|
"error_message": "Database write failed: disk I/O error",
|
||||||
|
"retry_after_sec": 60,
|
||||||
|
"timestamp": "2026-05-23T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ESP32 Retry Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 publishes announce (QoS 2, retain)
|
||||||
|
│
|
||||||
|
├── Subscribe to remoterig/cameras/+/command (QoS 2)
|
||||||
|
│
|
||||||
|
├── Wait for command = "registered" or "registration_error"
|
||||||
|
│
|
||||||
|
├── Timeout after 30s → retry announce (with exponential backoff)
|
||||||
|
│ ├── 1st attempt: immediate
|
||||||
|
│ ├── 2nd attempt: wait 5s
|
||||||
|
│ ├── 3rd attempt: wait 10s
|
||||||
|
│ ├── 4th attempt: wait 20s
|
||||||
|
│ └── 5th+ attempt: wait 30s, repeat every 30s
|
||||||
|
│
|
||||||
|
├── On success (registered): store camera_id in NVS, begin normal status loop
|
||||||
|
│
|
||||||
|
├── On fatal error (INVALID_MAC, CAPABILITY_REQUIRED):
|
||||||
|
│ Log error, blink LED pattern, do not retry
|
||||||
|
│
|
||||||
|
└── On transient error (DB_WRITE_FAILED, RATE_LIMITED):
|
||||||
|
Wait retry_after_sec (capped at 120s), then re-publish announce
|
||||||
|
```
|
||||||
|
|
||||||
|
**After successful registration:** On subsequent boots, the ESP32 reads `camera_id` from NVS (non-volatile storage). It does NOT re-publish announce unless:
|
||||||
|
- `camera_id` is missing from NVS (factory reset / first boot)
|
||||||
|
- The hub publishes `command: "reregister"` to force re-registration (admin action)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Hub Processing Logic
|
||||||
|
|
||||||
|
### Registration Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Hub receives announce on remoterig/cameras/+/announce
|
||||||
|
│
|
||||||
|
├── 1. VALIDATE: mac_address present? matches pattern? → if no: publish INVALID_MAC error
|
||||||
|
│
|
||||||
|
├── 2. VALIDATE: capabilities non-empty? → if no: publish CAPABILITY_REQUIRED error
|
||||||
|
│
|
||||||
|
├── 3. RATE LIMIT: >5 registrations from same IP/MAC in 60s? → RATE_LIMITED error
|
||||||
|
│
|
||||||
|
├── 4. LOOKUP: SELECT camera_id FROM cameras WHERE mac_address = ?
|
||||||
|
│ │
|
||||||
|
│ ├── FOUND → EXISTING CAMERA:
|
||||||
|
│ │ ├── Update: friendly_name, firmware_version, capabilities, updated_at
|
||||||
|
│ │ ├── Publish registered response with existing camera_id
|
||||||
|
│ │ ├── SSE broadcast: "camera_reconnected"
|
||||||
|
│ │ └── Clear MQTT stale announce (publish empty retained message)
|
||||||
|
│ │
|
||||||
|
│ └── NOT FOUND → NEW CAMERA:
|
||||||
|
│ ├── Generate camera_id: "cam-NNN" (sequential)
|
||||||
|
│ ├── INSERT into cameras
|
||||||
|
│ ├── Publish registered response with new camera_id
|
||||||
|
│ ├── SSE broadcast: "camera_registered"
|
||||||
|
│ └── Clear MQTT stale announce (publish empty retained message)
|
||||||
|
│
|
||||||
|
└── 5. CLEANUP: Publish zero-byte retained message to announce topic
|
||||||
|
(prevents stale announces after camera is registered)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
To protect against buggy firmware or network loops:
|
||||||
|
|
||||||
|
| Window | Max Attempts | Action |
|
||||||
|
|--------|-------------|--------|
|
||||||
|
| 60 seconds | 5 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 30` |
|
||||||
|
| 5 minutes | 20 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 60` |
|
||||||
|
|
||||||
|
Rate limit state is in-memory only (not persisted). Restarting the hub resets the counters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database Schema Changes
|
||||||
|
|
||||||
|
### Extended `cameras` Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: 002_add_camera_registration_fields.sql
|
||||||
|
|
||||||
|
ALTER TABLE cameras ADD COLUMN firmware_version TEXT;
|
||||||
|
ALTER TABLE cameras ADD COLUMN capabilities TEXT NOT NULL DEFAULT '["status"]';
|
||||||
|
ALTER TABLE cameras ADD COLUMN device_type TEXT NOT NULL DEFAULT 'esp32-gopro';
|
||||||
|
ALTER TABLE cameras ADD COLUMN registration_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK(registration_status IN ('pending', 'registered', 'error', 'decommissioned'));
|
||||||
|
ALTER TABLE cameras ADD COLUMN last_announce_at DATETIME;
|
||||||
|
ALTER TABLE cameras ADD COLUMN registration_error TEXT;
|
||||||
|
ALTER TABLE cameras ADD COLUMN mqtt_client_id TEXT;
|
||||||
|
|
||||||
|
-- Index for MAC lookups (already exists but confirm)
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
|
||||||
|
|
||||||
|
-- Index for registration status filtering
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cameras_reg_status ON cameras(registration_status);
|
||||||
|
|
||||||
|
-- Index for finding stale registrations (cameras that announced but never sent status)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cameras_last_announce ON cameras(last_announce_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Table Definition (post-migration)
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| `camera_id` | TEXT | PK | Hub-assigned short ID, e.g. `cam-001` |
|
||||||
|
| `friendly_name` | TEXT | NOT NULL | Human-readable name |
|
||||||
|
| `mac_address` | TEXT | UNIQUE | ESP32 Wi-Fi station MAC |
|
||||||
|
| `firmware_version` | TEXT | — | Firmware semver reported by ESP32 |
|
||||||
|
| `capabilities` | TEXT | NOT NULL, DEFAULT `'["status"]'` | JSON array of strings |
|
||||||
|
| `device_type` | TEXT | NOT NULL, DEFAULT `'esp32-gopro'` | Device class |
|
||||||
|
| `registration_status` | TEXT | NOT NULL, DEFAULT `'pending'` | `pending`, `registered`, `error`, `decommissioned` |
|
||||||
|
| `last_announce_at` | DATETIME | — | Timestamp of most recent announce |
|
||||||
|
| `registration_error` | TEXT | — | Last registration error message (cleared on success) |
|
||||||
|
| `mqtt_client_id` | TEXT | — | MQTT client ID from the announce |
|
||||||
|
| `created_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | First registration timestamp |
|
||||||
|
| `updated_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | Last update timestamp |
|
||||||
|
|
||||||
|
### Go Model Extension
|
||||||
|
|
||||||
|
The existing `models.Camera` struct gains:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Camera struct {
|
||||||
|
CameraID string `json:"camera_id"`
|
||||||
|
FriendlyName string `json:"friendly_name"`
|
||||||
|
MacAddress string `json:"mac_address,omitempty"`
|
||||||
|
FirmwareVersion string `json:"firmware_version,omitempty"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
|
RegistrationStatus string `json:"registration_status"`
|
||||||
|
LastAnnounceAt *time.Time `json:"last_announce_at,omitempty"`
|
||||||
|
RegistrationError string `json:"registration_error,omitempty"`
|
||||||
|
MqttClientID string `json:"mqtt_client_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note on `capabilities` storage:** SQLite does not have a native JSON array type. Store as TEXT (JSON-encoded array). Serialize/deserialize in the Go model layer. Migration default is `'["status"]'` — the minimum capability for a useful camera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Registration Flow Sequence Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant ESP32
|
||||||
|
participant Broker as MQTT Broker (Mosquitto)
|
||||||
|
participant Hub as Go Hub
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SSE as SSE Hub
|
||||||
|
participant UI as Dashboard UI
|
||||||
|
|
||||||
|
Note over ESP32: Power on / First boot
|
||||||
|
|
||||||
|
ESP32->>ESP32: Read camera_id from NVS
|
||||||
|
alt camera_id NOT in NVS (first boot or factory reset)
|
||||||
|
ESP32->>Broker: CONNECT (client_id: remoterig-<mac_last6>)
|
||||||
|
Broker-->>ESP32: CONNACK
|
||||||
|
|
||||||
|
ESP32->>Broker: SUBSCRIBE remoterig/cameras/+/command (QoS 2)
|
||||||
|
Broker-->>ESP32: SUBACK
|
||||||
|
|
||||||
|
ESP32->>Broker: PUBLISH remoterig/cameras/announce (QoS 2, retain)
|
||||||
|
Note over ESP32,Broker: {mac_address, firmware_version, capabilities, ...}
|
||||||
|
Broker->>Hub: Forward announce
|
||||||
|
|
||||||
|
Hub->>Hub: Validate: MAC present? capabilities non-empty?
|
||||||
|
alt Validation fails
|
||||||
|
Hub->>Broker: PUBLISH command {command: "registration_error", error_code: "INVALID_MAC"}
|
||||||
|
Broker->>ESP32: Forward error
|
||||||
|
Note over ESP32: Log error, halt (fatal)
|
||||||
|
else Validation passes
|
||||||
|
Hub->>Hub: Rate limit check
|
||||||
|
alt Rate limited
|
||||||
|
Hub->>Broker: PUBLISH command {error_code: "RATE_LIMITED", retry_after_sec: 30}
|
||||||
|
Broker->>ESP32: Forward error
|
||||||
|
Note over ESP32: Wait retry_after_sec, retry
|
||||||
|
else Allowed
|
||||||
|
Hub->>DB: SELECT camera_id WHERE mac_address = ?
|
||||||
|
alt MAC already registered
|
||||||
|
DB-->>Hub: camera_id = "cam-002"
|
||||||
|
Hub->>DB: UPDATE cameras SET firmware_version, capabilities, friendly_name, ...
|
||||||
|
Hub->>SSE: Broadcast "camera_reconnected"
|
||||||
|
else New MAC
|
||||||
|
DB-->>Hub: no rows
|
||||||
|
Hub->>DB: SELECT MAX(camera_id) → "cam-003"
|
||||||
|
Hub->>Hub: Generate "cam-004"
|
||||||
|
Hub->>DB: INSERT INTO cameras (cam-004, ...)
|
||||||
|
Hub->>SSE: Broadcast "camera_registered"
|
||||||
|
end
|
||||||
|
|
||||||
|
Hub->>Broker: PUBLISH command {command: "registered", camera_id: "cam-004"}
|
||||||
|
Broker->>ESP32: Forward registration response
|
||||||
|
|
||||||
|
Hub->>Broker: PUBLISH announce (zero-byte retain) — clear stale announce
|
||||||
|
|
||||||
|
SSE-->>UI: camera_registered / camera_reconnected event
|
||||||
|
UI->>UI: Show new camera card in grid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else camera_id FOUND in NVS (subsequent boot)
|
||||||
|
Note over ESP32: Skip announce, proceed to status loop
|
||||||
|
ESP32->>Broker: PUBLISH status (QoS 1, retain)
|
||||||
|
Broker->>Hub: Forward status
|
||||||
|
Hub->>SSE: Broadcast camera_status
|
||||||
|
SSE-->>UI: Update camera card
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reconnection vs. Registration
|
||||||
|
|
||||||
|
It is critical to distinguish two scenarios:
|
||||||
|
|
||||||
|
### Scenario A: Reconnection (camera was previously registered)
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 boots → reads camera_id from NVS → publishes status on remoterig/cameras/<id>/status
|
||||||
|
→ Hub sees status on a known camera_id → updates online flag → SSE broadcast
|
||||||
|
```
|
||||||
|
|
||||||
|
**No announce published.** The camera already has its identity.
|
||||||
|
|
||||||
|
### Scenario B: First Registration (or factory reset)
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 boots → NVS empty → publishes announce → Hub assigns camera_id →
|
||||||
|
ESP32 stores camera_id in NVS → begins status loop on remoterig/cameras/<id>/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario C: Hub Restart (ESP32 already running)
|
||||||
|
|
||||||
|
```
|
||||||
|
Hub restarts → subscribes to remoterig/cameras/+/announce →
|
||||||
|
MQTT broker delivers retained announce messages →
|
||||||
|
Hub processes each → re-registration safe (MAC already exists → update only)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is why announce messages use `retain: true`. If the hub restarts while ESP32s are running, it re-discovers them from retained announces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Considerations
|
||||||
|
|
||||||
|
| Concern | Mitigation |
|
||||||
|
|---------|-----------|
|
||||||
|
| Rogue node spoofing a MAC | Closed network (travel router, no internet). MAC filtering at the router level as defense-in-depth (future). |
|
||||||
|
| Replay attacks | Announce is idempotent — replaying it only updates timestamps, doesn't create duplicates. |
|
||||||
|
| Denial of registration | Rate limiting (Section 4) prevents flooding. |
|
||||||
|
| Unauthorized decommission | No `decommission` MQTT command exists. Decommission is admin-only via HTTP API with API key auth. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open Questions & Decisions
|
||||||
|
|
||||||
|
| Question | Decision | Rationale |
|
||||||
|
|----------|----------|-----------|
|
||||||
|
| **MAC as identity?** | ✅ Yes | Only globally unique, immutable ID available on a closed network. |
|
||||||
|
| **`camera_id` format?** | `cam-NNN` (zero-padded sequential) | Short, sortable, human-friendly. Collision-free with DB sequence. |
|
||||||
|
| **Re-registration behavior?** | Update existing, don't create duplicate | Announcing with same MAC = reconnection, not new camera. |
|
||||||
|
| **Retain on announce?** | ✅ Yes, cleared after processing | Allows hub restart recovery. Cleanup prevents stale data. |
|
||||||
|
| **Response protocol?** | Publish to `command` topic | Reuses existing command channel. ESP32 subscribes before publishing announce. |
|
||||||
|
| **Capabilities stored?** | ✅ Yes, in `capabilities` column | Enables future feature gating (e.g., "this camera can't start/stop recording"). |
|
||||||
|
| **`device_type` added?** | ✅ Yes, default `esp32-gopro` | Allows future camera types (e.g., Raspberry Pi CSI, USB webcam). |
|
||||||
|
| **Dashboard rename after auto-registration?** | ✅ Yes (via existing POST /cameras or settings API in future) | Already called out in MQTT_CONTRACT.md. No new work in this CUB. |
|
||||||
|
| **NVS key for camera_id?** | `"cam_id"` | Simple, unambiguous. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Plan
|
||||||
|
|
||||||
|
This design document covers the protocol and schema design. Implementation is tracked in the following sub-issues:
|
||||||
|
|
||||||
|
| CUB | Title | Agent | Depends On |
|
||||||
|
|-----|-------|-------|------------|
|
||||||
|
| CUB-229 | Design camera auto-discovery and registration flow | Dex | — (this task) |
|
||||||
|
| CUB-229a | Migration: add registration fields to cameras table | Hex | CUB-229 |
|
||||||
|
| CUB-229b | Go model update: Camera struct with new fields | Dex | CUB-229a |
|
||||||
|
| CUB-229c | MQTT subscriber: registration response protocol | Dex | CUB-229b |
|
||||||
|
| CUB-229d | Rate limiting for announce messages | Dex | CUB-229b |
|
||||||
|
| CUB-229e | SSE events: camera_registered / camera_reconnected | Dex | CUB-229c |
|
||||||
|
| CUB-229f | ESP32 firmware: NVS storage + announce on first boot | Pip | CUB-229 |
|
||||||
|
| CUB-229g | ESP32 firmware: command subscription + registration ACK handling | Pip | CUB-229c |
|
||||||
|
| CUB-229h | Update MQTT_CONTRACT.md with registration response spec | Dex | CUB-229 |
|
||||||
|
| CUB-229i | Integration test: camera auto-registration end-to-end | Dex/Pip | CUB-229e, CUB-229g |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. References
|
||||||
|
|
||||||
|
- [MQTT_CONTRACT.md](../MQTT_CONTRACT.md) — Network topology, topic hierarchy, existing status/heartbeat/command schemas
|
||||||
|
- [CONTEXT.md](../CONTEXT.md) — RemoteRig tech stack, directory layout, database schema
|
||||||
|
- [CUB-230 (Offline Buffer & Replay)](https://linear.app/cubecraft-creations/issue/CUB-230) — Related: offline buffering uses same dedup strategy
|
||||||
|
- [CUB-232 (MQTT Subscriber)](https://linear.app/cubecraft-creations/issue/CUB-232) — The subscriber that will implement this registration logic
|
||||||
|
- [CUB-189 (POST /cameras)](https://linear.app/cubecraft-creations/issue/CUB-189) — HTTP registration endpoint (may be replaced/supplemented by auto-discovery)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/cubecraft/remoterig
|
module github.com/cubecraft/remoterig
|
||||||
|
|
||||||
go 1.19
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq
|
|||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
@@ -17,6 +19,7 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
@@ -25,16 +28,23 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
@@ -42,8 +52,12 @@ modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJ
|
|||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cubecraft/remoterig/internal/db"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestRouter creates a test router backed by a temp file database so
|
||||||
|
// pooled connections all see the same data.
|
||||||
|
func setupTestRouter(t *testing.T) (*db.DB, chi.Router) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/cameras", ListCameras(database))
|
||||||
|
r.Post("/cameras", RegisterCamera(database))
|
||||||
|
r.Get("/cameras/{id}", GetCameraDetail(database))
|
||||||
|
r.Post("/cameras/{id}/start", StartRecording(database))
|
||||||
|
r.Post("/cameras/{id}/stop", StopRecording(database))
|
||||||
|
r.Post("/cameras/{id}/status", PushStatus(database))
|
||||||
|
|
||||||
|
return database, r
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReq(method, target string, body io.Reader) *http.Request {
|
||||||
|
return httptest.NewRequest(method, target, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertStatus(t *testing.T, resp *http.Response, expected int) {
|
||||||
|
t.Helper()
|
||||||
|
if resp.StatusCode != expected {
|
||||||
|
t.Errorf("expected status %d, got %d", expected, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertError(t *testing.T, resp *http.Response, expectedStatus int, want string) {
|
||||||
|
t.Helper()
|
||||||
|
assertStatus(t, resp, expectedStatus)
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var e APIError
|
||||||
|
if err := json.Unmarshal(body, &e); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal error: %v (body: %s)", err, string(body))
|
||||||
|
}
|
||||||
|
if e.Code != expectedStatus {
|
||||||
|
t.Errorf("expected code %d, got %d", expectedStatus, e.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(e.Error, want) {
|
||||||
|
t.Errorf("expected error containing %q, got %q", want, e.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func regCamera(t *testing.T, db *db.DB) string {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test Camera"}`))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
RegisterCamera(db)(w, r)
|
||||||
|
return "CAM-001"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GET /cameras ====================
|
||||||
|
|
||||||
|
func TestListCameras_Empty(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("GET", "/cameras", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
var cameras []map[string]interface{}
|
||||||
|
json.NewDecoder(w.Result().Body).Decode(&cameras)
|
||||||
|
if cameras == nil {
|
||||||
|
t.Error("expected non-nil cameras array, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCameras_WithData(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
// Push a status
|
||||||
|
sr := newReq("POST", "/cameras/CAM-001/status",
|
||||||
|
strings.NewReader(`{"battery_pct":85,"recording":false,"mode":"video","resolution":"4K","fps":30,"online":true}`))
|
||||||
|
sr.Header.Set("Content-Type", "application/json")
|
||||||
|
sw := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(sw, sr)
|
||||||
|
assertStatus(t, sw.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
// Now list
|
||||||
|
lr := newReq("GET", "/cameras", nil)
|
||||||
|
lw := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(lw, lr)
|
||||||
|
assertStatus(t, lw.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
var cameras []map[string]interface{}
|
||||||
|
json.NewDecoder(lw.Result().Body).Decode(&cameras)
|
||||||
|
if len(cameras) != 1 {
|
||||||
|
t.Errorf("expected 1 camera, got %d", len(cameras))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== POST /cameras (Register) ====================
|
||||||
|
|
||||||
|
func TestRegisterCamera_Success(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_WithMacAddress(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test","mac_address":"00:11:22:33:44:55"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_MissingBody(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_InvalidJSON(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras", strings.NewReader(`{not json`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_MissingRequiredFields(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"friendly_name":"Test"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required")
|
||||||
|
|
||||||
|
req2 := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"CAM-001"}`))
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w2, req2)
|
||||||
|
assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_FieldTooLong(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
longID := strings.Repeat("x", 65)
|
||||||
|
req := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"`+longID+`","friendly_name":"Test"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64")
|
||||||
|
|
||||||
|
longName := strings.Repeat("y", 129)
|
||||||
|
req2 := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"`+longName+`"}`))
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w2, req2)
|
||||||
|
assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name must be at most 128")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_WrongContentType(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
|
||||||
|
req.Header.Set("Content-Type", "text/plain")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusUnsupportedMediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_NoContentType(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_BodyTooLarge(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
req := httptest.NewRequest("POST", "/cameras", bytes.NewReader(make([]byte, 70000)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCamera_Duplicate(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras",
|
||||||
|
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusConflict, "camera already registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GET /cameras/{id} ====================
|
||||||
|
|
||||||
|
func TestGetCameraDetail_Success(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("GET", "/cameras/CAM-001", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCameraDetail_NotFound(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("GET", "/cameras/NONEXISTENT", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCameraDetail_BadID(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("GET", "/cameras/"+strings.Repeat("x", 65), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== POST /cameras/{id}/start ====================
|
||||||
|
|
||||||
|
func TestStartRecording_Success(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/start", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
json.NewDecoder(w.Result().Body).Decode(&resp)
|
||||||
|
if resp["status"] != "recording_started" {
|
||||||
|
t.Errorf("expected recording_started, got %q", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartRecording_CameraNotFound(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/NONEXISTENT/start", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartRecording_MissingID(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras//start", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== POST /cameras/{id}/stop ====================
|
||||||
|
|
||||||
|
func TestStopRecording_Success(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
// Start first
|
||||||
|
sr := newReq("POST", "/cameras/CAM-001/start", nil)
|
||||||
|
sw := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(sw, sr)
|
||||||
|
assertStatus(t, sw.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
// Now stop
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/stop", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
json.NewDecoder(w.Result().Body).Decode(&resp)
|
||||||
|
if resp["status"] != "recording_stopped" {
|
||||||
|
t.Errorf("expected recording_stopped, got %q", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopRecording_CameraNotFound(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/NONEXISTENT/stop", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== POST /cameras/{id}/status ====================
|
||||||
|
|
||||||
|
func TestPushStatus_Success(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status",
|
||||||
|
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
json.NewDecoder(w.Result().Body).Decode(&resp)
|
||||||
|
if resp["status"] != "accepted" {
|
||||||
|
t.Errorf("expected accepted, got %q", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_CameraNotFound(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/NONEXISTENT/status",
|
||||||
|
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_InvalidJSON(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{bad json`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_InvalidFPS(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status",
|
||||||
|
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":999,"online":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "fps must be between")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_NegativeFPS(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status",
|
||||||
|
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":-1,"online":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "fps must be between")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_InvalidBattery(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status",
|
||||||
|
strings.NewReader(`{"battery_pct":150,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_NegativeBattery(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status",
|
||||||
|
strings.NewReader(`{"battery_pct":-5,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_ModeTooLong(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status",
|
||||||
|
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"`+strings.Repeat("x", 33)+`","resolution":"1080p","fps":30,"online":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "mode must be at most")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushStatus_MissingBody(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
req := newReq("POST", "/cameras/CAM-001/status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Error Response Format ====================
|
||||||
|
|
||||||
|
func TestErrorResponseFormat_Consistent(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
checks := []struct {
|
||||||
|
method, target, body string
|
||||||
|
}{
|
||||||
|
{"GET", "/cameras/NONEXISTENT", ""},
|
||||||
|
{"POST", "/cameras", "bad json"},
|
||||||
|
{"POST", "/cameras/NONEXISTENT/start", ""},
|
||||||
|
{"POST", "/cameras/NONEXISTENT/status", "bad json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range checks {
|
||||||
|
var rd io.Reader
|
||||||
|
if c.body != "" {
|
||||||
|
rd = strings.NewReader(c.body)
|
||||||
|
}
|
||||||
|
req := newReq(c.method, c.target, rd)
|
||||||
|
if c.body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var errResp map[string]interface{}
|
||||||
|
json.NewDecoder(w.Result().Body).Decode(&errResp)
|
||||||
|
if _, ok := errResp["error"]; !ok {
|
||||||
|
t.Errorf("%s %s: missing 'error' key: %v", c.method, c.target, errResp)
|
||||||
|
}
|
||||||
|
if _, ok := errResp["code"]; !ok {
|
||||||
|
t.Errorf("%s %s: missing 'code' key: %v", c.method, c.target, errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SQL Injection ====================
|
||||||
|
|
||||||
|
func TestSQLInjection_CameraID(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
// Chi URL params are extracted after routing, so injection attempts will
|
||||||
|
// be treated as camera_ids and fail validation (too long) or return 404.
|
||||||
|
// Use URL encoding for special characters to avoid httptest panics.
|
||||||
|
paths := []string{
|
||||||
|
"/cameras/CAM-001%27+DROP+TABLE+cameras--",
|
||||||
|
"/cameras/1+UNION+SELECT+NULL--",
|
||||||
|
"/cameras/%27+OR+%27%27%3D%27",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
req := httptest.NewRequest("GET", path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
code := w.Result().StatusCode
|
||||||
|
if code != http.StatusNotFound && code != http.StatusBadRequest {
|
||||||
|
t.Errorf("unexpected status %d for injection path %s", code, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tables still exist
|
||||||
|
req := httptest.NewRequest("GET", "/cameras", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assertStatus(t, w.Result(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Recording Lifecycle ====================
|
||||||
|
|
||||||
|
func TestRecordingLifecycle(t *testing.T) {
|
||||||
|
db, r := setupTestRouter(t)
|
||||||
|
defer db.Close()
|
||||||
|
regCamera(t, db)
|
||||||
|
|
||||||
|
// Start
|
||||||
|
r1 := newReq("POST", "/cameras/CAM-001/start", nil)
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w1, r1)
|
||||||
|
assertStatus(t, w1.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
r2 := newReq("POST", "/cameras/CAM-001/stop", nil)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w2, r2)
|
||||||
|
assertStatus(t, w2.Result(), http.StatusOK)
|
||||||
|
|
||||||
|
// Start again
|
||||||
|
r3 := newReq("POST", "/cameras/CAM-001/start", nil)
|
||||||
|
w3 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w3, r3)
|
||||||
|
assertStatus(t, w3.Result(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Benchmark ====================
|
||||||
|
|
||||||
|
func BenchmarkListCameras(b *testing.B) {
|
||||||
|
db2, _ := db.Open(b.TempDir() + "/bench.db")
|
||||||
|
defer db2.Close()
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
id := string(rune('A'+i)) + "-CAM"
|
||||||
|
h := RegisterCamera(db2)
|
||||||
|
body := `{"camera_id":"` + id + `","friendly_name":"Test ` + string(rune('A'+i)) + `"}`
|
||||||
|
req := newReq("POST", "/cameras", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, req)
|
||||||
|
}
|
||||||
|
jh := ListCameras(db2)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
req := newReq("GET", "/cameras", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
jh(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-16
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/cubecraft/remoterig/internal/db"
|
"github.com/cubecraft/remoterig/internal/db"
|
||||||
"github.com/cubecraft/remoterig/pkg/models"
|
"github.com/cubecraft/remoterig/pkg/models"
|
||||||
@@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
|||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error querying cameras: %v", err)
|
log.Printf("Error querying cameras: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -64,7 +65,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
log.Printf("Error iterating camera rows: %v", err)
|
log.Printf("Error iterating camera rows: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +85,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
|||||||
FriendlyName string `json:"friendly_name"`
|
FriendlyName string `json:"friendly_name"`
|
||||||
MacAddress *string `json:"mac_address,omitempty"`
|
MacAddress *string `json:"mac_address,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if !decodeJSONBody(w, r, &req) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
|
||||||
if req.CameraID == "" || req.FriendlyName == "" {
|
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +97,12 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
|||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
`, req.CameraID, req.FriendlyName, req.MacAddress)
|
`, req.CameraID, req.FriendlyName, req.MacAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "UNIQUE constraint failed: cameras.mac_address" {
|
if isUniqueConstraintErr(err) {
|
||||||
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"})
|
respondError(w, http.StatusConflict, "camera already registered", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Error registering camera: %v", err)
|
log.Printf("Error registering camera: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +122,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
|||||||
func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +136,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
&c.CreatedAt, &c.UpdatedAt,
|
&c.CreatedAt, &c.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"})
|
respondError(w, http.StatusNotFound, "camera not found", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error querying camera: %v", err)
|
log.Printf("Error querying camera: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +162,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
log.Printf("Error querying latest status: %v", err)
|
log.Printf("Error querying latest status: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +177,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
`, cameraID)
|
`, cameraID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error querying history: %v", err)
|
log.Printf("Error querying history: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer historyRows.Close()
|
defer historyRows.Close()
|
||||||
@@ -210,6 +207,14 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// respondJSON writes a JSON response with the given status code.
|
||||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Package api provides HTTP handlers for camera operations.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRequestBody is the maximum accepted JSON body size (64KB).
|
||||||
|
const maxRequestBody = 64 * 1024
|
||||||
|
|
||||||
|
// APIError represents a structured API error response.
|
||||||
|
type APIError struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validationConstraints defines field-level validation limits.
|
||||||
|
const (
|
||||||
|
maxCameraIDLen = 64
|
||||||
|
maxFriendlyNameLen = 128
|
||||||
|
maxModeLen = 32
|
||||||
|
maxResolutionLen = 32
|
||||||
|
minFPS = 0
|
||||||
|
maxFPS = 240
|
||||||
|
)
|
||||||
|
|
||||||
|
// respondError writes a structured JSON error response.
|
||||||
|
func respondError(w http.ResponseWriter, status int, msg string, details ...string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
e := APIError{
|
||||||
|
Error: msg,
|
||||||
|
Code: status,
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
e.Details = details[0]
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJSONBody reads, limits, and decodes a JSON request body.
|
||||||
|
// Returns false if validation fails (response already written).
|
||||||
|
func decodeJSONBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||||
|
// Validate Content-Type
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
||||||
|
respondError(w, http.StatusUnsupportedMediaType, "content-type must be application/json")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit body size
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "request body too large or unreadable", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, v); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request body", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCameraID checks that cameraID is present and within max length.
|
||||||
|
func validateCameraID(w http.ResponseWriter, cameraID string) bool {
|
||||||
|
if cameraID == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "camera_id is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(cameraID) > maxCameraIDLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("camera_id must be at most %d characters", maxCameraIDLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCameraRegistration validates fields for POST /cameras.
|
||||||
|
func validateCameraRegistration(w http.ResponseWriter, cameraID, friendlyName string) bool {
|
||||||
|
if !validateCameraID(w, cameraID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if friendlyName == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "friendly_name is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(friendlyName) > maxFriendlyNameLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("friendly_name must be at most %d characters", maxFriendlyNameLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateStatusFields validates optional fields on the PushStatus payload.
|
||||||
|
func validateStatusFields(w http.ResponseWriter, mode, resolution string, fps int) bool {
|
||||||
|
if mode != "" && len(mode) > maxModeLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("mode must be at most %d characters", maxModeLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if resolution != "" && len(resolution) > maxResolutionLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("resolution must be at most %d characters", maxResolutionLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fps < minFPS || fps > maxFPS {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("fps must be between %d and %d", minFPS, maxFPS))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+22
-14
@@ -13,8 +13,7 @@ import (
|
|||||||
func StartRecording(database *db.DB) http.HandlerFunc {
|
func StartRecording(database *db.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +21,13 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
|||||||
var exists int
|
var exists int
|
||||||
err := database.QueryRowContext(r.Context(),
|
err := database.QueryRowContext(r.Context(),
|
||||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||||
if err != nil || exists == 0 {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
log.Printf("Error checking camera existence: %v", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "camera not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +38,12 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
|||||||
`, cameraID)
|
`, cameraID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error starting recording: %v", err)
|
log.Printf("Error starting recording: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, _ := result.RowsAffected()
|
rowsAffected, _ := result.RowsAffected()
|
||||||
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows)
|
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"status": "recording_started",
|
"status": "recording_started",
|
||||||
@@ -52,8 +56,7 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
|||||||
func StopRecording(database *db.DB) http.HandlerFunc {
|
func StopRecording(database *db.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +64,13 @@ func StopRecording(database *db.DB) http.HandlerFunc {
|
|||||||
var exists int
|
var exists int
|
||||||
err := database.QueryRowContext(r.Context(),
|
err := database.QueryRowContext(r.Context(),
|
||||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||||
if err != nil || exists == 0 {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
log.Printf("Error checking camera existence: %v", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "camera not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +81,12 @@ func StopRecording(database *db.DB) http.HandlerFunc {
|
|||||||
`, cameraID)
|
`, cameraID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error stopping recording: %v", err)
|
log.Printf("Error stopping recording: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, _ := result.RowsAffected()
|
rowsAffected, _ := result.RowsAffected()
|
||||||
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows)
|
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"status": "recording_stopped",
|
"status": "recording_stopped",
|
||||||
|
|||||||
+19
-8
@@ -2,7 +2,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -14,8 +13,7 @@ import (
|
|||||||
func PushStatus(database *db.DB) http.HandlerFunc {
|
func PushStatus(database *db.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +28,16 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
|||||||
RawBatteryPct *float64 `json:"raw_battery_pct"`
|
RawBatteryPct *float64 `json:"raw_battery_pct"`
|
||||||
Timestamp *string `json:"ts"`
|
Timestamp *string `json:"ts"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if !decodeJSONBody(w, r, &req) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
return
|
||||||
|
}
|
||||||
|
if !validateStatusFields(w, req.Mode, req.Resolution, req.FPS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate battery percentage range if provided
|
||||||
|
if req.BatteryPct != nil && (*req.BatteryPct < 0 || *req.BatteryPct > 100) {
|
||||||
|
respondError(w, http.StatusBadRequest, "battery_pct must be between 0 and 100")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
|||||||
var exists int
|
var exists int
|
||||||
err := database.QueryRowContext(r.Context(),
|
err := database.QueryRowContext(r.Context(),
|
||||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||||
if err != nil || exists == 0 {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
log.Printf("Error checking camera existence: %v", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "camera not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
|||||||
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
|
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error inserting status log: %v", err)
|
log.Printf("Error inserting status log: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-28
@@ -4,9 +4,11 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -14,13 +16,16 @@ import (
|
|||||||
//go:embed migrations/001_create_tables.sql
|
//go:embed migrations/001_create_tables.sql
|
||||||
var migration001 string
|
var migration001 string
|
||||||
|
|
||||||
|
//go:embed migrations/002_dedup_unique_index.sql
|
||||||
|
var migration002 string
|
||||||
|
|
||||||
// DB wraps the sql.DB with connection-level settings.
|
// DB wraps the sql.DB with connection-level settings.
|
||||||
type DB struct {
|
type DB struct {
|
||||||
*sql.DB
|
*sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open opens the SQLite database at the given path, enables WAL mode,
|
// Open opens the SQLite database at the given path, enables WAL mode,
|
||||||
// and runs all migrations if the tables don't exist yet.
|
// and runs all migrations using a schema_version table for tracking.
|
||||||
func Open(path string) (*DB, error) {
|
func Open(path string) (*DB, error) {
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
@@ -45,34 +50,57 @@ func Open(path string) (*DB, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tables already exist (idempotent migration)
|
// Ensure schema_version table exists for migration tracking
|
||||||
var count int
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
|
||||||
if err := db.QueryRow(`
|
|
||||||
SELECT COUNT(*) FROM sqlite_master
|
|
||||||
WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings')
|
|
||||||
`).Scan(&count); err != nil {
|
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if count < 4 {
|
// Read current schema version (0 if table is empty)
|
||||||
log.Printf("Running migrations for %s...", path)
|
var currentVersion int
|
||||||
if err := migrate(db, migration001); err != nil {
|
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤tVersion); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration definitions: ordered list of (version, sql)
|
||||||
|
type migration struct {
|
||||||
|
version int
|
||||||
|
sql string
|
||||||
|
}
|
||||||
|
migrations := []migration{
|
||||||
|
{1, migration001},
|
||||||
|
{2, migration002},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
if currentVersion >= m.version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Running migration %d for %s...", m.version, path)
|
||||||
|
if err := migrate(db, m.sql); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("record migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
log.Printf("Migration %d complete", m.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentVersion < len(migrations) {
|
||||||
log.Println("Migrations complete")
|
log.Println("Migrations complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DB{db}, nil
|
return &DB{db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate executes a SQL migration string.
|
// migrate executes a SQL migration string by splitting on semicolons.
|
||||||
func migrate(db *sql.DB, sql string) error {
|
func migrate(db *sql.DB, sql string) error {
|
||||||
// Split on semicolons to handle multiple statements
|
|
||||||
statements := splitSQL(sql)
|
statements := splitSQL(sql)
|
||||||
for _, stmt := range statements {
|
for _, stmt := range statements {
|
||||||
stmt = stripWhitespace(stmt)
|
stmt = strings.TrimSpace(stmt)
|
||||||
if stmt == "" {
|
if stmt == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -83,8 +111,13 @@ func migrate(db *sql.DB, sql string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
|
// splitSQL splits a SQL string on semicolons, respecting quoted strings
|
||||||
|
// and stripping SQL line comments (--).
|
||||||
func splitSQL(sql string) []string {
|
func splitSQL(sql string) []string {
|
||||||
|
// First, strip all line comments (--) to prevent them from swallowing
|
||||||
|
// subsequent SQL statements when newlines are collapsed.
|
||||||
|
sql = stripSQLLineComments(sql)
|
||||||
|
|
||||||
var stmts []string
|
var stmts []string
|
||||||
var current string
|
var current string
|
||||||
inQuote := false
|
inQuote := false
|
||||||
@@ -106,30 +139,42 @@ func splitSQL(sql string) []string {
|
|||||||
case ';':
|
case ';':
|
||||||
stmts = append(stmts, current)
|
stmts = append(stmts, current)
|
||||||
current = ""
|
current = ""
|
||||||
|
case '\r', '\n', '\t':
|
||||||
|
current += " "
|
||||||
default:
|
default:
|
||||||
current += string(r)
|
current += string(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(current) > 0 {
|
if strings.TrimSpace(current) != "" {
|
||||||
stmts = append(stmts, current)
|
stmts = append(stmts, current)
|
||||||
}
|
}
|
||||||
return stmts
|
return stmts
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripWhitespace removes leading/trailing whitespace and normalizes newlines.
|
// stripSQLLineComments removes all -- single-line comments from SQL text.
|
||||||
func stripWhitespace(s string) string {
|
func stripSQLLineComments(sql string) string {
|
||||||
result := ""
|
var result strings.Builder
|
||||||
runningSpace := false
|
i := 0
|
||||||
for _, r := range s {
|
runes := []rune(sql)
|
||||||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
|
||||||
if !runningSpace {
|
for i < len(runes) {
|
||||||
result += " "
|
r := runes[i]
|
||||||
runningSpace = true
|
|
||||||
|
// Check for -- comment start
|
||||||
|
if r == '-' && i+1 < len(runes) && runes[i+1] == '-' {
|
||||||
|
// Skip to end of line
|
||||||
|
i += 2
|
||||||
|
for i < len(runes) && runes[i] != '\n' && runes[i] != '\r' {
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
} else {
|
// Replace comment with a newline (preserves statement boundaries)
|
||||||
result += string(r)
|
result.WriteRune('\n')
|
||||||
runningSpace = false
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.WriteRune(r)
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: 002_dedup_unique_index
|
||||||
|
-- Add a UNIQUE index on (camera_id, recorded_at) to enforce hub-side
|
||||||
|
-- deduplication for ESP32 offline status replay (CUB-239).
|
||||||
|
-- This prevents race-condition double-inserts that a pure SELECT COUNT(*)
|
||||||
|
-- check cannot guard against.
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_status_logs_unique_entry
|
||||||
|
ON status_logs(camera_id, recorded_at);
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
type Camera struct {
|
type Camera struct {
|
||||||
CameraID string `json:"camera_id"`
|
CameraID string `json:"camera_id"`
|
||||||
FriendlyName string `json:"friendly_name"`
|
FriendlyName string `json:"friendly_name"`
|
||||||
MacAddress string `json:"mac_address,omitempty"`
|
MacAddress *string `json:"mac_address,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-53
@@ -1,88 +1,220 @@
|
|||||||
import { Camera, Radio } from 'lucide-react'
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
|
import { Camera, Play, Square, Wifi, WifiOff, AlertTriangle } from 'lucide-react'
|
||||||
import { useSSE } from './hooks/useSSE'
|
import { useSSE } from './hooks/useSSE'
|
||||||
import { useCameraStore } from './store/useCameraStore'
|
import { useCameraStore } from './store/useCameraStore'
|
||||||
import { CameraCard } from './components'
|
import { api } from './services/api'
|
||||||
|
import CameraCard from './components/CameraCard'
|
||||||
|
import HistoryViewer from './components/HistoryViewer'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Connect to SSE endpoint — auto-updates the camera store
|
const [commandBusy, setCommandBusy] = useState(false)
|
||||||
useSSE()
|
const [commandError, setCommandError] = useState<string | null>(null)
|
||||||
|
const [historyCameraId, setHistoryCameraId] = useState<string | null>(null)
|
||||||
|
const [historyCameraName, setHistoryCameraName] = useState<string>()
|
||||||
|
|
||||||
// Subscribe to the camera store for reactivity.
|
// SSE connection + live store
|
||||||
// getCameras / getOnlineCount / getRecordingCount pull from live state.
|
const { connectionState } = useSSE()
|
||||||
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
|
|
||||||
const cameras = getCameras()
|
// Subscribe to full camera state — dashboard needs every change
|
||||||
const onlineCount = getOnlineCount()
|
const camerasMap = useCameraStore((s) => s.cameras)
|
||||||
const recordingCount = getRecordingCount()
|
const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap])
|
||||||
|
const onlineCount = useMemo(() => cameras.filter((c) => c.online).length, [cameras])
|
||||||
|
const recordingCount = useMemo(() => cameras.filter((c) => c.recording).length, [cameras])
|
||||||
|
|
||||||
|
const cameraIds = cameras.map((c) => c.camera_id)
|
||||||
|
|
||||||
|
// ── Command helpers ──
|
||||||
|
|
||||||
|
const handleStart = useCallback(async (cameraId: string) => {
|
||||||
|
setCommandBusy(true)
|
||||||
|
setCommandError(null)
|
||||||
|
try {
|
||||||
|
await api.startRecording(cameraId)
|
||||||
|
} catch (err) {
|
||||||
|
setCommandError(err instanceof Error ? err.message : 'Command failed')
|
||||||
|
} finally {
|
||||||
|
setCommandBusy(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleStop = useCallback(async (cameraId: string) => {
|
||||||
|
setCommandBusy(true)
|
||||||
|
setCommandError(null)
|
||||||
|
try {
|
||||||
|
await api.stopRecording(cameraId)
|
||||||
|
} catch (err) {
|
||||||
|
setCommandError(err instanceof Error ? err.message : 'Command failed')
|
||||||
|
} finally {
|
||||||
|
setCommandBusy(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleStartAll = useCallback(async () => {
|
||||||
|
setCommandBusy(true)
|
||||||
|
setCommandError(null)
|
||||||
|
try {
|
||||||
|
await Promise.all(cameraIds.map((id) => api.startRecording(id)))
|
||||||
|
} catch {
|
||||||
|
// Individual failures are non-fatal — some may succeed
|
||||||
|
} finally {
|
||||||
|
setCommandBusy(false)
|
||||||
|
}
|
||||||
|
}, [cameraIds])
|
||||||
|
|
||||||
|
const handleStopAll = useCallback(async () => {
|
||||||
|
setCommandBusy(true)
|
||||||
|
setCommandError(null)
|
||||||
|
try {
|
||||||
|
await Promise.all(cameraIds.map((id) => api.stopRecording(id)))
|
||||||
|
} catch {
|
||||||
|
// Individual failures are non-fatal
|
||||||
|
} finally {
|
||||||
|
setCommandBusy(false)
|
||||||
|
}
|
||||||
|
}, [cameraIds])
|
||||||
|
|
||||||
|
const handleViewHistory = useCallback((cameraId: string) => {
|
||||||
|
const cam = useCameraStore.getState().cameras.get(cameraId)
|
||||||
|
setHistoryCameraId(cameraId)
|
||||||
|
setHistoryCameraName(cam?.friendly_name ?? cameraId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCloseHistory = useCallback(() => {
|
||||||
|
setHistoryCameraId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Connection badge ──
|
||||||
|
|
||||||
|
const connectionBadge = {
|
||||||
|
connected: { icon: Wifi, label: 'Live', class: 'bg-rig-success/15 text-rig-success' },
|
||||||
|
connecting: { icon: Wifi, label: 'Connecting...', class: 'bg-rig-warning/15 text-rig-warning' },
|
||||||
|
disconnected: { icon: WifiOff, label: 'Disconnected', class: 'bg-rig-danger/15 text-rig-danger' },
|
||||||
|
error: { icon: AlertTriangle, label: 'Stream Error', class: 'bg-rig-danger/15 text-rig-danger' },
|
||||||
|
}[connectionState] ?? {
|
||||||
|
icon: WifiOff,
|
||||||
|
label: 'Disconnected',
|
||||||
|
class: 'bg-rig-danger/15 text-rig-danger',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeIcon = connectionBadge.icon
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-rig-dark-900">
|
<div className="min-h-screen bg-rig-dark-900 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
<header className="shrink-0 border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Camera className="h-7 w-7 text-rig-accent" />
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
|
<Camera className="h-6 w-6 shrink-0 text-rig-accent" />
|
||||||
|
<h1 className="text-lg font-bold tracking-tight text-rig-dark-50 truncate">
|
||||||
RemoteRig
|
RemoteRig
|
||||||
</h1>
|
</h1>
|
||||||
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
<span className="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
||||||
Dashboard
|
Dashboard
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Stats badges */}
|
|
||||||
<div className="ml-auto flex items-center gap-4">
|
|
||||||
{/* Online count */}
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
|
||||||
title="Cameras online"
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 rounded-full bg-rig-success" />
|
|
||||||
{onlineCount} online
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Recording count */}
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
|
||||||
title="Cameras recording"
|
|
||||||
>
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-rig-danger" />
|
|
||||||
</span>
|
|
||||||
{recordingCount} recording
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* SSE badge */}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${connectionBadge.class}`}
|
||||||
|
>
|
||||||
|
<BadgeIcon className="h-3 w-3" />
|
||||||
|
{connectionBadge.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Global controls */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleStartAll}
|
||||||
|
disabled={commandBusy || cameras.length === 0}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-3 py-1.5 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Start recording on all cameras"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5 fill-current" />
|
||||||
|
<span className="hidden sm:inline">Start All</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStopAll}
|
||||||
|
disabled={commandBusy || cameras.length === 0}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-3 py-1.5 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Stop recording on all cameras"
|
||||||
|
>
|
||||||
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
|
<span className="hidden sm:inline">Stop All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-xs text-rig-dark-400">
|
||||||
|
<span>
|
||||||
|
<strong className="text-rig-dark-100">{cameras.length}</strong> camera{cameras.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-rig-success">{onlineCount}</strong> online
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className={recordingCount > 0 ? 'text-rig-danger' : 'text-rig-dark-300'}>
|
||||||
|
{recordingCount}
|
||||||
|
</strong>{' '}
|
||||||
|
recording
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Command error toast */}
|
||||||
|
{commandError && (
|
||||||
|
<div className="shrink-0 border-b border-rig-danger/30 bg-rig-danger/10 px-4 py-2">
|
||||||
|
<p className="mx-auto max-w-7xl text-xs text-rig-danger">
|
||||||
|
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
||||||
|
{commandError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<main className="flex-1 mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
{cameras.length === 0 ? (
|
{cameras.length === 0 ? (
|
||||||
/* Empty state */
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
|
||||||
<span className="relative mb-4 inline-flex">
|
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
|
||||||
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
|
|
||||||
</span>
|
|
||||||
<h2 className="text-lg font-semibold text-rig-dark-200">
|
<h2 className="text-lg font-semibold text-rig-dark-200">
|
||||||
Waiting for cameras…
|
No Cameras Connected
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
||||||
Connect cameras to your RemoteRig server and they will appear here
|
Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.
|
||||||
automatically.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Camera grid */
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
{cameras.map((cam) => (
|
||||||
{cameras.map((camera) => (
|
<CameraCard
|
||||||
<CameraCard key={camera.camera_id} camera={camera} />
|
key={cam.camera_id}
|
||||||
|
camera={cam}
|
||||||
|
onStart={handleStart}
|
||||||
|
onStop={handleStop}
|
||||||
|
onViewHistory={handleViewHistory}
|
||||||
|
disabled={commandBusy}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* History modal */}
|
||||||
|
<HistoryViewer
|
||||||
|
cameraId={historyCameraId}
|
||||||
|
cameraName={historyCameraName}
|
||||||
|
onClose={handleCloseHistory}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
|
<footer className="shrink-0 border-t border-rig-dark-700 bg-rig-dark-800/30">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||||
<p className="text-center text-xs text-rig-dark-500">
|
<p className="text-center text-xs text-rig-dark-500">
|
||||||
RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System
|
RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import CameraCard from './CameraCard'
|
import CameraCard from './CameraCard'
|
||||||
import type { CameraStatus } from '../types'
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
@@ -19,52 +19,52 @@ function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noop = vi.fn()
|
||||||
|
const renderCard = (overrides?: Partial<CameraStatus>) =>
|
||||||
|
render(<CameraCard camera={makeCamera(overrides ?? {})} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||||
|
const renderCardContainer = (camera: CameraStatus) =>
|
||||||
|
render(<CameraCard camera={camera} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||||
|
|
||||||
describe('CameraCard', () => {
|
describe('CameraCard', () => {
|
||||||
// ── Basic rendering ────────────────────────────────────────────────────
|
// ── Basic rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders camera name', () => {
|
it('renders camera name', () => {
|
||||||
render(<CameraCard camera={makeCamera()} />)
|
renderCard()
|
||||||
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows resolution and FPS', () => {
|
it('shows resolution and FPS', () => {
|
||||||
render(<CameraCard camera={makeCamera()} />)
|
renderCard()
|
||||||
expect(screen.getByText(/1080p/)).toBeInTheDocument()
|
expect(screen.getByText(/1080p/)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
|
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows battery percentage', () => {
|
it('shows battery percentage', () => {
|
||||||
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
|
renderCard({ battery_pct: 85 })
|
||||||
expect(screen.getByText('85%')).toBeInTheDocument()
|
expect(screen.getByText('85%')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows N/A when battery is null', () => {
|
it('shows N/A when battery is null', () => {
|
||||||
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
|
renderCard({ battery_pct: null })
|
||||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Battery bar colors ─────────────────────────────────────────────────
|
// ── Battery bar colors ─────────────────────────────────────────────────
|
||||||
|
|
||||||
it('uses green bar for high battery (>=50%)', () => {
|
it('uses green bar for high battery (>=50%)', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 85 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-success')
|
expect(bar?.className).toContain('bg-rig-success')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses yellow bar for medium battery (15-49%)', () => {
|
it('uses yellow bar for medium battery (15-49%)', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 30 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-warning')
|
expect(bar?.className).toContain('bg-rig-warning')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses red bar for low battery (<15%)', () => {
|
it('uses red bar for low battery (<15%)', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 8 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-danger')
|
expect(bar?.className).toContain('bg-rig-danger')
|
||||||
})
|
})
|
||||||
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
|
|||||||
// ── Recording state ────────────────────────────────────────────────────
|
// ── Recording state ────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows REC badge when recording', () => {
|
it('shows REC badge when recording', () => {
|
||||||
render(<CameraCard camera={makeCamera({ recording: true })} />)
|
renderCard({ recording: true })
|
||||||
expect(screen.getByText('REC')).toBeInTheDocument()
|
expect(screen.getByText('REC')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows IDLE badge when not recording', () => {
|
it('shows IDLE badge when not recording', () => {
|
||||||
render(<CameraCard camera={makeCamera({ recording: false })} />)
|
renderCard({ recording: false })
|
||||||
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Online / Offline badges ────────────────────────────────────────────
|
// ── Online / Offline badges ────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Online badge when camera is online', () => {
|
it('shows Online badge when camera is online', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
renderCard({ online: true })
|
||||||
expect(screen.getByText('Online')).toBeInTheDocument()
|
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Offline badge when camera is offline', () => {
|
it('shows Offline badge when camera is offline', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
renderCard({ online: false })
|
||||||
const offlineElements = screen.getAllByText('Offline')
|
const offlineElements = screen.getAllByText('Offline')
|
||||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
|
|||||||
// ── Video remaining ────────────────────────────────────────────────────
|
// ── Video remaining ────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows video remaining time when available', () => {
|
it('shows video remaining time when available', () => {
|
||||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />)
|
renderCard({ video_remaining_sec: 125 })
|
||||||
// formatTimeLeft(125) → "2m 5s left"
|
// formatTimeLeft(125) → "2m 5s left"
|
||||||
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
|
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show video remaining when null', () => {
|
it('does not show video remaining when null', () => {
|
||||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />)
|
renderCard({ video_remaining_sec: null })
|
||||||
// The Radio icon and time text should not be present
|
// The Radio icon and time text should not be present
|
||||||
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
|
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
|
|||||||
// ── Footer ─────────────────────────────────────────────────────────────
|
// ── Footer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Live + timestamp in footer when online', () => {
|
it('shows Live + timestamp in footer when online', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
renderCard({ online: true })
|
||||||
// Footer shows "Live" when online
|
|
||||||
expect(screen.getByText('Live')).toBeInTheDocument()
|
expect(screen.getByText('Live')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Offline + timestamp in footer when offline', () => {
|
it('shows Offline in footer when offline', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
renderCard({ online: false })
|
||||||
// Footer says "Offline" (the text appears both in the badge and footer)
|
|
||||||
// When offline, the footer specifically shows "Offline" text
|
|
||||||
const offlineElements = screen.getAllByText('Offline')
|
const offlineElements = screen.getAllByText('Offline')
|
||||||
// At least one should exist (badge + footer)
|
|
||||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows "unknown" when last_seen is malformed', () => {
|
it('shows "unknown" when last_seen is malformed', () => {
|
||||||
render(
|
renderCard({ last_seen: 'not-a-date' })
|
||||||
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows "unknown" when last_seen is in the future', () => {
|
it('shows "unknown" when last_seen is in the future', () => {
|
||||||
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
|
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
|
||||||
render(<CameraCard camera={makeCamera({ last_seen: future })} />)
|
const cam = makeCamera({ last_seen: future })
|
||||||
|
renderCardContainer(cam)
|
||||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Edge cases ──────────────────────────────────────────────────────────
|
// ── Edge cases ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('clamps negative battery_pct to 0%', () => {
|
it('clamps negative battery_pct to 0%', () => {
|
||||||
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
|
renderCard({ battery_pct: -5 })
|
||||||
expect(screen.getByText('0%')).toBeInTheDocument()
|
expect(screen.getByText('0%')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows exact boundary: 15% battery → yellow bar', () => {
|
it('shows exact boundary: 15% battery → yellow bar', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 15 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-warning')
|
expect(bar?.className).toContain('bg-rig-warning')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows exact boundary: 50% battery → green bar', () => {
|
it('shows exact boundary: 50% battery → green bar', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 50 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-success')
|
expect(bar?.className).toContain('bg-rig-success')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── New prop-driven tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onStart when Record button is clicked', () => {
|
||||||
|
const onStart = vi.fn()
|
||||||
|
render(<CameraCard camera={makeCamera({ recording: false })} onStart={onStart} onStop={noop} onViewHistory={noop} />)
|
||||||
|
screen.getByText('Record').click()
|
||||||
|
expect(onStart).toHaveBeenCalledWith('cam-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onStop when Stop button is clicked', () => {
|
||||||
|
const onStop = vi.fn()
|
||||||
|
render(<CameraCard camera={makeCamera({ recording: true })} onStart={noop} onStop={onStop} onViewHistory={noop} />)
|
||||||
|
screen.getByText('Stop').click()
|
||||||
|
expect(onStop).toHaveBeenCalledWith('cam-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onViewHistory when History button is clicked', () => {
|
||||||
|
const onViewHistory = vi.fn()
|
||||||
|
render(<CameraCard camera={makeCamera({})} onStart={noop} onStop={noop} onViewHistory={onViewHistory} />)
|
||||||
|
screen.getByText('History').click()
|
||||||
|
expect(onViewHistory).toHaveBeenCalledWith('cam-1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
|
import { Video, Wifi, WifiOff, Signal, Battery, Radio, Play, Square } from 'lucide-react'
|
||||||
import type { CameraStatus } from '../types'
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
|
|||||||
return `${diffDay}d ago`
|
return `${diffDay}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
function batteryColor(pct: number | null): { bar: string; text: string } {
|
function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
|
||||||
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
||||||
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
|
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||||
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||||
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeLeft(sec: number): string {
|
function formatTimeLeft(sec: number): string {
|
||||||
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
|
|||||||
return `${m}m ${s}s left`
|
return `${m}m ${s}s left`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cameraStatus(online: boolean, batteryPct: number | null): 'good' | 'warning' | 'critical' {
|
||||||
|
if (!online) return 'critical'
|
||||||
|
if (batteryPct === null) return 'good'
|
||||||
|
if (batteryPct >= 50) return 'good'
|
||||||
|
if (batteryPct >= 15) return 'warning'
|
||||||
|
return 'critical'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BORDER: Record<string, string> = {
|
||||||
|
good: 'border-l-rig-success',
|
||||||
|
warning: 'border-l-rig-warning',
|
||||||
|
critical: 'border-l-rig-danger',
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CameraCardProps {
|
interface CameraCardProps {
|
||||||
camera: CameraStatus
|
camera: CameraStatus
|
||||||
|
onStart: (cameraId: string) => void
|
||||||
|
onStop: (cameraId: string) => void
|
||||||
|
onViewHistory: (cameraId: string) => void
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CameraCard({ camera }: CameraCardProps) {
|
export default function CameraCard({ camera, onStart, onStop, onViewHistory, disabled }: CameraCardProps) {
|
||||||
const {
|
const {
|
||||||
|
camera_id,
|
||||||
friendly_name,
|
friendly_name,
|
||||||
online,
|
online,
|
||||||
resolution,
|
resolution,
|
||||||
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
} = camera
|
} = camera
|
||||||
|
|
||||||
const batt = batteryColor(battery_pct)
|
const batt = batteryColor(battery_pct)
|
||||||
|
const status = cameraStatus(online, battery_pct)
|
||||||
|
const borderColor = STATUS_BORDER[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
className={`rounded-xl border border-rig-dark-600 bg-rig-dark-800/60 transition-colors border-l-4 ${borderColor} ${
|
||||||
online
|
online
|
||||||
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
? 'hover:border-rig-accent/40'
|
||||||
: 'border-rig-dark-700 opacity-75'
|
: 'opacity-75'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
|
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
|
||||||
<h3
|
<h3
|
||||||
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
|
className="text-sm font-semibold text-rig-dark-100 truncate"
|
||||||
title={friendly_name}
|
title={friendly_name}
|
||||||
>
|
>
|
||||||
{friendly_name}
|
{friendly_name}
|
||||||
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={online ? 'Camera online' : 'Camera offline'}
|
aria-label={online ? 'Camera online' : 'Camera offline'}
|
||||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
className={`ml-2 shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
online
|
online
|
||||||
? 'bg-rig-success/15 text-rig-success'
|
? 'bg-rig-success/15 text-rig-success'
|
||||||
: 'bg-rig-danger/15 text-rig-danger'
|
: 'bg-rig-danger/15 text-rig-danger'
|
||||||
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
<div className="space-y-2.5 px-4 pb-3">
|
<div className="space-y-2.5 px-4 pb-3">
|
||||||
|
{/* Camera ID */}
|
||||||
|
<p className="text-[11px] font-mono text-rig-dark-500">{camera_id}</p>
|
||||||
|
|
||||||
{/* Resolution + FPS */}
|
{/* Resolution + FPS */}
|
||||||
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
||||||
<Signal className="h-3.5 w-3.5" />
|
<Signal className="h-3.5 w-3.5" />
|
||||||
@@ -159,7 +183,40 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── Footer ── */}
|
||||||
<div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2">
|
<div className="rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30">
|
||||||
|
{/* Controls row */}
|
||||||
|
<div className="flex items-center gap-1 px-3 py-2">
|
||||||
|
{recording ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onStop(camera_id)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-2.5 py-1 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label={`Stop recording ${friendly_name}`}
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 fill-current" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onStart(camera_id)}
|
||||||
|
disabled={disabled || !online}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-2.5 py-1 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label={`Start recording ${friendly_name}`}
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 fill-current" />
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onViewHistory(camera_id)}
|
||||||
|
className="ml-auto rounded-md bg-rig-dark-700/50 px-2 py-1 text-[11px] text-rig-dark-300 hover:bg-rig-dark-600 hover:text-rig-dark-100 transition-colors"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status strip */}
|
||||||
|
<div className="flex items-center justify-between px-4 pb-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
{online ? (
|
{online ? (
|
||||||
<>
|
<>
|
||||||
@@ -180,6 +237,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { X, Clock, Battery, Radio, Video } from 'lucide-react'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
import type { StatusLog } from '../types'
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryColor(pct: number | null): string {
|
||||||
|
if (pct === null) return 'text-rig-dark-400'
|
||||||
|
if (pct >= 50) return 'text-rig-success'
|
||||||
|
if (pct >= 15) return 'text-rig-warning'
|
||||||
|
return 'text-rig-danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HistoryViewerProps {
|
||||||
|
cameraId: string | null
|
||||||
|
cameraName?: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryViewer({ cameraId, cameraName, onClose }: HistoryViewerProps) {
|
||||||
|
const [logs, setLogs] = useState<StatusLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cameraId) {
|
||||||
|
setLogs([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
api
|
||||||
|
.getCameraDetail(cameraId)
|
||||||
|
.then((data) => {
|
||||||
|
setLogs(data.history)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load history')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [cameraId])
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (cameraId === null) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`History for ${cameraName ?? cameraId}`}
|
||||||
|
>
|
||||||
|
<div className="mx-4 w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl border border-rig-dark-600 bg-rig-dark-800 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between rounded-t-xl border-b border-rig-dark-700 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-rig-accent" />
|
||||||
|
<h2 className="text-sm font-semibold text-rig-dark-100">
|
||||||
|
History — {cameraName ?? cameraId}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md p-1 text-rig-dark-400 hover:bg-rig-dark-700 hover:text-rig-dark-100 transition-colors"
|
||||||
|
aria-label="Close history"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-rig-dark-500 border-t-rig-accent" />
|
||||||
|
<span className="ml-3 text-sm text-rig-dark-400">Loading history...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-rig-danger/30 bg-rig-danger/10 px-4 py-3 text-sm text-rig-danger">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && logs.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-sm text-rig-dark-400">
|
||||||
|
No history entries found for this camera.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && logs.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-rig-dark-700/50 bg-rig-dark-900/40 px-3 py-2.5 text-xs"
|
||||||
|
>
|
||||||
|
{/* Timestamp */}
|
||||||
|
<span className="font-mono text-rig-dark-400 min-w-[130px]">
|
||||||
|
{formatTimestamp(log.recorded_at)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Online/Recording badges */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
|
log.online
|
||||||
|
? 'bg-rig-success/15 text-rig-success'
|
||||||
|
: 'bg-rig-danger/15 text-rig-danger'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{log.recording_state ? (
|
||||||
|
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-rig-danger">
|
||||||
|
REC
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] text-rig-dark-500">
|
||||||
|
IDLE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Battery */}
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<Battery className="h-3 w-3 text-rig-dark-500" />
|
||||||
|
<span className={`font-mono ${batteryColor(log.battery_pct)}`}>
|
||||||
|
{log.battery_pct !== null ? `${log.battery_pct}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storage remaining */}
|
||||||
|
{log.video_remaining_sec !== null && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Radio className="h-3 w-3 text-rig-dark-500" />
|
||||||
|
<span className="font-mono text-rig-dark-400">
|
||||||
|
{Math.floor(log.video_remaining_sec / 60)}m left
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Video className="h-3 w-3 text-rig-dark-500" />
|
||||||
|
<span className="text-rig-dark-400">{log.mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="rounded-b-xl border-t border-rig-dark-700 px-5 py-3">
|
||||||
|
<p className="text-[11px] text-rig-dark-500">
|
||||||
|
{logs.length} entries (last 24 hours)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { default as CameraCard } from './CameraCard'
|
export { default as CameraCard } from './CameraCard'
|
||||||
|
export { default as HistoryViewer } from './HistoryViewer'
|
||||||
|
|||||||
+19
-6
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1'
|
||||||
|
|
||||||
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
@@ -12,9 +12,22 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getCameras: () => request<[]>('/cameras'),
|
/** GET /api/v1/cameras — list all cameras with latest status */
|
||||||
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
|
getCameras: () => request<import('../types').CameraStatus[]>('/cameras'),
|
||||||
getSystemHealth: () => request<[]>('/system/health'),
|
|
||||||
toggleRecording: (cameraId: string) =>
|
/** GET /api/v1/cameras/{id} — full detail + 24h history */
|
||||||
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
|
getCameraDetail: (id: string) =>
|
||||||
|
request<import('../types').CameraDetail>(`/cameras/${id}`),
|
||||||
|
|
||||||
|
/** POST /api/v1/cameras/{id}/start — start recording */
|
||||||
|
startRecording: (cameraId: string) =>
|
||||||
|
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** POST /api/v1/cameras/{id}/stop — stop recording */
|
||||||
|
stopRecording: (cameraId: string) =>
|
||||||
|
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,42 @@ export interface SSEEvent {
|
|||||||
payload?: unknown
|
payload?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single status log entry from GET /api/v1/cameras/{id} */
|
||||||
|
export interface StatusLog {
|
||||||
|
id: number
|
||||||
|
camera_id: string
|
||||||
|
recorded_at: string
|
||||||
|
battery_pct: number | null
|
||||||
|
video_remaining_sec: number | null
|
||||||
|
recording_state: number // 0 or 1 (SQLite bool)
|
||||||
|
mode: string
|
||||||
|
resolution: string
|
||||||
|
fps: number
|
||||||
|
online: number // 0 or 1
|
||||||
|
raw_battery_pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera detail response from GET /api/v1/cameras/{id} */
|
||||||
|
export interface CameraDetail {
|
||||||
|
camera: CameraInfo
|
||||||
|
last_status: StatusLog
|
||||||
|
history: StatusLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraInfo {
|
||||||
|
CameraID: string
|
||||||
|
FriendlyName: string
|
||||||
|
MacAddress: string | null
|
||||||
|
CreatedAt: string
|
||||||
|
UpdatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic API responses */
|
||||||
|
export interface StartStopResponse {
|
||||||
|
status: string
|
||||||
|
camera_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Camera {
|
export interface Camera {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user