generated from CubeCraft-Creations/Tracehound
Merge branch 'dev' into agent/rex/CUB-176-central-hub-frontend
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
+115
-15
@@ -2,30 +2,126 @@
|
|||||||
|
|
||||||
> Living queue for 3D-printed / physical hardware design work.
|
> Living queue for 3D-printed / physical hardware design work.
|
||||||
|
|
||||||
## Active / Ready for prototype print
|
## Active / Ready for CAD prototype
|
||||||
|
|
||||||
|
### Camera node case v4 — upright status panel + strap mount
|
||||||
|
|
||||||
|
**Status:** Parametric OpenSCAD source created; body/lid/preview STLs exported and validated watertight. Ready for CAD review, exact part measurement, and first prototype print.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- `hardware/case/camera-node-case-v4.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-body.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-lid.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-preview.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-front-review.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-body.stl`
|
||||||
|
- `hardware/case/camera-node-case-v4-lid.stl`
|
||||||
|
- `hardware/case/camera-node-case-v4-preview.stl`
|
||||||
|
- `hardware/case/camera-node-case-v4-front-review.stl`
|
||||||
|
|
||||||
|
**Design direction:**
|
||||||
|
|
||||||
|
- Stand-mounted upright camera node enclosure; the case still does **not** mount to the GoPro.
|
||||||
|
- Visual direction now matches the original green appliance-style reference: tall vertical body, large inset front panel, centered OLED near the upper third, blank middle area, two long rounded lower slots, bottom USB-C female power input, right-side USB-A female passthrough power port for the GoPro, and left-side IPEX/U.FL antenna pigtail/connector exit opposite the USB-A.
|
||||||
|
- This replaces the rejected wide/low generic electronics-box layout from the first v4 attempt.
|
||||||
|
- Primary mounting is reusable cloth zip ties / Velcro straps through two low-profile vertical rear brackets with long lateral side-feed openings, not a clamp/dovetail.
|
||||||
|
- Front has a recessed/flush full-height service lid similar to a field-service status panel.
|
||||||
|
- Lid includes cutouts for:
|
||||||
|
- 1.3-inch OLED/status screen.
|
||||||
|
- separate 3 mm power LED.
|
||||||
|
- single 3 mm RGB status LED replacing red/green status LEDs.
|
||||||
|
- small rocker on/off switch.
|
||||||
|
- two long rounded lower front slots styled after the reference.
|
||||||
|
- Front-panel screen, LED, rocker, and lower-slot openings are actual through-cuts through the full lid and locating lip so the back side of the printed lid is not skinned over.
|
||||||
|
- Body includes screw bosses, recessed lid pocket, lid locating geometry, a bottom USB-C female power inlet cutout, a right-side USB-A female passthrough power cutout, a left-side 5.0 mm prototype IPEX/U.FL antenna pigtail/connector through-hole with shallow exterior recess, and two vertical external rear zip-tie/Velcro brackets to resist rotation on a stand. The zip ties feed laterally through long side windows behind the raised bridge faces; the old top-to-bottom feed-through tunnel is intentionally closed by top/bottom anchor pads.
|
||||||
|
- Internal envelope is sized for known module dimensions plus service clearance:
|
||||||
|
- ESP32-C3 Super Mini: 22.5 × 18 mm.
|
||||||
|
- ESP-01S: ~24.7 × 14.3 × 12 mm.
|
||||||
|
|
||||||
|
**Prototype display content target:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
CAM 03 REC ●
|
||||||
|
BAT 87% LINK OK
|
||||||
|
00:12:34
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prototype dimensions to validate before production:**
|
||||||
|
|
||||||
|
- Exact 1.3-inch OLED module dimensions:
|
||||||
|
- PCB width/height/thickness.
|
||||||
|
- active display/window width/height.
|
||||||
|
- connector side and ribbon/header clearance.
|
||||||
|
- mounting-hole positions, if using module screws or adhesive tape.
|
||||||
|
- Rocker switch:
|
||||||
|
- snap-in cutout width/height.
|
||||||
|
- bezel/flange size.
|
||||||
|
- required panel thickness range.
|
||||||
|
- rear depth and terminal clearance.
|
||||||
|
- LEDs:
|
||||||
|
- preferred holder/bezel style, if any.
|
||||||
|
- final hole diameter for 3 mm PWR LED and 3 mm RGB STAT LED.
|
||||||
|
- current-limiting resistor placement.
|
||||||
|
- Wiring/service:
|
||||||
|
- USB cable diameter and bend radius.
|
||||||
|
- bottom USB-C female panel/breakout connector flange, body depth, and mounting requirements.
|
||||||
|
- right-side USB-A female panel/breakout connector flange, body depth, and mounting requirements for GoPro 5 V passthrough.
|
||||||
|
- left-side IPEX/U.FL antenna pigtail/bulkhead exact outside diameter, retention/flange needs, bend radius, strain relief, and whether the current 5.0 mm prototype through-hole plus 8.5 mm shallow exterior recess should change before production.
|
||||||
|
- actual regulator/power distribution board footprint if used.
|
||||||
|
- Fasteners:
|
||||||
|
- M2 vs M2.5 vs self-tapping screws for lid.
|
||||||
|
- pilot diameter, screw length, and head/counterbore diameter.
|
||||||
|
- Mounting straps:
|
||||||
|
- cloth zip-tie / Velcro width and thickness.
|
||||||
|
- prototype rear side-feed opening: ~40 mm long vertical side window × ~3.8 mm strap-thickness clearance behind each raised bridge, with each visible vertical bracket ~8.5 mm wide × 50 mm tall.
|
||||||
|
- whether two strap paths are enough to prevent case rotation on the expected stand diameter.
|
||||||
|
- whether rear vertical bracket/window edges need larger radii or TPU/silicone sleeve protection.
|
||||||
|
- Printability:
|
||||||
|
- rear vertical zip-tie bracket top/bottom anchor-pad and bridge strength, and whether the lateral side-feed openings print cleanly without supports.
|
||||||
|
- body/lid fit after PETG shrinkage.
|
||||||
|
- lid lip clearance and screw boss robustness.
|
||||||
|
- USB-C/USB-A and IPEX antenna exit cutout edge quality, wall strength, and connector retention/strain relief.
|
||||||
|
|
||||||
|
**Suggested OpenSCAD validation/export commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad -o /tmp/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4-body.scad
|
||||||
|
openscad -o /tmp/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4-lid.scad
|
||||||
|
openscad -o /tmp/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4-preview.scad
|
||||||
|
openscad -o /tmp/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4-front-review.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
Latest validation: OpenSCAD reports `Simple: yes`; trimesh confirms body, lid, preview, and front-review STLs are watertight. Body and lid each export as a single connected printable component; preview includes separate non-print board/connector guide volumes by design. A rear-bracket sanity check confirms both vertical brackets have clear non-solid lateral X-direction side-feed volumes behind the raised bridge faces, while the rear wall, bridge faces, and top/bottom anchor pads remain solid. The left-side IPEX/U.FL antenna hole is a through-wall cut to the interior cavity, not a blind exterior pocket.
|
||||||
|
|
||||||
|
Or with the main parametric selector:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad -D 'part="body"' -o /tmp/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="lid"' -o /tmp/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="preview"' -o /tmp/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="front_review"' -o /tmp/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prior prototype reference
|
||||||
|
|
||||||
### Tripod electronics case v3
|
### Tripod electronics case v3
|
||||||
|
|
||||||
**Status:** STL generated and validated watertight.
|
**Status:** Historical design reference. In this checkout, previous v3 SCAD/STL files are not present; v4 starts a new `hardware/case/` CAD source set.
|
||||||
|
|
||||||
**Files:**
|
**Previous design notes:**
|
||||||
- `hardware/case/tripod-case-v3.scad`
|
|
||||||
- `hardware/case/case-body-v3.stl`
|
|
||||||
- `hardware/case/case-lid-v3.stl`
|
|
||||||
- `hardware/case/tripod-clamp-v3.stl`
|
|
||||||
- `hardware/case/full-case-preview-v3.stl`
|
|
||||||
|
|
||||||
**Design notes:**
|
- Held ESP32 + ESP8266 stack.
|
||||||
- Holds ESP32 + ESP8266 stack.
|
|
||||||
- Screw-on lid with vent slots.
|
- Screw-on lid with vent slots.
|
||||||
- Rear dovetail-style rail/socket interface.
|
- Rear dovetail-style rail/socket interface.
|
||||||
- Separate screw-tightened tripod clamp sized around a 35 mm stand/pole.
|
- Separate screw-tightened tripod clamp sized around a 35 mm stand/pole.
|
||||||
- Clamp uses M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
|
- Clamp used M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
|
||||||
|
|
||||||
**Prototype questions:**
|
**Reasons superseded by v4:**
|
||||||
- Does the clamp close enough on smaller tripod legs, or do we need swappable inserts?
|
|
||||||
- Does the dovetail hold under vibration without a retention screw?
|
- User requested front status/service panel with OLED, LEDs, and rocker switch.
|
||||||
- Are USB/LED/UART cutouts in the correct orientation for the actual boards?
|
- Single RGB status LED replaces separate red/green status LEDs.
|
||||||
|
- Rear strap pass-through loops are simpler and more adaptable than a dedicated clamp/dovetail for field stands.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
@@ -36,6 +132,7 @@
|
|||||||
**Goal:** A printable enclosure for the RemoteRig hub/control panel using a 10.1-inch touchscreen and Raspberry Pi Zero / Zero 2 W.
|
**Goal:** A printable enclosure for the RemoteRig hub/control panel using a 10.1-inch touchscreen and Raspberry Pi Zero / Zero 2 W.
|
||||||
|
|
||||||
**Display target:**
|
**Display target:**
|
||||||
|
|
||||||
- Vendor/model: HZWDONE Raspberry Pi Screen 10.1" Touchscreen
|
- Vendor/model: HZWDONE Raspberry Pi Screen 10.1" Touchscreen
|
||||||
- Resolution: 1024×600
|
- Resolution: 1024×600
|
||||||
- Interface: HDMI portable monitor
|
- Interface: HDMI portable monitor
|
||||||
@@ -43,12 +140,14 @@
|
|||||||
- Compatibility listing: Raspberry Pi 5/4/3B/B+ and Windows 11/10/8
|
- Compatibility listing: Raspberry Pi 5/4/3B/B+ and Windows 11/10/8
|
||||||
|
|
||||||
**Initial assumptions to validate:**
|
**Initial assumptions to validate:**
|
||||||
|
|
||||||
- Compute: Raspberry Pi Zero / Zero 2 W mounted behind or below the display.
|
- Compute: Raspberry Pi Zero / Zero 2 W mounted behind or below the display.
|
||||||
- Use case: RemoteRig local monitor/control panel at field recording setup.
|
- Use case: RemoteRig local monitor/control panel at field recording setup.
|
||||||
- Likely needs: front bezel, rear electronics cavity, Pi mounting posts, HDMI/USB/power cable exits, strain relief, ventilation, and optional tripod/stand mounting.
|
- Likely needs: front bezel, rear electronics cavity, Pi mounting posts, HDMI/USB/power cable exits, strain relief, ventilation, and optional tripod/stand mounting.
|
||||||
- Because this is a 10.1" panel, design should prioritize rigidity: thicker bezel ribs, rear standoffs, and possibly a two-piece shell instead of a small snap case.
|
- Because this is a 10.1" panel, design should prioritize rigidity: thicker bezel ribs, rear standoffs, and possibly a two-piece shell instead of a small snap case.
|
||||||
|
|
||||||
**Required measurements before CAD:**
|
**Required measurements before CAD:**
|
||||||
|
|
||||||
- Product link or datasheet for the exact HZWDONE 10.1" variant.
|
- Product link or datasheet for the exact HZWDONE 10.1" variant.
|
||||||
- Screen/PCB outer dimensions: width, height, thickness.
|
- Screen/PCB outer dimensions: width, height, thickness.
|
||||||
- Active display opening dimensions.
|
- Active display opening dimensions.
|
||||||
@@ -60,6 +159,7 @@
|
|||||||
- Mounting preference: desktop kickstand, tripod clamp, VESA-style holes, handle, or combination.
|
- Mounting preference: desktop kickstand, tripod clamp, VESA-style holes, handle, or combination.
|
||||||
|
|
||||||
**Proposed design approach:**
|
**Proposed design approach:**
|
||||||
|
|
||||||
1. Create `hardware/display-case/`.
|
1. Create `hardware/display-case/`.
|
||||||
2. Build a parametric OpenSCAD model with measured display/Pi dimensions.
|
2. Build a parametric OpenSCAD model with measured display/Pi dimensions.
|
||||||
3. Split into printable parts: front bezel, rear shell, Pi/controller tray, optional stand/tripod mount.
|
3. Split into printable parts: front bezel, rear shell, Pi/controller tray, optional stand/tripod mount.
|
||||||
|
|||||||
+149
-70
@@ -1,112 +1,191 @@
|
|||||||
# RemoteRig — Camera Node Hardware Design
|
# RemoteRig — Camera Node Hardware Design
|
||||||
|
|
||||||
> **Version:** 0.2.0 | **Status:** Draft
|
> **Version:** 0.3.0 | **Status:** v4 CAD prototype ready for measurement/print validation
|
||||||
> **Target:** GoPro Hero 3 Black/Silver + ESP8266 + ESP32 + USB power bank
|
> **Target:** GoPro Hero 3 Black/Silver + ESP32-C3 Super Mini + ESP-01S + USB power bank
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Each camera node is two ESP boards in a small case that clips to the tripod/stand. The case **does not attach to the camera** — only to the stand. Powered by a standard USB power bank.
|
Each camera node is two ESP boards in a small upright stand-mounted case. The case **does not attach to the camera**; it straps to a tripod/lighting stand with reusable cloth zip ties / Velcro straps. Powered by a standard USB power bank.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ USB Power Bank │── USB ──→ GoPro (power only)
|
│ USB Power Bank │
|
||||||
│ (off-the-shelf)│── USB ──→ ESP32 + ESP8266 (shared)
|
│ (off-the-shelf)│
|
||||||
└─────────────────┘
|
└────────┬────────┘
|
||||||
│
|
│ USB-C cable into bottom USB-C female input
|
||||||
┌────────┴────────┐
|
▼
|
||||||
│ Tripod Case │ ← clips to stand leg
|
┌─────────────────────────────────────┐
|
||||||
│ ┌────────────┐ │
|
│ Camera Node Case v4 │ ← Velcro/cloth straps to stand
|
||||||
│ │ ESP8266 │ │ ← Wi-Fi → GoPro AP (10.5.5.1)
|
│ ┌──────────────────────────────┐ │
|
||||||
│ │ (camera) │ │
|
│ │ Flush/recessed service lid │ │
|
||||||
│ ├────────────┤ │ ← UART between boards
|
│ │ 1.3 OLED: CAM/REC/BAT/LINK │ │
|
||||||
│ │ ESP32 │ │ ← Wi-Fi → Travel Router
|
│ │ PWR LED + RGB STAT LED │ │
|
||||||
│ │ (MQTT) │ │
|
│ │ Small rocker power switch │ │
|
||||||
│ └────────────┘ │
|
│ └──────────────────────────────┘ │
|
||||||
└─────────────────┘
|
│ ESP-01S camera bridge ↔ ESP32-C3 │
|
||||||
|
│ side USB-A female power output ────┼── USB cable ──→ GoPro power
|
||||||
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bill of Materials
|
## Bill of Materials
|
||||||
|
|
||||||
| Item | Qty | Cost | Notes |
|
| Item | Qty | Cost | Notes |
|
||||||
|------|-----|------|-------|
|
|------|-----|------|-------|
|
||||||
| ESP32 Dev Board | 1 | ~$5 | MQTT bridge — talks to hub |
|
| ESP32-C3 Super Mini | 1 | ~$4–$6 | MQTT / hub-side bridge; known board envelope 22.5 × 18 mm |
|
||||||
| ESP8266 D1 Mini | 1 | ~$3 | Camera bridge — talks to GoPro |
|
| ESP-01S / ESP8266 module | 1 | ~$2–$3 | Camera-side GoPro Wi-Fi bridge; known envelope ~24.7 × 14.3 × 12 mm |
|
||||||
| USB power bank (5000mAh+) | 1 | ~$10 | Powers both boards + GoPro |
|
| 1.3-inch OLED/status screen | 1 | ~$4–$8 | Prototype CAD assumes ~31 × 16 mm visible window / ~37 × 22 mm panel recess; confirm exact module |
|
||||||
| Micro-USB cable (short) | 2 | ~$2 | Power bank → boards + GoPro |
|
| 3 mm power LED | 1 | <$1 | Separate always-power/5V indicator |
|
||||||
| Jumper wires F-F | 3 | ~$0.25 | UART TX/RX/GND between boards |
|
| 3 mm RGB status LED | 1 | <$1 | Replaces separate red/green status LEDs; firmware can map node states to color |
|
||||||
| PETG filament | ~25g | ~$0.50 | 3D printed case |
|
| Small rocker switch | 1 | ~$1–$3 | On/off switch; prototype CAD assumes 13 × 19 mm snap-in opening |
|
||||||
| Velcro strap (small) | 1 | ~$0.25 | Secure power bank to stand |
|
| USB-C female panel/breakout connector | 1 | ~$1–$4 | Bottom power input; prototype CAD assumes ~10.5 × 4.5 mm rounded visible opening plus shallow underside recess; measure purchased part |
|
||||||
|
| USB-A female panel/breakout connector | 1 | ~$1–$4 | Right-side GoPro power passthrough output; prototype CAD assumes ~16 × 8 mm side opening; measure purchased part |
|
||||||
|
| IPEX/U.FL antenna pigtail or bulkhead lead | 1 | TBD | Left-side antenna exit opposite the USB-A port; prototype CAD assumes a 5.0 mm circular through-hole plus shallow exterior recess; measure exact pigtail/bulkhead diameter before production |
|
||||||
|
| USB power bank (5000 mAh+) | 1 | ~$10 | Powers camera node and GoPro |
|
||||||
|
| Short USB cables / wiring | as needed | ~$2–$5 | Power bank → node USB-C input; node 5 V passthrough → USB-A female → GoPro USB cable; internal power/signal wiring |
|
||||||
|
| M2 or small self-tapping screws | 4 | <$1 | Front service lid screws; pilot holes are parametric |
|
||||||
|
| PETG filament | ~35–50 g | ~$1 | 3D printed case body + lid |
|
||||||
|
| Reusable cloth zip ties / Velcro straps | 2 | ~$1 | Primary stand mount through rear vertical zip-tie brackets with lateral side-feed openings |
|
||||||
|
|
||||||
**Total per node:** ~$21 (+ GoPro already owned)
|
**Total per node:** roughly ~$25–$35 plus GoPro and power bank, depending on display/switch choice.
|
||||||
|
|
||||||
## 3D Printed Case
|
## 3D Printed Case
|
||||||
|
|
||||||
**Current source:** `hardware/case/tripod-case-v3.scad`
|
**Current source:** `hardware/case/camera-node-case-v4.scad`
|
||||||
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
|
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
|
||||||
|
|
||||||
Four exported prototype files:
|
The current v4 CAD replaces the rejected wide/low electronics-box layout with a tall appliance-style enclosure matching the original upright reference: a clean vertical body, large inset front panel, OLED near the top, open blank middle area, two long rounded lower slots, a bottom USB-C female power input, a right-side USB-A female passthrough power port for the GoPro, and a left-side IPEX/U.FL antenna pigtail/connector hole opposite the USB-A. It also replaces the v3 clamp/dovetail concept with a simpler strap-mounted field enclosure:
|
||||||
1. **Case body** — holds both boards stacked, cable ports, rear dovetail-style receiver
|
|
||||||
2. **Case lid** — screw-on cover with ventilation
|
1. **Case body** — shell sized around ESP32-C3 Super Mini + ESP-01S with service/wiring clearance.
|
||||||
3. **Tripod clamp** — separate screw-tightened C-clamp sized around a 35mm stand/pole
|
2. **Flush/recessed full-height front service lid** — screw-on front panel with locating lip and a restrained raised/recessed border.
|
||||||
4. **Full preview** — combined visualization STL only, not intended as the print job
|
3. **Front panel controls/indicators**:
|
||||||
|
- 1.3-inch OLED/status screen window.
|
||||||
|
- 3 mm **PWR** LED.
|
||||||
|
- single 3 mm **RGB STAT** LED for state-dependent colors.
|
||||||
|
- small rectangular rocker switch cutout.
|
||||||
|
- two long rounded lower front slots styled after the reference appliance face.
|
||||||
|
4. **Rear vertical zip-tie pass-through brackets** — two low-profile external brackets, one left and one right of center, with top/bottom anchor pads and long vertical side-access openings. Zip ties feed laterally in the X direction behind each raised bridge face instead of top-to-bottom, while the rear wall stays sealed.
|
||||||
|
5. **USB power ports** — bottom USB-C female power input and right-side USB-A female passthrough power output for a GoPro USB power cable.
|
||||||
|
6. **Left-side antenna exit** — prototype 5.0 mm round through-wall IPEX/U.FL antenna pigtail/connector clearance, placed opposite the right-side USB-A port at the same vertical position, with a shallow exterior circular recess for visual/exit relief. Measure the actual antenna pigtail/bulkhead before production.
|
||||||
|
|
||||||
|
### Export wrappers
|
||||||
|
|
||||||
|
Simple per-part OpenSCAD wrappers are included:
|
||||||
|
|
||||||
|
- `hardware/case/camera-node-case-v4-body.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-lid.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-preview.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-front-review.scad`
|
||||||
|
|
||||||
|
Example CLI exports, if OpenSCAD is installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad -o hardware/case/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4-body.scad
|
||||||
|
openscad -o hardware/case/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4-lid.scad
|
||||||
|
openscad -o hardware/case/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4-preview.scad
|
||||||
|
openscad -o hardware/case/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4-front-review.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
Or render the main file directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad -D 'part="body"' -o hardware/case/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="lid"' -o hardware/case/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="preview"' -o hardware/case/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="front_review"' -o hardware/case/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
`camera-node-case-v4-preview.stl` is the seated fit-check assembly. `camera-node-case-v4-front-review.stl` is a non-print review layout with the body and front panel separated/angled so the OLED, LED, rocker, USB connector, and lower-slot cutouts are obvious in a slicer.
|
||||||
|
|
||||||
### Print Settings
|
### Print Settings
|
||||||
- **Material:** PETG preferred for heat/outdoor use and clamp flex
|
|
||||||
- **Layer:** 0.2mm | **Infill:** 20% gyroid minimum; 35%+ recommended for clamp
|
- **Material:** PETG preferred for heat/outdoor use and strap-tab durability.
|
||||||
- **Supports:** Likely yes for clamp ears / dovetail overhangs depending on slicer orientation
|
- **Layer:** 0.2 mm typical.
|
||||||
- **Post-processing:** M3x8mm screws for lid (4x), one M3 screw + M3 nut for clamp tightening
|
- **Infill:** 20% gyroid minimum; 30%+ recommended around rear vertical zip-tie bracket anchor pads/bridges.
|
||||||
|
- **Supports:** likely minimal/none depending on orientation; verify the rear lateral side-feed openings remain open and check USB-C/USB-A port cutouts in slicer.
|
||||||
|
- **Post-processing:** fit 4 lid screws; deburr OLED/LED/switch and IPEX antenna exit cutouts; clear any stringing inside the rear side-feed openings; soften strap-contact edges if the printed radius is too sharp for cloth ties.
|
||||||
|
|
||||||
|
## Expected Status Screen Content
|
||||||
|
|
||||||
|
Preferred 1.3-inch OLED layout/content style:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CAM 03 REC ●
|
||||||
|
BAT 87% LINK OK
|
||||||
|
00:12:34
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested fields:
|
||||||
|
|
||||||
|
- `CAM` / node ID.
|
||||||
|
- `REC` state with a clear recording indicator.
|
||||||
|
- Battery percentage or supply estimate.
|
||||||
|
- `LINK OK` / degraded / disconnected state.
|
||||||
|
- Recording/session timer.
|
||||||
|
|
||||||
## Wiring
|
## Wiring
|
||||||
|
|
||||||
```
|
```text
|
||||||
USB Power Bank
|
USB Power Bank
|
||||||
├── USB-A → Micro-USB cable → ESP32 USB port
|
└── USB-C cable → bottom USB-C female input on Camera Node Case
|
||||||
│ (powers ESP32, shared 5V rail)
|
├── rocker switch → node power rail
|
||||||
│
|
├── PWR LED indicator
|
||||||
├── USB-A → Micro-USB cable → GoPro USB port
|
├── ESP32-C3 Super Mini
|
||||||
│ (power only — no data)
|
├── ESP-01S / ESP8266
|
||||||
│
|
├── 1.3-inch OLED display
|
||||||
└── (ESP8266 powered via ESP32 3.3V pin, or via shared USB)
|
├── RGB status LED
|
||||||
|
└── 5 V passthrough rail → side USB-A female output
|
||||||
|
└── USB cable → GoPro USB port
|
||||||
|
(power only — no data)
|
||||||
|
|
||||||
UART (inside case):
|
UART / control inside case:
|
||||||
ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16)
|
ESP-01S TX ──→ ESP32-C3 RX
|
||||||
ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17)
|
ESP-01S RX ←── ESP32-C3 TX
|
||||||
ESP8266 GND ─────────── ESP32 GND
|
ESP-01S GND ─── ESP32-C3 GND
|
||||||
```
|
```
|
||||||
|
|
||||||
**Power note:** Both boards can be powered from a single USB cable if the ESP32's VIN/5V pin is bridged to the ESP8266's VIN. Alternatively, use a USB Y-splitter cable.
|
**Power note:** exact wiring depends on the regulator/power board used. Confirm OLED voltage, LED current limiting, and whether the rocker switches USB 5 V input or a regulated node rail.
|
||||||
|
|
||||||
## Wi-Fi Topology
|
## Wi-Fi Topology
|
||||||
|
|
||||||
```
|
```text
|
||||||
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP8266 (camera bridge)
|
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP-01S / ESP8266 camera bridge
|
||||||
│
|
│
|
||||||
UART │ (inside case)
|
UART │ (inside case)
|
||||||
│
|
│
|
||||||
Travel Router ──(AP)─────────────────→ ESP32 (MQTT bridge)
|
Travel Router ──(AP)────────────────────→ ESP32-C3 MQTT bridge
|
||||||
(10.60.1.1) │
|
(10.60.1.1) │
|
||||||
│
|
│
|
||||||
MQTT │
|
MQTT │
|
||||||
▼
|
▼
|
||||||
Pi Hub (10.60.1.56)
|
Pi Hub (10.60.1.56)
|
||||||
```
|
```
|
||||||
|
|
||||||
The ESP8266 and GoPro talk over Wi-Fi — **no data cable between them**. The only cable to the GoPro is USB power from the battery pack.
|
The ESP8266/ESP-01S and GoPro talk over Wi-Fi. The only cable to the GoPro is USB power from the case side USB-A passthrough port.
|
||||||
|
|
||||||
## Field Setup
|
## Field Setup
|
||||||
|
|
||||||
1. **Mount GoPro** on tripod/stand
|
1. Mount GoPro on tripod/stand.
|
||||||
2. **Clip case** to tripod leg
|
2. Feed two reusable cloth zip ties / Velcro straps laterally through the long side openings behind the rear vertical brackets.
|
||||||
3. **Connect power bank** via USB to case + GoPro
|
3. Strap the case to a tripod/stand leg; use both strap paths to resist rotation.
|
||||||
4. **Power on** — ESP32 auto-connects to travel router, ESP8266 auto-connects to GoPro
|
4. Connect the power bank to the case bottom USB-C input; connect the GoPro USB power cable to the case side USB-A passthrough output.
|
||||||
5. **Monitor** from `http://10.60.1.56:8080`
|
5. Toggle rocker switch on.
|
||||||
|
6. Verify PWR LED, RGB status LED, and OLED status: camera ID, REC state, battery, link, timer.
|
||||||
|
7. Monitor from `http://10.60.1.56:8080`.
|
||||||
|
|
||||||
## Case Dimensions
|
## Case Dimensions
|
||||||
|
|
||||||
| | W × D × H (mm) |
|
Prototype v4 nominal CAD dimensions:
|
||||||
|
|
||||||
|
| Part / feature | W × D × H (mm) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Case body external | ~56.8 × 38.2 × 19.0 |
|
| Case shell external | ~56 × 36 × 82 |
|
||||||
| Lid external | ~56.8 × 32.8 × 4.0 |
|
| Case with rear zip-tie brackets | ~56 × 41.2 × 82 |
|
||||||
| Tripod clamp | ~43.0 × 56.9 × 16.0 |
|
| Front recessed lid | visible panel ~48 × 2 × 74; total with locating lip ~48 × 3 × 74 |
|
||||||
| Clamp pole fit | Nominal 35mm; smaller poles TBD / may need inserts |
|
| OLED visible window assumption | ~31 × 16 |
|
||||||
| Total weight | TBD after prototype print |
|
| Rocker cutout assumption | ~13 × 19 |
|
||||||
|
| Bottom USB-C power input cutout | ~10.5 × 4.5 opening with ~18 × 10 shallow underside recess |
|
||||||
|
| Right-side USB-A passthrough cutout | ~16 Y/front-back × 8 Z opening through side wall |
|
||||||
|
| Rear vertical zip-tie brackets | two external side-feed brackets, each ~8.5 mm wide × 50 mm tall; lateral tunnel has ~40 mm vertical side-window length × ~3.8 mm strap-thickness clearance behind the raised bridge |
|
||||||
|
| Board clearance targets | ESP32-C3 22.5 × 18 mm + ESP-01S 24.7 × 14.3 × 12 mm plus wiring/service clearance |
|
||||||
|
|
||||||
|
These dimensions are placeholders for the first CAD prototype. Measure the actual OLED module, rocker switch, LEDs, screws, USB-C/USB-A connector flanges and body depths, USB cable bend radius, and strap width/thickness before committing to production prints.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 body.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_body_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 front-facing review layout.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_front_review_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 front service lid/status panel.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_lid_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 assembly preview.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_preview_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
// RemoteRig camera node case v4
|
||||||
|
// Upright appliance-style OpenSCAD prototype for a strap-mounted camera node.
|
||||||
|
// Units: millimeters. Coordinate system: X=width, Y=depth/front-back, Z=height.
|
||||||
|
// Front/service lid is on the -Y face. Rear side-feed zip-tie brackets are on the +Y face.
|
||||||
|
//
|
||||||
|
// v4 visual direction: tall/upright appliance/control box matching the original
|
||||||
|
// reference image, replacing the rejected wide, low generic electronics box.
|
||||||
|
// Nominal body: 56 W x 36 D x 82 H mm; with low rear zip-tie loops ~41 D.
|
||||||
|
//
|
||||||
|
// Prototype assumptions to confirm against purchased parts:
|
||||||
|
// - 1.3 inch OLED module/window opening: 31 x 16 mm visible window, 37 x 22 mm panel recess.
|
||||||
|
// - Small rocker switch cutout: 13 x 19 mm rectangular snap-in opening.
|
||||||
|
// - LEDs: two 3 mm panel LEDs (PWR + RGB STAT) with 3.2 mm holes.
|
||||||
|
// - Boards: ESP32-C3 Super Mini 22.5 x 18 mm, ESP-01S 24.7 x 14.3 x 12 mm.
|
||||||
|
// - USB-C bottom power inlet and side USB-A passthrough are panel/breakout placeholders;
|
||||||
|
// measure purchased connector flanges/bodies before production prints.
|
||||||
|
// - Left-side IPEX/U.FL antenna pigtail connector/lead hole is a prototype 5.0 mm
|
||||||
|
// circular through-wall clearance; measure the final bulkhead/lead before production.
|
||||||
|
|
||||||
|
$fn = 56;
|
||||||
|
|
||||||
|
// ----- Main enclosure parameters -----
|
||||||
|
case_w = 56; // upright appliance-style external width
|
||||||
|
case_d = 36; // depth for module stack + wiring clearance
|
||||||
|
case_h = 82; // tall vertical appliance-style height
|
||||||
|
wall = 2.2;
|
||||||
|
corner_r = 4.0;
|
||||||
|
front_recess_d = 2.0; // lid sits in this front pocket, nominally flush
|
||||||
|
lid_clearance = 0.35;
|
||||||
|
lid_w = case_w - 8; // nearly full-height/front-width inset panel
|
||||||
|
lid_h = case_h - 8;
|
||||||
|
lid_t = 2.0;
|
||||||
|
lid_lip_t = 1.2; // locating lip protrudes inside service opening
|
||||||
|
service_opening_w = lid_w - 10.0;
|
||||||
|
service_opening_h = lid_h - 16.0;
|
||||||
|
|
||||||
|
// Hardware
|
||||||
|
screw_d = 2.4; // M2 self-tapping / pilot; confirm hardware
|
||||||
|
screw_head_d = 4.6;
|
||||||
|
boss_d = 6.0;
|
||||||
|
boss_len = 8.0;
|
||||||
|
|
||||||
|
// Front panel components
|
||||||
|
oled_window_w = 31.0;
|
||||||
|
oled_window_h = 16.0;
|
||||||
|
oled_bezel_w = 37.0; // shallow recessed visual outline around window
|
||||||
|
oled_bezel_h = 22.0;
|
||||||
|
oled_z = 53.0; // upper third, clear of top screw counterbores
|
||||||
|
led_hole_d = 3.2; // 3 mm LED clearance
|
||||||
|
rocker_w = 13.0; // prototype cutout; measure purchased rocker
|
||||||
|
rocker_h = 19.0;
|
||||||
|
front_slot_w = 34.0; // two long rounded horizontal slots near lower front
|
||||||
|
front_slot_h = 3.2;
|
||||||
|
|
||||||
|
// Rear reusable cloth zip-tie / Velcro pass-through brackets.
|
||||||
|
// Two visibly vertical external brackets sit left/right of center.
|
||||||
|
// The strap path is a lateral X-direction tunnel between the sealed rear wall
|
||||||
|
// and raised bridge face; long side windows stay open for feeding from either side.
|
||||||
|
rear_loop_x = 13.0;
|
||||||
|
rear_loop_w = 8.5; // outside bracket width in X
|
||||||
|
rear_loop_h = 50.0; // outside bracket height in Z
|
||||||
|
rear_loop_z = case_h/2;
|
||||||
|
rear_loop_gap_y = 3.8; // usable strap-thickness clearance behind raised bridge
|
||||||
|
rear_loop_face_t = 1.4; // low-profile outer bridge skin
|
||||||
|
rear_loop_y = rear_loop_gap_y + rear_loop_face_t;
|
||||||
|
rear_loop_anchor_h = 5.0; // top/bottom weld pads; side window remains long vertically
|
||||||
|
rear_loop_side_window_h = rear_loop_h - 2*rear_loop_anchor_h;
|
||||||
|
|
||||||
|
// USB power connector placeholder cutouts
|
||||||
|
usb_c_cutout_w = 10.5; // bottom USB-C female inlet visible opening, X width
|
||||||
|
usb_c_cutout_d = 4.5; // bottom USB-C female inlet visible opening, Y/front-back
|
||||||
|
usb_c_recess_w = 18.0; // shallow underside panel-mount/breakout recess
|
||||||
|
usb_c_recess_d = 10.0;
|
||||||
|
usb_c_y = -7.5; // close to front/service side but clear of screw bosses/lower slots
|
||||||
|
usb_a_cutout_d = 16.0; // side USB-A female opening, Y/front-back dimension
|
||||||
|
usb_a_cutout_h = 8.0; // side USB-A female opening, Z height
|
||||||
|
usb_a_z = 26.0; // mid/lower right side, clear of front lid screws/strap bridges
|
||||||
|
usb_a_y = 2.0;
|
||||||
|
|
||||||
|
// Left-side antenna lead / IPEX-U.FL pigtail connector placeholder.
|
||||||
|
// Opposite the right-side USB-A port and cut fully through the left wall into the cavity.
|
||||||
|
ipex_hole_d = 5.0; // prototype circular clearance; measure final pigtail/bulkhead
|
||||||
|
ipex_recess_d = 8.5; // shallow exterior visual/seat recess, not retention geometry
|
||||||
|
ipex_recess_depth = 0.9;
|
||||||
|
ipex_z = usb_a_z;
|
||||||
|
ipex_y = usb_a_y;
|
||||||
|
|
||||||
|
// ----- Utility geometry -----
|
||||||
|
module rounded_box(size=[10,10,10], r=2, center_xy=true) {
|
||||||
|
// Rounded in XY, straight in Z.
|
||||||
|
linear_extrude(height=size[2])
|
||||||
|
offset(r=r)
|
||||||
|
square([size[0]-2*r, size[1]-2*r], center=center_xy);
|
||||||
|
}
|
||||||
|
|
||||||
|
module xz_rounded_prism(w, d, h, r=2) {
|
||||||
|
// Rounded rectangle in the visible X/Z plane, extruded through Y.
|
||||||
|
rotate([-90,0,0])
|
||||||
|
linear_extrude(height=d, center=true)
|
||||||
|
offset(r=r)
|
||||||
|
square([w-2*r, h-2*r], center=true);
|
||||||
|
}
|
||||||
|
|
||||||
|
module yz_rounded_prism(d, x, h, r=2) {
|
||||||
|
// Rounded rectangle in the visible Y/Z plane, extruded through X.
|
||||||
|
// First argument maps to global Y, third argument maps to global Z.
|
||||||
|
rotate([0,90,0])
|
||||||
|
linear_extrude(height=x, center=true)
|
||||||
|
offset(r=r)
|
||||||
|
square([h-2*r, d-2*r], center=true);
|
||||||
|
}
|
||||||
|
|
||||||
|
module y_cylinder(d, h, center=true) {
|
||||||
|
rotate([90,0,0]) cylinder(d=d, h=h, center=center);
|
||||||
|
}
|
||||||
|
|
||||||
|
module x_cylinder(d, h, center=true) {
|
||||||
|
rotate([0,90,0]) cylinder(d=d, h=h, center=center);
|
||||||
|
}
|
||||||
|
|
||||||
|
module screw_boss(x, z) {
|
||||||
|
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
|
||||||
|
difference() {
|
||||||
|
y_cylinder(d=boss_d, h=boss_len);
|
||||||
|
y_cylinder(d=screw_d, h=boss_len + 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module rear_zip_tie_loop(xc) {
|
||||||
|
// Vertical external belt-loop bracket for reusable cloth zip ties/Velcro.
|
||||||
|
// The bracket silhouette remains vertical, but the real strap tunnel runs
|
||||||
|
// laterally in X through the long side windows, behind the raised bridge face.
|
||||||
|
// Top/bottom pads weld the bridge to the shell; no cut reaches the rear wall.
|
||||||
|
loop_overlap_y = 0.75;
|
||||||
|
pad_r = 1.15;
|
||||||
|
bridge_y_center = case_d/2 + rear_loop_gap_y + rear_loop_face_t/2;
|
||||||
|
pad_y_center = case_d/2 + rear_loop_y/2 - loop_overlap_y;
|
||||||
|
pad_z_offset = rear_loop_h/2 - rear_loop_anchor_h/2;
|
||||||
|
|
||||||
|
union() {
|
||||||
|
// Raised vertical bridge face: visually preserves the requested vertical
|
||||||
|
// rear brackets while spanning the side-feed tunnel externally.
|
||||||
|
translate([xc, bridge_y_center, rear_loop_z])
|
||||||
|
xz_rounded_prism(rear_loop_w, rear_loop_face_t, rear_loop_h, r=1.6);
|
||||||
|
|
||||||
|
// Top and bottom anchor pads close the old top-to-bottom feed direction
|
||||||
|
// and tie the raised face back into the rear wall without opening the case.
|
||||||
|
for (zoff = [-pad_z_offset, pad_z_offset])
|
||||||
|
translate([xc, pad_y_center, rear_loop_z + zoff])
|
||||||
|
xz_rounded_prism(rear_loop_w, rear_loop_y, rear_loop_anchor_h, r=pad_r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Printable body -----
|
||||||
|
module camera_node_body_v4() {
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
// Upright outer shell with softened appliance-like corners.
|
||||||
|
rounded_box([case_w, case_d, case_h], r=corner_r);
|
||||||
|
|
||||||
|
// Rear cloth zip-tie / Velcro side-feed brackets kept flat/quiet.
|
||||||
|
rear_zip_tie_loop(-rear_loop_x);
|
||||||
|
rear_zip_tie_loop( rear_loop_x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-height front recessed lid pocket, like the green reference panel.
|
||||||
|
translate([0, -case_d/2 + front_recess_d/2, case_h/2])
|
||||||
|
cube([lid_w + lid_clearance, front_recess_d + 0.4, lid_h + lid_clearance], center=true);
|
||||||
|
|
||||||
|
// Through service opening behind the lid, leaving a strong inset frame.
|
||||||
|
service_depth = front_recess_d + wall + 2.0;
|
||||||
|
translate([0, -case_d/2 + service_depth/2, case_h/2])
|
||||||
|
xz_rounded_prism(service_opening_w, service_depth + 0.4, service_opening_h, r=2.0);
|
||||||
|
|
||||||
|
// Interior electronics cavity: ESP32-C3 Super Mini + ESP-01S plus wiring/service clearance.
|
||||||
|
cavity_d = case_d - front_recess_d - 2*wall;
|
||||||
|
translate([0, -case_d/2 + front_recess_d + wall + cavity_d/2, case_h/2])
|
||||||
|
cube([case_w - 2*wall, cavity_d, case_h - 2*wall], center=true);
|
||||||
|
|
||||||
|
// Bottom USB-C female power inlet: shallow underside recess plus
|
||||||
|
// rounded through-slot for a flush/panel-mount breakout placeholder.
|
||||||
|
translate([0, usb_c_y, -0.35])
|
||||||
|
rounded_box([usb_c_recess_w, usb_c_recess_d, 0.9], r=1.5);
|
||||||
|
translate([0, usb_c_y, -0.2])
|
||||||
|
rounded_box([usb_c_cutout_w, usb_c_cutout_d, wall + 1.2], r=1.6);
|
||||||
|
|
||||||
|
// Right-side USB-A female passthrough power port for the GoPro.
|
||||||
|
translate([case_w/2 - 0.10, usb_a_y, usb_a_z])
|
||||||
|
yz_rounded_prism(usb_a_cutout_d, wall + 2.8, usb_a_cutout_h, r=0.9);
|
||||||
|
|
||||||
|
// Left-side IPEX/U.FL antenna pigtail connector/lead clearance.
|
||||||
|
// Through-hole intentionally extends past the inner wall so it opens to the cavity.
|
||||||
|
translate([-case_w/2 - 0.10, ipex_y, ipex_z])
|
||||||
|
x_cylinder(d=ipex_hole_d, h=wall + 3.0);
|
||||||
|
// Shallow exterior circular recess marks/relieves the antenna exit area.
|
||||||
|
translate([-case_w/2 + ipex_recess_depth/2 - 0.05, ipex_y, ipex_z])
|
||||||
|
x_cylinder(d=ipex_recess_d, h=ipex_recess_depth + 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four protected screw bosses are added after shell hollowing so the
|
||||||
|
// electronics cavity cannot cut away the receiving material.
|
||||||
|
screw_x = lid_w/2 - 5.0;
|
||||||
|
screw_z_low = (case_h - lid_h)/2 + 5.0;
|
||||||
|
screw_z_high = case_h - screw_z_low;
|
||||||
|
screw_boss(-screw_x, screw_z_low);
|
||||||
|
screw_boss( screw_x, screw_z_low);
|
||||||
|
screw_boss(-screw_x, screw_z_high);
|
||||||
|
screw_boss( screw_x, screw_z_high);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final body-level pilot holes cut through the front frame into the protected bosses.
|
||||||
|
screw_x = lid_w/2 - 5.0;
|
||||||
|
screw_z_low = (case_h - lid_h)/2 + 5.0;
|
||||||
|
screw_z_high = case_h - screw_z_low;
|
||||||
|
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high])
|
||||||
|
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
|
||||||
|
y_cylinder(d=screw_d, h=boss_len + front_recess_d + 4.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Printable front service lid / status panel -----
|
||||||
|
module camera_node_lid_v4() {
|
||||||
|
panel_through_d = lid_t + lid_lip_t + 2.4;
|
||||||
|
panel_through_y = 0.25;
|
||||||
|
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
// Visible full-height flush panel; restrained and not a busy slab.
|
||||||
|
rounded_box([lid_w, lid_t, lid_h], r=0.65);
|
||||||
|
|
||||||
|
// Rear locating lip fits inside the large service opening.
|
||||||
|
translate([0, lid_t/2 + lid_lip_t/2 - 0.2, lid_h/2])
|
||||||
|
xz_rounded_prism(service_opening_w - 0.8, lid_lip_t, service_opening_h - 0.8, r=1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OLED window and shallow black-bezel-style recess near the top.
|
||||||
|
translate([0, -lid_t/2 + 0.35, oled_z])
|
||||||
|
xz_rounded_prism(oled_bezel_w, 0.9, oled_bezel_h, r=1.3);
|
||||||
|
translate([0, panel_through_y, oled_z])
|
||||||
|
xz_rounded_prism(oled_window_w, panel_through_d, oled_window_h, r=0.5);
|
||||||
|
|
||||||
|
// Subtle secondary indicators flanking the rocker, below the OLED bezel.
|
||||||
|
translate([-15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
|
||||||
|
translate([ 15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
|
||||||
|
|
||||||
|
// Small rocker lower on the panel, offset away from the OLED, screws, and slots.
|
||||||
|
translate([0, panel_through_y, 33.0])
|
||||||
|
xz_rounded_prism(rocker_w, panel_through_d, rocker_h, r=0.8);
|
||||||
|
|
||||||
|
// Two long rounded horizontal slots near the lower front, matching the reference.
|
||||||
|
translate([0, panel_through_y, 17.0])
|
||||||
|
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
|
||||||
|
translate([0, panel_through_y, 11.0])
|
||||||
|
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
|
||||||
|
|
||||||
|
// Screw clearance/counterbore holes.
|
||||||
|
screw_x = lid_w/2 - 5.0;
|
||||||
|
screw_z_low = 5.0;
|
||||||
|
screw_z_high = lid_h - screw_z_low;
|
||||||
|
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high]) {
|
||||||
|
translate([x, panel_through_y, z]) y_cylinder(d=screw_d + 0.4, h=panel_through_d);
|
||||||
|
translate([x, -lid_t/2 + 0.55, z]) y_cylinder(d=screw_head_d, h=1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Non-print preview assembly -----
|
||||||
|
module camera_node_preview_v4(show_lid=true) {
|
||||||
|
color("lightgray") camera_node_body_v4();
|
||||||
|
if (show_lid)
|
||||||
|
translate([0, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
|
||||||
|
color("gainsboro") camera_node_lid_v4();
|
||||||
|
|
||||||
|
// Dark OLED bezel/window cue for visual review only (not part of exported lid STL when rendering lid).
|
||||||
|
if (show_lid)
|
||||||
|
translate([0, -case_d/2 - 0.08, (case_h - lid_h)/2 + oled_z])
|
||||||
|
color("black") xz_rounded_prism(oled_bezel_w, 0.6, oled_bezel_h, r=1.3);
|
||||||
|
|
||||||
|
// Internal board/connector volume guides (not printed): ESP modules and USB connector envelopes.
|
||||||
|
color([0,0.45,0,0.35]) translate([-9, -1, 26]) cube([22.5, 18, 4], center=true);
|
||||||
|
color([0,0.2,0.8,0.35]) translate([9, -1, 45]) cube([24.7, 14.3, 12], center=true);
|
||||||
|
color([0.1,0.1,0.1,0.35]) translate([0, usb_c_y, 3.8]) cube([16, 9, 5], center=true);
|
||||||
|
color([0.1,0.1,0.1,0.35]) translate([case_w/2 - 5.5, usb_a_y, usb_a_z]) cube([11, usb_a_cutout_d + 2, usb_a_cutout_h + 2], center=true);
|
||||||
|
color([0.9,0.7,0.1,0.45]) translate([-case_w/2 - 1.8, ipex_y, ipex_z]) x_cylinder(d=ipex_hole_d, h=8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-print review layout: separates the body and front lid while keeping both
|
||||||
|
// front faces oriented toward -Y. Use this STL when checking that the screen,
|
||||||
|
// LED, rocker, USB connector, and lower-slot cutouts are visible in a slicer.
|
||||||
|
module camera_node_front_review_v4() {
|
||||||
|
translate([-34, 0, 0]) rotate([0,0,-18]) color("lightgray") camera_node_body_v4();
|
||||||
|
translate([34, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
|
||||||
|
color("gainsboro") camera_node_lid_v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select part to render from OpenSCAD CLI with: -D 'part="body"'
|
||||||
|
part = "preview"; // "body", "lid", "preview", or "front_review"
|
||||||
|
|
||||||
|
if (part == "body") {
|
||||||
|
camera_node_body_v4();
|
||||||
|
} else if (part == "lid") {
|
||||||
|
camera_node_lid_v4();
|
||||||
|
} else if (part == "front_review") {
|
||||||
|
camera_node_front_review_v4();
|
||||||
|
} else {
|
||||||
|
camera_node_preview_v4();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-17
@@ -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()
|
||||||
@@ -203,13 +200,21 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"camera": c,
|
"camera": c,
|
||||||
"last_status": sl,
|
"last_status": sl,
|
||||||
"history": history,
|
"history": history,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation.
|
||||||
|
func isUniqueConstraintErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||||
|
}
|
||||||
|
|
||||||
// respondJSON writes a JSON response with the given status code.
|
// 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",
|
||||||
|
|||||||
+28
-17
@@ -2,7 +2,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -14,24 +13,31 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
BatteryPct *int `json:"battery_pct"`
|
BatteryPct *int `json:"battery_pct"`
|
||||||
VideoRemainingSec *int `json:"video_remaining_sec"`
|
VideoRemainingSec *int `json:"video_remaining_sec"`
|
||||||
Recording bool `json:"recording"`
|
Recording bool `json:"recording"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
FPS int `json:"fps"`
|
FPS int `json:"fps"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
-30
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user