Dev #26

Open
overseer wants to merge 65 commits from dev into main
18 changed files with 1967 additions and 209 deletions
Showing only changes of commit 9accd34b50 - Show all commits
+508
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
module github.com/cubecraft/remoterig
go 1.19
go 1.25.0
require (
github.com/eclipse/paho.mqtt.golang v1.5.0
+14
View File
@@ -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/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/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
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/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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
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/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
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/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
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/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
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/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+596
View File
@@ -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
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/pkg/models"
@@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
`)
if err != nil {
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
}
defer rows.Close()
@@ -64,7 +65,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
}
if err := rows.Err(); err != nil {
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
}
@@ -84,13 +85,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
FriendlyName string `json:"friendly_name"`
MacAddress *string `json:"mac_address,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
if !decodeJSONBody(w, r, &req) {
return
}
if req.CameraID == "" || req.FriendlyName == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
return
}
@@ -99,12 +97,12 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
VALUES (?, ?, ?)
`, req.CameraID, req.FriendlyName, req.MacAddress)
if err != nil {
if err.Error() == "UNIQUE constraint failed: cameras.mac_address" {
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"})
if isUniqueConstraintErr(err) {
respondError(w, http.StatusConflict, "camera already registered", err.Error())
return
}
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
}
@@ -124,8 +122,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
func GetCameraDetail(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
@@ -139,12 +136,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
&c.CreatedAt, &c.UpdatedAt,
)
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
}
if err != nil {
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
}
@@ -165,7 +162,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
)
if err != nil && err != sql.ErrNoRows {
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
}
@@ -180,7 +177,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
`, cameraID)
if err != nil {
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
}
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.
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
+116
View File
@@ -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
View File
@@ -13,8 +13,7 @@ import (
func StartRecording(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
@@ -22,8 +21,13 @@ func StartRecording(database *db.DB) http.HandlerFunc {
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
if err != nil {
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
}
@@ -34,12 +38,12 @@ func StartRecording(database *db.DB) http.HandlerFunc {
`, cameraID)
if err != nil {
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
}
rows, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows)
rowsAffected, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_started",
@@ -52,8 +56,7 @@ func StartRecording(database *db.DB) http.HandlerFunc {
func StopRecording(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
@@ -61,8 +64,13 @@ func StopRecording(database *db.DB) http.HandlerFunc {
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
if err != nil {
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
}
@@ -73,12 +81,12 @@ func StopRecording(database *db.DB) http.HandlerFunc {
`, cameraID)
if err != nil {
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
}
rows, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows)
rowsAffected, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_stopped",
+19 -8
View File
@@ -2,7 +2,6 @@
package api
import (
"encoding/json"
"log"
"net/http"
@@ -14,8 +13,7 @@ import (
func PushStatus(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
@@ -30,8 +28,16 @@ func PushStatus(database *db.DB) http.HandlerFunc {
RawBatteryPct *float64 `json:"raw_battery_pct"`
Timestamp *string `json:"ts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
if !decodeJSONBody(w, r, &req) {
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
}
@@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc {
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
if err != nil {
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
}
@@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc {
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
if err != nil {
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
}
+73 -28
View File
@@ -4,9 +4,11 @@ package db
import (
"database/sql"
_ "embed"
"fmt"
"log"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
@@ -14,13 +16,16 @@ import (
//go:embed migrations/001_create_tables.sql
var migration001 string
//go:embed migrations/002_dedup_unique_index.sql
var migration002 string
// DB wraps the sql.DB with connection-level settings.
type DB struct {
*sql.DB
}
// 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) {
// Ensure the directory exists
dir := filepath.Dir(path)
@@ -45,34 +50,57 @@ func Open(path string) (*DB, error) {
return nil, err
}
// Check if tables already exist (idempotent migration)
var count int
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 {
// Ensure schema_version table exists for migration tracking
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
db.Close()
return nil, err
}
if count < 4 {
log.Printf("Running migrations for %s...", path)
if err := migrate(db, migration001); err != nil {
// Read current schema version (0 if table is empty)
var currentVersion int
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&currentVersion); err != nil {
db.Close()
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")
}
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 {
// Split on semicolons to handle multiple statements
statements := splitSQL(sql)
for _, stmt := range statements {
stmt = stripWhitespace(stmt)
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
@@ -83,8 +111,13 @@ func migrate(db *sql.DB, sql string) error {
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 {
// First, strip all line comments (--) to prevent them from swallowing
// subsequent SQL statements when newlines are collapsed.
sql = stripSQLLineComments(sql)
var stmts []string
var current string
inQuote := false
@@ -106,30 +139,42 @@ func splitSQL(sql string) []string {
case ';':
stmts = append(stmts, current)
current = ""
case '\r', '\n', '\t':
current += " "
default:
current += string(r)
}
}
if len(current) > 0 {
if strings.TrimSpace(current) != "" {
stmts = append(stmts, current)
}
return stmts
}
// stripWhitespace removes leading/trailing whitespace and normalizes newlines.
func stripWhitespace(s string) string {
result := ""
runningSpace := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !runningSpace {
result += " "
runningSpace = true
// stripSQLLineComments removes all -- single-line comments from SQL text.
func stripSQLLineComments(sql string) string {
var result strings.Builder
i := 0
runes := []rune(sql)
for i < len(runes) {
r := runes[i]
// 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 {
result += string(r)
runningSpace = false
// Replace comment with a newline (preserves statement boundaries)
result.WriteRune('\n')
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);
+1 -1
View File
@@ -9,7 +9,7 @@ import (
type Camera struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"`
MacAddress *string `json:"mac_address,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+185 -53
View File
@@ -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 { 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() {
// Connect to SSE endpoint — auto-updates the camera store
useSSE()
const [commandBusy, setCommandBusy] = useState(false)
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.
// getCameras / getOnlineCount / getRecordingCount pull from live state.
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
const cameras = getCameras()
const onlineCount = getOnlineCount()
const recordingCount = getRecordingCount()
// SSE connection + live store
const { connectionState } = useSSE()
// Subscribe to full camera state — dashboard needs every change
const camerasMap = useCameraStore((s) => s.cameras)
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 (
<div className="min-h-screen bg-rig-dark-900">
<div className="min-h-screen bg-rig-dark-900 flex flex-col">
{/* Header */}
<header className="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="flex items-center gap-3">
<Camera className="h-7 w-7 text-rig-accent" />
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
<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-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<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
</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
</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>
{/* 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>
</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 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 ? (
/* 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">
<span className="relative mb-4 inline-flex">
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
</span>
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
<h2 className="text-lg font-semibold text-rig-dark-200">
Waiting for cameras&hellip;
No Cameras Connected
</h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Connect cameras to your RemoteRig server and they will appear here
automatically.
Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.
</p>
</div>
) : (
/* Camera grid */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cameras.map((camera) => (
<CameraCard key={camera.camera_id} camera={camera} />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{cameras.map((cam) => (
<CameraCard
key={cam.camera_id}
camera={cam}
onStart={handleStart}
onStop={handleStop}
onViewHistory={handleViewHistory}
disabled={commandBusy}
/>
))}
</div>
)}
</main>
{/* History modal */}
<HistoryViewer
cameraId={historyCameraId}
cameraName={historyCameraName}
onClose={handleCloseHistory}
/>
{/* 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">
<p className="text-center text-xs text-rig-dark-500">
RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System
+52 -38
View File
@@ -1,5 +1,5 @@
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 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', () => {
// ── Basic rendering ────────────────────────────────────────────────────
it('renders camera name', () => {
render(<CameraCard camera={makeCamera()} />)
renderCard()
expect(screen.getByText('Front Camera')).toBeInTheDocument()
})
it('shows resolution and FPS', () => {
render(<CameraCard camera={makeCamera()} />)
renderCard()
expect(screen.getByText(/1080p/)).toBeInTheDocument()
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
})
it('shows battery percentage', () => {
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
renderCard({ battery_pct: 85 })
expect(screen.getByText('85%')).toBeInTheDocument()
})
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()
})
// ── Battery bar colors ─────────────────────────────────────────────────
it('uses green bar for high battery (>=50%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
)
const { container } = renderCard({ battery_pct: 85 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success')
})
it('uses yellow bar for medium battery (15-49%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
)
const { container } = renderCard({ battery_pct: 30 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('uses red bar for low battery (<15%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
)
const { container } = renderCard({ battery_pct: 8 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-danger')
})
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
// ── Recording state ────────────────────────────────────────────────────
it('shows REC badge when recording', () => {
render(<CameraCard camera={makeCamera({ recording: true })} />)
renderCard({ recording: true })
expect(screen.getByText('REC')).toBeInTheDocument()
})
it('shows IDLE badge when not recording', () => {
render(<CameraCard camera={makeCamera({ recording: false })} />)
renderCard({ recording: false })
expect(screen.getByText('IDLE')).toBeInTheDocument()
})
// ── Online / Offline badges ────────────────────────────────────────────
it('shows Online badge when camera is online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
renderCard({ online: true })
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('shows Offline badge when camera is offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
renderCard({ online: false })
const offlineElements = screen.getAllByText('Offline')
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
})
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
// ── Video remaining ────────────────────────────────────────────────────
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"
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
})
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
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
})
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
// ── Footer ─────────────────────────────────────────────────────────────
it('shows Live + timestamp in footer when online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
// Footer shows "Live" when online
renderCard({ online: true })
expect(screen.getByText('Live')).toBeInTheDocument()
})
it('shows Offline + timestamp in footer when offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
// Footer says "Offline" (the text appears both in the badge and footer)
// When offline, the footer specifically shows "Offline" text
it('shows Offline in footer when offline', () => {
renderCard({ online: false })
const offlineElements = screen.getAllByText('Offline')
// At least one should exist (badge + footer)
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
})
it('shows "unknown" when last_seen is malformed', () => {
render(
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
)
renderCard({ last_seen: 'not-a-date' })
expect(screen.getByText('unknown')).toBeInTheDocument()
})
it('shows "unknown" when last_seen is in the future', () => {
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()
})
// ── Edge cases ──────────────────────────────────────────────────────────
it('clamps negative battery_pct to 0%', () => {
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
renderCard({ battery_pct: -5 })
expect(screen.getByText('0%')).toBeInTheDocument()
})
it('shows exact boundary: 15% battery → yellow bar', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
)
const { container } = renderCard({ battery_pct: 15 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('shows exact boundary: 50% battery → green bar', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
)
const { container } = renderCard({ battery_pct: 50 })
const bar = container.querySelector('[role="progressbar"] div')
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')
})
})
+73 -15
View File
@@ -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'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
return `${diffDay}d ago`
}
function batteryColor(pct: number | null): { bar: string; text: string } {
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
}
function formatTimeLeft(sec: number): string {
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
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 ──────────────────────────────────────────────────────────────
interface CameraCardProps {
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 {
camera_id,
friendly_name,
online,
resolution,
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
} = camera
const batt = batteryColor(battery_pct)
const status = cameraStatus(online, battery_pct)
const borderColor = STATUS_BORDER[status]
return (
<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
? 'border-rig-dark-600 hover:border-rig-accent/40'
: 'border-rig-dark-700 opacity-75'
? 'hover:border-rig-accent/40'
: 'opacity-75'
}`}
>
{/* ── Header ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
<div className="flex items-center gap-2 min-w-0">
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
<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}
>
{friendly_name}
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
<span
role="status"
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
? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger'
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
{/* ── Body ── */}
<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 */}
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
<Signal className="h-3.5 w-3.5" />
@@ -159,7 +183,40 @@ export default function CameraCard({ camera }: CameraCardProps) {
</div>
{/* ── 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">
{online ? (
<>
@@ -180,6 +237,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
</div>
)}
</div>
</div>
</article>
)
}
+193
View File
@@ -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 &mdash; {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
View File
@@ -1 +1,2 @@
export { default as CameraCard } from './CameraCard'
export { default as HistoryViewer } from './HistoryViewer'
+19 -6
View File
@@ -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> {
const response = await fetch(`${API_BASE}${endpoint}`, {
@@ -12,9 +12,22 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
}
export const api = {
getCameras: () => request<[]>('/cameras'),
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
getSystemHealth: () => request<[]>('/system/health'),
toggleRecording: (cameraId: string) =>
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
/** GET /api/v1/cameras — list all cameras with latest status */
getCameras: () => request<import('../types').CameraStatus[]>('/cameras'),
/** GET /api/v1/cameras/{id} — full detail + 24h history */
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',
}),
}
+36
View File
@@ -21,6 +21,42 @@ export interface SSEEvent {
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 {
id: string
name: string