1 Commits

Author SHA1 Message Date
Hermes 5100f6be65 CUB-235: add tests for GET /api/v1/cameras/:id endpoint
CI/CD / lint-and-typecheck (pull_request) Successful in 9m27s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
- TestGetCameraDetail_NotFound: returns 404 for missing camera
- TestGetCameraDetail_Success: returns camera + last_status + history
- TestGetCameraDetail_EmptyHistory: camera with no status logs
- TestGetCameraDetail_HistoryLimitedTo100: history capped at 100 entries
- TestGetCameraDetail_MissingID: returns 400 for empty ID param
- TestGetCameraDetail_LastStatusPresent: verifies last_status field
- db_test.go: migration smoke test (documents splitSQL comment bug)
2026-05-23 04:36:18 +00:00
71 changed files with 1730 additions and 212523 deletions
-76
View File
@@ -1,76 +0,0 @@
// Publish the built hub binary to a rolling "dev" release on Gitea.
// Runs in the CI job with only Node available (the runner image has no
// curl/jq/sudo), so this uses Node built-ins + global fetch/FormData/Blob.
//
// Env: TOKEN (Gitea token), SERVER (github.server_url), REPO (owner/repo),
// SHA (github.sha). Expects ./remoterig in the working dir.
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
const { TOKEN, SERVER, REPO, SHA } = process.env;
const BIN = 'remoterig';
// Rolling release tag. NOT "dev" — that would collide with the dev branch
// and make refs ambiguous (git push/checkout dev breaks).
const TAG = 'dev-latest';
const VERSION = SHA.slice(0, 8);
const API = `${SERVER}/api/v1/repos/${REPO}`;
const H = { Authorization: `token ${TOKEN}` };
// The runner's network to Gitea is flaky (ECONNRESET mid-publish leaves a
// half-created release). Retry transient fetch failures so the multi-step
// publish is atomic-enough in practice.
const rfetch = async (url, opts = {}, tries = 4) => {
for (let i = 1; ; i++) {
try {
return await fetch(url, opts);
} catch (e) {
if (i >= tries) throw e;
console.log(`fetch ${url} failed (${e.cause?.code || e.message}); retry ${i}/${tries - 1}`);
await new Promise((r) => setTimeout(r, 1000 * i));
}
}
};
const ok = async (r) => {
if (!r.ok) throw new Error(`${r.status} ${r.url}\n${await r.text()}`);
const t = await r.text();
return t ? JSON.parse(t) : null;
};
const bin = readFileSync(BIN);
const sha256 = createHash('sha256').update(bin).digest('hex');
const files = {
[BIN]: bin,
[`${BIN}.sha256`]: Buffer.from(sha256 + '\n'),
'version.txt': Buffer.from(VERSION + '\n'),
};
// Roll the release forward to this commit: delete the old release + tag.
const existing = await rfetch(`${API}/releases/tags/${TAG}`, { headers: H });
if (existing.ok) {
const rel = await existing.json();
await rfetch(`${API}/releases/${rel.id}`, { method: 'DELETE', headers: H });
}
await rfetch(`${API}/tags/${TAG}`, { method: 'DELETE', headers: H }); // ignore if absent
const rel = await ok(await rfetch(`${API}/releases`, {
method: 'POST',
headers: { ...H, 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: TAG,
target_commitish: SHA,
name: `${TAG} (${VERSION})`,
body: `Rolling dev build ${SHA}`,
prerelease: true,
}),
}));
for (const [name, buf] of Object.entries(files)) {
const fd = new FormData();
fd.append('attachment', new Blob([buf]), name);
await ok(await rfetch(`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, {
method: 'POST', headers: H, body: fd,
}));
console.log(`uploaded ${name}`);
}
console.log(`Published dev release ${VERSION}`);
+30 -20
View File
@@ -7,27 +7,26 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
GO_VERSION: "1.23"
NODE_VERSION: "20"
BINARY_NAME: remoterig BINARY_NAME: remoterig
jobs: jobs:
build: build:
runs-on: go-react runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# go-react has Node but not Go. setup-go installs a statically-linked
# Go that runs fine here; setup-node's dynamically-linked Node does
# not (so Node comes from the image instead).
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.25" go-version: ${{ env.GO_VERSION }}
- name: Toolchain versions - name: Setup Node
run: | uses: actions/setup-node@v4
go version with:
node --version node-version: ${{ env.NODE_VERSION }}
- name: Build React frontend - name: Build React frontend
run: | run: |
@@ -40,14 +39,25 @@ jobs:
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \ go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
-o ${{ env.BINARY_NAME }} ./cmd/server -o ${{ env.BINARY_NAME }} ./cmd/server
# Pull-based deploy: publish the binary to a rolling "dev" release. - name: Upload build artifact
# The Pi polls this release and self-updates (scripts/pi-update.sh); uses: actions/upload-artifact@v4
# the runner never needs to reach the closed RemoteRig network. with:
# Done in Node (runner image has no curl/jq/sudo; Node is present). name: ${{ env.BINARY_NAME }}
- name: Publish to rolling dev release path: ${{ env.BINARY_NAME }}
env: retention-days: 5
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER: ${{ github.server_url }} - name: Trigger deploy workflow
REPO: ${{ github.repository }} if: success()
SHA: ${{ github.sha }} uses: actions/github-script@v7
run: node .gitea/scripts/publish-release.mjs with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: context.repo.repo,
event_type: 'dev-build-success',
client_payload: {
sha: context.sha,
ref: context.ref
}
})
+50 -8
View File
@@ -1,9 +1,4 @@
name: CI name: CI/CD
# Frontend quality gates (lint, typecheck, test, build-check).
# One job on purpose: the runner fetches each action from github.com,
# which is flaky (connection resets), so we check out once instead of
# re-cloning per job. The real hub build + deploy is build-dev.yaml.
on: on:
push: push:
@@ -12,12 +7,59 @@ on:
branches: [dev, main] branches: [dev, main]
jobs: jobs:
quality: lint-and-typecheck:
runs-on: go-react runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
- run: npx tsc --noEmit - run: npx tsc --noEmit
test:
needs: lint-and-typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test - run: npm test
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build - run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy static files
run: |
echo "Deploying to production..."
echo "Deploy target: /var/www/remote-rig/"
echo "Placeholder — configure deploy target before merging to main"
+115
View File
@@ -0,0 +1,115 @@
name: Deploy (Dev)
on:
repository_dispatch:
types:
- dev-build-success
workflow_dispatch:
env:
BINARY_NAME: remoterig
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_PATH: /opt/remoterig/remoterig
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.BINARY_NAME }}
- name: Ensure binary is executable
run: chmod +x ${{ env.BINARY_NAME }}
- name: Write deploy script
run: |
cat > deploy.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BINARY="${1:-remoterig}"
DEPLOY_PATH="${2:-/opt/remoterig/remoterig}"
SERVICE="${3:-remoterig}"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
echo "::backup:: copying current binary"
if [ -f "$DEPLOY_PATH" ]; then
cp "$DEPLOY_PATH" "$BACKUP"
fi
echo "::deploy:: installing new binary"
cp "$BINARY" "$DEPLOY_PATH"
chmod +x "$DEPLOY_PATH"
echo "::restart:: reloading service"
systemctl reload-or-restart "$SERVICE" || systemctl restart "$SERVICE"
echo "::health:: waiting for service"
sleep 3
if systemctl is-active --quiet "$SERVICE"; then
echo "deploy ok — ${SERVICE} is active"
else
echo "::rollback:: service failed, restoring backup"
if [ -f "$BACKUP" ]; then
cp "$BACKUP" "$DEPLOY_PATH"
systemctl restart "$SERVICE"
fi
echo "rolled back to previous binary"
exit 1
fi
echo "::cleanup:: removing old backups (keeping last 3)"
ls -t "${DEPLOY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
SCRIPT
chmod +x deploy.sh
- name: Deploy config.yaml (if present)
run: |
if [ -f config.yaml ]; then
echo "config.yaml found, will deploy alongside binary"
echo "config.yaml" >> deploy-files.txt
else
echo "no config.yaml in repo, skipping"
fi
- name: Deploy to dev server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
source: "${{ env.BINARY_NAME }},deploy.sh,config.yaml"
target: "/tmp/remoterig-deploy"
- name: Execute deploy on dev server
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
set -euo pipefail
cd /tmp/remoterig-deploy
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "remoterig"
if [ -f config.yaml ]; then
echo "::config:: deploying config.yaml"
sudo mkdir -p "$(dirname "${{ env.DEPLOY_PATH }}")"
sudo cp config.yaml "$(dirname "${{ env.DEPLOY_PATH }}")/config.yaml"
fi
rm -rf /tmp/remoterig-deploy
- name: Notify on failure
if: failure()
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
echo "deploy failed for commit ${{ github.sha }} on ${{ github.repository }}" > /tmp/remoterig-deploy-failure.txt
+4 -6
View File
@@ -9,15 +9,13 @@ lerna-debug.log*
# Dependencies # Dependencies
node_modules node_modules
/dist dist
dist-ssr dist-ssr
*.local *.local
# Frontend build output embedded into the Go binary at build time. # Frontend build output (embedded at Go build time)
# Vite writes here (cmd/server/src/dist); ignore the built output but keep # Allow the fallback placeholder so embed always has at least index.html
# the committed index.html placeholder so //go:embed always has a file. !src/dist/index.html
cmd/server/src/dist/*
!cmd/server/src/dist/index.html
# Environment files # Environment files
.env .env
-268
View File
@@ -1,268 +0,0 @@
# RemoteRig — Project Working Context
> **Purpose of this file:** a living, high-signal context + decision log for the
> RemoteRig project. It's the primary onboarding doc for humans and for any LLM
> working on the repo. Keep it updated as decisions are made.
> **Last updated:** 2026-06-05.
>
> Deeper references: `docs/CONTEXT.md` (system architecture detail),
> `docs/MQTT_CONTRACT.md` (MQTT topics/payloads), `docs/design/` (design notes),
> `hardware/README.md` (case/wiring/BOM), `README.md` (hub build/deploy).
---
## 1. What this is
RemoteRig is a multi-camera **GoPro Hero 3 monitoring & control** system for field
recording (large concerts in auditoriums, high-school marching band at stadiums).
Founder: **Joshua / CubeCraft Creations**.
Scope: **monitor status (battery, recording, link) and start/stop multiple GoPros**
over a closed, self-contained travel-router network. **No video flows through the
hub** — footage records locally to each GoPro's SD card. This keeps the hub a
lightweight control plane.
## 2. Architecture
```
GoPro Hero 3 ──Wi-Fi(10.5.5.1)── ESP-01S ──UART(JSON)── XIAO ESP32-C6 ──Wi-Fi/MQTT── Pi hub ── Dashboard
(per camera) (GoPro bridge) (MQTT bridge + OLED/LED) (Mosquitto+Go+SQLite)
```
**Camera node** (one per GoPro), two boards:
- **XIAO ESP32-C6** — main MCU / MQTT bridge. Joins the RemoteRig Wi-Fi, talks MQTT
to the hub, drives the OLED + RGB status LED, reads camera status from the ESP-01S
over UART. Powered from the 5V rail.
- **ESP-01S (ESP8266)** — GoPro Wi-Fi bridge. Joins the GoPro's AP (`10.5.5.1`),
relays status/commands to the XIAO over UART. Powered from its **own 3.3V buck**
(not the XIAO 3V3 pin — Wi-Fi TX spikes ~300 mA).
**Hub** — Raspberry Pi Zero 2 W (hostname `remote-rig-hub`, user `overseer`):
- **Mosquitto** MQTT broker (`:1883`, anonymous, listens on `0.0.0.0`).
- **Go controller** (`remoterig`, systemd service) — MQTT subscriber → SQLite,
REST API + SSE, serves the embedded React dashboard on `:8080`.
- **SQLite** (`/opt/remoterig/remoterig.db`).
- Decided to **stay on the Zero 2 W** (workload is tiny; a Pi 5 only makes sense if
video preview/ingest is ever added — not planned).
## 3. Network
- **RemoteRig travel router**, subnet **`192.168.8.0/24`**, gateway `192.168.8.1`,
has a WAN uplink (internet available — used for the Pi to pull builds).
- **Hub static IP: `192.168.8.56`** (`:1883` MQTT, `:8080` dashboard/API).
- Cameras get DHCP `192.168.8.x`.
- The GoPro AP network (`10.5.5.1`) is separate and only the ESP-01S touches it.
- **History:** the project was originally designed around `10.60.1.0/24`; it was
re-addressed to `192.168.8.0/24` to match the actual travel router (commit
`b0062f1`). Wi-Fi SSID `RemoteRig` (creds in `firmware/data/config.json`).
## 4. Repository & workflow
- **Gitea:** `ssh://sc-gitea@code.cubecraftcreations.com:2288/CubeCraft-Creations/remote-rig`
(web/API on `https://code.cubecraftcreations.com`, private repo).
- **Default/integration branch: `dev`** (work lands here; merges from `main`).
- Layout: `firmware/` (PlatformIO), `cmd/`,`internal/`,`pkg/` (Go hub),
`src/` (React/Vite/TS dashboard), `scripts/` (Pi setup/deploy), `.gitea/workflows/`
(CI), `hardware/` (CAD/wiring), `docs/`.
## 5. Tech stack
| Area | Choice |
|------|--------|
| Camera firmware | PlatformIO / Arduino |
| C6 env | `seeed_xiao_esp32c6`**pioarduino** platform fork, **LittleFS**, U8g2 |
| ESP-01S env | `esp8266-camera` — board `esp01_1m`, flash `dout` |
| Hub | **Go 1.25** (single static binary, `//go:embed` frontend) |
| Dashboard | React + Vite + TypeScript + Tailwind (Vitest) |
| Storage | **SQLite** (not Postgres) |
| Broker | **Mosquitto** |
| CI/CD | **Gitea Actions** (pull-based deploy) |
## 6. Camera node hardware (XIAO ESP32-C6 pin map)
| Pin | Use |
|-----|-----|
| 5V/VIN | rocker → 5V rail |
| D4/SDA, D5/SCL | 1.3" **SH1106** OLED (I2C @ `0x3C`) |
| D0 / D1 / D2 | RGB STAT LED R/G/B (220Ω each), **common-anode** |
| D6 (TX) / D7 (RX) | UART (`Serial1`) to ESP-01S (crossed) |
| D8 / D10 | **reserved** for ESP-01S UART-OTA control (RST / GPIO0) — not driven yet |
| 5V rail (330Ω) | PWR LED (not an MCU pin) |
Canonical wiring: Notion "XIAO ESP32-C6 Pin-to-Pin Wiring Diagram" + `hardware/README.md`.
## 7. Firmware behavior
**XIAO ESP32-C6 (`firmware/src/esp32-mqtt-bridge.cpp`)** — fw `0.4.0`:
- Loads config from LittleFS `/config.json` (Wi-Fi, broker, camera_id, battery cal).
- **Self-assigned camera_id** = device id `rig-<last3 MAC>` (e.g. `rig-86d978`) — see
decision #7. Subscribes `remoterig/cameras/<id>/command`, announces on
`remoterig/cameras/<id>/announce`, publishes `.../status`.
- OLED status panel (CAM id / REC + session timer / BAT / LINK / CAM reachability).
- RGB STAT LED health colors: blue=boot, red=offline, magenta=Wi-Fi-no-hub,
yellow=hub-no-camera, green=healthy.
- Battery calibration: two-point linear (raw offset-57 → %), persisted; `battery_pct`
emitted only when calibrated.
- No-reflash config: `set_camera_config` (MQTT) → forwarded to ESP-01S as `set_config`.
**ESP-01S (`firmware/src/esp8266-camera-bridge.cpp`)**:
- Joins GoPro AP, polls status, relays JSON over UART; `set_config` persists to LittleFS.
- No status LED (GPIO1 is the UART TX).
- ⚠️ **Known bug:** `fetchStatus()` GETs the **shutter** endpoint
(`/bacpac/SH?...&p=%01`) instead of a real status read — would *start recording*
each poll. Needs the GoPro Hero 3 protocol corrected + validated against a real
camera (also verify password/SSID). **Do not point at a live GoPro until fixed.**
**Provisioning:** `firmware/data/config.json` is flashed to the C6's LittleFS via
`pio run -e seeed_xiao_esp32c6 -t uploadfs`. Per maintainer decision the real Wi-Fi
password lives in this tracked file (private repo, low-sensitivity closed network).
## 8. Hub & CI/CD (pull-based deploy)
**Flow:** `push to dev` → Gitea Actions `build-dev.yaml` builds the React frontend
(into `cmd/server/src/dist`, embedded) + cross-compiles the **arm64** Go binary →
publishes a rolling **`dev-latest`** release (binary + `sha256` + `version.txt`) via
a Node script (`.gitea/scripts/publish-release.mjs`). The Pi's
`remoterig-update.timer` runs `scripts/pi-update.sh` every ~5 min → compares
`version.txt`, downloads + checksum-verifies, **atomically replaces** the binary,
restarts, health-checks (rolls back on failure).
**Why pull, not push:** the Pi is on a closed travel-router LAN the CI runner can't
reach; the Pi pulls instead.
- `ci.yaml` — frontend quality gates (lint/typecheck/test/build), single job.
- First-time Pi setup: `sudo bash scripts/setup-pi.sh --config config.yaml` (installs
Mosquitto, the service, the updater timer, static IP).
- Pi files: `/opt/remoterig/{remoterig, config.yaml, update.env, VERSION, deploy.sh, pi-update.sh}`.
- Future ESP-01S firmware OTA: `docs/design/esp01s-uart-ota.md` ("XIAO as flasher").
### Gitea Actions runner notes (important)
- Runner `remote-rig-runner`, label **`go-react`** (Dockerized act_runner). Workflows
must use `runs-on: go-react`.
- The `go-react` image has **Node but not Go** → use `setup-go` (its static binary
runs); get Node from the image (**don't** use `setup-node` — its dynamically-linked
Node won't execute here, "cannot execute: required file not found").
- Gitea doesn't support `actions/upload-artifact@v4`. No `curl`/`jq`/`sudo` on the
runner — the release publish is done in Node.
- The runner's network to github.com/Gitea is flaky (ECONNRESET) → keep few action
clones; `publish-release.mjs` retries.
- Rolling release tag is **`dev-latest`**, NOT `dev` (a `dev` tag collides with the
`dev` branch → ambiguous refs).
- Inspect CI from a dev machine with the **`tea` CLI**: `tea actions runs list|view|logs`,
`tea release list` (note "completed" ≠ success — check Conclusion).
## 9. Key decisions & gotchas (log)
1. **MCU:** ESP32-C3 Super Mini → **XIAO ESP32-C6**; C6 needs the **pioarduino**
platform fork. USB-CDC-on-boot for `Serial` over native USB.
2. **Mac build toolchain:** use `~/.platformio/penv/bin/pio` (Python 3.11), **not**
the pyenv 3.9.21 shim (too old for pioarduino).
3. **C6 filesystem = LittleFS** (pioarduino `uploadfs` builds LittleFS, not SPIFFS) —
the firmware reads `/config.json` (data file must be named `config.json`).
4. **Network re-addressed** `10.60.1.0/24``192.168.8.0/24`.
5. **Wi-Fi password kept in git** (`firmware/data/config.json`) — maintainer decision
(private repo, low-sensitivity, closed net).
6. **RGB LED is common-anode** (`RGB_COMMON_ANODE 1`); OLED is **SH1106** @ `0x3C`.
7. **Camera registration = "Option B" self-assigned IDs:** node uses `rig-<MAC>` as a
stable `camera_id` from first boot; the hub registers under that id. No `cam-NNN`
assignment, no `registered`-reply handshake. (`docs/MQTT_CONTRACT.md` updated.)
8. **Hub tolerates clockless status timestamps** — nodes have no RTC; firmware omits
`timestamp`, the hub stamps server-side (it used to reject the status).
9. **ESP-01S updates:** settings change live via `set_config` (no reflash); full
firmware OTA is the future XIAO-as-flasher path (`docs/design/esp01s-uart-ota.md`).
10. **Pull-based deploy** via rolling `dev-latest` release + atomic binary replace +
network retries.
11. **Pi systemd service user = `overseer`** (this Pi has no `pi` user); `setup-pi.sh`
now defaults the service user to `$SUDO_USER`.
12. **Hub embeds the frontend** via `//go:embed all:src/dist`; Vite builds into
`cmd/server/src/dist` (a committed `index.html` placeholder keeps the embed valid).
13. **SQLite/modernc datetime:** `modernc.org/sqlite` returns a `COALESCE()`/expression
as a raw string (no type affinity) → can't scan into `time.Time`. Scan plain
`DATETIME` columns (returned as `time.Time`) via `sql.NullTime`; `ListCameras`
`COALESCE`s NULL int/bool status columns. Nodes send no usable timestamp on
status/heartbeat (numeric `millis()`) — the hub ignores it / stamps server-side.
14. **Legacy id migration:** `handleAnnounce` migrates a MAC registered under a
different `camera_id` (e.g. a pre-self-id `cam-NNN`) to the node's self-id.
15. **Kiosk API auth:** `api_key: ""` in `config.yaml` = no auth on `/api/v1/*`
(closed LAN, consistent with anonymous MQTT). A non-empty key requires the SPA
to send `X-API-Key` too, or the dashboard 401s and shows no cameras.
16. **Ops gotcha:** the pull updater swaps only the **binary**. `config.yaml` is NOT
auto-deployed — change it on the Pi (`/opt/remoterig/config.yaml` + restart).
17. **GoPro Hero 3 protocol** (validated on a Silver): API host `10.5.5.9`, status
read `GET /camera/se?t=<pwd>` (binary, starts with 0x00 — read the stream, not
Arduino String), recording = byte 29, battery = byte 19; record start/stop =
`/bacpac/SH?t=<pwd>&p=%01/%00`. ESP-01S flashing needs RST tied HIGH (RST→GND
holds it in reset) and a known-good UART adapter (verify with a TX↔RX loopback).
18. **Control path:** `/cameras/{id}/start|stop` publish `{"command":...}` to
`remoterig/cameras/<id>/command` via `Subscriber.PublishCommand`; the XIAO forwards
it over UART to the ESP-01S. (The handlers used to only write a DB row — no command
was ever sent.)
19. **SSE longevity:** no global `middleware.Timeout` and `WriteTimeout: 0` — a write
deadline terminates the long-lived `/events/stream` (it was dying at 10s). The SPA
also **seeds** the list via `GET /api/v1/cameras` on mount (SSE only pushes on change).
20. **Nullable status JSON:** `battery_pct`/`video_remaining_sec` serialized as `null`
(not `omitempty`) — omitting them became `undefined` in the SPA → "NaN%".
21. **UART is two independent wires:** status (ESP `TX/GPIO1` → XIAO `D7`) and commands
(XIAO `D6` → ESP `RX/GPIO3`) are separate paths — receiving status does NOT prove
the command direction works. Verify the command path with the `set_config`
poll-interval test (status rate should change).
## 10. Conventions
- Production hub/controller in **Go**; Python fine for diagnostics/experiments/migrations.
- **SQLite**, not Postgres. **Timezone: US Eastern.**
- Work on `dev`; commit messages end with a `Co-Authored-By` trailer.
- Canonical design docs live in **Notion** (Remote Rig parent page) and the repo
`docs/`; CAD in Seafile; code/build in Gitea.
## 11. Current status & open items (2026-06-05)
**Working / proven on hardware:**
- Hub up on the Pi (Mosquitto + `remoterig` + SQLite); **dashboard renders live**
(kiosk mode `api_key:""`, SSE kept alive, list seeded via REST on mount).
- Full CI/CD loop proven: commit → CI build → `dev-latest` → Pi self-update
(checksum, atomic replace, health-check) → service active.
- C6 (fw `0.4.0`) self-IDs as `rig-86d978`, registered + listed.
- **GoPro monitoring works (Hero 3 Silver):** ESP-01S joins `goprosilver-1`, reads
`/camera/se`, and `online:true` + `battery_raw` + `video_remaining_sec` flow
GoPro → ESP-01S → XIAO → MQTT → hub → SQLite → API/SSE → dashboard.
- Hub publishes start/stop commands to `…/<id>/command` (verified on the bus).
**In progress / unresolved:**
- **Camera CONTROL not working — XIAO→ESP command wire is faulty.** Status (ESP→XIAO,
`GPIO1→D7`) works, but the command direction (XIAO `D6` → ESP `RX/GPIO3`) does not,
so `start_recording`/`set_config` never reach the ESP. Confirmed via the `set_config`
poll-interval test (status rate didn't change). Fix/re-seat that one jumper; then
Record + live config will work. (See decision #21.)
- **Battery calibration:** `battery_raw` (~5659) flows; set `set_battery_cal`
(`raw_min/raw_max`, provisionally 0/100) for `battery_pct` — but this is a *command*,
so it's blocked by the same XIAO→ESP wire above. `video_remaining` offset (25-26)
provisional. SPA now shows "N/A" (not "NaN%") when `battery_pct` is null.
- **Pi SD-card health:** a transient `Input/output error` on core binaries cleared on
reboot — watch for recurrence (failing card); re-image via `setup-pi.sh` if it
returns (everything is reproducible from git).
- **Rotate the Gitea runner registration token** (was exposed in a setup paste).
- Gitea repo **default-branch HEAD** points at a nonexistent ref — set default branch.
- Optional: clear the stale **retained** MQTT message at
`remoterig/cameras/announce-rig-86d978` (from old firmware).
## 12. Handy commands
```bash
# Build/flash C6 firmware (Mac):
~/.platformio/penv/bin/pio run -d firmware -e seeed_xiao_esp32c6 -t upload
~/.platformio/penv/bin/pio run -d firmware -e seeed_xiao_esp32c6 -t uploadfs # provision /config.json
# ESP-01S: needs a 3.3V USB-UART adapter with GPIO0->GND for flash mode.
# Inspect Gitea CI (Mac, tea CLI logged in as 'overseer'):
tea actions runs list --repo CubeCraft-Creations/remote-rig
tea actions runs view <id> --repo … ; tea actions runs logs <id> --repo …
tea release list --repo CubeCraft-Creations/remote-rig
# On the Pi:
sudo systemctl start remoterig-update.service # force a pull/deploy
cat /opt/remoterig/VERSION ; systemctl is-active remoterig
journalctl -u remoterig -n 40 --no-pager
mosquitto_sub -h localhost -t 'remoterig/#' -v
curl -s -H "X-API-Key: changeme" http://localhost:8080/api/v1/cameras
```
+16 -24
View File
@@ -117,39 +117,31 @@ Platform: pi-zero-2w (max 16 cameras)
RemoteRig hub ready RemoteRig hub ready
``` ```
## Deployment (CI/CD — pull-based) ## Building for Raspberry Pi Zero 2 W
Deploys are automated and pull-based, so nothing has to reach into the closed Cross-compile from your development machine:
RemoteRig network:
1. **Push to `dev`** → Gitea Actions (`.gitea/workflows/build-dev.yaml`) builds the
React frontend and cross-compiles the Go hub for **arm64**.
2. The workflow publishes the binary + `sha256` + `version.txt` to a rolling
**`dev` release**.
3. On the Pi, `remoterig-update.timer` runs `scripts/pi-update.sh` every few
minutes: it compares versions, downloads + verifies the checksum, and installs
via `scripts/deploy.sh` (backup → restart → rollback on failure).
First-time Pi setup (`sudo scripts/setup-pi.sh`) installs Mosquitto, the
`remoterig` service, and the updater timer. If the repo is private, set a read
token in `/opt/remoterig/update.env`.
### Manual / local cross-compile
The Pi Zero 2 W is a Cortex-A53 (ARMv8) running 64-bit Raspberry Pi OS, so the
target is **arm64**:
```bash ```bash
GOOS=linux GOARCH=arm64 go build -o remoterig-hub ./cmd/server/ GOOS=linux GOARCH=arm GOARM=6 go build -o remoterig-hub ./cmd/server/
scp remoterig-hub config.yaml pi@192.168.8.56:/opt/remoterig/ ```
Copy the binary and `config.yaml` to your Pi:
```bash
scp remoterig-hub config.yaml pi@raspberrypi:/home/pi/remoterig/
```
Then run on the Pi:
```bash
./remoterig-hub
``` ```
### Build Matrix ### Build Matrix
| Target | Command | | Target | Command |
| ------ | ------- | | ------ | ------- |
| Raspberry Pi Zero 2 W (64-bit OS) | `GOOS=linux GOARCH=arm64 go build -o remoterig-hub ./cmd/server/` | | Raspberry Pi Zero 2 W | `GOOS=linux GOARCH=arm GOARM=6 go build -o remoterig-hub ./cmd/server/` |
| Raspberry Pi (32-bit OS) | `GOOS=linux GOARCH=arm GOARM=7 go build -o remoterig-hub ./cmd/server/` |
| Local (same arch) | `go build -o remoterig-hub ./cmd/server/` | | Local (same arch) | `go build -o remoterig-hub ./cmd/server/` |
| Linux amd64 | `GOOS=linux GOARCH=amd64 go build -o remoterig-hub ./cmd/server/` | | Linux amd64 | `GOOS=linux GOARCH=amd64 go build -o remoterig-hub ./cmd/server/` |
+6 -9
View File
@@ -78,9 +78,7 @@ func main() {
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
// No global request timeout: it cancels the long-lived SSE stream r.Use(middleware.Timeout(cfg.WriteTimeout))
// (/api/v1/events/stream) — that's why the dashboard never received
// camera events. Closed-LAN kiosk, so dropping it is fine.
// Health check (no auth) // Health check (no auth)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
@@ -89,7 +87,7 @@ func main() {
}) })
// API routes (auth required if API key is configured) // API routes (auth required if API key is configured)
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB, mqttSub))) r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
// Serve embedded React frontend with SPA fallback // Serve embedded React frontend with SPA fallback
r.Mount("/", frontendHandler()) r.Mount("/", frontendHandler())
@@ -99,8 +97,7 @@ func main() {
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: r, Handler: r,
ReadTimeout: cfg.ReadTimeout, ReadTimeout: cfg.ReadTimeout,
// WriteTimeout intentionally 0: SSE responses are long-lived and a WriteTimeout: cfg.WriteTimeout,
// write deadline would terminate them mid-stream.
IdleTimeout: cfg.IdleTimeout, IdleTimeout: cfg.IdleTimeout,
} }
@@ -122,7 +119,7 @@ func main() {
} }
// apiRouter creates the API route tree. // apiRouter creates the API route tree.
func apiRouter(sseHub *events.Hub, database *db.DB, pub api.CommandPublisher) http.Handler { func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
// Camera management routes // Camera management routes
@@ -131,8 +128,8 @@ func apiRouter(sseHub *events.Hub, database *db.DB, pub api.CommandPublisher) ht
r.Get("/cameras/{id}", api.GetCameraDetail(database)) r.Get("/cameras/{id}", api.GetCameraDetail(database))
// Recording control routes // Recording control routes
r.Post("/cameras/{id}/start", api.StartRecording(database, pub)) r.Post("/cameras/{id}/start", api.StartRecording(database))
r.Post("/cameras/{id}/stop", api.StopRecording(database, pub)) r.Post("/cameras/{id}/stop", api.StopRecording(database))
// Status ingestion (from ESP32 nodes) // Status ingestion (from ESP32 nodes)
r.Post("/cameras/{id}/status", api.PushStatus(database)) r.Post("/cameras/{id}/status", api.PushStatus(database))
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<!-- Placeholder so //go:embed all:src/dist always has a file to embed.
Replaced by the real Vite build output (npm run build) at CI build time. -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>RemoteRig Hub</title>
</head>
<body>
<p>RemoteRig hub is running. Frontend not built into this binary.</p>
</body>
</html>
+2 -5
View File
@@ -4,11 +4,8 @@
# Database # Database
db_path: "remoterig.db" db_path: "remoterig.db"
# API key for endpoint authentication. Empty = kiosk mode (no auth) — # API Key for endpoint authentication
# intended for the closed travel-router LAN, consistent with anonymous MQTT. api_key: "changeme"
# Set a value to require the X-API-Key header on /api/v1/* (the SPA would
# then need it too).
api_key: ""
# Server settings # Server settings
port: 8080 port: 8080
+6 -6
View File
@@ -36,7 +36,7 @@ RemoteRig is a **multi-camera remote monitoring system**. It provides a camera g
``` ```
┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────┐
│ Travel Router (self-contained LAN) │ │ Travel Router (self-contained LAN) │
│ Subnet: 192.168.8.0/24 │ │ Subnet: 10.60.1.0/24 │
│ DHCP pool: .100-.200 │ │ DHCP pool: .100-.200 │
└──────┬──────────┬──────────┬──────────────┘ └──────┬──────────┬──────────┬──────────────┘
│ │ │ │ │ │
@@ -44,7 +44,7 @@ RemoteRig is a **multi-camera remote monitoring system**. It provides a camera g
▼ ▼ ▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │ │ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
│ DHCP addr │ │ DHCP addr │ │ 192.168.8.56 │ │ DHCP addr │ │ DHCP addr │ │ 10.60.1.56 │
│ STA→Router │ │ STA→Router │ │ (static IP) │ │ STA→Router │ │ STA→Router │ │ (static IP) │
│ MQTT→:1883 │ │ MQTT→:1883 │ │ Mosquitto :1883 │ │ MQTT→:1883 │ │ MQTT→:1883 │ │ Mosquitto :1883 │
│ UART relay │ │ UART relay │ │ Go API :8080 │ │ UART relay │ │ UART relay │ │ Go API :8080 │
@@ -59,14 +59,14 @@ RemoteRig is a **multi-camera remote monitoring system**. It provides a camera g
└──────┬───────┘ └──────┬───────┘ ┌──────────────────┐ └──────┬───────┘ └──────┬───────┘ ┌──────────────────┐
▼ ▼ │ User Device │ ▼ ▼ │ User Device │
┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │ ┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │
│ GoPro Hero 3 │ │ GoPro Hero 3 │ │ 192.168.8.56:8080 │ │ GoPro Hero 3 │ │ GoPro Hero 3 │ │ 10.60.1.56:8080 │
└──────────────┘ └──────────────┘ └──────────────────┘ └──────────────┘ └──────────────┘ └──────────────────┘
``` ```
**Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP8266 boards talk to the GoPro AP over HTTP, then relay camera status/commands over UART to ESP32 boards. ESP32 boards stay on the travel-router LAN and bridge UART messages to MQTT. **Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP8266 boards talk to the GoPro AP over HTTP, then relay camera status/commands over UART to ESP32 boards. ESP32 boards stay on the travel-router LAN and bridge UART messages to MQTT.
### Key Architecture Decisions (revised) ### Key Architecture Decisions (revised)
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `192.168.8.0/24`. - **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `10.60.1.0/24`.
- **Two-board camera node** — ESP8266 handles GoPro AP/HTTP; ESP32 stays on the travel-router LAN for MQTT. This avoids ESP32 dual-STA/channel switching complexity. - **Two-board camera node** — ESP8266 handles GoPro AP/HTTP; ESP32 stays on the travel-router LAN for MQTT. This avoids ESP32 dual-STA/channel switching complexity.
- **ESP8266 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable. - **ESP8266 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable.
- **UART bridge between boards** — ESP8266 reports GoPro status and receives commands over UART; ESP32 relays those messages to/from MQTT. - **UART bridge between boards** — ESP8266 reports GoPro status and receives commands over UART; ESP32 relays those messages to/from MQTT.
@@ -215,8 +215,8 @@ platform:
type: "pi-zero-2w" type: "pi-zero-2w"
max_cameras: 16 max_cameras: 16
network: network:
subnet: "192.168.8.0/24" # Travel router subnet subnet: "10.60.1.0/24" # Travel router subnet
hub_ip: "192.168.8.56" # Pi Zero 2 W static IP hub_ip: "10.60.1.56" # Pi Zero 2 W static IP
``` ```
## Frontend Component Tree ## Frontend Component Tree
+12 -20
View File
@@ -7,7 +7,7 @@
``` ```
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Travel Router (192.168.8.1) │ │ Travel Router (10.60.1.1) │
│ DHCP: .100-.200 │ │ DHCP: .100-.200 │
└──────┬──────────┬──────────┬──────┘ └──────┬──────────┬──────────┬──────┘
│ │ │ │ │ │
@@ -15,7 +15,7 @@
▼ ▼ ▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │ │ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
│ 192.168.8.101 │ │ 192.168.8.102 │ │ 192.168.8.56 │ │ 10.60.1.101 │ │ 10.60.1.102 │ │ 10.60.1.56 │
│ STA→Router │ │ STA→Router │ │ Mosquitto │ │ STA→Router │ │ STA→Router │ │ Mosquitto │
│ MQTT relay │ │ MQTT relay │ │ Go backend │ │ MQTT relay │ │ MQTT relay │ │ Go backend │
└──────┬───────┘ └──────┬───────┘ │ React UI │ └──────┬───────┘ └──────┬───────┘ │ React UI │
@@ -32,14 +32,14 @@
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
``` ```
- **Travel router:** Self-contained, no internet. Gateway `192.168.8.1`. DHCP pool: `192.168.8.100-200` - **Travel router:** Self-contained, no internet. Gateway `10.60.1.1`. DHCP pool: `10.60.1.100-200`
- **Pi Zero 2 W:** Static IP `192.168.8.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI - **Pi Zero 2 W:** Static IP `10.60.1.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
- **ESP32s:** DHCP from router. Each stays on the travel-router LAN, relays MQTT to/from its paired ESP8266 over UART - **ESP32s:** DHCP from router. Each stays on the travel-router LAN, relays MQTT to/from its paired ESP8266 over UART
- **User device:** Connects to router, opens `http://192.168.8.56:8080` for dashboard - **User device:** Connects to router, opens `http://10.60.1.56:8080` for dashboard
## MQTT Broker ## MQTT Broker
- **Host:** `192.168.8.56` (Pi Zero 2 W) - **Host:** `10.60.1.56` (Pi Zero 2 W)
- **Port:** `1883` (default MQTT, no TLS — closed network) - **Port:** `1883` (default MQTT, no TLS — closed network)
- **Auth:** None (closed network, no external access) - **Auth:** None (closed network, no external access)
- **Client ID format:** `remoterig-<esp32_mac_last6>` (e.g., `remoterig-a1b2c3`) - **Client ID format:** `remoterig-<esp32_mac_last6>` (e.g., `remoterig-a1b2c3`)
@@ -176,19 +176,11 @@ Published once on ESP32 first boot (or factory reset). Used for auto-registratio
| `capabilities` | string[] | Supported features | | `capabilities` | string[] | Supported features |
| `friendly_name` | string | Default human-readable name | | `friendly_name` | string | Default human-readable name |
**Camera IDs (self-assigned — "Option B"):** the node uses a stable **Hub behavior on first announce:**
device-derived id (`rig-<last3 MAC bytes>`, e.g. `rig-86d978`) as its
`camera_id` from first boot, and uses it for all topics
(`announce`/`status`/`heartbeat`/`command`). There is no hub-assigned
`cam-NNN` and no `registered` reply handshake.
**Hub behavior on announce:**
1. Check if MAC already registered → if yes, update `friendly_name` and log 1. Check if MAC already registered → if yes, update `friendly_name` and log
2. If new MAC → insert the camera using the node's self-assigned `camera_id` 2. If new MAC → create camera with auto-generated `camera_id = "cam-<NNN>"` (zero-padded sequential)
3. Broadcast via SSE that a new camera appeared 3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
4. Broadcast via SSE that a new camera appeared
> Note: nodes have no real-time clock, so `timestamp` may be absent; the hub
> stamps received-time server-side.
### Topic: `remoterig/hub/status` ### Topic: `remoterig/hub/status`
@@ -230,7 +222,7 @@ Hub health status broadcast.
ESP32 boots ESP32 boots
├── Connects to travel router Wi-Fi ├── Connects to travel router Wi-Fi
├── Connects to MQTT broker (192.168.8.56:1883) ├── Connects to MQTT broker (10.60.1.56:1883)
├── Publishes announce (retained) on cameras/<id>/announce ├── Publishes announce (retained) on cameras/<id>/announce
@@ -283,6 +275,6 @@ When ESP32 loses connection to travel router:
## Open Questions ## Open Questions
1. **NTP/time sync:** How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. **Recommendation:** Pi runs NTPd, ESP32s use SNTP from `192.168.8.56`. 1. **NTP/time sync:** How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. **Recommendation:** Pi runs NTPd, ESP32s use SNTP from `10.60.1.56`.
2. **Camera naming:** Should `friendly_name` be configurable from dashboard after auto-registration? **Recommendation:** Yes — allow rename via UI, stored in cameras table. 2. **Camera naming:** Should `friendly_name` be configurable from dashboard after auto-registration? **Recommendation:** Yes — allow rename via UI, stored in cameras table.
3. **Firmware OTA:** Should ESP32 firmware updates be possible over this network? **Recommendation:** Yes but out of scope for MVP. 3. **Firmware OTA:** Should ESP32 firmware updates be possible over this network? **Recommendation:** Yes but out of scope for MVP.
-508
View File
@@ -1,508 +0,0 @@
# 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)
-133
View File
@@ -1,133 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteRig Dashboard Preview</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg-dark: #0f172a;
--card-dark: #1e293b;
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
}
body { background-color: var(--bg-dark); color: white; font-family: 'Inter', sans-serif; }
.status-card { background-color: var(--card-dark); transition: transform 0.2s ease, box-shadow 0.2s ease; }
.status-card:hover { transform: translateY(-4px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); }
.border-green { border-left: 4px solid var(--status-green); }
.border-yellow { border-left: 4px solid var(--status-yellow); }
.border-red { border-left: 4px solid var(--status-red); }
</style>
</head>
<body class="p-6">
<!-- Top Bar -->
<header class="flex justify-between items-center mb-8 p-4 bg-slate-800 rounded-lg shadow-lg">
<div class="flex items-center gap-3">
<div class="w-3 h-3 bg-blue-500 rounded-full animate-pulse"></div>
<h1 class="text-xl font-bold">◉ RemoteRig</h1>
</div>
<div class="flex items-center gap-4">
<span class="text-sm text-slate-400 flex items-center gap-2">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
System Online
</span>
<button class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-bold rounded transition-colors uppercase text-xs tracking-wider">
⏹ Stop All
</button>
</div>
</header>
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Card: Healthy -->
<div class="status-card border-green p-5 rounded-xl shadow-md">
<div class="flex justify-between items-start mb-4">
<h3 class="font-bold text-lg">🎥 Front Door</h3>
<span class="text-green-500 text-xs font-bold">ONLINE</span>
</div>
<div class="space-y-3 mb-6">
<div>
<div class="flex justify-between text-xs mb-1"><span>Battery</span><span class="text-green-400">82%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-green-500 h-full" style="width: 82%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1"><span>Storage</span><span class="text-green-400">45%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-green-500 h-full" style="width: 45%"></div>
</div>
</div>
<div class="flex items-center gap-2 text-sm font-mono text-red-500">
<span class="w-2 h-2 bg-red-500 rounded-full animate-ping"></span>
REC: 00:42:10
</div>
</div>
<button class="w-full py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm font-medium transition-colors">View Details ▸</button>
</div>
<!-- Card: Warning -->
<div class="status-card border-yellow p-5 rounded-xl shadow-md">
<div class="flex justify-between items-start mb-4">
<h3 class="font-bold text-lg">🎥 Backyard</h3>
<span class="text-yellow-500 text-xs font-bold">ONLINE</span>
</div>
<div class="space-y-3 mb-6">
<div>
<div class="flex justify-between text-xs mb-1"><span>Battery</span><span class="text-yellow-400">41%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-yellow-500 h-full" style="width: 41%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1"><span>Storage</span><span class="text-yellow-400">88%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-yellow-500 h-full" style="width: 88%"></div>
</div>
</div>
<div class="flex items-center gap-2 text-sm font-mono text-yellow-500">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
PAUSED
</div>
</div>
<button class="w-full py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm font-medium transition-colors">View Details ▸</button>
</div>
<!-- Card: Critical -->
<div class="status-card border-red p-5 rounded-xl shadow-md">
<div class="flex justify-between items-start mb-4">
<h3 class="font-bold text-lg">🎥 Garage</h3>
<span class="text-red-500 text-xs font-bold">OFFLINE</span>
</div>
<div class="space-y-3 mb-6">
<div>
<div class="flex justify-between text-xs mb-1"><span>Battery</span><span class="text-red-400">12%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-red-500 h-full" style="width: 12%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1"><span>Storage</span><span class="text-red-400">95%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-red-500 h-full" style="width: 95%"></div>
</div>
</div>
<div class="flex items-center gap-2 text-sm font-mono text-red-500">
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
OFFLINE
</div>
</div>
<button class="w-full py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm font-medium transition-colors">View Details ▸</button>
</div>
</div>
<footer class="mt-12 p-4 bg-slate-800 rounded-lg text-center text-sm text-slate-400 shadow-inner">
📊 6 cams | 4 recording | 1 paused | 1 offline | Storage: 60% used
</footer>
</body>
</html>
-129
View File
@@ -1,129 +0,0 @@
# RemoteRig — Camera Monitoring Dashboard Design Specifications
## 1. Color Coding Thresholds
The system uses a semantic 3-tier color system to indicate device health.
| Metric | Green (Healthy) | Yellow (Warning) | Red (Critical) |
| :--- | :--- | :--- | :--- |
| **Battery %** | > 50% | 20% - 50% | < 20% |
| **Storage Used** | < 70% | 70% - 90% | > 90% |
| **Connection** | Heartbeat < 30s | Heartbeat 30s - 5m | Heartbeat > 5m / Offline |
| **Recording** | Recording Active | Standby/Paused | Stopped/Error |
---
## 2. Wireframes
### Screen A: Main Dashboard
**Layout:** Responsive Grid (3 col on 1920x1080, 2 col on 1024x600)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ◉ RemoteRig Dashboard [🟢 System OK] [⏹ STOP ALL] │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 🎥 Front Door │ │ 🎥 Backyard │ │ 🎥 Garage │ │
│ │ ------------------- │ │ ------------------- │ │ ------------------- │ │
│ │ 🔋 82% [Green] │ │ 🔋 41% [Yellow] │ │ 🔋 12% [Red] │ │
│ │ 💾 45% [Green] │ │ 💾 88% [Yellow] │ │ 💾 95% [Red] │ │
│ │ 🔴 REC: 00:42:10 │ │ 🟡 PAUSED │ │ ⚫ OFFLINE │ │
│ │ │ │ │ │ │
│ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 🎥 Driveway │ │ 🎥 Workshop │ │ 🎥 3D Printer │ │
│ │ ------------------- │ │ ------------------- │ │ ------------------- │ │
│ │ 🔋 98% [Green] │ │ 🔋 30% [Yellow] │ │ 🔋 65% [Green] │ │
│ │ 💾 12% [Green] │ │ 💾 20% [Green] │ │ 💾 15% [Green] │ │
│ │ 🔴 REC: 01:12:05 │ │ 🔴 REC: 00:05:22 │ │ 🟡 STANDBY │ │
│ │ │ │ │ │ │
│ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Screen B: Camera Detail
**Layout:** Two-column split (Visual/Stats vs. History/Events)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Back to Dashboard | 🎥 Front Door Detail [⏹ STOP] │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────┐ ┌────────────────────────────────────────┐ │
│ │ │ │ 📅 RECORDING HISTORY (Last 7 Days) │ │
│ │ LIVE VIDEO FEED │ │ │ │
│ │ (4:3 Aspect) │ │ M [███] T [███] W [█ ] T [███] F [█ ] │ │
│ │ │ │ S [███] S [███] (Total: 12.4h) │ │
│ │ │ │ │ │
│ │ │ ├────────────────────────────────────────┤ │
│ │ │ │ 📋 RECENT EVENTS │ │
│ │ │ │ - 12:01: Recording Started │ │
│ │ │ │ - 11:45: Motion Detected (Zone A) │ │
│ │ │ │ - 11:00: Battery Warning (40%) │ │
│ │ │ │ - 10:12: Camera Rebooted │ │
│ │ │ │ [ VIEW ALL EVENTS ] │ │
│ └────────────────────────────┘ └────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────┐ │
│ │ ⚙️ CAMERA STATUS │ │
│ │ -------------------------- │ │
│ │ Battery: 82% [Green] │ │
│ │ Storage: 45% [Green] │ │
│ │ Status: RECORDING │ │
│ │ IP: 192.168.8.12 │ │
│ │ MAC: AA:BB:CC:DD:EE:FF │ │
│ │ │ │
│ │ [ EDIT CAMERA SETTINGS ] │ │
│ └────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Screen C: Settings / Registration
**Layout:** Centered Form with Sidebar List
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Back to Dashboard | ⚙️ Camera Registration/Edit │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────┐ ┌───────────────────┐ │
│ │ 📝 CAMERA DETAILS │ │ 📋 REGISTERED CAMS │ │
│ │ --------------------------------------------- │ │ ----------------- │ │
│ │ │ │ 🎥 Front Door │ │
│ │ Friendly Name: │ │ 🎥 Backyard │ │
│ │ [ Front Door ] │ │ 🎥 Garage │ │
│ │ │ │ 🎥 Driveway │ │
│ │ MAC Address: │ │ 🎥 Workshop │ │
│ │ [ AA:BB:CC:DD:EE:FF ] │ │ 🎥 3D Printer │ │
│ │ │ │ │
│ │ Notes: │ │ [ + ADD NEW CAM ] │ │
│ │ [ Covers the main entryway and porch area. ] │ └───────────────────┘ │
│ │ [ ] │ │
│ │ [ ] │ │
│ │ │ │
│ │ [ TEST CONNECTION ] [ 💾 SAVE CHANGES ] │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## 3. Responsive Design Notes
### Viewport: 1920x1080 (Desktop)
- **Grid:** 3 columns for camera cards.
- **Navigation:** Top bar fixed, spacing is generous (24px margins).
- **Details:** Full split-screen layout for camera details.
### Viewport: 1024x600 (Kiosk)
- **Grid:** 2 columns for camera cards.
- **Top Bar:** Compact height (48px), "STOP ALL" button remains prominent in top-right.
- **Details:** Vertical stack for Camera Detail (Feed $\rightarrow$ Stats $\rightarrow$ Events).
- **Settings:** Sidebar moves to the bottom of the form.
- **Sizing:** UI elements scaled up (touch-friendly targets min 44x44px).
-100
View File
@@ -1,100 +0,0 @@
# ESP-01S firmware updates without a USB-UART adapter
Status: **design / not yet implemented.** Interim mitigations (config-over-UART,
GPIO reservation) are shipped; see "Shipped now" below.
## Problem
The ESP-01S camera bridge has no native USB. Today it is flashed with an external
3.3 V USB-UART adapter and a `GPIO0 → GND` jumper held during reset. On an assembled
field node that is impractical — we want to update it over the network.
## Why not Wi-Fi OTA on the ESP-01S itself
1. **Network topology.** The ESP-01S joins the *GoPro* AP (`10.5.5.1`), not the hub /
travel-router network. The hub cannot reach it to push an OTA.
2. **1 MB flash.** Standard ESP8266 OTA stages a second copy of the sketch alongside the
running one. Our sketch is ~333 KB; a 1 MB module has no room for two copies plus FS
and reserved areas.
So updates must arrive **through the XIAO**, which is already UART-connected to the
ESP-01S and sits on the hub network.
## Approach: XIAO ESP32-C6 as the flasher (UART OTA)
The XIAO plays the role the USB-UART adapter plays today, driving the ESP-01S's ROM
serial bootloader over the existing UART.
### Hardware — two added control lines
| XIAO pin | → ESP-01S | purpose |
|----------|-----------|---------|
| `D8` (`ESP01_RST_PIN`) | `RST` | pulse low to reset the ESP-01S |
| `D10` (`ESP01_PGM_PIN`) | `GPIO0` | hold low across reset → enter bootloader |
| `D6` (TX) / `D7` (RX) | `RX` / `TX` | existing `Serial1` link (crossed) |
| GND | GND | common ground |
> **Confirm before committing the PCB/wiring:** verify `D8`/`D10` on the actual XIAO
> ESP32-C6 variant do **not** map to ESP32-C6 strapping pins (`GPIO8`, `GPIO9`, `GPIO15`)
> or the USB-JTAG pins. Pins are reserved in firmware (`ESP01_RST_PIN`, `ESP01_PGM_PIN`)
> but not yet driven.
### Bootloader entry
`GPIO0 = LOW`, pulse `RST` low→high → ESP-01S enters the serial bootloader on the UART.
After writing: `GPIO0 = HIGH`, pulse `RST` → run the new firmware. Always restore
`GPIO0 = HIGH` on give-up so the ESP can boot normally.
### Flash protocol
Implement enough of the ESP8266 ROM bootloader / esptool SLIP protocol on the XIAO over
`Serial1`:
- `SYNC`, then `FLASH_BEGIN` / `FLASH_DATA` (≈1 KB blocks) / `FLASH_END` to write the app
at offset `0x0`.
- Start at 115200 baud; optionally raise after sync.
- Verify with the ROM `SPI_FLASH_MD5` against the expected MD5.
### Firmware delivery (hub → XIAO)
Greenfield on the Go hub (only a `firmware_version` field exists today). Recommended:
- **HTTP pull.** Hub exposes `GET /firmware/esp01s/<version>.bin` (+ MD5). XIAO is
triggered by an MQTT command, e.g.
`{"command":"update_esp01s","url":"http://<hub>/firmware/esp01s/0.4.0.bin","md5":"…"}`,
fetches the `.bin` in chunks, and streams each chunk straight into a `FLASH_DATA` block.
- Avoid buffering the whole image in RAM — stream HTTP chunk → flash block → repeat.
- MQTT chunked transfer is possible but heavier on the broker; prefer HTTP.
### Sequencing / safety
- Pause the UART JSON status/command protocol while flashing (the link is busy with the
bootloader protocol).
- On failure leave the ESP recoverable and retry; report progress/result to the hub over
MQTT.
### XIAO self-update (separate, easy)
The XIAO (4 MB flash, on the hub network) can use standard ESP32 OTA (`ArduinoOTA` or
`httpUpdate`). No gymnastics required. Split: **XIAO = native OTA; ESP-01S = flashed by
the XIAO over UART.**
## Scope estimate
- **XIAO firmware:** ESP8266 ROM-loader client over `Serial1` + `GPIO0`/`RST` control +
HTTP fetch + MQTT trigger. Mediumlarge.
- **Hub (Go):** firmware store + HTTP endpoint + MQTT trigger command. Smallmedium.
- **Hardware:** two control wires + confirm non-strapping pins. Small.
## Shipped now (interim)
- **Config-over-UART (`set_config`)** — change GoPro SSID/password/IP and poll interval
with no reflash. Hub → `set_camera_config` (MQTT) → XIAO → `set_config` (UART) →
ESP-01S persists to LittleFS.
- **GPIO reservation** — `ESP01_RST_PIN = D8`, `ESP01_PGM_PIN = D10` reserved in the XIAO
firmware for the flasher.
## References
- ESP8266 ROM serial bootloader protocol (esptool).
- Wiring: Notion "XIAO ESP32-C6 Pin-to-Pin Wiring Diagram".
+3 -3
View File
@@ -14,7 +14,7 @@ Each camera node uses **two boards** connected via UART — zero network switchi
│ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │ │ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │
│ │ 115200 │ │ │ │ 115200 │ │
│ STA → GoPro AP │ 8N1 │ STA → Travel Router │ │ STA → GoPro AP │ 8N1 │ STA → Travel Router │
│ HTTP → 10.5.5.1 │ │ MQTT → 192.168.8.56│ │ HTTP → 10.5.5.1 │ │ MQTT → 10.60.1.56│
│ Start/stop/status │ │ Hub registration │ │ Start/stop/status │ │ Hub registration │
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘ └──────────────────────┘
``` ```
@@ -22,7 +22,7 @@ Each camera node uses **two boards** connected via UART — zero network switchi
| Board | Job | Network | Protocol | | Board | Job | Network | Protocol |
|-------|-----|---------|----------| |-------|-----|---------|----------|
| ESP8266 | Camera control | GoPro AP only (10.5.5.1) | HTTP → UART JSON | | ESP8266 | Camera control | GoPro AP only (10.5.5.1) | HTTP → UART JSON |
| ESP32 | Hub relay | Travel router only (192.168.8.x) | UART JSON → MQTT | | ESP32 | Hub relay | Travel router only (10.60.1.x) | UART JSON → MQTT |
## Quick Start ## Quick Start
@@ -75,7 +75,7 @@ JSON-per-line at 115200 8N1. GPIO16 on both boards.
|-----|---------|-------------| |-----|---------|-------------|
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID | | `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
| `wifi_password` | `""` | Travel router password | | `wifi_password` | `""` | Travel router password |
| `mqtt_broker` | `"192.168.8.56"` | Pi Zero 2 W IP | | `mqtt_broker` | `"10.60.1.56"` | Pi Zero 2 W IP |
| `mqtt_port` | `1883` | Mosquitto port | | `mqtt_port` | `1883` | Mosquitto port |
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) | | `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency | | `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
-10
View File
@@ -1,10 +0,0 @@
{
"wifi_ssid": "RemoteRig",
"wifi_password": "RemoteRig1",
"mqtt_broker": "192.168.8.56",
"mqtt_port": 1883,
"camera_id": "",
"heartbeat_interval_sec": 60,
"bat_raw_min": 0,
"bat_raw_max": 0
}
+8
View File
@@ -0,0 +1,8 @@
{
"wifi_ssid": "RemoteRig",
"wifi_password": "",
"mqtt_broker": "10.60.1.56",
"mqtt_port": 1883,
"camera_id": "",
"heartbeat_interval_sec": 60
}
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"camera_ssid": "goprosilver-1", "camera_ssid": "GOPRO-BP-",
"camera_password": "Bzyeatn421", "camera_password": "goprohero",
"camera_ip": "10.5.5.9", "camera_ip": "10.5.5.1",
"poll_interval_sec": 30 "poll_interval_sec": 30
} }
+23 -39
View File
@@ -9,16 +9,16 @@
; (TX/RX) (RX16/TX17) ; (TX/RX) (RX16/TX17)
; ;
; Build: ; Build:
; pio run -e esp8266-camera (ESP8266 — GoPro camera bridge) ; pio run -e esp8266-camera (ESP8266 D1 Mini — camera bridge)
; pio run -e seeed_xiao_esp32c6 (XIAO ESP32-C6 — MQTT bridge) ; pio run -e esp32-mqtt (ESP32 Dev Board — MQTT bridge)
; ;
; Upload: ; Upload:
; pio run -e esp8266-camera --target upload ; pio run -e esp8266-camera --target upload
; pio run -e seeed_xiao_esp32c6 --target upload ; pio run -e esp32-mqtt --target upload
; ;
; Filesystem: ; Filesystem:
; pio run -e esp8266-camera --target uploadfs ; pio run -e esp8266-camera --target uploadfs
; pio run -e seeed_xiao_esp32c6 --target uploadfs ; pio run -e esp32-mqtt --target uploadfs
[common] [common]
lib_deps = lib_deps =
@@ -27,57 +27,41 @@ lib_deps =
build_flags = build_flags =
-D CORE_DEBUG_LEVEL=0 -D CORE_DEBUG_LEVEL=0
; ── ESP-01S: Camera Bridge ────────────────────────────────── ; ── ESP8266: Camera Bridge ──────────────────────────────────
; Flashed onto an ESP-01S (ESP8266, 1MB flash). Talks to the GoPro ; Flashed onto D1 Mini. Talks to GoPro over Wi-Fi, relays to
; over Wi-Fi, relays to the XIAO over the hardware UART (GPIO1/3). ; ESP32 over UART (TX/RX pins). No MQTT, no router connection.
; No MQTT, no router connection.
;
; Flash with a 3.3V USB-UART adapter: tie GPIO0 → GND, power up /
; reset into bootloader, then upload. ESP-01S flash is qio/dout;
; keep upload_speed modest for adapter reliability.
[env:esp8266-camera] [env:esp8266-camera]
platform = espressif8266 platform = espressif8266
board = esp01_1m board = d1_mini
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
upload_speed = 115200 upload_speed = 921600
lib_deps = ${common.lib_deps} lib_deps = ${common.lib_deps}
build_flags = ${common.build_flags} build_flags = ${common.build_flags}
-D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED -D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED
board_build.flash_mode = dout board_build.flash_mode = dio
board_build.f_cpu = 160000000L board_build.f_cpu = 160000000L
build_src_filter = build_src_filter =
-<*.cpp>
+<esp8266-camera-bridge.cpp>
+<../lib/> +<../lib/>
+<esp8266-camera-bridge.cpp>
-<*.cpp>
; ── XIAO ESP32-C6: MQTT Bridge ───────────────────────────── ; ── ESP32: MQTT Bridge ─────────────────────────────────────
; Flashed onto a Seeed Studio XIAO ESP32-C6. Connects to the ; Flashed onto ESP32 Dev Board. Connects to travel router,
; travel router, publishes MQTT to the Pi hub. Reads camera ; publishes MQTT to Pi hub. Reads camera status from ESP8266
; status from the ESP-01S over UART (Serial1: RX=D7, TX=D6). ; over UART2 (RX16/TX17). No direct camera communication.
; No direct camera communication.
;
; ESP32-C6 requires the pioarduino fork of platform-espressif32
; (mainline espressif32 lagged on the Arduino-core 3.x the C6
; needs). USB-CDC-on-boot is required for Serial over native USB.
;
; Upload fallback if it can't connect: hold B (BOOT), tap
; R (RESET), release B, then re-run upload.
[env:seeed_xiao_esp32c6] [env:esp32-mqtt]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip platform = espressif32
board = seeed_xiao_esp32c6 board = esp32dev
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
lib_deps = upload_speed = 921600
${common.lib_deps} lib_deps = ${common.lib_deps}
olikraus/U8g2 @ ^2.35
build_flags = ${common.build_flags} build_flags = ${common.build_flags}
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192 -D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
build_src_filter = build_src_filter =
-<*.cpp>
+<esp32-mqtt-bridge.cpp>
+<../lib/> +<../lib/>
+<esp32-mqtt-bridge.cpp>
-<*.cpp>
+36 -244
View File
@@ -19,10 +19,10 @@
* ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n * ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n
* *
* Hardware: * Hardware:
* - Seeed Studio XIAO ESP32-C6 * - ESP32 Dev Board (or D1 Mini ESP32)
* - Serial1: RX=D7, TX=D6 (crossed to the ESP-01S TX/RX) * - UART2: RX=GPIO16, TX=GPIO17 (connected to ESP8266)
* - Shared GND between boards * - Shared GND between boards
* - 5V rail → XIAO 5V/VIN; ESP-01S on its own 3.3V buck * - LiPo → 3.3V buck → VIN on both boards
*/ */
#include <Arduino.h> #include <Arduino.h>
@@ -30,32 +30,24 @@
#include <WiFiClient.h> #include <WiFiClient.h>
#include <PubSubClient.h> #include <PubSubClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <LittleFS.h> #include <SPIFFS.h>
#include <Wire.h>
#include <U8g2lib.h>
// ──────────────────────────────────────────── // ────────────────────────────────────────────
// Configuration (LittleFS) // Configuration (SPIFFS)
// ──────────────────────────────────────────── // ────────────────────────────────────────────
struct Config { struct Config {
String wifi_ssid = "RemoteRig"; String wifi_ssid = "RemoteRig";
String wifi_password = ""; String wifi_password = "";
String mqtt_broker = "192.168.8.56"; String mqtt_broker = "10.60.1.56";
int mqtt_port = 1883; int mqtt_port = 1883;
String camera_id = ""; // assigned by hub String camera_id = ""; // assigned by hub
int heartbeat_sec = 60; int heartbeat_sec = 60;
// Battery calibration: two-point linear map of the GoPro offset-57
// raw byte → percent. Uncalibrated when max <= min (then we omit
// battery_pct per the MQTT contract). Set via the set_battery_cal
// command and persisted here.
int bat_raw_min = 0; // raw at 0%
int bat_raw_max = 0; // raw at 100%
} cfg; } cfg;
bool loadConfig() { bool loadConfig() {
if (!LittleFS.begin(true)) { Serial.println("[CFG] LittleFS mount failed"); return false; } if (!SPIFFS.begin(true)) { Serial.println("[CFG] SPIFFS mount failed"); return false; }
File f = LittleFS.open("/config.json", "r"); File f = SPIFFS.open("/config.json", "r");
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; } if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
JsonDocument doc; JsonDocument doc;
@@ -69,16 +61,11 @@ bool loadConfig() {
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port; cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
cfg.camera_id = doc["camera_id"] | cfg.camera_id; cfg.camera_id = doc["camera_id"] | cfg.camera_id;
cfg.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec; cfg.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec;
cfg.bat_raw_min = doc["bat_raw_min"] | cfg.bat_raw_min;
cfg.bat_raw_max = doc["bat_raw_max"] | cfg.bat_raw_max;
Serial.printf("[CFG] Loaded: ssid=%s broker=%s:%d cam=%s\n",
cfg.wifi_ssid.c_str(), cfg.mqtt_broker.c_str(), cfg.mqtt_port,
cfg.camera_id.length() ? cfg.camera_id.c_str() : "-");
return true; return true;
} }
bool saveConfig() { bool saveConfig() {
File f = LittleFS.open("/config.json", "w"); File f = SPIFFS.open("/config.json", "w");
if (!f) return false; if (!f) return false;
JsonDocument doc; JsonDocument doc;
doc["wifi_ssid"] = cfg.wifi_ssid; doc["wifi_ssid"] = cfg.wifi_ssid;
@@ -87,116 +74,19 @@ bool saveConfig() {
doc["mqtt_port"] = cfg.mqtt_port; doc["mqtt_port"] = cfg.mqtt_port;
doc["camera_id"] = cfg.camera_id; doc["camera_id"] = cfg.camera_id;
doc["heartbeat_interval_sec"] = cfg.heartbeat_sec; doc["heartbeat_interval_sec"] = cfg.heartbeat_sec;
doc["bat_raw_min"] = cfg.bat_raw_min;
doc["bat_raw_max"] = cfg.bat_raw_max;
serializeJson(doc, f); serializeJson(doc, f);
f.close(); f.close();
return true; return true;
} }
// Map a raw offset-57 byte to battery percent using the stored
// two-point calibration. Returns -1 when uncalibrated.
int batteryPct(int raw) {
if (cfg.bat_raw_max <= cfg.bat_raw_min) return -1; // uncalibrated
long pct = (long)(raw - cfg.bat_raw_min) * 100 /
(cfg.bat_raw_max - cfg.bat_raw_min);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
return (int)pct;
}
// ──────────────────────────────────────────── // ────────────────────────────────────────────
// UART to ESP-01S (HardwareSerial1) // UART to ESP8266 (HardwareSerial2)
// ──────────────────────────────────────────── // ────────────────────────────────────────────
// XIAO ESP32-C6 Serial1: RX=D7, TX=D6 (Serial = native USB CDC) // ESP32 UART2: RX=GPIO16, TX=GPIO17
// Connect: XIAO RX(D7) ← ESP-01S TX // Connect: ESP32 RX(16) ← ESP8266 TX
// XIAO TX(D6) → ESP-01S RX // ESP32 TX(17) → ESP8266 RX
#define UART_ESP8266 Serial1 #define UART_ESP8266 Serial2
#define UART_RX_PIN D7
#define UART_TX_PIN D6
// Reserved for future ESP-01S UART OTA ("XIAO as flasher"): two control
// lines let the XIAO drive the ESP-01S into its serial bootloader and
// reflash it over Serial1 — no USB-UART adapter or GPIO0 jumper needed.
// Not driven yet; see docs/design/esp01s-uart-ota.md.
#define ESP01_RST_PIN D8 // → ESP-01S RST (pulse low to reset)
#define ESP01_PGM_PIN D10 // → ESP-01S GPIO0 (low at reset = bootloader)
// ────────────────────────────────────────────
// RGB STAT LED — D0/D1/D2 (red/green/blue) via 220Ω each
// ────────────────────────────────────────────
// Wiring assumes common cathode (HIGH = on). Set RGB_COMMON_ANODE to
// 1 for a common-anode part (LOW = on).
#define RGB_PIN_R D0
#define RGB_PIN_G D1
#define RGB_PIN_B D2
#define RGB_COMMON_ANODE 1 // this module is common-anode (LOW = on)
void rgbWrite(bool r, bool g, bool b) {
#if RGB_COMMON_ANODE
digitalWrite(RGB_PIN_R, !r); digitalWrite(RGB_PIN_G, !g); digitalWrite(RGB_PIN_B, !b);
#else
digitalWrite(RGB_PIN_R, r); digitalWrite(RGB_PIN_G, g); digitalWrite(RGB_PIN_B, b);
#endif
}
void rgbInit() {
pinMode(RGB_PIN_R, OUTPUT);
pinMode(RGB_PIN_G, OUTPUT);
pinMode(RGB_PIN_B, OUTPUT);
rgbWrite(0, 0, 1); // boot = blue
}
// ────────────────────────────────────────────
// Status OLED — 1.3" I2C panel on D4(SDA)/D5(SCL)
// ────────────────────────────────────────────
// 1.3" 128x64 modules are SH1106. If the image is shifted ~2px or
// wrapped, the panel is an SSD1306 — swap the constructor below to
// U8G2_SSD1306_128X64_NONAME_F_HW_I2C.
#define OLED_SDA_PIN D4
#define OLED_SCL_PIN D5
#define OLED_I2C_ADDR 0x3C
U8G2_SH1106_128X64_NONAME_F_HW_I2C oled(U8G2_R0, U8X8_PIN_NONE);
bool oledReady = false;
// Last-known camera status, mirrored for the display.
int dispBatteryRaw = 0;
bool dispRecording = false;
int dispVideoRemain = 0; // seconds
unsigned long recStartMs = 0; // 0 = not recording
// Walk the bus and log every responder — confirms the OLED address
// (and wiring) independent of the display driver.
void i2cScan() {
Serial.println("[I2C] Scanning...");
byte found = 0;
for (byte a = 1; a < 127; a++) {
Wire.beginTransmission(a);
if (Wire.endTransmission() == 0) {
Serial.printf("[I2C] device @ 0x%02X\n", a);
found++;
}
}
if (!found) Serial.println("[I2C] none found — check wiring/power");
}
void displayInit() {
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
i2cScan();
oled.setI2CAddress(OLED_I2C_ADDR << 1);
oledReady = oled.begin();
Serial.printf("[OLED] begin %s\n", oledReady ? "ok" : "FAILED");
if (!oledReady) return;
oled.clearBuffer();
oled.setFont(u8g2_font_7x14B_tr);
oled.drawStr(0, 14, "RemoteRig");
oled.setFont(u8g2_font_6x10_tr);
oled.drawStr(0, 32, "Camera node");
oled.drawStr(0, 46, "booting...");
oled.sendBuffer();
}
void sendCmdToESP8266(const String& command) { void sendCmdToESP8266(const String& command) {
JsonDocument doc; JsonDocument doc;
@@ -259,34 +149,6 @@ void mqttCallback(char* topic, byte* payload, unsigned int len) {
sendCmdToESP8266(cmd); sendCmdToESP8266(cmd);
} else if (cmd == "reboot") { } else if (cmd == "reboot") {
ESP.restart(); ESP.restart();
} else if (cmd == "set_battery_cal") {
// Two ways to calibrate:
// explicit: {"raw_min":185,"raw_max":245}
// capture: {"point":"full"|"empty"} → uses the latest raw reading
String point = doc["point"] | "";
if (point == "full") cfg.bat_raw_max = dispBatteryRaw;
else if (point == "empty") cfg.bat_raw_min = dispBatteryRaw;
else {
cfg.bat_raw_min = doc["raw_min"] | cfg.bat_raw_min;
cfg.bat_raw_max = doc["raw_max"] | cfg.bat_raw_max;
}
saveConfig();
Serial.printf("[BAT] Calibration set: raw_min=%d raw_max=%d\n",
cfg.bat_raw_min, cfg.bat_raw_max);
} else if (cmd == "set_camera_config") {
// Forward camera-bridge config to the ESP-01S over UART so the
// GoPro creds / poll rate can change without reflashing it.
JsonDocument out;
out["type"] = "cmd";
out["command"] = "set_config";
if (!doc["camera_ssid"].isNull()) out["camera_ssid"] = doc["camera_ssid"];
if (!doc["camera_password"].isNull()) out["camera_password"] = doc["camera_password"];
if (!doc["camera_ip"].isNull()) out["camera_ip"] = doc["camera_ip"];
if (!doc["poll_interval_sec"].isNull()) out["poll_interval_sec"] = doc["poll_interval_sec"];
String line; serializeJson(out, line);
UART_ESP8266.println(line);
UART_ESP8266.flush();
Serial.println("[MQTT] Forwarded set_config → ESP-01S");
} else if (cmd == "registered") { } else if (cmd == "registered") {
String id = doc["camera_id"] | ""; String id = doc["camera_id"] | "";
if (id.length() > 0 && id != cfg.camera_id) { if (id.length() > 0 && id != cfg.camera_id) {
@@ -311,86 +173,27 @@ bool connectMQTT() {
Serial.println("[MQTT] Connected"); Serial.println("[MQTT] Connected");
// Option B: self-assigned, stable camera_id derived from the device id. // Subscribe to commands (if registered)
if (cfg.camera_id.length() == 0) { if (cfg.camera_id.length() > 0) {
cfg.camera_id = clientID(); // e.g. "rig-86d978" mqtt.subscribe(mqttTopic("command").c_str(), 2);
} }
// Subscribe to our command topic. // Announce if new
mqtt.subscribe(mqttTopic("command").c_str(), 2); if (cfg.camera_id.length() == 0) {
// Announce (retained) on the contract topic so the hub registers/tracks us.
{
JsonDocument doc; JsonDocument doc;
doc["mac_address"] = WiFi.macAddress(); doc["mac_address"] = WiFi.macAddress();
doc["firmware_version"] = "0.4.0-esp32-mqtt-bridge"; doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge";
doc["friendly_name"] = "Cam-" + cfg.camera_id; doc["friendly_name"] = "Cam-" + clientID();
JsonArray caps = doc["capabilities"].to<JsonArray>(); JsonArray caps = doc["capabilities"].to<JsonArray>();
caps.add("start_stop"); caps.add("status"); caps.add("start_stop"); caps.add("status");
String payload; serializeJson(doc, payload); String payload; serializeJson(doc, payload);
mqtt.publish(mqttTopic("announce").c_str(), payload.c_str(), true); mqtt.publish("remoterig/cameras/announce-" + clientID(), payload.c_str(), true);
Serial.printf("[MQTT] Announced as %s\n", cfg.camera_id.c_str()); Serial.println("[MQTT] Announced for registration");
} }
return true; return true;
} }
// ────────────────────────────────────────────
// Status screen + LED
// ────────────────────────────────────────────
// Reflect overall health on the RGB STAT LED.
// red = offline (no Wi-Fi)
// magenta = Wi-Fi up, hub (MQTT) unreachable
// yellow = hub up, GoPro unreachable
// green = healthy (hub + camera reachable)
void updateStatusLed() {
if (WiFi.status() != WL_CONNECTED) rgbWrite(1, 0, 0); // red
else if (!mqtt.connected()) rgbWrite(1, 0, 1); // magenta
else if (!cameraOnline) rgbWrite(1, 1, 0); // yellow
else rgbWrite(0, 1, 0); // green
}
void renderStatus() {
if (!oledReady) return;
oled.clearBuffer();
// Camera id (top, bold)
oled.setFont(u8g2_font_7x14B_tr);
String id = cfg.camera_id.length() ? cfg.camera_id : clientID();
oled.drawStr(0, 13, id.c_str());
oled.setFont(u8g2_font_6x10_tr);
char line[24];
// REC state + session timer
if (dispRecording) {
unsigned long s = recStartMs ? (millis() - recStartMs) / 1000 : 0;
oled.drawBox(0, 19, 6, 6); // filled square = REC
snprintf(line, sizeof(line), "REC %02lu:%02lu", s / 60, s % 60);
oled.drawStr(10, 26, line);
} else {
oled.drawStr(0, 26, "IDLE");
}
// Battery (% when calibrated, else raw) + video remaining (minutes)
int pct = batteryPct(dispBatteryRaw);
if (pct >= 0) snprintf(line, sizeof(line), "BAT %d%% VID %dm", pct, dispVideoRemain / 60);
else snprintf(line, sizeof(line), "BAT %d VID %dm", dispBatteryRaw, dispVideoRemain / 60);
oled.drawStr(0, 38, line);
// Uplink to the hub
const char* link = mqtt.connected() ? "LINK: MQTT ok"
: WiFi.status() == WL_CONNECTED ? "LINK: wifi only"
: "LINK: offline";
oled.drawStr(0, 50, link);
// Camera reachability
oled.drawStr(0, 62, cameraOnline ? "CAM: online" : "CAM: --");
oled.sendBuffer();
}
// ──────────────────────────────────────────── // ────────────────────────────────────────────
// Setup // Setup
// ──────────────────────────────────────────── // ────────────────────────────────────────────
@@ -401,15 +204,14 @@ void setup() {
Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0"); Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0");
bootMs = millis(); bootMs = millis();
rgbInit(); // RGB STAT LED — blue during boot pinMode(2, OUTPUT); // built-in LED
digitalWrite(2, LOW);
displayInit(); // I2C scan + OLED splash
loadConfig(); loadConfig();
// UART to ESP-01S // UART to ESP8266
UART_ESP8266.begin(115200, SERIAL_8N1, UART_RX_PIN, UART_TX_PIN); UART_ESP8266.begin(115200, SERIAL_8N1, 16, 17); // RX=16, TX=17
Serial.println("[UART] ESP-01S link on Serial1 (RX=D7, TX=D6) @ 115200"); Serial.println("[UART] ESP8266 link on RX16/TX17 @ 115200");
// Connect to travel router — the ONLY network we touch // Connect to travel router — the ONLY network we touch
Serial.printf("[WIFI] Connecting to: %s\n", cfg.wifi_ssid.c_str()); Serial.printf("[WIFI] Connecting to: %s\n", cfg.wifi_ssid.c_str());
@@ -442,10 +244,6 @@ void loop() {
static unsigned long lastBeat = 0, lastRecon = 0; static unsigned long lastBeat = 0, lastRecon = 0;
static int reconDelay = 1; static int reconDelay = 1;
// ── OLED + LED refresh (always — keep them live even when offline) ──
static unsigned long lastDisp = 0;
if (now - lastDisp > 500) { lastDisp = now; renderStatus(); updateStatusLed(); }
// ── Wi-Fi watchdog ── // ── Wi-Fi watchdog ──
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); } if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); }
@@ -477,24 +275,18 @@ void loop() {
// Relay camera status to MQTT hub // Relay camera status to MQTT hub
lastStatusMs = now; lastStatusMs = now;
bool online = doc["online"] | false; bool online = doc["online"] | false;
cameraOnline = online; // reflected on the RGB LED by updateStatusLed()
// Mirror status onto the OLED fields if (online != cameraOnline) {
dispBatteryRaw = doc["battery_raw"] | 0; cameraOnline = online;
dispVideoRemain = doc["video_remaining_sec"] | 0; digitalWrite(2, online ? HIGH : LOW);
bool rec = doc["recording"] | false; }
if (rec && !dispRecording) recStartMs = millis();
if (!rec) recStartMs = 0;
dispRecording = rec;
if (cfg.camera_id.length() > 0) { if (cfg.camera_id.length() > 0) {
// Build the MQTT status payload per contract // Build the MQTT status payload per contract
JsonDocument mqttDoc; JsonDocument mqttDoc;
mqttDoc["camera_id"] = cfg.camera_id; mqttDoc["camera_id"] = cfg.camera_id;
// No timestamp: the node has no real clock; the hub stamps on receipt. mqttDoc["timestamp"] = millis();
mqttDoc["battery_raw"] = dispBatteryRaw; mqttDoc["battery_raw"] = doc["battery_raw"] | 0;
int pct = batteryPct(dispBatteryRaw);
if (pct >= 0) mqttDoc["battery_pct"] = pct; // omit when uncalibrated
mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0; mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0;
mqttDoc["recording"] = doc["recording"] | false; mqttDoc["recording"] = doc["recording"] | false;
mqttDoc["online"] = online; mqttDoc["online"] = online;
@@ -505,13 +297,13 @@ void loop() {
} }
} }
else if (type == "ack") { else if (type == "ack") {
Serial.printf("[UART] ESP8266 ack: %s\n", doc["cmd"] | "?"); Serial.printf("[UART] ESP8266 ack: %s\n", (doc["cmd"] | "?").c_str());
} }
else if (type == "pong") { else if (type == "pong") {
Serial.printf("[UART] ESP8266 pong (uptime=%d)\n", doc["uptime_ms"] | 0); Serial.printf("[UART] ESP8266 pong (uptime=%d)\n", doc["uptime_ms"] | 0);
} }
else if (type == "error") { else if (type == "error") {
Serial.printf("[UART] ESP8266 error: %s\n", doc["msg"] | "?"); Serial.printf("[UART] ESP8266 error: %s\n", (doc["msg"] | "?").c_str());
} }
} }
+49 -69
View File
@@ -16,23 +16,17 @@
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n * ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
* *
* Hardware: * Hardware:
* - ESP-01S (ESP8266, 1MB flash) on its own 3.3V buck * - ESP8266 D1 Mini (or NodeMCU)
* - UART is the hardware Serial (GPIO1 TX / GPIO3 RX), crossed: * - UART TX → ESP32 RX (GPIO 16)
* ESP-01S TX (GPIO1) → XIAO D7 (RX) * - UART RX → ESP32 TX (GPIO 16)
* ESP-01S RX (GPIO3) ← XIAO D6 (TX)
* - Shared GND between boards * - Shared GND between boards
* - Flash with a 3.3V USB-UART adapter, GPIO0 → GND on power-up * - LiPo → 3.3V buck → VIN on both boards
*
* Note: the JSON protocol shares the same UART as the boot-ROM/debug
* output, so the ESP32 also sees boot chatter and ignores it as
* non-JSON. There is no spare pin for a status LED on the ESP-01S
* (GPIO1 is the UART TX) — status is shown on the XIAO panel instead.
*/ */
#include <Arduino.h> #include <Arduino.h>
#include <ESP8266WiFi.h> #include <ESP8266WiFi.h>
#include <WiFiClient.h> #include <WiFiClient.h>
#include <ESP8266HTTPClient.h> #include <HTTPClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <LittleFS.h> #include <LittleFS.h>
@@ -41,11 +35,9 @@
// ──────────────────────────────────────────── // ────────────────────────────────────────────
struct Config { struct Config {
// Defaults validated against a GoPro Hero 3 Silver. Per-camera values can String camera_ssid = "GOPRO-BP-";
// be overridden at runtime via the set_config command (no reflash). String camera_password = "goprohero";
String camera_ssid = "goprosilver-1"; String camera_ip = "10.5.5.1";
String camera_password = "Bzyeatn421";
String camera_ip = "10.5.5.9"; // Hero 3 HTTP API host (not .1)
int poll_interval_sec = 30; int poll_interval_sec = 30;
} cfg; } cfg;
@@ -66,22 +58,6 @@ bool loadConfig() {
return true; return true;
} }
// Persist current config to LittleFS. Lets the hub update camera
// credentials/poll rate over UART without reflashing the ESP-01S.
bool saveConfig() {
if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; }
File f = LittleFS.open("/config.json", "w");
if (!f) { Serial.println("[CFG] open for write failed"); return false; }
JsonDocument doc;
doc["camera_ssid"] = cfg.camera_ssid;
doc["camera_password"] = cfg.camera_password;
doc["camera_ip"] = cfg.camera_ip;
doc["poll_interval_sec"] = cfg.poll_interval_sec;
serializeJson(doc, f);
f.close();
return true;
}
// ──────────────────────────────────────────── // ────────────────────────────────────────────
// Camera HTTP Client (GoPro Hero 3) // Camera HTTP Client (GoPro Hero 3)
// ──────────────────────────────────────────── // ────────────────────────────────────────────
@@ -98,37 +74,26 @@ struct CamStatus {
CamStatus fetchStatus() { CamStatus fetchStatus() {
CamStatus s; CamStatus s;
// READ status — must NOT be the shutter endpoint. Hero 3 status blob String url = "http://" + cfg.camera_ip +
// (validated on a Hero 3 Silver, ~31 bytes): "/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
// [29] recording flag (0 idle / 1 recording) — confirmed
// [19] battery level (raw; drains with charge) — calibrate on the hub
// [25..26] video-remaining (provisional)
// The body is binary and starts with 0x00, so read the stream directly —
// Arduino String truncates at the first null byte.
String url = "http://" + cfg.camera_ip + "/camera/se?t=" + cfg.camera_password;
HTTPClient http; HTTPClient http;
http.useHTTP10(true); http.useHTTP10(true);
http.begin(goproClient, url); http.begin(goproClient, url);
http.setTimeout(5000); http.setTimeout(5000);
int code = http.GET(); int code = http.GET();
if (code != 200) { http.end(); return s; } if (code != 200) { http.end(); return s; }
uint8_t buf[40] = {0}; String raw = http.getString();
WiFiClient* stream = http.getStreamPtr();
size_t n = 0;
unsigned long t0 = millis();
while (n < sizeof(buf) && millis() - t0 < 1500) {
if (stream && stream->available()) buf[n++] = (uint8_t)stream->read();
else delay(5);
}
http.end(); http.end();
if (n < 30) return s; if (raw.length() < 58) return s;
const uint8_t* buf = (const uint8_t*)raw.c_str();
s.valid = true; s.valid = true;
s.recording = (buf[29] == 1);
s.battery_raw = buf[19];
s.video_remaining_sec = buf[25] | (buf[26] << 8); s.video_remaining_sec = buf[25] | (buf[26] << 8);
s.recording = (buf[29] == 1);
s.battery_raw = buf[57];
return s; return s;
} }
@@ -208,21 +173,6 @@ void handleCommand(const JsonDocument& doc) {
pong["type"] = "pong"; pong["type"] = "pong";
pong["uptime_ms"] = millis(); pong["uptime_ms"] = millis();
sendToESP32(pong); sendToESP32(pong);
} else if (cmd == "set_config") {
// No-reflash config update from the hub (via the XIAO over UART).
// Only provided fields change; the rest keep their current value.
String oldSsid = cfg.camera_ssid, oldPw = cfg.camera_password;
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
cfg.camera_ip = doc["camera_ip"] | cfg.camera_ip;
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
saveConfig();
sendAck("set_config");
// Re-associate if the camera Wi-Fi credentials changed.
if (cfg.camera_ssid != oldSsid || cfg.camera_password != oldPw) {
WiFi.disconnect();
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
}
} else { } else {
sendError("Unknown command: " + cmd); sendError("Unknown command: " + cmd);
} }
@@ -247,16 +197,26 @@ bool readLine(String& line) {
return false; return false;
} }
// ────────────────────────────────────────────
// LED
// ────────────────────────────────────────────
const int LED = LED_BUILTIN; // active-low on ESP8266 D1 Mini
void ledOn() { digitalWrite(LED, LOW); }
void ledOff() { digitalWrite(LED, HIGH); }
// ──────────────────────────────────────────── // ────────────────────────────────────────────
// Setup // Setup
// ──────────────────────────────────────────── // ────────────────────────────────────────────
// No status LED: GPIO1 is the UART TX to the XIAO and GPIO3 is RX,
// leaving no free pin on the ESP-01S. Status lives on the XIAO panel.
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(500); delay(500);
Serial.println("\n[BRIDGE] ESP-01S Camera Bridge v1.0"); Serial.println("\n[BRIDGE] ESP8266 Camera Bridge v1.0");
pinMode(LED, OUTPUT);
ledOff();
loadConfig(); loadConfig();
@@ -272,6 +232,7 @@ void setup() {
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str()); Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
ledOn(); // Solid = connected
} else { } else {
Serial.println("\n[WIFI] FAILED — will retry in loop"); Serial.println("\n[WIFI] FAILED — will retry in loop");
} }
@@ -285,6 +246,7 @@ void loop() {
unsigned long now = millis(); unsigned long now = millis();
static unsigned long lastPoll = 0; static unsigned long lastPoll = 0;
static unsigned long lastWiFiRetry = 0; static unsigned long lastWiFiRetry = 0;
static bool cameraOnline = false;
// ── Wi-Fi reconnection ── // ── Wi-Fi reconnection ──
if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) { if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) {
@@ -299,6 +261,15 @@ void loop() {
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
CamStatus s = fetchStatus(); CamStatus s = fetchStatus();
if (s.valid && !cameraOnline) {
cameraOnline = true;
ledOn();
} else if (!s.valid && cameraOnline) {
cameraOnline = false;
ledOff();
}
sendStatus(s); sendStatus(s);
} else { } else {
// Offline — send empty status so ESP32 knows we're alive but camera is down // Offline — send empty status so ESP32 knows we're alive but camera is down
@@ -320,4 +291,13 @@ void loop() {
// Ignore other message types — they're for the ESP32 // Ignore other message types — they're for the ESP32
} }
} }
// ── LED blink when offline ──
if (!cameraOnline) {
static unsigned long lastBlink = 0;
if (now - lastBlink > 500) {
lastBlink = now;
digitalWrite(LED, !digitalRead(LED));
}
}
} }
-3
View File
@@ -12,12 +12,9 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
-6
View File
@@ -1,15 +1,11 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
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/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/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/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=
@@ -20,8 +16,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
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/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+15 -115
View File
@@ -2,126 +2,30 @@
> Living queue for 3D-printed / physical hardware design work. > Living queue for 3D-printed / physical hardware design work.
## Active / Ready for CAD prototype ## Active / Ready for prototype print
### 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:** Historical design reference. In this checkout, previous v3 SCAD/STL files are not present; v4 starts a new `hardware/case/` CAD source set. **Status:** STL generated and validated watertight.
**Previous design notes:** **Files:**
- `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`
- Held ESP32 + ESP8266 stack. **Design notes:**
- 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 used M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap. - Clamp uses M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
**Reasons superseded by v4:** **Prototype questions:**
- Does the clamp close enough on smaller tripod legs, or do we need swappable inserts?
- User requested front status/service panel with OLED, LEDs, and rocker switch. - Does the dovetail hold under vibration without a retention screw?
- Single RGB status LED replaces separate red/green status LEDs. - Are USB/LED/UART cutouts in the correct orientation for the actual boards?
- Rear strap pass-through loops are simpler and more adaptable than a dedicated clamp/dovetail for field stands.
## Backlog ## Backlog
@@ -132,7 +36,6 @@ openscad -D 'part="front_review"' -o /tmp/camera-node-case-v4-front-review.stl h
**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
@@ -140,14 +43,12 @@ openscad -D 'part="front_review"' -o /tmp/camera-node-case-v4-front-review.stl h
- 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.
@@ -159,7 +60,6 @@ openscad -D 'part="front_review"' -o /tmp/camera-node-case-v4-front-review.stl h
- 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.
+65 -149
View File
@@ -1,196 +1,112 @@
# RemoteRig — Camera Node Hardware Design # RemoteRig — Camera Node Hardware Design
> **Version:** 0.3.0 | **Status:** v4 CAD prototype ready for measurement/print validation > **Version:** 0.2.0 | **Status:** Draft
> **Target:** GoPro Hero 3 Black/Silver + ESP32-C3 Super Mini + ESP-01S + USB power bank > **Target:** GoPro Hero 3 Black/Silver + ESP8266 + ESP32 + USB power bank
## Overview ## Overview
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. 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.
``` ```
┌─────────────────┐ ┌─────────────────┐
│ USB Power Bank │ │ USB Power Bank │── USB ──→ GoPro (power only)
│ (off-the-shelf)│ │ (off-the-shelf)│── USB ──→ ESP32 + ESP8266 (shared)
└────────────────┘ └────────────────┘
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)
│ │ Flush/recessed service lid │ │ (camera) │ │
│ 1.3 OLED: CAM/REC/BAT/LINK │ │ ├────────────┤ │ ← UART between boards
│ │ PWR LED + RGB STAT LED │ │ ESP32 ← Wi-Fi → Travel Router
│ │ Small rocker power switch │ │ (MQTT) │ │
│ └──────────────────────────────┘ │ └────────────
│ 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-C3 Super Mini | 1 | ~$4$6 | MQTT / hub-side bridge; known board envelope 22.5 × 18 mm | | ESP32 Dev Board | 1 | ~$5 | MQTT bridge — talks to hub |
| ESP-01S / ESP8266 module | 1 | ~$2$3 | Camera-side GoPro Wi-Fi bridge; known envelope ~24.7 × 14.3 × 12 mm | | ESP8266 D1 Mini | 1 | ~$3 | Camera bridge — talks to 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 | | USB power bank (5000mAh+) | 1 | ~$10 | Powers both boards + GoPro |
| 3 mm power LED | 1 | <$1 | Separate always-power/5V indicator | | Micro-USB cable (short) | 2 | ~$2 | Power bank → boards + GoPro |
| 3 mm RGB status LED | 1 | <$1 | Replaces separate red/green status LEDs; firmware can map node states to color | | Jumper wires F-F | 3 | ~$0.25 | UART TX/RX/GND between boards |
| Small rocker switch | 1 | ~$1$3 | On/off switch; prototype CAD assumes 13 × 19 mm snap-in opening | | PETG filament | ~25g | ~$0.50 | 3D printed case |
| 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 | | Velcro strap (small) | 1 | ~$0.25 | Secure power bank to stand |
| 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:** roughly ~$25$35 plus GoPro and power bank, depending on display/switch choice. **Total per node:** ~$21 (+ GoPro already owned)
## 3D Printed Case ## 3D Printed Case
**Current source:** `hardware/case/camera-node-case-v4.scad` **Current source:** `hardware/case/tripod-case-v3.scad`
**Pipeline:** `hardware/DESIGN_PIPELINE.md` **Pipeline:** `hardware/DESIGN_PIPELINE.md`
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: Four exported prototype files:
1. **Case body** — holds both boards stacked, cable ports, rear dovetail-style receiver
1. **Case body** — shell sized around ESP32-C3 Super Mini + ESP-01S with service/wiring clearance. 2. **Case lid** — screw-on cover with ventilation
2. **Flush/recessed full-height front service lid** — screw-on front panel with locating lip and a restrained raised/recessed border. 3. **Tripod clamp** — separate screw-tightened C-clamp sized around a 35mm stand/pole
3. **Front panel controls/indicators**: 4. **Full preview** — combined visualization STL only, not intended as the print job
- 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
- **Material:** PETG preferred for heat/outdoor use and strap-tab durability. - **Layer:** 0.2mm | **Infill:** 20% gyroid minimum; 35%+ recommended for clamp
- **Layer:** 0.2 mm typical. - **Supports:** Likely yes for clamp ears / dovetail overhangs depending on slicer orientation
- **Infill:** 20% gyroid minimum; 30%+ recommended around rear vertical zip-tie bracket anchor pads/bridges. - **Post-processing:** M3x8mm screws for lid (4x), one M3 screw + M3 nut for clamp tightening
- **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-C cable → bottom USB-C female input on Camera Node Case ── USB-A → Micro-USB cable → ESP32 USB port
├── rocker switch → node power rail (powers ESP32, shared 5V rail)
├── PWR LED indicator
├── XIAO ESP32-C6 ├── USB-A → Micro-USB cable → GoPro USB port
├── ESP-01S / ESP8266 │ (power only — no data)
├── 1.3-inch OLED display
├── RGB status LED └── (ESP8266 powered via ESP32 3.3V pin, or via shared USB)
└── 5 V passthrough rail → side USB-A female output
└── USB cable → GoPro USB port
(power only — no data)
UART / control inside case: UART (inside case):
ESP-01S TX (GPIO1) ──→ XIAO D7 (RX) ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16)
ESP-01S RX (GPIO3) ←── XIAO D6 (TX) ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17)
ESP-01S GND ─── XIAO GND ESP8266 GND ─────────── ESP32 GND
# Reserved for UART OTA (XIAO reflashes the ESP-01S — no adapter):
ESP-01S RST ←── XIAO D8 (pulse low to reset)
ESP-01S GPIO0 ←── XIAO D10 (low at reset = bootloader)
# See docs/design/esp01s-uart-ota.md
``` ```
**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. **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.
## Wi-Fi Topology ## Wi-Fi Topology
```text ```
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP-01S / ESP8266 camera bridge GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP8266 (camera bridge)
UART │ (inside case) UART │ (inside case)
Travel Router ──(AP)────────────────────→ ESP32-C3 MQTT bridge Travel Router ──(AP)─────────────────→ ESP32 (MQTT bridge)
(192.168.8.1) (10.60.1.1)
MQTT │ MQTT │
Pi Hub (192.168.8.56) Pi Hub (10.60.1.56)
``` ```
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. 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.
## Field Setup ## Field Setup
1. Mount GoPro on tripod/stand. 1. **Mount GoPro** on tripod/stand
2. Feed two reusable cloth zip ties / Velcro straps laterally through the long side openings behind the rear vertical brackets. 2. **Clip case** to tripod leg
3. Strap the case to a tripod/stand leg; use both strap paths to resist rotation. 3. **Connect power bank** via USB to case + 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. 4. **Power on** — ESP32 auto-connects to travel router, ESP8266 auto-connects to GoPro
5. Toggle rocker switch on. 5. **Monitor** from `http://10.60.1.56:8080`
6. Verify PWR LED, RGB status LED, and OLED status: camera ID, REC state, battery, link, timer.
7. Monitor from `http://192.168.8.56:8080`.
## Case Dimensions ## Case Dimensions
Prototype v4 nominal CAD dimensions: | | W × D × H (mm) |
| Part / feature | W × D × H (mm) |
|---|---| |---|---|
| Case shell external | ~56 × 36 × 82 | | Case body external | ~56.8 × 38.2 × 19.0 |
| Case with rear zip-tie brackets | ~56 × 41.2 × 82 | | Lid external | ~56.8 × 32.8 × 4.0 |
| Front recessed lid | visible panel ~48 × 2 × 74; total with locating lip ~48 × 3 × 74 | | Tripod clamp | ~43.0 × 56.9 × 16.0 |
| OLED visible window assumption | ~31 × 16 | | Clamp pole fit | Nominal 35mm; smaller poles TBD / may need inserts |
| Rocker cutout assumption | ~13 × 19 | | Total weight | TBD after prototype print |
| 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.
@@ -1,4 +0,0 @@
// 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
@@ -1,4 +0,0 @@
// 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
@@ -1,4 +0,0 @@
// 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
@@ -1,4 +0,0 @@
// 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
@@ -1,309 +0,0 @@
// 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();
}
Binary file not shown.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) case_body();
+2
View File
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) case_lid();
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) full_case();
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) tripod_clamp();
Binary file not shown.
+217
View File
@@ -0,0 +1,217 @@
// RemoteRig — Dual-ESP Tripod Case v3
// v3 changes: screw-tightened tripod clamp + dovetail slide interface.
// Coordinate system: all case/lid geometry uses bottom-origin Z.
$fn = 36;
// Board dimensions
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
esp32_w = 52; esp32_d = 28; esp32_h = 5;
board_gap = 3;
stack_h = esp8266_h + esp32_h + board_gap;
inner_w = max(esp8266_w, esp32_w);
inner_d = max(esp8266_d, esp32_d);
inner_h = stack_h + 2;
// Case parameters
wall = 2.0;
tol = 0.4;
outer_w = inner_w + wall*2 + tol*2; // 56.8mm
outer_d = inner_d + wall*2 + tol*2; // 32.8mm
outer_h = inner_h + wall*2; // 19mm
corner_r = 2.5;
// Tripod clamp parameters
pole_dia = 35; // nominal stand/pole diameter
clamp_thick = 4.0; // ring wall thickness
clamp_width = 16.0; // extrusion width along Z
mouth_width = 13.0; // clamp opening
m3_clearance = 3.4; // M3 screw clearance
nut_flat = 6.4; // M3 nut trap flat-to-flat
// Dovetail slide interface
// Male rail is on the case; matching female socket is on the tripod clamp.
// This is easier to inspect and avoids the previous mismatched "two lips + tab" geometry.
rail_z = outer_h * 0.78;
rail_depth = 5.0;
rail_neck_w = 12.0; // narrow width at case wall / slot opening
rail_outer_w = 18.0; // wider retained edge
rail_clearance = 0.45; // FDM sliding clearance per side-ish
socket_wall = 2.2;
// Cable ports
usb_port_w = 12; usb_port_h = 6;
uart_port_w = 6; uart_port_h = 4;
// Uncomment one for manual OpenSCAD use
// full_case();
// case_body();
// case_lid();
// tripod_clamp();
module rounded_cube_centered(w, d, h, r) {
hull() {
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
sphere(r=r, $fn=24);
}
}
}
module rounded_cube0(w, d, h, r) {
translate([0, 0, h/2]) rounded_cube_centered(w, d, h, r);
}
module hex_prism(d, h) {
cylinder(d=d, h=h, center=true, $fn=6);
}
module dovetail_prism(length_z, front_w, back_w, depth) {
// 2D profile is X/Y, extruded along Z.
rotate([0, 0, 0])
linear_extrude(height=length_z, center=true, convexity=10)
polygon(points=[
[-front_w/2, 0], [front_w/2, 0],
[back_w/2, depth], [-back_w/2, depth]
]);
}
module case_shell() {
difference() {
rounded_cube0(outer_w, outer_d, outer_h, corner_r);
// Open internal cavity: starts above bottom wall, extends past top.
translate([0, 0, wall])
rounded_cube0(inner_w + tol, inner_d + tol, outer_h + 2, 1.6);
// USB power IN / OUT ports through front/back walls.
translate([0, outer_d/2 + 0.1, wall + 4])
cube([usb_port_w, wall*3, usb_port_h], center=true);
translate([0, -outer_d/2 - 0.1, wall + 4])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// UART side channel.
translate([outer_w/2 + 0.1, 0, wall + 6])
cube([wall*3, uart_port_w, uart_port_h], center=true);
// LED viewing window on front lower wall.
translate([-outer_w/4, -outer_d/2 - 0.1, wall + 2])
cube([6, wall*2, 3], center=true);
}
}
module screw_post(x, y) {
difference() {
translate([x, y, wall]) cylinder(d=5.0, h=outer_h-wall-0.5, center=false, $fn=24);
translate([x, y, wall-0.5]) cylinder(d=2.1, h=outer_h+1, center=false, $fn=20);
}
}
module case_male_dovetail_rail() {
// Positive tapered rail on the case back. Cross-section is narrow at the
// wall and wider at the outside, so the clamp socket captures it.
translate([0, outer_d/2 - 0.15, outer_h/2])
dovetail_prism(rail_z, rail_neck_w, rail_outer_w, rail_depth);
// Bottom stop so the clamp socket cannot slide past the case.
translate([0, outer_d/2 + rail_depth/2, outer_h*0.12])
rounded_cube_centered(rail_outer_w + 3.0, rail_depth + 0.8, 2.4, 0.8);
}
module case_body() {
union() {
case_shell();
for (x = [-1, 1], y = [-1, 1])
screw_post(x*(outer_w/2 - 5), y*(outer_d/2 - 5));
case_male_dovetail_rail();
}
}
module case_lid() {
difference() {
rounded_cube0(outer_w, outer_d, wall*2, 1.8);
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), -0.5])
cylinder(d=2.4, h=wall*2 + 1, center=false, $fn=20);
}
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, wall*2/2])
cube([8, outer_d*0.6, wall*3], center=true);
}
}
}
module clamp_ring_with_mouth() {
outer_r = pole_dia/2 + clamp_thick;
difference() {
cylinder(r=outer_r, h=clamp_width, center=true, $fn=72);
cylinder(r=pole_dia/2 + rail_clearance, h=clamp_width + 1, center=true, $fn=72);
// Mouth opens toward +Y. Width is intentionally generous for snap-on placement before tightening.
translate([0, outer_r, 0])
cube([mouth_width, outer_r*2, clamp_width + 2], center=true);
}
}
module clamp_ears() {
outer_r = pole_dia/2 + clamp_thick;
ear_y = outer_r + 2.2;
ear_z = 0;
difference() {
union() {
translate([-mouth_width/2 - 3.2, ear_y, ear_z])
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
translate([ mouth_width/2 + 3.2, ear_y, ear_z])
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
}
// M3 screw passes across the mouth along X.
translate([0, ear_y, ear_z])
rotate([0, 90, 0]) cylinder(d=m3_clearance, h=mouth_width + 24, center=true, $fn=24);
// Nut trap on the right ear.
translate([mouth_width/2 + 3.2, ear_y, ear_z])
rotate([0, 90, 0]) hex_prism(nut_flat, 4.2);
}
}
module clamp_dovetail_socket() {
outer_r = pole_dia/2 + clamp_thick;
socket_outer_w = rail_outer_w + socket_wall*2;
socket_depth = rail_depth + socket_wall*2;
// Solid boss on the rear of the clamp, opposite the tightening mouth.
// A matching dovetail void is cut through it along Z so the case rail
// slides in from the top/bottom with practical FDM clearance.
difference() {
translate([0, -outer_r - socket_depth/2 + socket_wall, 0])
rounded_cube_centered(socket_outer_w, socket_depth, clamp_width, 1.2);
translate([0, -outer_r - 0.15, 0])
dovetail_prism(
clamp_width + 1.0,
rail_neck_w + rail_clearance,
rail_outer_w + rail_clearance,
rail_depth + 0.6
);
}
}
module tripod_clamp() {
union() {
clamp_ring_with_mouth();
clamp_ears();
clamp_dovetail_socket();
}
}
// Backward-compatible alias for earlier export scripts.
module tripod_clip() {
tripod_clamp();
}
module full_case() {
case_body();
translate([0, 0, outer_h + 2]) case_lid();
translate([0, outer_d/2 + pole_dia/2 + clamp_thick + 8, outer_h/2])
rotate([90, 0, 0]) tripod_clamp();
}
+201
View File
@@ -0,0 +1,201 @@
// RemoteRig — Dual-ESP Tripod Case
// =================================
// Small box that clips onto a tripod leg or light stand pole.
// Holds ESP8266 D1 Mini + ESP32 Dev Board (stacked).
// Powered by standard USB battery pack. No camera sleeve needed.
//
// Print settings:
// Material: PETG | Layer: 0.2mm | Infill: 20% gyroid
// Supports: yes (for clip overhang) | Brim: 5mm
// ── Board dimensions ──
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
esp32_w = 52; esp32_d = 28; esp32_h = 5;
board_gap = 3; // air gap between stacked boards
stack_h = esp8266_h + esp32_h + board_gap;
inner_w = max(esp8266_w, esp32_w);
inner_d = max(esp8266_d, esp32_d);
inner_h = stack_h + 2;
// ── Case parameters ──
wall = 2.0;
tol = 0.4;
outer_w = inner_w + wall*2 + tol*2;
outer_d = inner_d + wall*2 + tol*2;
outer_h = inner_h + wall*2;
// ── Tripod clip parameters ──
pole_min_dia = 20; // smallest pole
pole_max_dia = 35; // largest pole
clip_width = 12; // clip width
clip_thick = 3; // clip arm thickness
clip_grip = 2; // grip ridges
// ── Cable ports ──
usb_port_w = 12; usb_port_h = 6;
uart_port_w = 6; uart_port_h = 4;
// ══════════════════════════════════════════════════════════════
// MAIN — render the full case
// ══════════════════════════════════════════════════════════════
// Uncomment to render individual parts:
full_case();
// case_body();
// case_lid();
// tripod_clip();
module full_case() {
case_body();
// Lid positioned above (for visualization)
translate([0, 0, outer_h + 2])
case_lid();
// Clip on the back
translate([0, outer_d/2 + pole_max_dia/2 + clip_thick, outer_h/2])
tripod_clip();
}
// ══════════════════════════════════════════════════════════════
// Case Body — holds both boards, cable ports
// ══════════════════════════════════════════════════════════════
module case_body() {
difference() {
// Outer shell
rounded_cube(outer_w, outer_d, outer_h, 3);
// Inner cavity
translate([0, 0, wall])
rounded_cube(inner_w + tol, inner_d + tol, inner_h + tol, 2);
// ── Board recesses ──
// Bottom: ESP32 (larger board)
translate([0, 0, wall + 1])
cube([esp32_w + tol, esp32_d + tol, esp32_h + 1], center=true);
// Top: ESP8266 (smaller board)
translate([0, 0, wall + esp32_h + board_gap + 1])
cube([esp8266_w + tol, esp8266_d + tol, esp8266_h + 1], center=true);
// ── Cable ports ──
// USB power IN (from battery pack → ESP32)
translate([0, outer_d/2, outer_h/3])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// USB power OUT (from battery pack → GoPro)
translate([0, -outer_d/2, outer_h/3])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// UART wire channel (ESP8266 → ESP32 internal)
translate([outer_w/2, 0, outer_h/2])
cube([wall*3, uart_port_w, uart_port_h], center=true);
// ── Ventilation slots (top edge) ──
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, outer_h - wall])
cube([8, outer_d*0.6, 2], center=true);
}
// ── Screw posts for lid ──
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), outer_h/2])
cylinder(d=3.2, h=outer_h, center=true, $fn=16);
}
// ── LED window (thin spot to see board LEDs) ──
translate([-outer_w/4, -outer_d/2, wall])
cube([6, 1, 3], center=true);
}
// ── Tripod clip mount (rail on back) ──
translate([0, outer_d/2, outer_h/2])
rotate([90, 0, 0])
difference() {
cube([clip_width + 4, outer_h*0.7, 6], center=true);
// T-slot for clip to slide in
cube([clip_width + 1, outer_h*0.7 + 1, 4], center=true);
}
}
// ══════════════════════════════════════════════════════════════
// Case Lid — snap-fit or screw-on cover
// ══════════════════════════════════════════════════════════════
module case_lid() {
difference() {
rounded_cube(outer_w, outer_d, wall*2, 2);
// Screw holes (match body posts)
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), 0])
cylinder(d=3.2, h=wall*3, center=true, $fn=16);
}
// Ventilation slots (match body)
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, 0])
cube([8, outer_d*0.6, 3], center=true);
}
}
}
// ══════════════════════════════════════════════════════════════
// Tripod Clip — C-clamp for pole mounting
// ══════════════════════════════════════════════════════════════
module tripod_clip() {
difference() {
union() {
// Main body
hull() {
translate([0, -pole_max_dia/2 - clip_thick, 0])
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
translate([0, pole_max_dia/2 + clip_thick, 0])
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
}
// Top arm (flexible)
translate([0, -pole_max_dia/2 - clip_thick, outer_h*0.35])
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
// Bottom arm
translate([0, -pole_max_dia/2 - clip_thick, -outer_h*0.35])
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
// Mounting tab (slides into case rail)
translate([0, -pole_max_dia/2 - clip_thick*3, 0])
cube([clip_width + 1, clip_thick*2, outer_h*0.7], center=true);
}
// Pole hole
cylinder(d=pole_max_dia + 2, h=outer_h*1.5, center=true, $fn=32);
// Grip ridges on inner surface
for (z = [-outer_h*0.25, 0, outer_h*0.25]) {
translate([0, 0, z])
rotate_extrude(angle=180, $fn=32)
translate([pole_max_dia/2 + 0.5, 0])
circle(d=1);
}
// Entry slot (pole slides in from front)
translate([0, pole_max_dia/2 + clip_thick, 0])
cube([clip_width + 2, pole_max_dia + 10, outer_h*0.7], center=true);
}
}
// ══════════════════════════════════════════════════════════════
// Utility: rounded cube
// ══════════════════════════════════════════════════════════════
module rounded_cube(w, d, h, r) {
hull() {
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
sphere(r=r, $fn=20);
}
}
}
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteRig Case — 3D Viewer</title>
<style>
body { margin: 0; overflow: hidden; background: #1a1a2e; font-family: system-ui; }
canvas { display: block; }
#info {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
color: #888; font-size: 13px; pointer-events: none;
}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ── Scene setup ──
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
scene.fog = new THREE.Fog(0x1a1a2e, 8, 25);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.5, 50);
camera.position.set(5, 3.5, 7);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ── Lighting ──
const ambient = new THREE.AmbientLight(0x404060, 0.6);
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 1.2);
key.position.set(8, 10, 5);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 0.5; key.shadow.camera.far = 50;
key.shadow.camera.left = -10; key.shadow.camera.right = 10;
key.shadow.camera.top = 10; key.shadow.camera.bottom = -10;
scene.add(key);
const fill = new THREE.DirectionalLight(0x8899cc, 0.4);
fill.position.set(-3, 2, -2);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xaaccff, 0.5);
rim.position.set(0, 1, -5);
scene.add(rim);
// ── Ground ──
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.8 })
);
ground.rotation.x = -Math.PI/2;
ground.position.y = -3;
ground.receiveShadow = true;
scene.add(ground);
// ── Materials ──
const petgMat = new THREE.MeshStandardMaterial({
color: 0x3d3d4a, roughness: 0.35, metalness: 0.1,
});
const accentMat = new THREE.MeshStandardMaterial({
color: 0xf59e0b, roughness: 0.3, metalness: 0.2, emissive: 0x331100, emissiveIntensity: 0.3
});
const boardMat = new THREE.MeshStandardMaterial({
color: 0x1a6630, roughness: 0.6
});
const metalMat = new THREE.MeshStandardMaterial({
color: 0x888899, roughness: 0.3, metalness: 0.8
});
// ── Create rounded box with bevel ──
function createRoundedBox(w, h, d, r, segments = 3) {
const shape = new THREE.Shape();
const hw = w/2 - r, hh = h/2 - r;
shape.moveTo(-hw, -hh + r);
shape.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
shape.lineTo(hw - r, -hh);
shape.quadraticCurveTo(hw, -hh, hw, -hh + r);
shape.lineTo(hw, hh - r);
shape.quadraticCurveTo(hw, hh, hw - r, hh);
shape.lineTo(-hw + r, hh);
shape.quadraticCurveTo(-hw, hh, -hw, hh - r);
shape.closePath();
const extrudeSettings = { depth: d - r*2, bevelEnabled: true, bevelThickness: r, bevelSize: r, bevelSegments: segments };
const geom = new THREE.ExtrudeGeometry(shape, extrudeSettings);
geom.translate(0, 0, -d/2 + r);
return geom;
}
// ── Case Body ──
const caseW = 2.5, caseH = 1.5, caseD = 1.1;
const bodyGeom = createRoundedBox(caseW, caseD, caseH, 0.12);
const body = new THREE.Mesh(bodyGeom, petgMat);
body.castShadow = true; body.receiveShadow = true;
scene.add(body);
// ── Lid (slightly offset) ──
const lidGeom = createRoundedBox(caseW, caseD, 0.15, 0.08);
const lid = new THREE.Mesh(lidGeom, petgMat);
lid.position.y = caseH/2 + 0.07;
lid.castShadow = true;
scene.add(lid);
// ── Ventilation slots ──
for (let i = -0.6; i <= 0.6; i += 0.6) {
const slot = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.04, caseD * 0.7),
new THREE.MeshStandardMaterial({ color: 0x1a1a2e })
);
slot.position.set(i, caseH/2 + 0.15, 0);
scene.add(slot);
}
// ── Screws ──
for (let x = -1; x <= 1; x += 2) {
for (let z = -0.35; z <= 0.35; z += 0.7) {
const screw = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.05, 0.04, 8),
metalMat
);
screw.position.set(x * (caseW/2 - 0.2), caseH/2 + 0.15, z);
scene.add(screw);
}
}
// ── Boards inside (semi-visible) ──
const esp32Board = new THREE.Mesh(
new THREE.BoxGeometry(caseW - 0.3, 0.04, caseD - 0.2),
boardMat
);
esp32Board.position.set(0, caseH/2 - 0.15, 0);
esp32Board.castShadow = true;
scene.add(esp32Board);
const esp8266Board = new THREE.Mesh(
new THREE.BoxGeometry(caseW - 0.5, 0.04, caseD - 0.3),
boardMat
);
esp8266Board.position.set(0, caseH/2 - 0.08, 0);
esp8266Board.castShadow = true;
scene.add(esp8266Board);
// Chip on ESP32
const chip = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.03, 0.3),
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
);
chip.position.set(0, caseH/2 - 0.12, 0);
scene.add(chip);
// LED
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.03, 8, 8),
new THREE.MeshStandardMaterial({ color: 0x00ff44, roughness: 0.2, emissive: 0x00ff44, emissiveIntensity: 1.5 })
);
led.position.set(-0.8, caseH/2 - 0.12, -0.3);
scene.add(led);
// ── USB Port (front face) ──
const usbPort = new THREE.Mesh(
new THREE.BoxGeometry(0.35, 0.02, 0.15),
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
);
usbPort.position.set(0, 0.2, caseD/2);
scene.add(usbPort);
// ── Tripod Clip ──
const clipGroup = new THREE.Group();
clipGroup.position.set(0, 0, -caseD/2 - 0.7);
// Clip arms
for (let y = -0.4; y <= 0.4; y += 0.8) {
const arm = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.08, 0.8),
petgMat
);
arm.position.set(0, y, 0.3);
arm.castShadow = true;
clipGroup.add(arm);
}
// Clip body
const clipBody = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 1.0, 0.15),
petgMat
);
clipBody.position.set(0, 0, -0.1);
clipBody.castShadow = true;
clipGroup.add(clipBody);
scene.add(clipGroup);
// ── Tripod Pole ──
const poleGeom = new THREE.CylinderGeometry(0.35, 0.35, 6, 24);
const poleMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.4, metalness: 0.3 });
const pole = new THREE.Mesh(poleGeom, poleMat);
pole.position.set(0, 0, -caseD/2 - 1.2);
pole.castShadow = true; pole.receiveShadow = true;
scene.add(pole);
// ── USB Cables ──
function createCable(start, end, color = 0x222233) {
const curve = new THREE.CubicBezierCurve3(
start,
new THREE.Vector3(start.x + 0.5, start.y - 0.5, start.z + 0.2),
new THREE.Vector3(end.x - 0.3, end.y - 0.3, end.z + 0.1),
end
);
const geom = new THREE.TubeGeometry(curve, 20, 0.03, 8, false);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6 });
return new THREE.Mesh(geom, mat);
}
const cable1 = createCable(
new THREE.Vector3(0, 0.2, caseD/2),
new THREE.Vector3(-2, -1, 1)
);
cable1.castShadow = true;
scene.add(cable1);
const cable2 = createCable(
new THREE.Vector3(0.1, 0.2, caseD/2),
new THREE.Vector3(2, -1.5, 1.2),
0x332222
);
cable2.castShadow = true;
scene.add(cable2);
// ── Interaction ──
let isDragging = false, prevMouse = { x: 0, y: 0 };
let rotY = 0.4, rotX = 0.3, zoom = 7;
document.addEventListener('mousedown', e => { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
document.addEventListener('mouseup', () => isDragging = false);
document.addEventListener('mousemove', e => {
if (!isDragging) return;
rotY += (e.clientX - prevMouse.x) * 0.005;
rotX += (e.clientY - prevMouse.y) * 0.005;
rotX = Math.max(-0.8, Math.min(1.2, rotX));
prevMouse = { x: e.clientX, y: e.clientY };
});
document.addEventListener('wheel', e => {
zoom += e.deltaY * 0.005;
zoom = Math.max(3, Math.min(15, zoom));
});
// ── Render loop ──
function animate() {
requestAnimationFrame(animate);
camera.position.x = zoom * Math.sin(rotY) * Math.cos(rotX);
camera.position.y = zoom * Math.sin(rotX);
camera.position.z = zoom * Math.cos(rotY) * Math.cos(rotX);
camera.lookAt(0, -0.1, 0);
renderer.render(scene, camera);
}
animate();
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
-596
View File
@@ -1,596 +0,0 @@
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, nil))
r.Post("/cameras/{id}/stop", StopRecording(database, nil))
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)
}
}
+19 -26
View File
@@ -6,7 +6,6 @@ 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"
@@ -26,11 +25,11 @@ func ListCameras(database *db.DB) http.HandlerFunc {
c.friendly_name, c.friendly_name,
s.battery_pct, s.battery_pct,
s.video_remaining_sec, s.video_remaining_sec,
COALESCE(s.recording_state, 0), s.recording_state,
s.mode, s.mode,
s.resolution, s.resolution,
s.fps, s.fps,
COALESCE(s.online, 0), s.online,
s.recorded_at s.recorded_at
FROM cameras c FROM cameras c
LEFT JOIN ( LEFT JOIN (
@@ -43,7 +42,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)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
defer rows.Close() defer rows.Close()
@@ -52,22 +51,20 @@ func ListCameras(database *db.DB) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var sl models.StatusLog var sl models.StatusLog
var c models.Camera var c models.Camera
var recordedAt sql.NullTime // NULL for a camera with no status yet
if err := rows.Scan( if err := rows.Scan(
&c.CameraID, &c.FriendlyName, &c.CameraID, &c.FriendlyName,
&sl.BatteryPct, &sl.VideoRemainingSec, &sl.BatteryPct, &sl.VideoRemainingSec,
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS, &sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
&sl.Online, &recordedAt, &sl.Online, &sl.RecordedAt,
); err != nil { ); err != nil {
log.Printf("Error scanning camera row: %v", err) log.Printf("Error scanning camera row: %v", err)
continue continue
} }
sl.RecordedAt = recordedAt.Time // zero time if no status
statuses = append(statuses, models.NewCameraStatus(c, sl)) statuses = append(statuses, models.NewCameraStatus(c, sl))
} }
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)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
@@ -87,10 +84,13 @@ 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 !decodeJSONBody(w, r, &req) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
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 +99,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 isUniqueConstraintErr(err) { if err.Error() == "UNIQUE constraint failed: cameras.mac_address" {
respondError(w, http.StatusConflict, "camera already registered", err.Error()) respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"})
return return
} }
log.Printf("Error registering camera: %v", err) log.Printf("Error registering camera: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
@@ -124,7 +124,8 @@ 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 !validateCameraID(w, cameraID) { if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -138,12 +139,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
&c.CreatedAt, &c.UpdatedAt, &c.CreatedAt, &c.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "camera not found", err.Error()) respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"})
return return
} }
if err != nil { if err != nil {
log.Printf("Error querying camera: %v", err) log.Printf("Error querying camera: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
@@ -164,7 +165,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)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
@@ -179,7 +180,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)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
defer historyRows.Close() defer historyRows.Close()
@@ -209,14 +210,6 @@ 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")
+265
View File
@@ -0,0 +1,265 @@
package api
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/cubecraft/remoterig/internal/db"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
)
// openTestDB creates a file-based SQLite database with the schema applied
// manually, bypassing the migration splitter in db.Open.
func openTestDB(t *testing.T) *db.DB {
t.Helper()
f, err := os.CreateTemp("", "remoterig-api-test-*.db")
if err != nil {
t.Fatalf("create temp: %v", err)
}
path := f.Name()
f.Close()
t.Cleanup(func() { os.Remove(path) })
sqldb, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open db: %v", err)
}
// Apply pragmas
if _, err := sqldb.Exec("PRAGMA journal_mode=WAL"); err != nil {
t.Fatalf("WAL: %v", err)
}
if _, err := sqldb.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("FK: %v", err)
}
// Apply schema directly
statements := []string{
`CREATE TABLE IF NOT EXISTS cameras (camera_id TEXT PRIMARY KEY, friendly_name TEXT NOT NULL, mac_address TEXT UNIQUE, created_at DATETIME NOT NULL DEFAULT (datetime('now')), updated_at DATETIME NOT NULL DEFAULT (datetime('now')))`,
`CREATE TABLE IF NOT EXISTS status_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id TEXT NOT NULL REFERENCES cameras(camera_id), recorded_at DATETIME NOT NULL DEFAULT (datetime('now')), battery_pct INTEGER, video_remaining_sec INTEGER, recording_state INTEGER NOT NULL DEFAULT 0, mode TEXT, resolution TEXT, fps INTEGER, online INTEGER NOT NULL DEFAULT 1, raw_battery_pct REAL)`,
`CREATE TABLE IF NOT EXISTS recording_events (id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id TEXT NOT NULL REFERENCES cameras(camera_id), started_at DATETIME NOT NULL, stopped_at DATETIME, reason TEXT, duration INTEGER)`,
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT (datetime('now')))`,
`CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address)`,
`CREATE INDEX IF NOT EXISTS idx_status_logs_camera_time ON status_logs(camera_id, recorded_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_recording_events_camera_time ON recording_events(camera_id, started_at DESC)`,
}
for _, stmt := range statements {
if _, err := sqldb.Exec(stmt); err != nil {
t.Fatalf("exec schema: %v\nstmt: %s", err, stmt[:min(len(stmt), 80)])
}
}
return &db.DB{DB: sqldb}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// seedTestCamera inserts a camera and optional status logs into the database.
func seedTestCamera(t *testing.T, database *db.DB, cameraID, friendlyName string, statusLogs int) {
t.Helper()
_, err := database.Exec(`
INSERT INTO cameras (camera_id, friendly_name, mac_address) VALUES (?, ?, ?)
`, cameraID, friendlyName, cameraID+"-mac")
if err != nil {
t.Fatalf("failed to seed camera: %v", err)
}
for i := 0; i < statusLogs; i++ {
online := 1
if i == 0 {
online = 0
}
_, err := database.Exec(`
INSERT INTO status_logs (camera_id, battery_pct, video_remaining_sec, recording_state, mode, resolution, fps, online, recorded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', ? || ' minutes'))
`, cameraID, 80-i, 3600-i*100, i%2, "video", "4K", 30, online, fmt.Sprintf("-%d", i))
if err != nil {
t.Fatalf("failed to seed status_log %d: %v", i, err)
}
}
}
func TestGetCameraDetail_NotFound(t *testing.T) {
database := openTestDB(t)
defer database.Close()
handler := GetCameraDetail(database)
r := chi.NewRouter()
r.Get("/cameras/{id}", handler)
req := httptest.NewRequest(http.MethodGet, "/cameras/nonexistent", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "camera not found" {
t.Errorf("expected 'camera not found' error, got %q", resp["error"])
}
}
func TestGetCameraDetail_Success(t *testing.T) {
database := openTestDB(t)
defer database.Close()
seedTestCamera(t, database, "cam-1", "GoPro Hero", 3)
handler := GetCameraDetail(database)
r := chi.NewRouter()
r.Get("/cameras/{id}", handler)
req := httptest.NewRequest(http.MethodGet, "/cameras/cam-1", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
camera, ok := resp["camera"].(map[string]interface{})
if !ok {
t.Fatal("response missing 'camera' field")
}
if camera["camera_id"] != "cam-1" {
t.Errorf("expected camera_id 'cam-1', got %v", camera["camera_id"])
}
if camera["friendly_name"] != "GoPro Hero" {
t.Errorf("expected friendly_name 'GoPro Hero', got %v", camera["friendly_name"])
}
history, ok := resp["history"].([]interface{})
if !ok {
t.Fatal("response missing 'history' field or wrong type")
}
if len(history) != 3 {
t.Errorf("expected 3 history entries, got %d", len(history))
}
}
func TestGetCameraDetail_EmptyHistory(t *testing.T) {
database := openTestDB(t)
defer database.Close()
seedTestCamera(t, database, "cam-2", "Cam No Status", 0)
handler := GetCameraDetail(database)
r := chi.NewRouter()
r.Get("/cameras/{id}", handler)
req := httptest.NewRequest(http.MethodGet, "/cameras/cam-2", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
history, ok := resp["history"].([]interface{})
if !ok {
t.Fatal("response missing 'history' field")
}
if len(history) != 0 {
t.Errorf("expected empty history, got %d entries", len(history))
}
}
func TestGetCameraDetail_HistoryLimitedTo100(t *testing.T) {
database := openTestDB(t)
defer database.Close()
seedTestCamera(t, database, "cam-3", "Verbose Cam", 105)
handler := GetCameraDetail(database)
r := chi.NewRouter()
r.Get("/cameras/{id}", handler)
req := httptest.NewRequest(http.MethodGet, "/cameras/cam-3", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
history, ok := resp["history"].([]interface{})
if !ok {
t.Fatal("response missing 'history' field")
}
if len(history) > 100 {
t.Errorf("expected history capped at 100, got %d", len(history))
}
}
func TestGetCameraDetail_MissingID(t *testing.T) {
database := openTestDB(t)
defer database.Close()
handler := GetCameraDetail(database)
// Without a chi router, chi.URLParam returns "", triggering the 400 branch
req := httptest.NewRequest(http.MethodGet, "/cameras/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestGetCameraDetail_LastStatusPresent(t *testing.T) {
database := openTestDB(t)
defer database.Close()
seedTestCamera(t, database, "cam-4", "Status Cam", 2)
handler := GetCameraDetail(database)
r := chi.NewRouter()
r.Get("/cameras/{id}", handler)
req := httptest.NewRequest(http.MethodGet, "/cameras/cam-4", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
lastStatus, ok := resp["last_status"].(map[string]interface{})
if !ok {
t.Fatal("response missing 'last_status' field or wrong type")
}
if lastStatus["camera_id"] != "cam-4" {
t.Errorf("expected last_status camera_id 'cam-4', got %v", lastStatus["camera_id"])
}
}
-116
View File
@@ -1,116 +0,0 @@
// 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
}
+16 -48
View File
@@ -9,17 +9,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// CommandPublisher sends a command to a camera (implemented by the MQTT
// subscriber). Nil is allowed (e.g. in tests) — the command is then skipped.
type CommandPublisher interface {
PublishCommand(cameraID, command string) error
}
// StartRecording returns a handler for POST /cameras/{id}/start. // StartRecording returns a handler for POST /cameras/{id}/start.
func StartRecording(database *db.DB, pub CommandPublisher) 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 !validateCameraID(w, cameraID) { if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -27,13 +22,8 @@ func StartRecording(database *db.DB, pub CommandPublisher) 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 { if err != nil || exists == 0 {
log.Printf("Error checking camera existence: %v", err) respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -44,21 +34,12 @@ func StartRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error starting recording: %v", err) log.Printf("Error starting recording: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
rowsAffected, _ := result.RowsAffected() rows, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected) log.Printf("Recording started on %s (%d rows affected)", cameraID, rows)
// Send the actual command to the camera over MQTT.
if pub != nil {
if err := pub.PublishCommand(cameraID, "start_recording"); err != nil {
log.Printf("Error sending start_recording to %s: %v", cameraID, err)
respondError(w, http.StatusBadGateway, "failed to send command to camera", err.Error())
return
}
}
respondJSON(w, http.StatusOK, map[string]string{ respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_started", "status": "recording_started",
@@ -68,10 +49,11 @@ func StartRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
} }
// StopRecording returns a handler for POST /cameras/{id}/stop. // StopRecording returns a handler for POST /cameras/{id}/stop.
func StopRecording(database *db.DB, pub CommandPublisher) 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 !validateCameraID(w, cameraID) { if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -79,13 +61,8 @@ func StopRecording(database *db.DB, pub CommandPublisher) 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 { if err != nil || exists == 0 {
log.Printf("Error checking camera existence: %v", err) respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -96,21 +73,12 @@ func StopRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error stopping recording: %v", err) log.Printf("Error stopping recording: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
rowsAffected, _ := result.RowsAffected() rows, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected) log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows)
// Send the actual command to the camera over MQTT.
if pub != nil {
if err := pub.PublishCommand(cameraID, "stop_recording"); err != nil {
log.Printf("Error sending stop_recording to %s: %v", cameraID, err)
respondError(w, http.StatusBadGateway, "failed to send command to camera", err.Error())
return
}
}
respondJSON(w, http.StatusOK, map[string]string{ respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_stopped", "status": "recording_stopped",
+8 -19
View File
@@ -2,6 +2,7 @@
package api package api
import ( import (
"encoding/json"
"log" "log"
"net/http" "net/http"
@@ -13,7 +14,8 @@ 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 !validateCameraID(w, cameraID) { if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -28,16 +30,8 @@ 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 !decodeJSONBody(w, r, &req) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
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
} }
@@ -45,13 +39,8 @@ 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 { if err != nil || exists == 0 {
log.Printf("Error checking camera existence: %v", err) respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -65,7 +54,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)
respondError(w, http.StatusInternalServerError, "database error", err.Error()) respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return return
} }
+28 -73
View File
@@ -4,11 +4,9 @@ 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"
) )
@@ -16,16 +14,13 @@ 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 using a schema_version table for tracking. // and runs all migrations if the tables don't exist yet.
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)
@@ -50,57 +45,34 @@ func Open(path string) (*DB, error) {
return nil, err return nil, err
} }
// Ensure schema_version table exists for migration tracking // Check if tables already exist (idempotent migration)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil { var count int
if err := db.QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings')
`).Scan(&count); err != nil {
db.Close() db.Close()
return nil, err return nil, err
} }
// Read current schema version (0 if table is empty) if count < 4 {
var currentVersion int log.Printf("Running migrations for %s...", path)
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&currentVersion); err != nil { if err := migrate(db, migration001); 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 by splitting on semicolons. // migrate executes a SQL migration string.
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 = strings.TrimSpace(stmt) stmt = stripWhitespace(stmt)
if stmt == "" { if stmt == "" {
continue continue
} }
@@ -111,13 +83,8 @@ 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
@@ -139,42 +106,30 @@ 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 strings.TrimSpace(current) != "" { if len(current) > 0 {
stmts = append(stmts, current) stmts = append(stmts, current)
} }
return stmts return stmts
} }
// stripSQLLineComments removes all -- single-line comments from SQL text. // stripWhitespace removes leading/trailing whitespace and normalizes newlines.
func stripSQLLineComments(sql string) string { func stripWhitespace(s string) string {
var result strings.Builder result := ""
i := 0 runningSpace := false
runes := []rune(sql) for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
for i < len(runes) { if !runningSpace {
r := runes[i] result += " "
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++
} }
// Replace comment with a newline (preserves statement boundaries) } else {
result.WriteRune('\n') result += string(r)
continue runningSpace = false
} }
result.WriteRune(r)
i++
} }
return result
return result.String()
} }
+49
View File
@@ -0,0 +1,49 @@
package db
import (
"os"
"strings"
"testing"
)
func TestOpenMigration(t *testing.T) {
f, err := os.CreateTemp("", "remoterig-db-test-*.db")
if err != nil {
t.Fatalf("create temp: %v", err)
}
path := f.Name()
f.Close()
defer os.Remove(path)
database, err := Open(path)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
defer database.Close()
var count int
err = database.QueryRow("SELECT COUNT(*) FROM cameras").Scan(&count)
if err != nil {
t.Fatalf("query cameras: %v", err)
}
t.Logf("cameras table exists, count=%d", count)
}
func TestSplitSQLComments(t *testing.T) {
sql := migration001
stmts := splitSQL(sql)
for i, s := range stmts {
s = strings.TrimSpace(s)
if s == "" {
continue
}
t.Logf("Statement %d: %s", i, s[:min(len(s), 80)])
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
@@ -1,8 +0,0 @@
-- 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);
+25 -48
View File
@@ -143,18 +143,6 @@ type statusPayload struct {
UptimeSec *int `json:"uptime_sec"` UptimeSec *int `json:"uptime_sec"`
} }
// PublishCommand sends a command (e.g. "start_recording") to a camera's
// command topic, which its ESP32 bridge subscribes to and forwards over UART.
func (s *Subscriber) PublishCommand(cameraID, command string) error {
topic := "remoterig/cameras/" + cameraID + "/command"
payload, _ := json.Marshal(map[string]string{"command": command})
tok := s.client.Publish(topic, 2, false, payload)
if !tok.WaitTimeout(3 * time.Second) {
return fmt.Errorf("publish to %s timed out", topic)
}
return tok.Error()
}
func (s *Subscriber) handleStatus(cameraID string, payload []byte) { func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
var sp statusPayload var sp statusPayload
if err := json.Unmarshal(payload, &sp); err != nil { if err := json.Unmarshal(payload, &sp); err != nil {
@@ -163,20 +151,22 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
} }
// Validate required fields // Validate required fields
if sp.CameraID == "" { if sp.CameraID == "" || sp.Timestamp == "" {
log.Printf("MQTT status missing camera_id from %s", cameraID) log.Printf("MQTT status missing required fields (camera_id, timestamp) from %s", cameraID)
return return
} }
// Nodes have no real clock, so tolerate an empty/invalid timestamp by // Validate timestamp sanity (reject >5min future, >24h past)
// stamping server-side. Still clamp obviously-bad supplied times below.
now := time.Now()
ts, err := time.Parse(time.RFC3339, sp.Timestamp) ts, err := time.Parse(time.RFC3339, sp.Timestamp)
if err != nil { if err != nil {
if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil { // Try ISO8601 without timezone
ts = now ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp)
if err != nil {
log.Printf("MQTT status invalid timestamp %q from %s", sp.Timestamp, cameraID)
return
} }
} }
now := time.Now()
if ts.After(now.Add(5 * time.Minute)) { if ts.After(now.Add(5 * time.Minute)) {
log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID) log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID)
ts = now ts = now
@@ -219,21 +209,6 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
prevRecording = -1 // no previous status prevRecording = -1 // no previous status
} }
// CUB-230: Deduplication check - skip if same (camera_id, recorded_at) exists
// This handles replayed entries from offline buffering
var dupCount int
err = s.db.QueryRow(`
SELECT COUNT(*) FROM status_logs
WHERE camera_id = ? AND recorded_at = ?
`, cameraID, ts).Scan(&dupCount)
if err != nil {
log.Printf("MQTT status dedup check error for %s: %v", cameraID, err)
// Continue anyway if check fails
} else if dupCount > 0 {
log.Printf("MQTT status deduplicated (camera_id=%s, recorded_at=%s) - replay from offline buffer", cameraID, ts.Format("2006-01-02 15:04:05"))
return
}
// Insert status_log // Insert status_log
_, err = s.db.Exec(` _, err = s.db.Exec(`
INSERT INTO status_logs (camera_id, recorded_at, battery_pct, INSERT INTO status_logs (camera_id, recorded_at, battery_pct,
@@ -295,9 +270,7 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
type heartbeatPayload struct { type heartbeatPayload struct {
CameraID string `json:"camera_id"` CameraID string `json:"camera_id"`
// No Timestamp field: the node sends a numeric millis() value and the Timestamp string `json:"timestamp"`
// handler doesn't use it; omitting the field lets it be ignored instead
// of failing JSON unmarshal (number into string).
UptimeSec *int `json:"uptime_sec"` UptimeSec *int `json:"uptime_sec"`
FreeHeap *int `json:"free_heap"` FreeHeap *int `json:"free_heap"`
} }
@@ -374,8 +347,8 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress, "SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
).Scan(&existingID) ).Scan(&existingID)
if err == nil && existingID == cameraID { if err == nil {
// Same self-id re-connecting — just refresh friendly_name. // Already registered — just update friendly_name
_, err = s.db.Exec( _, err = s.db.Exec(
"UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?", "UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?",
ap.FriendlyName, existingID, ap.FriendlyName, existingID,
@@ -386,26 +359,30 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
} }
log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName) log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName)
} else { } else {
// MAC known under a different id (legacy cam-NNN from before self-IDs) // New camera — generate sequential cam-NNN ID
// → drop the old row so we re-register under the node's self-id. var maxID string
if err == nil && existingID != cameraID { s.db.QueryRow("SELECT MAX(camera_id) FROM cameras").Scan(&maxID)
s.db.Exec("DELETE FROM cameras WHERE camera_id = ?", existingID)
log.Printf("MQTT announce: migrating %s -> %s (%s)", existingID, cameraID, ap.FriendlyName) seq := 1
if maxID != "" {
fmt.Sscanf(maxID, "cam-%d", &seq)
seq++
} }
// Option B: the node self-assigns its camera_id (the announce topic id).
newID := fmt.Sprintf("cam-%03d", seq)
_, err = s.db.Exec(` _, err = s.db.Exec(`
INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at) INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at)
VALUES (?, ?, ?, datetime('now'), datetime('now')) VALUES (?, ?, ?, datetime('now'), datetime('now'))
`, cameraID, ap.FriendlyName, ap.MacAddress) `, newID, ap.FriendlyName, ap.MacAddress)
if err != nil { if err != nil {
log.Printf("MQTT announce insert error for %s: %v", ap.MacAddress, err) log.Printf("MQTT announce insert error for %s: %v", ap.MacAddress, err)
return return
} }
log.Printf("MQTT announce: new camera registered as %s (%s)", cameraID, ap.FriendlyName) log.Printf("MQTT announce: new camera registered as %s (%s)", newID, ap.FriendlyName)
// Broadcast new camera via SSE // Broadcast new camera via SSE
cam, err := getCamera(s.db, cameraID) cam, err := getCamera(s.db, newID)
if err == nil { if err == nil {
s.hub.Broadcast("camera_registered", cam) s.hub.Broadcast("camera_registered", cam)
} }
+3 -6
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"`
} }
@@ -33,11 +33,8 @@ type StatusLog struct {
type CameraStatus struct { type CameraStatus struct {
CameraID string `json:"camera_id"` CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"` FriendlyName string `json:"friendly_name"`
// Not omitempty: the SPA expects these as `number | null`. Omitting them BatteryPct *int `json:"battery_pct,omitempty"`
// makes the field `undefined` in JS, which slips past null checks and VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
// renders as "NaN%".
BatteryPct *int `json:"battery_pct"`
VideoRemainingSec *int `json:"video_remaining_sec"`
Recording bool `json:"recording"` Recording bool `json:"recording"`
Mode string `json:"mode"` Mode string `json:"mode"`
Resolution string `json:"resolution"` Resolution string `json:"resolution"`
+4 -9
View File
@@ -72,12 +72,8 @@ fi
# 2. Deploy new binary # 2. Deploy new binary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
info "Deploying new binary..." info "Deploying new binary..."
# Atomic replace: copy alongside then rename over the target. A plain cp "${BINARY}" "${DEPLOY_PATH}"
# cp over a running executable fails with "Text file busy"; rename swaps chmod +x "${DEPLOY_PATH}"
# the directory entry and works while the old binary is still running.
cp "${BINARY}" "${DEPLOY_PATH}.new"
chmod +x "${DEPLOY_PATH}.new"
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
ok "Binary installed at ${DEPLOY_PATH}" ok "Binary installed at ${DEPLOY_PATH}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -120,9 +116,8 @@ else
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
if [ -f "${BACKUP}" ]; then if [ -f "${BACKUP}" ]; then
info "Restoring backup: ${BACKUP}" info "Restoring backup: ${BACKUP}"
cp "${BACKUP}" "${DEPLOY_PATH}.new" cp "${BACKUP}" "${DEPLOY_PATH}"
chmod +x "${DEPLOY_PATH}.new" chmod +x "${DEPLOY_PATH}"
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
systemctl restart "${SERVICE}" 2>/dev/null || true systemctl restart "${SERVICE}" 2>/dev/null || true
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# RemoteRig — Pi-side pull updater
# ================================
# Polls the rolling "dev" release on Gitea and, when the published version
# differs from what's installed, downloads + verifies (sha256) + deploys it
# via the existing rollback-capable deploy.sh. Run on a timer (see
# remoterig-update.timer). The Pi pulls; nothing pushes into the closed net.
#
# Config (env, or /opt/remoterig/update.env):
# GITEA_BASE default https://code.cubecraftcreations.com
# REPO default CubeCraft-Creations/remote-rig
# GITEA_TOKEN read token (required only if the repo is private)
# DEPLOY_PATH default /opt/remoterig/remoterig
# SERVICE default remoterig
set -euo pipefail
ENV_FILE="${ENV_FILE:-/opt/remoterig/update.env}"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
GITEA_BASE="${GITEA_BASE:-https://code.cubecraftcreations.com}"
REPO="${REPO:-CubeCraft-Creations/remote-rig}"
DEPLOY_DIR="/opt/remoterig"
DEPLOY_PATH="${DEPLOY_PATH:-$DEPLOY_DIR/remoterig}"
SERVICE="${SERVICE:-remoterig}"
TAG="dev-latest"
DL="$GITEA_BASE/$REPO/releases/download/$TAG"
VERSION_FILE="$DEPLOY_DIR/VERSION"
AUTH=()
[ -n "${GITEA_TOKEN:-}" ] && AUTH=(-H "Authorization: token $GITEA_TOKEN")
log() { echo "[$(date -Is)] $*"; }
# 1. What version is published?
REMOTE_VER="$(curl -fsSL "${AUTH[@]}" "$DL/version.txt" | tr -d '[:space:]')" || {
log "could not reach $DL/version.txt — skipping"; exit 0; }
[ -n "$REMOTE_VER" ] || { log "empty remote version — skipping"; exit 0; }
LOCAL_VER="$(cat "$VERSION_FILE" 2>/dev/null || echo none)"
if [ "$REMOTE_VER" = "$LOCAL_VER" ]; then
log "up to date ($LOCAL_VER)"; exit 0
fi
log "update available: $LOCAL_VER -> $REMOTE_VER"
# 2. Download + verify checksum
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
curl -fsSL "${AUTH[@]}" "$DL/remoterig" -o "$TMP/remoterig"
curl -fsSL "${AUTH[@]}" "$DL/remoterig.sha256" -o "$TMP/remoterig.sha256"
( cd "$TMP" && echo "$(cat remoterig.sha256) remoterig" | sha256sum -c - ) || {
log "checksum FAILED — aborting update"; exit 1; }
# 3. Deploy via the existing backup/restart/rollback logic
chmod +x "$TMP/remoterig"
"$DEPLOY_DIR/deploy.sh" "$TMP/remoterig" "$DEPLOY_PATH" "$SERVICE"
# 4. Record the installed version
echo "$REMOTE_VER" > "$VERSION_FILE"
log "updated to $REMOTE_VER"
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=RemoteRig pull updater (checks Gitea dev release)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/remoterig/pi-update.sh
# Updater needs root to write the binary and restart the service
User=root
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=Periodically check for RemoteRig updates (Gitea dev release)
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
+17 -66
View File
@@ -8,9 +8,9 @@
# #
# Options: # Options:
# --config PATH Path to config.yaml template to copy to /opt/remoterig/ # --config PATH Path to config.yaml template to copy to /opt/remoterig/
# --service-user USER Systemd service user (default: invoking sudo user, else pi) # --service-user USER Systemd service user (default: pi)
# --static-ip IP Static IP for wlan0 (default: 192.168.8.56/24) # --static-ip IP Static IP for wlan0 (default: 10.60.1.56/24)
# --gateway IP Gateway for wlan0 (default: 192.168.8.1) # --gateway IP Gateway for wlan0 (default: 10.60.1.1)
# --help Show this help # --help Show this help
set -euo pipefail set -euo pipefail
@@ -19,9 +19,9 @@ set -euo pipefail
# Defaults # Defaults
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CONFIG_TEMPLATE="" CONFIG_TEMPLATE=""
SERVICE_USER="${SUDO_USER:-pi}" # default to the invoking user (not every Pi has a 'pi' user) SERVICE_USER="pi"
STATIC_IP="192.168.8.56/24" STATIC_IP="10.60.1.56/24"
GATEWAY="192.168.8.1" GATEWAY="10.60.1.1"
MOSQUITTO_PKG="mosquitto mosquitto-clients" MOSQUITTO_PKG="mosquitto mosquitto-clients"
DEPLOY_DIR="/opt/remoterig" DEPLOY_DIR="/opt/remoterig"
SERVICE_NAME="remoterig" SERVICE_NAME="remoterig"
@@ -204,54 +204,6 @@ else
fi fi
fi fi
# ---------------------------------------------------------------------------
# 6b. Install pull updater (Pi polls the Gitea dev release and self-updates)
# ---------------------------------------------------------------------------
info "Installing pull updater..."
# deploy.sh + pi-update.sh live in the deploy dir (the updater calls them)
for f in deploy.sh pi-update.sh; do
if [ -f "${SCRIPT_DIR}/${f}" ]; then
cp "${SCRIPT_DIR}/${f}" "${DEPLOY_DIR}/${f}"
chmod +x "${DEPLOY_DIR}/${f}"
ok "Installed ${DEPLOY_DIR}/${f}"
else
warn "${SCRIPT_DIR}/${f} not found — skipping"
fi
done
# update.env template (don't clobber an existing one that may hold a token)
UPDATE_ENV="${DEPLOY_DIR}/update.env"
if [ -f "${UPDATE_ENV}" ]; then
skip "${UPDATE_ENV} already exists (not overwriting)"
else
cat > "${UPDATE_ENV}" <<'ENVEOF'
# RemoteRig updater config
GITEA_BASE=https://code.cubecraftcreations.com
REPO=CubeCraft-Creations/remote-rig
# Read token — required only if the repo is private:
GITEA_TOKEN=
ENVEOF
chmod 600 "${UPDATE_ENV}"
ok "Wrote ${UPDATE_ENV} (set GITEA_TOKEN if the repo is private)"
fi
# Updater service + timer
for unit in remoterig-update.service remoterig-update.timer; do
if [ -f "${SCRIPT_DIR}/${unit}" ]; then
cp "${SCRIPT_DIR}/${unit}" "/etc/systemd/system/${unit}"
ok "Installed ${unit}"
else
warn "${SCRIPT_DIR}/${unit} not found — skipping"
fi
done
systemctl daemon-reload
if systemctl enable --now remoterig-update.timer 2>/dev/null; then
ok "remoterig-update.timer enabled and started"
else
warn "Could not enable remoterig-update.timer"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. Set static IP on wlan0 # 7. Set static IP on wlan0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -365,21 +317,20 @@ echo " Setup complete!"
echo "==============================================" echo "=============================================="
echo " Mosquitto: $(systemctl is-active mosquitto 2>/dev/null || echo 'unknown')" echo " Mosquitto: $(systemctl is-active mosquitto 2>/dev/null || echo 'unknown')"
echo " Service: ${SERVICE_NAME} (systemctl status ${SERVICE_NAME})" echo " Service: ${SERVICE_NAME} (systemctl status ${SERVICE_NAME})"
echo " Updater: remoterig-update.timer (systemctl status remoterig-update.timer)"
echo " Deploy dir: ${DEPLOY_DIR}" echo " Deploy dir: ${DEPLOY_DIR}"
echo " Static IP: ${STATIC_IP} on wlan0" echo " Static IP: ${STATIC_IP} on wlan0"
echo "" echo ""
echo " Deploys are pull-based: push to 'dev' on Gitea -> CI builds the"
echo " arm64 binary -> the Pi's timer pulls + installs it automatically."
echo ""
echo " Next steps:" echo " Next steps:"
echo " 1. If the repo is private, set a read token:" echo " 1. Build the remoterig binary for ARM64:"
echo " sudo sed -i 's/^GITEA_TOKEN=.*/GITEA_TOKEN=<token>/' ${DEPLOY_DIR}/update.env" echo " GOOS=linux GOARCH=arm64 go build -o remoterig ./cmd/server"
echo " 2. Trigger / wait for an update check:" echo " 2. Copy binary to Pi:"
echo " sudo systemctl start remoterig-update.service" echo " scp remoterig pi@10.60.1.56:/opt/remoterig/"
echo " journalctl -u remoterig-update.service -n 30" echo " 3. Copy config if needed:"
echo " 3. Check health once deployed:" echo " scp config.yaml pi@10.60.1.56:/opt/remoterig/"
echo " curl http://${STATIC_IP%/*}:8080/health" echo " 4. Start the service:"
echo " sudo systemctl start remoterig"
echo " 5. Check health:"
echo " curl http://10.60.1.56:8080/health"
echo "" echo ""
echo " Manual one-off deploy (local binary) still works: scripts/deploy.sh" echo " To deploy updates, use: scripts/deploy.sh"
echo "==============================================" echo "=============================================="
+46 -186
View File
@@ -1,228 +1,88 @@
import { useState, useCallback, useMemo, useEffect } from 'react' import { Camera, Radio } from 'lucide-react'
import { Camera, Play, Square, Wifi, WifiOff, AlertTriangle } from 'lucide-react'
import { useSSE } from './hooks/useSSE' import { useSSE } from './hooks/useSSE'
import { useCameraStore } from './store/useCameraStore' import { useCameraStore } from './store/useCameraStore'
import { api } from './services/api' import { CameraCard } from './components'
import CameraCard from './components/CameraCard'
import HistoryViewer from './components/HistoryViewer'
function App() { function App() {
const [commandBusy, setCommandBusy] = useState(false) // Connect to SSE endpoint — auto-updates the camera store
const [commandError, setCommandError] = useState<string | null>(null) useSSE()
const [historyCameraId, setHistoryCameraId] = useState<string | null>(null)
const [historyCameraName, setHistoryCameraName] = useState<string>()
// SSE connection + live store // Subscribe to the camera store for reactivity.
const { connectionState } = useSSE() // getCameras / getOnlineCount / getRecordingCount pull from live state.
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
// Seed the list once on mount via the REST API. SSE only pushes on change, const cameras = getCameras()
// so without this the dashboard is empty until the next status event. const onlineCount = getOnlineCount()
useEffect(() => { const recordingCount = getRecordingCount()
api.getCameras()
.then((list) => useCameraStore.getState().setCameras(list))
.catch(() => { /* SSE will fill in shortly */ })
}, [])
// Subscribe to full camera state — dashboard needs every change
const camerasMap = useCameraStore((s) => s.cameras)
const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap])
const onlineCount = useMemo(() => cameras.filter((c) => c.online).length, [cameras])
const recordingCount = useMemo(() => cameras.filter((c) => c.recording).length, [cameras])
const cameraIds = cameras.map((c) => c.camera_id)
// ── Command helpers ──
const handleStart = useCallback(async (cameraId: string) => {
setCommandBusy(true)
setCommandError(null)
try {
await api.startRecording(cameraId)
} catch (err) {
setCommandError(err instanceof Error ? err.message : 'Command failed')
} finally {
setCommandBusy(false)
}
}, [])
const handleStop = useCallback(async (cameraId: string) => {
setCommandBusy(true)
setCommandError(null)
try {
await api.stopRecording(cameraId)
} catch (err) {
setCommandError(err instanceof Error ? err.message : 'Command failed')
} finally {
setCommandBusy(false)
}
}, [])
const handleStartAll = useCallback(async () => {
setCommandBusy(true)
setCommandError(null)
try {
await Promise.all(cameraIds.map((id) => api.startRecording(id)))
} catch {
// Individual failures are non-fatal — some may succeed
} finally {
setCommandBusy(false)
}
}, [cameraIds])
const handleStopAll = useCallback(async () => {
setCommandBusy(true)
setCommandError(null)
try {
await Promise.all(cameraIds.map((id) => api.stopRecording(id)))
} catch {
// Individual failures are non-fatal
} finally {
setCommandBusy(false)
}
}, [cameraIds])
const handleViewHistory = useCallback((cameraId: string) => {
const cam = useCameraStore.getState().cameras.get(cameraId)
setHistoryCameraId(cameraId)
setHistoryCameraName(cam?.friendly_name ?? cameraId)
}, [])
const handleCloseHistory = useCallback(() => {
setHistoryCameraId(null)
}, [])
// ── Connection badge ──
const connectionBadge = {
connected: { icon: Wifi, label: 'Live', class: 'bg-rig-success/15 text-rig-success' },
connecting: { icon: Wifi, label: 'Connecting...', class: 'bg-rig-warning/15 text-rig-warning' },
disconnected: { icon: WifiOff, label: 'Disconnected', class: 'bg-rig-danger/15 text-rig-danger' },
error: { icon: AlertTriangle, label: 'Stream Error', class: 'bg-rig-danger/15 text-rig-danger' },
}[connectionState] ?? {
icon: WifiOff,
label: 'Disconnected',
class: 'bg-rig-danger/15 text-rig-danger',
}
const BadgeIcon = connectionBadge.icon
// ── Render ──
return ( return (
<div className="min-h-screen bg-rig-dark-900 flex flex-col"> <div className="min-h-screen bg-rig-dark-900">
{/* Header */} {/* Header */}
<header className="shrink-0 border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm"> <header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3 min-w-0"> <Camera className="h-7 w-7 text-rig-accent" />
<Camera className="h-6 w-6 shrink-0 text-rig-accent" /> <h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
<h1 className="text-lg font-bold tracking-tight text-rig-dark-50 truncate">
RemoteRig RemoteRig
</h1> </h1>
<span className="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent"> <span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
Dashboard Dashboard
</span> </span>
</div>
{/* Connection status */} {/* Stats badges */}
<div className="flex items-center gap-3"> <div className="ml-auto flex items-center gap-4">
{/* SSE badge */} {/* Online count */}
<span <span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${connectionBadge.class}`} className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
title="Cameras online"
> >
<BadgeIcon className="h-3 w-3" /> <span className="h-2 w-2 rounded-full bg-rig-success" />
{connectionBadge.label} {onlineCount} online
</span> </span>
{/* Global controls */} {/* Recording count */}
<div className="flex items-center gap-1"> <span
<button className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
onClick={handleStartAll} title="Cameras recording"
disabled={commandBusy || cameras.length === 0}
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-3 py-1.5 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Start recording on all cameras"
> >
<Play className="h-3.5 w-3.5 fill-current" /> <span className="relative flex h-2 w-2">
<span className="hidden sm:inline">Start All</span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
</button> <span className="relative inline-flex h-2 w-2 rounded-full bg-rig-danger" />
<button
onClick={handleStopAll}
disabled={commandBusy || cameras.length === 0}
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-3 py-1.5 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Stop recording on all cameras"
>
<Square className="h-3.5 w-3.5 fill-current" />
<span className="hidden sm:inline">Stop All</span>
</button>
</div>
</div>
</div>
{/* Stats strip */}
<div className="mt-2 flex items-center gap-4 text-xs text-rig-dark-400">
<span>
<strong className="text-rig-dark-100">{cameras.length}</strong> camera{cameras.length !== 1 ? 's' : ''}
</span> </span>
<span> {recordingCount} recording
<strong className="text-rig-success">{onlineCount}</strong> online
</span>
<span>
<strong className={recordingCount > 0 ? 'text-rig-danger' : 'text-rig-dark-300'}>
{recordingCount}
</strong>{' '}
recording
</span> </span>
</div> </div>
</div> </div>
</div>
</header> </header>
{/* Command error toast */}
{commandError && (
<div className="shrink-0 border-b border-rig-danger/30 bg-rig-danger/10 px-4 py-2">
<p className="mx-auto max-w-7xl text-xs text-rig-danger">
<AlertTriangle className="inline h-3 w-3 mr-1" />
{commandError}
</p>
</div>
)}
{/* Main Content */} {/* Main Content */}
<main className="flex-1 mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{cameras.length === 0 ? ( {cameras.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" /> <span className="relative mb-4 inline-flex">
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
</span>
<h2 className="text-lg font-semibold text-rig-dark-200"> <h2 className="text-lg font-semibold text-rig-dark-200">
No Cameras Connected Waiting for cameras&hellip;
</h2> </h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400"> <p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network. Connect cameras to your RemoteRig server and they will appear here
automatically.
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> /* Camera grid */
{cameras.map((cam) => ( <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<CameraCard {cameras.map((camera) => (
key={cam.camera_id} <CameraCard key={camera.camera_id} camera={camera} />
camera={cam}
onStart={handleStart}
onStop={handleStop}
onViewHistory={handleViewHistory}
disabled={commandBusy}
/>
))} ))}
</div> </div>
)} )}
</main> </main>
{/* History modal */}
<HistoryViewer
cameraId={historyCameraId}
cameraName={historyCameraName}
onClose={handleCloseHistory}
/>
{/* Footer */} {/* Footer */}
<footer className="shrink-0 border-t border-rig-dark-700 bg-rig-dark-800/30"> <footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
<p className="text-center text-xs text-rig-dark-500"> <p className="text-center text-xs text-rig-dark-500">
RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System
+38 -52
View File
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect } from 'vitest'
import CameraCard from './CameraCard' import CameraCard from './CameraCard'
import type { CameraStatus } from '../types' import type { CameraStatus } from '../types'
@@ -19,52 +19,52 @@ function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
} }
} }
const noop = vi.fn()
const renderCard = (overrides?: Partial<CameraStatus>) =>
render(<CameraCard camera={makeCamera(overrides ?? {})} onStart={noop} onStop={noop} onViewHistory={noop} />)
const renderCardContainer = (camera: CameraStatus) =>
render(<CameraCard camera={camera} onStart={noop} onStop={noop} onViewHistory={noop} />)
describe('CameraCard', () => { describe('CameraCard', () => {
// ── Basic rendering ──────────────────────────────────────────────────── // ── Basic rendering ────────────────────────────────────────────────────
it('renders camera name', () => { it('renders camera name', () => {
renderCard() render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText('Front Camera')).toBeInTheDocument() expect(screen.getByText('Front Camera')).toBeInTheDocument()
}) })
it('shows resolution and FPS', () => { it('shows resolution and FPS', () => {
renderCard() render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText(/1080p/)).toBeInTheDocument() expect(screen.getByText(/1080p/)).toBeInTheDocument()
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument() expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
}) })
it('shows battery percentage', () => { it('shows battery percentage', () => {
renderCard({ battery_pct: 85 }) render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
expect(screen.getByText('85%')).toBeInTheDocument() expect(screen.getByText('85%')).toBeInTheDocument()
}) })
it('shows N/A when battery is null', () => { it('shows N/A when battery is null', () => {
renderCard({ battery_pct: null }) render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
expect(screen.getByText('N/A')).toBeInTheDocument() expect(screen.getByText('N/A')).toBeInTheDocument()
}) })
// ── Battery bar colors ───────────────────────────────────────────────── // ── Battery bar colors ─────────────────────────────────────────────────
it('uses green bar for high battery (>=50%)', () => { it('uses green bar for high battery (>=50%)', () => {
const { container } = renderCard({ battery_pct: 85 }) const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success') expect(bar?.className).toContain('bg-rig-success')
}) })
it('uses yellow bar for medium battery (15-49%)', () => { it('uses yellow bar for medium battery (15-49%)', () => {
const { container } = renderCard({ battery_pct: 30 }) const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning') expect(bar?.className).toContain('bg-rig-warning')
}) })
it('uses red bar for low battery (<15%)', () => { it('uses red bar for low battery (<15%)', () => {
const { container } = renderCard({ battery_pct: 8 }) const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-danger') expect(bar?.className).toContain('bg-rig-danger')
}) })
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
// ── Recording state ──────────────────────────────────────────────────── // ── Recording state ────────────────────────────────────────────────────
it('shows REC badge when recording', () => { it('shows REC badge when recording', () => {
renderCard({ recording: true }) render(<CameraCard camera={makeCamera({ recording: true })} />)
expect(screen.getByText('REC')).toBeInTheDocument() expect(screen.getByText('REC')).toBeInTheDocument()
}) })
it('shows IDLE badge when not recording', () => { it('shows IDLE badge when not recording', () => {
renderCard({ recording: false }) render(<CameraCard camera={makeCamera({ recording: false })} />)
expect(screen.getByText('IDLE')).toBeInTheDocument() expect(screen.getByText('IDLE')).toBeInTheDocument()
}) })
// ── Online / Offline badges ──────────────────────────────────────────── // ── Online / Offline badges ────────────────────────────────────────────
it('shows Online badge when camera is online', () => { it('shows Online badge when camera is online', () => {
renderCard({ online: true }) render(<CameraCard camera={makeCamera({ online: true })} />)
expect(screen.getByText('Online')).toBeInTheDocument() expect(screen.getByText('Online')).toBeInTheDocument()
}) })
it('shows Offline badge when camera is offline', () => { it('shows Offline badge when camera is offline', () => {
renderCard({ online: false }) render(<CameraCard camera={makeCamera({ online: false })} />)
const offlineElements = screen.getAllByText('Offline') const offlineElements = screen.getAllByText('Offline')
expect(offlineElements.length).toBeGreaterThanOrEqual(1) expect(offlineElements.length).toBeGreaterThanOrEqual(1)
}) })
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
// ── Video remaining ──────────────────────────────────────────────────── // ── Video remaining ────────────────────────────────────────────────────
it('shows video remaining time when available', () => { it('shows video remaining time when available', () => {
renderCard({ video_remaining_sec: 125 }) render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />)
// formatTimeLeft(125) → "2m 5s left" // formatTimeLeft(125) → "2m 5s left"
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument() expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
}) })
it('does not show video remaining when null', () => { it('does not show video remaining when null', () => {
renderCard({ video_remaining_sec: null }) render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />)
// The Radio icon and time text should not be present // The Radio icon and time text should not be present
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument() expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
}) })
@@ -111,67 +111,53 @@ describe('CameraCard', () => {
// ── Footer ───────────────────────────────────────────────────────────── // ── Footer ─────────────────────────────────────────────────────────────
it('shows Live + timestamp in footer when online', () => { it('shows Live + timestamp in footer when online', () => {
renderCard({ online: true }) render(<CameraCard camera={makeCamera({ online: true })} />)
// Footer shows "Live" when online
expect(screen.getByText('Live')).toBeInTheDocument() expect(screen.getByText('Live')).toBeInTheDocument()
}) })
it('shows Offline in footer when offline', () => { it('shows Offline + timestamp in footer when offline', () => {
renderCard({ online: false }) render(<CameraCard camera={makeCamera({ online: false })} />)
// Footer says "Offline" (the text appears both in the badge and footer)
// When offline, the footer specifically shows "Offline" text
const offlineElements = screen.getAllByText('Offline') const offlineElements = screen.getAllByText('Offline')
// At least one should exist (badge + footer)
expect(offlineElements.length).toBeGreaterThanOrEqual(1) expect(offlineElements.length).toBeGreaterThanOrEqual(1)
}) })
it('shows "unknown" when last_seen is malformed', () => { it('shows "unknown" when last_seen is malformed', () => {
renderCard({ last_seen: 'not-a-date' }) render(
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
)
expect(screen.getByText('unknown')).toBeInTheDocument() expect(screen.getByText('unknown')).toBeInTheDocument()
}) })
it('shows "unknown" when last_seen is in the future', () => { it('shows "unknown" when last_seen is in the future', () => {
const future = new Date(Date.now() + 86400000).toISOString() // +1 day const future = new Date(Date.now() + 86400000).toISOString() // +1 day
const cam = makeCamera({ last_seen: future }) render(<CameraCard camera={makeCamera({ last_seen: future })} />)
renderCardContainer(cam)
expect(screen.getByText('unknown')).toBeInTheDocument() expect(screen.getByText('unknown')).toBeInTheDocument()
}) })
// ── Edge cases ────────────────────────────────────────────────────────── // ── Edge cases ──────────────────────────────────────────────────────────
it('clamps negative battery_pct to 0%', () => { it('clamps negative battery_pct to 0%', () => {
renderCard({ battery_pct: -5 }) render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
expect(screen.getByText('0%')).toBeInTheDocument() expect(screen.getByText('0%')).toBeInTheDocument()
}) })
it('shows exact boundary: 15% battery → yellow bar', () => { it('shows exact boundary: 15% battery → yellow bar', () => {
const { container } = renderCard({ battery_pct: 15 }) const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning') expect(bar?.className).toContain('bg-rig-warning')
}) })
it('shows exact boundary: 50% battery → green bar', () => { it('shows exact boundary: 50% battery → green bar', () => {
const { container } = renderCard({ battery_pct: 50 }) const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success') expect(bar?.className).toContain('bg-rig-success')
}) })
// ── New prop-driven tests ──────────────────────────────────────────────
it('calls onStart when Record button is clicked', () => {
const onStart = vi.fn()
render(<CameraCard camera={makeCamera({ recording: false })} onStart={onStart} onStop={noop} onViewHistory={noop} />)
screen.getByText('Record').click()
expect(onStart).toHaveBeenCalledWith('cam-1')
})
it('calls onStop when Stop button is clicked', () => {
const onStop = vi.fn()
render(<CameraCard camera={makeCamera({ recording: true })} onStart={noop} onStop={onStop} onViewHistory={noop} />)
screen.getByText('Stop').click()
expect(onStop).toHaveBeenCalledWith('cam-1')
})
it('calls onViewHistory when History button is clicked', () => {
const onViewHistory = vi.fn()
render(<CameraCard camera={makeCamera({})} onStart={noop} onStop={noop} onViewHistory={onViewHistory} />)
screen.getByText('History').click()
expect(onViewHistory).toHaveBeenCalledWith('cam-1')
})
}) })
+15 -73
View File
@@ -1,4 +1,4 @@
import { Video, Wifi, WifiOff, Signal, Battery, Radio, Play, Square } from 'lucide-react' import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
import type { CameraStatus } from '../types' import type { CameraStatus } from '../types'
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
return `${diffDay}d ago` return `${diffDay}d ago`
} }
function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } { function batteryColor(pct: number | null): { bar: string; text: string } {
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' } if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' } if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' } if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' } return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
} }
function formatTimeLeft(sec: number): string { function formatTimeLeft(sec: number): string {
@@ -37,33 +37,14 @@ function formatTimeLeft(sec: number): string {
return `${m}m ${s}s left` return `${m}m ${s}s left`
} }
function cameraStatus(online: boolean, batteryPct: number | null): 'good' | 'warning' | 'critical' {
if (!online) return 'critical'
if (batteryPct === null) return 'good'
if (batteryPct >= 50) return 'good'
if (batteryPct >= 15) return 'warning'
return 'critical'
}
const STATUS_BORDER: Record<string, string> = {
good: 'border-l-rig-success',
warning: 'border-l-rig-warning',
critical: 'border-l-rig-danger',
}
// ── Component ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
interface CameraCardProps { interface CameraCardProps {
camera: CameraStatus camera: CameraStatus
onStart: (cameraId: string) => void
onStop: (cameraId: string) => void
onViewHistory: (cameraId: string) => void
disabled?: boolean
} }
export default function CameraCard({ camera, onStart, onStop, onViewHistory, disabled }: CameraCardProps) { export default function CameraCard({ camera }: CameraCardProps) {
const { const {
camera_id,
friendly_name, friendly_name,
online, online,
resolution, resolution,
@@ -76,23 +57,21 @@ export default function CameraCard({ camera, onStart, onStop, onViewHistory, dis
} = camera } = camera
const batt = batteryColor(battery_pct) const batt = batteryColor(battery_pct)
const status = cameraStatus(online, battery_pct)
const borderColor = STATUS_BORDER[status]
return ( return (
<article <article
className={`rounded-xl border border-rig-dark-600 bg-rig-dark-800/60 transition-colors border-l-4 ${borderColor} ${ className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
online online
? 'hover:border-rig-accent/40' ? 'border-rig-dark-600 hover:border-rig-accent/40'
: 'opacity-75' : 'border-rig-dark-700 opacity-75'
}`} }`}
> >
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2"> <div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2">
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" /> <Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
<h3 <h3
className="text-sm font-semibold text-rig-dark-100 truncate" className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
title={friendly_name} title={friendly_name}
> >
{friendly_name} {friendly_name}
@@ -103,7 +82,7 @@ export default function CameraCard({ camera, onStart, onStop, onViewHistory, dis
<span <span
role="status" role="status"
aria-label={online ? 'Camera online' : 'Camera offline'} aria-label={online ? 'Camera online' : 'Camera offline'}
className={`ml-2 shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
online online
? 'bg-rig-success/15 text-rig-success' ? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger' : 'bg-rig-danger/15 text-rig-danger'
@@ -120,9 +99,6 @@ export default function CameraCard({ camera, onStart, onStop, onViewHistory, dis
{/* ── Body ── */} {/* ── Body ── */}
<div className="space-y-2.5 px-4 pb-3"> <div className="space-y-2.5 px-4 pb-3">
{/* Camera ID */}
<p className="text-[11px] font-mono text-rig-dark-500">{camera_id}</p>
{/* Resolution + FPS */} {/* Resolution + FPS */}
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300"> <div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
<Signal className="h-3.5 w-3.5" /> <Signal className="h-3.5 w-3.5" />
@@ -183,40 +159,7 @@ export default function CameraCard({ camera, onStart, onStop, onViewHistory, dis
</div> </div>
{/* ── Footer ── */} {/* ── Footer ── */}
<div className="rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30"> <div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2">
{/* Controls row */}
<div className="flex items-center gap-1 px-3 py-2">
{recording ? (
<button
onClick={() => onStop(camera_id)}
disabled={disabled}
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-2.5 py-1 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={`Stop recording ${friendly_name}`}
>
<Square className="h-3 w-3 fill-current" />
Stop
</button>
) : (
<button
onClick={() => onStart(camera_id)}
disabled={disabled || !online}
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-2.5 py-1 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={`Start recording ${friendly_name}`}
>
<Play className="h-3 w-3 fill-current" />
Record
</button>
)}
<button
onClick={() => onViewHistory(camera_id)}
className="ml-auto rounded-md bg-rig-dark-700/50 px-2 py-1 text-[11px] text-rig-dark-300 hover:bg-rig-dark-600 hover:text-rig-dark-100 transition-colors"
>
History
</button>
</div>
{/* Status strip */}
<div className="flex items-center justify-between px-4 pb-2">
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-1.5 text-xs">
{online ? ( {online ? (
<> <>
@@ -237,7 +180,6 @@ export default function CameraCard({ camera, onStart, onStop, onViewHistory, dis
</div> </div>
)} )}
</div> </div>
</div>
</article> </article>
) )
} }
-193
View File
@@ -1,193 +0,0 @@
import { useEffect, useState } from 'react'
import { X, Clock, Battery, Radio, Video } from 'lucide-react'
import { api } from '../services/api'
import type { StatusLog } from '../types'
// ── Helpers ────────────────────────────────────────────────────────────────
function formatTimestamp(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function batteryColor(pct: number | null): string {
if (pct === null) return 'text-rig-dark-400'
if (pct >= 50) return 'text-rig-success'
if (pct >= 15) return 'text-rig-warning'
return 'text-rig-danger'
}
// ── Component ──────────────────────────────────────────────────────────────
interface HistoryViewerProps {
cameraId: string | null
cameraName?: string
onClose: () => void
}
export default function HistoryViewer({ cameraId, cameraName, onClose }: HistoryViewerProps) {
const [logs, setLogs] = useState<StatusLog[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!cameraId) {
setLogs([])
return
}
setLoading(true)
setError(null)
api
.getCameraDetail(cameraId)
.then((data) => {
setLogs(data.history)
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load history')
})
.finally(() => setLoading(false))
}, [cameraId])
// Handle escape key
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
if (cameraId === null) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
role="dialog"
aria-modal="true"
aria-label={`History for ${cameraName ?? cameraId}`}
>
<div className="mx-4 w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl border border-rig-dark-600 bg-rig-dark-800 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-xl border-b border-rig-dark-700 px-5 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-rig-accent" />
<h2 className="text-sm font-semibold text-rig-dark-100">
History &mdash; {cameraName ?? cameraId}
</h2>
</div>
<button
onClick={onClose}
className="rounded-md p-1 text-rig-dark-400 hover:bg-rig-dark-700 hover:text-rig-dark-100 transition-colors"
aria-label="Close history"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-rig-dark-500 border-t-rig-accent" />
<span className="ml-3 text-sm text-rig-dark-400">Loading history...</span>
</div>
)}
{error && (
<div className="rounded-lg border border-rig-danger/30 bg-rig-danger/10 px-4 py-3 text-sm text-rig-danger">
{error}
</div>
)}
{!loading && !error && logs.length === 0 && (
<p className="py-8 text-center text-sm text-rig-dark-400">
No history entries found for this camera.
</p>
)}
{!loading && logs.length > 0 && (
<div className="space-y-2">
{logs.map((log) => (
<div
key={log.id}
className="flex items-center gap-3 rounded-lg border border-rig-dark-700/50 bg-rig-dark-900/40 px-3 py-2.5 text-xs"
>
{/* Timestamp */}
<span className="font-mono text-rig-dark-400 min-w-[130px]">
{formatTimestamp(log.recorded_at)}
</span>
{/* Online/Recording badges */}
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${
log.online
? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger'
}`}
>
{log.online ? 'Online' : 'Offline'}
</span>
{log.recording_state ? (
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-rig-danger">
REC
</span>
) : (
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] text-rig-dark-500">
IDLE
</span>
)}
</div>
{/* Battery */}
<div className="flex items-center gap-1 ml-auto">
<Battery className="h-3 w-3 text-rig-dark-500" />
<span className={`font-mono ${batteryColor(log.battery_pct)}`}>
{log.battery_pct !== null ? `${log.battery_pct}%` : 'N/A'}
</span>
</div>
{/* Storage remaining */}
{log.video_remaining_sec !== null && (
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-rig-dark-500" />
<span className="font-mono text-rig-dark-400">
{Math.floor(log.video_remaining_sec / 60)}m left
</span>
</div>
)}
{/* Mode */}
<div className="flex items-center gap-1">
<Video className="h-3 w-3 text-rig-dark-500" />
<span className="text-rig-dark-400">{log.mode}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="rounded-b-xl border-t border-rig-dark-700 px-5 py-3">
<p className="text-[11px] text-rig-dark-500">
{logs.length} entries (last 24 hours)
</p>
</div>
</div>
</div>
)
}
-1
View File
@@ -1,2 +1 @@
export { default as CameraCard } from './CameraCard' export { default as CameraCard } from './CameraCard'
export { default as HistoryViewer } from './HistoryViewer'
+6 -19
View File
@@ -1,4 +1,4 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1' const API_BASE = import.meta.env.VITE_API_URL || '/api'
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> { async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
@@ -12,22 +12,9 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
} }
export const api = { export const api = {
/** GET /api/v1/cameras — list all cameras with latest status */ getCameras: () => request<[]>('/cameras'),
getCameras: () => request<import('../types').CameraStatus[]>('/cameras'), getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
getSystemHealth: () => request<[]>('/system/health'),
/** GET /api/v1/cameras/{id} — full detail + 24h history */ toggleRecording: (cameraId: string) =>
getCameraDetail: (id: string) => request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
request<import('../types').CameraDetail>(`/cameras/${id}`),
/** POST /api/v1/cameras/{id}/start — start recording */
startRecording: (cameraId: string) =>
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/start`, {
method: 'POST',
}),
/** POST /api/v1/cameras/{id}/stop — stop recording */
stopRecording: (cameraId: string) =>
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/stop`, {
method: 'POST',
}),
} }
-36
View File
@@ -21,42 +21,6 @@ export interface SSEEvent {
payload?: unknown payload?: unknown
} }
/** A single status log entry from GET /api/v1/cameras/{id} */
export interface StatusLog {
id: number
camera_id: string
recorded_at: string
battery_pct: number | null
video_remaining_sec: number | null
recording_state: number // 0 or 1 (SQLite bool)
mode: string
resolution: string
fps: number
online: number // 0 or 1
raw_battery_pct: number | null
}
/** Camera detail response from GET /api/v1/cameras/{id} */
export interface CameraDetail {
camera: CameraInfo
last_status: StatusLog
history: StatusLog[]
}
export interface CameraInfo {
CameraID: string
FriendlyName: string
MacAddress: string | null
CreatedAt: string
UpdatedAt: string
}
/** Generic API responses */
export interface StartStopResponse {
status: string
camera_id: string
}
export interface Camera { export interface Camera {
id: string id: string
name: string name: string
-6
View File
@@ -5,12 +5,6 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// Build straight into the Go embed location: cmd/server/main.go has
// //go:embed all:src/dist relative to its package dir (cmd/server/).
build: {
outDir: 'cmd/server/src/dist',
emptyOutDir: true,
},
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {