CUB-176: Central hub frontend — camera grid, start/stop controls, history viewer #15

Merged
overseer merged 2 commits from agent/rex/CUB-176-central-hub-frontend into dev 2026-05-28 07:22:37 -04:00
22 changed files with 210504 additions and 165 deletions
Showing only changes of commit a31dc62a24 - Show all commits
+508
View File
@@ -0,0 +1,508 @@
# Camera Auto-Discovery and Registration Flow — Design Document
> **Status:** Draft | **CUB:** 229 | **Date:** 2026-05-23
> **Depends on:** MQTT_CONTRACT.md v1.0.0 | **Affects:** CUB-189 (POST /cameras), CUB-232 (MQTT subscriber)
---
## 1. Overview
When a new ESP32 camera node powers on and connects to the travel router, it must self-register with the RemoteRig hub without any manual configuration. This document defines the auto-discovery protocol, message schemas, database extensions, error handling, and retry behavior.
### Design Goals
1. **Zero-touch provisioning** — ESP32 node registers itself on first MQTT connect; no dashboard interaction required
2. **Re-registration safe** — same node rejoining after a reboot or network blip is recognized and re-associated, not duplicated
3. **Idempotent** — replaying an announce due to MQTT retain or offline buffering does not create duplicate cameras
4. **Observable** — the dashboard receives real-time SSE events when a camera appears or reconnects
5. **Backward compatible** — existing announce format (`MQTT_CONTRACT.md`) is enhanced, not replaced
---
## 2. ESP32 Announce Message (Registration Request)
### Topic
```
remoterig/cameras/+/announce
```
**Direction:** ESP32 → Hub | **QoS:** 2 | **Retain:** true
Published once on ESP32 first boot (or factory reset). Retained so the hub sees it even if it restarts after the ESP32 came online.
### JSON Schema
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CameraAnnounce",
"type": "object",
"required": ["mac_address", "firmware_version", "capabilities"],
"properties": {
"mac_address": {
"type": "string",
"pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$",
"description": "ESP32 Wi-Fi station MAC address — the stable, globally unique hardware identifier"
},
"firmware_version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Semver of the ESP32 firmware (e.g. 0.2.0)"
},
"capabilities": {
"type": "array",
"items": { "type": "string", "enum": ["start_stop", "status", "reboot", "heartbeat"] },
"minItems": 1,
"description": "Supported feature flags. Minimal: [\"status\"]. Full: [\"start_stop\", \"status\", \"reboot\", \"heartbeat\"]"
},
"friendly_name": {
"type": "string",
"maxLength": 64,
"description": "Default human-readable name (e.g. 'ESP32-AA-BB-CC'). If omitted, hub generates one from the MAC."
},
"device_type": {
"type": "string",
"enum": ["esp32-gopro", "esp32-generic"],
"default": "esp32-gopro",
"description": "Device class for future multi-type support"
},
"mqtt_client_id": {
"type": "string",
"maxLength": 64,
"description": "The MQTT client ID the ESP32 connected with (diagnostic)"
},
"sdk_version": {
"type": "string",
"description": "ESP-IDF or Arduino SDK version (diagnostic)"
}
}
}
```
### Example — Minimal
```json
{
"mac_address": "AA:BB:CC:DD:EE:FF",
"firmware_version": "0.1.0",
"capabilities": ["status", "heartbeat"]
}
```
### Example — Full
```json
{
"mac_address": "AA:BB:CC:DD:EE:FF",
"firmware_version": "0.2.0",
"capabilities": ["start_stop", "status", "reboot", "heartbeat"],
"friendly_name": "GoPro Hero3 #1",
"device_type": "esp32-gopro",
"mqtt_client_id": "remoterig-ddeeff",
"sdk_version": "ESP-IDF v5.1.4"
}
```
### MAC Address as Identity
The ESP32's Wi-Fi station MAC is the only stable, globally unique identifier available on a closed network (no cloud, no serial number burned at factory). It is:
- **Globally unique** — OUI-assigned by Espressif
- **Immutable** — persists across firmware flashes and reboots
- **Available before MQTT connect** — no dependency on hub-assigned ID
The hub maps `mac_address → camera_id`. The `camera_id` (e.g. `cam-001`) is a short, human-friendly alias assigned at registration time.
---
## 3. Hub Response Protocol
When the hub processes an announce, it MUST publish a response so the ESP32 knows its registration outcome. The response goes to the **command topic** for the assigned camera.
### Response Topic
```
remoterig/cameras/<camera_id>/command
```
Direction: Hub → ESP32 | QoS: 2 | Retain: false
### Response Schema
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RegistrationResponse",
"type": "object",
"required": ["command", "request_id"],
"properties": {
"command": {
"type": "string",
"enum": ["registered", "registration_error"],
"description": "Outcome of the registration request"
},
"request_id": {
"type": "string",
"description": "Echo of the announce message's MAC + timestamp hash for correlation"
},
"camera_id": {
"type": "string",
"pattern": "^cam-\\d{3}$",
"description": "Assigned camera ID (present on success only)"
},
"error_code": {
"type": "string",
"enum": ["INVALID_MAC", "CAPABILITY_REQUIRED", "DB_WRITE_FAILED", "RATE_LIMITED"],
"description": "Machine-readable error code (present on failure only)"
},
"error_message": {
"type": "string",
"description": "Human-readable error description (present on failure only)"
},
"retry_after_sec": {
"type": "integer",
"minimum": 5,
"description": "Suggested retry delay in seconds (present on failure only)"
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 — hub clock time of the response"
}
}
}
```
### Success Response Example
```json
{
"command": "registered",
"request_id": "req-AABBCCDDEEFF-1684771200",
"camera_id": "cam-004",
"timestamp": "2026-05-23T14:30:00Z"
}
```
### Error Responses
| error_code | Meaning | retry_after_sec | ESP32 action |
|---|---|---|---|
| `INVALID_MAC` | MAC address absent or malformed | — (fatal) | Log error, halt registration |
| `CAPABILITY_REQUIRED` | No valid capabilities specified | — (fatal) | Log error, halt registration |
| `DB_WRITE_FAILED` | Hub database is unavailable (disk full, etc.) | 60 | Retry after delay |
| `RATE_LIMITED` | Too many registration attempts in a window | 30 | Retry after delay |
Example error response:
```json
{
"command": "registration_error",
"request_id": "req-AABBCCDDEEFF-1684771200",
"error_code": "DB_WRITE_FAILED",
"error_message": "Database write failed: disk I/O error",
"retry_after_sec": 60,
"timestamp": "2026-05-23T14:30:00Z"
}
```
### ESP32 Retry Logic
```
ESP32 publishes announce (QoS 2, retain)
├── Subscribe to remoterig/cameras/+/command (QoS 2)
├── Wait for command = "registered" or "registration_error"
├── Timeout after 30s → retry announce (with exponential backoff)
│ ├── 1st attempt: immediate
│ ├── 2nd attempt: wait 5s
│ ├── 3rd attempt: wait 10s
│ ├── 4th attempt: wait 20s
│ └── 5th+ attempt: wait 30s, repeat every 30s
├── On success (registered): store camera_id in NVS, begin normal status loop
├── On fatal error (INVALID_MAC, CAPABILITY_REQUIRED):
│ Log error, blink LED pattern, do not retry
└── On transient error (DB_WRITE_FAILED, RATE_LIMITED):
Wait retry_after_sec (capped at 120s), then re-publish announce
```
**After successful registration:** On subsequent boots, the ESP32 reads `camera_id` from NVS (non-volatile storage). It does NOT re-publish announce unless:
- `camera_id` is missing from NVS (factory reset / first boot)
- The hub publishes `command: "reregister"` to force re-registration (admin action)
---
## 4. Hub Processing Logic
### Registration Flow
```
Hub receives announce on remoterig/cameras/+/announce
├── 1. VALIDATE: mac_address present? matches pattern? → if no: publish INVALID_MAC error
├── 2. VALIDATE: capabilities non-empty? → if no: publish CAPABILITY_REQUIRED error
├── 3. RATE LIMIT: >5 registrations from same IP/MAC in 60s? → RATE_LIMITED error
├── 4. LOOKUP: SELECT camera_id FROM cameras WHERE mac_address = ?
│ │
│ ├── FOUND → EXISTING CAMERA:
│ │ ├── Update: friendly_name, firmware_version, capabilities, updated_at
│ │ ├── Publish registered response with existing camera_id
│ │ ├── SSE broadcast: "camera_reconnected"
│ │ └── Clear MQTT stale announce (publish empty retained message)
│ │
│ └── NOT FOUND → NEW CAMERA:
│ ├── Generate camera_id: "cam-NNN" (sequential)
│ ├── INSERT into cameras
│ ├── Publish registered response with new camera_id
│ ├── SSE broadcast: "camera_registered"
│ └── Clear MQTT stale announce (publish empty retained message)
└── 5. CLEANUP: Publish zero-byte retained message to announce topic
(prevents stale announces after camera is registered)
```
### Rate Limiting
To protect against buggy firmware or network loops:
| Window | Max Attempts | Action |
|--------|-------------|--------|
| 60 seconds | 5 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 30` |
| 5 minutes | 20 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 60` |
Rate limit state is in-memory only (not persisted). Restarting the hub resets the counters.
---
## 5. Database Schema Changes
### Extended `cameras` Table
```sql
-- Migration: 002_add_camera_registration_fields.sql
ALTER TABLE cameras ADD COLUMN firmware_version TEXT;
ALTER TABLE cameras ADD COLUMN capabilities TEXT NOT NULL DEFAULT '["status"]';
ALTER TABLE cameras ADD COLUMN device_type TEXT NOT NULL DEFAULT 'esp32-gopro';
ALTER TABLE cameras ADD COLUMN registration_status TEXT NOT NULL DEFAULT 'pending'
CHECK(registration_status IN ('pending', 'registered', 'error', 'decommissioned'));
ALTER TABLE cameras ADD COLUMN last_announce_at DATETIME;
ALTER TABLE cameras ADD COLUMN registration_error TEXT;
ALTER TABLE cameras ADD COLUMN mqtt_client_id TEXT;
-- Index for MAC lookups (already exists but confirm)
-- CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
-- Index for registration status filtering
CREATE INDEX IF NOT EXISTS idx_cameras_reg_status ON cameras(registration_status);
-- Index for finding stale registrations (cameras that announced but never sent status)
CREATE INDEX IF NOT EXISTS idx_cameras_last_announce ON cameras(last_announce_at);
```
### Full Table Definition (post-migration)
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `camera_id` | TEXT | PK | Hub-assigned short ID, e.g. `cam-001` |
| `friendly_name` | TEXT | NOT NULL | Human-readable name |
| `mac_address` | TEXT | UNIQUE | ESP32 Wi-Fi station MAC |
| `firmware_version` | TEXT | — | Firmware semver reported by ESP32 |
| `capabilities` | TEXT | NOT NULL, DEFAULT `'["status"]'` | JSON array of strings |
| `device_type` | TEXT | NOT NULL, DEFAULT `'esp32-gopro'` | Device class |
| `registration_status` | TEXT | NOT NULL, DEFAULT `'pending'` | `pending`, `registered`, `error`, `decommissioned` |
| `last_announce_at` | DATETIME | — | Timestamp of most recent announce |
| `registration_error` | TEXT | — | Last registration error message (cleared on success) |
| `mqtt_client_id` | TEXT | — | MQTT client ID from the announce |
| `created_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | First registration timestamp |
| `updated_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | Last update timestamp |
### Go Model Extension
The existing `models.Camera` struct gains:
```go
type Camera struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"`
FirmwareVersion string `json:"firmware_version,omitempty"`
Capabilities []string `json:"capabilities"`
DeviceType string `json:"device_type"`
RegistrationStatus string `json:"registration_status"`
LastAnnounceAt *time.Time `json:"last_announce_at,omitempty"`
RegistrationError string `json:"registration_error,omitempty"`
MqttClientID string `json:"mqtt_client_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
> **Note on `capabilities` storage:** SQLite does not have a native JSON array type. Store as TEXT (JSON-encoded array). Serialize/deserialize in the Go model layer. Migration default is `'["status"]'` — the minimum capability for a useful camera.
---
## 6. Registration Flow Sequence Diagram
```mermaid
sequenceDiagram
participant ESP32
participant Broker as MQTT Broker (Mosquitto)
participant Hub as Go Hub
participant DB as SQLite
participant SSE as SSE Hub
participant UI as Dashboard UI
Note over ESP32: Power on / First boot
ESP32->>ESP32: Read camera_id from NVS
alt camera_id NOT in NVS (first boot or factory reset)
ESP32->>Broker: CONNECT (client_id: remoterig-<mac_last6>)
Broker-->>ESP32: CONNACK
ESP32->>Broker: SUBSCRIBE remoterig/cameras/+/command (QoS 2)
Broker-->>ESP32: SUBACK
ESP32->>Broker: PUBLISH remoterig/cameras/announce (QoS 2, retain)
Note over ESP32,Broker: {mac_address, firmware_version, capabilities, ...}
Broker->>Hub: Forward announce
Hub->>Hub: Validate: MAC present? capabilities non-empty?
alt Validation fails
Hub->>Broker: PUBLISH command {command: "registration_error", error_code: "INVALID_MAC"}
Broker->>ESP32: Forward error
Note over ESP32: Log error, halt (fatal)
else Validation passes
Hub->>Hub: Rate limit check
alt Rate limited
Hub->>Broker: PUBLISH command {error_code: "RATE_LIMITED", retry_after_sec: 30}
Broker->>ESP32: Forward error
Note over ESP32: Wait retry_after_sec, retry
else Allowed
Hub->>DB: SELECT camera_id WHERE mac_address = ?
alt MAC already registered
DB-->>Hub: camera_id = "cam-002"
Hub->>DB: UPDATE cameras SET firmware_version, capabilities, friendly_name, ...
Hub->>SSE: Broadcast "camera_reconnected"
else New MAC
DB-->>Hub: no rows
Hub->>DB: SELECT MAX(camera_id) → "cam-003"
Hub->>Hub: Generate "cam-004"
Hub->>DB: INSERT INTO cameras (cam-004, ...)
Hub->>SSE: Broadcast "camera_registered"
end
Hub->>Broker: PUBLISH command {command: "registered", camera_id: "cam-004"}
Broker->>ESP32: Forward registration response
Hub->>Broker: PUBLISH announce (zero-byte retain) — clear stale announce
SSE-->>UI: camera_registered / camera_reconnected event
UI->>UI: Show new camera card in grid
end
end
else camera_id FOUND in NVS (subsequent boot)
Note over ESP32: Skip announce, proceed to status loop
ESP32->>Broker: PUBLISH status (QoS 1, retain)
Broker->>Hub: Forward status
Hub->>SSE: Broadcast camera_status
SSE-->>UI: Update camera card
end
```
---
## 7. Reconnection vs. Registration
It is critical to distinguish two scenarios:
### Scenario A: Reconnection (camera was previously registered)
```
ESP32 boots → reads camera_id from NVS → publishes status on remoterig/cameras/<id>/status
→ Hub sees status on a known camera_id → updates online flag → SSE broadcast
```
**No announce published.** The camera already has its identity.
### Scenario B: First Registration (or factory reset)
```
ESP32 boots → NVS empty → publishes announce → Hub assigns camera_id →
ESP32 stores camera_id in NVS → begins status loop on remoterig/cameras/<id>/status
```
### Scenario C: Hub Restart (ESP32 already running)
```
Hub restarts → subscribes to remoterig/cameras/+/announce →
MQTT broker delivers retained announce messages →
Hub processes each → re-registration safe (MAC already exists → update only)
```
This is why announce messages use `retain: true`. If the hub restarts while ESP32s are running, it re-discovers them from retained announces.
---
## 8. Security Considerations
| Concern | Mitigation |
|---------|-----------|
| Rogue node spoofing a MAC | Closed network (travel router, no internet). MAC filtering at the router level as defense-in-depth (future). |
| Replay attacks | Announce is idempotent — replaying it only updates timestamps, doesn't create duplicates. |
| Denial of registration | Rate limiting (Section 4) prevents flooding. |
| Unauthorized decommission | No `decommission` MQTT command exists. Decommission is admin-only via HTTP API with API key auth. |
---
## 9. Open Questions & Decisions
| Question | Decision | Rationale |
|----------|----------|-----------|
| **MAC as identity?** | ✅ Yes | Only globally unique, immutable ID available on a closed network. |
| **`camera_id` format?** | `cam-NNN` (zero-padded sequential) | Short, sortable, human-friendly. Collision-free with DB sequence. |
| **Re-registration behavior?** | Update existing, don't create duplicate | Announcing with same MAC = reconnection, not new camera. |
| **Retain on announce?** | ✅ Yes, cleared after processing | Allows hub restart recovery. Cleanup prevents stale data. |
| **Response protocol?** | Publish to `command` topic | Reuses existing command channel. ESP32 subscribes before publishing announce. |
| **Capabilities stored?** | ✅ Yes, in `capabilities` column | Enables future feature gating (e.g., "this camera can't start/stop recording"). |
| **`device_type` added?** | ✅ Yes, default `esp32-gopro` | Allows future camera types (e.g., Raspberry Pi CSI, USB webcam). |
| **Dashboard rename after auto-registration?** | ✅ Yes (via existing POST /cameras or settings API in future) | Already called out in MQTT_CONTRACT.md. No new work in this CUB. |
| **NVS key for camera_id?** | `"cam_id"` | Simple, unambiguous. |
---
## 10. Implementation Plan
This design document covers the protocol and schema design. Implementation is tracked in the following sub-issues:
| CUB | Title | Agent | Depends On |
|-----|-------|-------|------------|
| CUB-229 | Design camera auto-discovery and registration flow | Dex | — (this task) |
| CUB-229a | Migration: add registration fields to cameras table | Hex | CUB-229 |
| CUB-229b | Go model update: Camera struct with new fields | Dex | CUB-229a |
| CUB-229c | MQTT subscriber: registration response protocol | Dex | CUB-229b |
| CUB-229d | Rate limiting for announce messages | Dex | CUB-229b |
| CUB-229e | SSE events: camera_registered / camera_reconnected | Dex | CUB-229c |
| CUB-229f | ESP32 firmware: NVS storage + announce on first boot | Pip | CUB-229 |
| CUB-229g | ESP32 firmware: command subscription + registration ACK handling | Pip | CUB-229c |
| CUB-229h | Update MQTT_CONTRACT.md with registration response spec | Dex | CUB-229 |
| CUB-229i | Integration test: camera auto-registration end-to-end | Dex/Pip | CUB-229e, CUB-229g |
---
## 11. References
- [MQTT_CONTRACT.md](../MQTT_CONTRACT.md) — Network topology, topic hierarchy, existing status/heartbeat/command schemas
- [CONTEXT.md](../CONTEXT.md) — RemoteRig tech stack, directory layout, database schema
- [CUB-230 (Offline Buffer & Replay)](https://linear.app/cubecraft-creations/issue/CUB-230) — Related: offline buffering uses same dedup strategy
- [CUB-232 (MQTT Subscriber)](https://linear.app/cubecraft-creations/issue/CUB-232) — The subscriber that will implement this registration logic
- [CUB-189 (POST /cameras)](https://linear.app/cubecraft-creations/issue/CUB-189) — HTTP registration endpoint (may be replaced/supplemented by auto-discovery)
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/cubecraft/remoterig 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
+14
View File
@@ -5,11 +5,13 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 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
View File
@@ -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.
+142 -63
View File
@@ -1,89 +1,158 @@
# 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 | ~3550 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 │
@@ -91,22 +160,32 @@ Travel Router ──(AP)─────────────────→ E
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
+309
View File
@@ -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();
}
+596
View File
@@ -0,0 +1,596 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/cubecraft/remoterig/internal/db"
"github.com/go-chi/chi/v5"
)
// setupTestRouter creates a test router backed by a temp file database so
// pooled connections all see the same data.
func setupTestRouter(t *testing.T) (*db.DB, chi.Router) {
t.Helper()
database, err := db.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
r := chi.NewRouter()
r.Get("/cameras", ListCameras(database))
r.Post("/cameras", RegisterCamera(database))
r.Get("/cameras/{id}", GetCameraDetail(database))
r.Post("/cameras/{id}/start", StartRecording(database))
r.Post("/cameras/{id}/stop", StopRecording(database))
r.Post("/cameras/{id}/status", PushStatus(database))
return database, r
}
func newReq(method, target string, body io.Reader) *http.Request {
return httptest.NewRequest(method, target, body)
}
func assertStatus(t *testing.T, resp *http.Response, expected int) {
t.Helper()
if resp.StatusCode != expected {
t.Errorf("expected status %d, got %d", expected, resp.StatusCode)
}
}
func assertError(t *testing.T, resp *http.Response, expectedStatus int, want string) {
t.Helper()
assertStatus(t, resp, expectedStatus)
body, _ := io.ReadAll(resp.Body)
var e APIError
if err := json.Unmarshal(body, &e); err != nil {
t.Fatalf("failed to unmarshal error: %v (body: %s)", err, string(body))
}
if e.Code != expectedStatus {
t.Errorf("expected code %d, got %d", expectedStatus, e.Code)
}
if !strings.Contains(e.Error, want) {
t.Errorf("expected error containing %q, got %q", want, e.Error)
}
}
func regCamera(t *testing.T, db *db.DB) string {
t.Helper()
w := httptest.NewRecorder()
r := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test Camera"}`))
r.Header.Set("Content-Type", "application/json")
RegisterCamera(db)(w, r)
return "CAM-001"
}
// ==================== GET /cameras ====================
func TestListCameras_Empty(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("GET", "/cameras", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var cameras []map[string]interface{}
json.NewDecoder(w.Result().Body).Decode(&cameras)
if cameras == nil {
t.Error("expected non-nil cameras array, got nil")
}
}
func TestListCameras_WithData(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Push a status
sr := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":85,"recording":false,"mode":"video","resolution":"4K","fps":30,"online":true}`))
sr.Header.Set("Content-Type", "application/json")
sw := httptest.NewRecorder()
r.ServeHTTP(sw, sr)
assertStatus(t, sw.Result(), http.StatusOK)
// Now list
lr := newReq("GET", "/cameras", nil)
lw := httptest.NewRecorder()
r.ServeHTTP(lw, lr)
assertStatus(t, lw.Result(), http.StatusOK)
var cameras []map[string]interface{}
json.NewDecoder(lw.Result().Body).Decode(&cameras)
if len(cameras) != 1 {
t.Errorf("expected 1 camera, got %d", len(cameras))
}
}
// ==================== POST /cameras (Register) ====================
func TestRegisterCamera_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusCreated)
}
func TestRegisterCamera_WithMacAddress(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test","mac_address":"00:11:22:33:44:55"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusCreated)
}
func TestRegisterCamera_MissingBody(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
func TestRegisterCamera_InvalidJSON(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras", strings.NewReader(`{not json`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
func TestRegisterCamera_MissingRequiredFields(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required")
req2 := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001"}`))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name is required")
}
func TestRegisterCamera_FieldTooLong(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
longID := strings.Repeat("x", 65)
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"`+longID+`","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64")
longName := strings.Repeat("y", 129)
req2 := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"`+longName+`"}`))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name must be at most 128")
}
func TestRegisterCamera_WrongContentType(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusUnsupportedMediaType)
}
func TestRegisterCamera_NoContentType(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusCreated)
}
func TestRegisterCamera_BodyTooLarge(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := httptest.NewRequest("POST", "/cameras", bytes.NewReader(make([]byte, 70000)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "too large")
}
func TestRegisterCamera_Duplicate(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusConflict, "camera already registered")
}
// ==================== GET /cameras/{id} ====================
func TestGetCameraDetail_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("GET", "/cameras/CAM-001", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
}
func TestGetCameraDetail_NotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("GET", "/cameras/NONEXISTENT", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
func TestGetCameraDetail_BadID(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("GET", "/cameras/"+strings.Repeat("x", 65), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64")
}
// ==================== POST /cameras/{id}/start ====================
func TestStartRecording_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/start", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var resp map[string]string
json.NewDecoder(w.Result().Body).Decode(&resp)
if resp["status"] != "recording_started" {
t.Errorf("expected recording_started, got %q", resp["status"])
}
}
func TestStartRecording_CameraNotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras/NONEXISTENT/start", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
func TestStartRecording_MissingID(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras//start", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required")
}
// ==================== POST /cameras/{id}/stop ====================
func TestStopRecording_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Start first
sr := newReq("POST", "/cameras/CAM-001/start", nil)
sw := httptest.NewRecorder()
r.ServeHTTP(sw, sr)
assertStatus(t, sw.Result(), http.StatusOK)
// Now stop
req := newReq("POST", "/cameras/CAM-001/stop", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var resp map[string]string
json.NewDecoder(w.Result().Body).Decode(&resp)
if resp["status"] != "recording_stopped" {
t.Errorf("expected recording_stopped, got %q", resp["status"])
}
}
func TestStopRecording_CameraNotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras/NONEXISTENT/stop", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
// ==================== POST /cameras/{id}/status ====================
func TestPushStatus_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var resp map[string]string
json.NewDecoder(w.Result().Body).Decode(&resp)
if resp["status"] != "accepted" {
t.Errorf("expected accepted, got %q", resp["status"])
}
}
func TestPushStatus_CameraNotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras/NONEXISTENT/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
func TestPushStatus_InvalidJSON(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{bad json`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
func TestPushStatus_InvalidFPS(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":999,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "fps must be between")
}
func TestPushStatus_NegativeFPS(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":-1,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "fps must be between")
}
func TestPushStatus_InvalidBattery(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":150,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between")
}
func TestPushStatus_NegativeBattery(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":-5,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between")
}
func TestPushStatus_ModeTooLong(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"`+strings.Repeat("x", 33)+`","resolution":"1080p","fps":30,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "mode must be at most")
}
func TestPushStatus_MissingBody(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
// ==================== Error Response Format ====================
func TestErrorResponseFormat_Consistent(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
checks := []struct {
method, target, body string
}{
{"GET", "/cameras/NONEXISTENT", ""},
{"POST", "/cameras", "bad json"},
{"POST", "/cameras/NONEXISTENT/start", ""},
{"POST", "/cameras/NONEXISTENT/status", "bad json"},
}
for _, c := range checks {
var rd io.Reader
if c.body != "" {
rd = strings.NewReader(c.body)
}
req := newReq(c.method, c.target, rd)
if c.body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var errResp map[string]interface{}
json.NewDecoder(w.Result().Body).Decode(&errResp)
if _, ok := errResp["error"]; !ok {
t.Errorf("%s %s: missing 'error' key: %v", c.method, c.target, errResp)
}
if _, ok := errResp["code"]; !ok {
t.Errorf("%s %s: missing 'code' key: %v", c.method, c.target, errResp)
}
}
}
// ==================== SQL Injection ====================
func TestSQLInjection_CameraID(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Chi URL params are extracted after routing, so injection attempts will
// be treated as camera_ids and fail validation (too long) or return 404.
// Use URL encoding for special characters to avoid httptest panics.
paths := []string{
"/cameras/CAM-001%27+DROP+TABLE+cameras--",
"/cameras/1+UNION+SELECT+NULL--",
"/cameras/%27+OR+%27%27%3D%27",
}
for _, path := range paths {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
code := w.Result().StatusCode
if code != http.StatusNotFound && code != http.StatusBadRequest {
t.Errorf("unexpected status %d for injection path %s", code, path)
}
}
// Verify tables still exist
req := httptest.NewRequest("GET", "/cameras", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
}
// ==================== Recording Lifecycle ====================
func TestRecordingLifecycle(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Start
r1 := newReq("POST", "/cameras/CAM-001/start", nil)
w1 := httptest.NewRecorder()
r.ServeHTTP(w1, r1)
assertStatus(t, w1.Result(), http.StatusOK)
// Stop
r2 := newReq("POST", "/cameras/CAM-001/stop", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, r2)
assertStatus(t, w2.Result(), http.StatusOK)
// Start again
r3 := newReq("POST", "/cameras/CAM-001/start", nil)
w3 := httptest.NewRecorder()
r.ServeHTTP(w3, r3)
assertStatus(t, w3.Result(), http.StatusOK)
}
// ==================== Benchmark ====================
func BenchmarkListCameras(b *testing.B) {
db2, _ := db.Open(b.TempDir() + "/bench.db")
defer db2.Close()
for i := 0; i < 10; i++ {
id := string(rune('A'+i)) + "-CAM"
h := RegisterCamera(db2)
body := `{"camera_id":"` + id + `","friendly_name":"Test ` + string(rune('A'+i)) + `"}`
req := newReq("POST", "/cameras", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h(w, req)
}
jh := ListCameras(db2)
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := newReq("GET", "/cameras", nil)
w := httptest.NewRecorder()
jh(w, req)
}
}
+21 -16
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/cubecraft/remoterig/internal/db" "github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/pkg/models" "github.com/cubecraft/remoterig/pkg/models"
@@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
`) `)
if err != nil { if err != nil {
log.Printf("Error querying cameras: %v", err) log.Printf("Error querying cameras: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
defer rows.Close() defer rows.Close()
@@ -64,7 +65,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
log.Printf("Error iterating camera rows: %v", err) log.Printf("Error iterating camera rows: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -84,13 +85,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
FriendlyName string `json:"friendly_name"` FriendlyName string `json:"friendly_name"`
MacAddress *string `json:"mac_address,omitempty"` MacAddress *string `json:"mac_address,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSONBody(w, r, &req) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
if req.CameraID == "" || req.FriendlyName == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
return return
} }
@@ -99,12 +97,12 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, req.CameraID, req.FriendlyName, req.MacAddress) `, req.CameraID, req.FriendlyName, req.MacAddress)
if err != nil { if err != nil {
if err.Error() == "UNIQUE constraint failed: cameras.mac_address" { if isUniqueConstraintErr(err) {
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"}) respondError(w, http.StatusConflict, "camera already registered", err.Error())
return return
} }
log.Printf("Error registering camera: %v", err) log.Printf("Error registering camera: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -124,8 +122,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
func GetCameraDetail(database *db.DB) http.HandlerFunc { func GetCameraDetail(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id") cameraID := chi.URLParam(r, "id")
if cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -139,12 +136,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
&c.CreatedAt, &c.UpdatedAt, &c.CreatedAt, &c.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"}) respondError(w, http.StatusNotFound, "camera not found", err.Error())
return return
} }
if err != nil { if err != nil {
log.Printf("Error querying camera: %v", err) log.Printf("Error querying camera: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -165,7 +162,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
) )
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
log.Printf("Error querying latest status: %v", err) log.Printf("Error querying latest status: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -180,7 +177,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error querying history: %v", err) log.Printf("Error querying history: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
defer historyRows.Close() defer historyRows.Close()
@@ -210,6 +207,14 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
} }
} }
// isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation.
func isUniqueConstraintErr(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "UNIQUE constraint failed")
}
// respondJSON writes a JSON response with the given status code. // respondJSON writes a JSON response with the given status code.
func respondJSON(w http.ResponseWriter, status int, data interface{}) { func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+116
View File
@@ -0,0 +1,116 @@
// Package api provides HTTP handlers for camera operations.
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// maxRequestBody is the maximum accepted JSON body size (64KB).
const maxRequestBody = 64 * 1024
// APIError represents a structured API error response.
type APIError struct {
Error string `json:"error"`
Code int `json:"code"`
Details string `json:"details,omitempty"`
}
// validationConstraints defines field-level validation limits.
const (
maxCameraIDLen = 64
maxFriendlyNameLen = 128
maxModeLen = 32
maxResolutionLen = 32
minFPS = 0
maxFPS = 240
)
// respondError writes a structured JSON error response.
func respondError(w http.ResponseWriter, status int, msg string, details ...string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
e := APIError{
Error: msg,
Code: status,
}
if len(details) > 0 {
e.Details = details[0]
}
json.NewEncoder(w).Encode(e)
}
// decodeJSONBody reads, limits, and decodes a JSON request body.
// Returns false if validation fails (response already written).
func decodeJSONBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
// Validate Content-Type
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.HasPrefix(ct, "application/json") {
respondError(w, http.StatusUnsupportedMediaType, "content-type must be application/json")
return false
}
// Limit body size
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
body, err := io.ReadAll(r.Body)
if err != nil {
respondError(w, http.StatusBadRequest, "request body too large or unreadable", err.Error())
return false
}
if err := json.Unmarshal(body, v); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body", err.Error())
return false
}
return true
}
// validateCameraID checks that cameraID is present and within max length.
func validateCameraID(w http.ResponseWriter, cameraID string) bool {
if cameraID == "" {
respondError(w, http.StatusBadRequest, "camera_id is required")
return false
}
if len(cameraID) > maxCameraIDLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("camera_id must be at most %d characters", maxCameraIDLen))
return false
}
return true
}
// validateCameraRegistration validates fields for POST /cameras.
func validateCameraRegistration(w http.ResponseWriter, cameraID, friendlyName string) bool {
if !validateCameraID(w, cameraID) {
return false
}
if friendlyName == "" {
respondError(w, http.StatusBadRequest, "friendly_name is required")
return false
}
if len(friendlyName) > maxFriendlyNameLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("friendly_name must be at most %d characters", maxFriendlyNameLen))
return false
}
return true
}
// validateStatusFields validates optional fields on the PushStatus payload.
func validateStatusFields(w http.ResponseWriter, mode, resolution string, fps int) bool {
if mode != "" && len(mode) > maxModeLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("mode must be at most %d characters", maxModeLen))
return false
}
if resolution != "" && len(resolution) > maxResolutionLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("resolution must be at most %d characters", maxResolutionLen))
return false
}
if fps < minFPS || fps > maxFPS {
respondError(w, http.StatusBadRequest, fmt.Sprintf("fps must be between %d and %d", minFPS, maxFPS))
return false
}
return true
}
+22 -14
View File
@@ -13,8 +13,7 @@ import (
func StartRecording(database *db.DB) http.HandlerFunc { func StartRecording(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id") cameraID := chi.URLParam(r, "id")
if cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -22,8 +21,13 @@ func StartRecording(database *db.DB) http.HandlerFunc {
var exists int var exists int
err := database.QueryRowContext(r.Context(), err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 { if err != nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) log.Printf("Error checking camera existence: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -34,12 +38,12 @@ func StartRecording(database *db.DB) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error starting recording: %v", err) log.Printf("Error starting recording: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
rows, _ := result.RowsAffected() rowsAffected, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows) log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
respondJSON(w, http.StatusOK, map[string]string{ respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_started", "status": "recording_started",
@@ -52,8 +56,7 @@ func StartRecording(database *db.DB) http.HandlerFunc {
func StopRecording(database *db.DB) http.HandlerFunc { func StopRecording(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id") cameraID := chi.URLParam(r, "id")
if cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -61,8 +64,13 @@ func StopRecording(database *db.DB) http.HandlerFunc {
var exists int var exists int
err := database.QueryRowContext(r.Context(), err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 { if err != nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) log.Printf("Error checking camera existence: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -73,12 +81,12 @@ func StopRecording(database *db.DB) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error stopping recording: %v", err) log.Printf("Error stopping recording: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
rows, _ := result.RowsAffected() rowsAffected, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows) log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
respondJSON(w, http.StatusOK, map[string]string{ respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_stopped", "status": "recording_stopped",
+19 -8
View File
@@ -2,7 +2,6 @@
package api package api
import ( import (
"encoding/json"
"log" "log"
"net/http" "net/http"
@@ -14,8 +13,7 @@ import (
func PushStatus(database *db.DB) http.HandlerFunc { func PushStatus(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id") cameraID := chi.URLParam(r, "id")
if cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -30,8 +28,16 @@ func PushStatus(database *db.DB) http.HandlerFunc {
RawBatteryPct *float64 `json:"raw_battery_pct"` RawBatteryPct *float64 `json:"raw_battery_pct"`
Timestamp *string `json:"ts"` Timestamp *string `json:"ts"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSONBody(w, r, &req) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return
}
if !validateStatusFields(w, req.Mode, req.Resolution, req.FPS) {
return
}
// Validate battery percentage range if provided
if req.BatteryPct != nil && (*req.BatteryPct < 0 || *req.BatteryPct > 100) {
respondError(w, http.StatusBadRequest, "battery_pct must be between 0 and 100")
return return
} }
@@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc {
var exists int var exists int
err := database.QueryRowContext(r.Context(), err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 { if err != nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) log.Printf("Error checking camera existence: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc {
req.FPS, boolToInt(req.Online), req.RawBatteryPct) req.FPS, boolToInt(req.Online), req.RawBatteryPct)
if err != nil { if err != nil {
log.Printf("Error inserting status log: %v", err) log.Printf("Error inserting status log: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
+73 -28
View File
@@ -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(&currentVersion); 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);
+1 -1
View File
@@ -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"`
} }