generated from CubeCraft-Creations/Tracehound
Compare commits
82 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 | |||
| 45bfbcfdf5 | |||
| 37c5362216 | |||
| d9c06b28fd | |||
| d419dfe519 | |||
| e4324e626f | |||
| 5bc327e909 | |||
| 6b6b66ab89 | |||
| 74d6130dd5 | |||
| a90a1d567e | |||
| 607aea514b | |||
| f200cd9782 | |||
| ce188086cb | |||
| df212796d2 | |||
| 861aedd6d8 | |||
| 02fa6e4d4f | |||
| 07ecff3b5f | |||
| 69b050b62b |
@@ -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,64 +7,47 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
NODE_VERSION: "20"
|
||||
BINARY_NAME: openclaw
|
||||
BINARY_NAME: remoterig
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: go-react
|
||||
steps:
|
||||
- name: Checkout
|
||||
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
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Toolchain versions
|
||||
run: |
|
||||
go version
|
||||
node --version
|
||||
|
||||
- name: Build React frontend
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Embed frontend into Go binary
|
||||
- name: Build Go binary (ARM64 cross-compile)
|
||||
run: |
|
||||
mkdir -p internal/web/dist
|
||||
cp -r web/dist/* internal/web/dist/
|
||||
go generate ./internal/web/...
|
||||
|
||||
- name: Build Go binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
|
||||
-o ${{ env.BINARY_NAME }} ./cmd/server
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.BINARY_NAME }}
|
||||
path: ${{ env.BINARY_NAME }}
|
||||
retention-days: 5
|
||||
|
||||
- name: Trigger deploy workflow
|
||||
if: success()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
event_type: 'dev-build-success',
|
||||
client_payload: {
|
||||
sha: context.sha,
|
||||
ref: context.ref
|
||||
}
|
||||
})
|
||||
# Pull-based deploy: publish the binary to a rolling "dev" release.
|
||||
# The Pi polls this release and self-updates (scripts/pi-update.sh);
|
||||
# the runner never needs to reach the closed RemoteRig network.
|
||||
# Done in Node (runner image has no curl/jq/sudo; Node is present).
|
||||
- name: Publish to rolling dev release
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: node .gitea/scripts/publish-release.mjs
|
||||
@@ -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:
|
||||
push:
|
||||
@@ -7,53 +12,12 @@ on:
|
||||
branches: [dev, main]
|
||||
|
||||
jobs:
|
||||
lint-and-typecheck:
|
||||
quality:
|
||||
runs-on: go-react
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npx tsc --noEmit
|
||||
|
||||
test:
|
||||
needs: lint-and-typecheck
|
||||
runs-on: go-react
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: go-react
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Verify build output
|
||||
run: |
|
||||
du -sh dist/
|
||||
echo "Build successful"
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: go-react
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
environment: production
|
||||
steps:
|
||||
- name: Deploy placeholder
|
||||
run: |
|
||||
echo "Deploy target: /var/www/remote-rig/"
|
||||
echo "Configure deploy command before merging to main"
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
name: Deploy (Dev)
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types:
|
||||
- dev-build-success
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
BINARY_NAME: openclaw
|
||||
DEV_HOST: ${{ secrets.DEV_HOST }}
|
||||
DEV_USER: ${{ secrets.DEV_USER }}
|
||||
DEPLOY_PATH: /opt/openclaw/openclaw
|
||||
|
||||
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:-openclaw}"
|
||||
DEPLOY_PATH="${2:-/opt/openclaw/openclaw}"
|
||||
SERVICE="${3:-openclaw}"
|
||||
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 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"
|
||||
target: "/tmp/openclaw-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/openclaw-deploy
|
||||
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "openclaw"
|
||||
rm -rf /tmp/openclaw-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/openclaw-deploy-failure.txt
|
||||
+7
-1
@@ -9,10 +9,16 @@ lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
/dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Frontend build output — embedded into the Go binary at build time.
|
||||
# Vite writes here (cmd/server/src/dist); ignore the built output but keep
|
||||
# the committed index.html placeholder so //go:embed always has a file.
|
||||
cmd/server/src/dist/*
|
||||
!cmd/server/src/dist/index.html
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# RemoteRig Gitea CI/CD Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Set up Gitea Actions CI/CD pipeline with build → test → deploy stages for the RemoteRig React dashboard.
|
||||
|
||||
**Architecture:** Gitea Actions (GitHub Actions compatible) running in `.gitea/workflows/`. Single workflow file with three jobs: lint+typecheck, test, build, and a manual deploy step. The app is a Vite SPA that builds to `dist/` — deploy serves those static files.
|
||||
|
||||
**Tech Stack:** Gitea Actions, Node 22, Vite, Vitest, TypeScript, Tailwind
|
||||
|
||||
**Success criteria:**
|
||||
- Build step completes successfully (`tsc -b && vite build`)
|
||||
- All unit tests pass (`vitest run`)
|
||||
- Deploy step exists (manual trigger for now)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Verify Gitea Actions runner availability
|
||||
|
||||
**Objective:** Confirm the Gitea instance has at least one Actions runner registered.
|
||||
|
||||
**Files:** None (read-only check)
|
||||
|
||||
**Step 1:** Check Gitea Actions runners
|
||||
|
||||
```bash
|
||||
curl -s "https://code.cubecraftcreations.com/api/v1/admin/runners" \
|
||||
-H "Authorization: bearer ${HERMES_GITEA_TOKEN}" | jq '.'
|
||||
```
|
||||
|
||||
If this returns a list with runners, we're good. If 404 or empty, we need to register a runner.
|
||||
|
||||
**Step 2:** Check org-level runners
|
||||
|
||||
```bash
|
||||
curl -s "https://code.cubecraftcreations.com/api/v1/orgs/CubeCraft-Creations/actions/runners" \
|
||||
-H "Authorization: bearer ${HERMES_GITEA_TOKEN}" | jq '.'
|
||||
```
|
||||
|
||||
**Expected output:** At least one runner with `"is_online": true` at either admin or org level.
|
||||
|
||||
**Verification:** Confirm runners exist before proceeding.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Gitea Actions CI/CD workflow
|
||||
|
||||
**Objective:** Create `.gitea/workflows/ci.yaml` with jobs for lint, typecheck, test, build, and deploy.
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/ci.yaml`
|
||||
|
||||
**Workflow structure:**
|
||||
|
||||
```yaml
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
|
||||
jobs:
|
||||
# ── Quality Gates ──────────────────────────────────────────
|
||||
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 run lint
|
||||
- run: npx tsc --noEmit
|
||||
|
||||
# ── Unit Tests ─────────────────────────────────────────────
|
||||
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
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────
|
||||
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
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
# ── Deploy ─────────────────────────────────────────────────
|
||||
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..."
|
||||
# Replace with actual deploy command (rsync, scp, S3, etc.)
|
||||
echo "Deploy target: /var/www/remote-rig/"
|
||||
echo "Placeholder — configure deploy target before merging to main"
|
||||
```
|
||||
|
||||
**Step 1:** Create the directory and file
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/ai-storage/projects/remote-rig/.gitea/workflows
|
||||
```
|
||||
|
||||
**Step 2:** Write the workflow file with the content above
|
||||
|
||||
**Step 3:** Verify YAML syntax
|
||||
|
||||
```bash
|
||||
python3 -c "import yaml; yaml.safe_load(open('/mnt/ai-storage/projects/remote-rig/.gitea/workflows/ci.yaml'))" && echo "YAML: OK"
|
||||
```
|
||||
|
||||
**Step 4:** Commit
|
||||
|
||||
```bash
|
||||
cd /mnt/ai-storage/projects/remote-rig
|
||||
git add .gitea/
|
||||
git commit -m "ci: add Gitea Actions pipeline (lint, typecheck, test, build, deploy)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verify workflow triggers on push
|
||||
|
||||
**Objective:** Push the workflow and verify it appears in Gitea Actions.
|
||||
|
||||
**Step 1:** Push the branch
|
||||
|
||||
```bash
|
||||
cd /mnt/ai-storage/projects/remote-rig
|
||||
git push
|
||||
```
|
||||
|
||||
**Step 2:** Check if the workflow registered
|
||||
|
||||
```bash
|
||||
curl -s "https://code.cubecraftcreations.com/api/v1/repos/CubeCraft-Creations/remote-rig/actions/workflows" \
|
||||
-H "Authorization: bearer ${HERMES_GITEA_TOKEN}" | jq '.workflows[] | {name, state, path}'
|
||||
```
|
||||
|
||||
**Expected:** The CI/CD workflow appears with state "active".
|
||||
|
||||
**Verification:** Workflow is listed and active on the repo.
|
||||
+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
|
||||
```
|
||||
@@ -0,0 +1,164 @@
|
||||
# RemoteRig Central Hub
|
||||
|
||||
A central hub for managing remote camera rigs, designed for Raspberry Pi Zero 2 W.
|
||||
|
||||
## Overview
|
||||
|
||||
RemoteRig Central Hub is the control plane for remote camera setups. It connects to camera rigs over MQTT, stores configuration and state in SQLite, and exposes a management API — all from a lightweight Go binary optimized for resource-constrained devices like the Raspberry Pi Zero 2 W.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
| -------------- | ----------------------------- |
|
||||
| Language | Go 1.24+ |
|
||||
| Database | SQLite |
|
||||
| Messaging | MQTT |
|
||||
| Configuration | YAML (`gopkg.in/yaml.v3`) |
|
||||
| Target Platform| Raspberry Pi Zero 2 W (ARMv6) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
remote-rig/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Application entry point
|
||||
├── internal/
|
||||
│ └── db/
|
||||
│ └── db.go # SQLite database initialization and schema
|
||||
├── config.yaml # Application configuration
|
||||
├── go.mod # Go module definition
|
||||
├── go.sum # Dependency checksums
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.24+** — [Download and install](https://go.dev/dl/)
|
||||
- **MQTT Broker** — e.g., [Mosquitto](https://mosquitto.org/) (default: `localhost:1883`)
|
||||
- **Raspberry Pi Zero 2 W** (or any Linux system — macOS and Windows also work for development)
|
||||
- **Git** — for cloning the repository
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://code.cubecraftcreations.com/CubeCraft-Creations/remote-rig.git
|
||||
cd remote-rig
|
||||
```
|
||||
|
||||
### 2. Install Go Dependencies
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
### 3. Configure
|
||||
|
||||
Edit `config.yaml` to match your environment:
|
||||
|
||||
```yaml
|
||||
# Database file path (SQLite)
|
||||
db_path: "remoterig.db"
|
||||
|
||||
# API key for endpoint authentication — CHANGE THIS
|
||||
api_key: "your-secure-api-key-here"
|
||||
|
||||
# HTTP server settings
|
||||
port: 8080
|
||||
read_timeout: 5s
|
||||
write_timeout: 10s
|
||||
idle_timeout: 120s
|
||||
|
||||
# MQTT broker connection
|
||||
mqtt:
|
||||
broker: "localhost:1883"
|
||||
client_id: "remoterig-hub"
|
||||
|
||||
# Target platform
|
||||
platform:
|
||||
type: "pi-zero-2w"
|
||||
max_cameras: 16
|
||||
```
|
||||
|
||||
Key settings to review:
|
||||
|
||||
| Setting | Description | Default |
|
||||
| ------- | ----------- | ------- |
|
||||
| `api_key` | API key for authenticating requests | `changeme` (**must change**) |
|
||||
| `port` | HTTP server listen port | `8080` |
|
||||
| `mqtt.broker` | MQTT broker address | `localhost:1883` |
|
||||
| `mqtt.client_id` | MQTT client identifier | `remoterig-hub` |
|
||||
| `platform.type` | Target platform identifier | `pi-zero-2w` |
|
||||
| `platform.max_cameras` | Maximum number of camera rigs | `16` |
|
||||
| `db_path` | SQLite database file path | `remoterig.db` |
|
||||
| `read_timeout` | HTTP read timeout | `5s` |
|
||||
| `write_timeout` | HTTP write timeout | `10s` |
|
||||
| `idle_timeout` | HTTP idle connection timeout | `120s` |
|
||||
|
||||
## Running Locally
|
||||
|
||||
Start the hub with:
|
||||
|
||||
```bash
|
||||
go run ./cmd/server/
|
||||
```
|
||||
|
||||
You should see output similar to:
|
||||
|
||||
```
|
||||
RemoteRig hub starting...
|
||||
Database: remoterig.db
|
||||
API key set: true
|
||||
Server port: 8080
|
||||
MQTT broker: localhost:1883
|
||||
Platform: pi-zero-2w (max 16 cameras)
|
||||
RemoteRig hub ready
|
||||
```
|
||||
|
||||
## Deployment (CI/CD — pull-based)
|
||||
|
||||
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
|
||||
GOOS=linux GOARCH=arm64 go build -o remoterig-hub ./cmd/server/
|
||||
scp remoterig-hub config.yaml pi@192.168.8.56:/opt/remoterig/
|
||||
```
|
||||
|
||||
### Build Matrix
|
||||
|
||||
| Target | Command |
|
||||
| ------ | ------- |
|
||||
| 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/` |
|
||||
| Linux amd64 | `GOOS=linux GOARCH=amd64 go build -o remoterig-hub ./cmd/server/` |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — CubeCraft Creations.
|
||||
|
||||
+62
-10
@@ -3,7 +3,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -15,12 +17,16 @@ import (
|
||||
"github.com/cubecraft/remoterig/internal/auth"
|
||||
"github.com/cubecraft/remoterig/internal/db"
|
||||
"github.com/cubecraft/remoterig/internal/events"
|
||||
"github.com/cubecraft/remoterig/internal/mqtt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed all:src/dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
// Config holds the application configuration.
|
||||
type Config struct {
|
||||
DBPath string `yaml:"db_path"`
|
||||
@@ -59,13 +65,22 @@ func main() {
|
||||
// Create SSE hub for real-time updates
|
||||
sseHub := events.NewHub()
|
||||
|
||||
// Start MQTT subscriber for ESP32 camera status ingestion
|
||||
mqttSub := mqtt.NewSubscriber(cfg.MQTT.Broker, cfg.MQTT.ClientID, sqlDB, sseHub)
|
||||
if err := mqttSub.Connect(); err != nil {
|
||||
log.Printf("WARNING: MQTT subscriber failed to connect: %v (running without MQTT)", err)
|
||||
}
|
||||
defer mqttSub.Close()
|
||||
|
||||
// Set up router
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
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)
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -74,15 +89,19 @@ func main() {
|
||||
})
|
||||
|
||||
// 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
|
||||
r.Mount("/", frontendHandler())
|
||||
|
||||
// Create server
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: r,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: r,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
// WriteTimeout intentionally 0: SSE responses are long-lived and a
|
||||
// write deadline would terminate them mid-stream.
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
@@ -103,7 +122,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
// Camera management routes
|
||||
@@ -112,8 +131,8 @@ func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
|
||||
r.Get("/cameras/{id}", api.GetCameraDetail(database))
|
||||
|
||||
// Recording control routes
|
||||
r.Post("/cameras/{id}/start", api.StartRecording(database))
|
||||
r.Post("/cameras/{id}/stop", api.StopRecording(database))
|
||||
r.Post("/cameras/{id}/start", api.StartRecording(database, pub))
|
||||
r.Post("/cameras/{id}/stop", api.StopRecording(database, pub))
|
||||
|
||||
// Status ingestion (from ESP32 nodes)
|
||||
r.Post("/cameras/{id}/status", api.PushStatus(database))
|
||||
@@ -151,3 +170,36 @@ func loadConfig(path string) (*Config, error) {
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// frontendHandler returns an http.Handler that serves the embedded React
|
||||
// frontend from src/dist/ with SPA-style fallback: any path that doesn't
|
||||
// match a static file serves index.html for client-side routing.
|
||||
//
|
||||
// The frontend is embedded via //go:embed all:src/dist at build time.
|
||||
// If src/dist/ is empty or missing at build time, the embedded fallback
|
||||
// index.html (committed to the repo) is served instead, showing a
|
||||
// "run npm run build" message.
|
||||
func frontendHandler() http.Handler {
|
||||
distFS, err := fs.Sub(frontendFS, "src/dist")
|
||||
if err != nil {
|
||||
// Shouldn't happen if embed worked, but be defensive.
|
||||
panic("embedded frontend filesystem not found: " + err.Error())
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to serve the requested file.
|
||||
f, err := distFS.Open(r.URL.Path[1:]) // strip leading "/"
|
||||
if err != nil {
|
||||
// File not found — serve index.html for SPA routing.
|
||||
r.URL.Path = "/"
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// File exists, serve it.
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
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
|
||||
db_path: "remoterig.db"
|
||||
|
||||
# API Key for endpoint authentication
|
||||
api_key: "changeme"
|
||||
# API key for endpoint authentication. Empty = kiosk mode (no auth) —
|
||||
# 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
|
||||
port: 8080
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
# RemoteRig — Project Context
|
||||
|
||||
> **Last updated:** 2026-05-21 (evening — post-planning sync)
|
||||
> **Repo:** `CubeCraft-Creations/remote-rig` | **Host:** `code.cubecraftcreations.com`
|
||||
> **Local clone:** `/mnt/ai-storage/projects/remote-rig` | **Default branch:** `dev`
|
||||
> **Discord:** `DISCORD_DEV_REMOTERIG_CHANNEL_ID`
|
||||
> **Linear Epic:** [CUB-198](https://linear.app/cubecraft-creations/issue/CUB-198)
|
||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
RemoteRig is a **multi-camera remote monitoring system**. It provides a camera grid dashboard for monitoring multiple GoPro cameras remotely. Cameras push status via MQTT/HTTP, the UI shows a live grid with SSE updates, and the system supports start/stop recording control.
|
||||
|
||||
**Target hardware:** Raspberry Pi Zero 2 W as the central hub, with ESP32 nodes attached to each GoPro camera for status collection and MQTT communication.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Notes |
|
||||
|-------|-----------|-------|
|
||||
| Backend | Go 1.25+ | Chi v5 router, SQLite (modernc.org/sqlite), go-yaml v3 |
|
||||
| Frontend | React 19 + TypeScript 5.7 | Vite 6, Tailwind CSS 3.4 |
|
||||
| State | Zustand 5 | Client-side camera state store |
|
||||
| Icons | lucide-react 0.469 | |
|
||||
| Real-time | SSE (Server-Sent Events) | `/api/v1/events/stream` |
|
||||
| Messaging | MQTT | Mosquitto broker for ESP32 → hub communication |
|
||||
| Database | SQLite | WAL mode, foreign keys enabled |
|
||||
| Auth | API key (Bearer token) | Middleware, configurable in `config.yaml` |
|
||||
| Testing | Vitest 4.1 + testing-library/react | Unit tests for React components |
|
||||
| Linting | ESLint 9 | `npm run lint` |
|
||||
| CI/CD | Gitea Actions | `.gitea/workflows/ci.yaml`, `build-dev.yaml`, `deploy-dev.yaml` |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Travel Router (self-contained LAN) │
|
||||
│ Subnet: 192.168.8.0/24 │
|
||||
│ DHCP pool: .100-.200 │
|
||||
└──────┬──────────┬──────────┬──────────────┘
|
||||
│ │ │
|
||||
┌───────────────┘ │ └───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
|
||||
│ DHCP addr │ │ DHCP addr │ │ 192.168.8.56 │
|
||||
│ STA→Router │ │ STA→Router │ │ (static IP) │
|
||||
│ MQTT→:1883 │ │ MQTT→:1883 │ │ Mosquitto :1883 │
|
||||
│ UART relay │ │ UART relay │ │ Go API :8080 │
|
||||
│ │ │ │ │ React UI │
|
||||
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
|
||||
│ UART │ UART └──────────────────┘
|
||||
▼ ▼ │
|
||||
┌──────────────┐ ┌──────────────┐ │
|
||||
│ ESP8266 #1 │ │ ESP8266 #N │ SSE /api/v1/events/stream
|
||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │
|
||||
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│ ▼
|
||||
└──────┬───────┘ └──────┬───────┘ ┌──────────────────┐
|
||||
▼ ▼ │ User Device │
|
||||
┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │
|
||||
│ 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). 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)
|
||||
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `192.168.8.0/24`.
|
||||
- **Two-board camera node** — ESP8266 handles GoPro AP/HTTP; ESP32 stays on the travel-router LAN for MQTT. This avoids ESP32 dual-STA/channel switching complexity.
|
||||
- **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)
|
||||
- **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.
|
||||
- **Chi router** — Lightweight Go HTTP router with middleware support.
|
||||
- **Zustand over Redux** — Minimal boilerplate for camera status store.
|
||||
- **API key auth** — Simple bearer token; closed LAN, not internet-facing.
|
||||
- **Camera auto-discovery** — ESP32 publishes `announce` message on first MQTT connect. Hub auto-registers by MAC, assigns sequential `cam-NNN` ID.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
remote-rig/
|
||||
├── cmd/server/main.go # Entry point — config load, router setup, graceful shutdown
|
||||
├── config.yaml # Runtime configuration
|
||||
├── go.mod / go.sum # Go dependencies
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── api.go # Package doc
|
||||
│ │ ├── cameras.go # GET /cameras, POST /cameras, GET /cameras/:id
|
||||
│ │ ├── recording.go # POST /cameras/:id/start, POST /cameras/:id/stop
|
||||
│ │ └── status.go # POST /cameras/:id/status (push from ESP32)
|
||||
│ ├── auth/
|
||||
│ │ └── middleware.go # API key auth middleware
|
||||
│ ├── db/
|
||||
│ │ ├── db.go # Open, migrations, WAL mode
|
||||
│ │ └── migrations/
|
||||
│ │ └── 001_create_tables.sql
|
||||
│ └── events/
|
||||
│ └── sse.go # SSE hub (subscribe, broadcast)
|
||||
├── pkg/models/
|
||||
│ └── camera.go # Camera, StatusLog, RecordingEvent, CameraStatus, Settings
|
||||
├── src/ # React frontend
|
||||
│ ├── App.tsx # Main app — header, camera grid, footer
|
||||
│ ├── components/
|
||||
│ │ ├── CameraCard.tsx # Single camera status card
|
||||
│ │ ├── CameraCard.test.tsx # Unit tests
|
||||
│ │ └── index.ts
|
||||
│ ├── hooks/
|
||||
│ │ ├── useSSE.ts # SSE connection hook
|
||||
│ │ ├── useCameraStatus.ts # Camera status hook
|
||||
│ │ ├── useSystemHealth.ts # System health hook
|
||||
│ │ └── index.ts
|
||||
│ ├── services/
|
||||
│ │ └── api.ts # API client
|
||||
│ ├── store/
|
||||
│ │ ├── useCameraStore.ts # Zustand store
|
||||
│ │ └── index.ts
|
||||
│ ├── types/
|
||||
│ │ └── index.ts # TypeScript interfaces
|
||||
│ ├── utils/
|
||||
│ │ └── index.ts
|
||||
│ └── main.tsx
|
||||
├── docs/
|
||||
│ ├── CONTEXT.md # ← this file
|
||||
│ └── plans/
|
||||
│ └── 2026-05-21-cub-196-cameracard.md # CameraCard implementation plan
|
||||
├── .gitea/workflows/
|
||||
│ ├── ci.yaml # PR CI: lint → typecheck → test → build
|
||||
│ ├── build-dev.yaml # Go binary build on dev push
|
||||
│ └── deploy-dev.yaml # SCP + SSH deploy with rollback
|
||||
├── .env.example # VITE_API_URL=http://localhost:8080/api
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.js
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Database Schema (SQLite)
|
||||
|
||||
### cameras
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| camera_id | TEXT PK | Unique camera identifier |
|
||||
| friendly_name | TEXT NOT NULL | Human-readable name |
|
||||
| mac_address | TEXT UNIQUE | MAC address (optional) |
|
||||
| created_at | DATETIME | Default now |
|
||||
| updated_at | DATETIME | Default now |
|
||||
|
||||
### status_logs
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | INTEGER PK AUTO | |
|
||||
| camera_id | TEXT FK → cameras | |
|
||||
| recorded_at | DATETIME | Default now |
|
||||
| battery_pct | INTEGER | Nullable |
|
||||
| video_remaining_sec | INTEGER | Nullable |
|
||||
| recording_state | INTEGER | 0=idle, 1=recording |
|
||||
| mode | TEXT | e.g. "video" |
|
||||
| resolution | TEXT | e.g. "1080p" |
|
||||
| fps | INTEGER | |
|
||||
| online | INTEGER | 0=offline, 1=online |
|
||||
| raw_battery_pct | REAL | Float precision |
|
||||
|
||||
Index: `(camera_id, recorded_at DESC)`
|
||||
|
||||
### recording_events
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | INTEGER PK AUTO | |
|
||||
| camera_id | TEXT FK → cameras | |
|
||||
| started_at | DATETIME NOT NULL | |
|
||||
| stopped_at | DATETIME | Null while recording |
|
||||
| reason | TEXT | e.g. "manual" |
|
||||
| duration | INTEGER | Seconds |
|
||||
|
||||
Index: `(camera_id, started_at DESC)`
|
||||
|
||||
### settings
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| key | TEXT PK | |
|
||||
| value | TEXT NOT NULL | |
|
||||
| updated_at | DATETIME | Default now |
|
||||
|
||||
**Default seeds:** `poll_interval_sec=30`, `low_battery_threshold=15`, `low_storage_alert_sec=300`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | /health | No | Health check → `{"status":"ok"}` |
|
||||
| GET | /api/v1/cameras | Yes | List all cameras with latest status |
|
||||
| POST | /api/v1/cameras | Yes | Register a new camera |
|
||||
| GET | /api/v1/cameras/:id | Yes | Camera detail + latest status + 24h history |
|
||||
| POST | /api/v1/cameras/:id/start | Yes | Start recording + MQTT publish |
|
||||
| POST | /api/v1/cameras/:id/stop | Yes | Stop recording |
|
||||
| POST | /api/v1/cameras/:id/status | Yes | Push status from ESP32 node |
|
||||
| GET | /api/v1/events/stream | No (SSE) | Real-time camera status stream |
|
||||
|
||||
## Configuration (`config.yaml`)
|
||||
|
||||
```yaml
|
||||
db_path: "remoterig.db" # SQLite database path
|
||||
api_key: "changeme" # Bearer token for API auth
|
||||
port: 8080 # HTTP listen port
|
||||
read_timeout: 5s
|
||||
write_timeout: 10s
|
||||
idle_timeout: 120s
|
||||
mqtt:
|
||||
broker: "localhost:1883" # Mosquitto on Pi Zero 2 W
|
||||
client_id: "remoterig-hub"
|
||||
platform:
|
||||
type: "pi-zero-2w"
|
||||
max_cameras: 16
|
||||
network:
|
||||
subnet: "192.168.8.0/24" # Travel router subnet
|
||||
hub_ip: "192.168.8.56" # Pi Zero 2 W static IP
|
||||
```
|
||||
|
||||
## Frontend Component Tree
|
||||
|
||||
```
|
||||
App
|
||||
├── Header
|
||||
│ ├── Logo + Title ("RemoteRig Dashboard")
|
||||
│ └── Stats bar (online count, recording count)
|
||||
├── CameraGrid
|
||||
│ └── CameraCard[] (responsive: 1→2→3→4 columns)
|
||||
│ ├── Camera name + online/offline badge
|
||||
│ ├── Resolution + FPS display
|
||||
│ ├── Recording indicator (pulsing dot + REC/IDLE badge)
|
||||
│ ├── Battery bar (color-coded: green/yellow/red)
|
||||
│ └── Footer (Live/Last seen + video remaining)
|
||||
└── Footer
|
||||
└── "RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System"
|
||||
```
|
||||
|
||||
**Empty state:** "Waiting for cameras..." with pulsing radio icon when no cameras connected.
|
||||
**Offline state:** Camera card dimmed with dashed border, shows "Last seen Xm ago".
|
||||
|
||||
## Color Palette (Tailwind — dark dashboard theme)
|
||||
|
||||
Custom theme in `tailwind.config.js`:
|
||||
- `rig-dark-900` (background), `rig-dark-800`, `rig-dark-700` (cards), etc.
|
||||
- `rig-accent` (accent color)
|
||||
- `rig-success` (green — battery ≥50%, online)
|
||||
- `rig-warning` (yellow — battery 15-49%)
|
||||
- `rig-danger` (red — battery <15%, offline, recording)
|
||||
|
||||
## Linear Issue Map
|
||||
|
||||
**Last synced:** 2026-05-21 (evening)
|
||||
|
||||
| CUB | Title | Status | Agent |
|
||||
|-----|-------|--------|-------|
|
||||
| 198 | **Epic: Multi-camera remote monitoring system** | Backlog | — |
|
||||
| 238 | Define MQTT message format contract | ✅ Done | Dex |
|
||||
| 228 | Add battery_calibration_offset to cameras table | Backlog | Hex |
|
||||
| 230 | ESP32 offline status buffering and replay | Backlog | Pip |
|
||||
| 232 | Implement MQTT subscriber in Go hub | Backlog | Dex |
|
||||
| 229 | Design camera auto-discovery and registration flow | Backlog | Dex |
|
||||
| 231 | Mosquitto MQTT broker setup on Pi Zero 2 W | Backlog | Dex |
|
||||
| 233 | Verify and harden SSE endpoint | Backlog | Dex |
|
||||
| 234 | Verify and harden all camera API endpoints | Backlog | Dex |
|
||||
| 235 | Implement GET /api/v1/cameras/:id with 24h history | Backlog | Dex |
|
||||
| 236 | Implement POST /api/v1/cameras registration | Backlog | Dex |
|
||||
| 237 | Update CONTEXT.md to actual state | Backlog | Otto |
|
||||
| — | — | — | — |
|
||||
| 173 | Confirm GoPro Hero 3 Wi‑Fi control API | ✅ Done | Otto |
|
||||
| 174 | ESP32 firmware baseline | ✅ Done | Pip |
|
||||
| 175 | Central hub backend (Go service) | ✅ Done | Dex |
|
||||
| 177 | Database schema (cameras, events, status_logs) | ✅ Done | Hex |
|
||||
| 180 | Risk mitigation checklist | ✅ Done | — |
|
||||
| 181 | Scaffold Go module, directory layout, config | ✅ Done | — |
|
||||
| 187 | POST start recording + MQTT publish | ✅ Done | Dex |
|
||||
| 194 | Scaffold Vite + React + TypeScript + Tailwind | ✅ Done | — |
|
||||
| 195 | React SSE hook (useSSE.ts) + Zustand store | ✅ Done | Rex |
|
||||
| 196 | CameraCard component + 16 unit tests | ✅ Done | Rex |
|
||||
| 179 | Logging & persistence strategy | ✅ Done | — |
|
||||
| 208 | Add README with project overview | ✅ Done | Hermes |
|
||||
| 182 | ~~Duplicate of CUB-181~~ | ❌ Canceled | — |
|
||||
| — | — | — | — |
|
||||
| 183 | SQLite schema migration + DB init | Backlog | Hex |
|
||||
| 184 | API key auth middleware | Backlog | Dex |
|
||||
| 185 | Camera/StatusLog/RecordingEvent Go models | Backlog | Hex |
|
||||
| 186 | GET /api/v1/cameras (list with live status) | Backlog | Dex |
|
||||
| 188 | POST stop recording | Backlog | Dex |
|
||||
| 189 | POST register new camera | Backlog | Dex |
|
||||
| 190 | GET camera detail + history | Backlog | Dex |
|
||||
| 191 | POST push-status (HTTP ingestion) | Backlog | Dex |
|
||||
| 192 | MQTT subscriber | Backlog | Dex |
|
||||
| 193 | SSE /api/v1/events/stream endpoint | Backlog | Dex |
|
||||
| 178 | UX/UI design mockups | Backlog | Sketch |
|
||||
| 197 | Dashboard camera grid wireframe | In Review | Sketch |
|
||||
| 176 | Frontend umbrella (React + Tailwind) | Backlog | Rex |
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### ci.yaml (PR gate)
|
||||
Runs on push/PR to `dev` and `main`:
|
||||
1. `lint-and-typecheck`: npm ci → eslint → tsc --noEmit
|
||||
2. `test` (needs lint): npm test (vitest)
|
||||
3. `build` (needs test): npm run build → upload dist artifact
|
||||
|
||||
### build-dev.yaml
|
||||
Triggered by `repository_dispatch: dev-build-success`:
|
||||
- Checks out, downloads artifact, builds Go binary
|
||||
|
||||
### deploy-dev.yaml
|
||||
Triggered by `workflow_dispatch`:
|
||||
- SCP binary + deploy script to dev host
|
||||
- Deploy with backup/rollback, systemctl restart
|
||||
- Failure notification
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **SQLite chosen over PostgreSQL** — Single-node Pi Zero 2 W deployment; no need for a separate DB server. WAL mode for concurrent read/write.
|
||||
2. **SSE chosen over WebSocket** — Unidirectional updates (hub → browser) are sufficient for status dashboard; SSE is simpler to implement and maintain.
|
||||
3. **Chi router** — Lightweight, idiomatic Go HTTP router with middleware support.
|
||||
4. **Zustand over Redux** — Minimal boilerplate for the camera status store.
|
||||
5. **API key auth** — Simple bearer token; hub is on a local network, not internet-facing.
|
||||
6. **MQTT** — Standard IoT protocol for ESP32 communication; Mosquitto broker runs locally on the Pi.
|
||||
7. **Recording state tracking** — Status push handler detects recording state changes and automatically opens/closes recording_events rows.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **MQTT subscriber not yet implemented** (CUB-232) — ESP32→hub communication backbone is designed (see [MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)) but not built. Currently only HTTP status push is wired up.
|
||||
- **SSE endpoint needs verification** (CUB-233) — Frontend SSE hook exists (merged PR #2), backend SSE hub code exists (140 lines) but needs heartbeat, reconnection, and integration testing.
|
||||
- **No camera auto-discovery** (CUB-229) — Cameras must be manually registered. ESP32 announce protocol designed in MQTT contract but not implemented.
|
||||
- **No battery calibration** (CUB-228) — GoPro Hero 3 reports raw byte; per-camera calibration offset column not yet added to schema.
|
||||
- **No offline buffering on ESP32** (CUB-230) — If travel router Wi-Fi drops, status data is lost. SPIFFS buffer designed but not implemented.
|
||||
- **CameraCard wireframe pending** (CUB-197) — Dashboard UI has live code but needs UX review/wireframe sign-off.
|
||||
- **remoterig.db committed to repo** — Should be in `.gitignore` for production. Low priority (convenient for dev).
|
||||
- **Time sync TBD** — ESP32s need accurate timestamps without internet. Options: Pi as NTP server, GPS module, or HTTP time endpoint. See MQTT contract open questions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
cd /mnt/ai-storage/projects/remote-rig
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
# Backend
|
||||
go run cmd/server/main.go
|
||||
# → runs on :8080
|
||||
|
||||
# Frontend
|
||||
cp .env.example .env # edit if needed
|
||||
npm install
|
||||
npm run dev # → Vite dev server with API proxy
|
||||
```
|
||||
|
||||
## Default Agent Assignments
|
||||
|
||||
| Area | Agent | Notes |
|
||||
|------|-------|-------|
|
||||
| Backend (Go API, MQTT, SSE) | Dex | gitea-dex MCP |
|
||||
| Database (SQLite schema/migrations) | Hex | gitea-hex MCP |
|
||||
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
|
||||
| Hardware (ESP32 firmware, GPIO) | Pip | gitea-pip MCP |
|
||||
| Design (wireframes, UX) | Sketch | |
|
||||
@@ -0,0 +1,288 @@
|
||||
# MQTT Message Format Contract — RemoteRig
|
||||
|
||||
> **Version:** 1.0.0 | **Status:** Draft | **Blocks:** CUB-232 (MQTT subscriber), CUB-174 (ESP32 firmware)
|
||||
> **Last updated:** 2026-05-21
|
||||
|
||||
## Network Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Travel Router (192.168.8.1) │
|
||||
│ DHCP: .100-.200 │
|
||||
└──────┬──────────┬──────────┬──────┘
|
||||
│ │ │
|
||||
┌───────────────┘ │ └───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
|
||||
│ 192.168.8.101 │ │ 192.168.8.102 │ │ 192.168.8.56 │
|
||||
│ STA→Router │ │ STA→Router │ │ Mosquitto │
|
||||
│ MQTT relay │ │ MQTT relay │ │ Go backend │
|
||||
└──────┬───────┘ └──────┬───────┘ │ 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 │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- **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.8.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
|
||||
- **ESP32s:** DHCP from router. Each stays on the travel-router LAN, relays MQTT to/from its paired ESP8266 over UART
|
||||
- **User device:** Connects to router, opens `http://192.168.8.56:8080` for dashboard
|
||||
|
||||
## MQTT Broker
|
||||
|
||||
- **Host:** `192.168.8.56` (Pi Zero 2 W)
|
||||
- **Port:** `1883` (default MQTT, no TLS — closed network)
|
||||
- **Auth:** None (closed network, no external access)
|
||||
- **Client ID format:** `remoterig-<esp32_mac_last6>` (e.g., `remoterig-a1b2c3`)
|
||||
- **QoS:** 1 (at least once) for status/heartbeat. 2 (exactly once) for commands.
|
||||
- **Retain:** Status messages use `retain: true` so new subscribers get latest state immediately
|
||||
|
||||
## Topic Hierarchy
|
||||
|
||||
```
|
||||
remoterig/
|
||||
├── cameras/
|
||||
│ └── <camera_id>/
|
||||
│ ├── status ← ESP32 publishes (retained, QoS 1)
|
||||
│ ├── heartbeat ← ESP32 publishes (QoS 1, not retained)
|
||||
│ ├── command → Hub publishes (QoS 2)
|
||||
│ └── announce ← ESP32 publishes on first boot (QoS 2, retained)
|
||||
└── hub/
|
||||
└── status ← Hub publishes (retained, QoS 1)
|
||||
```
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/status`
|
||||
|
||||
**Direction:** ESP32 → Hub
|
||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
||||
|
||||
Published by the ESP32 every 30s using the latest GoPro status received from the paired ESP8266 over UART.
|
||||
|
||||
```json
|
||||
{
|
||||
"camera_id": "cam-001",
|
||||
"timestamp": "2026-05-21T18:30:00Z",
|
||||
"battery_pct": 85,
|
||||
"battery_raw": 217,
|
||||
"video_remaining_sec": 3420,
|
||||
"recording": true,
|
||||
"mode": "video",
|
||||
"resolution": "1080p",
|
||||
"fps": 30,
|
||||
"online": true,
|
||||
"rssi": -52,
|
||||
"uptime_sec": 1247
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `camera_id` | string | ✅ | Unique camera identifier (set during registration) |
|
||||
| `timestamp` | ISO 8601 | ✅ | ESP32 clock time when status was read |
|
||||
| `battery_pct` | int 0-100 | ✅ | Calibrated battery percentage (null if uncalibrated → omit) |
|
||||
| `battery_raw` | int 0-255 | — | Raw byte from GoPro status offset 57 |
|
||||
| `video_remaining_sec` | int | — | Estimated remaining recording seconds (null if unavailable) |
|
||||
| `recording` | bool | ✅ | Whether camera is currently recording |
|
||||
| `mode` | string | — | Current mode (e.g., "video", "photo", "burst") |
|
||||
| `resolution` | string | — | Current resolution string |
|
||||
| `fps` | int | — | Current frames per second |
|
||||
| `online` | bool | ✅ | ESP32 can reach the GoPro (false if GoPro AP unreachable) |
|
||||
| `rssi` | int | — | Wi-Fi RSSI to GoPro AP (dBm, negative) |
|
||||
| `uptime_sec` | int | — | ESP32 uptime in seconds |
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/heartbeat`
|
||||
|
||||
**Direction:** ESP32 → Hub
|
||||
**QoS:** 1 | **Retain:** false | **Interval:** 60 seconds
|
||||
|
||||
Lightweight keepalive so the hub can detect dead ESP32s.
|
||||
|
||||
```json
|
||||
{
|
||||
"camera_id": "cam-001",
|
||||
"timestamp": "2026-05-21T18:31:00Z",
|
||||
"uptime_sec": 1307,
|
||||
"free_heap": 28672
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `camera_id` | string | Camera identifier |
|
||||
| `timestamp` | ISO 8601 | Current ESP32 time |
|
||||
| `uptime_sec` | int | ESP32 uptime |
|
||||
| `free_heap` | int | Free heap in bytes (diagnostic) |
|
||||
|
||||
**Hub behavior:** If no heartbeat for 120 seconds, mark camera as offline (`online: false` in SSE broadcast).
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/command`
|
||||
|
||||
**Direction:** Hub → ESP32
|
||||
**QoS:** 2 | **Retain:** false
|
||||
|
||||
Commands sent from the dashboard to individual cameras.
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "start_recording",
|
||||
"request_id": "req-abc123",
|
||||
"timestamp": "2026-05-21T18:32:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Supported commands:**
|
||||
|
||||
| `command` | Description | Response topic |
|
||||
|-----------|-------------|----------------|
|
||||
| `start_recording` | Start 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) |
|
||||
|
||||
**ESP32 / ESP8266 behavior:**
|
||||
- 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
|
||||
- If command fails (GoPro unreachable), publish status with `online: false`
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/announce`
|
||||
|
||||
**Direction:** ESP32 → Hub
|
||||
**QoS:** 2 | **Retain:** true
|
||||
|
||||
Published once on ESP32 first boot (or factory reset). Used for auto-registration.
|
||||
|
||||
```json
|
||||
{
|
||||
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||
"firmware_version": "0.1.0",
|
||||
"capabilities": ["start_stop", "status"],
|
||||
"friendly_name": "ESP32-AA-BB-CC"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `mac_address` | string | ESP32 Wi-Fi MAC address |
|
||||
| `firmware_version` | string | ESP32 firmware semver |
|
||||
| `capabilities` | string[] | Supported features |
|
||||
| `friendly_name` | string | Default human-readable name |
|
||||
|
||||
**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
|
||||
2. If new MAC → insert the camera using the node's self-assigned `camera_id`
|
||||
3. 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`
|
||||
|
||||
**Direction:** Hub → All
|
||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
||||
|
||||
Hub health status broadcast.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"uptime_sec": 86400,
|
||||
"cameras_online": 3,
|
||||
"cameras_total": 4,
|
||||
"mqtt_connected": true,
|
||||
"db_size_bytes": 1048576
|
||||
}
|
||||
```
|
||||
|
||||
## Message Validation Rules
|
||||
|
||||
### Hub-side (incoming from ESP32)
|
||||
|
||||
1. **Required fields:** `camera_id` and `timestamp` must be present in all messages
|
||||
2. **Timestamp sanity:** Reject if timestamp is > 5 minutes in the future or > 24 hours in the past
|
||||
3. **Duplicate detection:** Status messages with same `(camera_id, timestamp)` are ignored (idempotent)
|
||||
4. **Schema validation:** Unknown fields are ignored (forward-compatible), missing required fields → log warning + reject
|
||||
5. **battery_pct bounds:** If present, must be 0–100. Out of range → clamp to [0,100] with warning
|
||||
|
||||
### ESP32-side (incoming from hub)
|
||||
|
||||
1. **Acknowledge commands:** After processing a command, the next status publish reflects the new state
|
||||
2. **Unknown commands:** Log and ignore
|
||||
3. **Malformed JSON:** Log error, ignore message
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
```
|
||||
ESP32 boots
|
||||
│
|
||||
├── Connects to travel router Wi-Fi
|
||||
├── Connects to MQTT broker (192.168.8.56:1883)
|
||||
├── Publishes announce (retained) on cameras/<id>/announce
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Main loop (every 30s): │
|
||||
│ 1. ESP32 requests/receives status via UART │
|
||||
│ 2. ESP8266 polls GoPro HTTP (10.5.5.1) │
|
||||
│ 3. ESP8266 returns parsed status over UART │
|
||||
│ 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 ESP8266/GoPro unreachable → publish status with online: false
|
||||
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
|
||||
│
|
||||
▼
|
||||
ESP32 shutdown / watchdog reboot
|
||||
```
|
||||
|
||||
## Data Flow: Start Recording Example
|
||||
|
||||
```
|
||||
1. User clicks "Start" on dashboard
|
||||
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"}
|
||||
4. ESP32 receives command and forwards it to ESP8266 over UART
|
||||
5. ESP8266 sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
|
||||
6. GoPro starts recording
|
||||
7. Next 30s poll: ESP8266 reports status over UART; ESP32 publishes status with recording: true
|
||||
8. Go backend receives status, updates SQLite, fans out via SSE
|
||||
9. Dashboard updates with pulsing REC indicator
|
||||
```
|
||||
|
||||
## Offline Buffering (future — CUB-230)
|
||||
|
||||
When ESP32 loses connection to travel router:
|
||||
|
||||
1. **Buffer:** Store status snapshots in SPIFFS (LittleFS), max 100 entries (~6KB)
|
||||
2. **Eviction:** FIFO — oldest dropped when buffer full
|
||||
3. **Replay:** On MQTT reconnect, publish buffered messages in chronological order with original timestamps
|
||||
4. **Dedup:** Hub ignores duplicates via `(camera_id, recorded_at)` unique constraint in status_logs
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- **Adding fields:** Safe — unknown fields ignored by both sides
|
||||
- **Removing fields:** Mark as optional first, remove in next major version
|
||||
- **Changing field types:** New topic path (e.g., `status/v2`) or new field name
|
||||
- **New topics:** Add freely — old clients ignore unknown topics
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **NTP/time sync:** How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. **Recommendation:** Pi runs NTPd, ESP32s use SNTP from `192.168.8.56`.
|
||||
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.
|
||||
@@ -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".
|
||||
@@ -0,0 +1,134 @@
|
||||
# RemoteRig — Dual-Board Camera Node Firmware
|
||||
|
||||
> **Platform:** PlatformIO (esp8266-camera + esp32-mqtt)
|
||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.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
|
||||
|
||||
```bash
|
||||
pip install platformio
|
||||
cd firmware
|
||||
|
||||
# Build both
|
||||
pio run -e esp8266-camera
|
||||
pio run -e esp32-mqtt
|
||||
|
||||
# Upload to boards (connect one at a time via USB)
|
||||
pio run -e esp8266-camera --target upload
|
||||
pio run -e esp32-mqtt --target upload
|
||||
|
||||
# Upload configs (each board needs its own)
|
||||
# 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
|
||||
|
||||
### 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 |
|
||||
|-----|---------|-------------|
|
||||
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
|
||||
| `wifi_password` | `""` | Travel router password |
|
||||
| `mqtt_broker` | `"192.168.8.56"` | Pi Zero 2 W IP |
|
||||
| `mqtt_port` | `1883` | Mosquitto port |
|
||||
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
|
||||
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
|
||||
|
||||
## Wiring
|
||||
|
||||
```
|
||||
ESP8266 D1 Mini ESP32 Dev Board
|
||||
┌────────────┐ ┌────────────┐
|
||||
│ │ │ │
|
||||
│ TX (GPIO1)│──────────→│ RX (GPIO16)│
|
||||
│ RX (GPIO3)│←──────────│ TX (GPIO17)│
|
||||
│ GND │───────────│ GND │
|
||||
│ 3.3V │ │ 3.3V │
|
||||
│ │ │ │
|
||||
└────────────┘ └────────────┘
|
||||
│ │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
LiPo → 3.3V Buck
|
||||
(shared power)
|
||||
```
|
||||
|
||||
## Boot Sequence
|
||||
|
||||
1. **ESP8266:** Connect to GoPro AP → wait for UART commands
|
||||
2. **ESP32:** Connect to travel router → connect MQTT → announce if new
|
||||
3. **ESP8266:** Poll camera every 30s → send status over UART
|
||||
4. **ESP32:** Receive status → publish MQTT
|
||||
5. **Hub → MQTT command → ESP32 → UART → ESP8266 → HTTP → GoPro**
|
||||
|
||||
## Camera Compatibility
|
||||
|
||||
| Camera | `camera_ip` | Protocol | Status |
|
||||
|--------|------------|----------|--------|
|
||||
| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support |
|
||||
| Akaso Brave 7 | `192.168.1.1` | Varies | 🔬 Set `camera_ip`, test |
|
||||
|
||||
For non-GoPro cameras: only the ESP8266 firmware needs changes — the ESP32 stays the same.
|
||||
|
||||
## LED Status (ESP8266)
|
||||
|
||||
| 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
|
||||
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| No UART communication | Verify TX→RX crossover. Both boards at 115200. Shared GND. |
|
||||
| ESP8266 can't connect | GoPro must be ON with Wi-Fi enabled. Default password: `goprohero` |
|
||||
| ESP32 can't connect MQTT | `systemctl status mosquitto` on Pi. Port 1883 open. |
|
||||
| Camera never registers | Watch ESP32 serial for "Announced" message. Check hub logs. |
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"wifi_ssid": "RemoteRig",
|
||||
"wifi_password": "RemoteRig1",
|
||||
"mqtt_broker": "192.168.8.56",
|
||||
"mqtt_port": 1883,
|
||||
"camera_id": "",
|
||||
"heartbeat_interval_sec": 60,
|
||||
"bat_raw_min": 0,
|
||||
"bat_raw_max": 0
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"camera_ssid": "goprosilver-1",
|
||||
"camera_password": "Bzyeatn421",
|
||||
"camera_ip": "10.5.5.9",
|
||||
"poll_interval_sec": 30
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
; RemoteRig — Dual-Board Camera Node Firmware
|
||||
; ============================================
|
||||
; Each camera node has TWO boards connected via UART:
|
||||
;
|
||||
; ESP8266 (Camera Bridge): Connects to GoPro AP → HTTP status/control
|
||||
; ESP32 (MQTT Bridge): Connects to travel router → MQTT to hub
|
||||
;
|
||||
; ESP8266 ←──UART──→ ESP32
|
||||
; (TX/RX) (RX16/TX17)
|
||||
;
|
||||
; Build:
|
||||
; pio run -e esp8266-camera (ESP8266 — GoPro camera bridge)
|
||||
; pio run -e seeed_xiao_esp32c6 (XIAO ESP32-C6 — MQTT bridge)
|
||||
;
|
||||
; 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 =
|
||||
knolleary/PubSubClient @ ^2.8
|
||||
bblanchon/ArduinoJson @ ^7.3
|
||||
build_flags =
|
||||
-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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ module github.com/cubecraft/remoterig
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
@@ -11,9 +12,12 @@ require (
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // 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/ncruces/go-strftime v1.0.0 // 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
|
||||
modernc.org/libc v1.72.3 // 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/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/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -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=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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.
|
||||
@@ -0,0 +1,196 @@
|
||||
# RemoteRig — Camera Node Hardware Design
|
||||
|
||||
> **Version:** 0.3.0 | **Status:** v4 CAD prototype ready for measurement/print validation
|
||||
> **Target:** GoPro Hero 3 Black/Silver + ESP32-C3 Super Mini + ESP-01S + USB power bank
|
||||
|
||||
## Overview
|
||||
|
||||
Each camera node is two ESP boards in a small upright stand-mounted case. The case **does not attach to the camera**; it straps to a tripod/lighting stand with reusable cloth zip ties / Velcro straps. Powered by a standard USB power bank.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ USB Power Bank │
|
||||
│ (off-the-shelf)│
|
||||
└────────┬────────┘
|
||||
│ USB-C cable into bottom USB-C female input
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Camera Node Case v4 │ ← Velcro/cloth straps to stand
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ Flush/recessed service lid │ │
|
||||
│ │ 1.3 OLED: CAM/REC/BAT/LINK │ │
|
||||
│ │ PWR LED + RGB STAT LED │ │
|
||||
│ │ Small rocker power switch │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ ESP-01S camera bridge ↔ ESP32-C3 │
|
||||
│ side USB-A female power output ────┼── USB cable ──→ GoPro power
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Bill of Materials
|
||||
|
||||
| Item | Qty | Cost | Notes |
|
||||
|------|-----|------|-------|
|
||||
| ESP32-C3 Super Mini | 1 | ~$4–$6 | MQTT / hub-side bridge; known board envelope 22.5 × 18 mm |
|
||||
| ESP-01S / ESP8266 module | 1 | ~$2–$3 | Camera-side GoPro Wi-Fi bridge; known envelope ~24.7 × 14.3 × 12 mm |
|
||||
| 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 |
|
||||
| 3 mm power LED | 1 | <$1 | Separate always-power/5V indicator |
|
||||
| 3 mm RGB status LED | 1 | <$1 | Replaces separate red/green status LEDs; firmware can map node states to color |
|
||||
| Small rocker switch | 1 | ~$1–$3 | On/off switch; prototype CAD assumes 13 × 19 mm snap-in opening |
|
||||
| USB-C female panel/breakout connector | 1 | ~$1–$4 | Bottom power input; prototype CAD assumes ~10.5 × 4.5 mm rounded visible opening plus shallow underside recess; measure purchased part |
|
||||
| USB-A female panel/breakout connector | 1 | ~$1–$4 | Right-side GoPro power passthrough output; prototype CAD assumes ~16 × 8 mm side opening; measure purchased part |
|
||||
| IPEX/U.FL antenna pigtail or bulkhead lead | 1 | TBD | Left-side antenna exit opposite the USB-A port; prototype CAD assumes a 5.0 mm circular through-hole plus shallow exterior recess; measure exact pigtail/bulkhead diameter before production |
|
||||
| USB power bank (5000 mAh+) | 1 | ~$10 | Powers camera node and GoPro |
|
||||
| Short USB cables / wiring | as needed | ~$2–$5 | Power bank → node USB-C input; node 5 V passthrough → USB-A female → GoPro USB cable; internal power/signal wiring |
|
||||
| M2 or small self-tapping screws | 4 | <$1 | Front service lid screws; pilot holes are parametric |
|
||||
| PETG filament | ~35–50 g | ~$1 | 3D printed case body + lid |
|
||||
| Reusable cloth zip ties / Velcro straps | 2 | ~$1 | Primary stand mount through rear vertical zip-tie brackets with lateral side-feed openings |
|
||||
|
||||
**Total per node:** roughly ~$25–$35 plus GoPro and power bank, depending on display/switch choice.
|
||||
|
||||
## 3D Printed Case
|
||||
|
||||
**Current source:** `hardware/case/camera-node-case-v4.scad`
|
||||
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
|
||||
|
||||
The current v4 CAD replaces the rejected wide/low electronics-box layout with a tall appliance-style enclosure matching the original upright reference: a clean vertical body, large inset front panel, OLED near the top, open blank middle area, two long rounded lower slots, a bottom USB-C female power input, a right-side USB-A female passthrough power port for the GoPro, and a left-side IPEX/U.FL antenna pigtail/connector hole opposite the USB-A. It also replaces the v3 clamp/dovetail concept with a simpler strap-mounted field enclosure:
|
||||
|
||||
1. **Case body** — shell sized around ESP32-C3 Super Mini + ESP-01S with service/wiring clearance.
|
||||
2. **Flush/recessed full-height front service lid** — screw-on front panel with locating lip and a restrained raised/recessed border.
|
||||
3. **Front panel controls/indicators**:
|
||||
- 1.3-inch OLED/status screen window.
|
||||
- 3 mm **PWR** LED.
|
||||
- single 3 mm **RGB STAT** LED for state-dependent colors.
|
||||
- small rectangular rocker switch cutout.
|
||||
- two long rounded lower front slots styled after the reference appliance face.
|
||||
4. **Rear vertical zip-tie pass-through brackets** — two low-profile external brackets, one left and one right of center, with top/bottom anchor pads and long vertical side-access openings. Zip ties feed laterally in the X direction behind each raised bridge face instead of top-to-bottom, while the rear wall stays sealed.
|
||||
5. **USB power ports** — bottom USB-C female power input and right-side USB-A female passthrough power output for a GoPro USB power cable.
|
||||
6. **Left-side antenna exit** — prototype 5.0 mm round through-wall IPEX/U.FL antenna pigtail/connector clearance, placed opposite the right-side USB-A port at the same vertical position, with a shallow exterior circular recess for visual/exit relief. Measure the actual antenna pigtail/bulkhead before production.
|
||||
|
||||
### Export wrappers
|
||||
|
||||
Simple per-part OpenSCAD wrappers are included:
|
||||
|
||||
- `hardware/case/camera-node-case-v4-body.scad`
|
||||
- `hardware/case/camera-node-case-v4-lid.scad`
|
||||
- `hardware/case/camera-node-case-v4-preview.scad`
|
||||
- `hardware/case/camera-node-case-v4-front-review.scad`
|
||||
|
||||
Example CLI exports, if OpenSCAD is installed:
|
||||
|
||||
```bash
|
||||
openscad -o hardware/case/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4-body.scad
|
||||
openscad -o hardware/case/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4-lid.scad
|
||||
openscad -o hardware/case/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4-preview.scad
|
||||
openscad -o hardware/case/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4-front-review.scad
|
||||
```
|
||||
|
||||
Or render the main file directly:
|
||||
|
||||
```bash
|
||||
openscad -D 'part="body"' -o hardware/case/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4.scad
|
||||
openscad -D 'part="lid"' -o hardware/case/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4.scad
|
||||
openscad -D 'part="preview"' -o hardware/case/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4.scad
|
||||
openscad -D 'part="front_review"' -o hardware/case/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4.scad
|
||||
```
|
||||
|
||||
`camera-node-case-v4-preview.stl` is the seated fit-check assembly. `camera-node-case-v4-front-review.stl` is a non-print review layout with the body and front panel separated/angled so the OLED, LED, rocker, USB connector, and lower-slot cutouts are obvious in a slicer.
|
||||
|
||||
### Print Settings
|
||||
|
||||
- **Material:** PETG preferred for heat/outdoor use and strap-tab durability.
|
||||
- **Layer:** 0.2 mm typical.
|
||||
- **Infill:** 20% gyroid minimum; 30%+ recommended around rear vertical zip-tie bracket anchor pads/bridges.
|
||||
- **Supports:** likely minimal/none depending on orientation; verify the rear lateral side-feed openings remain open and check USB-C/USB-A port cutouts in slicer.
|
||||
- **Post-processing:** fit 4 lid screws; deburr OLED/LED/switch and IPEX antenna exit cutouts; clear any stringing inside the rear side-feed openings; soften strap-contact edges if the printed radius is too sharp for cloth ties.
|
||||
|
||||
## Expected Status Screen Content
|
||||
|
||||
Preferred 1.3-inch OLED layout/content style:
|
||||
|
||||
```text
|
||||
CAM 03 REC ●
|
||||
BAT 87% LINK OK
|
||||
00:12:34
|
||||
```
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `CAM` / node ID.
|
||||
- `REC` state with a clear recording indicator.
|
||||
- Battery percentage or supply estimate.
|
||||
- `LINK OK` / degraded / disconnected state.
|
||||
- Recording/session timer.
|
||||
|
||||
## Wiring
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**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
|
||||
│
|
||||
UART │ (inside case)
|
||||
│
|
||||
Travel Router ──(AP)────────────────────→ ESP32-C3 MQTT bridge
|
||||
(192.168.8.1) │
|
||||
│
|
||||
MQTT │
|
||||
▼
|
||||
Pi Hub (192.168.8.56)
|
||||
```
|
||||
|
||||
The ESP8266/ESP-01S and GoPro talk over Wi-Fi. The only cable to the GoPro is USB power from the case side USB-A passthrough port.
|
||||
|
||||
## Field Setup
|
||||
|
||||
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.
|
||||
3. Strap the case to a tripod/stand leg; use both strap paths to resist rotation.
|
||||
4. Connect the power bank to the case bottom USB-C input; connect the GoPro USB power cable to the case side USB-A passthrough output.
|
||||
5. Toggle rocker switch on.
|
||||
6. Verify PWR LED, RGB status LED, and OLED status: camera ID, REC state, battery, link, timer.
|
||||
7. Monitor from `http://192.168.8.56:8080`.
|
||||
|
||||
## Case Dimensions
|
||||
|
||||
Prototype v4 nominal CAD dimensions:
|
||||
|
||||
| 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 |
|
||||
|
||||
These dimensions are placeholders for the first CAD prototype. Measure the actual OLED module, rocker switch, LEDs, screws, USB-C/USB-A connector flanges and body depths, USB cable bend radius, and strap width/thickness before committing to production prints.
|
||||
@@ -0,0 +1,4 @@
|
||||
// Export wrapper for RemoteRig camera node case v4 body.
|
||||
use <camera-node-case-v4.scad>
|
||||
|
||||
camera_node_body_v4();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
// Export wrapper for RemoteRig camera node case v4 front-facing review layout.
|
||||
use <camera-node-case-v4.scad>
|
||||
|
||||
camera_node_front_review_v4();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
// Export wrapper for RemoteRig camera node case v4 front service lid/status panel.
|
||||
use <camera-node-case-v4.scad>
|
||||
|
||||
camera_node_lid_v4();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
// Export wrapper for RemoteRig camera node case v4 assembly preview.
|
||||
use <camera-node-case-v4.scad>
|
||||
|
||||
camera_node_preview_v4();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
// RemoteRig camera node case v4
|
||||
// Upright appliance-style OpenSCAD prototype for a strap-mounted camera node.
|
||||
// Units: millimeters. Coordinate system: X=width, Y=depth/front-back, Z=height.
|
||||
// Front/service lid is on the -Y face. Rear side-feed zip-tie brackets are on the +Y face.
|
||||
//
|
||||
// v4 visual direction: tall/upright appliance/control box matching the original
|
||||
// reference image, replacing the rejected wide, low generic electronics box.
|
||||
// Nominal body: 56 W x 36 D x 82 H mm; with low rear zip-tie loops ~41 D.
|
||||
//
|
||||
// Prototype assumptions to confirm against purchased parts:
|
||||
// - 1.3 inch OLED module/window opening: 31 x 16 mm visible window, 37 x 22 mm panel recess.
|
||||
// - Small rocker switch cutout: 13 x 19 mm rectangular snap-in opening.
|
||||
// - LEDs: two 3 mm panel LEDs (PWR + RGB STAT) with 3.2 mm holes.
|
||||
// - Boards: ESP32-C3 Super Mini 22.5 x 18 mm, ESP-01S 24.7 x 14.3 x 12 mm.
|
||||
// - USB-C bottom power inlet and side USB-A passthrough are panel/breakout placeholders;
|
||||
// measure purchased connector flanges/bodies before production prints.
|
||||
// - Left-side IPEX/U.FL antenna pigtail connector/lead hole is a prototype 5.0 mm
|
||||
// circular through-wall clearance; measure the final bulkhead/lead before production.
|
||||
|
||||
$fn = 56;
|
||||
|
||||
// ----- Main enclosure parameters -----
|
||||
case_w = 56; // upright appliance-style external width
|
||||
case_d = 36; // depth for module stack + wiring clearance
|
||||
case_h = 82; // tall vertical appliance-style height
|
||||
wall = 2.2;
|
||||
corner_r = 4.0;
|
||||
front_recess_d = 2.0; // lid sits in this front pocket, nominally flush
|
||||
lid_clearance = 0.35;
|
||||
lid_w = case_w - 8; // nearly full-height/front-width inset panel
|
||||
lid_h = case_h - 8;
|
||||
lid_t = 2.0;
|
||||
lid_lip_t = 1.2; // locating lip protrudes inside service opening
|
||||
service_opening_w = lid_w - 10.0;
|
||||
service_opening_h = lid_h - 16.0;
|
||||
|
||||
// Hardware
|
||||
screw_d = 2.4; // M2 self-tapping / pilot; confirm hardware
|
||||
screw_head_d = 4.6;
|
||||
boss_d = 6.0;
|
||||
boss_len = 8.0;
|
||||
|
||||
// Front panel components
|
||||
oled_window_w = 31.0;
|
||||
oled_window_h = 16.0;
|
||||
oled_bezel_w = 37.0; // shallow recessed visual outline around window
|
||||
oled_bezel_h = 22.0;
|
||||
oled_z = 53.0; // upper third, clear of top screw counterbores
|
||||
led_hole_d = 3.2; // 3 mm LED clearance
|
||||
rocker_w = 13.0; // prototype cutout; measure purchased rocker
|
||||
rocker_h = 19.0;
|
||||
front_slot_w = 34.0; // two long rounded horizontal slots near lower front
|
||||
front_slot_h = 3.2;
|
||||
|
||||
// Rear reusable cloth zip-tie / Velcro pass-through brackets.
|
||||
// Two visibly vertical external brackets sit left/right of center.
|
||||
// The strap path is a lateral X-direction tunnel between the sealed rear wall
|
||||
// and raised bridge face; long side windows stay open for feeding from either side.
|
||||
rear_loop_x = 13.0;
|
||||
rear_loop_w = 8.5; // outside bracket width in X
|
||||
rear_loop_h = 50.0; // outside bracket height in Z
|
||||
rear_loop_z = case_h/2;
|
||||
rear_loop_gap_y = 3.8; // usable strap-thickness clearance behind raised bridge
|
||||
rear_loop_face_t = 1.4; // low-profile outer bridge skin
|
||||
rear_loop_y = rear_loop_gap_y + rear_loop_face_t;
|
||||
rear_loop_anchor_h = 5.0; // top/bottom weld pads; side window remains long vertically
|
||||
rear_loop_side_window_h = rear_loop_h - 2*rear_loop_anchor_h;
|
||||
|
||||
// USB power connector placeholder cutouts
|
||||
usb_c_cutout_w = 10.5; // bottom USB-C female inlet visible opening, X width
|
||||
usb_c_cutout_d = 4.5; // bottom USB-C female inlet visible opening, Y/front-back
|
||||
usb_c_recess_w = 18.0; // shallow underside panel-mount/breakout recess
|
||||
usb_c_recess_d = 10.0;
|
||||
usb_c_y = -7.5; // close to front/service side but clear of screw bosses/lower slots
|
||||
usb_a_cutout_d = 16.0; // side USB-A female opening, Y/front-back dimension
|
||||
usb_a_cutout_h = 8.0; // side USB-A female opening, Z height
|
||||
usb_a_z = 26.0; // mid/lower right side, clear of front lid screws/strap bridges
|
||||
usb_a_y = 2.0;
|
||||
|
||||
// Left-side antenna lead / IPEX-U.FL pigtail connector placeholder.
|
||||
// Opposite the right-side USB-A port and cut fully through the left wall into the cavity.
|
||||
ipex_hole_d = 5.0; // prototype circular clearance; measure final pigtail/bulkhead
|
||||
ipex_recess_d = 8.5; // shallow exterior visual/seat recess, not retention geometry
|
||||
ipex_recess_depth = 0.9;
|
||||
ipex_z = usb_a_z;
|
||||
ipex_y = usb_a_y;
|
||||
|
||||
// ----- Utility geometry -----
|
||||
module rounded_box(size=[10,10,10], r=2, center_xy=true) {
|
||||
// Rounded in XY, straight in Z.
|
||||
linear_extrude(height=size[2])
|
||||
offset(r=r)
|
||||
square([size[0]-2*r, size[1]-2*r], center=center_xy);
|
||||
}
|
||||
|
||||
module xz_rounded_prism(w, d, h, r=2) {
|
||||
// Rounded rectangle in the visible X/Z plane, extruded through Y.
|
||||
rotate([-90,0,0])
|
||||
linear_extrude(height=d, center=true)
|
||||
offset(r=r)
|
||||
square([w-2*r, h-2*r], center=true);
|
||||
}
|
||||
|
||||
module yz_rounded_prism(d, x, h, r=2) {
|
||||
// Rounded rectangle in the visible Y/Z plane, extruded through X.
|
||||
// First argument maps to global Y, third argument maps to global Z.
|
||||
rotate([0,90,0])
|
||||
linear_extrude(height=x, center=true)
|
||||
offset(r=r)
|
||||
square([h-2*r, d-2*r], center=true);
|
||||
}
|
||||
|
||||
module y_cylinder(d, h, center=true) {
|
||||
rotate([90,0,0]) cylinder(d=d, h=h, center=center);
|
||||
}
|
||||
|
||||
module x_cylinder(d, h, center=true) {
|
||||
rotate([0,90,0]) cylinder(d=d, h=h, center=center);
|
||||
}
|
||||
|
||||
module screw_boss(x, z) {
|
||||
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
|
||||
difference() {
|
||||
y_cylinder(d=boss_d, h=boss_len);
|
||||
y_cylinder(d=screw_d, h=boss_len + 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
module rear_zip_tie_loop(xc) {
|
||||
// Vertical external belt-loop bracket for reusable cloth zip ties/Velcro.
|
||||
// The bracket silhouette remains vertical, but the real strap tunnel runs
|
||||
// laterally in X through the long side windows, behind the raised bridge face.
|
||||
// Top/bottom pads weld the bridge to the shell; no cut reaches the rear wall.
|
||||
loop_overlap_y = 0.75;
|
||||
pad_r = 1.15;
|
||||
bridge_y_center = case_d/2 + rear_loop_gap_y + rear_loop_face_t/2;
|
||||
pad_y_center = case_d/2 + rear_loop_y/2 - loop_overlap_y;
|
||||
pad_z_offset = rear_loop_h/2 - rear_loop_anchor_h/2;
|
||||
|
||||
union() {
|
||||
// Raised vertical bridge face: visually preserves the requested vertical
|
||||
// rear brackets while spanning the side-feed tunnel externally.
|
||||
translate([xc, bridge_y_center, rear_loop_z])
|
||||
xz_rounded_prism(rear_loop_w, rear_loop_face_t, rear_loop_h, r=1.6);
|
||||
|
||||
// Top and bottom anchor pads close the old top-to-bottom feed direction
|
||||
// and tie the raised face back into the rear wall without opening the case.
|
||||
for (zoff = [-pad_z_offset, pad_z_offset])
|
||||
translate([xc, pad_y_center, rear_loop_z + zoff])
|
||||
xz_rounded_prism(rear_loop_w, rear_loop_y, rear_loop_anchor_h, r=pad_r);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Printable body -----
|
||||
module camera_node_body_v4() {
|
||||
difference() {
|
||||
union() {
|
||||
difference() {
|
||||
union() {
|
||||
// Upright outer shell with softened appliance-like corners.
|
||||
rounded_box([case_w, case_d, case_h], r=corner_r);
|
||||
|
||||
// Rear cloth zip-tie / Velcro side-feed brackets kept flat/quiet.
|
||||
rear_zip_tie_loop(-rear_loop_x);
|
||||
rear_zip_tie_loop( rear_loop_x);
|
||||
}
|
||||
|
||||
// Full-height front recessed lid pocket, like the green reference panel.
|
||||
translate([0, -case_d/2 + front_recess_d/2, case_h/2])
|
||||
cube([lid_w + lid_clearance, front_recess_d + 0.4, lid_h + lid_clearance], center=true);
|
||||
|
||||
// Through service opening behind the lid, leaving a strong inset frame.
|
||||
service_depth = front_recess_d + wall + 2.0;
|
||||
translate([0, -case_d/2 + service_depth/2, case_h/2])
|
||||
xz_rounded_prism(service_opening_w, service_depth + 0.4, service_opening_h, r=2.0);
|
||||
|
||||
// Interior electronics cavity: ESP32-C3 Super Mini + ESP-01S plus wiring/service clearance.
|
||||
cavity_d = case_d - front_recess_d - 2*wall;
|
||||
translate([0, -case_d/2 + front_recess_d + wall + cavity_d/2, case_h/2])
|
||||
cube([case_w - 2*wall, cavity_d, case_h - 2*wall], center=true);
|
||||
|
||||
// Bottom USB-C female power inlet: shallow underside recess plus
|
||||
// rounded through-slot for a flush/panel-mount breakout placeholder.
|
||||
translate([0, usb_c_y, -0.35])
|
||||
rounded_box([usb_c_recess_w, usb_c_recess_d, 0.9], r=1.5);
|
||||
translate([0, usb_c_y, -0.2])
|
||||
rounded_box([usb_c_cutout_w, usb_c_cutout_d, wall + 1.2], r=1.6);
|
||||
|
||||
// Right-side USB-A female passthrough power port for the GoPro.
|
||||
translate([case_w/2 - 0.10, usb_a_y, usb_a_z])
|
||||
yz_rounded_prism(usb_a_cutout_d, wall + 2.8, usb_a_cutout_h, r=0.9);
|
||||
|
||||
// Left-side IPEX/U.FL antenna pigtail connector/lead clearance.
|
||||
// Through-hole intentionally extends past the inner wall so it opens to the cavity.
|
||||
translate([-case_w/2 - 0.10, ipex_y, ipex_z])
|
||||
x_cylinder(d=ipex_hole_d, h=wall + 3.0);
|
||||
// Shallow exterior circular recess marks/relieves the antenna exit area.
|
||||
translate([-case_w/2 + ipex_recess_depth/2 - 0.05, ipex_y, ipex_z])
|
||||
x_cylinder(d=ipex_recess_d, h=ipex_recess_depth + 0.2);
|
||||
}
|
||||
|
||||
// Four protected screw bosses are added after shell hollowing so the
|
||||
// electronics cavity cannot cut away the receiving material.
|
||||
screw_x = lid_w/2 - 5.0;
|
||||
screw_z_low = (case_h - lid_h)/2 + 5.0;
|
||||
screw_z_high = case_h - screw_z_low;
|
||||
screw_boss(-screw_x, screw_z_low);
|
||||
screw_boss( screw_x, screw_z_low);
|
||||
screw_boss(-screw_x, screw_z_high);
|
||||
screw_boss( screw_x, screw_z_high);
|
||||
}
|
||||
|
||||
// Final body-level pilot holes cut through the front frame into the protected bosses.
|
||||
screw_x = lid_w/2 - 5.0;
|
||||
screw_z_low = (case_h - lid_h)/2 + 5.0;
|
||||
screw_z_high = case_h - screw_z_low;
|
||||
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high])
|
||||
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
|
||||
y_cylinder(d=screw_d, h=boss_len + front_recess_d + 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Printable front service lid / status panel -----
|
||||
module camera_node_lid_v4() {
|
||||
panel_through_d = lid_t + lid_lip_t + 2.4;
|
||||
panel_through_y = 0.25;
|
||||
|
||||
difference() {
|
||||
union() {
|
||||
// Visible full-height flush panel; restrained and not a busy slab.
|
||||
rounded_box([lid_w, lid_t, lid_h], r=0.65);
|
||||
|
||||
// Rear locating lip fits inside the large service opening.
|
||||
translate([0, lid_t/2 + lid_lip_t/2 - 0.2, lid_h/2])
|
||||
xz_rounded_prism(service_opening_w - 0.8, lid_lip_t, service_opening_h - 0.8, r=1.5);
|
||||
}
|
||||
|
||||
// OLED window and shallow black-bezel-style recess near the top.
|
||||
translate([0, -lid_t/2 + 0.35, oled_z])
|
||||
xz_rounded_prism(oled_bezel_w, 0.9, oled_bezel_h, r=1.3);
|
||||
translate([0, panel_through_y, oled_z])
|
||||
xz_rounded_prism(oled_window_w, panel_through_d, oled_window_h, r=0.5);
|
||||
|
||||
// Subtle secondary indicators flanking the rocker, below the OLED bezel.
|
||||
translate([-15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
|
||||
translate([ 15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
|
||||
|
||||
// Small rocker lower on the panel, offset away from the OLED, screws, and slots.
|
||||
translate([0, panel_through_y, 33.0])
|
||||
xz_rounded_prism(rocker_w, panel_through_d, rocker_h, r=0.8);
|
||||
|
||||
// Two long rounded horizontal slots near the lower front, matching the reference.
|
||||
translate([0, panel_through_y, 17.0])
|
||||
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
|
||||
translate([0, panel_through_y, 11.0])
|
||||
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
|
||||
|
||||
// Screw clearance/counterbore holes.
|
||||
screw_x = lid_w/2 - 5.0;
|
||||
screw_z_low = 5.0;
|
||||
screw_z_high = lid_h - screw_z_low;
|
||||
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high]) {
|
||||
translate([x, panel_through_y, z]) y_cylinder(d=screw_d + 0.4, h=panel_through_d);
|
||||
translate([x, -lid_t/2 + 0.55, z]) y_cylinder(d=screw_head_d, h=1.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Non-print preview assembly -----
|
||||
module camera_node_preview_v4(show_lid=true) {
|
||||
color("lightgray") camera_node_body_v4();
|
||||
if (show_lid)
|
||||
translate([0, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
|
||||
color("gainsboro") camera_node_lid_v4();
|
||||
|
||||
// Dark OLED bezel/window cue for visual review only (not part of exported lid STL when rendering lid).
|
||||
if (show_lid)
|
||||
translate([0, -case_d/2 - 0.08, (case_h - lid_h)/2 + oled_z])
|
||||
color("black") xz_rounded_prism(oled_bezel_w, 0.6, oled_bezel_h, r=1.3);
|
||||
|
||||
// Internal board/connector volume guides (not printed): ESP modules and USB connector envelopes.
|
||||
color([0,0.45,0,0.35]) translate([-9, -1, 26]) cube([22.5, 18, 4], center=true);
|
||||
color([0,0.2,0.8,0.35]) translate([9, -1, 45]) cube([24.7, 14.3, 12], center=true);
|
||||
color([0.1,0.1,0.1,0.35]) translate([0, usb_c_y, 3.8]) cube([16, 9, 5], center=true);
|
||||
color([0.1,0.1,0.1,0.35]) translate([case_w/2 - 5.5, usb_a_y, usb_a_z]) cube([11, usb_a_cutout_d + 2, usb_a_cutout_h + 2], center=true);
|
||||
color([0.9,0.7,0.1,0.45]) translate([-case_w/2 - 1.8, ipex_y, ipex_z]) x_cylinder(d=ipex_hole_d, h=8.0);
|
||||
}
|
||||
|
||||
// Non-print review layout: separates the body and front lid while keeping both
|
||||
// front faces oriented toward -Y. Use this STL when checking that the screen,
|
||||
// LED, rocker, USB connector, and lower-slot cutouts are visible in a slicer.
|
||||
module camera_node_front_review_v4() {
|
||||
translate([-34, 0, 0]) rotate([0,0,-18]) color("lightgray") camera_node_body_v4();
|
||||
translate([34, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
|
||||
color("gainsboro") camera_node_lid_v4();
|
||||
}
|
||||
|
||||
// Select part to render from OpenSCAD CLI with: -D 'part="body"'
|
||||
part = "preview"; // "body", "lid", "preview", or "front_review"
|
||||
|
||||
if (part == "body") {
|
||||
camera_node_body_v4();
|
||||
} else if (part == "lid") {
|
||||
camera_node_lid_v4();
|
||||
} else if (part == "front_review") {
|
||||
camera_node_front_review_v4();
|
||||
} else {
|
||||
camera_node_preview_v4();
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cubecraft/remoterig/internal/db"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// setupTestRouter creates a test router backed by a temp file database so
|
||||
// pooled connections all see the same data.
|
||||
func setupTestRouter(t *testing.T) (*db.DB, chi.Router) {
|
||||
t.Helper()
|
||||
database, err := db.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test db: %v", err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/cameras", ListCameras(database))
|
||||
r.Post("/cameras", RegisterCamera(database))
|
||||
r.Get("/cameras/{id}", GetCameraDetail(database))
|
||||
r.Post("/cameras/{id}/start", StartRecording(database, 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)
|
||||
}
|
||||
}
|
||||
+27
-20
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/cubecraft/remoterig/internal/db"
|
||||
"github.com/cubecraft/remoterig/pkg/models"
|
||||
@@ -25,11 +26,11 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
||||
c.friendly_name,
|
||||
s.battery_pct,
|
||||
s.video_remaining_sec,
|
||||
s.recording_state,
|
||||
COALESCE(s.recording_state, 0),
|
||||
s.mode,
|
||||
s.resolution,
|
||||
s.fps,
|
||||
s.online,
|
||||
COALESCE(s.online, 0),
|
||||
s.recorded_at
|
||||
FROM cameras c
|
||||
LEFT JOIN (
|
||||
@@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Error querying cameras: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -51,20 +52,22 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var sl models.StatusLog
|
||||
var c models.Camera
|
||||
var recordedAt sql.NullTime // NULL for a camera with no status yet
|
||||
if err := rows.Scan(
|
||||
&c.CameraID, &c.FriendlyName,
|
||||
&sl.BatteryPct, &sl.VideoRemainingSec,
|
||||
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
|
||||
&sl.Online, &sl.RecordedAt,
|
||||
&sl.Online, &recordedAt,
|
||||
); err != nil {
|
||||
log.Printf("Error scanning camera row: %v", err)
|
||||
continue
|
||||
}
|
||||
sl.RecordedAt = recordedAt.Time // zero time if no status
|
||||
statuses = append(statuses, models.NewCameraStatus(c, sl))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Error iterating camera rows: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -84,13 +87,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
MacAddress *string `json:"mac_address,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
if !decodeJSONBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.CameraID == "" || req.FriendlyName == "" {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
|
||||
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,12 +99,12 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
||||
VALUES (?, ?, ?)
|
||||
`, req.CameraID, req.FriendlyName, req.MacAddress)
|
||||
if err != nil {
|
||||
if err.Error() == "UNIQUE constraint failed: cameras.mac_address" {
|
||||
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"})
|
||||
if isUniqueConstraintErr(err) {
|
||||
respondError(w, http.StatusConflict, "camera already registered", err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("Error registering camera: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -124,8 +124,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
|
||||
func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cameraID := chi.URLParam(r, "id")
|
||||
if cameraID == "" {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
||||
if !validateCameraID(w, cameraID) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,12 +138,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||
&c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"})
|
||||
respondError(w, http.StatusNotFound, "camera not found", err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error querying camera: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,7 +164,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||
)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Printf("Error querying latest status: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||
`, cameraID)
|
||||
if err != nil {
|
||||
log.Printf("Error querying history: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
defer historyRows.Close()
|
||||
@@ -203,13 +202,21 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"camera": c,
|
||||
"camera": c,
|
||||
"last_status": sl,
|
||||
"history": history,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation.
|
||||
func isUniqueConstraintErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
// respondJSON writes a JSON response with the given status code.
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
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"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func StartRecording(database *db.DB) http.HandlerFunc {
|
||||
func StartRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cameraID := chi.URLParam(r, "id")
|
||||
if cameraID == "" {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
||||
if !validateCameraID(w, cameraID) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,8 +27,13 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
||||
var exists int
|
||||
err := database.QueryRowContext(r.Context(),
|
||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||
if err != nil || exists == 0 {
|
||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
||||
if err != nil {
|
||||
log.Printf("Error checking camera existence: %v", err)
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
if exists == 0 {
|
||||
respondError(w, http.StatusNotFound, "camera not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,12 +44,21 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
||||
`, cameraID)
|
||||
if err != nil {
|
||||
log.Printf("Error starting recording: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows)
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
|
||||
|
||||
// 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{
|
||||
"status": "recording_started",
|
||||
@@ -49,11 +68,10 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cameraID := chi.URLParam(r, "id")
|
||||
if cameraID == "" {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
||||
if !validateCameraID(w, cameraID) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,8 +79,13 @@ func StopRecording(database *db.DB) http.HandlerFunc {
|
||||
var exists int
|
||||
err := database.QueryRowContext(r.Context(),
|
||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||
if err != nil || exists == 0 {
|
||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
||||
if err != nil {
|
||||
log.Printf("Error checking camera existence: %v", err)
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
if exists == 0 {
|
||||
respondError(w, http.StatusNotFound, "camera not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,12 +96,21 @@ func StopRecording(database *db.DB) http.HandlerFunc {
|
||||
`, cameraID)
|
||||
if err != nil {
|
||||
log.Printf("Error stopping recording: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows)
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
|
||||
|
||||
// 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{
|
||||
"status": "recording_stopped",
|
||||
|
||||
+28
-17
@@ -2,7 +2,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
@@ -14,24 +13,31 @@ import (
|
||||
func PushStatus(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cameraID := chi.URLParam(r, "id")
|
||||
if cameraID == "" {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
|
||||
if !validateCameraID(w, cameraID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
BatteryPct *int `json:"battery_pct"`
|
||||
VideoRemainingSec *int `json:"video_remaining_sec"`
|
||||
Recording bool `json:"recording"`
|
||||
Mode string `json:"mode"`
|
||||
Resolution string `json:"resolution"`
|
||||
FPS int `json:"fps"`
|
||||
Online bool `json:"online"`
|
||||
RawBatteryPct *float64 `json:"raw_battery_pct"`
|
||||
Timestamp *string `json:"ts"`
|
||||
BatteryPct *int `json:"battery_pct"`
|
||||
VideoRemainingSec *int `json:"video_remaining_sec"`
|
||||
Recording bool `json:"recording"`
|
||||
Mode string `json:"mode"`
|
||||
Resolution string `json:"resolution"`
|
||||
FPS int `json:"fps"`
|
||||
Online bool `json:"online"`
|
||||
RawBatteryPct *float64 `json:"raw_battery_pct"`
|
||||
Timestamp *string `json:"ts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
if !decodeJSONBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !validateStatusFields(w, req.Mode, req.Resolution, req.FPS) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate battery percentage range if provided
|
||||
if req.BatteryPct != nil && (*req.BatteryPct < 0 || *req.BatteryPct > 100) {
|
||||
respondError(w, http.StatusBadRequest, "battery_pct must be between 0 and 100")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
||||
var exists int
|
||||
err := database.QueryRowContext(r.Context(),
|
||||
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
|
||||
if err != nil || exists == 0 {
|
||||
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
|
||||
if err != nil {
|
||||
log.Printf("Error checking camera existence: %v", err)
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
if exists == 0 {
|
||||
respondError(w, http.StatusNotFound, "camera not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc {
|
||||
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
|
||||
if err != nil {
|
||||
log.Printf("Error inserting status log: %v", err)
|
||||
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
|
||||
respondError(w, http.StatusInternalServerError, "database error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+75
-30
@@ -4,9 +4,11 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -14,13 +16,16 @@ import (
|
||||
//go:embed migrations/001_create_tables.sql
|
||||
var migration001 string
|
||||
|
||||
//go:embed migrations/002_dedup_unique_index.sql
|
||||
var migration002 string
|
||||
|
||||
// DB wraps the sql.DB with connection-level settings.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open opens the SQLite database at the given path, enables WAL mode,
|
||||
// and runs all migrations if the tables don't exist yet.
|
||||
// and runs all migrations using a schema_version table for tracking.
|
||||
func Open(path string) (*DB, error) {
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(path)
|
||||
@@ -45,34 +50,57 @@ func Open(path string) (*DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if tables already exist (idempotent migration)
|
||||
var count int
|
||||
if err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings')
|
||||
`).Scan(&count); err != nil {
|
||||
// Ensure schema_version table exists for migration tracking
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if count < 4 {
|
||||
log.Printf("Running migrations for %s...", path)
|
||||
if err := migrate(db, migration001); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
// Read current schema version (0 if table is empty)
|
||||
var currentVersion int
|
||||
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤tVersion); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Migration definitions: ordered list of (version, sql)
|
||||
type migration struct {
|
||||
version int
|
||||
sql string
|
||||
}
|
||||
migrations := []migration{
|
||||
{1, migration001},
|
||||
{2, migration002},
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if currentVersion >= m.version {
|
||||
continue
|
||||
}
|
||||
log.Printf("Running migration %d for %s...", m.version, path)
|
||||
if err := migrate(db, m.sql); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migration %d: %w", m.version, err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("record migration %d: %w", m.version, err)
|
||||
}
|
||||
log.Printf("Migration %d complete", m.version)
|
||||
}
|
||||
|
||||
if currentVersion < len(migrations) {
|
||||
log.Println("Migrations complete")
|
||||
}
|
||||
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
// migrate executes a SQL migration string.
|
||||
// migrate executes a SQL migration string by splitting on semicolons.
|
||||
func migrate(db *sql.DB, sql string) error {
|
||||
// Split on semicolons to handle multiple statements
|
||||
statements := splitSQL(sql)
|
||||
for _, stmt := range statements {
|
||||
stmt = stripWhitespace(stmt)
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
@@ -83,8 +111,13 @@ func migrate(db *sql.DB, sql string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
|
||||
// splitSQL splits a SQL string on semicolons, respecting quoted strings
|
||||
// and stripping SQL line comments (--).
|
||||
func splitSQL(sql string) []string {
|
||||
// First, strip all line comments (--) to prevent them from swallowing
|
||||
// subsequent SQL statements when newlines are collapsed.
|
||||
sql = stripSQLLineComments(sql)
|
||||
|
||||
var stmts []string
|
||||
var current string
|
||||
inQuote := false
|
||||
@@ -106,30 +139,42 @@ func splitSQL(sql string) []string {
|
||||
case ';':
|
||||
stmts = append(stmts, current)
|
||||
current = ""
|
||||
case '\r', '\n', '\t':
|
||||
current += " "
|
||||
default:
|
||||
current += string(r)
|
||||
}
|
||||
}
|
||||
if len(current) > 0 {
|
||||
if strings.TrimSpace(current) != "" {
|
||||
stmts = append(stmts, current)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
// stripWhitespace removes leading/trailing whitespace and normalizes newlines.
|
||||
func stripWhitespace(s string) string {
|
||||
result := ""
|
||||
runningSpace := false
|
||||
for _, r := range s {
|
||||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||||
if !runningSpace {
|
||||
result += " "
|
||||
runningSpace = true
|
||||
// stripSQLLineComments removes all -- single-line comments from SQL text.
|
||||
func stripSQLLineComments(sql string) string {
|
||||
var result strings.Builder
|
||||
i := 0
|
||||
runes := []rune(sql)
|
||||
|
||||
for i < len(runes) {
|
||||
r := runes[i]
|
||||
|
||||
// Check for -- comment start
|
||||
if r == '-' && i+1 < len(runes) && runes[i+1] == '-' {
|
||||
// Skip to end of line
|
||||
i += 2
|
||||
for i < len(runes) && runes[i] != '\n' && runes[i] != '\r' {
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
result += string(r)
|
||||
runningSpace = false
|
||||
// Replace comment with a newline (preserves statement boundaries)
|
||||
result.WriteRune('\n')
|
||||
continue
|
||||
}
|
||||
|
||||
result.WriteRune(r)
|
||||
i++
|
||||
}
|
||||
return result
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: 002_dedup_unique_index
|
||||
-- Add a UNIQUE index on (camera_id, recorded_at) to enforce hub-side
|
||||
-- deduplication for ESP32 offline status replay (CUB-239).
|
||||
-- This prevents race-condition double-inserts that a pure SELECT COUNT(*)
|
||||
-- check cannot guard against.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_status_logs_unique_entry
|
||||
ON status_logs(camera_id, recorded_at);
|
||||
+31
-6
@@ -7,13 +7,15 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Hub manages SSE client connections and event broadcasting.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
eventSeq atomic.Int64 // monotonic event ID for Last-Event-ID
|
||||
}
|
||||
|
||||
// NewHub creates a new SSE hub.
|
||||
@@ -58,6 +60,13 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Last-Event-ID")
|
||||
|
||||
// Handle preflight
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Get flusher
|
||||
flusher, ok := w.(http.Flusher)
|
||||
@@ -85,12 +94,21 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||
client.Close()
|
||||
}()
|
||||
|
||||
// Acknowledge Last-Event-ID if sent by client on reconnect
|
||||
if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" {
|
||||
fmt.Fprintf(w, "id: %s\nevent: reconnected\ndata: {\"type\":\"reconnected\",\"last_event_id\":\"%s\"}\n\n", lastEventID, lastEventID)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
data, _ := json.Marshal(map[string]string{
|
||||
seq := h.eventSeq.Add(1)
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "connected",
|
||||
"id": seq,
|
||||
"ts": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
if !client.Write(data) {
|
||||
eventLine := fmt.Sprintf("id: %d\nevent: connected\ndata: %s\n\n", seq, string(data))
|
||||
if !client.Write([]byte(eventLine)) {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
@@ -115,13 +133,18 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends an event to all connected clients.
|
||||
// Broadcast sends a typed SSE event to all connected clients.
|
||||
// eventType becomes the "event:" field, enabling client-side filtering.
|
||||
// Each event gets a monotonic ID for Last-Event-ID reconnection support.
|
||||
func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
seq := h.eventSeq.Add(1)
|
||||
|
||||
event := map[string]interface{}{
|
||||
"type": eventType,
|
||||
"id": seq,
|
||||
"ts": time.Now().Format(time.RFC3339),
|
||||
"payload": payload,
|
||||
}
|
||||
@@ -132,8 +155,10 @@ func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
||||
return
|
||||
}
|
||||
|
||||
eventLine := fmt.Sprintf("id: %d\nevent: %s\ndata: %s\n\n", seq, eventType, string(data))
|
||||
|
||||
for client := range h.clients {
|
||||
if !client.Write(data) {
|
||||
if !client.Write([]byte(eventLine)) {
|
||||
log.Println("SSE client buffer full, dropping event")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
// Package mqtt provides the MQTT subscriber that ingests ESP32 camera status
|
||||
// via Mosquitto broker and persists to SQLite with SSE fan-out.
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cubecraft/remoterig/internal/db"
|
||||
"github.com/cubecraft/remoterig/internal/events"
|
||||
"github.com/cubecraft/remoterig/pkg/models"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Subscriber connects to Mosquitto, subscribes to camera topics, and
|
||||
// processes incoming status, heartbeat, and announce messages.
|
||||
type Subscriber struct {
|
||||
mu sync.Mutex
|
||||
broker string
|
||||
clientID string
|
||||
client mqtt.Client
|
||||
db *db.DB
|
||||
hub *events.Hub
|
||||
|
||||
// Heartbeat tracking: last heartbeat time per camera_id
|
||||
heartbeats map[string]time.Time
|
||||
|
||||
// Shutdown
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewSubscriber creates a new MQTT subscriber (does not connect yet).
|
||||
func NewSubscriber(broker, clientID string, database *db.DB, sseHub *events.Hub) *Subscriber {
|
||||
return &Subscriber{
|
||||
broker: broker,
|
||||
clientID: clientID,
|
||||
db: database,
|
||||
hub: sseHub,
|
||||
heartbeats: make(map[string]time.Time),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes the MQTT connection and subscribes to topics.
|
||||
// Returns an error if the initial connection fails.
|
||||
func (s *Subscriber) Connect() error {
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(fmt.Sprintf("tcp://%s", s.broker)).
|
||||
SetClientID(s.clientID).
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetMaxReconnectInterval(30 * time.Second).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(5 * time.Second).
|
||||
SetOnConnectHandler(s.onConnect).
|
||||
SetDefaultPublishHandler(s.onMessage).
|
||||
SetConnectionLostHandler(func(c mqtt.Client, err error) {
|
||||
log.Printf("MQTT connection lost: %v (will auto-reconnect)", err)
|
||||
})
|
||||
|
||||
s.client = mqtt.NewClient(opts)
|
||||
token := s.client.Connect()
|
||||
if !token.WaitTimeout(10 * time.Second) {
|
||||
return fmt.Errorf("mqtt connect timeout")
|
||||
}
|
||||
if err := token.Error(); err != nil {
|
||||
return fmt.Errorf("mqtt connect: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("MQTT connected to %s as %s", s.broker, s.clientID)
|
||||
|
||||
// Start heartbeat watchdog (runs independently)
|
||||
go s.heartbeatWatchdog()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// onConnect is called after every (re)connection. It re-subscribes to topics.
|
||||
func (s *Subscriber) onConnect(c mqtt.Client) {
|
||||
topics := map[string]byte{
|
||||
"remoterig/cameras/+/status": 1, // QoS 1
|
||||
"remoterig/cameras/+/heartbeat": 1, // QoS 1
|
||||
"remoterig/cameras/+/announce": 2, // QoS 2
|
||||
"remoterig/cameras/+/command": 2, // QoS 2 (hub publishes, ESP32 receives)
|
||||
}
|
||||
|
||||
token := c.SubscribeMultiple(topics, nil)
|
||||
if token.WaitTimeout(5 * time.Second) {
|
||||
if err := token.Error(); err != nil {
|
||||
log.Printf("MQTT subscribe error: %v", err)
|
||||
} else {
|
||||
log.Printf("MQTT subscribed to %d topics", len(topics))
|
||||
}
|
||||
} else {
|
||||
log.Println("MQTT subscribe timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// onMessage is the default message handler. It routes by topic.
|
||||
func (s *Subscriber) onMessage(c mqtt.Client, msg mqtt.Message) {
|
||||
topic := msg.Topic()
|
||||
payload := msg.Payload()
|
||||
|
||||
// Extract camera_id from topic: remoterig/cameras/<camera_id>/<type>
|
||||
cameraID := extractCameraID(topic)
|
||||
if cameraID == "" {
|
||||
log.Printf("MQTT: could not extract camera_id from topic %q", topic)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(topic, "/status"):
|
||||
s.handleStatus(cameraID, payload)
|
||||
case strings.HasSuffix(topic, "/heartbeat"):
|
||||
s.handleHeartbeat(cameraID, payload)
|
||||
case strings.HasSuffix(topic, "/announce"):
|
||||
s.handleAnnounce(cameraID, payload)
|
||||
default:
|
||||
log.Printf("MQTT: unhandled topic %q", topic)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status handler ──────────────────────────────────────────────────────
|
||||
|
||||
// statusPayload matches the MQTT contract status message.
|
||||
type statusPayload struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
BatteryPct *int `json:"battery_pct"`
|
||||
BatteryRaw *int `json:"battery_raw"`
|
||||
VideoRemainingSec *int `json:"video_remaining_sec"`
|
||||
Recording bool `json:"recording"`
|
||||
Mode *string `json:"mode"`
|
||||
Resolution *string `json:"resolution"`
|
||||
FPS *int `json:"fps"`
|
||||
Online bool `json:"online"`
|
||||
RSSI *int `json:"rssi"`
|
||||
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) {
|
||||
var sp statusPayload
|
||||
if err := json.Unmarshal(payload, &sp); err != nil {
|
||||
log.Printf("MQTT status parse error for %s: %v", cameraID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if sp.CameraID == "" {
|
||||
log.Printf("MQTT status missing camera_id from %s", cameraID)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil {
|
||||
ts = now
|
||||
}
|
||||
}
|
||||
if ts.After(now.Add(5 * time.Minute)) {
|
||||
log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID)
|
||||
ts = now
|
||||
}
|
||||
if ts.Before(now.Add(-24 * time.Hour)) {
|
||||
log.Printf("MQTT status timestamp too far in past (%s) from %s — using now", ts, cameraID)
|
||||
ts = now
|
||||
}
|
||||
|
||||
// Clamp battery_pct to 0-100
|
||||
batteryPct := sp.BatteryPct
|
||||
if batteryPct != nil {
|
||||
if *batteryPct < 0 {
|
||||
v := 0
|
||||
batteryPct = &v
|
||||
}
|
||||
if *batteryPct > 100 {
|
||||
v := 100
|
||||
batteryPct = &v
|
||||
}
|
||||
}
|
||||
|
||||
recordingState := 0
|
||||
if sp.Recording {
|
||||
recordingState = 1
|
||||
}
|
||||
onlineState := 0
|
||||
if sp.Online {
|
||||
onlineState = 1
|
||||
}
|
||||
|
||||
// Detect recording state change by checking previous status
|
||||
var prevRecording int
|
||||
row := s.db.QueryRow(`
|
||||
SELECT recording_state FROM status_logs
|
||||
WHERE camera_id = ? AND recorded_at > datetime('now', '-120 seconds')
|
||||
ORDER BY recorded_at DESC LIMIT 1
|
||||
`, cameraID)
|
||||
if err := row.Scan(&prevRecording); err != nil {
|
||||
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
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO status_logs (camera_id, recorded_at, battery_pct,
|
||||
video_remaining_sec, recording_state, mode, resolution, fps, online)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, cameraID, ts, batteryPct, sp.VideoRemainingSec,
|
||||
recordingState, stringPtr(sp.Mode), stringPtr(sp.Resolution),
|
||||
intPtr(sp.FPS), onlineState)
|
||||
if err != nil {
|
||||
log.Printf("MQTT status insert error for %s: %v", cameraID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle recording state transitions
|
||||
if prevRecording >= 0 && prevRecording != recordingState {
|
||||
reason := "mqtt"
|
||||
if recordingState == 1 {
|
||||
// Started recording — open new recording_event
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO recording_events (camera_id, started_at, reason)
|
||||
VALUES (?, ?, ?)
|
||||
`, cameraID, ts, reason)
|
||||
if err != nil {
|
||||
log.Printf("MQTT recording_event insert error for %s: %v", cameraID, err)
|
||||
}
|
||||
} else {
|
||||
// Stopped recording — close most recent open event
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE recording_events SET stopped_at = ?
|
||||
WHERE camera_id = ? AND stopped_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
`, ts, cameraID)
|
||||
if err != nil {
|
||||
log.Printf("MQTT recording_event close error for %s: %v", cameraID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast via SSE
|
||||
cam, err := getCamera(s.db, cameraID)
|
||||
if err == nil {
|
||||
sl := models.StatusLog{
|
||||
CameraID: cameraID,
|
||||
RecordedAt: ts,
|
||||
BatteryPct: batteryPct,
|
||||
VideoRemainingSec: sp.VideoRemainingSec,
|
||||
RecordingState: recordingState,
|
||||
Mode: stringPtr(sp.Mode),
|
||||
Resolution: stringPtr(sp.Resolution),
|
||||
FPS: intPtr(sp.FPS),
|
||||
Online: onlineState,
|
||||
}
|
||||
cs := models.NewCameraStatus(cam, sl)
|
||||
s.hub.Broadcast("camera_status", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat handler ───────────────────────────────────────────────────
|
||||
|
||||
type heartbeatPayload struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
// 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"`
|
||||
FreeHeap *int `json:"free_heap"`
|
||||
}
|
||||
|
||||
func (s *Subscriber) handleHeartbeat(cameraID string, payload []byte) {
|
||||
var hp heartbeatPayload
|
||||
if err := json.Unmarshal(payload, &hp); err != nil {
|
||||
log.Printf("MQTT heartbeat parse error for %s: %v", cameraID, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.heartbeats[cameraID] = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// heartbeatWatchdog runs every 10 seconds and marks cameras offline if
|
||||
// no heartbeat received in 120 seconds.
|
||||
func (s *Subscriber) heartbeatWatchdog() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
for cameraID, lastBeat := range s.heartbeats {
|
||||
if time.Since(lastBeat) > 120*time.Second {
|
||||
// Camera missed heartbeat — broadcast offline
|
||||
cam, err := getCamera(s.db, cameraID)
|
||||
if err == nil {
|
||||
cs := models.CameraStatus{
|
||||
CameraID: cameraID,
|
||||
FriendlyName: cam.FriendlyName,
|
||||
Online: false,
|
||||
LastSeen: lastBeat,
|
||||
}
|
||||
s.hub.Broadcast("camera_offline", cs)
|
||||
}
|
||||
delete(s.heartbeats, cameraID)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Announce handler (auto-registration) ───────────────────────────────
|
||||
|
||||
type announcePayload struct {
|
||||
MacAddress string `json:"mac_address"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
}
|
||||
|
||||
func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
|
||||
var ap announcePayload
|
||||
if err := json.Unmarshal(payload, &ap); err != nil {
|
||||
log.Printf("MQTT announce parse error for %s: %v", cameraID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if ap.MacAddress == "" {
|
||||
log.Printf("MQTT announce missing mac_address from %s", cameraID)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this MAC is already registered
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil && existingID == cameraID {
|
||||
// Same self-id re-connecting — just refresh friendly_name.
|
||||
_, err = s.db.Exec(
|
||||
"UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?",
|
||||
ap.FriendlyName, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("MQTT announce update error for %s: %v", existingID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName)
|
||||
} else {
|
||||
// MAC known under a different id (legacy cam-NNN from before self-IDs)
|
||||
// → drop the old row so we re-register under the node's self-id.
|
||||
if err == nil && existingID != cameraID {
|
||||
s.db.Exec("DELETE FROM cameras WHERE camera_id = ?", existingID)
|
||||
log.Printf("MQTT announce: migrating %s -> %s (%s)", existingID, cameraID, ap.FriendlyName)
|
||||
}
|
||||
// Option B: the node self-assigns its camera_id (the announce topic id).
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
||||
`, cameraID, ap.FriendlyName, ap.MacAddress)
|
||||
if err != nil {
|
||||
log.Printf("MQTT announce insert error for %s: %v", ap.MacAddress, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("MQTT announce: new camera registered as %s (%s)", cameraID, ap.FriendlyName)
|
||||
|
||||
// Broadcast new camera via SSE
|
||||
cam, err := getCamera(s.db, cameraID)
|
||||
if err == nil {
|
||||
s.hub.Broadcast("camera_registered", cam)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// extractCameraID pulls <camera_id> from remoterig/cameras/<camera_id>/<type>
|
||||
func extractCameraID(topic string) string {
|
||||
parts := strings.Split(topic, "/")
|
||||
if len(parts) >= 3 && parts[0] == "remoterig" && parts[1] == "cameras" {
|
||||
return parts[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCamera fetches a camera by ID from the database.
|
||||
func getCamera(db *db.DB, cameraID string) (models.Camera, error) {
|
||||
var cam models.Camera
|
||||
err := db.QueryRow(
|
||||
"SELECT camera_id, friendly_name, COALESCE(mac_address,''), created_at, updated_at FROM cameras WHERE camera_id = ?",
|
||||
cameraID,
|
||||
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.CreatedAt, &cam.UpdatedAt)
|
||||
return cam, err
|
||||
}
|
||||
|
||||
func stringPtr(s *string) *string {
|
||||
if s == nil || *s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func intPtr(i *int) *int {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// Close shuts down the MQTT subscriber.
|
||||
func (s *Subscriber) Close() {
|
||||
close(s.done)
|
||||
if s.client != nil && s.client.IsConnected() {
|
||||
s.client.Disconnect(250)
|
||||
log.Println("MQTT disconnected")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
type Camera struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
MacAddress *string `json:"mac_address,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -33,8 +33,11 @@ type StatusLog struct {
|
||||
type CameraStatus struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
BatteryPct *int `json:"battery_pct,omitempty"`
|
||||
VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
|
||||
// Not omitempty: the SPA expects these as `number | null`. Omitting them
|
||||
// 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"`
|
||||
Mode string `json:"mode"`
|
||||
Resolution string `json:"resolution"`
|
||||
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
# RemoteRig — Pi-side deploy script
|
||||
# Deploys a new binary with backup, health-check, and automatic rollback.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./deploy.sh [BINARY_PATH] [DEPLOY_PATH] [SERVICE_NAME]
|
||||
#
|
||||
# Defaults:
|
||||
# BINARY_PATH = ./remoterig (new binary to deploy)
|
||||
# DEPLOY_PATH = /opt/remoterig/remoterig
|
||||
# SERVICE_NAME = remoterig
|
||||
#
|
||||
# Examples:
|
||||
# # Deploy locally-built binary with defaults
|
||||
# sudo ./deploy.sh ./remoterig
|
||||
#
|
||||
# # Custom paths
|
||||
# sudo ./deploy.sh /tmp/remoterig-arm64 /opt/remoterig/remoterig remoterig
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Args
|
||||
# ---------------------------------------------------------------------------
|
||||
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"
|
||||
MAX_BACKUPS=3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
info() { echo "[INFO] $*"; }
|
||||
ok() { echo "[OK] $*"; }
|
||||
fail() { echo "[FAIL] $*" >&2; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight checks
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: must run as root (sudo ./deploy.sh ...)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${BINARY}" ]; then
|
||||
fail "Binary not found: ${BINARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=============================================="
|
||||
echo " RemoteRig Deploy"
|
||||
echo " Binary: ${BINARY}"
|
||||
echo " Deploy path: ${DEPLOY_PATH}"
|
||||
echo " Service: ${SERVICE}"
|
||||
echo " Timestamp: ${TIMESTAMP}"
|
||||
echo "=============================================="
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Backup existing binary
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Backing up current binary..."
|
||||
if [ -f "${DEPLOY_PATH}" ]; then
|
||||
cp "${DEPLOY_PATH}" "${BACKUP}"
|
||||
ok "Backed up to ${BACKUP}"
|
||||
else
|
||||
info "No existing binary at ${DEPLOY_PATH} — fresh install"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Deploy new binary
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Deploying new binary..."
|
||||
# Atomic replace: copy alongside then rename over the target. A plain
|
||||
# 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}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Reload systemd and restart service
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Reloading systemd and restarting ${SERVICE}..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Restart (or start if not running)
|
||||
if systemctl is-active --quiet "${SERVICE}" 2>/dev/null; then
|
||||
systemctl restart "${SERVICE}"
|
||||
else
|
||||
systemctl start "${SERVICE}"
|
||||
fi
|
||||
ok "Service restart issued"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Waiting 3s for service to stabilize..."
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet "${SERVICE}"; then
|
||||
ok "${SERVICE} is active — deploy successful"
|
||||
|
||||
# Optional: curl health endpoint
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
HEALTH_URL="http://localhost:8080/health"
|
||||
if curl -sf --max-time 3 "${HEALTH_URL}" >/dev/null 2>&1; then
|
||||
ok "Health check passed: ${HEALTH_URL}"
|
||||
else
|
||||
info "Health endpoint not reachable (may need more startup time)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "${SERVICE} is NOT active — rolling back"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 5. Rollback on failure
|
||||
# -----------------------------------------------------------------------
|
||||
if [ -f "${BACKUP}" ]; then
|
||||
info "Restoring backup: ${BACKUP}"
|
||||
cp "${BACKUP}" "${DEPLOY_PATH}.new"
|
||||
chmod +x "${DEPLOY_PATH}.new"
|
||||
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
|
||||
|
||||
systemctl restart "${SERVICE}" 2>/dev/null || true
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "${SERVICE}"; then
|
||||
ok "Rollback successful — previous binary restored and service is active"
|
||||
else
|
||||
fail "Rollback failed — service still not active"
|
||||
echo "Check logs: journalctl -u ${SERVICE} -n 50" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
fail "No backup available — cannot roll back"
|
||||
echo "Check logs: journalctl -u ${SERVICE} -n 50" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Cleanup old backups (keep last N)
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Cleaning up old backups (keeping last ${MAX_BACKUPS})..."
|
||||
DEPLOY_DIR="$(dirname "${DEPLOY_PATH}")"
|
||||
BASE_NAME="$(basename "${DEPLOY_PATH}")"
|
||||
|
||||
# List backups, skip current, keep last MAX_BACKUPS, delete the rest
|
||||
ls -1t "${DEPLOY_DIR}/${BASE_NAME}."*.bak 2>/dev/null | \
|
||||
tail -n +$((MAX_BACKUPS + 1)) | \
|
||||
while IFS= read -r old_backup; do
|
||||
rm -f "${old_backup}"
|
||||
info "Removed old backup: $(basename "${old_backup}")"
|
||||
done
|
||||
|
||||
ok "Deploy complete — ${MAX_BACKUPS} backups retained"
|
||||
echo ""
|
||||
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
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=RemoteRig Central Hub
|
||||
Documentation=https://github.com/CubeCraft-Creations/remote-rig
|
||||
After=network.target mosquitto.service
|
||||
Wants=mosquitto.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/opt/remoterig
|
||||
ExecStart=/opt/remoterig/remoterig
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening (optional, uncomment to enable)
|
||||
# NoNewPrivileges=yes
|
||||
# ProtectSystem=strict
|
||||
# ProtectHome=yes
|
||||
# ReadWritePaths=/opt/remoterig
|
||||
|
||||
# Allow graceful shutdown
|
||||
TimeoutStopSec=10s
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+385
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env bash
|
||||
# RemoteRig — First-Time Raspberry Pi Zero 2 W Setup
|
||||
# Target: Debian/Raspberry Pi OS (bookworm)
|
||||
# Idempotent: safe to run multiple times
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./setup-pi.sh [--config PATH] [--service-user USER]
|
||||
#
|
||||
# Options:
|
||||
# --config PATH Path to config.yaml template to copy to /opt/remoterig/
|
||||
# --service-user USER Systemd service user (default: invoking sudo user, else pi)
|
||||
# --static-ip IP Static IP for wlan0 (default: 192.168.8.56/24)
|
||||
# --gateway IP Gateway for wlan0 (default: 192.168.8.1)
|
||||
# --help Show this help
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_TEMPLATE=""
|
||||
SERVICE_USER="${SUDO_USER:-pi}" # default to the invoking user (not every Pi has a 'pi' user)
|
||||
STATIC_IP="192.168.8.56/24"
|
||||
GATEWAY="192.168.8.1"
|
||||
MOSQUITTO_PKG="mosquitto mosquitto-clients"
|
||||
DEPLOY_DIR="/opt/remoterig"
|
||||
SERVICE_NAME="remoterig"
|
||||
SERVICE_FILE="scripts/remoterig.service"
|
||||
MOSQUITTO_CONF="/etc/mosquitto/conf.d/remoterig.conf"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Help
|
||||
# ---------------------------------------------------------------------------
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# //'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse args
|
||||
# ---------------------------------------------------------------------------
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--config)
|
||||
CONFIG_TEMPLATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--service-user)
|
||||
SERVICE_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--static-ip)
|
||||
STATIC_IP="$2"
|
||||
shift 2
|
||||
;;
|
||||
--gateway)
|
||||
GATEWAY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown option: $1" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight checks
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: must run as root (sudo ./setup-pi.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info() { echo "[INFO] $*"; }
|
||||
ok() { echo "[OK] $*"; }
|
||||
skip() { echo "[SKIP] $*"; }
|
||||
warn() { echo "[WARN] $*" >&2; }
|
||||
|
||||
echo "=============================================="
|
||||
echo " RemoteRig Pi Zero 2 W Setup"
|
||||
echo " Target: ${STATIC_IP} via ${GATEWAY}"
|
||||
echo " Service user: ${SERVICE_USER}"
|
||||
echo "=============================================="
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Update package list (always safe)
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Updating package list..."
|
||||
apt-get update -qq
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Install Mosquitto MQTT broker + clients
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing Mosquitto..."
|
||||
if dpkg -l mosquitto mosquitto-clients >/dev/null 2>&1; then
|
||||
# Already installed — ensure latest
|
||||
apt-get install -y -qq ${MOSQUITTO_PKG} 2>/dev/null && \
|
||||
ok "Mosquitto packages up to date" || \
|
||||
warn "Mosquitto package update had warnings (non-fatal)"
|
||||
else
|
||||
apt-get install -y -qq ${MOSQUITTO_PKG}
|
||||
ok "Mosquitto installed"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Configure Mosquitto — anonymous on localhost, listener on 0.0.0.0:1883
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Configuring Mosquitto..."
|
||||
mkdir -p /etc/mosquitto/conf.d
|
||||
|
||||
# Write idempotent config
|
||||
cat > "${MOSQUITTO_CONF}" <<'MQTTEOF'
|
||||
# RemoteRig Mosquitto configuration
|
||||
# Closed travel-router LAN — anonymous access is intentional
|
||||
|
||||
# Listen on all interfaces (LAN + localhost)
|
||||
listener 1883 0.0.0.0
|
||||
|
||||
# No authentication (closed network, no internet access)
|
||||
allow_anonymous true
|
||||
MQTTEOF
|
||||
|
||||
ok "Mosquitto config written: ${MOSQUITTO_CONF}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Create /opt/remoterig directory
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Creating deploy directory..."
|
||||
if [ -d "${DEPLOY_DIR}" ]; then
|
||||
skip "${DEPLOY_DIR} already exists"
|
||||
else
|
||||
mkdir -p "${DEPLOY_DIR}"
|
||||
ok "Created ${DEPLOY_DIR}"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Copy config.yaml template (if provided)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -n "${CONFIG_TEMPLATE}" ] && [ -f "${CONFIG_TEMPLATE}" ]; then
|
||||
info "Copying config.yaml template..."
|
||||
if [ -f "${DEPLOY_DIR}/config.yaml" ]; then
|
||||
skip "${DEPLOY_DIR}/config.yaml already exists (not overwriting)"
|
||||
else
|
||||
cp "${CONFIG_TEMPLATE}" "${DEPLOY_DIR}/config.yaml"
|
||||
ok "Copied config.yaml to ${DEPLOY_DIR}/config.yaml"
|
||||
fi
|
||||
elif [ -n "${CONFIG_TEMPLATE}" ]; then
|
||||
warn "Config template '${CONFIG_TEMPLATE}' not found — skipping"
|
||||
else
|
||||
info "No config template provided — skipping"
|
||||
fi
|
||||
|
||||
# Ensure service user owns the deploy directory
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DEPLOY_DIR}" 2>/dev/null || \
|
||||
warn "Could not chown ${DEPLOY_DIR} to ${SERVICE_USER} (user may not exist yet)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Install and enable systemd service
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing systemd service..."
|
||||
|
||||
# Locate the service file relative to this script's directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SRC_SERVICE="${SCRIPT_DIR}/remoterig.service"
|
||||
|
||||
if [ ! -f "${SRC_SERVICE}" ]; then
|
||||
warn "Service file not found at ${SRC_SERVICE} — skipping service install"
|
||||
warn "Run this script from the repository root (scripts/setup-pi.sh)"
|
||||
else
|
||||
DST_SERVICE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
# Copy if different
|
||||
if [ -f "${DST_SERVICE}" ]; then
|
||||
if cmp -s "${SRC_SERVICE}" "${DST_SERVICE}"; then
|
||||
skip "systemd service already installed and up to date"
|
||||
else
|
||||
cp "${SRC_SERVICE}" "${DST_SERVICE}"
|
||||
ok "systemd service updated"
|
||||
RELOAD_SYSTEMD=1
|
||||
fi
|
||||
else
|
||||
cp "${SRC_SERVICE}" "${DST_SERVICE}"
|
||||
ok "systemd service installed"
|
||||
RELOAD_SYSTEMD=1
|
||||
fi
|
||||
|
||||
# Substitute the service user
|
||||
sed -i "s/^User=.*/User=${SERVICE_USER}/" "${DST_SERVICE}"
|
||||
|
||||
if [ "${RELOAD_SYSTEMD:-0}" -eq 1 ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
|
||||
# Enable (idempotent)
|
||||
if systemctl is-enabled --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
||||
skip "systemd service already enabled"
|
||||
else
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
ok "systemd service enabled"
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Configuring static IP on wlan0..."
|
||||
|
||||
# Check if wlan0 exists
|
||||
if ! ip link show wlan0 >/dev/null 2>&1; then
|
||||
warn "wlan0 interface not found — skipping static IP configuration"
|
||||
warn "Connect Wi-Fi first (raspi-config), then re-run this script"
|
||||
else
|
||||
STATIC_IP_SET=0
|
||||
|
||||
# --- Method A: NetworkManager (default on bookworm) ---
|
||||
if command -v nmcli >/dev/null 2>&1; then
|
||||
info "Using NetworkManager (nmcli)..."
|
||||
|
||||
# Find the Wi-Fi connection profile
|
||||
WIFI_CON=$(nmcli -t -f NAME,TYPE con show 2>/dev/null | grep ':802-11-wireless' | cut -d: -f1 | head -1)
|
||||
|
||||
if [ -n "${WIFI_CON}" ]; then
|
||||
CURRENT_IP=$(nmcli -t -f IP4.ADDRESS con show "${WIFI_CON}" 2>/dev/null | cut -d: -f2 | head -1 || true)
|
||||
|
||||
if [ "${CURRENT_IP}" = "${STATIC_IP}" ]; then
|
||||
skip "wlan0 already set to ${STATIC_IP} via nmcli"
|
||||
STATIC_IP_SET=1
|
||||
else
|
||||
nmcli con mod "${WIFI_CON}" ipv4.addresses "${STATIC_IP}"
|
||||
nmcli con mod "${WIFI_CON}" ipv4.gateway "${GATEWAY}"
|
||||
nmcli con mod "${WIFI_CON}" ipv4.dns "${GATEWAY}"
|
||||
nmcli con mod "${WIFI_CON}" ipv4.method manual
|
||||
nmcli con up "${WIFI_CON}" 2>/dev/null || true
|
||||
ok "wlan0 set to ${STATIC_IP} via nmcli (connection: ${WIFI_CON})"
|
||||
STATIC_IP_SET=1
|
||||
fi
|
||||
else
|
||||
warn "No Wi-Fi connection profile found in NetworkManager"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Method B: dhcpcd (fallback for older PiOS) ---
|
||||
if [ ${STATIC_IP_SET} -eq 0 ] && command -v dhcpcd >/dev/null 2>&1; then
|
||||
info "Using dhcpcd..."
|
||||
|
||||
DHCPCD_CONF="/etc/dhcpcd.conf"
|
||||
|
||||
if grep -q "interface wlan0" "${DHCPCD_CONF}" 2>/dev/null; then
|
||||
skip "dhcpcd already has wlan0 config"
|
||||
else
|
||||
cat >> "${DHCPCD_CONF}" <<DHCPCDEOF
|
||||
|
||||
# RemoteRig static IP
|
||||
interface wlan0
|
||||
static ip_address=${STATIC_IP}
|
||||
static routers=${GATEWAY}
|
||||
static domain_name_servers=${GATEWAY}
|
||||
DHCPCDEOF
|
||||
ok "dhcpcd configured for wlan0 static IP"
|
||||
fi
|
||||
STATIC_IP_SET=1
|
||||
fi
|
||||
|
||||
# --- Method C: /etc/network/interfaces (last resort) ---
|
||||
if [ ${STATIC_IP_SET} -eq 0 ]; then
|
||||
warn "Neither nmcli nor dhcpcd found — attempting /etc/network/interfaces"
|
||||
|
||||
INTERFACES_FILE="/etc/network/interfaces"
|
||||
if ! grep -q "iface wlan0 inet static" "${INTERFACES_FILE}" 2>/dev/null; then
|
||||
cat >> "${INTERFACES_FILE}" <<NETEOF
|
||||
|
||||
# RemoteRig static IP
|
||||
auto wlan0
|
||||
iface wlan0 inet static
|
||||
address ${STATIC_IP%/*}
|
||||
netmask 255.255.255.0
|
||||
gateway ${GATEWAY}
|
||||
NETEOF
|
||||
ok "wlan0 static IP configured in ${INTERFACES_FILE}"
|
||||
else
|
||||
skip "${INTERFACES_FILE} already has static wlan0 config"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Enable and start Mosquitto
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Enabling and starting Mosquitto..."
|
||||
|
||||
if systemctl is-active --quiet mosquitto 2>/dev/null; then
|
||||
skip "Mosquitto already running"
|
||||
else
|
||||
systemctl enable mosquitto 2>/dev/null || true
|
||||
systemctl restart mosquitto
|
||||
ok "Mosquitto started"
|
||||
fi
|
||||
|
||||
# Verify Mosquitto is listening
|
||||
sleep 1
|
||||
if systemctl is-active --quiet mosquitto 2>/dev/null; then
|
||||
ok "Mosquitto is running and listening on :1883"
|
||||
else
|
||||
warn "Mosquitto may not have started — check: sudo systemctl status mosquitto"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo " Setup complete!"
|
||||
echo "=============================================="
|
||||
echo " Mosquitto: $(systemctl is-active mosquitto 2>/dev/null || echo 'unknown')"
|
||||
echo " Service: ${SERVICE_NAME} (systemctl status ${SERVICE_NAME})"
|
||||
echo " Updater: remoterig-update.timer (systemctl status remoterig-update.timer)"
|
||||
echo " Deploy dir: ${DEPLOY_DIR}"
|
||||
echo " Static IP: ${STATIC_IP} on wlan0"
|
||||
echo ""
|
||||
echo " Deploys are pull-based: push to 'dev' on Gitea -> CI builds the"
|
||||
echo " arm64 binary -> the Pi's timer pulls + installs it automatically."
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 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 "=============================================="
|
||||
+196
-56
@@ -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 { useCameraStore } from './store/useCameraStore'
|
||||
import { CameraCard } from './components'
|
||||
import { api } from './services/api'
|
||||
import CameraCard from './components/CameraCard'
|
||||
import HistoryViewer from './components/HistoryViewer'
|
||||
|
||||
function App() {
|
||||
// Connect to SSE endpoint — auto-updates the camera store
|
||||
useSSE()
|
||||
const [commandBusy, setCommandBusy] = useState(false)
|
||||
const [commandError, setCommandError] = useState<string | null>(null)
|
||||
const [historyCameraId, setHistoryCameraId] = useState<string | null>(null)
|
||||
const [historyCameraName, setHistoryCameraName] = useState<string>()
|
||||
|
||||
// Subscribe to the camera store for reactivity.
|
||||
// getCameras / getOnlineCount / getRecordingCount pull from live state.
|
||||
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
|
||||
const cameras = getCameras()
|
||||
const onlineCount = getOnlineCount()
|
||||
const recordingCount = getRecordingCount()
|
||||
// SSE connection + live store
|
||||
const { connectionState } = useSSE()
|
||||
|
||||
// Seed the list once on mount via the REST API. SSE only pushes on change,
|
||||
// so without this the dashboard is empty until the next status event.
|
||||
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 (
|
||||
<div className="min-h-screen bg-rig-dark-900">
|
||||
<div className="min-h-screen bg-rig-dark-900 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Camera className="h-7 w-7 text-rig-accent" />
|
||||
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
|
||||
RemoteRig
|
||||
</h1>
|
||||
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
||||
Dashboard
|
||||
</span>
|
||||
|
||||
{/* Stats badges */}
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
{/* Online count */}
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
||||
title="Cameras online"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-rig-success" />
|
||||
{onlineCount} online
|
||||
</span>
|
||||
|
||||
{/* Recording count */}
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
|
||||
title="Cameras recording"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-rig-danger" />
|
||||
</span>
|
||||
{recordingCount} recording
|
||||
<header className="shrink-0 border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Camera className="h-6 w-6 shrink-0 text-rig-accent" />
|
||||
<h1 className="text-lg font-bold tracking-tight text-rig-dark-50 truncate">
|
||||
RemoteRig
|
||||
</h1>
|
||||
<span className="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
||||
Dashboard
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* SSE badge */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${connectionBadge.class}`}
|
||||
>
|
||||
<BadgeIcon className="h-3 w-3" />
|
||||
{connectionBadge.label}
|
||||
</span>
|
||||
|
||||
{/* Global controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleStartAll}
|
||||
disabled={commandBusy || cameras.length === 0}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-3 py-1.5 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Start recording on all cameras"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="hidden sm:inline">Start All</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStopAll}
|
||||
disabled={commandBusy || cameras.length === 0}
|
||||
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-3 py-1.5 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Stop recording on all cameras"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="hidden sm:inline">Stop All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-rig-dark-400">
|
||||
<span>
|
||||
<strong className="text-rig-dark-100">{cameras.length}</strong> camera{cameras.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-rig-success">{onlineCount}</strong> online
|
||||
</span>
|
||||
<span>
|
||||
<strong className={recordingCount > 0 ? 'text-rig-danger' : 'text-rig-dark-300'}>
|
||||
{recordingCount}
|
||||
</strong>{' '}
|
||||
recording
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Command error toast */}
|
||||
{commandError && (
|
||||
<div className="shrink-0 border-b border-rig-danger/30 bg-rig-danger/10 px-4 py-2">
|
||||
<p className="mx-auto max-w-7xl text-xs text-rig-danger">
|
||||
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
||||
{commandError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<main className="flex-1 mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
{cameras.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
|
||||
<span className="relative mb-4 inline-flex">
|
||||
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
|
||||
</span>
|
||||
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
|
||||
<h2 className="text-lg font-semibold text-rig-dark-200">
|
||||
Waiting for cameras…
|
||||
No Cameras Connected
|
||||
</h2>
|
||||
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
||||
Connect cameras to your RemoteRig server and they will appear here
|
||||
automatically.
|
||||
Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Camera grid */
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{cameras.map((camera) => (
|
||||
<CameraCard key={camera.camera_id} camera={camera} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{cameras.map((cam) => (
|
||||
<CameraCard
|
||||
key={cam.camera_id}
|
||||
camera={cam}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onViewHistory={handleViewHistory}
|
||||
disabled={commandBusy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* History modal */}
|
||||
<HistoryViewer
|
||||
cameraId={historyCameraId}
|
||||
cameraName={historyCameraName}
|
||||
onClose={handleCloseHistory}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
|
||||
<footer className="shrink-0 border-t border-rig-dark-700 bg-rig-dark-800/30">
|
||||
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-xs text-rig-dark-500">
|
||||
RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import CameraCard from './CameraCard'
|
||||
import type { CameraStatus } from '../types'
|
||||
|
||||
@@ -19,52 +19,52 @@ function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
|
||||
}
|
||||
}
|
||||
|
||||
const noop = vi.fn()
|
||||
const renderCard = (overrides?: Partial<CameraStatus>) =>
|
||||
render(<CameraCard camera={makeCamera(overrides ?? {})} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||
const renderCardContainer = (camera: CameraStatus) =>
|
||||
render(<CameraCard camera={camera} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||
|
||||
describe('CameraCard', () => {
|
||||
// ── Basic rendering ────────────────────────────────────────────────────
|
||||
|
||||
it('renders camera name', () => {
|
||||
render(<CameraCard camera={makeCamera()} />)
|
||||
renderCard()
|
||||
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows resolution and FPS', () => {
|
||||
render(<CameraCard camera={makeCamera()} />)
|
||||
renderCard()
|
||||
expect(screen.getByText(/1080p/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows battery percentage', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
|
||||
renderCard({ battery_pct: 85 })
|
||||
expect(screen.getByText('85%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows N/A when battery is null', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
|
||||
renderCard({ battery_pct: null })
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Battery bar colors ─────────────────────────────────────────────────
|
||||
|
||||
it('uses green bar for high battery (>=50%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 85 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-success')
|
||||
})
|
||||
|
||||
it('uses yellow bar for medium battery (15-49%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 30 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-warning')
|
||||
})
|
||||
|
||||
it('uses red bar for low battery (<15%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 8 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-danger')
|
||||
})
|
||||
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
|
||||
// ── Recording state ────────────────────────────────────────────────────
|
||||
|
||||
it('shows REC badge when recording', () => {
|
||||
render(<CameraCard camera={makeCamera({ recording: true })} />)
|
||||
renderCard({ recording: true })
|
||||
expect(screen.getByText('REC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows IDLE badge when not recording', () => {
|
||||
render(<CameraCard camera={makeCamera({ recording: false })} />)
|
||||
renderCard({ recording: false })
|
||||
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Online / Offline badges ────────────────────────────────────────────
|
||||
|
||||
it('shows Online badge when camera is online', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||
renderCard({ online: true })
|
||||
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Offline badge when camera is offline', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
||||
renderCard({ online: false })
|
||||
const offlineElements = screen.getAllByText('Offline')
|
||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
|
||||
// ── Video remaining ────────────────────────────────────────────────────
|
||||
|
||||
it('shows video remaining time when available', () => {
|
||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />)
|
||||
renderCard({ video_remaining_sec: 125 })
|
||||
// formatTimeLeft(125) → "2m 5s left"
|
||||
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show video remaining when null', () => {
|
||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />)
|
||||
renderCard({ video_remaining_sec: null })
|
||||
// The Radio icon and time text should not be present
|
||||
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
|
||||
// ── Footer ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows Live + timestamp in footer when online', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||
// Footer shows "Live" when online
|
||||
renderCard({ online: true })
|
||||
expect(screen.getByText('Live')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Offline + timestamp in footer when offline', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
||||
// Footer says "Offline" (the text appears both in the badge and footer)
|
||||
// When offline, the footer specifically shows "Offline" text
|
||||
it('shows Offline in footer when offline', () => {
|
||||
renderCard({ online: false })
|
||||
const offlineElements = screen.getAllByText('Offline')
|
||||
// At least one should exist (badge + footer)
|
||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows "unknown" when last_seen is malformed', () => {
|
||||
render(
|
||||
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
|
||||
)
|
||||
renderCard({ last_seen: 'not-a-date' })
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "unknown" when last_seen is in the future', () => {
|
||||
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
|
||||
render(<CameraCard camera={makeCamera({ last_seen: future })} />)
|
||||
const cam = makeCamera({ last_seen: future })
|
||||
renderCardContainer(cam)
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────────────────
|
||||
|
||||
it('clamps negative battery_pct to 0%', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
|
||||
renderCard({ battery_pct: -5 })
|
||||
expect(screen.getByText('0%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows exact boundary: 15% battery → yellow bar', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 15 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-warning')
|
||||
})
|
||||
|
||||
it('shows exact boundary: 50% battery → green bar', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 50 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-success')
|
||||
})
|
||||
|
||||
// ── New prop-driven tests ──────────────────────────────────────────────
|
||||
|
||||
it('calls onStart when Record button is clicked', () => {
|
||||
const onStart = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({ recording: false })} onStart={onStart} onStop={noop} onViewHistory={noop} />)
|
||||
screen.getByText('Record').click()
|
||||
expect(onStart).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
|
||||
it('calls onStop when Stop button is clicked', () => {
|
||||
const onStop = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({ recording: true })} onStart={noop} onStop={onStop} onViewHistory={noop} />)
|
||||
screen.getByText('Stop').click()
|
||||
expect(onStop).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
|
||||
it('calls onViewHistory when History button is clicked', () => {
|
||||
const onViewHistory = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({})} onStart={noop} onStop={noop} onViewHistory={onViewHistory} />)
|
||||
screen.getByText('History').click()
|
||||
expect(onViewHistory).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
|
||||
import { Video, Wifi, WifiOff, Signal, Battery, Radio, Play, Square } from 'lucide-react'
|
||||
import type { CameraStatus } from '../types'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
|
||||
return `${diffDay}d ago`
|
||||
}
|
||||
|
||||
function batteryColor(pct: number | null): { bar: string; text: string } {
|
||||
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
||||
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||
function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
|
||||
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
|
||||
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||
}
|
||||
|
||||
function formatTimeLeft(sec: number): string {
|
||||
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
|
||||
return `${m}m ${s}s left`
|
||||
}
|
||||
|
||||
function cameraStatus(online: boolean, batteryPct: number | null): 'good' | 'warning' | 'critical' {
|
||||
if (!online) return 'critical'
|
||||
if (batteryPct === null) return 'good'
|
||||
if (batteryPct >= 50) return 'good'
|
||||
if (batteryPct >= 15) return 'warning'
|
||||
return 'critical'
|
||||
}
|
||||
|
||||
const STATUS_BORDER: Record<string, string> = {
|
||||
good: 'border-l-rig-success',
|
||||
warning: 'border-l-rig-warning',
|
||||
critical: 'border-l-rig-danger',
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface CameraCardProps {
|
||||
camera: CameraStatus
|
||||
onStart: (cameraId: string) => void
|
||||
onStop: (cameraId: string) => void
|
||||
onViewHistory: (cameraId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function CameraCard({ camera }: CameraCardProps) {
|
||||
export default function CameraCard({ camera, onStart, onStop, onViewHistory, disabled }: CameraCardProps) {
|
||||
const {
|
||||
camera_id,
|
||||
friendly_name,
|
||||
online,
|
||||
resolution,
|
||||
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
} = camera
|
||||
|
||||
const batt = batteryColor(battery_pct)
|
||||
const status = cameraStatus(online, battery_pct)
|
||||
const borderColor = STATUS_BORDER[status]
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
||||
className={`rounded-xl border border-rig-dark-600 bg-rig-dark-800/60 transition-colors border-l-4 ${borderColor} ${
|
||||
online
|
||||
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
||||
: 'border-rig-dark-700 opacity-75'
|
||||
? 'hover:border-rig-accent/40'
|
||||
: 'opacity-75'
|
||||
}`}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
|
||||
<h3
|
||||
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
|
||||
className="text-sm font-semibold text-rig-dark-100 truncate"
|
||||
title={friendly_name}
|
||||
>
|
||||
{friendly_name}
|
||||
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
<span
|
||||
role="status"
|
||||
aria-label={online ? 'Camera online' : 'Camera offline'}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
className={`ml-2 shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
online
|
||||
? 'bg-rig-success/15 text-rig-success'
|
||||
: 'bg-rig-danger/15 text-rig-danger'
|
||||
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="space-y-2.5 px-4 pb-3">
|
||||
{/* Camera ID */}
|
||||
<p className="text-[11px] font-mono text-rig-dark-500">{camera_id}</p>
|
||||
|
||||
{/* Resolution + FPS */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
|
||||
<Signal className="h-3.5 w-3.5" />
|
||||
@@ -159,26 +183,60 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{online ? (
|
||||
<>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
|
||||
<span className="text-rig-success">Live</span>
|
||||
</>
|
||||
<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>
|
||||
) : (
|
||||
<span className="text-rig-dark-400">Offline</span>
|
||||
<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>
|
||||
)}
|
||||
<span className="text-rig-dark-500">·</span>
|
||||
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
|
||||
<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>
|
||||
|
||||
{video_remaining_sec !== null && (
|
||||
<div className="flex items-center gap-1 text-xs text-rig-dark-400">
|
||||
<Radio className="h-3 w-3" />
|
||||
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span>
|
||||
{/* Status strip */}
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{online ? (
|
||||
<>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
|
||||
<span className="text-rig-success">Live</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-rig-dark-400">Offline</span>
|
||||
)}
|
||||
<span className="text-rig-dark-500">·</span>
|
||||
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{video_remaining_sec !== null && (
|
||||
<div className="flex items-center gap-1 text-xs text-rig-dark-400">
|
||||
<Radio className="h-3 w-3" />
|
||||
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 HistoryViewer } from './HistoryViewer'
|
||||
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RemoteRig - Frontend Not Built</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
.message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 { color: #e74c3c; margin-bottom: 0.5rem; }
|
||||
code {
|
||||
background: #eee;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="message">
|
||||
<h1>Frontend Not Built</h1>
|
||||
<p>The React frontend has not been built yet.</p>
|
||||
<p>Run <code>npm run build</code> from the project root, then rebuild the Go binary.</p>
|
||||
<p><small>API is still available at <code>/api/v1/</code> and health at <code>/health</code></small></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+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> {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
@@ -12,9 +12,22 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getCameras: () => request<[]>('/cameras'),
|
||||
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
|
||||
getSystemHealth: () => request<[]>('/system/health'),
|
||||
toggleRecording: (cameraId: string) =>
|
||||
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
|
||||
/** GET /api/v1/cameras — list all cameras with latest status */
|
||||
getCameras: () => request<import('../types').CameraStatus[]>('/cameras'),
|
||||
|
||||
/** GET /api/v1/cameras/{id} — full detail + 24h history */
|
||||
getCameraDetail: (id: string) =>
|
||||
request<import('../types').CameraDetail>(`/cameras/${id}`),
|
||||
|
||||
/** POST /api/v1/cameras/{id}/start — start recording */
|
||||
startRecording: (cameraId: string) =>
|
||||
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/start`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
/** POST /api/v1/cameras/{id}/stop — stop recording */
|
||||
stopRecording: (cameraId: string) =>
|
||||
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/stop`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,42 @@ export interface SSEEvent {
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
/** A single status log entry from GET /api/v1/cameras/{id} */
|
||||
export interface StatusLog {
|
||||
id: number
|
||||
camera_id: string
|
||||
recorded_at: string
|
||||
battery_pct: number | null
|
||||
video_remaining_sec: number | null
|
||||
recording_state: number // 0 or 1 (SQLite bool)
|
||||
mode: string
|
||||
resolution: string
|
||||
fps: number
|
||||
online: number // 0 or 1
|
||||
raw_battery_pct: number | null
|
||||
}
|
||||
|
||||
/** Camera detail response from GET /api/v1/cameras/{id} */
|
||||
export interface CameraDetail {
|
||||
camera: CameraInfo
|
||||
last_status: StatusLog
|
||||
history: StatusLog[]
|
||||
}
|
||||
|
||||
export interface CameraInfo {
|
||||
CameraID: string
|
||||
FriendlyName: string
|
||||
MacAddress: string | null
|
||||
CreatedAt: string
|
||||
UpdatedAt: string
|
||||
}
|
||||
|
||||
/** Generic API responses */
|
||||
export interface StartStopResponse {
|
||||
status: string
|
||||
camera_id: string
|
||||
}
|
||||
|
||||
export interface Camera {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -5,6 +5,12 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
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: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user