generated from CubeCraft-Creations/Tracehound
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c07338707 | |||
| d538dd3b70 | |||
| b1ed8cdb20 | |||
| cb549a8803 | |||
| 832dd7cbf2 | |||
| ee947485d1 | |||
| f03dbb056d | |||
| 8e6cd11d9c | |||
| e00c8dce85 | |||
| 5239346eaa | |||
| 18db26c265 | |||
| 7929d1d969 | |||
| 9fc80a27c9 | |||
| c6d812cca2 | |||
| d2222d4947 | |||
| 4823b746ca | |||
| 4ba11cc945 | |||
| 53ed73ff6c | |||
| 35136cb9ad | |||
| b1edabd3da | |||
| 50e672e753 | |||
| a1456fe741 | |||
| 8387a4208f | |||
| d8ea71a295 | |||
| c2a05f9b7c | |||
| f261fa0f55 | |||
| b0062f1373 | |||
| f6a25fc324 | |||
| 403e1d9edd | |||
| cefb7ef52c | |||
| 996ef87dfd | |||
| 2fb73ec8c4 | |||
| a478f7d478 | |||
| 9accd34b50 | |||
| 8165822e45 | |||
| a31dc62a24 | |||
| 7fcae17239 | |||
| c2670a9f33 | |||
| cc1b05a4e7 | |||
| 81f168e8a4 | |||
| 3e277349ed | |||
| f669ec182a | |||
| 56fe3d228a | |||
| c913039362 | |||
| dd5ffe9fba | |||
| f118b890f0 | |||
| d89f9dc20b | |||
| 95c225e51b | |||
| daeea9f2c9 | |||
| 9a50d0c801 | |||
| af68bfaa3a | |||
| 74c8697e57 | |||
| 1f253283f8 | |||
| bbc6b1ea05 | |||
| 4487f0e0a4 | |||
| 8c8d4e45e5 | |||
| fe193701ae | |||
| 1a8f67a392 | |||
| 4c4368a79f | |||
| 0e2e94a4cf | |||
| c5cbeabd92 | |||
| f4bf37d6a3 | |||
| 893574ee79 | |||
| b3d4226b1c | |||
| 324402f268 |
@@ -0,0 +1,76 @@
|
|||||||
|
// 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}`);
|
||||||
@@ -7,26 +7,27 @@ 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: ubuntu-latest
|
runs-on: go-react
|
||||||
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: ${{ env.GO_VERSION }}
|
go-version: "1.25"
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Toolchain versions
|
||||||
uses: actions/setup-node@v4
|
run: |
|
||||||
with:
|
go version
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node --version
|
||||||
|
|
||||||
- name: Build React frontend
|
- name: Build React frontend
|
||||||
run: |
|
run: |
|
||||||
@@ -39,25 +40,14 @@ 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
|
||||||
|
|
||||||
- name: Upload build artifact
|
# Pull-based deploy: publish the binary to a rolling "dev" release.
|
||||||
uses: actions/upload-artifact@v4
|
# The Pi polls this release and self-updates (scripts/pi-update.sh);
|
||||||
with:
|
# the runner never needs to reach the closed RemoteRig network.
|
||||||
name: ${{ env.BINARY_NAME }}
|
# Done in Node (runner image has no curl/jq/sudo; Node is present).
|
||||||
path: ${{ env.BINARY_NAME }}
|
- name: Publish to rolling dev release
|
||||||
retention-days: 5
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Trigger deploy workflow
|
SERVER: ${{ github.server_url }}
|
||||||
if: success()
|
REPO: ${{ github.repository }}
|
||||||
uses: actions/github-script@v7
|
SHA: ${{ github.sha }}
|
||||||
with:
|
run: node .gitea/scripts/publish-release.mjs
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
name: CI/CD
|
name: CI
|
||||||
|
|
||||||
|
# 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:
|
||||||
@@ -7,59 +12,12 @@ on:
|
|||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-typecheck:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: go-react
|
||||||
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"
|
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
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
|
|
||||||
+6
-4
@@ -9,13 +9,15 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
/dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Frontend build output (embedded at Go build time)
|
# Frontend build output — embedded into the Go binary at build time.
|
||||||
# Allow the fallback placeholder so embed always has at least index.html
|
# Vite writes here (cmd/server/src/dist); ignore the built output but keep
|
||||||
!src/dist/index.html
|
# the committed index.html placeholder so //go:embed always has a file.
|
||||||
|
cmd/server/src/dist/*
|
||||||
|
!cmd/server/src/dist/index.html
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
|||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
# 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` (~56–59) 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
|
||||||
|
```
|
||||||
@@ -117,31 +117,39 @@ Platform: pi-zero-2w (max 16 cameras)
|
|||||||
RemoteRig hub ready
|
RemoteRig hub ready
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building for Raspberry Pi Zero 2 W
|
## Deployment (CI/CD — pull-based)
|
||||||
|
|
||||||
Cross-compile from your development machine:
|
Deploys are automated and pull-based, so nothing has to reach into the closed
|
||||||
|
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=arm GOARM=6 go build -o remoterig-hub ./cmd/server/
|
GOOS=linux GOARCH=arm64 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 | `GOOS=linux GOARCH=arm GOARM=6 go build -o remoterig-hub ./cmd/server/` |
|
| Raspberry Pi Zero 2 W (64-bit OS) | `GOOS=linux GOARCH=arm64 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/` |
|
||||||
|
|
||||||
|
|||||||
+9
-6
@@ -78,7 +78,9 @@ 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)
|
||||||
r.Use(middleware.Timeout(cfg.WriteTimeout))
|
// No global request timeout: it cancels the long-lived SSE stream
|
||||||
|
// (/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) {
|
||||||
@@ -87,7 +89,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)))
|
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB, mqttSub)))
|
||||||
|
|
||||||
// Serve embedded React frontend with SPA fallback
|
// Serve embedded React frontend with SPA fallback
|
||||||
r.Mount("/", frontendHandler())
|
r.Mount("/", frontendHandler())
|
||||||
@@ -97,7 +99,8 @@ func main() {
|
|||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
ReadTimeout: cfg.ReadTimeout,
|
ReadTimeout: cfg.ReadTimeout,
|
||||||
WriteTimeout: cfg.WriteTimeout,
|
// WriteTimeout intentionally 0: SSE responses are long-lived and a
|
||||||
|
// write deadline would terminate them mid-stream.
|
||||||
IdleTimeout: cfg.IdleTimeout,
|
IdleTimeout: cfg.IdleTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +122,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiRouter creates the API route tree.
|
// apiRouter creates the API route tree.
|
||||||
func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
|
func apiRouter(sseHub *events.Hub, database *db.DB, pub api.CommandPublisher) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Camera management routes
|
// Camera management routes
|
||||||
@@ -128,8 +131,8 @@ func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
|
|||||||
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))
|
r.Post("/cameras/{id}/start", api.StartRecording(database, pub))
|
||||||
r.Post("/cameras/{id}/stop", api.StopRecording(database))
|
r.Post("/cameras/{id}/stop", api.StopRecording(database, pub))
|
||||||
|
|
||||||
// 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))
|
||||||
|
|||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
+5
-2
@@ -4,8 +4,11 @@
|
|||||||
# Database
|
# Database
|
||||||
db_path: "remoterig.db"
|
db_path: "remoterig.db"
|
||||||
|
|
||||||
# API Key for endpoint authentication
|
# API key for endpoint authentication. Empty = kiosk mode (no auth) —
|
||||||
api_key: "changeme"
|
# intended for the closed travel-router LAN, consistent with anonymous MQTT.
|
||||||
|
# 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
|
||||||
|
|||||||
+22
-22
@@ -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.4.0/24 │
|
│ Subnet: 192.168.8.0/24 │
|
||||||
│ DHCP pool: .100-.200 │
|
│ DHCP pool: .100-.200 │
|
||||||
└──────┬──────────┬──────────┬──────────────┘
|
└──────┬──────────┬──────────┬──────────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -44,32 +44,32 @@ 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.4.10 │
|
│ DHCP addr │ │ DHCP addr │ │ 192.168.8.56 │
|
||||||
│ │ │ │ │ (static IP) │
|
│ STA→Router │ │ STA→Router │ │ (static IP) │
|
||||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │ │
|
│ MQTT→:1883 │ │ MQTT→:1883 │ │ Mosquitto :1883 │
|
||||||
│ STA→Router │ │ STA→Router │ │ Mosquitto :1883 │
|
│ UART relay │ │ UART relay │ │ Go API :8080 │
|
||||||
│ │ │ │ │ Go API :8080 │
|
│ │ │ │ │ React UI │
|
||||||
│ MQTT→:1883 │ │ MQTT→:1883 │ │ React UI │
|
|
||||||
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
|
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
|
||||||
│ │ └──────────────────┘
|
│ UART │ UART └──────────────────┘
|
||||||
▼ ▼ │
|
▼ ▼ │
|
||||||
┌──────────────┐ ┌──────────────┐ │
|
┌──────────────┐ ┌──────────────┐ │
|
||||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │ SSE /api/v1/events/stream
|
│ ESP8266 #1 │ │ ESP8266 #N │ SSE /api/v1/events/stream
|
||||||
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │ │
|
│ STA→GoPro AP │ │ STA→GoPro AP │ │
|
||||||
│ Wi-Fi only │ │ Wi-Fi only │ ▼
|
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│ ▼
|
||||||
└──────────────┘ └──────────────┘ ┌──────────────────┐
|
└──────┬───────┘ └──────┬───────┘ ┌──────────────────┐
|
||||||
│ User Device │
|
▼ ▼ │ User Device │
|
||||||
│ (laptop/kiosk) │
|
┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │
|
||||||
│ http://.4.10 │
|
│ GoPro Hero 3 │ │ GoPro Hero 3 │ │ 192.168.8.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). ESP32s bridge the GoPro's AP to the LAN via 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.4.0/24`.
|
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `192.168.8.0/24`.
|
||||||
- **ESP32 dual-STA** — One STA to GoPro AP (10.5.5.1), one STA to travel router. No channel-hopping concerns on closed network.
|
- **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.
|
||||||
- **ESP32 → 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.
|
||||||
- **MQTT for ESP32 → Hub** — Lightweight, designed for IoT. Mosquitto on Pi. QoS 1 for status, QoS 2 for commands. Full contract: [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
|
- **MQTT for ESP32 → Hub** — Lightweight, designed for IoT. Mosquitto on Pi. QoS 1 for status, QoS 2 for commands. Full contract: [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
|
||||||
- **SQLite over PostgreSQL** — Single-node Pi Zero 2 W deployment. WAL mode for concurrent read/write.
|
- **SQLite over PostgreSQL** — Single-node Pi Zero 2 W deployment. WAL mode for concurrent read/write.
|
||||||
- **SSE over WebSocket** — Unidirectional hub → browser updates. Simpler, sufficient for status dashboard.
|
- **SSE over WebSocket** — Unidirectional hub → browser updates. Simpler, sufficient for status dashboard.
|
||||||
@@ -215,8 +215,8 @@ platform:
|
|||||||
type: "pi-zero-2w"
|
type: "pi-zero-2w"
|
||||||
max_cameras: 16
|
max_cameras: 16
|
||||||
network:
|
network:
|
||||||
subnet: "192.168.4.0/24" # Travel router subnet
|
subnet: "192.168.8.0/24" # Travel router subnet
|
||||||
hub_ip: "192.168.4.10" # Pi Zero 2 W static IP
|
hub_ip: "192.168.8.56" # Pi Zero 2 W static IP
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Component Tree
|
## Frontend Component Tree
|
||||||
|
|||||||
+48
-33
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
│ Travel Router (192.168.4.1) │
|
│ Travel Router (192.168.8.1) │
|
||||||
│ DHCP: .100-.200 │
|
│ DHCP: .100-.200 │
|
||||||
└──────┬──────────┬──────────┬──────┘
|
└──────┬──────────┬──────────┬──────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -15,27 +15,31 @@
|
|||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
|
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
|
||||||
│ 192.168.4.101│ │ 192.168.4.102│ │ 192.168.4.10 │
|
│ 192.168.8.101 │ │ 192.168.8.102 │ │ 192.168.8.56 │
|
||||||
│ │ │ │ │ │
|
│ STA→Router │ │ STA→Router │ │ Mosquitto │
|
||||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │ Mosquitto │
|
│ MQTT relay │ │ MQTT relay │ │ Go backend │
|
||||||
│ STA→Router │ │ STA→Router │ │ Go backend │
|
|
||||||
└──────┬───────┘ └──────┬───────┘ │ React UI │
|
└──────┬───────┘ └──────┬───────┘ │ React UI │
|
||||||
│ │ └──────────────┘
|
│ UART │ UART └──────────────┘
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ ESP8266 #1 │ │ ESP8266 #2 │
|
||||||
|
│ STA→GoPro AP │ │ STA→GoPro AP │
|
||||||
|
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│
|
||||||
|
└──────┬───────┘ └──────┬───────┘
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌──────────────┐ ┌──────────────┐
|
┌──────────────┐ ┌──────────────┐
|
||||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │
|
│ GoPro Hero 3 │ │ GoPro Hero 3 │
|
||||||
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │
|
|
||||||
└──────────────┘ └──────────────┘
|
└──────────────┘ └──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Travel router:** Self-contained, no internet. DHCP pool: `192.168.4.100-200`
|
- **Travel router:** Self-contained, no internet. Gateway `192.168.8.1`. DHCP pool: `192.168.8.100-200`
|
||||||
- **Pi Zero 2 W:** Static IP `192.168.4.10`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
|
- **Pi Zero 2 W:** Static IP `192.168.8.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
|
||||||
- **ESP32s:** DHCP from router. Each has dual STA: one to GoPro AP, one to router
|
- **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.4.10:8080` for dashboard
|
- **User device:** Connects to router, opens `http://192.168.8.56:8080` for dashboard
|
||||||
|
|
||||||
## MQTT Broker
|
## MQTT Broker
|
||||||
|
|
||||||
- **Host:** `192.168.4.10` (Pi Zero 2 W)
|
- **Host:** `192.168.8.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`)
|
||||||
@@ -61,7 +65,7 @@ remoterig/
|
|||||||
**Direction:** ESP32 → Hub
|
**Direction:** ESP32 → Hub
|
||||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
||||||
|
|
||||||
Published by the ESP32 every 30s with the latest GoPro status.
|
Published by the ESP32 every 30s using the latest GoPro status received from the paired ESP8266 over UART.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -143,8 +147,9 @@ Commands sent from the dashboard to individual cameras.
|
|||||||
| `stop_recording` | Stop GoPro recording | status (updated on next poll) |
|
| `stop_recording` | Stop GoPro recording | status (updated on next poll) |
|
||||||
| `reboot` | Reboot the ESP32 | — (ESP32 reconnects after boot) |
|
| `reboot` | Reboot the ESP32 | — (ESP32 reconnects after boot) |
|
||||||
|
|
||||||
**ESP32 behavior:**
|
**ESP32 / ESP8266 behavior:**
|
||||||
- On receipt, execute command against GoPro
|
- ESP32 receives the MQTT command and forwards it over UART to the paired ESP8266
|
||||||
|
- ESP8266 executes the corresponding HTTP command against the GoPro AP
|
||||||
- Next status publish will reflect the new state
|
- Next status publish will reflect the new state
|
||||||
- If command fails (GoPro unreachable), publish status with `online: false`
|
- If command fails (GoPro unreachable), publish status with `online: false`
|
||||||
|
|
||||||
@@ -171,11 +176,19 @@ 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 |
|
||||||
|
|
||||||
**Hub behavior on first announce:**
|
**Camera IDs (self-assigned — "Option B"):** the node uses a stable
|
||||||
|
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 → create camera with auto-generated `camera_id = "cam-<NNN>"` (zero-padded sequential)
|
2. If new MAC → insert the camera using the node's self-assigned `camera_id`
|
||||||
3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
|
3. Broadcast via SSE that a new camera appeared
|
||||||
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`
|
||||||
|
|
||||||
@@ -217,20 +230,21 @@ 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.4.10:1883)
|
├── Connects to MQTT broker (192.168.8.56:1883)
|
||||||
├── Publishes announce (retained) on cameras/<id>/announce
|
├── Publishes announce (retained) on cameras/<id>/announce
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────┐
|
┌───────────────────────────────────────────────┐
|
||||||
│ Main loop (every 30s): │
|
│ Main loop (every 30s): │
|
||||||
│ 1. HTTP GET GoPro status (10.5.5.1) │
|
│ 1. ESP32 requests/receives status via UART │
|
||||||
│ 2. Parse 60-byte status blob │
|
│ 2. ESP8266 polls GoPro HTTP (10.5.5.1) │
|
||||||
│ 3. Publish status (retained) │
|
│ 3. ESP8266 returns parsed status over UART │
|
||||||
│ 4. Every 60s: publish heartbeat │
|
│ 4. ESP32 publishes MQTT status (retained) │
|
||||||
└─────────────────────────────────────────┘
|
│ 5. Every 60s: ESP32 publishes heartbeat │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
|
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
|
||||||
├── On GoPro unreachable → publish status with online: false
|
├── On ESP8266/GoPro unreachable → publish status with online: false
|
||||||
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
|
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -243,11 +257,12 @@ ESP32 shutdown / watchdog reboot
|
|||||||
1. User clicks "Start" on dashboard
|
1. User clicks "Start" on dashboard
|
||||||
2. Browser → HTTP POST /api/v1/cameras/cam-001/start → Go backend
|
2. Browser → HTTP POST /api/v1/cameras/cam-001/start → Go backend
|
||||||
3. Go backend → MQTT publish remoterig/cameras/cam-001/command {command: "start_recording"}
|
3. Go backend → MQTT publish remoterig/cameras/cam-001/command {command: "start_recording"}
|
||||||
4. ESP32 receives command, sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
|
4. ESP32 receives command and forwards it to ESP8266 over UART
|
||||||
5. GoPro starts recording
|
5. ESP8266 sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
|
||||||
6. Next 30s poll: ESP32 publishes status with recording: true
|
6. GoPro starts recording
|
||||||
7. Go backend receives status, updates SQLite, fans out via SSE
|
7. Next 30s poll: ESP8266 reports status over UART; ESP32 publishes status with recording: true
|
||||||
8. Dashboard updates with pulsing REC indicator
|
8. Go backend receives status, updates SQLite, fans out via SSE
|
||||||
|
9. Dashboard updates with pulsing REC indicator
|
||||||
```
|
```
|
||||||
|
|
||||||
## Offline Buffering (future — CUB-230)
|
## Offline Buffering (future — CUB-230)
|
||||||
@@ -268,6 +283,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.4.10`.
|
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`.
|
||||||
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.
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
# Camera Auto-Discovery and Registration Flow — Design Document
|
||||||
|
|
||||||
|
> **Status:** Draft | **CUB:** 229 | **Date:** 2026-05-23
|
||||||
|
> **Depends on:** MQTT_CONTRACT.md v1.0.0 | **Affects:** CUB-189 (POST /cameras), CUB-232 (MQTT subscriber)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
When a new ESP32 camera node powers on and connects to the travel router, it must self-register with the RemoteRig hub without any manual configuration. This document defines the auto-discovery protocol, message schemas, database extensions, error handling, and retry behavior.
|
||||||
|
|
||||||
|
### Design Goals
|
||||||
|
|
||||||
|
1. **Zero-touch provisioning** — ESP32 node registers itself on first MQTT connect; no dashboard interaction required
|
||||||
|
2. **Re-registration safe** — same node rejoining after a reboot or network blip is recognized and re-associated, not duplicated
|
||||||
|
3. **Idempotent** — replaying an announce due to MQTT retain or offline buffering does not create duplicate cameras
|
||||||
|
4. **Observable** — the dashboard receives real-time SSE events when a camera appears or reconnects
|
||||||
|
5. **Backward compatible** — existing announce format (`MQTT_CONTRACT.md`) is enhanced, not replaced
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ESP32 Announce Message (Registration Request)
|
||||||
|
|
||||||
|
### Topic
|
||||||
|
|
||||||
|
```
|
||||||
|
remoterig/cameras/+/announce
|
||||||
|
```
|
||||||
|
|
||||||
|
**Direction:** ESP32 → Hub | **QoS:** 2 | **Retain:** true
|
||||||
|
|
||||||
|
Published once on ESP32 first boot (or factory reset). Retained so the hub sees it even if it restarts after the ESP32 came online.
|
||||||
|
|
||||||
|
### JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "CameraAnnounce",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["mac_address", "firmware_version", "capabilities"],
|
||||||
|
"properties": {
|
||||||
|
"mac_address": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$",
|
||||||
|
"description": "ESP32 Wi-Fi station MAC address — the stable, globally unique hardware identifier"
|
||||||
|
},
|
||||||
|
"firmware_version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||||
|
"description": "Semver of the ESP32 firmware (e.g. 0.2.0)"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "enum": ["start_stop", "status", "reboot", "heartbeat"] },
|
||||||
|
"minItems": 1,
|
||||||
|
"description": "Supported feature flags. Minimal: [\"status\"]. Full: [\"start_stop\", \"status\", \"reboot\", \"heartbeat\"]"
|
||||||
|
},
|
||||||
|
"friendly_name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"description": "Default human-readable name (e.g. 'ESP32-AA-BB-CC'). If omitted, hub generates one from the MAC."
|
||||||
|
},
|
||||||
|
"device_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["esp32-gopro", "esp32-generic"],
|
||||||
|
"default": "esp32-gopro",
|
||||||
|
"description": "Device class for future multi-type support"
|
||||||
|
},
|
||||||
|
"mqtt_client_id": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"description": "The MQTT client ID the ESP32 connected with (diagnostic)"
|
||||||
|
},
|
||||||
|
"sdk_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ESP-IDF or Arduino SDK version (diagnostic)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example — Minimal
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"firmware_version": "0.1.0",
|
||||||
|
"capabilities": ["status", "heartbeat"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example — Full
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"firmware_version": "0.2.0",
|
||||||
|
"capabilities": ["start_stop", "status", "reboot", "heartbeat"],
|
||||||
|
"friendly_name": "GoPro Hero3 #1",
|
||||||
|
"device_type": "esp32-gopro",
|
||||||
|
"mqtt_client_id": "remoterig-ddeeff",
|
||||||
|
"sdk_version": "ESP-IDF v5.1.4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MAC Address as Identity
|
||||||
|
|
||||||
|
The ESP32's Wi-Fi station MAC is the only stable, globally unique identifier available on a closed network (no cloud, no serial number burned at factory). It is:
|
||||||
|
|
||||||
|
- **Globally unique** — OUI-assigned by Espressif
|
||||||
|
- **Immutable** — persists across firmware flashes and reboots
|
||||||
|
- **Available before MQTT connect** — no dependency on hub-assigned ID
|
||||||
|
|
||||||
|
The hub maps `mac_address → camera_id`. The `camera_id` (e.g. `cam-001`) is a short, human-friendly alias assigned at registration time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Hub Response Protocol
|
||||||
|
|
||||||
|
When the hub processes an announce, it MUST publish a response so the ESP32 knows its registration outcome. The response goes to the **command topic** for the assigned camera.
|
||||||
|
|
||||||
|
### Response Topic
|
||||||
|
|
||||||
|
```
|
||||||
|
remoterig/cameras/<camera_id>/command
|
||||||
|
```
|
||||||
|
|
||||||
|
Direction: Hub → ESP32 | QoS: 2 | Retain: false
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "RegistrationResponse",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["command", "request_id"],
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["registered", "registration_error"],
|
||||||
|
"description": "Outcome of the registration request"
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Echo of the announce message's MAC + timestamp hash for correlation"
|
||||||
|
},
|
||||||
|
"camera_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^cam-\\d{3}$",
|
||||||
|
"description": "Assigned camera ID (present on success only)"
|
||||||
|
},
|
||||||
|
"error_code": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["INVALID_MAC", "CAPABILITY_REQUIRED", "DB_WRITE_FAILED", "RATE_LIMITED"],
|
||||||
|
"description": "Machine-readable error code (present on failure only)"
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable error description (present on failure only)"
|
||||||
|
},
|
||||||
|
"retry_after_sec": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 5,
|
||||||
|
"description": "Suggested retry delay in seconds (present on failure only)"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "ISO 8601 — hub clock time of the response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "registered",
|
||||||
|
"request_id": "req-AABBCCDDEEFF-1684771200",
|
||||||
|
"camera_id": "cam-004",
|
||||||
|
"timestamp": "2026-05-23T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
| error_code | Meaning | retry_after_sec | ESP32 action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `INVALID_MAC` | MAC address absent or malformed | — (fatal) | Log error, halt registration |
|
||||||
|
| `CAPABILITY_REQUIRED` | No valid capabilities specified | — (fatal) | Log error, halt registration |
|
||||||
|
| `DB_WRITE_FAILED` | Hub database is unavailable (disk full, etc.) | 60 | Retry after delay |
|
||||||
|
| `RATE_LIMITED` | Too many registration attempts in a window | 30 | Retry after delay |
|
||||||
|
|
||||||
|
Example error response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "registration_error",
|
||||||
|
"request_id": "req-AABBCCDDEEFF-1684771200",
|
||||||
|
"error_code": "DB_WRITE_FAILED",
|
||||||
|
"error_message": "Database write failed: disk I/O error",
|
||||||
|
"retry_after_sec": 60,
|
||||||
|
"timestamp": "2026-05-23T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ESP32 Retry Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 publishes announce (QoS 2, retain)
|
||||||
|
│
|
||||||
|
├── Subscribe to remoterig/cameras/+/command (QoS 2)
|
||||||
|
│
|
||||||
|
├── Wait for command = "registered" or "registration_error"
|
||||||
|
│
|
||||||
|
├── Timeout after 30s → retry announce (with exponential backoff)
|
||||||
|
│ ├── 1st attempt: immediate
|
||||||
|
│ ├── 2nd attempt: wait 5s
|
||||||
|
│ ├── 3rd attempt: wait 10s
|
||||||
|
│ ├── 4th attempt: wait 20s
|
||||||
|
│ └── 5th+ attempt: wait 30s, repeat every 30s
|
||||||
|
│
|
||||||
|
├── On success (registered): store camera_id in NVS, begin normal status loop
|
||||||
|
│
|
||||||
|
├── On fatal error (INVALID_MAC, CAPABILITY_REQUIRED):
|
||||||
|
│ Log error, blink LED pattern, do not retry
|
||||||
|
│
|
||||||
|
└── On transient error (DB_WRITE_FAILED, RATE_LIMITED):
|
||||||
|
Wait retry_after_sec (capped at 120s), then re-publish announce
|
||||||
|
```
|
||||||
|
|
||||||
|
**After successful registration:** On subsequent boots, the ESP32 reads `camera_id` from NVS (non-volatile storage). It does NOT re-publish announce unless:
|
||||||
|
- `camera_id` is missing from NVS (factory reset / first boot)
|
||||||
|
- The hub publishes `command: "reregister"` to force re-registration (admin action)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Hub Processing Logic
|
||||||
|
|
||||||
|
### Registration Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Hub receives announce on remoterig/cameras/+/announce
|
||||||
|
│
|
||||||
|
├── 1. VALIDATE: mac_address present? matches pattern? → if no: publish INVALID_MAC error
|
||||||
|
│
|
||||||
|
├── 2. VALIDATE: capabilities non-empty? → if no: publish CAPABILITY_REQUIRED error
|
||||||
|
│
|
||||||
|
├── 3. RATE LIMIT: >5 registrations from same IP/MAC in 60s? → RATE_LIMITED error
|
||||||
|
│
|
||||||
|
├── 4. LOOKUP: SELECT camera_id FROM cameras WHERE mac_address = ?
|
||||||
|
│ │
|
||||||
|
│ ├── FOUND → EXISTING CAMERA:
|
||||||
|
│ │ ├── Update: friendly_name, firmware_version, capabilities, updated_at
|
||||||
|
│ │ ├── Publish registered response with existing camera_id
|
||||||
|
│ │ ├── SSE broadcast: "camera_reconnected"
|
||||||
|
│ │ └── Clear MQTT stale announce (publish empty retained message)
|
||||||
|
│ │
|
||||||
|
│ └── NOT FOUND → NEW CAMERA:
|
||||||
|
│ ├── Generate camera_id: "cam-NNN" (sequential)
|
||||||
|
│ ├── INSERT into cameras
|
||||||
|
│ ├── Publish registered response with new camera_id
|
||||||
|
│ ├── SSE broadcast: "camera_registered"
|
||||||
|
│ └── Clear MQTT stale announce (publish empty retained message)
|
||||||
|
│
|
||||||
|
└── 5. CLEANUP: Publish zero-byte retained message to announce topic
|
||||||
|
(prevents stale announces after camera is registered)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
To protect against buggy firmware or network loops:
|
||||||
|
|
||||||
|
| Window | Max Attempts | Action |
|
||||||
|
|--------|-------------|--------|
|
||||||
|
| 60 seconds | 5 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 30` |
|
||||||
|
| 5 minutes | 20 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 60` |
|
||||||
|
|
||||||
|
Rate limit state is in-memory only (not persisted). Restarting the hub resets the counters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database Schema Changes
|
||||||
|
|
||||||
|
### Extended `cameras` Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: 002_add_camera_registration_fields.sql
|
||||||
|
|
||||||
|
ALTER TABLE cameras ADD COLUMN firmware_version TEXT;
|
||||||
|
ALTER TABLE cameras ADD COLUMN capabilities TEXT NOT NULL DEFAULT '["status"]';
|
||||||
|
ALTER TABLE cameras ADD COLUMN device_type TEXT NOT NULL DEFAULT 'esp32-gopro';
|
||||||
|
ALTER TABLE cameras ADD COLUMN registration_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK(registration_status IN ('pending', 'registered', 'error', 'decommissioned'));
|
||||||
|
ALTER TABLE cameras ADD COLUMN last_announce_at DATETIME;
|
||||||
|
ALTER TABLE cameras ADD COLUMN registration_error TEXT;
|
||||||
|
ALTER TABLE cameras ADD COLUMN mqtt_client_id TEXT;
|
||||||
|
|
||||||
|
-- Index for MAC lookups (already exists but confirm)
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
|
||||||
|
|
||||||
|
-- Index for registration status filtering
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cameras_reg_status ON cameras(registration_status);
|
||||||
|
|
||||||
|
-- Index for finding stale registrations (cameras that announced but never sent status)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cameras_last_announce ON cameras(last_announce_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Table Definition (post-migration)
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| `camera_id` | TEXT | PK | Hub-assigned short ID, e.g. `cam-001` |
|
||||||
|
| `friendly_name` | TEXT | NOT NULL | Human-readable name |
|
||||||
|
| `mac_address` | TEXT | UNIQUE | ESP32 Wi-Fi station MAC |
|
||||||
|
| `firmware_version` | TEXT | — | Firmware semver reported by ESP32 |
|
||||||
|
| `capabilities` | TEXT | NOT NULL, DEFAULT `'["status"]'` | JSON array of strings |
|
||||||
|
| `device_type` | TEXT | NOT NULL, DEFAULT `'esp32-gopro'` | Device class |
|
||||||
|
| `registration_status` | TEXT | NOT NULL, DEFAULT `'pending'` | `pending`, `registered`, `error`, `decommissioned` |
|
||||||
|
| `last_announce_at` | DATETIME | — | Timestamp of most recent announce |
|
||||||
|
| `registration_error` | TEXT | — | Last registration error message (cleared on success) |
|
||||||
|
| `mqtt_client_id` | TEXT | — | MQTT client ID from the announce |
|
||||||
|
| `created_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | First registration timestamp |
|
||||||
|
| `updated_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | Last update timestamp |
|
||||||
|
|
||||||
|
### Go Model Extension
|
||||||
|
|
||||||
|
The existing `models.Camera` struct gains:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Camera struct {
|
||||||
|
CameraID string `json:"camera_id"`
|
||||||
|
FriendlyName string `json:"friendly_name"`
|
||||||
|
MacAddress string `json:"mac_address,omitempty"`
|
||||||
|
FirmwareVersion string `json:"firmware_version,omitempty"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
|
RegistrationStatus string `json:"registration_status"`
|
||||||
|
LastAnnounceAt *time.Time `json:"last_announce_at,omitempty"`
|
||||||
|
RegistrationError string `json:"registration_error,omitempty"`
|
||||||
|
MqttClientID string `json:"mqtt_client_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note on `capabilities` storage:** SQLite does not have a native JSON array type. Store as TEXT (JSON-encoded array). Serialize/deserialize in the Go model layer. Migration default is `'["status"]'` — the minimum capability for a useful camera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Registration Flow Sequence Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant ESP32
|
||||||
|
participant Broker as MQTT Broker (Mosquitto)
|
||||||
|
participant Hub as Go Hub
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SSE as SSE Hub
|
||||||
|
participant UI as Dashboard UI
|
||||||
|
|
||||||
|
Note over ESP32: Power on / First boot
|
||||||
|
|
||||||
|
ESP32->>ESP32: Read camera_id from NVS
|
||||||
|
alt camera_id NOT in NVS (first boot or factory reset)
|
||||||
|
ESP32->>Broker: CONNECT (client_id: remoterig-<mac_last6>)
|
||||||
|
Broker-->>ESP32: CONNACK
|
||||||
|
|
||||||
|
ESP32->>Broker: SUBSCRIBE remoterig/cameras/+/command (QoS 2)
|
||||||
|
Broker-->>ESP32: SUBACK
|
||||||
|
|
||||||
|
ESP32->>Broker: PUBLISH remoterig/cameras/announce (QoS 2, retain)
|
||||||
|
Note over ESP32,Broker: {mac_address, firmware_version, capabilities, ...}
|
||||||
|
Broker->>Hub: Forward announce
|
||||||
|
|
||||||
|
Hub->>Hub: Validate: MAC present? capabilities non-empty?
|
||||||
|
alt Validation fails
|
||||||
|
Hub->>Broker: PUBLISH command {command: "registration_error", error_code: "INVALID_MAC"}
|
||||||
|
Broker->>ESP32: Forward error
|
||||||
|
Note over ESP32: Log error, halt (fatal)
|
||||||
|
else Validation passes
|
||||||
|
Hub->>Hub: Rate limit check
|
||||||
|
alt Rate limited
|
||||||
|
Hub->>Broker: PUBLISH command {error_code: "RATE_LIMITED", retry_after_sec: 30}
|
||||||
|
Broker->>ESP32: Forward error
|
||||||
|
Note over ESP32: Wait retry_after_sec, retry
|
||||||
|
else Allowed
|
||||||
|
Hub->>DB: SELECT camera_id WHERE mac_address = ?
|
||||||
|
alt MAC already registered
|
||||||
|
DB-->>Hub: camera_id = "cam-002"
|
||||||
|
Hub->>DB: UPDATE cameras SET firmware_version, capabilities, friendly_name, ...
|
||||||
|
Hub->>SSE: Broadcast "camera_reconnected"
|
||||||
|
else New MAC
|
||||||
|
DB-->>Hub: no rows
|
||||||
|
Hub->>DB: SELECT MAX(camera_id) → "cam-003"
|
||||||
|
Hub->>Hub: Generate "cam-004"
|
||||||
|
Hub->>DB: INSERT INTO cameras (cam-004, ...)
|
||||||
|
Hub->>SSE: Broadcast "camera_registered"
|
||||||
|
end
|
||||||
|
|
||||||
|
Hub->>Broker: PUBLISH command {command: "registered", camera_id: "cam-004"}
|
||||||
|
Broker->>ESP32: Forward registration response
|
||||||
|
|
||||||
|
Hub->>Broker: PUBLISH announce (zero-byte retain) — clear stale announce
|
||||||
|
|
||||||
|
SSE-->>UI: camera_registered / camera_reconnected event
|
||||||
|
UI->>UI: Show new camera card in grid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else camera_id FOUND in NVS (subsequent boot)
|
||||||
|
Note over ESP32: Skip announce, proceed to status loop
|
||||||
|
ESP32->>Broker: PUBLISH status (QoS 1, retain)
|
||||||
|
Broker->>Hub: Forward status
|
||||||
|
Hub->>SSE: Broadcast camera_status
|
||||||
|
SSE-->>UI: Update camera card
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reconnection vs. Registration
|
||||||
|
|
||||||
|
It is critical to distinguish two scenarios:
|
||||||
|
|
||||||
|
### Scenario A: Reconnection (camera was previously registered)
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 boots → reads camera_id from NVS → publishes status on remoterig/cameras/<id>/status
|
||||||
|
→ Hub sees status on a known camera_id → updates online flag → SSE broadcast
|
||||||
|
```
|
||||||
|
|
||||||
|
**No announce published.** The camera already has its identity.
|
||||||
|
|
||||||
|
### Scenario B: First Registration (or factory reset)
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 boots → NVS empty → publishes announce → Hub assigns camera_id →
|
||||||
|
ESP32 stores camera_id in NVS → begins status loop on remoterig/cameras/<id>/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario C: Hub Restart (ESP32 already running)
|
||||||
|
|
||||||
|
```
|
||||||
|
Hub restarts → subscribes to remoterig/cameras/+/announce →
|
||||||
|
MQTT broker delivers retained announce messages →
|
||||||
|
Hub processes each → re-registration safe (MAC already exists → update only)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is why announce messages use `retain: true`. If the hub restarts while ESP32s are running, it re-discovers them from retained announces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Considerations
|
||||||
|
|
||||||
|
| Concern | Mitigation |
|
||||||
|
|---------|-----------|
|
||||||
|
| Rogue node spoofing a MAC | Closed network (travel router, no internet). MAC filtering at the router level as defense-in-depth (future). |
|
||||||
|
| Replay attacks | Announce is idempotent — replaying it only updates timestamps, doesn't create duplicates. |
|
||||||
|
| Denial of registration | Rate limiting (Section 4) prevents flooding. |
|
||||||
|
| Unauthorized decommission | No `decommission` MQTT command exists. Decommission is admin-only via HTTP API with API key auth. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open Questions & Decisions
|
||||||
|
|
||||||
|
| Question | Decision | Rationale |
|
||||||
|
|----------|----------|-----------|
|
||||||
|
| **MAC as identity?** | ✅ Yes | Only globally unique, immutable ID available on a closed network. |
|
||||||
|
| **`camera_id` format?** | `cam-NNN` (zero-padded sequential) | Short, sortable, human-friendly. Collision-free with DB sequence. |
|
||||||
|
| **Re-registration behavior?** | Update existing, don't create duplicate | Announcing with same MAC = reconnection, not new camera. |
|
||||||
|
| **Retain on announce?** | ✅ Yes, cleared after processing | Allows hub restart recovery. Cleanup prevents stale data. |
|
||||||
|
| **Response protocol?** | Publish to `command` topic | Reuses existing command channel. ESP32 subscribes before publishing announce. |
|
||||||
|
| **Capabilities stored?** | ✅ Yes, in `capabilities` column | Enables future feature gating (e.g., "this camera can't start/stop recording"). |
|
||||||
|
| **`device_type` added?** | ✅ Yes, default `esp32-gopro` | Allows future camera types (e.g., Raspberry Pi CSI, USB webcam). |
|
||||||
|
| **Dashboard rename after auto-registration?** | ✅ Yes (via existing POST /cameras or settings API in future) | Already called out in MQTT_CONTRACT.md. No new work in this CUB. |
|
||||||
|
| **NVS key for camera_id?** | `"cam_id"` | Simple, unambiguous. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Plan
|
||||||
|
|
||||||
|
This design document covers the protocol and schema design. Implementation is tracked in the following sub-issues:
|
||||||
|
|
||||||
|
| CUB | Title | Agent | Depends On |
|
||||||
|
|-----|-------|-------|------------|
|
||||||
|
| CUB-229 | Design camera auto-discovery and registration flow | Dex | — (this task) |
|
||||||
|
| CUB-229a | Migration: add registration fields to cameras table | Hex | CUB-229 |
|
||||||
|
| CUB-229b | Go model update: Camera struct with new fields | Dex | CUB-229a |
|
||||||
|
| CUB-229c | MQTT subscriber: registration response protocol | Dex | CUB-229b |
|
||||||
|
| CUB-229d | Rate limiting for announce messages | Dex | CUB-229b |
|
||||||
|
| CUB-229e | SSE events: camera_registered / camera_reconnected | Dex | CUB-229c |
|
||||||
|
| CUB-229f | ESP32 firmware: NVS storage + announce on first boot | Pip | CUB-229 |
|
||||||
|
| CUB-229g | ESP32 firmware: command subscription + registration ACK handling | Pip | CUB-229c |
|
||||||
|
| CUB-229h | Update MQTT_CONTRACT.md with registration response spec | Dex | CUB-229 |
|
||||||
|
| CUB-229i | Integration test: camera auto-registration end-to-end | Dex/Pip | CUB-229e, CUB-229g |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. References
|
||||||
|
|
||||||
|
- [MQTT_CONTRACT.md](../MQTT_CONTRACT.md) — Network topology, topic hierarchy, existing status/heartbeat/command schemas
|
||||||
|
- [CONTEXT.md](../CONTEXT.md) — RemoteRig tech stack, directory layout, database schema
|
||||||
|
- [CUB-230 (Offline Buffer & Replay)](https://linear.app/cubecraft-creations/issue/CUB-230) — Related: offline buffering uses same dedup strategy
|
||||||
|
- [CUB-232 (MQTT Subscriber)](https://linear.app/cubecraft-creations/issue/CUB-232) — The subscriber that will implement this registration logic
|
||||||
|
- [CUB-189 (POST /cameras)](https://linear.app/cubecraft-creations/issue/CUB-189) — HTTP registration endpoint (may be replaced/supplemented by auto-discovery)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 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. Medium–large.
|
||||||
|
- **Hub (Go):** firmware store + HTTP endpoint + MQTT trigger command. Small–medium.
|
||||||
|
- **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".
|
||||||
+93
-77
@@ -1,118 +1,134 @@
|
|||||||
# RemoteRig — ESP32 Camera Node Firmware
|
# RemoteRig — Dual-Board Camera Node Firmware
|
||||||
|
|
||||||
> **Platform:** PlatformIO (esp32dev) | **Framework:** Arduino
|
> **Platform:** PlatformIO (esp8266-camera + esp32-mqtt)
|
||||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
|
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
|
||||||
> **Hardware:** [hardware/README.md](../hardware/README.md)
|
> **Hardware:** [hardware/README.md](../hardware/README.md)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Each camera node uses **two boards** connected via UART — zero network switching:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ UART ┌─────────────────────┐
|
||||||
|
│ ESP8266 D1 Mini │ TX──────→RX │ ESP32 Dev Board │
|
||||||
|
│ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │
|
||||||
|
│ │ 115200 │ │
|
||||||
|
│ STA → GoPro AP │ 8N1 │ STA → Travel Router │
|
||||||
|
│ HTTP → 10.5.5.1 │ │ MQTT → 192.168.8.56│
|
||||||
|
│ Start/stop/status │ │ Hub registration │
|
||||||
|
└─────────────────────┘ └──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Board | Job | Network | Protocol |
|
||||||
|
|-------|-----|---------|----------|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install PlatformIO (if not already)
|
|
||||||
pip install platformio
|
pip install platformio
|
||||||
|
|
||||||
# Build
|
|
||||||
cd firmware
|
cd firmware
|
||||||
pio run
|
|
||||||
|
|
||||||
# Upload to ESP32 (USB connected)
|
# Build both
|
||||||
pio run --target upload
|
pio run -e esp8266-camera
|
||||||
|
pio run -e esp32-mqtt
|
||||||
|
|
||||||
# Upload SPIFFS config (first time only, or after config changes)
|
# Upload to boards (connect one at a time via USB)
|
||||||
pio run --target uploadfs
|
pio run -e esp8266-camera --target upload
|
||||||
|
pio run -e esp32-mqtt --target upload
|
||||||
|
|
||||||
# Serial monitor
|
# Upload configs (each board needs its own)
|
||||||
pio device monitor
|
# ESP8266: copy esp8266-config.json to data/config.json, then:
|
||||||
|
pio run -e esp8266-camera --target uploadfs
|
||||||
|
# ESP32: copy esp32-config.json to data/config.json, then:
|
||||||
|
pio run -e esp32-mqtt --target uploadfs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## UART Protocol (ESP8266 ↔ ESP32)
|
||||||
|
|
||||||
|
JSON-per-line at 115200 8N1. GPIO16 on both boards.
|
||||||
|
|
||||||
|
| Direction | Type | Format | Purpose |
|
||||||
|
|-----------|------|--------|---------|
|
||||||
|
| ESP8266 → ESP32 | `status` | `{"type":"status","battery_raw":217,...}` | Camera poll result |
|
||||||
|
| ESP8266 → ESP32 | `ack` | `{"type":"ack","cmd":"start_recording"}` | Command confirmation |
|
||||||
|
| ESP8266 → ESP32 | `pong` | `{"type":"pong","uptime_ms":12345}` | Ping response |
|
||||||
|
| ESP8266 → ESP32 | `error` | `{"type":"error","msg":"camera unreachable"}` | Error report |
|
||||||
|
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"start_recording"}` | Hub command |
|
||||||
|
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"ping"}` | Link health check |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The ESP32 stores configuration in SPIFFS (`data/config.json`):
|
### ESP8266 (`data/esp8266-config.json`)
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP name |
|
||||||
|
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
|
||||||
|
| `camera_ip` | `"10.5.5.1"` | Camera IP (change for Akaso to 192.168.1.1) |
|
||||||
|
| `poll_interval_sec` | `30` | How often to poll camera |
|
||||||
|
|
||||||
|
### ESP32 (`data/esp32-config.json`)
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|-----|---------|-------------|
|
|-----|---------|-------------|
|
||||||
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
|
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
|
||||||
| `wifi_password` | `""` | Travel router password |
|
| `wifi_password` | `""` | Travel router password |
|
||||||
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP prefix (auto-discovered) |
|
| `mqtt_broker` | `"192.168.8.56"` | Pi Zero 2 W IP |
|
||||||
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
|
|
||||||
| `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W static 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) |
|
||||||
| `poll_interval_sec` | `30` | GoPro status poll frequency |
|
|
||||||
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
|
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
|
||||||
|
|
||||||
**First boot:** Leave `camera_id` empty. The ESP32 will auto-announce to the hub, which assigns a `cam-NNN` ID. The assigned ID is saved to SPIFFS automatically.
|
## Wiring
|
||||||
|
|
||||||
## LED Status Codes
|
|
||||||
|
|
||||||
| Pattern | Meaning |
|
|
||||||
|---------|---------|
|
|
||||||
| Slow blink (1s) | Connected to router + MQTT, normal operation |
|
|
||||||
| Fast blink (200ms) | No Wi-Fi connection — reconnecting |
|
|
||||||
| Solid on | Connected but GoPro unreachable |
|
|
||||||
| Off | Boot/shutdown |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────┐
|
ESP8266 D1 Mini ESP32 Dev Board
|
||||||
│ ESP32 (Arduino) │
|
┌────────────┐ ┌────────────┐
|
||||||
│ │
|
│ │ │ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
|
│ TX (GPIO1)│──────────→│ RX (GPIO16)│
|
||||||
│ │ WiFi STA │ │ WiFi STA │ │ MQTT │ │
|
│ RX (GPIO3)│←──────────│ TX (GPIO17)│
|
||||||
│ │ (Router) │ │ (GoPro) │ │ Client │ │
|
│ GND │───────────│ GND │
|
||||||
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │
|
│ 3.3V │ │ 3.3V │
|
||||||
│ │ │ │ │
|
│ │ │ │
|
||||||
│ │ ┌────────┘ │ │
|
└────────────┘ └────────────┘
|
||||||
│ ▼ ▼ ▼ │
|
│ │
|
||||||
│ ┌─────────────────────────────────┐ │
|
└────────┬─────────────┘
|
||||||
│ │ Main Loop │ │
|
│
|
||||||
│ │ Every 30s: │ │
|
LiPo → 3.3V Buck
|
||||||
│ │ HTTP GET GoPro status │ │
|
(shared power)
|
||||||
│ │ Parse 60-byte blob │ │
|
|
||||||
│ │ MQTT publish status │ │
|
|
||||||
│ │ Every 60s: │ │
|
|
||||||
│ │ MQTT publish heartbeat │ │
|
|
||||||
│ └─────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ SPIFFS: /config.json (persistent) │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Boot Sequence
|
## Boot Sequence
|
||||||
|
|
||||||
1. Load config from SPIFFS
|
1. **ESP8266:** Connect to GoPro AP → wait for UART commands
|
||||||
2. Connect to travel router Wi-Fi (STA mode)
|
2. **ESP32:** Connect to travel router → connect MQTT → announce if new
|
||||||
3. Connect to GoPro AP Wi-Fi (STA mode — simultaneous)
|
3. **ESP8266:** Poll camera every 30s → send status over UART
|
||||||
4. Connect to MQTT broker (192.168.4.10)
|
4. **ESP32:** Receive status → publish MQTT
|
||||||
5. If no `camera_id` → publish announce → hub registers us
|
5. **Hub → MQTT command → ESP32 → UART → ESP8266 → HTTP → GoPro**
|
||||||
6. Subscribe to `remoterig/cameras/{camera_id}/command`
|
|
||||||
7. Enter main loop
|
|
||||||
|
|
||||||
## GoPro API Notes (Hero 3 Black/Silver)
|
## Camera Compatibility
|
||||||
|
|
||||||
- **IP:** Always `10.5.5.1` (GoPro's own AP)
|
| Camera | `camera_ip` | Protocol | Status |
|
||||||
- **Status endpoint:** `GET /bacpac/SH?t={password}&p=%01`
|
|--------|------------|----------|--------|
|
||||||
- **Start recording:** `GET /bacpac/SH?t={password}&p=%01` (mode byte = 1)
|
| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support |
|
||||||
- **Stop recording:** `GET /bacpac/SH?t={password}&p=%00` (mode byte = 0)
|
| Akaso Brave 7 | `192.168.1.1` | Varies | 🔬 Set `camera_ip`, test |
|
||||||
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
|
|
||||||
- **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets
|
|
||||||
|
|
||||||
## ESP8266 Compatibility
|
For non-GoPro cameras: only the ESP8266 firmware needs changes — the ESP32 stays the same.
|
||||||
|
|
||||||
To target ESP8266 instead:
|
## LED Status (ESP8266)
|
||||||
1. Change `platformio.ini`: `board = d1_mini` under `[env:d1_mini]`
|
|
||||||
2. Change `WiFi.h` → `ESP8266WiFi.h`
|
|
||||||
3. ESP8266 doesn't do true simultaneous STA — use single STA to travel router, HTTP to GoPro via router bridge
|
|
||||||
4. SPIFFS → LittleFS on some boards
|
|
||||||
|
|
||||||
ESP32 is recommended for dual-STA capability.
|
| LED | Meaning |
|
||||||
|
|-----|---------|
|
||||||
|
| Solid on | Connected to camera AP, camera responding |
|
||||||
|
| Slow blink (500ms) | Connected to AP but camera not responding |
|
||||||
|
| Off | Wi-Fi disconnected |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Symptom | Check |
|
| Symptom | Check |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| No serial output | Baud rate: 115200. Hold BOOT, press EN, release BOOT for flash mode |
|
| No UART communication | Verify TX→RX crossover. Both boards at 115200. Shared GND. |
|
||||||
| Can't connect to router | Verify SSID/password in SPIFFS config, check router DHCP range |
|
| ESP8266 can't connect | GoPro must be ON with Wi-Fi enabled. Default password: `goprohero` |
|
||||||
| GoPro unreachable | GoPro must be ON and Wi-Fi enabled. Password defaults to "goprohero" |
|
| ESP32 can't connect MQTT | `systemctl status mosquitto` on Pi. Port 1883 open. |
|
||||||
| MQTT connect fails | Verify Mosquitto running on Pi: `systemctl status mosquitto` |
|
| Camera never registers | Watch ESP32 serial for "Announced" message. Check hub logs. |
|
||||||
| Camera never registers | Watch serial for "announce" message, check hub logs for registration |
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"wifi_ssid": "RemoteRig",
|
"wifi_ssid": "RemoteRig",
|
||||||
"wifi_password": "",
|
"wifi_password": "RemoteRig1",
|
||||||
"camera_ssid": "GOPRO-BP-",
|
"mqtt_broker": "192.168.8.56",
|
||||||
"camera_password": "goprohero",
|
|
||||||
"mqtt_broker": "192.168.4.10",
|
|
||||||
"mqtt_port": 1883,
|
"mqtt_port": 1883,
|
||||||
"camera_id": "",
|
"camera_id": "",
|
||||||
"poll_interval_sec": 30,
|
"heartbeat_interval_sec": 60,
|
||||||
"heartbeat_interval_sec": 60
|
"bat_raw_min": 0,
|
||||||
|
"bat_raw_max": 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"camera_ssid": "goprosilver-1",
|
||||||
|
"camera_password": "Bzyeatn421",
|
||||||
|
"camera_ip": "10.5.5.9",
|
||||||
|
"poll_interval_sec": 30
|
||||||
|
}
|
||||||
+75
-15
@@ -1,23 +1,83 @@
|
|||||||
; RemoteRig — ESP32 Camera Node Firmware
|
; RemoteRig — Dual-Board Camera Node Firmware
|
||||||
; Platform: ESP32 (ESP8266 compatible with minor changes)
|
; ============================================
|
||||||
; Framework: Arduino
|
; Each camera node has TWO boards connected via UART:
|
||||||
;
|
;
|
||||||
; Build: pio run
|
; ESP8266 (Camera Bridge): Connects to GoPro AP → HTTP status/control
|
||||||
; Upload: pio run --target upload
|
; ESP32 (MQTT Bridge): Connects to travel router → MQTT to hub
|
||||||
; SPIFFS: pio run --target uploadfs
|
;
|
||||||
; Monitor: pio device monitor
|
; ESP8266 ←──UART──→ ESP32
|
||||||
|
; (TX/RX) (RX16/TX17)
|
||||||
[env:esp32dev]
|
;
|
||||||
platform = espressif32
|
; Build:
|
||||||
board = esp32dev
|
; pio run -e esp8266-camera (ESP8266 — GoPro camera bridge)
|
||||||
framework = arduino
|
; pio run -e seeed_xiao_esp32c6 (XIAO ESP32-C6 — MQTT bridge)
|
||||||
monitor_speed = 115200
|
;
|
||||||
upload_speed = 921600
|
; Upload:
|
||||||
|
; pio run -e esp8266-camera --target upload
|
||||||
|
; pio run -e seeed_xiao_esp32c6 --target upload
|
||||||
|
;
|
||||||
|
; Filesystem:
|
||||||
|
; pio run -e esp8266-camera --target uploadfs
|
||||||
|
; pio run -e seeed_xiao_esp32c6 --target uploadfs
|
||||||
|
|
||||||
|
[common]
|
||||||
lib_deps =
|
lib_deps =
|
||||||
knolleary/PubSubClient @ ^2.8
|
knolleary/PubSubClient @ ^2.8
|
||||||
bblanchon/ArduinoJson @ ^7.3
|
bblanchon/ArduinoJson @ ^7.3
|
||||||
|
|
||||||
build_flags =
|
build_flags =
|
||||||
-D CORE_DEBUG_LEVEL=0
|
-D CORE_DEBUG_LEVEL=0
|
||||||
|
|
||||||
|
; ── ESP-01S: Camera Bridge ──────────────────────────────────
|
||||||
|
; Flashed onto an ESP-01S (ESP8266, 1MB flash). Talks to the GoPro
|
||||||
|
; over Wi-Fi, relays to the XIAO over the hardware UART (GPIO1/3).
|
||||||
|
; 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]
|
||||||
|
platform = espressif8266
|
||||||
|
board = esp01_1m
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
upload_speed = 115200
|
||||||
|
lib_deps = ${common.lib_deps}
|
||||||
|
build_flags = ${common.build_flags}
|
||||||
|
-D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED
|
||||||
|
board_build.flash_mode = dout
|
||||||
|
board_build.f_cpu = 160000000L
|
||||||
|
build_src_filter =
|
||||||
|
-<*.cpp>
|
||||||
|
+<esp8266-camera-bridge.cpp>
|
||||||
|
+<../lib/>
|
||||||
|
|
||||||
|
; ── XIAO ESP32-C6: MQTT Bridge ─────────────────────────────
|
||||||
|
; Flashed onto a Seeed Studio XIAO ESP32-C6. Connects to the
|
||||||
|
; travel router, publishes MQTT to the Pi hub. Reads camera
|
||||||
|
; status from the ESP-01S over UART (Serial1: RX=D7, TX=D6).
|
||||||
|
; 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]
|
||||||
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
|
||||||
|
board = seeed_xiao_esp32c6
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
lib_deps =
|
||||||
|
${common.lib_deps}
|
||||||
|
olikraus/U8g2 @ ^2.35
|
||||||
|
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 =
|
||||||
|
-<*.cpp>
|
||||||
|
+<esp32-mqtt-bridge.cpp>
|
||||||
|
+<../lib/>
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
/**
|
||||||
|
* RemoteRig — ESP32 MQTT Bridge Firmware
|
||||||
|
* ======================================
|
||||||
|
* Dedicated board per camera node. Connects the ESP8266 camera bridge
|
||||||
|
* to the RemoteRig MQTT hub.
|
||||||
|
*
|
||||||
|
* ONE JOB: relay between UART (ESP8266) and MQTT (Pi hub).
|
||||||
|
* - Connects to travel router Wi-Fi
|
||||||
|
* - Reads status JSON from ESP8266 over UART → publishes via MQTT
|
||||||
|
* - Receives commands via MQTT from hub → forwards to ESP8266 over UART
|
||||||
|
* - Handles auto-registration (announce on first boot)
|
||||||
|
* - Heartbeat publishing
|
||||||
|
* - Zero camera communication, zero network switching
|
||||||
|
*
|
||||||
|
* UART Protocol: JSON-per-line at 115200 8N1
|
||||||
|
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
|
||||||
|
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
|
||||||
|
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
|
||||||
|
* ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n
|
||||||
|
*
|
||||||
|
* Hardware:
|
||||||
|
* - Seeed Studio XIAO ESP32-C6
|
||||||
|
* - Serial1: RX=D7, TX=D6 (crossed to the ESP-01S TX/RX)
|
||||||
|
* - Shared GND between boards
|
||||||
|
* - 5V rail → XIAO 5V/VIN; ESP-01S on its own 3.3V buck
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <WiFiClient.h>
|
||||||
|
#include <PubSubClient.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <U8g2lib.h>
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Configuration (LittleFS)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
String wifi_ssid = "RemoteRig";
|
||||||
|
String wifi_password = "";
|
||||||
|
String mqtt_broker = "192.168.8.56";
|
||||||
|
int mqtt_port = 1883;
|
||||||
|
String camera_id = ""; // assigned by hub
|
||||||
|
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;
|
||||||
|
|
||||||
|
bool loadConfig() {
|
||||||
|
if (!LittleFS.begin(true)) { Serial.println("[CFG] LittleFS mount failed"); return false; }
|
||||||
|
File f = LittleFS.open("/config.json", "r");
|
||||||
|
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, f);
|
||||||
|
f.close();
|
||||||
|
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
|
||||||
|
|
||||||
|
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
||||||
|
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
||||||
|
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
||||||
|
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
||||||
|
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saveConfig() {
|
||||||
|
File f = LittleFS.open("/config.json", "w");
|
||||||
|
if (!f) return false;
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["wifi_ssid"] = cfg.wifi_ssid;
|
||||||
|
doc["wifi_password"] = cfg.wifi_password;
|
||||||
|
doc["mqtt_broker"] = cfg.mqtt_broker;
|
||||||
|
doc["mqtt_port"] = cfg.mqtt_port;
|
||||||
|
doc["camera_id"] = cfg.camera_id;
|
||||||
|
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);
|
||||||
|
f.close();
|
||||||
|
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)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// XIAO ESP32-C6 Serial1: RX=D7, TX=D6 (Serial = native USB CDC)
|
||||||
|
// Connect: XIAO RX(D7) ← ESP-01S TX
|
||||||
|
// XIAO TX(D6) → ESP-01S RX
|
||||||
|
|
||||||
|
#define UART_ESP8266 Serial1
|
||||||
|
#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) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["type"] = "cmd";
|
||||||
|
doc["command"] = command;
|
||||||
|
String line;
|
||||||
|
serializeJson(doc, line);
|
||||||
|
UART_ESP8266.println(line);
|
||||||
|
UART_ESP8266.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
String uartLine;
|
||||||
|
bool readFromESP8266(String& line) {
|
||||||
|
while (UART_ESP8266.available()) {
|
||||||
|
char c = UART_ESP8266.read();
|
||||||
|
if (c == '\n') {
|
||||||
|
line = uartLine;
|
||||||
|
uartLine = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c != '\r') uartLine += c;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// MQTT
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
WiFiClient routerClient;
|
||||||
|
PubSubClient mqtt(routerClient);
|
||||||
|
|
||||||
|
unsigned long bootMs = 0;
|
||||||
|
bool cameraOnline = false;
|
||||||
|
unsigned long lastStatusMs = 0;
|
||||||
|
|
||||||
|
String clientID() {
|
||||||
|
uint8_t mac[6];
|
||||||
|
WiFi.macAddress(mac);
|
||||||
|
char buf[32];
|
||||||
|
snprintf(buf, sizeof(buf), "rig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
||||||
|
return String(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
String mqttTopic(const char* t) {
|
||||||
|
return "remoterig/cameras/" + cfg.camera_id + "/" + t;
|
||||||
|
}
|
||||||
|
|
||||||
|
void mqttCallback(char* topic, byte* payload, unsigned int len) {
|
||||||
|
char buf[256];
|
||||||
|
unsigned int n = len < 255 ? len : 255;
|
||||||
|
memcpy(buf, payload, n); buf[n] = 0;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, buf)) return;
|
||||||
|
|
||||||
|
String cmd = doc["command"] | "";
|
||||||
|
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||||
|
Serial.printf("[MQTT] Forwarding command: %s → ESP8266\n", cmd.c_str());
|
||||||
|
sendCmdToESP8266(cmd);
|
||||||
|
} else if (cmd == "reboot") {
|
||||||
|
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") {
|
||||||
|
String id = doc["camera_id"] | "";
|
||||||
|
if (id.length() > 0 && id != cfg.camera_id) {
|
||||||
|
cfg.camera_id = id;
|
||||||
|
saveConfig();
|
||||||
|
mqtt.unsubscribe(mqttTopic("command").c_str());
|
||||||
|
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
||||||
|
Serial.printf("[MQTT] Registered as %s\n", id.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool connectMQTT() {
|
||||||
|
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
|
||||||
|
mqtt.setCallback(mqttCallback);
|
||||||
|
mqtt.setKeepAlive(60);
|
||||||
|
|
||||||
|
if (!mqtt.connect(clientID().c_str())) {
|
||||||
|
Serial.printf("[MQTT] Connect fail (state=%d)\n", mqtt.state());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("[MQTT] Connected");
|
||||||
|
|
||||||
|
// Option B: self-assigned, stable camera_id derived from the device id.
|
||||||
|
if (cfg.camera_id.length() == 0) {
|
||||||
|
cfg.camera_id = clientID(); // e.g. "rig-86d978"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to our command topic.
|
||||||
|
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
||||||
|
|
||||||
|
// Announce (retained) on the contract topic so the hub registers/tracks us.
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["mac_address"] = WiFi.macAddress();
|
||||||
|
doc["firmware_version"] = "0.4.0-esp32-mqtt-bridge";
|
||||||
|
doc["friendly_name"] = "Cam-" + cfg.camera_id;
|
||||||
|
JsonArray caps = doc["capabilities"].to<JsonArray>();
|
||||||
|
caps.add("start_stop"); caps.add("status");
|
||||||
|
String payload; serializeJson(doc, payload);
|
||||||
|
mqtt.publish(mqttTopic("announce").c_str(), payload.c_str(), true);
|
||||||
|
Serial.printf("[MQTT] Announced as %s\n", cfg.camera_id.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(500);
|
||||||
|
Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0");
|
||||||
|
|
||||||
|
bootMs = millis();
|
||||||
|
rgbInit(); // RGB STAT LED — blue during boot
|
||||||
|
|
||||||
|
displayInit(); // I2C scan + OLED splash
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// UART to ESP-01S
|
||||||
|
UART_ESP8266.begin(115200, SERIAL_8N1, UART_RX_PIN, UART_TX_PIN);
|
||||||
|
Serial.println("[UART] ESP-01S link on Serial1 (RX=D7, TX=D6) @ 115200");
|
||||||
|
|
||||||
|
// Connect to travel router — the ONLY network we touch
|
||||||
|
Serial.printf("[WIFI] Connecting to: %s\n", cfg.wifi_ssid.c_str());
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
|
||||||
|
|
||||||
|
int attempts = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
|
||||||
|
delay(500); Serial.print("."); attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||||
|
} else {
|
||||||
|
Serial.println("\n[WIFI] FAILED — will retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MQTT
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
connectMQTT();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Main Loop
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
static unsigned long lastBeat = 0, lastRecon = 0;
|
||||||
|
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 ──
|
||||||
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
|
if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); }
|
||||||
|
delay(100); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MQTT watchdog ──
|
||||||
|
if (!mqtt.connected()) {
|
||||||
|
if (now - lastRecon > (unsigned long)(reconDelay * 1000)) {
|
||||||
|
lastRecon = now;
|
||||||
|
if (connectMQTT()) reconDelay = 1;
|
||||||
|
else reconDelay = min(reconDelay * 2, 30);
|
||||||
|
}
|
||||||
|
mqtt.loop(); delay(100); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mqtt.loop();
|
||||||
|
|
||||||
|
// ── Read status from ESP8266 over UART → publish via MQTT ──
|
||||||
|
String line;
|
||||||
|
while (readFromESP8266(line)) {
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, line);
|
||||||
|
if (err) { Serial.printf("[UART] Bad JSON: %s\n", line.c_str()); continue; }
|
||||||
|
|
||||||
|
String type = doc["type"] | "";
|
||||||
|
|
||||||
|
if (type == "status") {
|
||||||
|
// Relay camera status to MQTT hub
|
||||||
|
lastStatusMs = now;
|
||||||
|
bool online = doc["online"] | false;
|
||||||
|
cameraOnline = online; // reflected on the RGB LED by updateStatusLed()
|
||||||
|
|
||||||
|
// Mirror status onto the OLED fields
|
||||||
|
dispBatteryRaw = doc["battery_raw"] | 0;
|
||||||
|
dispVideoRemain = doc["video_remaining_sec"] | 0;
|
||||||
|
bool rec = doc["recording"] | false;
|
||||||
|
if (rec && !dispRecording) recStartMs = millis();
|
||||||
|
if (!rec) recStartMs = 0;
|
||||||
|
dispRecording = rec;
|
||||||
|
|
||||||
|
if (cfg.camera_id.length() > 0) {
|
||||||
|
// Build the MQTT status payload per contract
|
||||||
|
JsonDocument mqttDoc;
|
||||||
|
mqttDoc["camera_id"] = cfg.camera_id;
|
||||||
|
// No timestamp: the node has no real clock; the hub stamps on receipt.
|
||||||
|
mqttDoc["battery_raw"] = dispBatteryRaw;
|
||||||
|
int pct = batteryPct(dispBatteryRaw);
|
||||||
|
if (pct >= 0) mqttDoc["battery_pct"] = pct; // omit when uncalibrated
|
||||||
|
mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0;
|
||||||
|
mqttDoc["recording"] = doc["recording"] | false;
|
||||||
|
mqttDoc["online"] = online;
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
serializeJson(mqttDoc, payload);
|
||||||
|
mqtt.publish(mqttTopic("status").c_str(), payload.c_str(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type == "ack") {
|
||||||
|
Serial.printf("[UART] ESP8266 ack: %s\n", doc["cmd"] | "?");
|
||||||
|
}
|
||||||
|
else if (type == "pong") {
|
||||||
|
Serial.printf("[UART] ESP8266 pong (uptime=%d)\n", doc["uptime_ms"] | 0);
|
||||||
|
}
|
||||||
|
else if (type == "error") {
|
||||||
|
Serial.printf("[UART] ESP8266 error: %s\n", doc["msg"] | "?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heartbeat to hub (every heartbeat_sec) ──
|
||||||
|
if (cfg.camera_id.length() > 0 &&
|
||||||
|
now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) {
|
||||||
|
lastBeat = now;
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["camera_id"] = cfg.camera_id;
|
||||||
|
doc["timestamp"] = millis();
|
||||||
|
doc["uptime_sec"] = (now - bootMs) / 1000;
|
||||||
|
doc["free_heap"] = ESP.getFreeHeap();
|
||||||
|
doc["status_age_ms"] = now - lastStatusMs;
|
||||||
|
String payload; serializeJson(doc, payload);
|
||||||
|
mqtt.publish(mqttTopic("heartbeat").c_str(), payload.c_str(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Periodic ping to ESP8266 to verify UART link ──
|
||||||
|
static unsigned long lastPing = 0;
|
||||||
|
if (now - lastPing > 30000) {
|
||||||
|
lastPing = now;
|
||||||
|
sendCmdToESP8266("ping");
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(50);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* RemoteRig — ESP8266 Camera Bridge Firmware
|
||||||
|
* ==========================================
|
||||||
|
* Dedicated board clipped to each GoPro Hero 3.
|
||||||
|
*
|
||||||
|
* ONE JOB: talk to the camera.
|
||||||
|
* - Connects to GoPro Wi-Fi AP (10.5.5.1)
|
||||||
|
* - Polls status every 30s → sends JSON over UART to ESP32
|
||||||
|
* - Receives commands from ESP32 over UART → executes against camera
|
||||||
|
* - Zero network switching, zero MQTT, zero cloud
|
||||||
|
*
|
||||||
|
* UART Protocol: JSON-per-line at 115200 8N1
|
||||||
|
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
|
||||||
|
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
|
||||||
|
* ESP8266 → ESP32: {"type":"error","msg":"..."}\n
|
||||||
|
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
|
||||||
|
*
|
||||||
|
* Hardware:
|
||||||
|
* - ESP-01S (ESP8266, 1MB flash) on its own 3.3V buck
|
||||||
|
* - UART is the hardware Serial (GPIO1 TX / GPIO3 RX), crossed:
|
||||||
|
* ESP-01S TX (GPIO1) → XIAO D7 (RX)
|
||||||
|
* ESP-01S RX (GPIO3) ← XIAO D6 (TX)
|
||||||
|
* - Shared GND between boards
|
||||||
|
* - Flash with a 3.3V USB-UART adapter, GPIO0 → GND on power-up
|
||||||
|
*
|
||||||
|
* 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 <ESP8266WiFi.h>
|
||||||
|
#include <WiFiClient.h>
|
||||||
|
#include <ESP8266HTTPClient.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Configuration (SPIFFS via LittleFS)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
// Defaults validated against a GoPro Hero 3 Silver. Per-camera values can
|
||||||
|
// be overridden at runtime via the set_config command (no reflash).
|
||||||
|
String camera_ssid = "goprosilver-1";
|
||||||
|
String camera_password = "Bzyeatn421";
|
||||||
|
String camera_ip = "10.5.5.9"; // Hero 3 HTTP API host (not .1)
|
||||||
|
int poll_interval_sec = 30;
|
||||||
|
} cfg;
|
||||||
|
|
||||||
|
bool loadConfig() {
|
||||||
|
if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; }
|
||||||
|
File f = LittleFS.open("/config.json", "r");
|
||||||
|
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, f);
|
||||||
|
f.close();
|
||||||
|
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
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)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
WiFiClient goproClient;
|
||||||
|
|
||||||
|
struct CamStatus {
|
||||||
|
bool valid = false;
|
||||||
|
int video_remaining_sec = 0;
|
||||||
|
bool recording = false;
|
||||||
|
int battery_raw = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
CamStatus fetchStatus() {
|
||||||
|
CamStatus s;
|
||||||
|
|
||||||
|
// READ status — must NOT be the shutter endpoint. Hero 3 status blob
|
||||||
|
// (validated on a Hero 3 Silver, ~31 bytes):
|
||||||
|
// [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;
|
||||||
|
http.useHTTP10(true);
|
||||||
|
http.begin(goproClient, url);
|
||||||
|
http.setTimeout(5000);
|
||||||
|
int code = http.GET();
|
||||||
|
if (code != 200) { http.end(); return s; }
|
||||||
|
|
||||||
|
uint8_t buf[40] = {0};
|
||||||
|
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();
|
||||||
|
if (n < 30) return s;
|
||||||
|
|
||||||
|
s.valid = true;
|
||||||
|
s.recording = (buf[29] == 1);
|
||||||
|
s.battery_raw = buf[19];
|
||||||
|
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool sendCommand(const String& cmd) {
|
||||||
|
String param = (cmd == "start_recording") ? "%01" : "%00";
|
||||||
|
String url = "http://" + cfg.camera_ip +
|
||||||
|
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.useHTTP10(true);
|
||||||
|
http.begin(goproClient, url);
|
||||||
|
http.setTimeout(5000);
|
||||||
|
int code = http.GET();
|
||||||
|
http.end();
|
||||||
|
return (code == 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// UART Protocol (to ESP32)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Using HardwareSerial on GPIO1/3 (D1 Mini default TX/RX)
|
||||||
|
// On D1 Mini: TX=GPIO1, RX=GPIO3 (labeled TX/RX on board)
|
||||||
|
|
||||||
|
// Send JSON line to ESP32
|
||||||
|
void sendToESP32(const JsonDocument& doc) {
|
||||||
|
String line;
|
||||||
|
serializeJson(doc, line);
|
||||||
|
Serial.println(line); // newline-terminated for framing
|
||||||
|
Serial.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send status update
|
||||||
|
void sendStatus(const CamStatus& s) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["type"] = "status";
|
||||||
|
doc["valid"] = s.valid;
|
||||||
|
doc["battery_raw"] = s.battery_raw;
|
||||||
|
doc["video_remaining_sec"] = s.video_remaining_sec;
|
||||||
|
doc["recording"] = s.recording;
|
||||||
|
doc["online"] = s.valid;
|
||||||
|
doc["uptime_ms"] = millis();
|
||||||
|
sendToESP32(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send acknowledgment
|
||||||
|
void sendAck(const String& cmd) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["type"] = "ack";
|
||||||
|
doc["cmd"] = cmd;
|
||||||
|
sendToESP32(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send error
|
||||||
|
void sendError(const String& msg) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["type"] = "error";
|
||||||
|
doc["msg"] = msg;
|
||||||
|
sendToESP32(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Command handling (from ESP32 over UART)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
void handleCommand(const JsonDocument& doc) {
|
||||||
|
String cmd = doc["command"] | "";
|
||||||
|
|
||||||
|
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||||
|
bool ok = sendCommand(cmd);
|
||||||
|
if (ok) {
|
||||||
|
sendAck(cmd);
|
||||||
|
} else {
|
||||||
|
sendError("Camera unreachable — command failed");
|
||||||
|
}
|
||||||
|
} else if (cmd == "ping") {
|
||||||
|
JsonDocument pong;
|
||||||
|
pong["type"] = "pong";
|
||||||
|
pong["uptime_ms"] = millis();
|
||||||
|
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 {
|
||||||
|
sendError("Unknown command: " + cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// UART line reader (non-blocking)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
String serialLine;
|
||||||
|
|
||||||
|
bool readLine(String& line) {
|
||||||
|
while (Serial.available()) {
|
||||||
|
char c = Serial.read();
|
||||||
|
if (c == '\n') {
|
||||||
|
line = serialLine;
|
||||||
|
serialLine = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c != '\r') serialLine += c;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// 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() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(500);
|
||||||
|
Serial.println("\n[BRIDGE] ESP-01S Camera Bridge v1.0");
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// Connect to GoPro AP — this is the ONLY network we touch
|
||||||
|
Serial.printf("[WIFI] Connecting to camera AP: %s\n", cfg.camera_ssid.c_str());
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
|
||||||
|
|
||||||
|
int attempts = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
|
||||||
|
delay(500); Serial.print("."); attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||||
|
} else {
|
||||||
|
Serial.println("\n[WIFI] FAILED — will retry in loop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Main Loop — poll camera, relay over UART
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
static unsigned long lastPoll = 0;
|
||||||
|
static unsigned long lastWiFiRetry = 0;
|
||||||
|
|
||||||
|
// ── Wi-Fi reconnection ──
|
||||||
|
if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) {
|
||||||
|
lastWiFiRetry = now;
|
||||||
|
Serial.println("[WIFI] Reconnecting...");
|
||||||
|
WiFi.reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poll camera ──
|
||||||
|
if (now - lastPoll > (unsigned long)(cfg.poll_interval_sec * 1000)) {
|
||||||
|
lastPoll = now;
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
CamStatus s = fetchStatus();
|
||||||
|
sendStatus(s);
|
||||||
|
} else {
|
||||||
|
// Offline — send empty status so ESP32 knows we're alive but camera is down
|
||||||
|
CamStatus s;
|
||||||
|
sendStatus(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read commands from ESP32 over UART ──
|
||||||
|
String line;
|
||||||
|
if (readLine(line)) {
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, line);
|
||||||
|
if (!err) {
|
||||||
|
String type = doc["type"] | "";
|
||||||
|
if (type == "cmd") {
|
||||||
|
handleCommand(doc);
|
||||||
|
}
|
||||||
|
// Ignore other message types — they're for the ESP32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
/**
|
|
||||||
* RemoteRig — ESP32 Camera Node Firmware
|
|
||||||
* =======================================
|
|
||||||
* One ESP32 per GoPro Hero 3. Bridges the camera's Wi-Fi AP (10.5.5.1)
|
|
||||||
* to the travel router LAN via MQTT (Mosquitto on Pi Zero 2 W).
|
|
||||||
*
|
|
||||||
* MQTT Contract: docs/MQTT_CONTRACT.md
|
|
||||||
* Hardware: hardware/README.md
|
|
||||||
* Platform: PlatformIO (esp32dev)
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
#include <WiFi.h>
|
|
||||||
#include <WiFiClient.h>
|
|
||||||
#include <HTTPClient.h>
|
|
||||||
#include <PubSubClient.h>
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <SPIFFS.h>
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// Configuration (overridden by SPIFFS /data/config.json)
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
struct Config {
|
|
||||||
// Travel router Wi-Fi
|
|
||||||
String wifi_ssid = "RemoteRig";
|
|
||||||
String wifi_password = "";
|
|
||||||
|
|
||||||
// GoPro Hero 3 Wi-Fi AP
|
|
||||||
String camera_ssid = "GOPRO-BP-"; // prefix — auto-discovered
|
|
||||||
String camera_password = "goprohero";
|
|
||||||
|
|
||||||
// MQTT broker (Pi Zero 2 W on travel router)
|
|
||||||
String mqtt_broker = "192.168.4.10";
|
|
||||||
int mqtt_port = 1883;
|
|
||||||
|
|
||||||
// Assigned by hub on first announce; empty until registered
|
|
||||||
String camera_id = "";
|
|
||||||
|
|
||||||
// Polling
|
|
||||||
int poll_interval_sec = 30;
|
|
||||||
int heartbeat_interval_sec = 60;
|
|
||||||
|
|
||||||
// Stored in SPIFFS
|
|
||||||
bool dirty = false;
|
|
||||||
} cfg;
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// Network clients
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WiFiClient wifiClient; // for HTTP to GoPro
|
|
||||||
WiFiClient mqttWifiClient; // for MQTT via travel router
|
|
||||||
PubSubClient mqtt(mqttWifiClient);
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// State
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
unsigned long lastPollMs = 0;
|
|
||||||
unsigned long lastHeartbeatMs = 0;
|
|
||||||
unsigned long lastReconnectMs = 0;
|
|
||||||
unsigned long bootMs = 0;
|
|
||||||
int reconnectDelay = 1; // exponential backoff (seconds)
|
|
||||||
bool goproOnline = false;
|
|
||||||
|
|
||||||
// Heartbeat sequence
|
|
||||||
unsigned int heartbeatSeq = 0;
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// LED Pin (built-in on most ESP32 dev boards = GPIO 2)
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const int LED_PIN = 2;
|
|
||||||
|
|
||||||
enum LedMode { LED_OFF, LED_SLOW, LED_FAST, LED_ON };
|
|
||||||
LedMode ledMode = LED_SLOW;
|
|
||||||
|
|
||||||
void setLed(LedMode mode) {
|
|
||||||
ledMode = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// SPIFFS Config
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
bool loadConfig() {
|
|
||||||
if (!SPIFFS.begin(true)) {
|
|
||||||
Serial.println("[CFG] SPIFFS mount failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
File f = SPIFFS.open("/config.json", "r");
|
|
||||||
if (!f) {
|
|
||||||
Serial.println("[CFG] No /config.json — using defaults");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
DeserializationError err = deserializeJson(doc, f);
|
|
||||||
f.close();
|
|
||||||
if (err) {
|
|
||||||
Serial.printf("[CFG] JSON parse error: %s\n", err.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
|
||||||
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
|
||||||
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
|
|
||||||
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
|
|
||||||
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
|
||||||
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
|
||||||
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
|
||||||
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
|
|
||||||
cfg.heartbeat_interval_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_interval_sec;
|
|
||||||
|
|
||||||
Serial.println("[CFG] Loaded from /config.json");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool saveConfig() {
|
|
||||||
File f = SPIFFS.open("/config.json", "w");
|
|
||||||
if (!f) return false;
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["wifi_ssid"] = cfg.wifi_ssid;
|
|
||||||
doc["wifi_password"] = cfg.wifi_password;
|
|
||||||
doc["camera_ssid"] = cfg.camera_ssid;
|
|
||||||
doc["camera_password"] = cfg.camera_password;
|
|
||||||
doc["mqtt_broker"] = cfg.mqtt_broker;
|
|
||||||
doc["mqtt_port"] = cfg.mqtt_port;
|
|
||||||
doc["camera_id"] = cfg.camera_id;
|
|
||||||
doc["poll_interval_sec"] = cfg.poll_interval_sec;
|
|
||||||
doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec;
|
|
||||||
|
|
||||||
serializeJson(doc, f);
|
|
||||||
f.close();
|
|
||||||
Serial.println("[CFG] Saved config");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// Wi-Fi — Dual STA (GoPro AP + Travel Router)
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
bool connectCameraWiFi() {
|
|
||||||
Serial.printf("[WIFI] Connecting to GoPro AP: %s\n", cfg.camera_ssid.c_str());
|
|
||||||
|
|
||||||
// Use WiFi.begin with a second AP config — ESP32 supports this
|
|
||||||
// We connect to travel router first, then GoPro
|
|
||||||
// GoPro AP: static IP on 10.5.5.x subnet
|
|
||||||
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
|
|
||||||
|
|
||||||
int attempts = 0;
|
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
Serial.printf("\n[WIFI] Connected to GoPro AP. IP: %s\n", WiFi.localIP().toString().c_str());
|
|
||||||
goproOnline = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.println("\n[WIFI] Failed to connect to GoPro AP");
|
|
||||||
goproOnline = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// GoPro Hero 3 HTTP API
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// GoPro AP gateway (always 10.5.5.1 for Hero 3)
|
|
||||||
const char* GOPRO_IP = "10.5.5.1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the GoPro camera password.
|
|
||||||
* Hero 3 exposes it via GET /bacpac/sd (no auth required).
|
|
||||||
* Default is "goprohero" but user may have changed it.
|
|
||||||
*/
|
|
||||||
String fetchGoProPassword() {
|
|
||||||
HTTPClient http;
|
|
||||||
http.begin(wifiClient, String("http://") + GOPRO_IP + "/bacpac/sd");
|
|
||||||
int code = http.GET();
|
|
||||||
String body = http.getString();
|
|
||||||
http.end();
|
|
||||||
|
|
||||||
if (code == 200 && body.length() > 0) {
|
|
||||||
// Password is in plain text in the response body
|
|
||||||
body.trim();
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
return cfg.camera_password; // fallback to config value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the GoPro status blob (60 bytes binary).
|
|
||||||
* Returns empty string on failure.
|
|
||||||
*/
|
|
||||||
String fetchGoProStatus() {
|
|
||||||
String url = String("http://") + GOPRO_IP +
|
|
||||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
|
|
||||||
HTTPClient http;
|
|
||||||
http.begin(wifiClient, url);
|
|
||||||
http.setTimeout(5000);
|
|
||||||
int code = http.GET();
|
|
||||||
|
|
||||||
if (code != 200) {
|
|
||||||
http.end();
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// GoPro returns raw binary — use getString() which handles it
|
|
||||||
String raw = http.getString();
|
|
||||||
http.end();
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the 60-byte GoPro status blob into structured data.
|
|
||||||
* Hero 3 status format (offsets are 0-based):
|
|
||||||
* [25-26] video_remaining_sec (uint16 LE)
|
|
||||||
* [29] recording state (0=idle, 1=recording)
|
|
||||||
* [30] mode
|
|
||||||
* [31-32] resolution
|
|
||||||
* [33-34] fps
|
|
||||||
* [57] battery_raw (uint8)
|
|
||||||
*/
|
|
||||||
struct GoProStatus {
|
|
||||||
bool valid = false;
|
|
||||||
int video_remaining_sec = 0;
|
|
||||||
bool recording = false;
|
|
||||||
int mode = 0;
|
|
||||||
int fps = 0;
|
|
||||||
int battery_raw = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
GoProStatus parseStatus(const String& raw) {
|
|
||||||
GoProStatus s;
|
|
||||||
if (raw.length() < 58) {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint8_t* buf = (const uint8_t*)raw.c_str();
|
|
||||||
|
|
||||||
s.valid = true;
|
|
||||||
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
|
||||||
s.recording = (buf[29] == 1);
|
|
||||||
s.mode = buf[30];
|
|
||||||
s.fps = buf[33] | (buf[34] << 8);
|
|
||||||
s.battery_raw = buf[57];
|
|
||||||
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool sendGoProCommand(const String& command) {
|
|
||||||
String param;
|
|
||||||
if (command == "start_recording") {
|
|
||||||
param = "%01"; // mode 1 = record
|
|
||||||
} else if (command == "stop_recording") {
|
|
||||||
param = "%00"; // mode 0 = stop
|
|
||||||
} else {
|
|
||||||
Serial.printf("[GOPRO] Unknown command: %s\n", command.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String url = String("http://") + GOPRO_IP +
|
|
||||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
|
|
||||||
|
|
||||||
HTTPClient http;
|
|
||||||
http.begin(wifiClient, url);
|
|
||||||
http.setTimeout(5000);
|
|
||||||
int code = http.GET();
|
|
||||||
http.end();
|
|
||||||
|
|
||||||
Serial.printf("[GOPRO] Command %s → HTTP %d\n", command.c_str(), code);
|
|
||||||
return (code == 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// MQTT
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
String clientID() {
|
|
||||||
uint8_t mac[6];
|
|
||||||
WiFi.macAddress(mac);
|
|
||||||
char buf[32];
|
|
||||||
snprintf(buf, sizeof(buf), "remoterig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
|
||||||
return String(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
String statusTopic() { return "remoterig/cameras/" + cfg.camera_id + "/status"; }
|
|
||||||
String heartbeatTopic() { return "remoterig/cameras/" + cfg.camera_id + "/heartbeat"; }
|
|
||||||
String announceTopic() { return "remoterig/cameras/" + cfg.camera_id + "/announce"; }
|
|
||||||
String commandTopic() { return "remoterig/cameras/" + cfg.camera_id + "/command"; }
|
|
||||||
|
|
||||||
void mqttCallback(char* topic, byte* payload, unsigned int length) {
|
|
||||||
// Null-terminate payload
|
|
||||||
char buf[256];
|
|
||||||
unsigned int len = length < 255 ? length : 255;
|
|
||||||
memcpy(buf, payload, len);
|
|
||||||
buf[len] = 0;
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] ← %s: %s\n", topic, buf);
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
DeserializationError err = deserializeJson(doc, buf);
|
|
||||||
if (err) {
|
|
||||||
Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String cmd = doc["command"] | "";
|
|
||||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
|
||||||
sendGoProCommand(cmd);
|
|
||||||
} else if (cmd == "reboot") {
|
|
||||||
Serial.println("[MQTT] Reboot command received");
|
|
||||||
ESP.restart();
|
|
||||||
} else if (cmd == "registered") {
|
|
||||||
// Hub assigned us a camera_id on announce
|
|
||||||
String newID = doc["camera_id"] | "";
|
|
||||||
if (newID.length() > 0 && newID != cfg.camera_id) {
|
|
||||||
cfg.camera_id = newID;
|
|
||||||
cfg.dirty = true;
|
|
||||||
Serial.printf("[MQTT] Registered as %s\n", newID.c_str());
|
|
||||||
// Re-subscribe to our new command topic
|
|
||||||
mqtt.unsubscribe(commandTopic().c_str());
|
|
||||||
mqtt.subscribe(commandTopic().c_str(), 2);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool connectMQTT() {
|
|
||||||
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
|
|
||||||
mqtt.setCallback(mqttCallback);
|
|
||||||
mqtt.setKeepAlive(60);
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] Connecting to %s:%d as %s...\n",
|
|
||||||
cfg.mqtt_broker.c_str(), cfg.mqtt_port, clientID().c_str());
|
|
||||||
|
|
||||||
if (mqtt.connect(clientID().c_str())) {
|
|
||||||
Serial.println("[MQTT] Connected");
|
|
||||||
|
|
||||||
// Subscribe to command topic
|
|
||||||
mqtt.subscribe(commandTopic().c_str(), 2);
|
|
||||||
Serial.printf("[MQTT] Subscribed to %s\n", commandTopic().c_str());
|
|
||||||
|
|
||||||
// If we have no camera_id yet, announce ourselves
|
|
||||||
if (cfg.camera_id.length() == 0) {
|
|
||||||
publishAnnounce();
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnectDelay = 1; // reset backoff
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] Connection failed (state=%d)\n", mqtt.state());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void publishAnnounce() {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["mac_address"] = WiFi.macAddress();
|
|
||||||
doc["firmware_version"] = "0.1.0";
|
|
||||||
doc["friendly_name"] = "ESP32-" + clientID().substring(9);
|
|
||||||
|
|
||||||
JsonArray caps = doc["capabilities"].to<JsonArray>();
|
|
||||||
caps.add("start_stop");
|
|
||||||
caps.add("status");
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
// Publish on a temporary announce topic (using MAC as ID until registered)
|
|
||||||
String tempAnnounce = "remoterig/cameras/announce-" + clientID().substring(9);
|
|
||||||
mqtt.publish(tempAnnounce.c_str(), payload.c_str(), true);
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] Published announce: %s\n", payload.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
void publishStatus(const GoProStatus& s) {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["camera_id"] = cfg.camera_id;
|
|
||||||
doc["timestamp"] = millis(); // milliseconds since boot — hub converts to ISO
|
|
||||||
doc["battery_raw"] = s.battery_raw;
|
|
||||||
doc["video_remaining_sec"] = s.video_remaining_sec;
|
|
||||||
doc["recording"] = s.recording;
|
|
||||||
doc["online"] = goproOnline;
|
|
||||||
|
|
||||||
if (s.recording) {
|
|
||||||
doc["mode"] = "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
bool ok = mqtt.publish(statusTopic().c_str(), payload.c_str(), true);
|
|
||||||
if (ok) {
|
|
||||||
Serial.printf("[MQTT] → status (batt=%d, rec=%d, online=%d)\n",
|
|
||||||
s.battery_raw, s.recording, goproOnline);
|
|
||||||
} else {
|
|
||||||
Serial.println("[MQTT] Status publish failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void publishHeartbeat() {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["camera_id"] = cfg.camera_id;
|
|
||||||
doc["timestamp"] = millis();
|
|
||||||
doc["uptime_sec"] = (millis() - bootMs) / 1000;
|
|
||||||
doc["free_heap"] = ESP.getFreeHeap();
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
mqtt.publish(heartbeatTopic().c_str(), payload.c_str(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// Setup
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
delay(1000);
|
|
||||||
Serial.println("\n\nRemoteRig ESP32 Camera Node v0.1.0");
|
|
||||||
Serial.println("===================================");
|
|
||||||
|
|
||||||
bootMs = millis();
|
|
||||||
|
|
||||||
pinMode(LED_PIN, OUTPUT);
|
|
||||||
digitalWrite(LED_PIN, LOW);
|
|
||||||
|
|
||||||
// Load config from SPIFFS
|
|
||||||
loadConfig();
|
|
||||||
Serial.printf("[CFG] camera_id: %s (empty = not yet registered)\n",
|
|
||||||
cfg.camera_id.length() > 0 ? cfg.camera_id.c_str() : "(none)");
|
|
||||||
|
|
||||||
// Connect to travel router Wi-Fi
|
|
||||||
Serial.printf("[WIFI] Connecting to travel router: %s\n", cfg.wifi_ssid.c_str());
|
|
||||||
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
|
|
||||||
|
|
||||||
int wifiAttempts = 0;
|
|
||||||
while (WiFi.status() != WL_CONNECTED && wifiAttempts < 40) {
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
wifiAttempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
|
||||||
setLed(LED_SLOW); // connected to router
|
|
||||||
} else {
|
|
||||||
Serial.println("\n[WIFI] Failed to connect to travel router — will retry in loop");
|
|
||||||
setLed(LED_FAST); // no router connection
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to GoPro AP
|
|
||||||
if (!connectCameraWiFi()) {
|
|
||||||
Serial.println("[WIFI] GoPro not reachable — will retry");
|
|
||||||
setLed(LED_FAST);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect MQTT
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
connectMQTT();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// Main Loop
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
unsigned long now = millis();
|
|
||||||
|
|
||||||
// ── LED heartbeat ──
|
|
||||||
static unsigned long lastLedToggle = 0;
|
|
||||||
int ledInterval = (ledMode == LED_FAST) ? 200 : (ledMode == LED_SLOW) ? 1000 : 0;
|
|
||||||
if (ledInterval > 0 && now - lastLedToggle > ledInterval) {
|
|
||||||
lastLedToggle = now;
|
|
||||||
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
|
|
||||||
}
|
|
||||||
if (ledMode == LED_ON) digitalWrite(LED_PIN, HIGH);
|
|
||||||
if (ledMode == LED_OFF) digitalWrite(LED_PIN, LOW);
|
|
||||||
|
|
||||||
// ── Wi-Fi reconnection ──
|
|
||||||
if (WiFi.status() != WL_CONNECTED) {
|
|
||||||
setLed(LED_FAST);
|
|
||||||
if (now - lastReconnectMs > 5000) {
|
|
||||||
lastReconnectMs = now;
|
|
||||||
Serial.println("[WIFI] Reconnecting...");
|
|
||||||
WiFi.reconnect();
|
|
||||||
}
|
|
||||||
delay(100);
|
|
||||||
return; // skip everything else until Wi-Fi is back
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MQTT reconnection ──
|
|
||||||
if (!mqtt.connected()) {
|
|
||||||
setLed(LED_SLOW);
|
|
||||||
if (now - lastReconnectMs > (unsigned long)(reconnectDelay * 1000)) {
|
|
||||||
lastReconnectMs = now;
|
|
||||||
if (connectMQTT()) {
|
|
||||||
reconnectDelay = 1;
|
|
||||||
} else {
|
|
||||||
reconnectDelay = min(reconnectDelay * 2, 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mqtt.loop();
|
|
||||||
delay(100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLed(LED_SLOW);
|
|
||||||
mqtt.loop();
|
|
||||||
|
|
||||||
// ── GoPro reconnection ──
|
|
||||||
static unsigned long lastGoProRetry = 0;
|
|
||||||
if (!goproOnline && now - lastGoProRetry > 30000) {
|
|
||||||
lastGoProRetry = now;
|
|
||||||
connectCameraWiFi();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status polling (every cfg.poll_interval_sec) ──
|
|
||||||
if (now - lastPollMs > (unsigned long)(cfg.poll_interval_sec * 1000)) {
|
|
||||||
lastPollMs = now;
|
|
||||||
|
|
||||||
String raw = fetchGoProStatus();
|
|
||||||
GoProStatus status = parseStatus(raw);
|
|
||||||
|
|
||||||
if (status.valid) {
|
|
||||||
goproOnline = true;
|
|
||||||
if (cfg.camera_id.length() > 0) {
|
|
||||||
publishStatus(status);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
goproOnline = false;
|
|
||||||
if (cfg.camera_id.length() > 0) {
|
|
||||||
GoProStatus offline = {};
|
|
||||||
offline.valid = true;
|
|
||||||
publishStatus(offline); // publish with online=false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Heartbeat (every heartbeat_interval_sec) ──
|
|
||||||
if (cfg.camera_id.length() > 0 &&
|
|
||||||
now - lastHeartbeatMs > (unsigned long)(cfg.heartbeat_interval_sec * 1000)) {
|
|
||||||
lastHeartbeatMs = now;
|
|
||||||
publishHeartbeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Save config if dirty ──
|
|
||||||
if (cfg.dirty) {
|
|
||||||
cfg.dirty = false;
|
|
||||||
saveConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(100);
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,12 @@ 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
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
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=
|
||||||
@@ -16,6 +20,8 @@ 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=
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# RemoteRig Hardware Design Pipeline
|
||||||
|
|
||||||
|
> Living queue for 3D-printed / physical hardware design work.
|
||||||
|
|
||||||
|
## Active / Ready for CAD prototype
|
||||||
|
|
||||||
|
### Camera node case v4 — upright status panel + strap mount
|
||||||
|
|
||||||
|
**Status:** Parametric OpenSCAD source created; body/lid/preview STLs exported and validated watertight. Ready for CAD review, exact part measurement, and first prototype print.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- `hardware/case/camera-node-case-v4.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-body.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-lid.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-preview.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-front-review.scad`
|
||||||
|
- `hardware/case/camera-node-case-v4-body.stl`
|
||||||
|
- `hardware/case/camera-node-case-v4-lid.stl`
|
||||||
|
- `hardware/case/camera-node-case-v4-preview.stl`
|
||||||
|
- `hardware/case/camera-node-case-v4-front-review.stl`
|
||||||
|
|
||||||
|
**Design direction:**
|
||||||
|
|
||||||
|
- Stand-mounted upright camera node enclosure; the case still does **not** mount to the GoPro.
|
||||||
|
- Visual direction now matches the original green appliance-style reference: tall vertical body, large inset front panel, centered OLED near the upper third, blank middle area, two long rounded lower slots, bottom USB-C female power input, right-side USB-A female passthrough power port for the GoPro, and left-side IPEX/U.FL antenna pigtail/connector exit opposite the USB-A.
|
||||||
|
- This replaces the rejected wide/low generic electronics-box layout from the first v4 attempt.
|
||||||
|
- Primary mounting is reusable cloth zip ties / Velcro straps through two low-profile vertical rear brackets with long lateral side-feed openings, not a clamp/dovetail.
|
||||||
|
- Front has a recessed/flush full-height service lid similar to a field-service status panel.
|
||||||
|
- Lid includes cutouts for:
|
||||||
|
- 1.3-inch OLED/status screen.
|
||||||
|
- separate 3 mm power LED.
|
||||||
|
- single 3 mm RGB status LED replacing red/green status LEDs.
|
||||||
|
- small rocker on/off switch.
|
||||||
|
- two long rounded lower front slots styled after the reference.
|
||||||
|
- Front-panel screen, LED, rocker, and lower-slot openings are actual through-cuts through the full lid and locating lip so the back side of the printed lid is not skinned over.
|
||||||
|
- Body includes screw bosses, recessed lid pocket, lid locating geometry, a bottom USB-C female power inlet cutout, a right-side USB-A female passthrough power cutout, a left-side 5.0 mm prototype IPEX/U.FL antenna pigtail/connector through-hole with shallow exterior recess, and two vertical external rear zip-tie/Velcro brackets to resist rotation on a stand. The zip ties feed laterally through long side windows behind the raised bridge faces; the old top-to-bottom feed-through tunnel is intentionally closed by top/bottom anchor pads.
|
||||||
|
- Internal envelope is sized for known module dimensions plus service clearance:
|
||||||
|
- ESP32-C3 Super Mini: 22.5 × 18 mm.
|
||||||
|
- ESP-01S: ~24.7 × 14.3 × 12 mm.
|
||||||
|
|
||||||
|
**Prototype display content target:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
CAM 03 REC ●
|
||||||
|
BAT 87% LINK OK
|
||||||
|
00:12:34
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prototype dimensions to validate before production:**
|
||||||
|
|
||||||
|
- Exact 1.3-inch OLED module dimensions:
|
||||||
|
- PCB width/height/thickness.
|
||||||
|
- active display/window width/height.
|
||||||
|
- connector side and ribbon/header clearance.
|
||||||
|
- mounting-hole positions, if using module screws or adhesive tape.
|
||||||
|
- Rocker switch:
|
||||||
|
- snap-in cutout width/height.
|
||||||
|
- bezel/flange size.
|
||||||
|
- required panel thickness range.
|
||||||
|
- rear depth and terminal clearance.
|
||||||
|
- LEDs:
|
||||||
|
- preferred holder/bezel style, if any.
|
||||||
|
- final hole diameter for 3 mm PWR LED and 3 mm RGB STAT LED.
|
||||||
|
- current-limiting resistor placement.
|
||||||
|
- Wiring/service:
|
||||||
|
- USB cable diameter and bend radius.
|
||||||
|
- bottom USB-C female panel/breakout connector flange, body depth, and mounting requirements.
|
||||||
|
- right-side USB-A female panel/breakout connector flange, body depth, and mounting requirements for GoPro 5 V passthrough.
|
||||||
|
- left-side IPEX/U.FL antenna pigtail/bulkhead exact outside diameter, retention/flange needs, bend radius, strain relief, and whether the current 5.0 mm prototype through-hole plus 8.5 mm shallow exterior recess should change before production.
|
||||||
|
- actual regulator/power distribution board footprint if used.
|
||||||
|
- Fasteners:
|
||||||
|
- M2 vs M2.5 vs self-tapping screws for lid.
|
||||||
|
- pilot diameter, screw length, and head/counterbore diameter.
|
||||||
|
- Mounting straps:
|
||||||
|
- cloth zip-tie / Velcro width and thickness.
|
||||||
|
- prototype rear side-feed opening: ~40 mm long vertical side window × ~3.8 mm strap-thickness clearance behind each raised bridge, with each visible vertical bracket ~8.5 mm wide × 50 mm tall.
|
||||||
|
- whether two strap paths are enough to prevent case rotation on the expected stand diameter.
|
||||||
|
- whether rear vertical bracket/window edges need larger radii or TPU/silicone sleeve protection.
|
||||||
|
- Printability:
|
||||||
|
- rear vertical zip-tie bracket top/bottom anchor-pad and bridge strength, and whether the lateral side-feed openings print cleanly without supports.
|
||||||
|
- body/lid fit after PETG shrinkage.
|
||||||
|
- lid lip clearance and screw boss robustness.
|
||||||
|
- USB-C/USB-A and IPEX antenna exit cutout edge quality, wall strength, and connector retention/strain relief.
|
||||||
|
|
||||||
|
**Suggested OpenSCAD validation/export commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad -o /tmp/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4-body.scad
|
||||||
|
openscad -o /tmp/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4-lid.scad
|
||||||
|
openscad -o /tmp/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4-preview.scad
|
||||||
|
openscad -o /tmp/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4-front-review.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
Latest validation: OpenSCAD reports `Simple: yes`; trimesh confirms body, lid, preview, and front-review STLs are watertight. Body and lid each export as a single connected printable component; preview includes separate non-print board/connector guide volumes by design. A rear-bracket sanity check confirms both vertical brackets have clear non-solid lateral X-direction side-feed volumes behind the raised bridge faces, while the rear wall, bridge faces, and top/bottom anchor pads remain solid. The left-side IPEX/U.FL antenna hole is a through-wall cut to the interior cavity, not a blind exterior pocket.
|
||||||
|
|
||||||
|
Or with the main parametric selector:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad -D 'part="body"' -o /tmp/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="lid"' -o /tmp/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="preview"' -o /tmp/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
openscad -D 'part="front_review"' -o /tmp/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prior prototype reference
|
||||||
|
|
||||||
|
### Tripod electronics case v3
|
||||||
|
|
||||||
|
**Status:** Historical design reference. In this checkout, previous v3 SCAD/STL files are not present; v4 starts a new `hardware/case/` CAD source set.
|
||||||
|
|
||||||
|
**Previous design notes:**
|
||||||
|
|
||||||
|
- Held ESP32 + ESP8266 stack.
|
||||||
|
- Screw-on lid with vent slots.
|
||||||
|
- Rear dovetail-style rail/socket interface.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**Reasons superseded by v4:**
|
||||||
|
|
||||||
|
- User requested front status/service panel with OLED, LEDs, and rocker switch.
|
||||||
|
- Single RGB status LED replaces separate red/green status LEDs.
|
||||||
|
- Rear strap pass-through loops are simpler and more adaptable than a dedicated clamp/dovetail for field stands.
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
### 10.1-inch touchscreen + Raspberry Pi Zero case
|
||||||
|
|
||||||
|
**Status:** Specific display identified; mechanical measurements needed before CAD.
|
||||||
|
|
||||||
|
**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:**
|
||||||
|
|
||||||
|
- Vendor/model: HZWDONE Raspberry Pi Screen 10.1" Touchscreen
|
||||||
|
- Resolution: 1024×600
|
||||||
|
- Interface: HDMI portable monitor
|
||||||
|
- Mounting: includes fixing holes
|
||||||
|
- Compatibility listing: Raspberry Pi 5/4/3B/B+ and Windows 11/10/8
|
||||||
|
|
||||||
|
**Initial assumptions to validate:**
|
||||||
|
|
||||||
|
- Compute: Raspberry Pi Zero / Zero 2 W mounted behind or below the display.
|
||||||
|
- 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.
|
||||||
|
- 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:**
|
||||||
|
|
||||||
|
- Product link or datasheet for the exact HZWDONE 10.1" variant.
|
||||||
|
- Screen/PCB outer dimensions: width, height, thickness.
|
||||||
|
- Active display opening dimensions.
|
||||||
|
- Fixing-hole locations, hole diameter, and screw size.
|
||||||
|
- Connector locations/orientation for HDMI, USB touch, and power.
|
||||||
|
- Whether the driver/controller board is integrated with the display PCB or separate.
|
||||||
|
- Pi Zero orientation, port access requirements, and whether GPIO/header must remain accessible.
|
||||||
|
- Power connector position and desired cable routing.
|
||||||
|
- Mounting preference: desktop kickstand, tripod clamp, VESA-style holes, handle, or combination.
|
||||||
|
|
||||||
|
**Proposed design approach:**
|
||||||
|
|
||||||
|
1. Create `hardware/display-case/`.
|
||||||
|
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.
|
||||||
|
4. Validate STLs with OpenSCAD + trimesh.
|
||||||
|
5. Upload generated STL/SCAD artifacts to Seafile.
|
||||||
+159
-103
@@ -1,140 +1,196 @@
|
|||||||
# RemoteRig — Camera Node Hardware Design
|
# RemoteRig — Camera Node Hardware Design
|
||||||
|
|
||||||
> **Version:** 0.1.0 | **Status:** Draft
|
> **Version:** 0.3.0 | **Status:** v4 CAD prototype ready for measurement/print validation
|
||||||
> **Target:** GoPro Hero 3 Black/Silver + ESP32 D1 Mini + 1000mAh LiPo
|
> **Target:** GoPro Hero 3 Black/Silver + ESP32-C3 Super Mini + ESP-01S + USB power bank
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Each camera node is a self-contained unit clipped onto a GoPro Hero 3. It provides:
|
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.
|
||||||
- Camera control (start/stop recording) via Wi-Fi
|
|
||||||
- Status monitoring (battery, storage, recording state)
|
|
||||||
- MQTT communication to the central Pi Zero 2 W hub
|
|
||||||
- Battery power for both the ESP32 and GoPro
|
|
||||||
|
|
||||||
## Physical Assembly
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────┐
|
┌─────────────────┐
|
||||||
│ GoPro Hero 3 │
|
│ USB Power Bank │
|
||||||
│ ┌─────────────────────────┐ │
|
│ (off-the-shelf)│
|
||||||
│ │ Lens (front) │ │
|
└────────┬────────┘
|
||||||
│ └─────────────────────────┘ │
|
│ USB-C cable into bottom USB-C female input
|
||||||
│ ┌─────────────────────────┐ │
|
▼
|
||||||
│ │ Screen │ │
|
┌─────────────────────────────────────┐
|
||||||
│ └─────────────────────────┘ │
|
│ Camera Node Case v4 │ ← Velcro/cloth straps to stand
|
||||||
│ ┌──────────┐ │
|
│ ┌──────────────────────────────┐ │
|
||||||
│ 3D Sleeve ─────→│ ESP32 │ │ ← clips onto back/bottom
|
│ │ Flush/recessed service lid │ │
|
||||||
│ │ D1 Mini │ │
|
│ │ 1.3 OLED: CAM/REC/BAT/LINK │ │
|
||||||
│ └──────────┘ │
|
│ │ PWR LED + RGB STAT LED │ │
|
||||||
│ ┌──────────┐ │
|
│ │ Small rocker power switch │ │
|
||||||
│ │ LiPo │ │ ← slides under GoPro
|
│ └──────────────────────────────┘ │
|
||||||
│ │ 1000mAh │ │
|
│ 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 |
|
||||||
|------|-----|------|-------|
|
|------|-----|------|-------|
|
||||||
| GoPro Hero 3 Black/Silver | 1 | Already owned | Target camera |
|
| ESP32-C3 Super Mini | 1 | ~$4–$6 | MQTT / hub-side bridge; known board envelope 22.5 × 18 mm |
|
||||||
| ESP32 D1 Mini | 1 | ~$4 | Or NodeMCU-32S (~$5) |
|
| ESP-01S / ESP8266 module | 1 | ~$2–$3 | Camera-side GoPro Wi-Fi bridge; known envelope ~24.7 × 14.3 × 12 mm |
|
||||||
| LiPo 3.7V 1000mAh | 1 | ~$8 | 50x34x8mm typical |
|
| 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 |
|
||||||
| 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB |
|
| 3 mm power LED | 1 | <$1 | Separate always-power/5V indicator |
|
||||||
| 3.3V buck converter | 1 | ~$1 | LiPo → ESP32 VIN |
|
| 3 mm RGB status LED | 1 | <$1 | Replaces separate red/green status LEDs; firmware can map node states to color |
|
||||||
| JST-XH 2-pin connectors | 2 | ~$1 | Battery quick-disconnect |
|
| Small rocker switch | 1 | ~$1–$3 | On/off switch; prototype CAD assumes 13 × 19 mm snap-in opening |
|
||||||
| Micro-USB right-angle cable | 1 | ~$2 | Buck → GoPro |
|
| 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 (20cm) | 1 | ~$0.50 | Secure to GoPro |
|
| 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 |
|
||||||
| PETG filament | ~30g | ~$0.60 | 3D printed case |
|
| IPEX/U.FL antenna pigtail or bulkhead lead | 1 | TBD | Left-side antenna exit opposite the USB-A port; prototype CAD assumes a 5.0 mm circular through-hole plus shallow exterior recess; measure exact pigtail/bulkhead diameter before production |
|
||||||
|
| USB power bank (5000 mAh+) | 1 | ~$10 | Powers camera node and GoPro |
|
||||||
|
| Short USB cables / wiring | as needed | ~$2–$5 | Power bank → node USB-C input; node 5 V passthrough → USB-A female → GoPro USB cable; internal power/signal wiring |
|
||||||
|
| M2 or small self-tapping screws | 4 | <$1 | Front service lid screws; pilot holes are parametric |
|
||||||
|
| PETG filament | ~35–50 g | ~$1 | 3D printed case body + lid |
|
||||||
|
| Reusable cloth zip ties / Velcro straps | 2 | ~$1 | Primary stand mount through rear vertical zip-tie brackets with lateral side-feed openings |
|
||||||
|
|
||||||
**Total per node:** ~$20
|
**Total per node:** roughly ~$25–$35 plus GoPro and power bank, depending on display/switch choice.
|
||||||
|
|
||||||
## 3D Printed Case
|
## 3D Printed Case
|
||||||
|
|
||||||
The case consists of three parts (see `hardware/case/remoterig-case.scad`):
|
**Current source:** `hardware/case/camera-node-case-v4.scad`
|
||||||
|
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
|
||||||
|
|
||||||
### Part 1: GoPro Sleeve
|
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:
|
||||||
Wraps around the GoPro body with cutouts for:
|
|
||||||
- Lens (front)
|
|
||||||
- Screen/viewfinder (back)
|
|
||||||
- USB port (side)
|
|
||||||
- Bottom mounting fingers
|
|
||||||
- Mounting ears for electronics compartment
|
|
||||||
|
|
||||||
### Part 2: Electronics Compartment
|
1. **Case body** — shell sized around ESP32-C3 Super Mini + ESP-01S with service/wiring clearance.
|
||||||
Clips onto the sleeve's mounting ears. Holds:
|
2. **Flush/recessed full-height front service lid** — screw-on front panel with locating lip and a restrained raised/recessed border.
|
||||||
- ESP32 D1 Mini board (recessed fit)
|
3. **Front panel controls/indicators**:
|
||||||
- USB cable routing (in → ESP32, out → GoPro)
|
- 1.3-inch OLED/status screen window.
|
||||||
- Ventilation slots (top)
|
- 3 mm **PWR** LED.
|
||||||
- LED visibility window
|
- 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.
|
||||||
|
|
||||||
### Part 3: Battery Compartment
|
### Export wrappers
|
||||||
Slides under the GoPro. Contains:
|
|
||||||
- LiPo battery cavity
|
Simple per-part OpenSCAD wrappers are included:
|
||||||
- Cable exits (to ESP32, to GoPro buck converter)
|
|
||||||
- Velcro strap slots
|
- `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 (outdoor/heat resistant) or PLA+
|
|
||||||
- **Layer height:** 0.2mm
|
- **Material:** PETG preferred for heat/outdoor use and strap-tab durability.
|
||||||
- **Infill:** 20% gyroid
|
- **Layer:** 0.2 mm typical.
|
||||||
- **Supports:** Yes (for cable channels)
|
- **Infill:** 20% gyroid minimum; 30%+ recommended around rear vertical zip-tie bracket anchor pads/bridges.
|
||||||
- **Bed adhesion:** Brim (5mm) for sleeve
|
- **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.
|
||||||
- **Orientation:** Print sleeve on its back, compartments flat
|
- **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-C cable → bottom USB-C female input on Camera Node Case
|
||||||
|
├── rocker switch → node power rail
|
||||||
|
├── PWR LED indicator
|
||||||
|
├── XIAO ESP32-C6
|
||||||
|
├── ESP-01S / ESP8266
|
||||||
|
├── 1.3-inch OLED display
|
||||||
|
├── RGB status LED
|
||||||
|
└── 5 V passthrough rail → side USB-A female output
|
||||||
|
└── USB cable → GoPro USB port
|
||||||
|
(power only — no data)
|
||||||
|
|
||||||
|
UART / control inside case:
|
||||||
|
ESP-01S TX (GPIO1) ──→ XIAO D7 (RX)
|
||||||
|
ESP-01S RX (GPIO3) ←── XIAO D6 (TX)
|
||||||
|
ESP-01S GND ─── XIAO 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
|
||||||
```
|
```
|
||||||
LiPo 3.7V
|
|
||||||
├── JST-XH connector
|
**Power note:** exact wiring depends on the regulator/power board used. Confirm OLED voltage, LED current limiting, and whether the rocker switches USB 5 V input or a regulated node rail.
|
||||||
|
|
||||||
|
## Wi-Fi Topology
|
||||||
|
|
||||||
|
```text
|
||||||
|
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP-01S / ESP8266 camera bridge
|
||||||
│
|
│
|
||||||
├──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port
|
UART │ (inside case)
|
||||||
│ (power only — no data over USB)
|
|
||||||
│
|
│
|
||||||
└──→ 3.3V Buck Converter → ESP32 VIN + GND
|
Travel Router ──(AP)────────────────────→ ESP32-C3 MQTT bridge
|
||||||
(or ESP32 D1 Mini has built-in regulator — connect directly to 5V pin)
|
(192.168.8.1) │
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** ESP32 D1 Mini has an onboard 3.3V regulator. You can feed it 5V directly to the 5V pin if using a single 5V buck converter. This simplifies wiring:
|
|
||||||
```
|
|
||||||
LiPo → 5V Buck → ├── ESP32 5V pin
|
|
||||||
└── GoPro USB port
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wi-Fi Topology (No Cables for Camera Control)
|
|
||||||
|
|
||||||
```
|
|
||||||
GoPro Hero 3 ──(Wi-Fi AP @ 10.5.5.1)──→ ESP32 STA #1
|
|
||||||
│
|
│
|
||||||
Travel Router ──(Wi-Fi AP)─────────────────→ ESP32 STA #2
|
MQTT │
|
||||||
(192.168.4.1) │
|
▼
|
||||||
│
|
Pi Hub (192.168.8.56)
|
||||||
└──→ MQTT → Pi Hub (192.168.4.10)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The ESP32 has **no wired data connection** to the GoPro. All camera control is over Wi-Fi. The USB cable is **power only**.
|
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.
|
||||||
|
|
||||||
## Enclosure Dimensions
|
## Field Setup
|
||||||
|
|
||||||
| Component | W × H × D (mm) |
|
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.
|
||||||
| GoPro Hero 3 | 60 × 42 × 30 |
|
3. Strap the case to a tripod/stand leg; use both strap paths to resist rotation.
|
||||||
| ESP32 D1 Mini | 34 × 26 × 5 |
|
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.
|
||||||
| LiPo 1000mAh | 50 × 34 × 8 |
|
5. Toggle rocker switch on.
|
||||||
| Full assembly | ~70 × 60 × 55 |
|
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`.
|
||||||
|
|
||||||
## Usage in the Field
|
## Case Dimensions
|
||||||
|
|
||||||
1. **Pre-show:** Charge LiPos, flash ESP32 firmware, verify MQTT connectivity
|
Prototype v4 nominal CAD dimensions:
|
||||||
2. **At venue:** Mount cameras, power on ESP32s (they auto-connect to travel router)
|
|
||||||
3. **Monitoring:** Open `http://192.168.4.10:8080` on laptop/kiosk
|
|
||||||
4. **Control:** Start/stop recording from dashboard
|
|
||||||
5. **Post-show:** Stop recording, power down, swap batteries for next session
|
|
||||||
|
|
||||||
## Future Improvements
|
| Part / feature | W × D × H (mm) |
|
||||||
|
|---|---|
|
||||||
|
| Case shell external | ~56 × 36 × 82 |
|
||||||
|
| Case with rear zip-tie brackets | ~56 × 41.2 × 82 |
|
||||||
|
| Front recessed lid | visible panel ~48 × 2 × 74; total with locating lip ~48 × 3 × 74 |
|
||||||
|
| OLED visible window assumption | ~31 × 16 |
|
||||||
|
| Rocker cutout assumption | ~13 × 19 |
|
||||||
|
| Bottom USB-C power input cutout | ~10.5 × 4.5 opening with ~18 × 10 shallow underside recess |
|
||||||
|
| Right-side USB-A passthrough cutout | ~16 Y/front-back × 8 Z opening through side wall |
|
||||||
|
| Rear vertical zip-tie brackets | two external side-feed brackets, each ~8.5 mm wide × 50 mm tall; lateral tunnel has ~40 mm vertical side-window length × ~3.8 mm strap-thickness clearance behind the raised bridge |
|
||||||
|
| Board clearance targets | ESP32-C3 22.5 × 18 mm + ESP-01S 24.7 × 14.3 × 12 mm plus wiring/service clearance |
|
||||||
|
|
||||||
- **Hot-swap battery:** Quick-release battery tray with spring contacts
|
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.
|
||||||
- **Weather sealing:** O-ring groove in sleeve for outdoor rain protection
|
|
||||||
- **Lens hood:** Integrated sun shield for outdoor daytime recording
|
|
||||||
- **Mount adapter:** 1/4"-20 tripod mount thread on bottom
|
|
||||||
- **Antenna routing:** External antenna connector for improved Wi-Fi range in stadiums
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 body.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_body_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 front-facing review layout.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_front_review_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 front service lid/status panel.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_lid_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
// Export wrapper for RemoteRig camera node case v4 assembly preview.
|
||||||
|
use <camera-node-case-v4.scad>
|
||||||
|
|
||||||
|
camera_node_preview_v4();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
// RemoteRig camera node case v4
|
||||||
|
// Upright appliance-style OpenSCAD prototype for a strap-mounted camera node.
|
||||||
|
// Units: millimeters. Coordinate system: X=width, Y=depth/front-back, Z=height.
|
||||||
|
// Front/service lid is on the -Y face. Rear side-feed zip-tie brackets are on the +Y face.
|
||||||
|
//
|
||||||
|
// v4 visual direction: tall/upright appliance/control box matching the original
|
||||||
|
// reference image, replacing the rejected wide, low generic electronics box.
|
||||||
|
// Nominal body: 56 W x 36 D x 82 H mm; with low rear zip-tie loops ~41 D.
|
||||||
|
//
|
||||||
|
// Prototype assumptions to confirm against purchased parts:
|
||||||
|
// - 1.3 inch OLED module/window opening: 31 x 16 mm visible window, 37 x 22 mm panel recess.
|
||||||
|
// - Small rocker switch cutout: 13 x 19 mm rectangular snap-in opening.
|
||||||
|
// - LEDs: two 3 mm panel LEDs (PWR + RGB STAT) with 3.2 mm holes.
|
||||||
|
// - Boards: ESP32-C3 Super Mini 22.5 x 18 mm, ESP-01S 24.7 x 14.3 x 12 mm.
|
||||||
|
// - USB-C bottom power inlet and side USB-A passthrough are panel/breakout placeholders;
|
||||||
|
// measure purchased connector flanges/bodies before production prints.
|
||||||
|
// - Left-side IPEX/U.FL antenna pigtail connector/lead hole is a prototype 5.0 mm
|
||||||
|
// circular through-wall clearance; measure the final bulkhead/lead before production.
|
||||||
|
|
||||||
|
$fn = 56;
|
||||||
|
|
||||||
|
// ----- Main enclosure parameters -----
|
||||||
|
case_w = 56; // upright appliance-style external width
|
||||||
|
case_d = 36; // depth for module stack + wiring clearance
|
||||||
|
case_h = 82; // tall vertical appliance-style height
|
||||||
|
wall = 2.2;
|
||||||
|
corner_r = 4.0;
|
||||||
|
front_recess_d = 2.0; // lid sits in this front pocket, nominally flush
|
||||||
|
lid_clearance = 0.35;
|
||||||
|
lid_w = case_w - 8; // nearly full-height/front-width inset panel
|
||||||
|
lid_h = case_h - 8;
|
||||||
|
lid_t = 2.0;
|
||||||
|
lid_lip_t = 1.2; // locating lip protrudes inside service opening
|
||||||
|
service_opening_w = lid_w - 10.0;
|
||||||
|
service_opening_h = lid_h - 16.0;
|
||||||
|
|
||||||
|
// Hardware
|
||||||
|
screw_d = 2.4; // M2 self-tapping / pilot; confirm hardware
|
||||||
|
screw_head_d = 4.6;
|
||||||
|
boss_d = 6.0;
|
||||||
|
boss_len = 8.0;
|
||||||
|
|
||||||
|
// Front panel components
|
||||||
|
oled_window_w = 31.0;
|
||||||
|
oled_window_h = 16.0;
|
||||||
|
oled_bezel_w = 37.0; // shallow recessed visual outline around window
|
||||||
|
oled_bezel_h = 22.0;
|
||||||
|
oled_z = 53.0; // upper third, clear of top screw counterbores
|
||||||
|
led_hole_d = 3.2; // 3 mm LED clearance
|
||||||
|
rocker_w = 13.0; // prototype cutout; measure purchased rocker
|
||||||
|
rocker_h = 19.0;
|
||||||
|
front_slot_w = 34.0; // two long rounded horizontal slots near lower front
|
||||||
|
front_slot_h = 3.2;
|
||||||
|
|
||||||
|
// Rear reusable cloth zip-tie / Velcro pass-through brackets.
|
||||||
|
// Two visibly vertical external brackets sit left/right of center.
|
||||||
|
// The strap path is a lateral X-direction tunnel between the sealed rear wall
|
||||||
|
// and raised bridge face; long side windows stay open for feeding from either side.
|
||||||
|
rear_loop_x = 13.0;
|
||||||
|
rear_loop_w = 8.5; // outside bracket width in X
|
||||||
|
rear_loop_h = 50.0; // outside bracket height in Z
|
||||||
|
rear_loop_z = case_h/2;
|
||||||
|
rear_loop_gap_y = 3.8; // usable strap-thickness clearance behind raised bridge
|
||||||
|
rear_loop_face_t = 1.4; // low-profile outer bridge skin
|
||||||
|
rear_loop_y = rear_loop_gap_y + rear_loop_face_t;
|
||||||
|
rear_loop_anchor_h = 5.0; // top/bottom weld pads; side window remains long vertically
|
||||||
|
rear_loop_side_window_h = rear_loop_h - 2*rear_loop_anchor_h;
|
||||||
|
|
||||||
|
// USB power connector placeholder cutouts
|
||||||
|
usb_c_cutout_w = 10.5; // bottom USB-C female inlet visible opening, X width
|
||||||
|
usb_c_cutout_d = 4.5; // bottom USB-C female inlet visible opening, Y/front-back
|
||||||
|
usb_c_recess_w = 18.0; // shallow underside panel-mount/breakout recess
|
||||||
|
usb_c_recess_d = 10.0;
|
||||||
|
usb_c_y = -7.5; // close to front/service side but clear of screw bosses/lower slots
|
||||||
|
usb_a_cutout_d = 16.0; // side USB-A female opening, Y/front-back dimension
|
||||||
|
usb_a_cutout_h = 8.0; // side USB-A female opening, Z height
|
||||||
|
usb_a_z = 26.0; // mid/lower right side, clear of front lid screws/strap bridges
|
||||||
|
usb_a_y = 2.0;
|
||||||
|
|
||||||
|
// Left-side antenna lead / IPEX-U.FL pigtail connector placeholder.
|
||||||
|
// Opposite the right-side USB-A port and cut fully through the left wall into the cavity.
|
||||||
|
ipex_hole_d = 5.0; // prototype circular clearance; measure final pigtail/bulkhead
|
||||||
|
ipex_recess_d = 8.5; // shallow exterior visual/seat recess, not retention geometry
|
||||||
|
ipex_recess_depth = 0.9;
|
||||||
|
ipex_z = usb_a_z;
|
||||||
|
ipex_y = usb_a_y;
|
||||||
|
|
||||||
|
// ----- Utility geometry -----
|
||||||
|
module rounded_box(size=[10,10,10], r=2, center_xy=true) {
|
||||||
|
// Rounded in XY, straight in Z.
|
||||||
|
linear_extrude(height=size[2])
|
||||||
|
offset(r=r)
|
||||||
|
square([size[0]-2*r, size[1]-2*r], center=center_xy);
|
||||||
|
}
|
||||||
|
|
||||||
|
module xz_rounded_prism(w, d, h, r=2) {
|
||||||
|
// Rounded rectangle in the visible X/Z plane, extruded through Y.
|
||||||
|
rotate([-90,0,0])
|
||||||
|
linear_extrude(height=d, center=true)
|
||||||
|
offset(r=r)
|
||||||
|
square([w-2*r, h-2*r], center=true);
|
||||||
|
}
|
||||||
|
|
||||||
|
module yz_rounded_prism(d, x, h, r=2) {
|
||||||
|
// Rounded rectangle in the visible Y/Z plane, extruded through X.
|
||||||
|
// First argument maps to global Y, third argument maps to global Z.
|
||||||
|
rotate([0,90,0])
|
||||||
|
linear_extrude(height=x, center=true)
|
||||||
|
offset(r=r)
|
||||||
|
square([h-2*r, d-2*r], center=true);
|
||||||
|
}
|
||||||
|
|
||||||
|
module y_cylinder(d, h, center=true) {
|
||||||
|
rotate([90,0,0]) cylinder(d=d, h=h, center=center);
|
||||||
|
}
|
||||||
|
|
||||||
|
module x_cylinder(d, h, center=true) {
|
||||||
|
rotate([0,90,0]) cylinder(d=d, h=h, center=center);
|
||||||
|
}
|
||||||
|
|
||||||
|
module screw_boss(x, z) {
|
||||||
|
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
|
||||||
|
difference() {
|
||||||
|
y_cylinder(d=boss_d, h=boss_len);
|
||||||
|
y_cylinder(d=screw_d, h=boss_len + 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module rear_zip_tie_loop(xc) {
|
||||||
|
// Vertical external belt-loop bracket for reusable cloth zip ties/Velcro.
|
||||||
|
// The bracket silhouette remains vertical, but the real strap tunnel runs
|
||||||
|
// laterally in X through the long side windows, behind the raised bridge face.
|
||||||
|
// Top/bottom pads weld the bridge to the shell; no cut reaches the rear wall.
|
||||||
|
loop_overlap_y = 0.75;
|
||||||
|
pad_r = 1.15;
|
||||||
|
bridge_y_center = case_d/2 + rear_loop_gap_y + rear_loop_face_t/2;
|
||||||
|
pad_y_center = case_d/2 + rear_loop_y/2 - loop_overlap_y;
|
||||||
|
pad_z_offset = rear_loop_h/2 - rear_loop_anchor_h/2;
|
||||||
|
|
||||||
|
union() {
|
||||||
|
// Raised vertical bridge face: visually preserves the requested vertical
|
||||||
|
// rear brackets while spanning the side-feed tunnel externally.
|
||||||
|
translate([xc, bridge_y_center, rear_loop_z])
|
||||||
|
xz_rounded_prism(rear_loop_w, rear_loop_face_t, rear_loop_h, r=1.6);
|
||||||
|
|
||||||
|
// Top and bottom anchor pads close the old top-to-bottom feed direction
|
||||||
|
// and tie the raised face back into the rear wall without opening the case.
|
||||||
|
for (zoff = [-pad_z_offset, pad_z_offset])
|
||||||
|
translate([xc, pad_y_center, rear_loop_z + zoff])
|
||||||
|
xz_rounded_prism(rear_loop_w, rear_loop_y, rear_loop_anchor_h, r=pad_r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Printable body -----
|
||||||
|
module camera_node_body_v4() {
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
// Upright outer shell with softened appliance-like corners.
|
||||||
|
rounded_box([case_w, case_d, case_h], r=corner_r);
|
||||||
|
|
||||||
|
// Rear cloth zip-tie / Velcro side-feed brackets kept flat/quiet.
|
||||||
|
rear_zip_tie_loop(-rear_loop_x);
|
||||||
|
rear_zip_tie_loop( rear_loop_x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-height front recessed lid pocket, like the green reference panel.
|
||||||
|
translate([0, -case_d/2 + front_recess_d/2, case_h/2])
|
||||||
|
cube([lid_w + lid_clearance, front_recess_d + 0.4, lid_h + lid_clearance], center=true);
|
||||||
|
|
||||||
|
// Through service opening behind the lid, leaving a strong inset frame.
|
||||||
|
service_depth = front_recess_d + wall + 2.0;
|
||||||
|
translate([0, -case_d/2 + service_depth/2, case_h/2])
|
||||||
|
xz_rounded_prism(service_opening_w, service_depth + 0.4, service_opening_h, r=2.0);
|
||||||
|
|
||||||
|
// Interior electronics cavity: ESP32-C3 Super Mini + ESP-01S plus wiring/service clearance.
|
||||||
|
cavity_d = case_d - front_recess_d - 2*wall;
|
||||||
|
translate([0, -case_d/2 + front_recess_d + wall + cavity_d/2, case_h/2])
|
||||||
|
cube([case_w - 2*wall, cavity_d, case_h - 2*wall], center=true);
|
||||||
|
|
||||||
|
// Bottom USB-C female power inlet: shallow underside recess plus
|
||||||
|
// rounded through-slot for a flush/panel-mount breakout placeholder.
|
||||||
|
translate([0, usb_c_y, -0.35])
|
||||||
|
rounded_box([usb_c_recess_w, usb_c_recess_d, 0.9], r=1.5);
|
||||||
|
translate([0, usb_c_y, -0.2])
|
||||||
|
rounded_box([usb_c_cutout_w, usb_c_cutout_d, wall + 1.2], r=1.6);
|
||||||
|
|
||||||
|
// Right-side USB-A female passthrough power port for the GoPro.
|
||||||
|
translate([case_w/2 - 0.10, usb_a_y, usb_a_z])
|
||||||
|
yz_rounded_prism(usb_a_cutout_d, wall + 2.8, usb_a_cutout_h, r=0.9);
|
||||||
|
|
||||||
|
// Left-side IPEX/U.FL antenna pigtail connector/lead clearance.
|
||||||
|
// Through-hole intentionally extends past the inner wall so it opens to the cavity.
|
||||||
|
translate([-case_w/2 - 0.10, ipex_y, ipex_z])
|
||||||
|
x_cylinder(d=ipex_hole_d, h=wall + 3.0);
|
||||||
|
// Shallow exterior circular recess marks/relieves the antenna exit area.
|
||||||
|
translate([-case_w/2 + ipex_recess_depth/2 - 0.05, ipex_y, ipex_z])
|
||||||
|
x_cylinder(d=ipex_recess_d, h=ipex_recess_depth + 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four protected screw bosses are added after shell hollowing so the
|
||||||
|
// electronics cavity cannot cut away the receiving material.
|
||||||
|
screw_x = lid_w/2 - 5.0;
|
||||||
|
screw_z_low = (case_h - lid_h)/2 + 5.0;
|
||||||
|
screw_z_high = case_h - screw_z_low;
|
||||||
|
screw_boss(-screw_x, screw_z_low);
|
||||||
|
screw_boss( screw_x, screw_z_low);
|
||||||
|
screw_boss(-screw_x, screw_z_high);
|
||||||
|
screw_boss( screw_x, screw_z_high);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final body-level pilot holes cut through the front frame into the protected bosses.
|
||||||
|
screw_x = lid_w/2 - 5.0;
|
||||||
|
screw_z_low = (case_h - lid_h)/2 + 5.0;
|
||||||
|
screw_z_high = case_h - screw_z_low;
|
||||||
|
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high])
|
||||||
|
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
|
||||||
|
y_cylinder(d=screw_d, h=boss_len + front_recess_d + 4.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Printable front service lid / status panel -----
|
||||||
|
module camera_node_lid_v4() {
|
||||||
|
panel_through_d = lid_t + lid_lip_t + 2.4;
|
||||||
|
panel_through_y = 0.25;
|
||||||
|
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
// Visible full-height flush panel; restrained and not a busy slab.
|
||||||
|
rounded_box([lid_w, lid_t, lid_h], r=0.65);
|
||||||
|
|
||||||
|
// Rear locating lip fits inside the large service opening.
|
||||||
|
translate([0, lid_t/2 + lid_lip_t/2 - 0.2, lid_h/2])
|
||||||
|
xz_rounded_prism(service_opening_w - 0.8, lid_lip_t, service_opening_h - 0.8, r=1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OLED window and shallow black-bezel-style recess near the top.
|
||||||
|
translate([0, -lid_t/2 + 0.35, oled_z])
|
||||||
|
xz_rounded_prism(oled_bezel_w, 0.9, oled_bezel_h, r=1.3);
|
||||||
|
translate([0, panel_through_y, oled_z])
|
||||||
|
xz_rounded_prism(oled_window_w, panel_through_d, oled_window_h, r=0.5);
|
||||||
|
|
||||||
|
// Subtle secondary indicators flanking the rocker, below the OLED bezel.
|
||||||
|
translate([-15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
|
||||||
|
translate([ 15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
|
||||||
|
|
||||||
|
// Small rocker lower on the panel, offset away from the OLED, screws, and slots.
|
||||||
|
translate([0, panel_through_y, 33.0])
|
||||||
|
xz_rounded_prism(rocker_w, panel_through_d, rocker_h, r=0.8);
|
||||||
|
|
||||||
|
// Two long rounded horizontal slots near the lower front, matching the reference.
|
||||||
|
translate([0, panel_through_y, 17.0])
|
||||||
|
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
|
||||||
|
translate([0, panel_through_y, 11.0])
|
||||||
|
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
|
||||||
|
|
||||||
|
// Screw clearance/counterbore holes.
|
||||||
|
screw_x = lid_w/2 - 5.0;
|
||||||
|
screw_z_low = 5.0;
|
||||||
|
screw_z_high = lid_h - screw_z_low;
|
||||||
|
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high]) {
|
||||||
|
translate([x, panel_through_y, z]) y_cylinder(d=screw_d + 0.4, h=panel_through_d);
|
||||||
|
translate([x, -lid_t/2 + 0.55, z]) y_cylinder(d=screw_head_d, h=1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Non-print preview assembly -----
|
||||||
|
module camera_node_preview_v4(show_lid=true) {
|
||||||
|
color("lightgray") camera_node_body_v4();
|
||||||
|
if (show_lid)
|
||||||
|
translate([0, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
|
||||||
|
color("gainsboro") camera_node_lid_v4();
|
||||||
|
|
||||||
|
// Dark OLED bezel/window cue for visual review only (not part of exported lid STL when rendering lid).
|
||||||
|
if (show_lid)
|
||||||
|
translate([0, -case_d/2 - 0.08, (case_h - lid_h)/2 + oled_z])
|
||||||
|
color("black") xz_rounded_prism(oled_bezel_w, 0.6, oled_bezel_h, r=1.3);
|
||||||
|
|
||||||
|
// Internal board/connector volume guides (not printed): ESP modules and USB connector envelopes.
|
||||||
|
color([0,0.45,0,0.35]) translate([-9, -1, 26]) cube([22.5, 18, 4], center=true);
|
||||||
|
color([0,0.2,0.8,0.35]) translate([9, -1, 45]) cube([24.7, 14.3, 12], center=true);
|
||||||
|
color([0.1,0.1,0.1,0.35]) translate([0, usb_c_y, 3.8]) cube([16, 9, 5], center=true);
|
||||||
|
color([0.1,0.1,0.1,0.35]) translate([case_w/2 - 5.5, usb_a_y, usb_a_z]) cube([11, usb_a_cutout_d + 2, usb_a_cutout_h + 2], center=true);
|
||||||
|
color([0.9,0.7,0.1,0.45]) translate([-case_w/2 - 1.8, ipex_y, ipex_z]) x_cylinder(d=ipex_hole_d, h=8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-print review layout: separates the body and front lid while keeping both
|
||||||
|
// front faces oriented toward -Y. Use this STL when checking that the screen,
|
||||||
|
// LED, rocker, USB connector, and lower-slot cutouts are visible in a slicer.
|
||||||
|
module camera_node_front_review_v4() {
|
||||||
|
translate([-34, 0, 0]) rotate([0,0,-18]) color("lightgray") camera_node_body_v4();
|
||||||
|
translate([34, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
|
||||||
|
color("gainsboro") camera_node_lid_v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select part to render from OpenSCAD CLI with: -D 'part="body"'
|
||||||
|
part = "preview"; // "body", "lid", "preview", or "front_review"
|
||||||
|
|
||||||
|
if (part == "body") {
|
||||||
|
camera_node_body_v4();
|
||||||
|
} else if (part == "lid") {
|
||||||
|
camera_node_lid_v4();
|
||||||
|
} else if (part == "front_review") {
|
||||||
|
camera_node_front_review_v4();
|
||||||
|
} else {
|
||||||
|
camera_node_preview_v4();
|
||||||
|
}
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
// RemoteRig — GoPro Hero 3 + ESP32 Camera Case
|
|
||||||
// ==============================================
|
|
||||||
// Sleeve that wraps around GoPro Hero 3 body with ESP32 + LiPo compartment.
|
|
||||||
// Designed for: ESP32 D1 Mini, 1000mAh LiPo, GoPro Hero 3 Black/Silver.
|
|
||||||
//
|
|
||||||
// Print settings:
|
|
||||||
// Material: PETG (outdoor/heat) or PLA+ (indoor)
|
|
||||||
// Layer: 0.2mm | Infill: 20% gyroid | Supports: yes (for cable channels)
|
|
||||||
// Nozzle: 0.4mm | Bed: 60°C (PLA) / 80°C (PETG)
|
|
||||||
|
|
||||||
// ── GoPro Hero 3 Body (approximate) ──
|
|
||||||
gopro_width = 60; // mm — body width
|
|
||||||
gopro_height = 42; // mm — body height (top to bottom)
|
|
||||||
gopro_depth = 30; // mm — body depth (front to back)
|
|
||||||
gopro_lens_dia = 28; // mm — lens protrusion diameter
|
|
||||||
gopro_lens_offset = 18; // mm — lens center from top
|
|
||||||
|
|
||||||
// ── ESP32 D1 Mini ──
|
|
||||||
esp_width = 34.2;
|
|
||||||
esp_height = 25.6;
|
|
||||||
esp_thick = 5; // board + components
|
|
||||||
usb_cutout_w = 10;
|
|
||||||
usb_cutout_h = 5;
|
|
||||||
|
|
||||||
// ── LiPo Battery (1000mAh typical) ──
|
|
||||||
lipo_width = 35;
|
|
||||||
lipo_height = 25;
|
|
||||||
lipo_thick = 8;
|
|
||||||
|
|
||||||
// ── Case parameters ──
|
|
||||||
wall = 2.0; // case wall thickness
|
|
||||||
tolerance = 0.3; // print tolerance for friction fit
|
|
||||||
compartment_height = max(esp_thick, lipo_thick) + 3; // internal compartment height
|
|
||||||
|
|
||||||
// ── Cable channels ──
|
|
||||||
cable_dia = 4; // USB cable diameter
|
|
||||||
cable_channel_depth = 3;
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// MAIN ASSEMBLY
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// Uncomment the part you want to export:
|
|
||||||
gopro_sleeve();
|
|
||||||
// translate([0, -20, 0]) electronics_compartment();
|
|
||||||
// translate([0, 20, 0]) battery_compartment();
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// GoPro Sleeve — wraps around the GoPro body
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module gopro_sleeve() {
|
|
||||||
union() {
|
|
||||||
// Main sleeve body — wraps around GoPro
|
|
||||||
difference() {
|
|
||||||
// Outer shell
|
|
||||||
rounded_cube(
|
|
||||||
gopro_width + wall*2,
|
|
||||||
gopro_height + wall*2,
|
|
||||||
gopro_depth + wall*2,
|
|
||||||
4 // corner radius
|
|
||||||
);
|
|
||||||
|
|
||||||
// Inner cavity (GoPro body)
|
|
||||||
translate([0, 0, wall])
|
|
||||||
rounded_cube(
|
|
||||||
gopro_width + tolerance,
|
|
||||||
gopro_height + tolerance,
|
|
||||||
gopro_depth + tolerance,
|
|
||||||
3
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lens cutout (front face)
|
|
||||||
translate([0, gopro_height/2 - gopro_lens_offset, 0])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=gopro_lens_dia + 4, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Front screen/viewfinder cutout
|
|
||||||
translate([0, gopro_height/2 - gopro_lens_offset - 18, wall*2])
|
|
||||||
cube([gopro_width - 10, gopro_height - 20, wall*4], center=true);
|
|
||||||
|
|
||||||
// Bottom cutout (for GoPro mounting fingers)
|
|
||||||
translate([0, 0, gopro_depth/2 + wall])
|
|
||||||
cube([gopro_width - 10, wall*4, wall*4], center=true);
|
|
||||||
|
|
||||||
// USB port access (side)
|
|
||||||
translate([gopro_width/2 + wall, 0, -5])
|
|
||||||
cube([wall*4, 16, 10], center=true);
|
|
||||||
|
|
||||||
// Cable channel from ESP32 compartment to GoPro USB
|
|
||||||
translate([gopro_width/2 - 5, -gopro_height/2 + 10, -gopro_depth/2 + 5])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mounting ears for electronics compartment
|
|
||||||
for (x = [-1, 1]) {
|
|
||||||
translate([x * (gopro_width/2 - 6), -gopro_height/2 - 6, 0])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=8, h=10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// Electronics Compartment — holds ESP32 + routes cables
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module electronics_compartment() {
|
|
||||||
comp_w = max(esp_width, esp_height) + wall*2 + 10;
|
|
||||||
comp_h = compartment_height + wall*2;
|
|
||||||
comp_d = gopro_depth + wall*2;
|
|
||||||
|
|
||||||
difference() {
|
|
||||||
union() {
|
|
||||||
// Main box
|
|
||||||
rounded_cube(comp_w, comp_d, comp_h, 3);
|
|
||||||
|
|
||||||
// Mounting tabs (match GoPro sleeve ears)
|
|
||||||
for (x = [-1, 1]) {
|
|
||||||
translate([x * (gopro_width/2 - 6), 0, comp_h/2])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=6, h=4, center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inner cavity
|
|
||||||
translate([0, 0, wall])
|
|
||||||
rounded_cube(comp_w - wall*2, comp_d - wall*2, comp_h - wall, 2);
|
|
||||||
|
|
||||||
// ESP32 board recess
|
|
||||||
translate([0, 5, wall + 1])
|
|
||||||
cube([esp_width + tolerance, esp_height + tolerance, esp_thick + 1], center=true);
|
|
||||||
|
|
||||||
// USB cable entry (side hole)
|
|
||||||
translate([comp_w/2, 15, comp_h/2])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=usb_cutout_w, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// USB cable exit (to GoPro)
|
|
||||||
translate([comp_w/2, -15, comp_h/2])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Ventilation slots
|
|
||||||
for (y = [-1:2:1]) {
|
|
||||||
for (i = [-15:10:15]) {
|
|
||||||
translate([i, y * comp_d/3, comp_h - 2])
|
|
||||||
cube([6, 1.5, wall*2], center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LED window (thin wall for ESP32 LED visibility)
|
|
||||||
translate([0, 0, wall])
|
|
||||||
cube([5, 5, wall], center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// Battery Compartment — holds LiPo under GoPro
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module battery_compartment() {
|
|
||||||
bat_w = lipo_width + wall*2 + tolerance;
|
|
||||||
bat_d = lipo_height + wall*2 + tolerance;
|
|
||||||
bat_h = lipo_thick + wall*2 + 4;
|
|
||||||
|
|
||||||
difference() {
|
|
||||||
// Shell
|
|
||||||
rounded_cube(bat_w, bat_d, bat_h, 3);
|
|
||||||
|
|
||||||
// Battery cavity
|
|
||||||
translate([0, 0, wall])
|
|
||||||
rounded_cube(lipo_width + tolerance, lipo_height + tolerance, lipo_thick + tolerance, 1);
|
|
||||||
|
|
||||||
// Cable exit (to ESP32 compartment)
|
|
||||||
translate([0, bat_d/2, bat_h/2])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Cable exit (to GoPro USB)
|
|
||||||
translate([bat_w/3, -bat_d/2, bat_h/2])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Strap slots (velcro strap to secure to GoPro)
|
|
||||||
for (x = [-bat_w/3, bat_w/3]) {
|
|
||||||
translate([x, -bat_d/2, bat_h/2])
|
|
||||||
cube([8, wall*4, 3], center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// Utility: Rounded cube (positive X/Y/Z = full dimensions)
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cubecraft/remoterig/internal/db"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestRouter creates a test router backed by a temp file database so
|
||||||
|
// pooled connections all see the same data.
|
||||||
|
func setupTestRouter(t *testing.T) (*db.DB, chi.Router) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/cameras", ListCameras(database))
|
||||||
|
r.Post("/cameras", RegisterCamera(database))
|
||||||
|
r.Get("/cameras/{id}", GetCameraDetail(database))
|
||||||
|
r.Post("/cameras/{id}/start", StartRecording(database, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-19
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/cubecraft/remoterig/internal/db"
|
"github.com/cubecraft/remoterig/internal/db"
|
||||||
"github.com/cubecraft/remoterig/pkg/models"
|
"github.com/cubecraft/remoterig/pkg/models"
|
||||||
@@ -25,11 +26,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,
|
||||||
s.recording_state,
|
COALESCE(s.recording_state, 0),
|
||||||
s.mode,
|
s.mode,
|
||||||
s.resolution,
|
s.resolution,
|
||||||
s.fps,
|
s.fps,
|
||||||
s.online,
|
COALESCE(s.online, 0),
|
||||||
s.recorded_at
|
s.recorded_at
|
||||||
FROM cameras c
|
FROM cameras c
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
|||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error querying cameras: %v", err)
|
log.Printf("Error querying cameras: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -51,20 +52,22 @@ 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, &sl.RecordedAt,
|
&sl.Online, &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)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +87,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
|||||||
FriendlyName string `json:"friendly_name"`
|
FriendlyName string `json:"friendly_name"`
|
||||||
MacAddress *string `json:"mac_address,omitempty"`
|
MacAddress *string `json:"mac_address,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if !decodeJSONBody(w, r, &req) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
|
||||||
if req.CameraID == "" || req.FriendlyName == "" {
|
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +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 err.Error() == "UNIQUE constraint failed: cameras.mac_address" {
|
if isUniqueConstraintErr(err) {
|
||||||
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"})
|
respondError(w, http.StatusConflict, "camera already registered", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Error registering camera: %v", err)
|
log.Printf("Error registering camera: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +124,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
|||||||
func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +138,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
&c.CreatedAt, &c.UpdatedAt,
|
&c.CreatedAt, &c.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"})
|
respondError(w, http.StatusNotFound, "camera not found", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error querying camera: %v", err)
|
log.Printf("Error querying camera: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +164,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
log.Printf("Error querying latest status: %v", err)
|
log.Printf("Error querying latest status: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +179,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
`, cameraID)
|
`, cameraID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error querying history: %v", err)
|
log.Printf("Error querying history: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer historyRows.Close()
|
defer historyRows.Close()
|
||||||
@@ -210,6 +209,14 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation.
|
||||||
|
func isUniqueConstraintErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||||
|
}
|
||||||
|
|
||||||
// respondJSON writes a JSON response with the given status code.
|
// respondJSON writes a JSON response with the given status code.
|
||||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Package api provides HTTP handlers for camera operations.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRequestBody is the maximum accepted JSON body size (64KB).
|
||||||
|
const maxRequestBody = 64 * 1024
|
||||||
|
|
||||||
|
// APIError represents a structured API error response.
|
||||||
|
type APIError struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validationConstraints defines field-level validation limits.
|
||||||
|
const (
|
||||||
|
maxCameraIDLen = 64
|
||||||
|
maxFriendlyNameLen = 128
|
||||||
|
maxModeLen = 32
|
||||||
|
maxResolutionLen = 32
|
||||||
|
minFPS = 0
|
||||||
|
maxFPS = 240
|
||||||
|
)
|
||||||
|
|
||||||
|
// respondError writes a structured JSON error response.
|
||||||
|
func respondError(w http.ResponseWriter, status int, msg string, details ...string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
e := APIError{
|
||||||
|
Error: msg,
|
||||||
|
Code: status,
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
e.Details = details[0]
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJSONBody reads, limits, and decodes a JSON request body.
|
||||||
|
// Returns false if validation fails (response already written).
|
||||||
|
func decodeJSONBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||||
|
// Validate Content-Type
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
||||||
|
respondError(w, http.StatusUnsupportedMediaType, "content-type must be application/json")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit body size
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "request body too large or unreadable", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, v); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request body", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCameraID checks that cameraID is present and within max length.
|
||||||
|
func validateCameraID(w http.ResponseWriter, cameraID string) bool {
|
||||||
|
if cameraID == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "camera_id is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(cameraID) > maxCameraIDLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("camera_id must be at most %d characters", maxCameraIDLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCameraRegistration validates fields for POST /cameras.
|
||||||
|
func validateCameraRegistration(w http.ResponseWriter, cameraID, friendlyName string) bool {
|
||||||
|
if !validateCameraID(w, cameraID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if friendlyName == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "friendly_name is required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(friendlyName) > maxFriendlyNameLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("friendly_name must be at most %d characters", maxFriendlyNameLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateStatusFields validates optional fields on the PushStatus payload.
|
||||||
|
func validateStatusFields(w http.ResponseWriter, mode, resolution string, fps int) bool {
|
||||||
|
if mode != "" && len(mode) > maxModeLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("mode must be at most %d characters", maxModeLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if resolution != "" && len(resolution) > maxResolutionLen {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("resolution must be at most %d characters", maxResolutionLen))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fps < minFPS || fps > maxFPS {
|
||||||
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("fps must be between %d and %d", minFPS, maxFPS))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+48
-16
@@ -9,12 +9,17 @@ 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) http.HandlerFunc {
|
func StartRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +27,13 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
|||||||
var exists int
|
var exists int
|
||||||
err := database.QueryRowContext(r.Context(),
|
err := database.QueryRowContext(r.Context(),
|
||||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||||
if err != nil || exists == 0 {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
log.Printf("Error checking camera existence: %v", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "camera not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +44,21 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
|||||||
`, cameraID)
|
`, cameraID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error starting recording: %v", err)
|
log.Printf("Error starting recording: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, _ := result.RowsAffected()
|
rowsAffected, _ := result.RowsAffected()
|
||||||
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows)
|
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
|
||||||
|
|
||||||
|
// 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",
|
||||||
@@ -49,11 +68,10 @@ func StartRecording(database *db.DB) 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) http.HandlerFunc {
|
func StopRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +79,13 @@ func StopRecording(database *db.DB) http.HandlerFunc {
|
|||||||
var exists int
|
var exists int
|
||||||
err := database.QueryRowContext(r.Context(),
|
err := database.QueryRowContext(r.Context(),
|
||||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||||
if err != nil || exists == 0 {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
log.Printf("Error checking camera existence: %v", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "camera not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +96,21 @@ func StopRecording(database *db.DB) http.HandlerFunc {
|
|||||||
`, cameraID)
|
`, cameraID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error stopping recording: %v", err)
|
log.Printf("Error stopping recording: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, _ := result.RowsAffected()
|
rowsAffected, _ := result.RowsAffected()
|
||||||
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows)
|
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
|||||||
+19
-8
@@ -2,7 +2,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -14,8 +13,7 @@ import (
|
|||||||
func PushStatus(database *db.DB) http.HandlerFunc {
|
func PushStatus(database *db.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if cameraID == "" {
|
if !validateCameraID(w, cameraID) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +28,16 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
|||||||
RawBatteryPct *float64 `json:"raw_battery_pct"`
|
RawBatteryPct *float64 `json:"raw_battery_pct"`
|
||||||
Timestamp *string `json:"ts"`
|
Timestamp *string `json:"ts"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if !decodeJSONBody(w, r, &req) {
|
||||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
return
|
||||||
|
}
|
||||||
|
if !validateStatusFields(w, req.Mode, req.Resolution, req.FPS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate battery percentage range if provided
|
||||||
|
if req.BatteryPct != nil && (*req.BatteryPct < 0 || *req.BatteryPct > 100) {
|
||||||
|
respondError(w, http.StatusBadRequest, "battery_pct must be between 0 and 100")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
|||||||
var exists int
|
var exists int
|
||||||
err := database.QueryRowContext(r.Context(),
|
err := database.QueryRowContext(r.Context(),
|
||||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||||
if err != nil || exists == 0 {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
log.Printf("Error checking camera existence: %v", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "camera not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
|||||||
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
|
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error inserting status log: %v", err)
|
log.Printf("Error inserting status log: %v", err)
|
||||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-28
@@ -4,9 +4,11 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -14,13 +16,16 @@ import (
|
|||||||
//go:embed migrations/001_create_tables.sql
|
//go:embed migrations/001_create_tables.sql
|
||||||
var migration001 string
|
var migration001 string
|
||||||
|
|
||||||
|
//go:embed migrations/002_dedup_unique_index.sql
|
||||||
|
var migration002 string
|
||||||
|
|
||||||
// DB wraps the sql.DB with connection-level settings.
|
// DB wraps the sql.DB with connection-level settings.
|
||||||
type DB struct {
|
type DB struct {
|
||||||
*sql.DB
|
*sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open opens the SQLite database at the given path, enables WAL mode,
|
// Open opens the SQLite database at the given path, enables WAL mode,
|
||||||
// and runs all migrations if the tables don't exist yet.
|
// and runs all migrations using a schema_version table for tracking.
|
||||||
func Open(path string) (*DB, error) {
|
func Open(path string) (*DB, error) {
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
@@ -45,34 +50,57 @@ func Open(path string) (*DB, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tables already exist (idempotent migration)
|
// Ensure schema_version table exists for migration tracking
|
||||||
var count int
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
|
||||||
if err := db.QueryRow(`
|
|
||||||
SELECT COUNT(*) FROM sqlite_master
|
|
||||||
WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings')
|
|
||||||
`).Scan(&count); err != nil {
|
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if count < 4 {
|
// Read current schema version (0 if table is empty)
|
||||||
log.Printf("Running migrations for %s...", path)
|
var currentVersion int
|
||||||
if err := migrate(db, migration001); err != nil {
|
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤tVersion); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration definitions: ordered list of (version, sql)
|
||||||
|
type migration struct {
|
||||||
|
version int
|
||||||
|
sql string
|
||||||
|
}
|
||||||
|
migrations := []migration{
|
||||||
|
{1, migration001},
|
||||||
|
{2, migration002},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
if currentVersion >= m.version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Running migration %d for %s...", m.version, path)
|
||||||
|
if err := migrate(db, m.sql); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("record migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
log.Printf("Migration %d complete", m.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentVersion < len(migrations) {
|
||||||
log.Println("Migrations complete")
|
log.Println("Migrations complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DB{db}, nil
|
return &DB{db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate executes a SQL migration string.
|
// migrate executes a SQL migration string by splitting on semicolons.
|
||||||
func migrate(db *sql.DB, sql string) error {
|
func migrate(db *sql.DB, sql string) error {
|
||||||
// Split on semicolons to handle multiple statements
|
|
||||||
statements := splitSQL(sql)
|
statements := splitSQL(sql)
|
||||||
for _, stmt := range statements {
|
for _, stmt := range statements {
|
||||||
stmt = stripWhitespace(stmt)
|
stmt = strings.TrimSpace(stmt)
|
||||||
if stmt == "" {
|
if stmt == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -83,8 +111,13 @@ func migrate(db *sql.DB, sql string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
|
// splitSQL splits a SQL string on semicolons, respecting quoted strings
|
||||||
|
// and stripping SQL line comments (--).
|
||||||
func splitSQL(sql string) []string {
|
func splitSQL(sql string) []string {
|
||||||
|
// First, strip all line comments (--) to prevent them from swallowing
|
||||||
|
// subsequent SQL statements when newlines are collapsed.
|
||||||
|
sql = stripSQLLineComments(sql)
|
||||||
|
|
||||||
var stmts []string
|
var stmts []string
|
||||||
var current string
|
var current string
|
||||||
inQuote := false
|
inQuote := false
|
||||||
@@ -106,30 +139,42 @@ func splitSQL(sql string) []string {
|
|||||||
case ';':
|
case ';':
|
||||||
stmts = append(stmts, current)
|
stmts = append(stmts, current)
|
||||||
current = ""
|
current = ""
|
||||||
|
case '\r', '\n', '\t':
|
||||||
|
current += " "
|
||||||
default:
|
default:
|
||||||
current += string(r)
|
current += string(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(current) > 0 {
|
if strings.TrimSpace(current) != "" {
|
||||||
stmts = append(stmts, current)
|
stmts = append(stmts, current)
|
||||||
}
|
}
|
||||||
return stmts
|
return stmts
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripWhitespace removes leading/trailing whitespace and normalizes newlines.
|
// stripSQLLineComments removes all -- single-line comments from SQL text.
|
||||||
func stripWhitespace(s string) string {
|
func stripSQLLineComments(sql string) string {
|
||||||
result := ""
|
var result strings.Builder
|
||||||
runningSpace := false
|
i := 0
|
||||||
for _, r := range s {
|
runes := []rune(sql)
|
||||||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
|
||||||
if !runningSpace {
|
for i < len(runes) {
|
||||||
result += " "
|
r := runes[i]
|
||||||
runningSpace = true
|
|
||||||
|
// Check for -- comment start
|
||||||
|
if r == '-' && i+1 < len(runes) && runes[i+1] == '-' {
|
||||||
|
// Skip to end of line
|
||||||
|
i += 2
|
||||||
|
for i < len(runes) && runes[i] != '\n' && runes[i] != '\r' {
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
} else {
|
// Replace comment with a newline (preserves statement boundaries)
|
||||||
result += string(r)
|
result.WriteRune('\n')
|
||||||
runningSpace = false
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.WriteRune(r)
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: 002_dedup_unique_index
|
||||||
|
-- Add a UNIQUE index on (camera_id, recorded_at) to enforce hub-side
|
||||||
|
-- deduplication for ESP32 offline status replay (CUB-239).
|
||||||
|
-- This prevents race-condition double-inserts that a pure SELECT COUNT(*)
|
||||||
|
-- check cannot guard against.
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_status_logs_unique_entry
|
||||||
|
ON status_logs(camera_id, recorded_at);
|
||||||
+48
-25
@@ -143,6 +143,18 @@ 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 {
|
||||||
@@ -151,22 +163,20 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if sp.CameraID == "" || sp.Timestamp == "" {
|
if sp.CameraID == "" {
|
||||||
log.Printf("MQTT status missing required fields (camera_id, timestamp) from %s", cameraID)
|
log.Printf("MQTT status missing camera_id from %s", cameraID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate timestamp sanity (reject >5min future, >24h past)
|
// Nodes have no real clock, so tolerate an empty/invalid timestamp by
|
||||||
|
// 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 {
|
||||||
// Try ISO8601 without timezone
|
if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil {
|
||||||
ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp)
|
ts = now
|
||||||
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
|
||||||
@@ -209,6 +219,21 @@ 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,
|
||||||
@@ -270,7 +295,9 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
|
|||||||
|
|
||||||
type heartbeatPayload struct {
|
type heartbeatPayload struct {
|
||||||
CameraID string `json:"camera_id"`
|
CameraID string `json:"camera_id"`
|
||||||
Timestamp string `json:"timestamp"`
|
// No Timestamp field: the node sends a numeric millis() value and the
|
||||||
|
// 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"`
|
||||||
}
|
}
|
||||||
@@ -347,8 +374,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 {
|
if err == nil && existingID == cameraID {
|
||||||
// Already registered — just update friendly_name
|
// Same self-id re-connecting — just refresh 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,
|
||||||
@@ -359,30 +386,26 @@ 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 {
|
||||||
// New camera — generate sequential cam-NNN ID
|
// MAC known under a different id (legacy cam-NNN from before self-IDs)
|
||||||
var maxID string
|
// → drop the old row so we re-register under the node's self-id.
|
||||||
s.db.QueryRow("SELECT MAX(camera_id) FROM cameras").Scan(&maxID)
|
if err == nil && existingID != cameraID {
|
||||||
|
s.db.Exec("DELETE FROM cameras WHERE camera_id = ?", existingID)
|
||||||
seq := 1
|
log.Printf("MQTT announce: migrating %s -> %s (%s)", existingID, cameraID, ap.FriendlyName)
|
||||||
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'))
|
||||||
`, newID, ap.FriendlyName, ap.MacAddress)
|
`, cameraID, 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)", newID, ap.FriendlyName)
|
log.Printf("MQTT announce: new camera registered as %s (%s)", cameraID, ap.FriendlyName)
|
||||||
|
|
||||||
// Broadcast new camera via SSE
|
// Broadcast new camera via SSE
|
||||||
cam, err := getCamera(s.db, newID)
|
cam, err := getCamera(s.db, cameraID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.hub.Broadcast("camera_registered", cam)
|
s.hub.Broadcast("camera_registered", cam)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +33,11 @@ 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"`
|
||||||
BatteryPct *int `json:"battery_pct,omitempty"`
|
// Not omitempty: the SPA expects these as `number | null`. Omitting them
|
||||||
VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
|
// makes the field `undefined` in JS, which slips past null checks and
|
||||||
|
// 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"`
|
||||||
|
|||||||
+9
-4
@@ -72,8 +72,12 @@ fi
|
|||||||
# 2. Deploy new binary
|
# 2. Deploy new binary
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
info "Deploying new binary..."
|
info "Deploying new binary..."
|
||||||
cp "${BINARY}" "${DEPLOY_PATH}"
|
# Atomic replace: copy alongside then rename over the target. A plain
|
||||||
chmod +x "${DEPLOY_PATH}"
|
# cp over a running executable fails with "Text file busy"; rename swaps
|
||||||
|
# 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}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -116,8 +120,9 @@ else
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
if [ -f "${BACKUP}" ]; then
|
if [ -f "${BACKUP}" ]; then
|
||||||
info "Restoring backup: ${BACKUP}"
|
info "Restoring backup: ${BACKUP}"
|
||||||
cp "${BACKUP}" "${DEPLOY_PATH}"
|
cp "${BACKUP}" "${DEPLOY_PATH}.new"
|
||||||
chmod +x "${DEPLOY_PATH}"
|
chmod +x "${DEPLOY_PATH}.new"
|
||||||
|
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
|
||||||
|
|
||||||
systemctl restart "${SERVICE}" 2>/dev/null || true
|
systemctl restart "${SERVICE}" 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Periodically check for RemoteRig updates (Gitea dev release)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+67
-18
@@ -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: pi)
|
# --service-user USER Systemd service user (default: invoking sudo user, else pi)
|
||||||
# --static-ip IP Static IP for wlan0 (default: 192.168.4.10/24)
|
# --static-ip IP Static IP for wlan0 (default: 192.168.8.56/24)
|
||||||
# --gateway IP Gateway for wlan0 (default: 192.168.4.1)
|
# --gateway IP Gateway for wlan0 (default: 192.168.8.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="pi"
|
SERVICE_USER="${SUDO_USER:-pi}" # default to the invoking user (not every Pi has a 'pi' user)
|
||||||
STATIC_IP="192.168.4.10/24"
|
STATIC_IP="192.168.8.56/24"
|
||||||
GATEWAY="192.168.4.1"
|
GATEWAY="192.168.8.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,6 +204,54 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -317,20 +365,21 @@ 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 " Next steps:"
|
echo " Deploys are pull-based: push to 'dev' on Gitea -> CI builds the"
|
||||||
echo " 1. Build the remoterig binary for ARM64:"
|
echo " arm64 binary -> the Pi's timer pulls + installs it automatically."
|
||||||
echo " GOOS=linux GOARCH=arm64 go build -o remoterig ./cmd/server"
|
|
||||||
echo " 2. Copy binary to Pi:"
|
|
||||||
echo " scp remoterig pi@192.168.4.10:/opt/remoterig/"
|
|
||||||
echo " 3. Copy config if needed:"
|
|
||||||
echo " scp config.yaml pi@192.168.4.10:/opt/remoterig/"
|
|
||||||
echo " 4. Start the service:"
|
|
||||||
echo " sudo systemctl start remoterig"
|
|
||||||
echo " 5. Check health:"
|
|
||||||
echo " curl http://192.168.4.10:8080/health"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " To deploy updates, use: scripts/deploy.sh"
|
echo " Next steps:"
|
||||||
|
echo " 1. If the repo is private, set a read token:"
|
||||||
|
echo " sudo sed -i 's/^GITEA_TOKEN=.*/GITEA_TOKEN=<token>/' ${DEPLOY_DIR}/update.env"
|
||||||
|
echo " 2. Trigger / wait for an update check:"
|
||||||
|
echo " sudo systemctl start remoterig-update.service"
|
||||||
|
echo " journalctl -u remoterig-update.service -n 30"
|
||||||
|
echo " 3. Check health once deployed:"
|
||||||
|
echo " curl http://${STATIC_IP%/*}:8080/health"
|
||||||
|
echo ""
|
||||||
|
echo " Manual one-off deploy (local binary) still works: scripts/deploy.sh"
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
|
|||||||
+193
-53
@@ -1,88 +1,228 @@
|
|||||||
import { Camera, Radio } from 'lucide-react'
|
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||||
|
import { Camera, Play, Square, Wifi, WifiOff, AlertTriangle } from 'lucide-react'
|
||||||
import { useSSE } from './hooks/useSSE'
|
import { useSSE } from './hooks/useSSE'
|
||||||
import { useCameraStore } from './store/useCameraStore'
|
import { useCameraStore } from './store/useCameraStore'
|
||||||
import { CameraCard } from './components'
|
import { api } from './services/api'
|
||||||
|
import CameraCard from './components/CameraCard'
|
||||||
|
import HistoryViewer from './components/HistoryViewer'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Connect to SSE endpoint — auto-updates the camera store
|
const [commandBusy, setCommandBusy] = useState(false)
|
||||||
useSSE()
|
const [commandError, setCommandError] = useState<string | null>(null)
|
||||||
|
const [historyCameraId, setHistoryCameraId] = useState<string | null>(null)
|
||||||
|
const [historyCameraName, setHistoryCameraName] = useState<string>()
|
||||||
|
|
||||||
// Subscribe to the camera store for reactivity.
|
// SSE connection + live store
|
||||||
// getCameras / getOnlineCount / getRecordingCount pull from live state.
|
const { connectionState } = useSSE()
|
||||||
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
|
|
||||||
const cameras = getCameras()
|
// Seed the list once on mount via the REST API. SSE only pushes on change,
|
||||||
const onlineCount = getOnlineCount()
|
// so without this the dashboard is empty until the next status event.
|
||||||
const recordingCount = getRecordingCount()
|
useEffect(() => {
|
||||||
|
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">
|
<div className="min-h-screen bg-rig-dark-900 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
<header className="shrink-0 border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Camera className="h-7 w-7 text-rig-accent" />
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
|
<Camera className="h-6 w-6 shrink-0 text-rig-accent" />
|
||||||
|
<h1 className="text-lg font-bold tracking-tight text-rig-dark-50 truncate">
|
||||||
RemoteRig
|
RemoteRig
|
||||||
</h1>
|
</h1>
|
||||||
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
<span className="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
||||||
Dashboard
|
Dashboard
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Stats badges */}
|
|
||||||
<div className="ml-auto flex items-center gap-4">
|
|
||||||
{/* Online count */}
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
|
||||||
title="Cameras online"
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 rounded-full bg-rig-success" />
|
|
||||||
{onlineCount} online
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Recording count */}
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
|
||||||
title="Cameras recording"
|
|
||||||
>
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-rig-danger" />
|
|
||||||
</span>
|
|
||||||
{recordingCount} recording
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* SSE badge */}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${connectionBadge.class}`}
|
||||||
|
>
|
||||||
|
<BadgeIcon className="h-3 w-3" />
|
||||||
|
{connectionBadge.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Global controls */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleStartAll}
|
||||||
|
disabled={commandBusy || cameras.length === 0}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-3 py-1.5 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Start recording on all cameras"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5 fill-current" />
|
||||||
|
<span className="hidden sm:inline">Start All</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStopAll}
|
||||||
|
disabled={commandBusy || cameras.length === 0}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-3 py-1.5 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Stop recording on all cameras"
|
||||||
|
>
|
||||||
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
|
<span className="hidden sm:inline">Stop All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-xs text-rig-dark-400">
|
||||||
|
<span>
|
||||||
|
<strong className="text-rig-dark-100">{cameras.length}</strong> camera{cameras.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-rig-success">{onlineCount}</strong> online
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className={recordingCount > 0 ? 'text-rig-danger' : 'text-rig-dark-300'}>
|
||||||
|
{recordingCount}
|
||||||
|
</strong>{' '}
|
||||||
|
recording
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Command error toast */}
|
||||||
|
{commandError && (
|
||||||
|
<div className="shrink-0 border-b border-rig-danger/30 bg-rig-danger/10 px-4 py-2">
|
||||||
|
<p className="mx-auto max-w-7xl text-xs text-rig-danger">
|
||||||
|
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
||||||
|
{commandError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<main className="flex-1 mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
{cameras.length === 0 ? (
|
{cameras.length === 0 ? (
|
||||||
/* Empty state */
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
|
||||||
<span className="relative mb-4 inline-flex">
|
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
|
||||||
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
|
|
||||||
</span>
|
|
||||||
<h2 className="text-lg font-semibold text-rig-dark-200">
|
<h2 className="text-lg font-semibold text-rig-dark-200">
|
||||||
Waiting for cameras…
|
No Cameras Connected
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
||||||
Connect cameras to your RemoteRig server and they will appear here
|
Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.
|
||||||
automatically.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Camera grid */
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
{cameras.map((cam) => (
|
||||||
{cameras.map((camera) => (
|
<CameraCard
|
||||||
<CameraCard key={camera.camera_id} camera={camera} />
|
key={cam.camera_id}
|
||||||
|
camera={cam}
|
||||||
|
onStart={handleStart}
|
||||||
|
onStop={handleStop}
|
||||||
|
onViewHistory={handleViewHistory}
|
||||||
|
disabled={commandBusy}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* History modal */}
|
||||||
|
<HistoryViewer
|
||||||
|
cameraId={historyCameraId}
|
||||||
|
cameraName={historyCameraName}
|
||||||
|
onClose={handleCloseHistory}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
|
<footer className="shrink-0 border-t border-rig-dark-700 bg-rig-dark-800/30">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||||
<p className="text-center text-xs text-rig-dark-500">
|
<p className="text-center text-xs text-rig-dark-500">
|
||||||
RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System
|
RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import CameraCard from './CameraCard'
|
import CameraCard from './CameraCard'
|
||||||
import type { CameraStatus } from '../types'
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
@@ -19,52 +19,52 @@ function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noop = vi.fn()
|
||||||
|
const renderCard = (overrides?: Partial<CameraStatus>) =>
|
||||||
|
render(<CameraCard camera={makeCamera(overrides ?? {})} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||||
|
const renderCardContainer = (camera: CameraStatus) =>
|
||||||
|
render(<CameraCard camera={camera} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||||
|
|
||||||
describe('CameraCard', () => {
|
describe('CameraCard', () => {
|
||||||
// ── Basic rendering ────────────────────────────────────────────────────
|
// ── Basic rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders camera name', () => {
|
it('renders camera name', () => {
|
||||||
render(<CameraCard camera={makeCamera()} />)
|
renderCard()
|
||||||
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows resolution and FPS', () => {
|
it('shows resolution and FPS', () => {
|
||||||
render(<CameraCard camera={makeCamera()} />)
|
renderCard()
|
||||||
expect(screen.getByText(/1080p/)).toBeInTheDocument()
|
expect(screen.getByText(/1080p/)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
|
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows battery percentage', () => {
|
it('shows battery percentage', () => {
|
||||||
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
|
renderCard({ battery_pct: 85 })
|
||||||
expect(screen.getByText('85%')).toBeInTheDocument()
|
expect(screen.getByText('85%')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows N/A when battery is null', () => {
|
it('shows N/A when battery is null', () => {
|
||||||
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
|
renderCard({ battery_pct: null })
|
||||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Battery bar colors ─────────────────────────────────────────────────
|
// ── Battery bar colors ─────────────────────────────────────────────────
|
||||||
|
|
||||||
it('uses green bar for high battery (>=50%)', () => {
|
it('uses green bar for high battery (>=50%)', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 85 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-success')
|
expect(bar?.className).toContain('bg-rig-success')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses yellow bar for medium battery (15-49%)', () => {
|
it('uses yellow bar for medium battery (15-49%)', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 30 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-warning')
|
expect(bar?.className).toContain('bg-rig-warning')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses red bar for low battery (<15%)', () => {
|
it('uses red bar for low battery (<15%)', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 8 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-danger')
|
expect(bar?.className).toContain('bg-rig-danger')
|
||||||
})
|
})
|
||||||
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
|
|||||||
// ── Recording state ────────────────────────────────────────────────────
|
// ── Recording state ────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows REC badge when recording', () => {
|
it('shows REC badge when recording', () => {
|
||||||
render(<CameraCard camera={makeCamera({ recording: true })} />)
|
renderCard({ recording: true })
|
||||||
expect(screen.getByText('REC')).toBeInTheDocument()
|
expect(screen.getByText('REC')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows IDLE badge when not recording', () => {
|
it('shows IDLE badge when not recording', () => {
|
||||||
render(<CameraCard camera={makeCamera({ recording: false })} />)
|
renderCard({ recording: false })
|
||||||
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Online / Offline badges ────────────────────────────────────────────
|
// ── Online / Offline badges ────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Online badge when camera is online', () => {
|
it('shows Online badge when camera is online', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
renderCard({ online: true })
|
||||||
expect(screen.getByText('Online')).toBeInTheDocument()
|
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Offline badge when camera is offline', () => {
|
it('shows Offline badge when camera is offline', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
renderCard({ online: false })
|
||||||
const offlineElements = screen.getAllByText('Offline')
|
const offlineElements = screen.getAllByText('Offline')
|
||||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
|
|||||||
// ── Video remaining ────────────────────────────────────────────────────
|
// ── Video remaining ────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows video remaining time when available', () => {
|
it('shows video remaining time when available', () => {
|
||||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />)
|
renderCard({ video_remaining_sec: 125 })
|
||||||
// formatTimeLeft(125) → "2m 5s left"
|
// formatTimeLeft(125) → "2m 5s left"
|
||||||
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
|
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show video remaining when null', () => {
|
it('does not show video remaining when null', () => {
|
||||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />)
|
renderCard({ video_remaining_sec: null })
|
||||||
// The Radio icon and time text should not be present
|
// The Radio icon and time text should not be present
|
||||||
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
|
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
|
|||||||
// ── Footer ─────────────────────────────────────────────────────────────
|
// ── Footer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Live + timestamp in footer when online', () => {
|
it('shows Live + timestamp in footer when online', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
renderCard({ online: true })
|
||||||
// Footer shows "Live" when online
|
|
||||||
expect(screen.getByText('Live')).toBeInTheDocument()
|
expect(screen.getByText('Live')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Offline + timestamp in footer when offline', () => {
|
it('shows Offline in footer when offline', () => {
|
||||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
renderCard({ online: false })
|
||||||
// Footer says "Offline" (the text appears both in the badge and footer)
|
|
||||||
// When offline, the footer specifically shows "Offline" text
|
|
||||||
const offlineElements = screen.getAllByText('Offline')
|
const offlineElements = screen.getAllByText('Offline')
|
||||||
// At least one should exist (badge + footer)
|
|
||||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows "unknown" when last_seen is malformed', () => {
|
it('shows "unknown" when last_seen is malformed', () => {
|
||||||
render(
|
renderCard({ last_seen: 'not-a-date' })
|
||||||
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows "unknown" when last_seen is in the future', () => {
|
it('shows "unknown" when last_seen is in the future', () => {
|
||||||
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
|
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
|
||||||
render(<CameraCard camera={makeCamera({ last_seen: future })} />)
|
const cam = makeCamera({ last_seen: future })
|
||||||
|
renderCardContainer(cam)
|
||||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Edge cases ──────────────────────────────────────────────────────────
|
// ── Edge cases ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('clamps negative battery_pct to 0%', () => {
|
it('clamps negative battery_pct to 0%', () => {
|
||||||
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
|
renderCard({ battery_pct: -5 })
|
||||||
expect(screen.getByText('0%')).toBeInTheDocument()
|
expect(screen.getByText('0%')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows exact boundary: 15% battery → yellow bar', () => {
|
it('shows exact boundary: 15% battery → yellow bar', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 15 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-warning')
|
expect(bar?.className).toContain('bg-rig-warning')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows exact boundary: 50% battery → green bar', () => {
|
it('shows exact boundary: 50% battery → green bar', () => {
|
||||||
const { container } = render(
|
const { container } = renderCard({ battery_pct: 50 })
|
||||||
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
|
|
||||||
)
|
|
||||||
const bar = container.querySelector('[role="progressbar"] div')
|
const bar = container.querySelector('[role="progressbar"] div')
|
||||||
expect(bar?.className).toContain('bg-rig-success')
|
expect(bar?.className).toContain('bg-rig-success')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── New prop-driven tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onStart when Record button is clicked', () => {
|
||||||
|
const onStart = vi.fn()
|
||||||
|
render(<CameraCard camera={makeCamera({ recording: false })} onStart={onStart} onStop={noop} onViewHistory={noop} />)
|
||||||
|
screen.getByText('Record').click()
|
||||||
|
expect(onStart).toHaveBeenCalledWith('cam-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onStop when Stop button is clicked', () => {
|
||||||
|
const onStop = vi.fn()
|
||||||
|
render(<CameraCard camera={makeCamera({ recording: true })} onStart={noop} onStop={onStop} onViewHistory={noop} />)
|
||||||
|
screen.getByText('Stop').click()
|
||||||
|
expect(onStop).toHaveBeenCalledWith('cam-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onViewHistory when History button is clicked', () => {
|
||||||
|
const onViewHistory = vi.fn()
|
||||||
|
render(<CameraCard camera={makeCamera({})} onStart={noop} onStop={noop} onViewHistory={onViewHistory} />)
|
||||||
|
screen.getByText('History').click()
|
||||||
|
expect(onViewHistory).toHaveBeenCalledWith('cam-1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
|
import { Video, Wifi, WifiOff, Signal, Battery, Radio, Play, Square } from 'lucide-react'
|
||||||
import type { CameraStatus } from '../types'
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
|
|||||||
return `${diffDay}d ago`
|
return `${diffDay}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
function batteryColor(pct: number | null): { bar: string; text: string } {
|
function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
|
||||||
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
||||||
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
|
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||||
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||||
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeLeft(sec: number): string {
|
function formatTimeLeft(sec: number): string {
|
||||||
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
|
|||||||
return `${m}m ${s}s left`
|
return `${m}m ${s}s left`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cameraStatus(online: boolean, batteryPct: number | null): 'good' | 'warning' | 'critical' {
|
||||||
|
if (!online) return 'critical'
|
||||||
|
if (batteryPct === null) return 'good'
|
||||||
|
if (batteryPct >= 50) return 'good'
|
||||||
|
if (batteryPct >= 15) return 'warning'
|
||||||
|
return 'critical'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BORDER: Record<string, string> = {
|
||||||
|
good: 'border-l-rig-success',
|
||||||
|
warning: 'border-l-rig-warning',
|
||||||
|
critical: 'border-l-rig-danger',
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CameraCardProps {
|
interface CameraCardProps {
|
||||||
camera: CameraStatus
|
camera: CameraStatus
|
||||||
|
onStart: (cameraId: string) => void
|
||||||
|
onStop: (cameraId: string) => void
|
||||||
|
onViewHistory: (cameraId: string) => void
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CameraCard({ camera }: CameraCardProps) {
|
export default function CameraCard({ camera, onStart, onStop, onViewHistory, disabled }: CameraCardProps) {
|
||||||
const {
|
const {
|
||||||
|
camera_id,
|
||||||
friendly_name,
|
friendly_name,
|
||||||
online,
|
online,
|
||||||
resolution,
|
resolution,
|
||||||
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
} = camera
|
} = camera
|
||||||
|
|
||||||
const batt = batteryColor(battery_pct)
|
const batt = batteryColor(battery_pct)
|
||||||
|
const status = cameraStatus(online, battery_pct)
|
||||||
|
const borderColor = STATUS_BORDER[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
className={`rounded-xl border border-rig-dark-600 bg-rig-dark-800/60 transition-colors border-l-4 ${borderColor} ${
|
||||||
online
|
online
|
||||||
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
? 'hover:border-rig-accent/40'
|
||||||
: 'border-rig-dark-700 opacity-75'
|
: 'opacity-75'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
|
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
|
||||||
<h3
|
<h3
|
||||||
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
|
className="text-sm font-semibold text-rig-dark-100 truncate"
|
||||||
title={friendly_name}
|
title={friendly_name}
|
||||||
>
|
>
|
||||||
{friendly_name}
|
{friendly_name}
|
||||||
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={online ? 'Camera online' : 'Camera offline'}
|
aria-label={online ? 'Camera online' : 'Camera offline'}
|
||||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
className={`ml-2 shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
online
|
online
|
||||||
? 'bg-rig-success/15 text-rig-success'
|
? 'bg-rig-success/15 text-rig-success'
|
||||||
: 'bg-rig-danger/15 text-rig-danger'
|
: 'bg-rig-danger/15 text-rig-danger'
|
||||||
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
<div className="space-y-2.5 px-4 pb-3">
|
<div className="space-y-2.5 px-4 pb-3">
|
||||||
|
{/* Camera ID */}
|
||||||
|
<p className="text-[11px] font-mono text-rig-dark-500">{camera_id}</p>
|
||||||
|
|
||||||
{/* Resolution + FPS */}
|
{/* Resolution + FPS */}
|
||||||
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
||||||
<Signal className="h-3.5 w-3.5" />
|
<Signal className="h-3.5 w-3.5" />
|
||||||
@@ -159,7 +183,40 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── Footer ── */}
|
||||||
<div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2">
|
<div className="rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30">
|
||||||
|
{/* Controls row */}
|
||||||
|
<div className="flex items-center gap-1 px-3 py-2">
|
||||||
|
{recording ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onStop(camera_id)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-2.5 py-1 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label={`Stop recording ${friendly_name}`}
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 fill-current" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onStart(camera_id)}
|
||||||
|
disabled={disabled || !online}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-2.5 py-1 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label={`Start recording ${friendly_name}`}
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 fill-current" />
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onViewHistory(camera_id)}
|
||||||
|
className="ml-auto rounded-md bg-rig-dark-700/50 px-2 py-1 text-[11px] text-rig-dark-300 hover:bg-rig-dark-600 hover:text-rig-dark-100 transition-colors"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status strip */}
|
||||||
|
<div className="flex items-center justify-between px-4 pb-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
{online ? (
|
{online ? (
|
||||||
<>
|
<>
|
||||||
@@ -180,6 +237,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { X, Clock, Battery, Radio, Video } from 'lucide-react'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
import type { StatusLog } from '../types'
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryColor(pct: number | null): string {
|
||||||
|
if (pct === null) return 'text-rig-dark-400'
|
||||||
|
if (pct >= 50) return 'text-rig-success'
|
||||||
|
if (pct >= 15) return 'text-rig-warning'
|
||||||
|
return 'text-rig-danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HistoryViewerProps {
|
||||||
|
cameraId: string | null
|
||||||
|
cameraName?: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryViewer({ cameraId, cameraName, onClose }: HistoryViewerProps) {
|
||||||
|
const [logs, setLogs] = useState<StatusLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cameraId) {
|
||||||
|
setLogs([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
api
|
||||||
|
.getCameraDetail(cameraId)
|
||||||
|
.then((data) => {
|
||||||
|
setLogs(data.history)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load history')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [cameraId])
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (cameraId === null) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`History for ${cameraName ?? cameraId}`}
|
||||||
|
>
|
||||||
|
<div className="mx-4 w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl border border-rig-dark-600 bg-rig-dark-800 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between rounded-t-xl border-b border-rig-dark-700 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-rig-accent" />
|
||||||
|
<h2 className="text-sm font-semibold text-rig-dark-100">
|
||||||
|
History — {cameraName ?? cameraId}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md p-1 text-rig-dark-400 hover:bg-rig-dark-700 hover:text-rig-dark-100 transition-colors"
|
||||||
|
aria-label="Close history"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-rig-dark-500 border-t-rig-accent" />
|
||||||
|
<span className="ml-3 text-sm text-rig-dark-400">Loading history...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-rig-danger/30 bg-rig-danger/10 px-4 py-3 text-sm text-rig-danger">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && logs.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-sm text-rig-dark-400">
|
||||||
|
No history entries found for this camera.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && logs.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-rig-dark-700/50 bg-rig-dark-900/40 px-3 py-2.5 text-xs"
|
||||||
|
>
|
||||||
|
{/* Timestamp */}
|
||||||
|
<span className="font-mono text-rig-dark-400 min-w-[130px]">
|
||||||
|
{formatTimestamp(log.recorded_at)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Online/Recording badges */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
|
log.online
|
||||||
|
? 'bg-rig-success/15 text-rig-success'
|
||||||
|
: 'bg-rig-danger/15 text-rig-danger'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{log.recording_state ? (
|
||||||
|
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-rig-danger">
|
||||||
|
REC
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] text-rig-dark-500">
|
||||||
|
IDLE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Battery */}
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<Battery className="h-3 w-3 text-rig-dark-500" />
|
||||||
|
<span className={`font-mono ${batteryColor(log.battery_pct)}`}>
|
||||||
|
{log.battery_pct !== null ? `${log.battery_pct}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storage remaining */}
|
||||||
|
{log.video_remaining_sec !== null && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Radio className="h-3 w-3 text-rig-dark-500" />
|
||||||
|
<span className="font-mono text-rig-dark-400">
|
||||||
|
{Math.floor(log.video_remaining_sec / 60)}m left
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Video className="h-3 w-3 text-rig-dark-500" />
|
||||||
|
<span className="text-rig-dark-400">{log.mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="rounded-b-xl border-t border-rig-dark-700 px-5 py-3">
|
||||||
|
<p className="text-[11px] text-rig-dark-500">
|
||||||
|
{logs.length} entries (last 24 hours)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { default as CameraCard } from './CameraCard'
|
export { default as CameraCard } from './CameraCard'
|
||||||
|
export { default as HistoryViewer } from './HistoryViewer'
|
||||||
|
|||||||
+19
-6
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1'
|
||||||
|
|
||||||
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
@@ -12,9 +12,22 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getCameras: () => request<[]>('/cameras'),
|
/** GET /api/v1/cameras — list all cameras with latest status */
|
||||||
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
|
getCameras: () => request<import('../types').CameraStatus[]>('/cameras'),
|
||||||
getSystemHealth: () => request<[]>('/system/health'),
|
|
||||||
toggleRecording: (cameraId: string) =>
|
/** GET /api/v1/cameras/{id} — full detail + 24h history */
|
||||||
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
|
getCameraDetail: (id: string) =>
|
||||||
|
request<import('../types').CameraDetail>(`/cameras/${id}`),
|
||||||
|
|
||||||
|
/** POST /api/v1/cameras/{id}/start — start recording */
|
||||||
|
startRecording: (cameraId: string) =>
|
||||||
|
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** POST /api/v1/cameras/{id}/stop — stop recording */
|
||||||
|
stopRecording: (cameraId: string) =>
|
||||||
|
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,42 @@ export interface SSEEvent {
|
|||||||
payload?: unknown
|
payload?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single status log entry from GET /api/v1/cameras/{id} */
|
||||||
|
export interface StatusLog {
|
||||||
|
id: number
|
||||||
|
camera_id: string
|
||||||
|
recorded_at: string
|
||||||
|
battery_pct: number | null
|
||||||
|
video_remaining_sec: number | null
|
||||||
|
recording_state: number // 0 or 1 (SQLite bool)
|
||||||
|
mode: string
|
||||||
|
resolution: string
|
||||||
|
fps: number
|
||||||
|
online: number // 0 or 1
|
||||||
|
raw_battery_pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera detail response from GET /api/v1/cameras/{id} */
|
||||||
|
export interface CameraDetail {
|
||||||
|
camera: CameraInfo
|
||||||
|
last_status: StatusLog
|
||||||
|
history: StatusLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraInfo {
|
||||||
|
CameraID: string
|
||||||
|
FriendlyName: string
|
||||||
|
MacAddress: string | null
|
||||||
|
CreatedAt: string
|
||||||
|
UpdatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic API responses */
|
||||||
|
export interface StartStopResponse {
|
||||||
|
status: string
|
||||||
|
camera_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Camera {
|
export interface Camera {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ 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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user