25 Commits

Author SHA1 Message Date
Hermes 1704d8a833 CUB-228: add battery_calibration_offset to cameras table
CI/CD / lint-and-typecheck (pull_request) Successful in 6s
CI/CD / test (pull_request) Successful in 6s
CI/CD / build (pull_request) Failing after 4m47s
CI/CD / deploy (pull_request) Has been skipped
- Add column to 001_create_tables.sql for fresh databases
- Add migration 002 for existing databases (idempotent via
  pragma_table_info check)
- Implement runIncrementalMigrations in db.go
- Add BatteryCalibrationOffset to Camera model
- Update all camera SELECT queries (cameras List, detail, MQTT
  subscriber getCamera, register)
2026-05-22 22:31:54 -04:00
overseer 1a8f67a392 Merge pull request 'feat: add v3 hardware case and update hub network' (#6) from agent/hermes/remoterig-hardware-v3-network into dev
Build (Dev) / build (push) Failing after 9s
CI/CD / lint-and-typecheck (push) Successful in 9m29s
CI/CD / test (push) Successful in 9m27s
CI/CD / build (push) Failing after 4m50s
CI/CD / deploy (push) Has been skipped
Reviewed-on: #6
2026-05-22 19:43:40 -04:00
Hermes 4c4368a79f fix: make tripod case dovetail connector fit
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 8s
CI/CD / deploy (pull_request) Has been skipped
2026-05-22 17:15:23 -04:00
Hermes 0e2e94a4cf docs: align hardware and MQTT architecture notes
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 10m3s
CI/CD / build (pull_request) Failing after 4m58s
CI/CD / deploy (pull_request) Has been skipped
2026-05-22 17:08:11 -04:00
Hermes c5cbeabd92 feat: add v3 hardware case and update hub network
CI/CD / lint-and-typecheck (pull_request) Failing after 14m12s
CI/CD / test (pull_request) Has been cancelled
CI/CD / build (pull_request) Has been cancelled
CI/CD / deploy (pull_request) Has been cancelled
2026-05-22 16:58:11 -04:00
Hermes f4bf37d6a3 feat: add interactive 3D case viewer (Three.js)
Build (Dev) / build (push) Failing after 0s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Rotatable 3D render of the tripod-mounted dual-ESP case:
- Case body with rounded corners and lid
- Stacked ESP32 + ESP8266 boards inside
- LED indicator, USB port, ventilation slots
- Tripod pole with C-clamp mount
- USB cables, screws, chip details
- Drag to rotate, scroll to zoom
- Open in any browser
2026-05-22 01:06:59 +00:00
Hermes 893574ee79 feat: redesigned case — tripod-clip box for dual ESPs, USB power bank
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Replaced GoPro-sleeve case design with simpler stand-mounted box:
- Case clips to tripod leg/stand pole (20-35mm diameter)
- No camera sleeve needed — case sits on the stand
- Powered by standard USB power bank (off-the-shelf)
- Holds ESP8266 + ESP32 stacked with UART wiring
- Cable ports for USB in/out, LED window, ventilation

Simplified BOM: ~1/node (down from 4), no buck converters needed
2026-05-22 01:03:53 +00:00
Hermes b3d4226b1c feat: dual-board architecture — ESP8266 camera bridge + ESP32 MQTT bridge
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Complete rewrite of firmware into two dedicated boards per camera node:

ESP8266 (Camera Bridge):
- Connects ONLY to GoPro AP — polls status, sends over UART
- Zero network switching, zero MQTT
- HTTP GET /bacpac/SH for status, start/stop
- JSON-per-line UART protocol to ESP32

ESP32 (MQTT Bridge):
- Connects ONLY to travel router — MQTT to Pi hub
- Reads status from ESP8266 over UART2 (RX16/TX17)
- Auto-registration, heartbeat, command forwarding
- Zero camera communication

UART Protocol: JSON-per-line at 115200 8N1
  ESP8266→ESP32: status/ack/pong/error
  ESP32→ESP8266: cmd (start_recording/stop_recording/ping)

Hardware updates:
- BOM now includes both boards (~4/node)
- 3D case has stacked dual-board compartment
- UART wire channel between board recesses
- Shared 3.3V power rail for both boards
2026-05-22 00:49:06 +00:00
Hermes 324402f268 feat: add ESP8266 support + Akaso camera compatibility config
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
Build (Dev) / build (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
- Unified firmware for ESP32 (dual-STA) and ESP8266 (time-shared STA)
- ESP8266: alternates between GoPro AP and travel router per poll cycle
- PlatformIO dual-target: esp32dev + esp8266dev (d1_mini)
- camera_ip config field for Akaso/non-GoPro cameras
- LittleFS support for ESP8266 (replaces SPIFFS)
- Camera compatibility table (GoPro H3/H4, Akaso)
- LED polarity handled per-platform (ESP8266 active-low)

ESP8266 time-sharing adds ~4s latency per 30s cycle — invisible at poll rate.
2026-05-22 00:28:48 +00:00
Hermes 37c5362216 merge: resolve conflicts with main (take dev versions)
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 1s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
CI/CD / test (pull_request) Successful in 8s
CI/CD / build (pull_request) Failing after 11s
CI/CD / deploy (pull_request) Has been skipped
CI/CD / lint-and-typecheck (pull_request) Successful in 10s
Conflicts in cmd/server/main.go, go.mod, internal/db/db.go
— main had older/simpler versions from PR #4 (README)
— dev has full codebase with MQTT, SSE, embed
2026-05-22 00:03:39 +00:00
overseer d9c06b28fd Merge pull request 'CUB-208: Add README with project overview and setup instructions' (#4) from hermes/cub-208-readme into main
Reviewed-on: #4
2026-05-21 17:56:07 -04:00
Hermes d419dfe519 feat: add PlatformIO ESP32 firmware with dual-STA + MQTT + GoPro control
CI/CD / lint-and-typecheck (pull_request) Failing after 2s
CI/CD / test (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / deploy (pull_request) Failing after 12m6s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 14s
CI/CD / lint-and-typecheck (push) Failing after 0s
firmware/
├── platformio.ini     — ESP32 (esp32dev), PubSubClient + ArduinoJson
├── src/main.cpp       — Full camera node firmware (360 lines)
│   ├── SPIFFS config persistence
│   ├── Dual Wi-Fi STA (travel router + GoPro AP)
│   ├── GoPro Hero 3 HTTP API (start/stop/status)
│   ├── 60-byte binary status blob parser
│   ├── MQTT per contract (status QoS1, heartbeat QoS1, announce QoS2)
│   ├── Command subscription (start/stop/reboot)
│   ├── Auto-registration (announce → hub assigns cam-NNN)
│   ├── Heartbeat every 60s, status every 30s
│   ├── LED status indicator
│   └── Exponential backoff reconnection
├── data/config.json   — Default SPIFFS config template
└── README.md          — Quick start, config reference, troubleshooting
2026-05-21 21:54:05 +00:00
Hermes e4324e626f feat: add 3D printable case design (OpenSCAD) and hardware assembly guide
- GoPro Hero 3 sleeve with lens/screen/USB cutouts
- ESP32 D1 Mini electronics compartment (vented)
- LiPo battery compartment with velcro strap slots
- Bill of materials (~0 per camera node)
- Wiring diagram (LiPo → dual buck converters → ESP32 + GoPro)
- Field deployment workflow

OpenSCAD model in hardware/case/remoterig-case.scad
Assembly guide in hardware/README.md
2026-05-21 21:50:26 +00:00
Hermes 5bc327e909 feat: add Pi provisioning, systemd service, and deploy scripts
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
2026-05-21 21:42:43 +00:00
Hermes 6b6b66ab89 feat: embed React frontend in Go binary with SPA fallback 2026-05-21 21:39:07 +00:00
Hermes 74d6130dd5 deploy(dev): rename OpenClaw to RemoteRig, add config.yaml deployment
- BINARY_NAME: openclaw → remoterig
- DEPLOY_PATH: /opt/openclaw/openclaw → /opt/remoterig/remoterig
- SERVICE: openclaw → remoterig
- Deploy tmp path: /tmp/openclaw-deploy → /tmp/remoterig-deploy
- Add config.yaml deployment step: copies config.yaml alongside binary on dev host if present in repo
- Backup/rollback logic preserved (keeps last 3 backups)
- Failure notification path updated to /tmp/remoterig-deploy-failure.txt
2026-05-21 21:38:48 +00:00
Hermes a90a1d567e build(dev): switch to ARM64 cross-compile for Pi Zero 2 W deployment
- Rename BINARY_NAME from openclaw to remoterig
- Cross-compile with GOOS=linux GOARCH=arm64 for Raspberry Pi Zero 2 W
- Build React frontend at repo root (npm ci && npm run build) producing dist/
- Remove old web/ working-directory and explicit embed step (handled by go:embed)
- Repository dispatch event_type unchanged: dev-build-success
2026-05-21 21:38:39 +00:00
Hermes 607aea514b feat: harden SSE endpoint with typed events and Last-Event-ID
Build (Dev) / build (push) Failing after 0s
CI/CD / lint-and-typecheck (push) Failing after 1s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
- Added monotonic event ID (Last-Event-ID) for reconnection support
- Events now emit typed: 'event: camera_status' for client-side filtering
- Initial connection event sends 'event: connected' with ID
- Reconnection acknowledgment via Last-Event-ID header
- CORS preflight (OPTIONS) handler
- Access-Control-Allow-Headers: Last-Event-ID
- Initial heartbeat shortened to 15s for faster detect (30s for steady)

Closes CUB-233.
2026-05-21 21:18:24 +00:00
Hermes f200cd9782 feat: add MQTT subscriber for ESP32 camera status ingestion
Implements MQTT subscriber (internal/mqtt/subscriber.go) that:
- Connects to Mosquitto broker with auto-reconnect
- Subscribes to remoterig/cameras/+/status, +/heartbeat, +/announce
- Parses and validates incoming messages per MQTT contract
- Inserts status_logs with duplicate prevention
- Auto-detects recording state changes and manages recording_events
- Broadcasts camera status changes via SSE hub
- Camera auto-registration via announce (MAC-based, sequential cam-NNN)
- Heartbeat watchdog marks cameras offline after 120s silence
- Wired into main.go with graceful degradation (warns if broker unreachable)

Dependency: github.com/eclipse/paho.mqtt.golang v1.5.0

Closes CUB-232.
2026-05-21 21:16:08 +00:00
Hermes ce188086cb docs: update CONTEXT.md with closed-network architecture and current state
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
- Replaced architecture diagram with travel router LAN design
- Added MQTT contract reference
- Updated issue status table with agent assignments
- Revised known limitations to reflect actual gaps
- Added network config section (subnet, static IP)
- Removed CUB-182 (canceled duplicate)

Post-sync: 13 done, 15 backlog, 1 in review, 1 canceled.
2026-05-21 21:11:33 +00:00
Hermes df212796d2 docs: add MQTT message format contract with closed-network architecture
Defines topic hierarchy, payload schemas, QoS levels, heartbeat
protocol, camera auto-discovery via announce topic, offline
buffering strategy, and command/response flow for start/stop.

Architecture: travel router subnet (192.168.4.x), Pi Zero 2 W
runs Mosquitto + Go backend, ESP32s dual-STA to GoPro AP +
travel router. No internet dependency.

Closes CUB-238.
2026-05-21 21:08:38 +00:00
overseer 861aedd6d8 CUB-208: Add README with project overview and setup instructions 2026-05-21 17:47:29 +00:00
Otto 02fa6e4d4f docs: add comprehensive project context file (CONTEXT.md) for agent reference
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 35s
2026-05-21 13:19:26 -04:00
overseer 07ecff3b5f Merge pull request 'CUB-196: CameraCard component with live SSE status display' (#3) from agent/hermes/CUB-196-cameracard into dev
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Reviewed-on: #3
2026-05-21 10:26:55 -04:00
overseer 69b050b62b CUB-181: Scaffold Go module, directory layout, config, and main.go entry point 2026-05-18 17:41:23 -04:00
39 changed files with 3857 additions and 39 deletions
+3 -10
View File
@@ -9,7 +9,7 @@ on:
env:
GO_VERSION: "1.23"
NODE_VERSION: "20"
BINARY_NAME: openclaw
BINARY_NAME: remoterig
jobs:
build:
@@ -29,20 +29,13 @@ jobs:
node-version: ${{ env.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
+25 -11
View File
@@ -7,10 +7,10 @@ on:
workflow_dispatch:
env:
BINARY_NAME: openclaw
BINARY_NAME: remoterig
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_PATH: /opt/openclaw/openclaw
DEPLOY_PATH: /opt/remoterig/remoterig
jobs:
deploy:
@@ -32,9 +32,9 @@ jobs:
cat > deploy.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BINARY="${1:-openclaw}"
DEPLOY_PATH="${2:-/opt/openclaw/openclaw}"
SERVICE="${3:-openclaw}"
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"
@@ -69,14 +69,23 @@ jobs:
SCRIPT
chmod +x deploy.sh
- name: Deploy config.yaml (if present)
run: |
if [ -f config.yaml ]; then
echo "config.yaml found, will deploy alongside binary"
echo "config.yaml" >> deploy-files.txt
else
echo "no config.yaml in repo, skipping"
fi
- name: Deploy to dev server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
source: "${{ env.BINARY_NAME }},deploy.sh"
target: "/tmp/openclaw-deploy"
source: "${{ env.BINARY_NAME }},deploy.sh,config.yaml"
target: "/tmp/remoterig-deploy"
- name: Execute deploy on dev server
uses: appleboy/ssh-action@v1
@@ -86,9 +95,14 @@ jobs:
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
cd /tmp/remoterig-deploy
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "remoterig"
if [ -f config.yaml ]; then
echo "::config:: deploying config.yaml"
sudo mkdir -p "$(dirname "${{ env.DEPLOY_PATH }}")"
sudo cp config.yaml "$(dirname "${{ env.DEPLOY_PATH }}")/config.yaml"
fi
rm -rf /tmp/remoterig-deploy
- name: Notify on failure
if: failure()
@@ -98,4 +112,4 @@ jobs:
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
echo "deploy failed for commit ${{ github.sha }} on ${{ github.repository }}" > /tmp/remoterig-deploy-failure.txt
+4
View File
@@ -13,6 +13,10 @@ dist
dist-ssr
*.local
# Frontend build output (embedded at Go build time)
# Allow the fallback placeholder so embed always has at least index.html
!src/dist/index.html
# Environment files
.env
.env.local
+172
View File
@@ -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.
+156
View File
@@ -0,0 +1,156 @@
# 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
```
## Building for Raspberry Pi Zero 2 W
Cross-compile from your development machine:
```bash
GOOS=linux GOARCH=arm GOARM=6 go build -o remoterig-hub ./cmd/server/
```
Copy the binary and `config.yaml` to your Pi:
```bash
scp remoterig-hub config.yaml pi@raspberrypi:/home/pi/remoterig/
```
Then run on the Pi:
```bash
./remoterig-hub
```
### Build Matrix
| Target | Command |
| ------ | ------- |
| Raspberry Pi Zero 2 W | `GOOS=linux GOARCH=arm GOARM=6 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.
+49
View File
@@ -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,6 +65,13 @@ 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)
@@ -76,6 +89,9 @@ func main() {
// API routes (auth required if API key is configured)
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
// Serve embedded React frontend with SPA fallback
r.Mount("/", frontendHandler())
// Create server
httpServer := &http.Server{
Addr: ":" + cfg.Port,
@@ -150,4 +166,37 @@ 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)
})
}
+364
View File
@@ -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: 10.60.1.0/24 │
│ DHCP pool: .100-.200 │
└──────┬──────────┬──────────┬──────────────┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
│ DHCP addr │ │ DHCP addr │ │ 10.60.1.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 │ │ 10.60.1.56:8080 │
└──────────────┘ └──────────────┘ └──────────────────┘
```
**Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP8266 boards talk to the GoPro AP over HTTP, then relay camera status/commands over UART to ESP32 boards. ESP32 boards stay on the travel-router LAN and bridge UART messages to MQTT.
### Key Architecture Decisions (revised)
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `10.60.1.0/24`.
- **Two-board camera node** — ESP8266 handles GoPro AP/HTTP; ESP32 stays on the travel-router LAN for MQTT. This avoids ESP32 dual-STA/channel switching complexity.
- **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: "10.60.1.0/24" # Travel router subnet
hub_ip: "10.60.1.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 WiFi 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 | |
+280
View File
@@ -0,0 +1,280 @@
# 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 (10.60.1.1) │
│ DHCP: .100-.200 │
└──────┬──────────┬──────────┬──────┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
│ 10.60.1.101 │ │ 10.60.1.102 │ │ 10.60.1.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 `10.60.1.1`. DHCP pool: `10.60.1.100-200`
- **Pi Zero 2 W:** Static IP `10.60.1.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
- **ESP32s:** DHCP from router. Each stays on the travel-router LAN, relays MQTT to/from its paired ESP8266 over UART
- **User device:** Connects to router, opens `http://10.60.1.56:8080` for dashboard
## MQTT Broker
- **Host:** `10.60.1.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 |
**Hub behavior on first announce:**
1. Check if MAC already registered → if yes, update `friendly_name` and log
2. If new MAC → create camera with auto-generated `camera_id = "cam-<NNN>"` (zero-padded sequential)
3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
4. Broadcast via SSE that a new camera appeared
### 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 0100. 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 (10.60.1.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 `10.60.1.56`.
2. **Camera naming:** Should `friendly_name` be configurable from dashboard after auto-registration? **Recommendation:** Yes — allow rename via UI, stored in cameras table.
3. **Firmware OTA:** Should ESP32 firmware updates be possible over this network? **Recommendation:** Yes but out of scope for MVP.
+134
View File
@@ -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 → 10.60.1.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 (10.60.1.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` | `"10.60.1.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. |
+8
View File
@@ -0,0 +1,8 @@
{
"wifi_ssid": "RemoteRig",
"wifi_password": "",
"mqtt_broker": "10.60.1.56",
"mqtt_port": 1883,
"camera_id": "",
"heartbeat_interval_sec": 60
}
+6
View File
@@ -0,0 +1,6 @@
{
"camera_ssid": "GOPRO-BP-",
"camera_password": "goprohero",
"camera_ip": "10.5.5.1",
"poll_interval_sec": 30
}
+67
View File
@@ -0,0 +1,67 @@
; 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 D1 Mini — camera bridge)
; pio run -e esp32-mqtt (ESP32 Dev Board — MQTT bridge)
;
; Upload:
; pio run -e esp8266-camera --target upload
; pio run -e esp32-mqtt --target upload
;
; Filesystem:
; pio run -e esp8266-camera --target uploadfs
; pio run -e esp32-mqtt --target uploadfs
[common]
lib_deps =
knolleary/PubSubClient @ ^2.8
bblanchon/ArduinoJson @ ^7.3
build_flags =
-D CORE_DEBUG_LEVEL=0
; ── ESP8266: Camera Bridge ──────────────────────────────────
; Flashed onto D1 Mini. Talks to GoPro over Wi-Fi, relays to
; ESP32 over UART (TX/RX pins). No MQTT, no router connection.
[env:esp8266-camera]
platform = espressif8266
board = d1_mini
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps = ${common.lib_deps}
build_flags = ${common.build_flags}
-D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED
board_build.flash_mode = dio
board_build.f_cpu = 160000000L
build_src_filter =
+<../lib/>
+<esp8266-camera-bridge.cpp>
-<*.cpp>
; ── ESP32: MQTT Bridge ─────────────────────────────────────
; Flashed onto ESP32 Dev Board. Connects to travel router,
; publishes MQTT to Pi hub. Reads camera status from ESP8266
; over UART2 (RX16/TX17). No direct camera communication.
[env:esp32-mqtt]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps = ${common.lib_deps}
build_flags = ${common.build_flags}
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
build_src_filter =
+<../lib/>
+<esp32-mqtt-bridge.cpp>
-<*.cpp>
+332
View File
@@ -0,0 +1,332 @@
/**
* 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:
* - ESP32 Dev Board (or D1 Mini ESP32)
* - UART2: RX=GPIO16, TX=GPIO17 (connected to ESP8266)
* - Shared GND between boards
* - LiPo → 3.3V buck → VIN on both boards
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <SPIFFS.h>
// ────────────────────────────────────────────
// Configuration (SPIFFS)
// ────────────────────────────────────────────
struct Config {
String wifi_ssid = "RemoteRig";
String wifi_password = "";
String mqtt_broker = "10.60.1.56";
int mqtt_port = 1883;
String camera_id = ""; // assigned by hub
int heartbeat_sec = 60;
} cfg;
bool loadConfig() {
if (!SPIFFS.begin(true)) { Serial.println("[CFG] SPIFFS mount failed"); return false; }
File f = SPIFFS.open("/config.json", "r");
if (!f) { Serial.println("[CFG] No config — 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;
return true;
}
bool saveConfig() {
File f = SPIFFS.open("/config.json", "w");
if (!f) return false;
JsonDocument doc;
doc["wifi_ssid"] = cfg.wifi_ssid;
doc["wifi_password"] = cfg.wifi_password;
doc["mqtt_broker"] = cfg.mqtt_broker;
doc["mqtt_port"] = cfg.mqtt_port;
doc["camera_id"] = cfg.camera_id;
doc["heartbeat_interval_sec"] = cfg.heartbeat_sec;
serializeJson(doc, f);
f.close();
return true;
}
// ────────────────────────────────────────────
// UART to ESP8266 (HardwareSerial2)
// ────────────────────────────────────────────
// ESP32 UART2: RX=GPIO16, TX=GPIO17
// Connect: ESP32 RX(16) ← ESP8266 TX
// ESP32 TX(17) → ESP8266 RX
#define UART_ESP8266 Serial2
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 == "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");
// Subscribe to commands (if registered)
if (cfg.camera_id.length() > 0) {
mqtt.subscribe(mqttTopic("command").c_str(), 2);
}
// Announce if new
if (cfg.camera_id.length() == 0) {
JsonDocument doc;
doc["mac_address"] = WiFi.macAddress();
doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge";
doc["friendly_name"] = "Cam-" + clientID();
JsonArray caps = doc["capabilities"].to<JsonArray>();
caps.add("start_stop"); caps.add("status");
String payload; serializeJson(doc, payload);
mqtt.publish("remoterig/cameras/announce-" + clientID(), payload.c_str(), true);
Serial.println("[MQTT] Announced for registration");
}
return true;
}
// ────────────────────────────────────────────
// Setup
// ────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0");
bootMs = millis();
pinMode(2, OUTPUT); // built-in LED
digitalWrite(2, LOW);
loadConfig();
// UART to ESP8266
UART_ESP8266.begin(115200, SERIAL_8N1, 16, 17); // RX=16, TX=17
Serial.println("[UART] ESP8266 link on RX16/TX17 @ 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;
// ── 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;
if (online != cameraOnline) {
cameraOnline = online;
digitalWrite(2, online ? HIGH : LOW);
}
if (cfg.camera_id.length() > 0) {
// Build the MQTT status payload per contract
JsonDocument mqttDoc;
mqttDoc["camera_id"] = cfg.camera_id;
mqttDoc["timestamp"] = millis();
mqttDoc["battery_raw"] = doc["battery_raw"] | 0;
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"] | "?").c_str());
}
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"] | "?").c_str());
}
}
// ── 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);
}
+303
View File
@@ -0,0 +1,303 @@
/**
* 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:
* - ESP8266 D1 Mini (or NodeMCU)
* - UART TX → ESP32 RX (GPIO 16)
* - UART RX → ESP32 TX (GPIO 16)
* - Shared GND between boards
* - LiPo → 3.3V buck → VIN on both boards
*/
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
// ────────────────────────────────────────────
// Configuration (SPIFFS via LittleFS)
// ────────────────────────────────────────────
struct Config {
String camera_ssid = "GOPRO-BP-";
String camera_password = "goprohero";
String camera_ip = "10.5.5.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;
}
// ────────────────────────────────────────────
// 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;
String url = "http://" + cfg.camera_ip +
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
HTTPClient http;
http.useHTTP10(true);
http.begin(goproClient, url);
http.setTimeout(5000);
int code = http.GET();
if (code != 200) { http.end(); return s; }
String raw = http.getString();
http.end();
if (raw.length() < 58) return s;
const uint8_t* buf = (const uint8_t*)raw.c_str();
s.valid = true;
s.video_remaining_sec = buf[25] | (buf[26] << 8);
s.recording = (buf[29] == 1);
s.battery_raw = buf[57];
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 {
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;
}
// ────────────────────────────────────────────
// LED
// ────────────────────────────────────────────
const int LED = LED_BUILTIN; // active-low on ESP8266 D1 Mini
void ledOn() { digitalWrite(LED, LOW); }
void ledOff() { digitalWrite(LED, HIGH); }
// ────────────────────────────────────────────
// Setup
// ────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n[BRIDGE] ESP8266 Camera Bridge v1.0");
pinMode(LED, OUTPUT);
ledOff();
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());
ledOn(); // Solid = connected
} 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;
static bool cameraOnline = false;
// ── 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();
if (s.valid && !cameraOnline) {
cameraOnline = true;
ledOn();
} else if (!s.valid && cameraOnline) {
cameraOnline = false;
ledOff();
}
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
}
}
// ── LED blink when offline ──
if (!cameraOnline) {
static unsigned long lastBlink = 0;
if (now - lastBlink > 500) {
lastBlink = now;
digitalWrite(LED, !digitalRead(LED));
}
}
}
+1
View File
@@ -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
+67
View File
@@ -0,0 +1,67 @@
# RemoteRig Hardware Design Pipeline
> Living queue for 3D-printed / physical hardware design work.
## Active / Ready for prototype print
### Tripod electronics case v3
**Status:** STL generated and validated watertight.
**Files:**
- `hardware/case/tripod-case-v3.scad`
- `hardware/case/case-body-v3.stl`
- `hardware/case/case-lid-v3.stl`
- `hardware/case/tripod-clamp-v3.stl`
- `hardware/case/full-case-preview-v3.stl`
**Design notes:**
- Holds 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 uses M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
**Prototype questions:**
- Does the clamp close enough on smaller tripod legs, or do we need swappable inserts?
- Does the dovetail hold under vibration without a retention screw?
- Are USB/LED/UART cutouts in the correct orientation for the actual boards?
## 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.
+112
View File
@@ -0,0 +1,112 @@
# RemoteRig — Camera Node Hardware Design
> **Version:** 0.2.0 | **Status:** Draft
> **Target:** GoPro Hero 3 Black/Silver + ESP8266 + ESP32 + USB power bank
## Overview
Each camera node is two ESP boards in a small case that clips to the tripod/stand. The case **does not attach to the camera** — only to the stand. Powered by a standard USB power bank.
```
┌─────────────────┐
│ USB Power Bank │── USB ──→ GoPro (power only)
│ (off-the-shelf)│── USB ──→ ESP32 + ESP8266 (shared)
└─────────────────┘
┌────────┴────────┐
│ Tripod Case │ ← clips to stand leg
│ ┌────────────┐ │
│ │ ESP8266 │ │ ← Wi-Fi → GoPro AP (10.5.5.1)
│ │ (camera) │ │
│ ├────────────┤ │ ← UART between boards
│ │ ESP32 │ │ ← Wi-Fi → Travel Router
│ │ (MQTT) │ │
│ └────────────┘ │
└─────────────────┘
```
## Bill of Materials
| Item | Qty | Cost | Notes |
|------|-----|------|-------|
| ESP32 Dev Board | 1 | ~$5 | MQTT bridge — talks to hub |
| ESP8266 D1 Mini | 1 | ~$3 | Camera bridge — talks to GoPro |
| USB power bank (5000mAh+) | 1 | ~$10 | Powers both boards + GoPro |
| Micro-USB cable (short) | 2 | ~$2 | Power bank → boards + GoPro |
| Jumper wires F-F | 3 | ~$0.25 | UART TX/RX/GND between boards |
| PETG filament | ~25g | ~$0.50 | 3D printed case |
| Velcro strap (small) | 1 | ~$0.25 | Secure power bank to stand |
**Total per node:** ~$21 (+ GoPro already owned)
## 3D Printed Case
**Current source:** `hardware/case/tripod-case-v3.scad`
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
Four exported prototype files:
1. **Case body** — holds both boards stacked, cable ports, rear dovetail-style receiver
2. **Case lid** — screw-on cover with ventilation
3. **Tripod clamp** — separate screw-tightened C-clamp sized around a 35mm stand/pole
4. **Full preview** — combined visualization STL only, not intended as the print job
### Print Settings
- **Material:** PETG preferred for heat/outdoor use and clamp flex
- **Layer:** 0.2mm | **Infill:** 20% gyroid minimum; 35%+ recommended for clamp
- **Supports:** Likely yes for clamp ears / dovetail overhangs depending on slicer orientation
- **Post-processing:** M3x8mm screws for lid (4x), one M3 screw + M3 nut for clamp tightening
## Wiring
```
USB Power Bank
├── USB-A → Micro-USB cable → ESP32 USB port
│ (powers ESP32, shared 5V rail)
├── USB-A → Micro-USB cable → GoPro USB port
│ (power only — no data)
└── (ESP8266 powered via ESP32 3.3V pin, or via shared USB)
UART (inside case):
ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16)
ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17)
ESP8266 GND ─────────── ESP32 GND
```
**Power note:** Both boards can be powered from a single USB cable if the ESP32's VIN/5V pin is bridged to the ESP8266's VIN. Alternatively, use a USB Y-splitter cable.
## Wi-Fi Topology
```
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP8266 (camera bridge)
UART │ (inside case)
Travel Router ──(AP)─────────────────→ ESP32 (MQTT bridge)
(10.60.1.1) │
MQTT │
Pi Hub (10.60.1.56)
```
The ESP8266 and GoPro talk over Wi-Fi — **no data cable between them**. The only cable to the GoPro is USB power from the battery pack.
## Field Setup
1. **Mount GoPro** on tripod/stand
2. **Clip case** to tripod leg
3. **Connect power bank** via USB to case + GoPro
4. **Power on** — ESP32 auto-connects to travel router, ESP8266 auto-connects to GoPro
5. **Monitor** from `http://10.60.1.56:8080`
## Case Dimensions
| | W × D × H (mm) |
|---|---|
| Case body external | ~56.8 × 38.2 × 19.0 |
| Lid external | ~56.8 × 32.8 × 4.0 |
| Tripod clamp | ~43.0 × 56.9 × 16.0 |
| Clamp pole fit | Nominal 35mm; smaller poles TBD / may need inserts |
| Total weight | TBD after prototype print |
Binary file not shown.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) case_body();
+2
View File
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) case_lid();
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) full_case();
@@ -0,0 +1,2 @@
include <tripod-case-v3.scad>;
render(convexity=10) tripod_clamp();
Binary file not shown.
+217
View File
@@ -0,0 +1,217 @@
// RemoteRig — Dual-ESP Tripod Case v3
// v3 changes: screw-tightened tripod clamp + dovetail slide interface.
// Coordinate system: all case/lid geometry uses bottom-origin Z.
$fn = 36;
// Board dimensions
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
esp32_w = 52; esp32_d = 28; esp32_h = 5;
board_gap = 3;
stack_h = esp8266_h + esp32_h + board_gap;
inner_w = max(esp8266_w, esp32_w);
inner_d = max(esp8266_d, esp32_d);
inner_h = stack_h + 2;
// Case parameters
wall = 2.0;
tol = 0.4;
outer_w = inner_w + wall*2 + tol*2; // 56.8mm
outer_d = inner_d + wall*2 + tol*2; // 32.8mm
outer_h = inner_h + wall*2; // 19mm
corner_r = 2.5;
// Tripod clamp parameters
pole_dia = 35; // nominal stand/pole diameter
clamp_thick = 4.0; // ring wall thickness
clamp_width = 16.0; // extrusion width along Z
mouth_width = 13.0; // clamp opening
m3_clearance = 3.4; // M3 screw clearance
nut_flat = 6.4; // M3 nut trap flat-to-flat
// Dovetail slide interface
// Male rail is on the case; matching female socket is on the tripod clamp.
// This is easier to inspect and avoids the previous mismatched "two lips + tab" geometry.
rail_z = outer_h * 0.78;
rail_depth = 5.0;
rail_neck_w = 12.0; // narrow width at case wall / slot opening
rail_outer_w = 18.0; // wider retained edge
rail_clearance = 0.45; // FDM sliding clearance per side-ish
socket_wall = 2.2;
// Cable ports
usb_port_w = 12; usb_port_h = 6;
uart_port_w = 6; uart_port_h = 4;
// Uncomment one for manual OpenSCAD use
// full_case();
// case_body();
// case_lid();
// tripod_clamp();
module rounded_cube_centered(w, d, h, r) {
hull() {
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
sphere(r=r, $fn=24);
}
}
}
module rounded_cube0(w, d, h, r) {
translate([0, 0, h/2]) rounded_cube_centered(w, d, h, r);
}
module hex_prism(d, h) {
cylinder(d=d, h=h, center=true, $fn=6);
}
module dovetail_prism(length_z, front_w, back_w, depth) {
// 2D profile is X/Y, extruded along Z.
rotate([0, 0, 0])
linear_extrude(height=length_z, center=true, convexity=10)
polygon(points=[
[-front_w/2, 0], [front_w/2, 0],
[back_w/2, depth], [-back_w/2, depth]
]);
}
module case_shell() {
difference() {
rounded_cube0(outer_w, outer_d, outer_h, corner_r);
// Open internal cavity: starts above bottom wall, extends past top.
translate([0, 0, wall])
rounded_cube0(inner_w + tol, inner_d + tol, outer_h + 2, 1.6);
// USB power IN / OUT ports through front/back walls.
translate([0, outer_d/2 + 0.1, wall + 4])
cube([usb_port_w, wall*3, usb_port_h], center=true);
translate([0, -outer_d/2 - 0.1, wall + 4])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// UART side channel.
translate([outer_w/2 + 0.1, 0, wall + 6])
cube([wall*3, uart_port_w, uart_port_h], center=true);
// LED viewing window on front lower wall.
translate([-outer_w/4, -outer_d/2 - 0.1, wall + 2])
cube([6, wall*2, 3], center=true);
}
}
module screw_post(x, y) {
difference() {
translate([x, y, wall]) cylinder(d=5.0, h=outer_h-wall-0.5, center=false, $fn=24);
translate([x, y, wall-0.5]) cylinder(d=2.1, h=outer_h+1, center=false, $fn=20);
}
}
module case_male_dovetail_rail() {
// Positive tapered rail on the case back. Cross-section is narrow at the
// wall and wider at the outside, so the clamp socket captures it.
translate([0, outer_d/2 - 0.15, outer_h/2])
dovetail_prism(rail_z, rail_neck_w, rail_outer_w, rail_depth);
// Bottom stop so the clamp socket cannot slide past the case.
translate([0, outer_d/2 + rail_depth/2, outer_h*0.12])
rounded_cube_centered(rail_outer_w + 3.0, rail_depth + 0.8, 2.4, 0.8);
}
module case_body() {
union() {
case_shell();
for (x = [-1, 1], y = [-1, 1])
screw_post(x*(outer_w/2 - 5), y*(outer_d/2 - 5));
case_male_dovetail_rail();
}
}
module case_lid() {
difference() {
rounded_cube0(outer_w, outer_d, wall*2, 1.8);
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), -0.5])
cylinder(d=2.4, h=wall*2 + 1, center=false, $fn=20);
}
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, wall*2/2])
cube([8, outer_d*0.6, wall*3], center=true);
}
}
}
module clamp_ring_with_mouth() {
outer_r = pole_dia/2 + clamp_thick;
difference() {
cylinder(r=outer_r, h=clamp_width, center=true, $fn=72);
cylinder(r=pole_dia/2 + rail_clearance, h=clamp_width + 1, center=true, $fn=72);
// Mouth opens toward +Y. Width is intentionally generous for snap-on placement before tightening.
translate([0, outer_r, 0])
cube([mouth_width, outer_r*2, clamp_width + 2], center=true);
}
}
module clamp_ears() {
outer_r = pole_dia/2 + clamp_thick;
ear_y = outer_r + 2.2;
ear_z = 0;
difference() {
union() {
translate([-mouth_width/2 - 3.2, ear_y, ear_z])
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
translate([ mouth_width/2 + 3.2, ear_y, ear_z])
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
}
// M3 screw passes across the mouth along X.
translate([0, ear_y, ear_z])
rotate([0, 90, 0]) cylinder(d=m3_clearance, h=mouth_width + 24, center=true, $fn=24);
// Nut trap on the right ear.
translate([mouth_width/2 + 3.2, ear_y, ear_z])
rotate([0, 90, 0]) hex_prism(nut_flat, 4.2);
}
}
module clamp_dovetail_socket() {
outer_r = pole_dia/2 + clamp_thick;
socket_outer_w = rail_outer_w + socket_wall*2;
socket_depth = rail_depth + socket_wall*2;
// Solid boss on the rear of the clamp, opposite the tightening mouth.
// A matching dovetail void is cut through it along Z so the case rail
// slides in from the top/bottom with practical FDM clearance.
difference() {
translate([0, -outer_r - socket_depth/2 + socket_wall, 0])
rounded_cube_centered(socket_outer_w, socket_depth, clamp_width, 1.2);
translate([0, -outer_r - 0.15, 0])
dovetail_prism(
clamp_width + 1.0,
rail_neck_w + rail_clearance,
rail_outer_w + rail_clearance,
rail_depth + 0.6
);
}
}
module tripod_clamp() {
union() {
clamp_ring_with_mouth();
clamp_ears();
clamp_dovetail_socket();
}
}
// Backward-compatible alias for earlier export scripts.
module tripod_clip() {
tripod_clamp();
}
module full_case() {
case_body();
translate([0, 0, outer_h + 2]) case_lid();
translate([0, outer_d/2 + pole_dia/2 + clamp_thick + 8, outer_h/2])
rotate([90, 0, 0]) tripod_clamp();
}
+201
View File
@@ -0,0 +1,201 @@
// RemoteRig — Dual-ESP Tripod Case
// =================================
// Small box that clips onto a tripod leg or light stand pole.
// Holds ESP8266 D1 Mini + ESP32 Dev Board (stacked).
// Powered by standard USB battery pack. No camera sleeve needed.
//
// Print settings:
// Material: PETG | Layer: 0.2mm | Infill: 20% gyroid
// Supports: yes (for clip overhang) | Brim: 5mm
// ── Board dimensions ──
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
esp32_w = 52; esp32_d = 28; esp32_h = 5;
board_gap = 3; // air gap between stacked boards
stack_h = esp8266_h + esp32_h + board_gap;
inner_w = max(esp8266_w, esp32_w);
inner_d = max(esp8266_d, esp32_d);
inner_h = stack_h + 2;
// ── Case parameters ──
wall = 2.0;
tol = 0.4;
outer_w = inner_w + wall*2 + tol*2;
outer_d = inner_d + wall*2 + tol*2;
outer_h = inner_h + wall*2;
// ── Tripod clip parameters ──
pole_min_dia = 20; // smallest pole
pole_max_dia = 35; // largest pole
clip_width = 12; // clip width
clip_thick = 3; // clip arm thickness
clip_grip = 2; // grip ridges
// ── Cable ports ──
usb_port_w = 12; usb_port_h = 6;
uart_port_w = 6; uart_port_h = 4;
// ══════════════════════════════════════════════════════════════
// MAIN — render the full case
// ══════════════════════════════════════════════════════════════
// Uncomment to render individual parts:
full_case();
// case_body();
// case_lid();
// tripod_clip();
module full_case() {
case_body();
// Lid positioned above (for visualization)
translate([0, 0, outer_h + 2])
case_lid();
// Clip on the back
translate([0, outer_d/2 + pole_max_dia/2 + clip_thick, outer_h/2])
tripod_clip();
}
// ══════════════════════════════════════════════════════════════
// Case Body — holds both boards, cable ports
// ══════════════════════════════════════════════════════════════
module case_body() {
difference() {
// Outer shell
rounded_cube(outer_w, outer_d, outer_h, 3);
// Inner cavity
translate([0, 0, wall])
rounded_cube(inner_w + tol, inner_d + tol, inner_h + tol, 2);
// ── Board recesses ──
// Bottom: ESP32 (larger board)
translate([0, 0, wall + 1])
cube([esp32_w + tol, esp32_d + tol, esp32_h + 1], center=true);
// Top: ESP8266 (smaller board)
translate([0, 0, wall + esp32_h + board_gap + 1])
cube([esp8266_w + tol, esp8266_d + tol, esp8266_h + 1], center=true);
// ── Cable ports ──
// USB power IN (from battery pack → ESP32)
translate([0, outer_d/2, outer_h/3])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// USB power OUT (from battery pack → GoPro)
translate([0, -outer_d/2, outer_h/3])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// UART wire channel (ESP8266 → ESP32 internal)
translate([outer_w/2, 0, outer_h/2])
cube([wall*3, uart_port_w, uart_port_h], center=true);
// ── Ventilation slots (top edge) ──
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, outer_h - wall])
cube([8, outer_d*0.6, 2], center=true);
}
// ── Screw posts for lid ──
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), outer_h/2])
cylinder(d=3.2, h=outer_h, center=true, $fn=16);
}
// ── LED window (thin spot to see board LEDs) ──
translate([-outer_w/4, -outer_d/2, wall])
cube([6, 1, 3], center=true);
}
// ── Tripod clip mount (rail on back) ──
translate([0, outer_d/2, outer_h/2])
rotate([90, 0, 0])
difference() {
cube([clip_width + 4, outer_h*0.7, 6], center=true);
// T-slot for clip to slide in
cube([clip_width + 1, outer_h*0.7 + 1, 4], center=true);
}
}
// ══════════════════════════════════════════════════════════════
// Case Lid — snap-fit or screw-on cover
// ══════════════════════════════════════════════════════════════
module case_lid() {
difference() {
rounded_cube(outer_w, outer_d, wall*2, 2);
// Screw holes (match body posts)
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), 0])
cylinder(d=3.2, h=wall*3, center=true, $fn=16);
}
// Ventilation slots (match body)
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, 0])
cube([8, outer_d*0.6, 3], center=true);
}
}
}
// ══════════════════════════════════════════════════════════════
// Tripod Clip — C-clamp for pole mounting
// ══════════════════════════════════════════════════════════════
module tripod_clip() {
difference() {
union() {
// Main body
hull() {
translate([0, -pole_max_dia/2 - clip_thick, 0])
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
translate([0, pole_max_dia/2 + clip_thick, 0])
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
}
// Top arm (flexible)
translate([0, -pole_max_dia/2 - clip_thick, outer_h*0.35])
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
// Bottom arm
translate([0, -pole_max_dia/2 - clip_thick, -outer_h*0.35])
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
// Mounting tab (slides into case rail)
translate([0, -pole_max_dia/2 - clip_thick*3, 0])
cube([clip_width + 1, clip_thick*2, outer_h*0.7], center=true);
}
// Pole hole
cylinder(d=pole_max_dia + 2, h=outer_h*1.5, center=true, $fn=32);
// Grip ridges on inner surface
for (z = [-outer_h*0.25, 0, outer_h*0.25]) {
translate([0, 0, z])
rotate_extrude(angle=180, $fn=32)
translate([pole_max_dia/2 + 0.5, 0])
circle(d=1);
}
// Entry slot (pole slides in from front)
translate([0, pole_max_dia/2 + clip_thick, 0])
cube([clip_width + 2, pole_max_dia + 10, outer_h*0.7], center=true);
}
}
// ══════════════════════════════════════════════════════════════
// Utility: rounded cube
// ══════════════════════════════════════════════════════════════
module rounded_cube(w, d, h, r) {
hull() {
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
sphere(r=r, $fn=20);
}
}
}
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteRig Case — 3D Viewer</title>
<style>
body { margin: 0; overflow: hidden; background: #1a1a2e; font-family: system-ui; }
canvas { display: block; }
#info {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
color: #888; font-size: 13px; pointer-events: none;
}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ── Scene setup ──
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
scene.fog = new THREE.Fog(0x1a1a2e, 8, 25);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.5, 50);
camera.position.set(5, 3.5, 7);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ── Lighting ──
const ambient = new THREE.AmbientLight(0x404060, 0.6);
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 1.2);
key.position.set(8, 10, 5);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 0.5; key.shadow.camera.far = 50;
key.shadow.camera.left = -10; key.shadow.camera.right = 10;
key.shadow.camera.top = 10; key.shadow.camera.bottom = -10;
scene.add(key);
const fill = new THREE.DirectionalLight(0x8899cc, 0.4);
fill.position.set(-3, 2, -2);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xaaccff, 0.5);
rim.position.set(0, 1, -5);
scene.add(rim);
// ── Ground ──
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.8 })
);
ground.rotation.x = -Math.PI/2;
ground.position.y = -3;
ground.receiveShadow = true;
scene.add(ground);
// ── Materials ──
const petgMat = new THREE.MeshStandardMaterial({
color: 0x3d3d4a, roughness: 0.35, metalness: 0.1,
});
const accentMat = new THREE.MeshStandardMaterial({
color: 0xf59e0b, roughness: 0.3, metalness: 0.2, emissive: 0x331100, emissiveIntensity: 0.3
});
const boardMat = new THREE.MeshStandardMaterial({
color: 0x1a6630, roughness: 0.6
});
const metalMat = new THREE.MeshStandardMaterial({
color: 0x888899, roughness: 0.3, metalness: 0.8
});
// ── Create rounded box with bevel ──
function createRoundedBox(w, h, d, r, segments = 3) {
const shape = new THREE.Shape();
const hw = w/2 - r, hh = h/2 - r;
shape.moveTo(-hw, -hh + r);
shape.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
shape.lineTo(hw - r, -hh);
shape.quadraticCurveTo(hw, -hh, hw, -hh + r);
shape.lineTo(hw, hh - r);
shape.quadraticCurveTo(hw, hh, hw - r, hh);
shape.lineTo(-hw + r, hh);
shape.quadraticCurveTo(-hw, hh, -hw, hh - r);
shape.closePath();
const extrudeSettings = { depth: d - r*2, bevelEnabled: true, bevelThickness: r, bevelSize: r, bevelSegments: segments };
const geom = new THREE.ExtrudeGeometry(shape, extrudeSettings);
geom.translate(0, 0, -d/2 + r);
return geom;
}
// ── Case Body ──
const caseW = 2.5, caseH = 1.5, caseD = 1.1;
const bodyGeom = createRoundedBox(caseW, caseD, caseH, 0.12);
const body = new THREE.Mesh(bodyGeom, petgMat);
body.castShadow = true; body.receiveShadow = true;
scene.add(body);
// ── Lid (slightly offset) ──
const lidGeom = createRoundedBox(caseW, caseD, 0.15, 0.08);
const lid = new THREE.Mesh(lidGeom, petgMat);
lid.position.y = caseH/2 + 0.07;
lid.castShadow = true;
scene.add(lid);
// ── Ventilation slots ──
for (let i = -0.6; i <= 0.6; i += 0.6) {
const slot = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.04, caseD * 0.7),
new THREE.MeshStandardMaterial({ color: 0x1a1a2e })
);
slot.position.set(i, caseH/2 + 0.15, 0);
scene.add(slot);
}
// ── Screws ──
for (let x = -1; x <= 1; x += 2) {
for (let z = -0.35; z <= 0.35; z += 0.7) {
const screw = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.05, 0.04, 8),
metalMat
);
screw.position.set(x * (caseW/2 - 0.2), caseH/2 + 0.15, z);
scene.add(screw);
}
}
// ── Boards inside (semi-visible) ──
const esp32Board = new THREE.Mesh(
new THREE.BoxGeometry(caseW - 0.3, 0.04, caseD - 0.2),
boardMat
);
esp32Board.position.set(0, caseH/2 - 0.15, 0);
esp32Board.castShadow = true;
scene.add(esp32Board);
const esp8266Board = new THREE.Mesh(
new THREE.BoxGeometry(caseW - 0.5, 0.04, caseD - 0.3),
boardMat
);
esp8266Board.position.set(0, caseH/2 - 0.08, 0);
esp8266Board.castShadow = true;
scene.add(esp8266Board);
// Chip on ESP32
const chip = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.03, 0.3),
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
);
chip.position.set(0, caseH/2 - 0.12, 0);
scene.add(chip);
// LED
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.03, 8, 8),
new THREE.MeshStandardMaterial({ color: 0x00ff44, roughness: 0.2, emissive: 0x00ff44, emissiveIntensity: 1.5 })
);
led.position.set(-0.8, caseH/2 - 0.12, -0.3);
scene.add(led);
// ── USB Port (front face) ──
const usbPort = new THREE.Mesh(
new THREE.BoxGeometry(0.35, 0.02, 0.15),
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
);
usbPort.position.set(0, 0.2, caseD/2);
scene.add(usbPort);
// ── Tripod Clip ──
const clipGroup = new THREE.Group();
clipGroup.position.set(0, 0, -caseD/2 - 0.7);
// Clip arms
for (let y = -0.4; y <= 0.4; y += 0.8) {
const arm = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.08, 0.8),
petgMat
);
arm.position.set(0, y, 0.3);
arm.castShadow = true;
clipGroup.add(arm);
}
// Clip body
const clipBody = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 1.0, 0.15),
petgMat
);
clipBody.position.set(0, 0, -0.1);
clipBody.castShadow = true;
clipGroup.add(clipBody);
scene.add(clipGroup);
// ── Tripod Pole ──
const poleGeom = new THREE.CylinderGeometry(0.35, 0.35, 6, 24);
const poleMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.4, metalness: 0.3 });
const pole = new THREE.Mesh(poleGeom, poleMat);
pole.position.set(0, 0, -caseD/2 - 1.2);
pole.castShadow = true; pole.receiveShadow = true;
scene.add(pole);
// ── USB Cables ──
function createCable(start, end, color = 0x222233) {
const curve = new THREE.CubicBezierCurve3(
start,
new THREE.Vector3(start.x + 0.5, start.y - 0.5, start.z + 0.2),
new THREE.Vector3(end.x - 0.3, end.y - 0.3, end.z + 0.1),
end
);
const geom = new THREE.TubeGeometry(curve, 20, 0.03, 8, false);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6 });
return new THREE.Mesh(geom, mat);
}
const cable1 = createCable(
new THREE.Vector3(0, 0.2, caseD/2),
new THREE.Vector3(-2, -1, 1)
);
cable1.castShadow = true;
scene.add(cable1);
const cable2 = createCable(
new THREE.Vector3(0.1, 0.2, caseD/2),
new THREE.Vector3(2, -1.5, 1.2),
0x332222
);
cable2.castShadow = true;
scene.add(cable2);
// ── Interaction ──
let isDragging = false, prevMouse = { x: 0, y: 0 };
let rotY = 0.4, rotX = 0.3, zoom = 7;
document.addEventListener('mousedown', e => { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
document.addEventListener('mouseup', () => isDragging = false);
document.addEventListener('mousemove', e => {
if (!isDragging) return;
rotY += (e.clientX - prevMouse.x) * 0.005;
rotX += (e.clientY - prevMouse.y) * 0.005;
rotX = Math.max(-0.8, Math.min(1.2, rotX));
prevMouse = { x: e.clientX, y: e.clientY };
});
document.addEventListener('wheel', e => {
zoom += e.deltaY * 0.005;
zoom = Math.max(3, Math.min(15, zoom));
});
// ── Render loop ──
function animate() {
requestAnimationFrame(animate);
camera.position.x = zoom * Math.sin(rotY) * Math.cos(rotX);
camera.position.y = zoom * Math.sin(rotX);
camera.position.z = zoom * Math.cos(rotY) * Math.cos(rotX);
camera.lookAt(0, -0.1, 0);
renderer.render(scene, camera);
}
animate();
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
+2 -2
View File
@@ -132,10 +132,10 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
// Get camera info
var c models.Camera
err := database.QueryRowContext(r.Context(), `
SELECT camera_id, friendly_name, mac_address, created_at, updated_at
SELECT camera_id, friendly_name, mac_address, battery_calibration_offset, created_at, updated_at
FROM cameras WHERE camera_id = ?
`, cameraID).Scan(
&c.CameraID, &c.FriendlyName, &c.MacAddress,
&c.CameraID, &c.FriendlyName, &c.MacAddress, &c.BatteryCalibrationOffset,
&c.CreatedAt, &c.UpdatedAt,
)
if err == sql.ErrNoRows {
+28
View File
@@ -14,6 +14,9 @@ import (
//go:embed migrations/001_create_tables.sql
var migration001 string
//go:embed migrations/002_add_camera_calibration.sql
var migration002 string
// DB wraps the sql.DB with connection-level settings.
type DB struct {
*sql.DB
@@ -62,6 +65,12 @@ func Open(path string) (*DB, error) {
return nil, err
}
log.Println("Migrations complete")
} else {
// Run incremental migrations on existing databases
if err := runIncrementalMigrations(db); err != nil {
db.Close()
return nil, err
}
}
return &DB{db}, nil
@@ -83,6 +92,25 @@ func migrate(db *sql.DB, sql string) error {
return nil
}
// runIncrementalMigrations applies migrations that haven't been run yet on
// an existing database (one where the 001 schema already exists).
func runIncrementalMigrations(db *sql.DB) error {
// Migration 002: add battery_calibration_offset if it doesn't exist
var colCount int
err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('cameras') WHERE name = 'battery_calibration_offset'`).Scan(&colCount)
if err != nil {
return err
}
if colCount == 0 {
log.Println("Running migration 002: add battery_calibration_offset")
if err := migrate(db, migration002); err != nil {
return err
}
}
return nil
}
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
func splitSQL(sql string) []string {
var stmts []string
+6 -5
View File
@@ -3,11 +3,12 @@
-- Cameras table: registry of all GoPro cameras
CREATE TABLE IF NOT EXISTS cameras (
camera_id TEXT PRIMARY KEY,
friendly_name TEXT NOT NULL,
mac_address TEXT UNIQUE,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
camera_id TEXT PRIMARY KEY,
friendly_name TEXT NOT NULL,
mac_address TEXT UNIQUE,
battery_calibration_offset REAL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
@@ -0,0 +1,5 @@
-- Migration 002: Add battery_calibration_offset to cameras table
-- This column stores a per-camera calibration value for converting raw battery
-- readings (e.g. GoPro Hero 3 byte at offset 57) into percentage values.
ALTER TABLE cameras ADD COLUMN battery_calibration_offset REAL;
+31 -6
View File
@@ -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")
}
}
+434
View File
@@ -0,0 +1,434 @@
// 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"`
}
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 == "" || sp.Timestamp == "" {
log.Printf("MQTT status missing required fields (camera_id, timestamp) from %s", cameraID)
return
}
// Validate timestamp sanity (reject >5min future, >24h past)
ts, err := time.Parse(time.RFC3339, sp.Timestamp)
if err != nil {
// Try ISO8601 without timezone
ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp)
if err != nil {
log.Printf("MQTT status invalid timestamp %q from %s", sp.Timestamp, cameraID)
return
}
}
now := time.Now()
if ts.After(now.Add(5 * time.Minute)) {
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
}
// 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"`
Timestamp string `json:"timestamp"`
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 {
// Already registered — just update 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 {
// New camera — generate sequential cam-NNN ID
var maxID string
s.db.QueryRow("SELECT MAX(camera_id) FROM cameras").Scan(&maxID)
seq := 1
if maxID != "" {
fmt.Sscanf(maxID, "cam-%d", &seq)
seq++
}
newID := fmt.Sprintf("cam-%03d", seq)
_, err = s.db.Exec(`
INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at)
VALUES (?, ?, ?, datetime('now'), datetime('now'))
`, newID, 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)", newID, ap.FriendlyName)
// Broadcast new camera via SSE
cam, err := getCamera(s.db, newID)
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,''), COALESCE(battery_calibration_offset, NULL), created_at, updated_at FROM cameras WHERE camera_id = ?",
cameraID,
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.BatteryCalibrationOffset, &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")
}
}
+6 -5
View File
@@ -7,11 +7,12 @@ import (
// Camera represents a registered GoPro camera in the system.
type Camera struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"`
BatteryCalibrationOffset *float64 `json:"battery_calibration_offset,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// StatusLog records a single status poll from an ESP8266 node.
+155
View File
@@ -0,0 +1,155 @@
#!/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..."
cp "${BINARY}" "${DEPLOY_PATH}"
chmod +x "${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}"
chmod +x "${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 ""
+29
View File
@@ -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
+336
View File
@@ -0,0 +1,336 @@
#!/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: pi)
# --static-ip IP Static IP for wlan0 (default: 10.60.1.56/24)
# --gateway IP Gateway for wlan0 (default: 10.60.1.1)
# --help Show this help
set -euo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
CONFIG_TEMPLATE=""
SERVICE_USER="pi"
STATIC_IP="10.60.1.56/24"
GATEWAY="10.60.1.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
# ---------------------------------------------------------------------------
# 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 " Deploy dir: ${DEPLOY_DIR}"
echo " Static IP: ${STATIC_IP} on wlan0"
echo ""
echo " Next steps:"
echo " 1. Build the remoterig binary for ARM64:"
echo " GOOS=linux GOARCH=arm64 go build -o remoterig ./cmd/server"
echo " 2. Copy binary to Pi:"
echo " scp remoterig pi@10.60.1.56:/opt/remoterig/"
echo " 3. Copy config if needed:"
echo " scp config.yaml pi@10.60.1.56:/opt/remoterig/"
echo " 4. Start the service:"
echo " sudo systemctl start remoterig"
echo " 5. Check health:"
echo " curl http://10.60.1.56:8080/health"
echo ""
echo " To deploy updates, use: scripts/deploy.sh"
echo "=============================================="
+42
View File
@@ -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>