RemoteRig: Core infrastructure — MQTT subscriber, Pi deployment, ESP32 firmware, hardware design #5

Merged
overseer merged 33 commits from dev into main 2026-05-21 20:04:36 -04:00
64 changed files with 10931 additions and 10 deletions
Showing only changes of commit 37c5362216 - Show all commits
+5
View File
@@ -0,0 +1,5 @@
# RemoteRig Frontend Environment Variables
# Copy this file to .env and fill in your values
# Backend API URL (default: /api proxied through Vite dev server)
VITE_API_URL=http://localhost:8080/api
+63
View File
@@ -0,0 +1,63 @@
name: Build (Dev)
on:
push:
branches:
- dev
workflow_dispatch:
env:
GO_VERSION: "1.23"
NODE_VERSION: "20"
BINARY_NAME: remoterig
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build React frontend
run: |
npm ci
npm run build
- name: Build Go binary (ARM64 cross-compile)
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
-o ${{ env.BINARY_NAME }} ./cmd/server
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.BINARY_NAME }}
path: ${{ env.BINARY_NAME }}
retention-days: 5
- name: Trigger deploy workflow
if: success()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: context.repo.repo,
event_type: 'dev-build-success',
client_payload: {
sha: context.sha,
ref: context.ref
}
})
+65
View File
@@ -0,0 +1,65 @@
name: CI/CD
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
jobs:
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
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:
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:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy static files
run: |
echo "Deploying to production..."
echo "Deploy target: /var/www/remote-rig/"
echo "Placeholder — configure deploy target before merging to main"
+115
View File
@@ -0,0 +1,115 @@
name: Deploy (Dev)
on:
repository_dispatch:
types:
- dev-build-success
workflow_dispatch:
env:
BINARY_NAME: remoterig
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_PATH: /opt/remoterig/remoterig
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.BINARY_NAME }}
- name: Ensure binary is executable
run: chmod +x ${{ env.BINARY_NAME }}
- name: Write deploy script
run: |
cat > deploy.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BINARY="${1:-remoterig}"
DEPLOY_PATH="${2:-/opt/remoterig/remoterig}"
SERVICE="${3:-remoterig}"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
echo "::backup:: copying current binary"
if [ -f "$DEPLOY_PATH" ]; then
cp "$DEPLOY_PATH" "$BACKUP"
fi
echo "::deploy:: installing new binary"
cp "$BINARY" "$DEPLOY_PATH"
chmod +x "$DEPLOY_PATH"
echo "::restart:: reloading service"
systemctl reload-or-restart "$SERVICE" || systemctl restart "$SERVICE"
echo "::health:: waiting for service"
sleep 3
if systemctl is-active --quiet "$SERVICE"; then
echo "deploy ok — ${SERVICE} is active"
else
echo "::rollback:: service failed, restoring backup"
if [ -f "$BACKUP" ]; then
cp "$BACKUP" "$DEPLOY_PATH"
systemctl restart "$SERVICE"
fi
echo "rolled back to previous binary"
exit 1
fi
echo "::cleanup:: removing old backups (keeping last 3)"
ls -t "${DEPLOY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
SCRIPT
chmod +x deploy.sh
- name: Deploy config.yaml (if present)
run: |
if [ -f config.yaml ]; then
echo "config.yaml found, will deploy alongside binary"
echo "config.yaml" >> deploy-files.txt
else
echo "no config.yaml in repo, skipping"
fi
- name: Deploy to dev server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
source: "${{ env.BINARY_NAME }},deploy.sh,config.yaml"
target: "/tmp/remoterig-deploy"
- name: Execute deploy on dev server
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
set -euo pipefail
cd /tmp/remoterig-deploy
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "remoterig"
if [ -f config.yaml ]; then
echo "::config:: deploying config.yaml"
sudo mkdir -p "$(dirname "${{ env.DEPLOY_PATH }}")"
sudo cp config.yaml "$(dirname "${{ env.DEPLOY_PATH }}")/config.yaml"
fi
rm -rf /tmp/remoterig-deploy
- name: Notify on failure
if: failure()
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
echo "deploy failed for commit ${{ github.sha }} on ${{ github.repository }}" > /tmp/remoterig-deploy-failure.txt
+37
View File
@@ -0,0 +1,37 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
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
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Vite
.vite
+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.
+134 -7
View File
@@ -2,14 +2,31 @@
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/cubecraft/remoterig/internal/api"
"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"`
@@ -37,14 +54,90 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
// Print config
log.Printf("Database: %s", cfg.DBPath)
log.Printf("API key set: %t", cfg.APIKey != "")
log.Printf("Server port: %s", cfg.Port)
log.Printf("MQTT broker: %s", cfg.MQTT.Broker)
log.Printf("Platform: %s (max %d cameras)", cfg.Platform.Type, cfg.Platform.MaxCameras)
// Open database
sqlDB, err := db.Open(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer sqlDB.Close()
log.Printf("Database open: %s", cfg.DBPath)
log.Println("RemoteRig hub ready")
// Create SSE hub for real-time updates
sseHub := events.NewHub()
// Start MQTT subscriber for ESP32 camera status ingestion
mqttSub := mqtt.NewSubscriber(cfg.MQTT.Broker, cfg.MQTT.ClientID, sqlDB, sseHub)
if err := mqttSub.Connect(); err != nil {
log.Printf("WARNING: MQTT subscriber failed to connect: %v (running without MQTT)", err)
}
defer mqttSub.Close()
// Set up router
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(cfg.WriteTimeout))
// Health check (no auth)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// 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,
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
}
// Graceful shutdown
go func() {
sigInt := make(chan os.Signal, 1)
signal.Notify(sigInt, syscall.SIGINT, syscall.SIGTERM)
<-sigInt
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
}()
log.Printf("Server listening on port %s", cfg.Port)
if err := httpServer.ListenAndServe(); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
// apiRouter creates the API route tree.
func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
r := chi.NewRouter()
// Camera management routes
r.Get("/cameras", api.ListCameras(database))
r.Post("/cameras", api.RegisterCamera(database))
r.Get("/cameras/{id}", api.GetCameraDetail(database))
// Recording control routes
r.Post("/cameras/{id}/start", api.StartRecording(database))
r.Post("/cameras/{id}/stop", api.StopRecording(database))
// Status ingestion (from ESP32 nodes)
r.Post("/cameras/{id}/status", api.PushStatus(database))
// Real-time events (SSE)
r.Handle("/events/stream", sseHub.Handler())
return r
}
func loadConfig(path string) (*Config, error) {
@@ -58,6 +151,7 @@ func loadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("parse config: %w", err)
}
// Defaults
if cfg.Port == "" {
cfg.Port = "8080"
}
@@ -73,3 +167,36 @@ func loadConfig(path string) (*Config, error) {
return &cfg, nil
}
// frontendHandler returns an http.Handler that serves the embedded React
// frontend from src/dist/ with SPA-style fallback: any path that doesn't
// match a static file serves index.html for client-side routing.
//
// The frontend is embedded via //go:embed all:src/dist at build time.
// If src/dist/ is empty or missing at build time, the embedded fallback
// index.html (committed to the repo) is served instead, showing a
// "run npm run build" message.
func frontendHandler() http.Handler {
distFS, err := fs.Sub(frontendFS, "src/dist")
if err != nil {
// Shouldn't happen if embed worked, but be defensive.
panic("embedded frontend filesystem not found: " + err.Error())
}
fileServer := http.FileServer(http.FS(distFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to serve the requested file.
f, err := distFS.Open(r.URL.Path[1:]) // strip leading "/"
if err != nil {
// File not found — serve index.html for SPA routing.
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
return
}
f.Close()
// File exists, serve it.
fileServer.ServeHTTP(w, r)
})
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utils",
"hooks": "@/hooks",
"lib": "@/lib",
"types": "@/types"
}
}
+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: 192.168.4.0/24 │
│ DHCP pool: .100-.200 │
└──────┬──────────┬──────────┬──────────────┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
│ DHCP addr │ │ DHCP addr │ │ 192.168.4.10 │
│ │ │ │ │ (static IP) │
│ STA→GoPro AP │ │ STA→GoPro AP │ │ │
│ STA→Router │ │ STA→Router │ │ Mosquitto :1883 │
│ │ │ │ │ Go API :8080 │
│ MQTT→:1883 │ │ MQTT→:1883 │ │ React UI │
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
│ │ └──────────────────┘
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ GoPro Hero 3 │ │ GoPro Hero 3 │ SSE /api/v1/events/stream
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │ │
│ Wi-Fi only │ │ Wi-Fi only │ ▼
└──────────────┘ └──────────────┘ ┌──────────────────┐
│ User Device │
│ (laptop/kiosk) │
│ http://.4.10 │
└──────────────────┘
```
**Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP32s bridge the GoPro's AP to the LAN via MQTT.
### Key Architecture Decisions (revised)
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `192.168.4.0/24`.
- **ESP32 dual-STA** — One STA to GoPro AP (10.5.5.1), one STA to travel router. No channel-hopping concerns on closed network.
- **ESP32 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable.
- **MQTT for ESP32 → Hub** — Lightweight, designed for IoT. Mosquitto on Pi. QoS 1 for status, QoS 2 for commands. Full contract: [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
- **SQLite over PostgreSQL** — Single-node Pi Zero 2 W deployment. WAL mode for concurrent read/write.
- **SSE over WebSocket** — Unidirectional hub → browser updates. Simpler, sufficient for status dashboard.
- **Chi router** — Lightweight Go HTTP router with middleware support.
- **Zustand over Redux** — Minimal boilerplate for camera status store.
- **API key auth** — Simple bearer token; closed LAN, not internet-facing.
- **Camera auto-discovery** — ESP32 publishes `announce` message on first MQTT connect. Hub auto-registers by MAC, assigns sequential `cam-NNN` ID.
## Directory Layout
```
remote-rig/
├── cmd/server/main.go # Entry point — config load, router setup, graceful shutdown
├── config.yaml # Runtime configuration
├── go.mod / go.sum # Go dependencies
├── internal/
│ ├── api/
│ │ ├── api.go # Package doc
│ │ ├── cameras.go # GET /cameras, POST /cameras, GET /cameras/:id
│ │ ├── recording.go # POST /cameras/:id/start, POST /cameras/:id/stop
│ │ └── status.go # POST /cameras/:id/status (push from ESP32)
│ ├── auth/
│ │ └── middleware.go # API key auth middleware
│ ├── db/
│ │ ├── db.go # Open, migrations, WAL mode
│ │ └── migrations/
│ │ └── 001_create_tables.sql
│ └── events/
│ └── sse.go # SSE hub (subscribe, broadcast)
├── pkg/models/
│ └── camera.go # Camera, StatusLog, RecordingEvent, CameraStatus, Settings
├── src/ # React frontend
│ ├── App.tsx # Main app — header, camera grid, footer
│ ├── components/
│ │ ├── CameraCard.tsx # Single camera status card
│ │ ├── CameraCard.test.tsx # Unit tests
│ │ └── index.ts
│ ├── hooks/
│ │ ├── useSSE.ts # SSE connection hook
│ │ ├── useCameraStatus.ts # Camera status hook
│ │ ├── useSystemHealth.ts # System health hook
│ │ └── index.ts
│ ├── services/
│ │ └── api.ts # API client
│ ├── store/
│ │ ├── useCameraStore.ts # Zustand store
│ │ └── index.ts
│ ├── types/
│ │ └── index.ts # TypeScript interfaces
│ ├── utils/
│ │ └── index.ts
│ └── main.tsx
├── docs/
│ ├── CONTEXT.md # ← this file
│ └── plans/
│ └── 2026-05-21-cub-196-cameracard.md # CameraCard implementation plan
├── .gitea/workflows/
│ ├── ci.yaml # PR CI: lint → typecheck → test → build
│ ├── build-dev.yaml # Go binary build on dev push
│ └── deploy-dev.yaml # SCP + SSH deploy with rollback
├── .env.example # VITE_API_URL=http://localhost:8080/api
├── package.json
├── vite.config.ts
├── tailwind.config.js
└── tsconfig.json
```
## Database Schema (SQLite)
### cameras
| Column | Type | Notes |
|--------|------|-------|
| camera_id | TEXT PK | Unique camera identifier |
| friendly_name | TEXT NOT NULL | Human-readable name |
| mac_address | TEXT UNIQUE | MAC address (optional) |
| created_at | DATETIME | Default now |
| updated_at | DATETIME | Default now |
### status_logs
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER PK AUTO | |
| camera_id | TEXT FK → cameras | |
| recorded_at | DATETIME | Default now |
| battery_pct | INTEGER | Nullable |
| video_remaining_sec | INTEGER | Nullable |
| recording_state | INTEGER | 0=idle, 1=recording |
| mode | TEXT | e.g. "video" |
| resolution | TEXT | e.g. "1080p" |
| fps | INTEGER | |
| online | INTEGER | 0=offline, 1=online |
| raw_battery_pct | REAL | Float precision |
Index: `(camera_id, recorded_at DESC)`
### recording_events
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER PK AUTO | |
| camera_id | TEXT FK → cameras | |
| started_at | DATETIME NOT NULL | |
| stopped_at | DATETIME | Null while recording |
| reason | TEXT | e.g. "manual" |
| duration | INTEGER | Seconds |
Index: `(camera_id, started_at DESC)`
### settings
| Column | Type | Notes |
|--------|------|-------|
| key | TEXT PK | |
| value | TEXT NOT NULL | |
| updated_at | DATETIME | Default now |
**Default seeds:** `poll_interval_sec=30`, `low_battery_threshold=15`, `low_storage_alert_sec=300`
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /health | No | Health check → `{"status":"ok"}` |
| GET | /api/v1/cameras | Yes | List all cameras with latest status |
| POST | /api/v1/cameras | Yes | Register a new camera |
| GET | /api/v1/cameras/:id | Yes | Camera detail + latest status + 24h history |
| POST | /api/v1/cameras/:id/start | Yes | Start recording + MQTT publish |
| POST | /api/v1/cameras/:id/stop | Yes | Stop recording |
| POST | /api/v1/cameras/:id/status | Yes | Push status from ESP32 node |
| GET | /api/v1/events/stream | No (SSE) | Real-time camera status stream |
## Configuration (`config.yaml`)
```yaml
db_path: "remoterig.db" # SQLite database path
api_key: "changeme" # Bearer token for API auth
port: 8080 # HTTP listen port
read_timeout: 5s
write_timeout: 10s
idle_timeout: 120s
mqtt:
broker: "localhost:1883" # Mosquitto on Pi Zero 2 W
client_id: "remoterig-hub"
platform:
type: "pi-zero-2w"
max_cameras: 16
network:
subnet: "192.168.4.0/24" # Travel router subnet
hub_ip: "192.168.4.10" # 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 | |
+273
View File
@@ -0,0 +1,273 @@
# MQTT Message Format Contract — RemoteRig
> **Version:** 1.0.0 | **Status:** Draft | **Blocks:** CUB-232 (MQTT subscriber), CUB-174 (ESP32 firmware)
> **Last updated:** 2026-05-21
## Network Architecture
```
┌──────────────────────────────────┐
│ Travel Router (192.168.4.1) │
│ DHCP: .100-.200 │
└──────┬──────────┬──────────┬──────┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
│ 192.168.4.101│ │ 192.168.4.102│ │ 192.168.4.10 │
│ │ │ │ │ │
│ STA→GoPro AP │ │ STA→GoPro AP │ │ Mosquitto │
│ STA→Router │ │ STA→Router │ │ Go backend │
└──────┬───────┘ └──────┬───────┘ │ React UI │
│ │ └──────────────┘
▼ ▼
┌──────────────┐ ┌──────────────┐
│ GoPro Hero 3 │ │ GoPro Hero 3 │
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │
└──────────────┘ └──────────────┘
```
- **Travel router:** Self-contained, no internet. DHCP pool: `192.168.4.100-200`
- **Pi Zero 2 W:** Static IP `192.168.4.10`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
- **ESP32s:** DHCP from router. Each has dual STA: one to GoPro AP, one to router
- **User device:** Connects to router, opens `http://192.168.4.10:8080` for dashboard
## MQTT Broker
- **Host:** `192.168.4.10` (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 with the latest GoPro status.
```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 behavior:**
- On receipt, execute command against GoPro
- 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 (192.168.4.10:1883)
├── Publishes announce (retained) on cameras/<id>/announce
┌─────────────────────────────────────────┐
│ Main loop (every 30s): │
│ 1. HTTP GET GoPro status (10.5.5.1) │
│ 2. Parse 60-byte status blob │
│ 3. Publish status (retained) │
│ 4. Every 60s: publish heartbeat │
└─────────────────────────────────────────┘
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
├── On 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, sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
5. GoPro starts recording
6. Next 30s poll: ESP32 publishes status with recording: true
7. Go backend receives status, updates SQLite, fans out via SSE
8. Dashboard updates with pulsing REC indicator
```
## Offline Buffering (future — CUB-230)
When ESP32 loses connection to travel router:
1. **Buffer:** Store status snapshots in SPIFFS (LittleFS), max 100 entries (~6KB)
2. **Eviction:** FIFO — oldest dropped when buffer full
3. **Replay:** On MQTT reconnect, publish buffered messages in chronological order with original timestamps
4. **Dedup:** Hub ignores duplicates via `(camera_id, recorded_at)` unique constraint in status_logs
## Backward Compatibility
- **Adding fields:** Safe — unknown fields ignored by both sides
- **Removing fields:** Mark as optional first, remove in next major version
- **Changing field types:** New topic path (e.g., `status/v2`) or new field name
- **New topics:** Add freely — old clients ignore unknown topics
## Open Questions
1. **NTP/time sync:** How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. **Recommendation:** Pi runs NTPd, ESP32s use SNTP from `192.168.4.10`.
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.
+515
View File
@@ -0,0 +1,515 @@
# CUB-196: CameraCard Component Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Build the CameraCard React component that displays one camera's live status with color-coded battery, pulsing recording indicator, and online/offline state.
**Architecture:** Single component at `src/components/CameraCard.tsx` consuming `CameraStatus` from the existing Zustand store. Uses Tailwind for styling (existing dark dashboard palette), lucide-react for icons. No new dependencies needed — everything is already in `package.json`.
**Tech Stack:** React 19, TypeScript 5.7, Tailwind CSS 3.4, Zustand 5, lucide-react 0.469
---
## Prerequisites
Before any task, ensure the repo is on `dev` and clean:
```bash
cd /mnt/ai-storage/projects/remote-rig
git checkout dev
git pull origin dev
git checkout -b agent/<your-agent-name>/CUB-196-cameracard
```
---
### Task 1: Create the CameraCard component
**Objective:** Build `src/components/CameraCard.tsx` with all required display elements.
**Files:**
- Create: `src/components/CameraCard.tsx`
**Step 1: Create the component file**
Create `src/components/CameraCard.tsx`:
```tsx
import { Battery, Radio, Signal, Video, Wifi, WifiOff } from 'lucide-react'
import type { CameraStatus } from '../types'
interface CameraCardProps {
camera: CameraStatus
}
/** Format ISO timestamp as relative time (e.g. "2m ago", "1h ago") */
function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const sec = Math.floor(diff / 1000)
if (sec < 60) return 'just now'
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hours = Math.floor(min / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
/** Return Tailwind color classes for battery level */
function batteryColor(pct: number | null): { bar: string; text: string } {
if (pct === null) return { bar: 'bg-rig-dark-500', text: 'text-rig-dark-300' }
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
}
export function CameraCard({ camera }: CameraCardProps) {
const battery = batteryColor(camera.battery_pct)
const online = camera.online
return (
<div
className={`
rounded-xl border bg-rig-dark-800/60 p-5 transition-all
${online
? 'border-rig-dark-700 hover:border-rig-dark-600'
: 'border-rig-danger/30 opacity-70'
}
`}
>
{/* Header: camera name + online indicator */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2.5 min-w-0">
<Video className="h-5 w-5 shrink-0 text-rig-accent" />
<h3 className="truncate text-base font-semibold text-rig-dark-100">
{camera.friendly_name}
</h3>
</div>
<span
className={`
flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
${online
? 'bg-rig-success/10 text-rig-success'
: 'bg-rig-danger/10 text-rig-danger'
}
`}
>
{online ? (
<Wifi className="h-3 w-3" />
) : (
<WifiOff className="h-3 w-3" />
)}
{online ? 'Online' : 'Offline'}
</span>
</div>
{/* Body: resolution + fps, recording indicator */}
<div className="mb-4 space-y-2.5">
<div className="flex items-center gap-2 text-sm text-rig-dark-300">
<Signal className="h-4 w-4 text-rig-dark-400" />
<span>{camera.resolution}</span>
<span className="text-rig-dark-500">·</span>
<span>{camera.fps} FPS</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span
className={`
inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
${camera.recording
? 'bg-rig-danger/15 text-rig-danger'
: 'bg-rig-dark-600/50 text-rig-dark-400'
}
`}
>
<span
className={`
inline-block h-2 w-2 rounded-full
${camera.recording ? 'animate-pulse bg-rig-danger' : 'bg-rig-dark-500'}
`}
/>
{camera.recording ? 'REC' : 'IDLE'}
</span>
<span className="text-xs text-rig-dark-500">{camera.mode}</span>
</div>
</div>
{/* Battery bar */}
<div className="mb-3">
<div className="mb-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
<Battery className="h-3.5 w-3.5" />
<span>Battery</span>
</div>
<span className={`text-xs font-medium ${battery.text}`}>
{camera.battery_pct !== null ? `${camera.battery_pct}%` : 'N/A'}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700">
<div
className={`h-full rounded-full transition-all duration-500 ${battery.bar}`}
style={{
width: `${camera.battery_pct !== null ? Math.max(camera.battery_pct, 4) : 0}%`,
}}
/>
</div>
</div>
{/* Footer: last seen + video remaining */}
<div className="flex items-center justify-between border-t border-rig-dark-700 pt-3">
<div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
<Radio className="h-3 w-3" />
<span>
{camera.online ? 'Live' : `Last seen ${formatRelativeTime(camera.last_seen)}`}
</span>
</div>
{camera.video_remaining_sec !== null && (
<span className="text-xs font-mono text-rig-dark-300">
{Math.floor(camera.video_remaining_sec / 60)}m {camera.video_remaining_sec % 60}s left
</span>
)}
</div>
</div>
)
}
```
**Step 2: Create barrel export**
Create `src/components/index.ts`:
```ts
export { CameraCard } from './CameraCard'
```
**Step 3: Type-check**
Run: `npx tsc --noEmit`
Expected: No errors.
**Step 4: Commit**
```bash
git add src/components/
git commit -m "CUB-196: add CameraCard component with battery bar, recording indicator, online status"
```
---
### Task 2: Wire CameraCard into App.tsx
**Objective:** Replace the placeholder with a live camera grid using the SSE-backed store.
**Files:**
- Modify: `src/App.tsx`
**Step 1: Update App.tsx**
Replace `src/App.tsx` content:
```tsx
import { Camera, Radio } from 'lucide-react'
import { useSSE } from './hooks/useSSE'
import { useCameraStore } from './store/useCameraStore'
import { CameraCard } from './components/CameraCard'
function App() {
useSSE()
const cameras = useCameraStore((s) => s.getCameras())
const onlineCount = useCameraStore((s) => s.getOnlineCount())
const recordingCount = useCameraStore((s) => s.getRecordingCount())
return (
<div className="min-h-screen bg-rig-dark-900">
{/* Header */}
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Camera className="h-7 w-7 text-rig-accent" />
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
RemoteRig
</h1>
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
Dashboard
</span>
</div>
{/* Stats */}
{cameras.length > 0 && (
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-rig-dark-300">
<span className="inline-block h-2 w-2 rounded-full bg-rig-success" />
{onlineCount} online
</span>
<span className="flex items-center gap-1.5 text-rig-dark-300">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-rig-danger" />
{recordingCount} recording
</span>
</div>
)}
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{cameras.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
<Radio className="mb-4 h-12 w-12 animate-pulse text-rig-dark-500" />
<h2 className="text-lg font-semibold text-rig-dark-200">
Waiting for cameras...
</h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Connect a camera or start the hub backend to see live status here.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cameras.map((cam) => (
<CameraCard key={cam.camera_id} camera={cam} />
))}
</div>
)}
</main>
{/* Footer */}
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
<p className="text-center text-xs text-rig-dark-500">
RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System
</p>
</div>
</footer>
</div>
)
}
export default App
```
**Step 2: Type-check and build**
```bash
npx tsc --noEmit
npm run build
```
Expected: TypeScript compiles clean, Vite build succeeds.
**Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "CUB-196: wire CameraCard into App grid with SSE live updates"
```
---
### Task 3: Add unit tests for CameraCard
**Objective:** Install vitest + testing-library and write tests for battery colors, recording indicator, and online/offline state.
**Files:**
- Create: `src/components/CameraCard.test.tsx`
- Modify: `package.json` (add test script)
**Step 1: Install test dependencies**
```bash
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom
```
**Step 2: Add test config**
Add to `vite.config.ts` the `test` block (reference: https://vitest.dev/config/):
```ts
/// <reference types="vitest/config" />
// ... existing imports and config, add:
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
}
```
**Step 3: Create setup file**
Create `src/test-setup.ts`:
```ts
import '@testing-library/jest-dom'
```
Update vite.config.ts `setupFiles` to `['./src/test-setup.ts']`.
**Step 4: Add test script**
Update `package.json` scripts:
```json
"test": "vitest run",
"test:watch": "vitest"
```
**Step 5: Write CameraCard tests**
Create `src/components/CameraCard.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CameraCard } from './CameraCard'
import type { CameraStatus } from '../types'
function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
return {
camera_id: 'cam-1',
friendly_name: 'Front Camera',
battery_pct: 85,
video_remaining_sec: 3600,
recording: false,
mode: 'video',
resolution: '1080p',
fps: 30,
online: true,
last_seen: new Date().toISOString(),
...overrides,
}
}
describe('CameraCard', () => {
it('renders camera name', () => {
render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText('Front Camera')).toBeInTheDocument()
})
it('shows resolution and FPS', () => {
render(<CameraCard camera={makeCamera({ resolution: '4K', fps: 60 })} />)
expect(screen.getByText('4K')).toBeInTheDocument()
expect(screen.getByText('60 FPS')).toBeInTheDocument()
})
it('shows battery percentage', () => {
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
expect(screen.getByText('85%')).toBeInTheDocument()
})
it('shows N/A when battery is null', () => {
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
expect(screen.getByText('N/A')).toBeInTheDocument()
})
it('uses green for high battery (>=50%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
)
// The battery bar inner div should have bg-rig-success
const bar = container.querySelector('.h-1\\.5 > div')
expect(bar?.className).toContain('bg-rig-success')
})
it('uses yellow for medium battery (15-49%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
)
const bar = container.querySelector('.h-1\\.5 > div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('uses red for low battery (<15%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
)
const bar = container.querySelector('.h-1\\.5 > div')
expect(bar?.className).toContain('bg-rig-danger')
})
it('shows REC badge with pulsing dot when recording', () => {
render(<CameraCard camera={makeCamera({ recording: true })} />)
expect(screen.getByText('REC')).toBeInTheDocument()
})
it('shows IDLE badge when not recording', () => {
render(<CameraCard camera={makeCamera({ recording: false })} />)
expect(screen.getByText('IDLE')).toBeInTheDocument()
})
it('shows Online badge when camera is online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('shows Offline badge when camera is offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
expect(screen.getByText('Offline')).toBeInTheDocument()
})
it('shows video remaining time when available', () => {
render(
<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />,
)
expect(screen.getByText('2m 5s left')).toBeInTheDocument()
})
it('does not show video remaining when null', () => {
const { container } = render(
<CameraCard camera={makeCamera({ video_remaining_sec: null })} />,
)
expect(container.textContent).not.toContain('left')
})
it('shows Live in footer when online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
expect(screen.getByText('Live')).toBeInTheDocument()
})
it('shows relative time in footer when offline', () => {
render(
<CameraCard
camera={makeCamera({
online: false,
last_seen: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
})}
/>,
)
expect(screen.getByText(/5m ago/)).toBeInTheDocument()
})
})
```
**Step 6: Run tests**
```bash
npm test
```
Expected: All 15 tests pass.
**Step 7: Commit**
```bash
git add src/components/CameraCard.test.tsx src/test-setup.ts vite.config.ts package.json
git commit -m "CUB-196: add CameraCard unit tests (vitest + testing-library)"
```
---
## Verification
After all tasks complete:
```bash
# Type-check
npx tsc --noEmit
# Build
npm run build
# Tests
npm test
```
All must pass before pushing.
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
+118
View File
@@ -0,0 +1,118 @@
# RemoteRig — ESP32 Camera Node Firmware
> **Platform:** PlatformIO (esp32dev) | **Framework:** Arduino
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
> **Hardware:** [hardware/README.md](../hardware/README.md)
## Quick Start
```bash
# Install PlatformIO (if not already)
pip install platformio
# Build
cd firmware
pio run
# Upload to ESP32 (USB connected)
pio run --target upload
# Upload SPIFFS config (first time only, or after config changes)
pio run --target uploadfs
# Serial monitor
pio device monitor
```
## Configuration
The ESP32 stores configuration in SPIFFS (`data/config.json`):
| Key | Default | Description |
|-----|---------|-------------|
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
| `wifi_password` | `""` | Travel router password |
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP prefix (auto-discovered) |
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
| `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W static IP |
| `mqtt_port` | `1883` | Mosquitto port |
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
| `poll_interval_sec` | `30` | GoPro status poll frequency |
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
**First boot:** Leave `camera_id` empty. The ESP32 will auto-announce to the hub, which assigns a `cam-NNN` ID. The assigned ID is saved to SPIFFS automatically.
## LED Status Codes
| Pattern | Meaning |
|---------|---------|
| Slow blink (1s) | Connected to router + MQTT, normal operation |
| Fast blink (200ms) | No Wi-Fi connection — reconnecting |
| Solid on | Connected but GoPro unreachable |
| Off | Boot/shutdown |
## Architecture
```
┌──────────────────────────────────────────┐
│ ESP32 (Arduino) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ WiFi STA │ │ WiFi STA │ │ MQTT │ │
│ │ (Router) │ │ (GoPro) │ │ Client │ │
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ │ │ │ │
│ │ ┌────────┘ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Main Loop │ │
│ │ Every 30s: │ │
│ │ HTTP GET GoPro status │ │
│ │ Parse 60-byte blob │ │
│ │ MQTT publish status │ │
│ │ Every 60s: │ │
│ │ MQTT publish heartbeat │ │
│ └─────────────────────────────────┘ │
│ │
│ SPIFFS: /config.json (persistent) │
└──────────────────────────────────────────┘
```
## Boot Sequence
1. Load config from SPIFFS
2. Connect to travel router Wi-Fi (STA mode)
3. Connect to GoPro AP Wi-Fi (STA mode — simultaneous)
4. Connect to MQTT broker (192.168.4.10)
5. If no `camera_id` → publish announce → hub registers us
6. Subscribe to `remoterig/cameras/{camera_id}/command`
7. Enter main loop
## GoPro API Notes (Hero 3 Black/Silver)
- **IP:** Always `10.5.5.1` (GoPro's own AP)
- **Status endpoint:** `GET /bacpac/SH?t={password}&p=%01`
- **Start recording:** `GET /bacpac/SH?t={password}&p=%01` (mode byte = 1)
- **Stop recording:** `GET /bacpac/SH?t={password}&p=%00` (mode byte = 0)
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
- **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets
## ESP8266 Compatibility
To target ESP8266 instead:
1. Change `platformio.ini`: `board = d1_mini` under `[env:d1_mini]`
2. Change `WiFi.h``ESP8266WiFi.h`
3. ESP8266 doesn't do true simultaneous STA — use single STA to travel router, HTTP to GoPro via router bridge
4. SPIFFS → LittleFS on some boards
ESP32 is recommended for dual-STA capability.
## Troubleshooting
| Symptom | Check |
|---------|-------|
| No serial output | Baud rate: 115200. Hold BOOT, press EN, release BOOT for flash mode |
| Can't connect to router | Verify SSID/password in SPIFFS config, check router DHCP range |
| GoPro unreachable | GoPro must be ON and Wi-Fi enabled. Password defaults to "goprohero" |
| MQTT connect fails | Verify Mosquitto running on Pi: `systemctl status mosquitto` |
| Camera never registers | Watch serial for "announce" message, check hub logs for registration |
+11
View File
@@ -0,0 +1,11 @@
{
"wifi_ssid": "RemoteRig",
"wifi_password": "",
"camera_ssid": "GOPRO-BP-",
"camera_password": "goprohero",
"mqtt_broker": "192.168.4.10",
"mqtt_port": 1883,
"camera_id": "",
"poll_interval_sec": 30,
"heartbeat_interval_sec": 60
}
+23
View File
@@ -0,0 +1,23 @@
; RemoteRig — ESP32 Camera Node Firmware
; Platform: ESP32 (ESP8266 compatible with minor changes)
; Framework: Arduino
;
; Build: pio run
; Upload: pio run --target upload
; SPIFFS: pio run --target uploadfs
; Monitor: pio device monitor
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps =
knolleary/PubSubClient @ ^2.8
bblanchon/ArduinoJson @ ^7.3
build_flags =
-D CORE_DEBUG_LEVEL=0
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
+566
View File
@@ -0,0 +1,566 @@
/**
* RemoteRig — ESP32 Camera Node Firmware
* =======================================
* One ESP32 per GoPro Hero 3. Bridges the camera's Wi-Fi AP (10.5.5.1)
* to the travel router LAN via MQTT (Mosquitto on Pi Zero 2 W).
*
* MQTT Contract: docs/MQTT_CONTRACT.md
* Hardware: hardware/README.md
* Platform: PlatformIO (esp32dev)
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <SPIFFS.h>
// ────────────────────────────────────────────────────────────
// Configuration (overridden by SPIFFS /data/config.json)
// ────────────────────────────────────────────────────────────
struct Config {
// Travel router Wi-Fi
String wifi_ssid = "RemoteRig";
String wifi_password = "";
// GoPro Hero 3 Wi-Fi AP
String camera_ssid = "GOPRO-BP-"; // prefix — auto-discovered
String camera_password = "goprohero";
// MQTT broker (Pi Zero 2 W on travel router)
String mqtt_broker = "192.168.4.10";
int mqtt_port = 1883;
// Assigned by hub on first announce; empty until registered
String camera_id = "";
// Polling
int poll_interval_sec = 30;
int heartbeat_interval_sec = 60;
// Stored in SPIFFS
bool dirty = false;
} cfg;
// ────────────────────────────────────────────────────────────
// Network clients
// ────────────────────────────────────────────────────────────
WiFiClient wifiClient; // for HTTP to GoPro
WiFiClient mqttWifiClient; // for MQTT via travel router
PubSubClient mqtt(mqttWifiClient);
// ────────────────────────────────────────────────────────────
// State
// ────────────────────────────────────────────────────────────
unsigned long lastPollMs = 0;
unsigned long lastHeartbeatMs = 0;
unsigned long lastReconnectMs = 0;
unsigned long bootMs = 0;
int reconnectDelay = 1; // exponential backoff (seconds)
bool goproOnline = false;
// Heartbeat sequence
unsigned int heartbeatSeq = 0;
// ────────────────────────────────────────────────────────────
// LED Pin (built-in on most ESP32 dev boards = GPIO 2)
// ────────────────────────────────────────────────────────────
const int LED_PIN = 2;
enum LedMode { LED_OFF, LED_SLOW, LED_FAST, LED_ON };
LedMode ledMode = LED_SLOW;
void setLed(LedMode mode) {
ledMode = mode;
}
// ────────────────────────────────────────────────────────────
// SPIFFS Config
// ────────────────────────────────────────────────────────────
bool loadConfig() {
if (!SPIFFS.begin(true)) {
Serial.println("[CFG] SPIFFS mount failed");
return false;
}
File f = SPIFFS.open("/config.json", "r");
if (!f) {
Serial.println("[CFG] No /config.json — using defaults");
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.printf("[CFG] JSON parse error: %s\n", err.c_str());
return false;
}
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
cfg.heartbeat_interval_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_interval_sec;
Serial.println("[CFG] Loaded from /config.json");
return true;
}
bool saveConfig() {
File f = SPIFFS.open("/config.json", "w");
if (!f) return false;
JsonDocument doc;
doc["wifi_ssid"] = cfg.wifi_ssid;
doc["wifi_password"] = cfg.wifi_password;
doc["camera_ssid"] = cfg.camera_ssid;
doc["camera_password"] = cfg.camera_password;
doc["mqtt_broker"] = cfg.mqtt_broker;
doc["mqtt_port"] = cfg.mqtt_port;
doc["camera_id"] = cfg.camera_id;
doc["poll_interval_sec"] = cfg.poll_interval_sec;
doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec;
serializeJson(doc, f);
f.close();
Serial.println("[CFG] Saved config");
return true;
}
// ────────────────────────────────────────────────────────────
// Wi-Fi — Dual STA (GoPro AP + Travel Router)
// ────────────────────────────────────────────────────────────
bool connectCameraWiFi() {
Serial.printf("[WIFI] Connecting to GoPro AP: %s\n", cfg.camera_ssid.c_str());
// Use WiFi.begin with a second AP config — ESP32 supports this
// We connect to travel router first, then GoPro
// GoPro AP: static IP on 10.5.5.x subnet
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected to GoPro AP. IP: %s\n", WiFi.localIP().toString().c_str());
goproOnline = true;
return true;
}
Serial.println("\n[WIFI] Failed to connect to GoPro AP");
goproOnline = false;
return false;
}
// ═══════════════════════════════════════════════════════════
// GoPro Hero 3 HTTP API
// ═══════════════════════════════════════════════════════════
// GoPro AP gateway (always 10.5.5.1 for Hero 3)
const char* GOPRO_IP = "10.5.5.1";
/**
* Get the GoPro camera password.
* Hero 3 exposes it via GET /bacpac/sd (no auth required).
* Default is "goprohero" but user may have changed it.
*/
String fetchGoProPassword() {
HTTPClient http;
http.begin(wifiClient, String("http://") + GOPRO_IP + "/bacpac/sd");
int code = http.GET();
String body = http.getString();
http.end();
if (code == 200 && body.length() > 0) {
// Password is in plain text in the response body
body.trim();
return body;
}
return cfg.camera_password; // fallback to config value
}
/**
* Fetch the GoPro status blob (60 bytes binary).
* Returns empty string on failure.
*/
String fetchGoProStatus() {
String url = String("http://") + GOPRO_IP +
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
HTTPClient http;
http.begin(wifiClient, url);
http.setTimeout(5000);
int code = http.GET();
if (code != 200) {
http.end();
return "";
}
// GoPro returns raw binary — use getString() which handles it
String raw = http.getString();
http.end();
return raw;
}
/**
* Parse the 60-byte GoPro status blob into structured data.
* Hero 3 status format (offsets are 0-based):
* [25-26] video_remaining_sec (uint16 LE)
* [29] recording state (0=idle, 1=recording)
* [30] mode
* [31-32] resolution
* [33-34] fps
* [57] battery_raw (uint8)
*/
struct GoProStatus {
bool valid = false;
int video_remaining_sec = 0;
bool recording = false;
int mode = 0;
int fps = 0;
int battery_raw = 0;
};
GoProStatus parseStatus(const String& raw) {
GoProStatus s;
if (raw.length() < 58) {
return s;
}
const uint8_t* buf = (const uint8_t*)raw.c_str();
s.valid = true;
s.video_remaining_sec = buf[25] | (buf[26] << 8);
s.recording = (buf[29] == 1);
s.mode = buf[30];
s.fps = buf[33] | (buf[34] << 8);
s.battery_raw = buf[57];
return s;
}
bool sendGoProCommand(const String& command) {
String param;
if (command == "start_recording") {
param = "%01"; // mode 1 = record
} else if (command == "stop_recording") {
param = "%00"; // mode 0 = stop
} else {
Serial.printf("[GOPRO] Unknown command: %s\n", command.c_str());
return false;
}
String url = String("http://") + GOPRO_IP +
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
HTTPClient http;
http.begin(wifiClient, url);
http.setTimeout(5000);
int code = http.GET();
http.end();
Serial.printf("[GOPRO] Command %s → HTTP %d\n", command.c_str(), code);
return (code == 200);
}
// ═══════════════════════════════════════════════════════════
// MQTT
// ═══════════════════════════════════════════════════════════
String clientID() {
uint8_t mac[6];
WiFi.macAddress(mac);
char buf[32];
snprintf(buf, sizeof(buf), "remoterig-%02x%02x%02x", mac[3], mac[4], mac[5]);
return String(buf);
}
String statusTopic() { return "remoterig/cameras/" + cfg.camera_id + "/status"; }
String heartbeatTopic() { return "remoterig/cameras/" + cfg.camera_id + "/heartbeat"; }
String announceTopic() { return "remoterig/cameras/" + cfg.camera_id + "/announce"; }
String commandTopic() { return "remoterig/cameras/" + cfg.camera_id + "/command"; }
void mqttCallback(char* topic, byte* payload, unsigned int length) {
// Null-terminate payload
char buf[256];
unsigned int len = length < 255 ? length : 255;
memcpy(buf, payload, len);
buf[len] = 0;
Serial.printf("[MQTT] ← %s: %s\n", topic, buf);
JsonDocument doc;
DeserializationError err = deserializeJson(doc, buf);
if (err) {
Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str());
return;
}
String cmd = doc["command"] | "";
if (cmd == "start_recording" || cmd == "stop_recording") {
sendGoProCommand(cmd);
} else if (cmd == "reboot") {
Serial.println("[MQTT] Reboot command received");
ESP.restart();
} else if (cmd == "registered") {
// Hub assigned us a camera_id on announce
String newID = doc["camera_id"] | "";
if (newID.length() > 0 && newID != cfg.camera_id) {
cfg.camera_id = newID;
cfg.dirty = true;
Serial.printf("[MQTT] Registered as %s\n", newID.c_str());
// Re-subscribe to our new command topic
mqtt.unsubscribe(commandTopic().c_str());
mqtt.subscribe(commandTopic().c_str(), 2);
}
} else {
Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str());
}
}
bool connectMQTT() {
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
mqtt.setCallback(mqttCallback);
mqtt.setKeepAlive(60);
Serial.printf("[MQTT] Connecting to %s:%d as %s...\n",
cfg.mqtt_broker.c_str(), cfg.mqtt_port, clientID().c_str());
if (mqtt.connect(clientID().c_str())) {
Serial.println("[MQTT] Connected");
// Subscribe to command topic
mqtt.subscribe(commandTopic().c_str(), 2);
Serial.printf("[MQTT] Subscribed to %s\n", commandTopic().c_str());
// If we have no camera_id yet, announce ourselves
if (cfg.camera_id.length() == 0) {
publishAnnounce();
}
reconnectDelay = 1; // reset backoff
return true;
}
Serial.printf("[MQTT] Connection failed (state=%d)\n", mqtt.state());
return false;
}
void publishAnnounce() {
JsonDocument doc;
doc["mac_address"] = WiFi.macAddress();
doc["firmware_version"] = "0.1.0";
doc["friendly_name"] = "ESP32-" + clientID().substring(9);
JsonArray caps = doc["capabilities"].to<JsonArray>();
caps.add("start_stop");
caps.add("status");
String payload;
serializeJson(doc, payload);
// Publish on a temporary announce topic (using MAC as ID until registered)
String tempAnnounce = "remoterig/cameras/announce-" + clientID().substring(9);
mqtt.publish(tempAnnounce.c_str(), payload.c_str(), true);
Serial.printf("[MQTT] Published announce: %s\n", payload.c_str());
}
void publishStatus(const GoProStatus& s) {
JsonDocument doc;
doc["camera_id"] = cfg.camera_id;
doc["timestamp"] = millis(); // milliseconds since boot — hub converts to ISO
doc["battery_raw"] = s.battery_raw;
doc["video_remaining_sec"] = s.video_remaining_sec;
doc["recording"] = s.recording;
doc["online"] = goproOnline;
if (s.recording) {
doc["mode"] = "video";
}
String payload;
serializeJson(doc, payload);
bool ok = mqtt.publish(statusTopic().c_str(), payload.c_str(), true);
if (ok) {
Serial.printf("[MQTT] → status (batt=%d, rec=%d, online=%d)\n",
s.battery_raw, s.recording, goproOnline);
} else {
Serial.println("[MQTT] Status publish failed");
}
}
void publishHeartbeat() {
JsonDocument doc;
doc["camera_id"] = cfg.camera_id;
doc["timestamp"] = millis();
doc["uptime_sec"] = (millis() - bootMs) / 1000;
doc["free_heap"] = ESP.getFreeHeap();
String payload;
serializeJson(doc, payload);
mqtt.publish(heartbeatTopic().c_str(), payload.c_str(), false);
}
// ═══════════════════════════════════════════════════════════
// Setup
// ═══════════════════════════════════════════════════════════
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n\nRemoteRig ESP32 Camera Node v0.1.0");
Serial.println("===================================");
bootMs = millis();
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
// Load config from SPIFFS
loadConfig();
Serial.printf("[CFG] camera_id: %s (empty = not yet registered)\n",
cfg.camera_id.length() > 0 ? cfg.camera_id.c_str() : "(none)");
// Connect to travel router Wi-Fi
Serial.printf("[WIFI] Connecting to travel router: %s\n", cfg.wifi_ssid.c_str());
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
int wifiAttempts = 0;
while (WiFi.status() != WL_CONNECTED && wifiAttempts < 40) {
delay(500);
Serial.print(".");
wifiAttempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
setLed(LED_SLOW); // connected to router
} else {
Serial.println("\n[WIFI] Failed to connect to travel router — will retry in loop");
setLed(LED_FAST); // no router connection
}
// Connect to GoPro AP
if (!connectCameraWiFi()) {
Serial.println("[WIFI] GoPro not reachable — will retry");
setLed(LED_FAST);
}
// Connect MQTT
if (WiFi.status() == WL_CONNECTED) {
connectMQTT();
}
}
// ═══════════════════════════════════════════════════════════
// Main Loop
// ═══════════════════════════════════════════════════════════
void loop() {
unsigned long now = millis();
// ── LED heartbeat ──
static unsigned long lastLedToggle = 0;
int ledInterval = (ledMode == LED_FAST) ? 200 : (ledMode == LED_SLOW) ? 1000 : 0;
if (ledInterval > 0 && now - lastLedToggle > ledInterval) {
lastLedToggle = now;
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
}
if (ledMode == LED_ON) digitalWrite(LED_PIN, HIGH);
if (ledMode == LED_OFF) digitalWrite(LED_PIN, LOW);
// ── Wi-Fi reconnection ──
if (WiFi.status() != WL_CONNECTED) {
setLed(LED_FAST);
if (now - lastReconnectMs > 5000) {
lastReconnectMs = now;
Serial.println("[WIFI] Reconnecting...");
WiFi.reconnect();
}
delay(100);
return; // skip everything else until Wi-Fi is back
}
// ── MQTT reconnection ──
if (!mqtt.connected()) {
setLed(LED_SLOW);
if (now - lastReconnectMs > (unsigned long)(reconnectDelay * 1000)) {
lastReconnectMs = now;
if (connectMQTT()) {
reconnectDelay = 1;
} else {
reconnectDelay = min(reconnectDelay * 2, 30);
}
}
mqtt.loop();
delay(100);
return;
}
setLed(LED_SLOW);
mqtt.loop();
// ── GoPro reconnection ──
static unsigned long lastGoProRetry = 0;
if (!goproOnline && now - lastGoProRetry > 30000) {
lastGoProRetry = now;
connectCameraWiFi();
}
// ── Status polling (every cfg.poll_interval_sec) ──
if (now - lastPollMs > (unsigned long)(cfg.poll_interval_sec * 1000)) {
lastPollMs = now;
String raw = fetchGoProStatus();
GoProStatus status = parseStatus(raw);
if (status.valid) {
goproOnline = true;
if (cfg.camera_id.length() > 0) {
publishStatus(status);
}
} else {
goproOnline = false;
if (cfg.camera_id.length() > 0) {
GoProStatus offline = {};
offline.valid = true;
publishStatus(offline); // publish with online=false
}
}
}
// ── Heartbeat (every heartbeat_interval_sec) ──
if (cfg.camera_id.length() > 0 &&
now - lastHeartbeatMs > (unsigned long)(cfg.heartbeat_interval_sec * 1000)) {
lastHeartbeatMs = now;
publishHeartbeat();
}
// ── Save config if dirty ──
if (cfg.dirty) {
cfg.dirty = false;
saveConfig();
}
delay(100);
}
+20 -1
View File
@@ -1,3 +1,22 @@
module github.com/cubecraft/remoterig
go 1.24.2
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
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+57
View File
@@ -0,0 +1,57 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+140
View File
@@ -0,0 +1,140 @@
# RemoteRig — Camera Node Hardware Design
> **Version:** 0.1.0 | **Status:** Draft
> **Target:** GoPro Hero 3 Black/Silver + ESP32 D1 Mini + 1000mAh LiPo
## Overview
Each camera node is a self-contained unit clipped onto a GoPro Hero 3. It provides:
- Camera control (start/stop recording) via Wi-Fi
- Status monitoring (battery, storage, recording state)
- MQTT communication to the central Pi Zero 2 W hub
- Battery power for both the ESP32 and GoPro
## Physical Assembly
```
┌─────────────────────────────────┐
│ GoPro Hero 3 │
│ ┌─────────────────────────┐ │
│ │ Lens (front) │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ Screen │ │
│ └─────────────────────────┘ │
│ ┌──────────┐ │
│ 3D Sleeve ─────→│ ESP32 │ │ ← clips onto back/bottom
│ │ D1 Mini │ │
│ └──────────┘ │
│ ┌──────────┐ │
│ │ LiPo │ │ ← slides under GoPro
│ │ 1000mAh │ │
│ └──────────┘ │
└─────────────────────────────────┘
```
## Bill of Materials
| Item | Qty | Cost | Notes |
|------|-----|------|-------|
| GoPro Hero 3 Black/Silver | 1 | Already owned | Target camera |
| ESP32 D1 Mini | 1 | ~$4 | Or NodeMCU-32S (~$5) |
| LiPo 3.7V 1000mAh | 1 | ~$8 | 50x34x8mm typical |
| 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB |
| 3.3V buck converter | 1 | ~$1 | LiPo → ESP32 VIN |
| JST-XH 2-pin connectors | 2 | ~$1 | Battery quick-disconnect |
| Micro-USB right-angle cable | 1 | ~$2 | Buck → GoPro |
| Velcro strap (20cm) | 1 | ~$0.50 | Secure to GoPro |
| PETG filament | ~30g | ~$0.60 | 3D printed case |
**Total per node:** ~$20
## 3D Printed Case
The case consists of three parts (see `hardware/case/remoterig-case.scad`):
### Part 1: GoPro Sleeve
Wraps around the GoPro body with cutouts for:
- Lens (front)
- Screen/viewfinder (back)
- USB port (side)
- Bottom mounting fingers
- Mounting ears for electronics compartment
### Part 2: Electronics Compartment
Clips onto the sleeve's mounting ears. Holds:
- ESP32 D1 Mini board (recessed fit)
- USB cable routing (in → ESP32, out → GoPro)
- Ventilation slots (top)
- LED visibility window
### Part 3: Battery Compartment
Slides under the GoPro. Contains:
- LiPo battery cavity
- Cable exits (to ESP32, to GoPro buck converter)
- Velcro strap slots
### Print Settings
- **Material:** PETG (outdoor/heat resistant) or PLA+
- **Layer height:** 0.2mm
- **Infill:** 20% gyroid
- **Supports:** Yes (for cable channels)
- **Bed adhesion:** Brim (5mm) for sleeve
- **Orientation:** Print sleeve on its back, compartments flat
## Wiring
```
LiPo 3.7V
├── JST-XH connector
├──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port
│ (power only — no data over USB)
└──→ 3.3V Buck Converter → ESP32 VIN + GND
(or ESP32 D1 Mini has built-in regulator — connect directly to 5V pin)
```
**Note:** ESP32 D1 Mini has an onboard 3.3V regulator. You can feed it 5V directly to the 5V pin if using a single 5V buck converter. This simplifies wiring:
```
LiPo → 5V Buck → ├── ESP32 5V pin
└── GoPro USB port
```
## Wi-Fi Topology (No Cables for Camera Control)
```
GoPro Hero 3 ──(Wi-Fi AP @ 10.5.5.1)──→ ESP32 STA #1
Travel Router ──(Wi-Fi AP)─────────────────→ ESP32 STA #2
(192.168.4.1) │
└──→ MQTT → Pi Hub (192.168.4.10)
```
The ESP32 has **no wired data connection** to the GoPro. All camera control is over Wi-Fi. The USB cable is **power only**.
## Enclosure Dimensions
| Component | W × H × D (mm) |
|-----------|-----------------|
| GoPro Hero 3 | 60 × 42 × 30 |
| ESP32 D1 Mini | 34 × 26 × 5 |
| LiPo 1000mAh | 50 × 34 × 8 |
| Full assembly | ~70 × 60 × 55 |
## Usage in the Field
1. **Pre-show:** Charge LiPos, flash ESP32 firmware, verify MQTT connectivity
2. **At venue:** Mount cameras, power on ESP32s (they auto-connect to travel router)
3. **Monitoring:** Open `http://192.168.4.10:8080` on laptop/kiosk
4. **Control:** Start/stop recording from dashboard
5. **Post-show:** Stop recording, power down, swap batteries for next session
## Future Improvements
- **Hot-swap battery:** Quick-release battery tray with spring contacts
- **Weather sealing:** O-ring groove in sleeve for outdoor rain protection
- **Lens hood:** Integrated sun shield for outdoor daytime recording
- **Mount adapter:** 1/4"-20 tripod mount thread on bottom
- **Antenna routing:** External antenna connector for improved Wi-Fi range in stadiums
+205
View File
@@ -0,0 +1,205 @@
// RemoteRig — GoPro Hero 3 + ESP32 Camera Case
// ==============================================
// Sleeve that wraps around GoPro Hero 3 body with ESP32 + LiPo compartment.
// Designed for: ESP32 D1 Mini, 1000mAh LiPo, GoPro Hero 3 Black/Silver.
//
// Print settings:
// Material: PETG (outdoor/heat) or PLA+ (indoor)
// Layer: 0.2mm | Infill: 20% gyroid | Supports: yes (for cable channels)
// Nozzle: 0.4mm | Bed: 60°C (PLA) / 80°C (PETG)
// ── GoPro Hero 3 Body (approximate) ──
gopro_width = 60; // mm — body width
gopro_height = 42; // mm — body height (top to bottom)
gopro_depth = 30; // mm — body depth (front to back)
gopro_lens_dia = 28; // mm — lens protrusion diameter
gopro_lens_offset = 18; // mm — lens center from top
// ── ESP32 D1 Mini ──
esp_width = 34.2;
esp_height = 25.6;
esp_thick = 5; // board + components
usb_cutout_w = 10;
usb_cutout_h = 5;
// ── LiPo Battery (1000mAh typical) ──
lipo_width = 35;
lipo_height = 25;
lipo_thick = 8;
// ── Case parameters ──
wall = 2.0; // case wall thickness
tolerance = 0.3; // print tolerance for friction fit
compartment_height = max(esp_thick, lipo_thick) + 3; // internal compartment height
// ── Cable channels ──
cable_dia = 4; // USB cable diameter
cable_channel_depth = 3;
// ══════════════════════════════════════════════════════════════
// MAIN ASSEMBLY
// ══════════════════════════════════════════════════════════════
// Uncomment the part you want to export:
gopro_sleeve();
// translate([0, -20, 0]) electronics_compartment();
// translate([0, 20, 0]) battery_compartment();
// ══════════════════════════════════════════════════════════════
// GoPro Sleeve — wraps around the GoPro body
// ══════════════════════════════════════════════════════════════
module gopro_sleeve() {
union() {
// Main sleeve body — wraps around GoPro
difference() {
// Outer shell
rounded_cube(
gopro_width + wall*2,
gopro_height + wall*2,
gopro_depth + wall*2,
4 // corner radius
);
// Inner cavity (GoPro body)
translate([0, 0, wall])
rounded_cube(
gopro_width + tolerance,
gopro_height + tolerance,
gopro_depth + tolerance,
3
);
// Lens cutout (front face)
translate([0, gopro_height/2 - gopro_lens_offset, 0])
rotate([90, 0, 0])
cylinder(d=gopro_lens_dia + 4, h=wall*3, center=true);
// Front screen/viewfinder cutout
translate([0, gopro_height/2 - gopro_lens_offset - 18, wall*2])
cube([gopro_width - 10, gopro_height - 20, wall*4], center=true);
// Bottom cutout (for GoPro mounting fingers)
translate([0, 0, gopro_depth/2 + wall])
cube([gopro_width - 10, wall*4, wall*4], center=true);
// USB port access (side)
translate([gopro_width/2 + wall, 0, -5])
cube([wall*4, 16, 10], center=true);
// Cable channel from ESP32 compartment to GoPro USB
translate([gopro_width/2 - 5, -gopro_height/2 + 10, -gopro_depth/2 + 5])
rotate([0, 90, 0])
cylinder(d=cable_dia, h=wall*3, center=true);
}
// Mounting ears for electronics compartment
for (x = [-1, 1]) {
translate([x * (gopro_width/2 - 6), -gopro_height/2 - 6, 0])
rotate([90, 0, 0])
cylinder(d=8, h=10);
}
}
}
// ══════════════════════════════════════════════════════════════
// Electronics Compartment — holds ESP32 + routes cables
// ══════════════════════════════════════════════════════════════
module electronics_compartment() {
comp_w = max(esp_width, esp_height) + wall*2 + 10;
comp_h = compartment_height + wall*2;
comp_d = gopro_depth + wall*2;
difference() {
union() {
// Main box
rounded_cube(comp_w, comp_d, comp_h, 3);
// Mounting tabs (match GoPro sleeve ears)
for (x = [-1, 1]) {
translate([x * (gopro_width/2 - 6), 0, comp_h/2])
rotate([0, 90, 0])
cylinder(d=6, h=4, center=true);
}
}
// Inner cavity
translate([0, 0, wall])
rounded_cube(comp_w - wall*2, comp_d - wall*2, comp_h - wall, 2);
// ESP32 board recess
translate([0, 5, wall + 1])
cube([esp_width + tolerance, esp_height + tolerance, esp_thick + 1], center=true);
// USB cable entry (side hole)
translate([comp_w/2, 15, comp_h/2])
rotate([0, 90, 0])
cylinder(d=usb_cutout_w, h=wall*3, center=true);
// USB cable exit (to GoPro)
translate([comp_w/2, -15, comp_h/2])
rotate([0, 90, 0])
cylinder(d=cable_dia, h=wall*3, center=true);
// Ventilation slots
for (y = [-1:2:1]) {
for (i = [-15:10:15]) {
translate([i, y * comp_d/3, comp_h - 2])
cube([6, 1.5, wall*2], center=true);
}
}
// LED window (thin wall for ESP32 LED visibility)
translate([0, 0, wall])
cube([5, 5, wall], center=true);
}
}
// ══════════════════════════════════════════════════════════════
// Battery Compartment — holds LiPo under GoPro
// ══════════════════════════════════════════════════════════════
module battery_compartment() {
bat_w = lipo_width + wall*2 + tolerance;
bat_d = lipo_height + wall*2 + tolerance;
bat_h = lipo_thick + wall*2 + 4;
difference() {
// Shell
rounded_cube(bat_w, bat_d, bat_h, 3);
// Battery cavity
translate([0, 0, wall])
rounded_cube(lipo_width + tolerance, lipo_height + tolerance, lipo_thick + tolerance, 1);
// Cable exit (to ESP32 compartment)
translate([0, bat_d/2, bat_h/2])
rotate([90, 0, 0])
cylinder(d=cable_dia, h=wall*3, center=true);
// Cable exit (to GoPro USB)
translate([bat_w/3, -bat_d/2, bat_h/2])
rotate([90, 0, 0])
cylinder(d=cable_dia, h=wall*3, center=true);
// Strap slots (velcro strap to secure to GoPro)
for (x = [-bat_w/3, bat_w/3]) {
translate([x, -bat_d/2, bat_h/2])
cube([8, wall*4, 3], center=true);
}
}
}
// ══════════════════════════════════════════════════════════════
// Utility: Rounded cube (positive X/Y/Z = full dimensions)
// ══════════════════════════════════════════════════════════════
module rounded_cube(w, d, h, r) {
hull() {
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
translate([x * (w/2 - r), y * (d/2 - r), z * (h/2 - r)])
sphere(r=r, $fn=20);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RemoteRig — Camera Monitoring Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body class="bg-rig-dark-900 text-rig-dark-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
// Package api handles HTTP request routing and handlers.
package api
+218
View File
@@ -0,0 +1,218 @@
// Package api provides HTTP handlers for camera operations.
package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/pkg/models"
"github.com/go-chi/chi/v5"
)
// ListCameras returns a handler for GET /cameras.
// Returns all cameras with their latest status from status_logs.
func ListCameras(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Query: join cameras with their most recent status_logs row
rows, err := database.QueryContext(ctx, `
SELECT
c.camera_id,
c.friendly_name,
s.battery_pct,
s.video_remaining_sec,
s.recording_state,
s.mode,
s.resolution,
s.fps,
s.online,
s.recorded_at
FROM cameras c
LEFT JOIN (
SELECT camera_id, battery_pct, video_remaining_sec, recording_state,
mode, resolution, fps, online, recorded_at,
ROW_NUMBER() OVER (PARTITION BY camera_id ORDER BY recorded_at DESC) as rn
FROM status_logs
) s ON c.camera_id = s.camera_id AND s.rn = 1
ORDER BY c.camera_id
`)
if err != nil {
log.Printf("Error querying cameras: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
defer rows.Close()
var statuses []models.CameraStatus
for rows.Next() {
var sl models.StatusLog
var c models.Camera
if err := rows.Scan(
&c.CameraID, &c.FriendlyName,
&sl.BatteryPct, &sl.VideoRemainingSec,
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
&sl.Online, &sl.RecordedAt,
); err != nil {
log.Printf("Error scanning camera row: %v", err)
continue
}
statuses = append(statuses, models.NewCameraStatus(c, sl))
}
if err := rows.Err(); err != nil {
log.Printf("Error iterating camera rows: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
if statuses == nil {
statuses = []models.CameraStatus{}
}
respondJSON(w, http.StatusOK, statuses)
}
}
// RegisterCamera returns a handler for POST /cameras.
func RegisterCamera(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress *string `json:"mac_address,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.CameraID == "" || req.FriendlyName == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
return
}
_, err := database.ExecContext(r.Context(), `
INSERT INTO cameras (camera_id, friendly_name, mac_address)
VALUES (?, ?, ?)
`, req.CameraID, req.FriendlyName, req.MacAddress)
if err != nil {
if err.Error() == "UNIQUE constraint failed: cameras.mac_address" {
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"})
return
}
log.Printf("Error registering camera: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
log.Printf("Registered camera %s (%s)", req.CameraID, req.FriendlyName)
resp := map[string]interface{}{
"camera_id": req.CameraID,
"friendly_name": req.FriendlyName,
}
if req.MacAddress != nil {
resp["mac_address"] = *req.MacAddress
}
respondJSON(w, http.StatusCreated, resp)
}
}
// GetCameraDetail returns a handler for GET /cameras/{id}.
func GetCameraDetail(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return
}
// Get camera info
var c models.Camera
err := database.QueryRowContext(r.Context(), `
SELECT camera_id, friendly_name, mac_address, created_at, updated_at
FROM cameras WHERE camera_id = ?
`, cameraID).Scan(
&c.CameraID, &c.FriendlyName, &c.MacAddress,
&c.CreatedAt, &c.UpdatedAt,
)
if err == sql.ErrNoRows {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"})
return
}
if err != nil {
log.Printf("Error querying camera: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
// Get latest status
var sl models.StatusLog
err = database.QueryRowContext(r.Context(), `
SELECT id, camera_id, recorded_at, battery_pct, video_remaining_sec,
recording_state, mode, resolution, fps, online, raw_battery_pct
FROM status_logs
WHERE camera_id = ?
ORDER BY recorded_at DESC
LIMIT 1
`, cameraID).Scan(
&sl.ID, &sl.CameraID, &sl.RecordedAt,
&sl.BatteryPct, &sl.VideoRemainingSec,
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
&sl.Online, &sl.RawBatteryPct,
)
if err != nil && err != sql.ErrNoRows {
log.Printf("Error querying latest status: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
// Get 24h history
historyRows, err := database.QueryContext(r.Context(), `
SELECT id, camera_id, recorded_at, battery_pct, video_remaining_sec,
recording_state, mode, resolution, fps, online, raw_battery_pct
FROM status_logs
WHERE camera_id = ? AND recorded_at >= datetime('now', '-24 hours')
ORDER BY recorded_at DESC
LIMIT 100
`, cameraID)
if err != nil {
log.Printf("Error querying history: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
defer historyRows.Close()
var history []models.StatusLog
for historyRows.Next() {
var h models.StatusLog
if err := historyRows.Scan(
&h.ID, &h.CameraID, &h.RecordedAt,
&h.BatteryPct, &h.VideoRemainingSec,
&h.RecordingState, &h.Mode, &h.Resolution, &h.FPS,
&h.Online, &h.RawBatteryPct,
); err != nil {
continue
}
history = append(history, h)
}
if history == nil {
history = []models.StatusLog{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"camera": c,
"last_status": sl,
"history": history,
})
}
}
// respondJSON writes a JSON response with the given status code.
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
+88
View File
@@ -0,0 +1,88 @@
// Package api provides HTTP handlers for camera operations.
package api
import (
"log"
"net/http"
"github.com/cubecraft/remoterig/internal/db"
"github.com/go-chi/chi/v5"
)
// StartRecording returns a handler for POST /cameras/{id}/start.
func StartRecording(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return
}
// Check if camera is registered
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
return
}
// Open recording event
result, err := database.ExecContext(r.Context(), `
INSERT INTO recording_events (camera_id, started_at, reason)
VALUES (?, datetime('now'), 'manual')
`, cameraID)
if err != nil {
log.Printf("Error starting recording: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
rows, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows)
respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_started",
"camera_id": cameraID,
})
}
}
// StopRecording returns a handler for POST /cameras/{id}/stop.
func StopRecording(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return
}
// Check if camera is registered
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
return
}
// Close the most recent open recording event
result, err := database.ExecContext(r.Context(), `
UPDATE recording_events SET stopped_at = datetime('now'), reason = 'manual'
WHERE camera_id = ? AND stopped_at IS NULL
`, cameraID)
if err != nil {
log.Printf("Error stopping recording: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
rows, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows)
respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_stopped",
"camera_id": cameraID,
})
}
}
+106
View File
@@ -0,0 +1,106 @@
// Package api provides HTTP handlers for camera operations.
package api
import (
"encoding/json"
"log"
"net/http"
"github.com/cubecraft/remoterig/internal/db"
"github.com/go-chi/chi/v5"
)
// PushStatus accepts a status update from an ESP32 node and persists it.
func PushStatus(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return
}
var req struct {
BatteryPct *int `json:"battery_pct"`
VideoRemainingSec *int `json:"video_remaining_sec"`
Recording bool `json:"recording"`
Mode string `json:"mode"`
Resolution string `json:"resolution"`
FPS int `json:"fps"`
Online bool `json:"online"`
RawBatteryPct *float64 `json:"raw_battery_pct"`
Timestamp *string `json:"ts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Check if camera is registered
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
return
}
// Insert status log
result, err := database.ExecContext(r.Context(), `
INSERT INTO status_logs (camera_id, battery_pct, video_remaining_sec,
recording_state, mode, resolution, fps, online, raw_battery_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, cameraID, req.BatteryPct, req.VideoRemainingSec,
boolToInt(req.Recording), req.Mode, req.Resolution,
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
if err != nil {
log.Printf("Error inserting status log: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"})
return
}
// Check if recording state changed - update recording_events if so
var prevRecording int
err = database.QueryRowContext(r.Context(), `
SELECT recording_state FROM status_logs
WHERE camera_id = ? AND recorded_at > datetime('now', '-60 seconds')
ORDER BY recorded_at DESC LIMIT 1
`, cameraID).Scan(&prevRecording)
if err == nil && prevRecording != boolToInt(req.Recording) {
reason := "manual"
if req.Recording {
// Start recording - open a new event
_, err := database.ExecContext(r.Context(), `
INSERT INTO recording_events (camera_id, started_at, reason)
VALUES (?, datetime('now'), ?)
`, cameraID, reason)
if err != nil {
log.Printf("Error inserting recording event: %v", err)
}
} else {
// Stop recording - close the most recent open event
_, err := database.ExecContext(r.Context(), `
UPDATE recording_events SET stopped_at = datetime('now')
WHERE camera_id = ? AND stopped_at IS NULL
ORDER BY started_at DESC LIMIT 1
`, cameraID)
if err != nil {
log.Printf("Error updating recording event: %v", err)
}
}
}
_, _ = result.RowsAffected() // consume the result
respondJSON(w, http.StatusOK, map[string]string{
"status": "accepted",
})
}
}
// boolToInt converts a bool to 0 or 1 for SQLite storage.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+32
View File
@@ -0,0 +1,32 @@
// Package auth provides API key authentication middleware.
package auth
import (
"net/http"
)
// Middleware returns a Chi middleware that validates the X-API-Key header.
func Middleware(apiKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("X-API-Key")
if apiKey == "" {
// No API key configured — allow all requests (kiosk mode)
next.ServeHTTP(w, r)
return
}
if header == "" || header != apiKey {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
// ExtractKey reads and returns the API key from the request, or empty string.
func ExtractKey(r *http.Request) string {
return r.Header.Get("X-API-Key")
}
+133
View File
@@ -1,2 +1,135 @@
// Package db provides SQLite database initialization and schema management.
package db
import (
"database/sql"
_ "embed"
"log"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
//go:embed migrations/001_create_tables.sql
var migration001 string
// DB wraps the sql.DB with connection-level settings.
type DB struct {
*sql.DB
}
// Open opens the SQLite database at the given path, enables WAL mode,
// and runs all migrations if the tables don't exist yet.
func Open(path string) (*DB, error) {
// Ensure the directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
// Enable WAL for concurrent read/write performance
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, err
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, err
}
// Check if tables already exist (idempotent migration)
var count int
if err := db.QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings')
`).Scan(&count); err != nil {
db.Close()
return nil, err
}
if count < 4 {
log.Printf("Running migrations for %s...", path)
if err := migrate(db, migration001); err != nil {
db.Close()
return nil, err
}
log.Println("Migrations complete")
}
return &DB{db}, nil
}
// migrate executes a SQL migration string.
func migrate(db *sql.DB, sql string) error {
// Split on semicolons to handle multiple statements
statements := splitSQL(sql)
for _, stmt := range statements {
stmt = stripWhitespace(stmt)
if stmt == "" {
continue
}
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
func splitSQL(sql string) []string {
var stmts []string
var current string
inQuote := false
quoteChar := rune(0)
for _, r := range sql {
if inQuote {
current += string(r)
if r == quoteChar {
inQuote = false
}
continue
}
switch r {
case '"', '\'', '`':
inQuote = true
quoteChar = r
current += string(r)
case ';':
stmts = append(stmts, current)
current = ""
default:
current += string(r)
}
}
if len(current) > 0 {
stmts = append(stmts, current)
}
return stmts
}
// stripWhitespace removes leading/trailing whitespace and normalizes newlines.
func stripWhitespace(s string) string {
result := ""
runningSpace := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !runningSpace {
result += " "
runningSpace = true
}
} else {
result += string(r)
runningSpace = false
}
}
return result
}
@@ -0,0 +1,57 @@
-- RemoteRig Database Schema (SQLite)
-- Migration: 001_create_tables
-- 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'))
);
CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
-- Status logs: every poll from an ESP8266 node
CREATE TABLE IF NOT EXISTS status_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_id TEXT NOT NULL REFERENCES cameras(camera_id),
recorded_at DATETIME NOT NULL DEFAULT (datetime('now')),
battery_pct INTEGER,
video_remaining_sec INTEGER,
recording_state INTEGER NOT NULL DEFAULT 0,
mode TEXT,
resolution TEXT,
fps INTEGER,
online INTEGER NOT NULL DEFAULT 1,
raw_battery_pct REAL
);
CREATE INDEX IF NOT EXISTS idx_status_logs_camera_time
ON status_logs(camera_id, recorded_at DESC);
-- Recording events: explicit start/stop events
CREATE TABLE IF NOT EXISTS recording_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_id TEXT NOT NULL REFERENCES cameras(camera_id),
started_at DATETIME NOT NULL,
stopped_at DATETIME,
reason TEXT,
duration INTEGER
);
CREATE INDEX IF NOT EXISTS idx_recording_events_camera_time
ON recording_events(camera_id, started_at DESC);
-- Settings: system-wide and per-camera config
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
-- Seed: default poll interval and thresholds
INSERT INTO settings (key, value) VALUES
('poll_interval_sec', '30'),
('low_battery_threshold', '15'),
('low_storage_alert_sec', '300');
+165
View File
@@ -0,0 +1,165 @@
// Package events provides Server-Sent Events (SSE) for real-time camera status.
package events
import (
"encoding/json"
"fmt"
"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
eventSeq atomic.Int64 // monotonic event ID for Last-Event-ID
}
// NewHub creates a new SSE hub.
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
}
}
// Handler returns an HTTP handler for SSE connections.
func (h *Hub) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.serveSSE(w, r)
})
}
// Client represents an SSE client connection.
type Client struct {
done chan struct{}
writes chan []byte
}
// Write sends data to the client.
func (c *Client) Write(data []byte) bool {
select {
case c.writes <- data:
return true
default:
return false // buffer full
}
}
// Close stops the client.
func (c *Client) Close() {
close(c.done)
}
// serveSSE handles SSE connections.
func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
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)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
// Create client
client := &Client{
done: make(chan struct{}),
writes: make(chan []byte, 256),
}
// Register client
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
// Cleanup on disconnect
defer func() {
h.mu.Lock()
delete(h.clients, client)
h.mu.Unlock()
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
seq := h.eventSeq.Add(1)
data, _ := json.Marshal(map[string]interface{}{
"type": "connected",
"id": seq,
"ts": time.Now().Format(time.RFC3339),
})
eventLine := fmt.Sprintf("id: %d\nevent: connected\ndata: %s\n\n", seq, string(data))
if !client.Write([]byte(eventLine)) {
return // client disconnected
}
// Heartbeat and write loop
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-client.done:
return
case <-r.Context().Done():
log.Println("SSE client disconnected")
return
case event := <-client.writes:
fmt.Fprintf(w, "data: %s\n\n", string(event))
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(w, ": heartbeat\n\n")
flusher.Flush()
}
}
}
// 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,
}
data, err := json.Marshal(event)
if err != nil {
log.Printf("SSE marshal error: %v", err)
return
}
eventLine := fmt.Sprintf("id: %d\nevent: %s\ndata: %s\n\n", seq, eventType, string(data))
for client := range h.clients {
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,''), created_at, updated_at FROM cameras WHERE camera_id = ?",
cameraID,
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.CreatedAt, &cam.UpdatedAt)
return cam, err
}
func stringPtr(s *string) *string {
if s == nil || *s == "" {
return nil
}
return s
}
func intPtr(i *int) *int {
if i == nil {
return nil
}
return i
}
// Close shuts down the MQTT subscriber.
func (s *Subscriber) Close() {
close(s.done)
if s.client != nil && s.client.IsConnected() {
s.client.Disconnect(250)
log.Println("MQTT disconnected")
}
}
Executable
BIN
View File
Binary file not shown.
+5040
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "remoterig-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"lucide-react": "^0.469.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.7.2",
"typescript-eslint": "^8.18.0",
"vite": "^6.1.0",
"vitest": "^4.1.7"
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package models contains the data structures for RemoteRig.
package models
import (
"time"
)
// 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"`
}
// StatusLog records a single status poll from an ESP8266 node.
type StatusLog struct {
ID int64 `json:"id"`
CameraID string `json:"camera_id"`
RecordedAt time.Time `json:"recorded_at"`
BatteryPct *int `json:"battery_pct,omitempty"`
VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
RecordingState int `json:"recording_state"` // 0=idle, 1=recording
Mode *string `json:"mode,omitempty"`
Resolution *string `json:"resolution,omitempty"`
FPS *int `json:"fps,omitempty"`
Online int `json:"online"` // 0=offline, 1=online
RawBatteryPct *float64 `json:"raw_battery_pct,omitempty"`
}
// CameraStatus is a combined view: latest status log joined with camera info.
type CameraStatus struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
BatteryPct *int `json:"battery_pct,omitempty"`
VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
Recording bool `json:"recording"`
Mode string `json:"mode"`
Resolution string `json:"resolution"`
FPS int `json:"fps"`
Online bool `json:"online"`
LastSeen time.Time `json:"last_seen"`
}
// RecordingEvent represents a start/stop recording event.
type RecordingEvent struct {
ID int64 `json:"id"`
CameraID string `json:"camera_id"`
StartedAt time.Time `json:"started_at"`
StoppedAt *time.Time `json:"stopped_at,omitempty"`
Reason *string `json:"reason,omitempty"`
Duration *int `json:"duration,omitempty"` // seconds
}
// Settings stores system-wide configuration.
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewCameraStatus creates a CameraStatus from a Camera and StatusLog.
func NewCameraStatus(c Camera, sl StatusLog) CameraStatus {
return CameraStatus{
CameraID: c.CameraID,
FriendlyName: c.FriendlyName,
BatteryPct: sl.BatteryPct,
VideoRemainingSec: sl.VideoRemainingSec,
Recording: sl.RecordingState == 1,
Mode: takeString(sl.Mode),
Resolution: takeString(sl.Resolution),
FPS: takeInt(sl.FPS),
Online: sl.Online == 1,
LastSeen: sl.RecordedAt,
}
}
func takeString(s *string) string {
if s == nil {
return ""
}
return *s
}
func takeInt(i *int) int {
if i == nil {
return 0
}
return *i
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
View File
+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: 192.168.4.10/24)
# --gateway IP Gateway for wlan0 (default: 192.168.4.1)
# --help Show this help
set -euo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
CONFIG_TEMPLATE=""
SERVICE_USER="pi"
STATIC_IP="192.168.4.10/24"
GATEWAY="192.168.4.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@192.168.4.10:/opt/remoterig/"
echo " 3. Copy config if needed:"
echo " scp config.yaml pi@192.168.4.10:/opt/remoterig/"
echo " 4. Start the service:"
echo " sudo systemctl start remoterig"
echo " 5. Check health:"
echo " curl http://192.168.4.10:8080/health"
echo ""
echo " To deploy updates, use: scripts/deploy.sh"
echo "=============================================="
+96
View File
@@ -0,0 +1,96 @@
import { Camera, Radio } from 'lucide-react'
import { useSSE } from './hooks/useSSE'
import { useCameraStore } from './store/useCameraStore'
import { CameraCard } from './components'
function App() {
// Connect to SSE endpoint — auto-updates the camera store
useSSE()
// Subscribe to the camera store for reactivity.
// getCameras / getOnlineCount / getRecordingCount pull from live state.
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
const cameras = getCameras()
const onlineCount = getOnlineCount()
const recordingCount = getRecordingCount()
return (
<div className="min-h-screen bg-rig-dark-900">
{/* Header */}
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<Camera className="h-7 w-7 text-rig-accent" />
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
RemoteRig
</h1>
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
Dashboard
</span>
{/* Stats badges */}
<div className="ml-auto flex items-center gap-4">
{/* Online count */}
<span
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
title="Cameras online"
>
<span className="h-2 w-2 rounded-full bg-rig-success" />
{onlineCount} online
</span>
{/* Recording count */}
<span
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
title="Cameras recording"
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-rig-danger" />
</span>
{recordingCount} recording
</span>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{cameras.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
<span className="relative mb-4 inline-flex">
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
</span>
<h2 className="text-lg font-semibold text-rig-dark-200">
Waiting for cameras&hellip;
</h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Connect cameras to your RemoteRig server and they will appear here
automatically.
</p>
</div>
) : (
/* Camera grid */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cameras.map((camera) => (
<CameraCard key={camera.camera_id} camera={camera} />
))}
</div>
)}
</main>
{/* Footer */}
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
<p className="text-center text-xs text-rig-dark-500">
RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System
</p>
</div>
</footer>
</div>
)
}
export default App
+163
View File
@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import CameraCard from './CameraCard'
import type { CameraStatus } from '../types'
function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
return {
camera_id: 'cam-1',
friendly_name: 'Front Camera',
battery_pct: 85,
video_remaining_sec: 3600,
recording: false,
mode: 'video',
resolution: '1080p',
fps: 30,
online: true,
last_seen: new Date().toISOString(),
...overrides,
}
}
describe('CameraCard', () => {
// ── Basic rendering ────────────────────────────────────────────────────
it('renders camera name', () => {
render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText('Front Camera')).toBeInTheDocument()
})
it('shows resolution and FPS', () => {
render(<CameraCard camera={makeCamera()} />)
expect(screen.getByText(/1080p/)).toBeInTheDocument()
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
})
it('shows battery percentage', () => {
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
expect(screen.getByText('85%')).toBeInTheDocument()
})
it('shows N/A when battery is null', () => {
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
expect(screen.getByText('N/A')).toBeInTheDocument()
})
// ── Battery bar colors ─────────────────────────────────────────────────
it('uses green bar for high battery (>=50%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
)
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success')
})
it('uses yellow bar for medium battery (15-49%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
)
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('uses red bar for low battery (<15%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
)
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-danger')
})
// ── Recording state ────────────────────────────────────────────────────
it('shows REC badge when recording', () => {
render(<CameraCard camera={makeCamera({ recording: true })} />)
expect(screen.getByText('REC')).toBeInTheDocument()
})
it('shows IDLE badge when not recording', () => {
render(<CameraCard camera={makeCamera({ recording: false })} />)
expect(screen.getByText('IDLE')).toBeInTheDocument()
})
// ── Online / Offline badges ────────────────────────────────────────────
it('shows Online badge when camera is online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('shows Offline badge when camera is offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
const offlineElements = screen.getAllByText('Offline')
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
})
// ── Video remaining ────────────────────────────────────────────────────
it('shows video remaining time when available', () => {
render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />)
// formatTimeLeft(125) → "2m 5s left"
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
})
it('does not show video remaining when null', () => {
render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />)
// The Radio icon and time text should not be present
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
})
// ── Footer ─────────────────────────────────────────────────────────────
it('shows Live + timestamp in footer when online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
// Footer shows "Live" when online
expect(screen.getByText('Live')).toBeInTheDocument()
})
it('shows Offline + timestamp in footer when offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
// Footer says "Offline" (the text appears both in the badge and footer)
// When offline, the footer specifically shows "Offline" text
const offlineElements = screen.getAllByText('Offline')
// At least one should exist (badge + footer)
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
})
it('shows "unknown" when last_seen is malformed', () => {
render(
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
)
expect(screen.getByText('unknown')).toBeInTheDocument()
})
it('shows "unknown" when last_seen is in the future', () => {
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
render(<CameraCard camera={makeCamera({ last_seen: future })} />)
expect(screen.getByText('unknown')).toBeInTheDocument()
})
// ── Edge cases ──────────────────────────────────────────────────────────
it('clamps negative battery_pct to 0%', () => {
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
expect(screen.getByText('0%')).toBeInTheDocument()
})
it('shows exact boundary: 15% battery → yellow bar', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
)
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('shows exact boundary: 50% battery → green bar', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
)
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success')
})
})
+185
View File
@@ -0,0 +1,185 @@
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react'
import type { CameraStatus } from '../types'
// ── Helpers ────────────────────────────────────────────────────────────────
function formatRelativeTime(iso: string): string {
const then = new Date(iso).getTime()
if (isNaN(then)) return 'unknown'
const diffSec = Math.floor((Date.now() - then) / 1000)
if (diffSec < 0) return 'unknown'
if (diffSec < 10) return 'just now'
if (diffSec < 60) return `${diffSec}s ago`
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDay = Math.floor(diffHr / 24)
return `${diffDay}d ago`
}
function batteryColor(pct: number | null): { bar: string; text: string } {
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
}
function formatTimeLeft(sec: number): string {
if (sec <= 0 || !isFinite(sec)) return '--'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}m ${s}s left`
}
// ── Component ──────────────────────────────────────────────────────────────
interface CameraCardProps {
camera: CameraStatus
}
export default function CameraCard({ camera }: CameraCardProps) {
const {
friendly_name,
online,
resolution,
fps,
recording,
mode,
battery_pct,
last_seen,
video_remaining_sec,
} = camera
const batt = batteryColor(battery_pct)
return (
<article
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
online
? 'border-rig-dark-600 hover:border-rig-accent/40'
: 'border-rig-dark-700 opacity-75'
}`}
>
{/* ── Header ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
<h3
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
title={friendly_name}
>
{friendly_name}
</h3>
</div>
{/* Online / Offline badge */}
<span
role="status"
aria-label={online ? 'Camera online' : 'Camera offline'}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
online
? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger'
}`}
>
{online ? (
<Wifi className="h-3 w-3" aria-hidden="true" />
) : (
<WifiOff className="h-3 w-3" aria-hidden="true" />
)}
{online ? 'Online' : 'Offline'}
</span>
</div>
{/* ── Body ── */}
<div className="space-y-2.5 px-4 pb-3">
{/* Resolution + FPS */}
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
<Signal className="h-3.5 w-3.5" />
<span>
{resolution} &middot; {fps} FPS
</span>
</div>
{/* Recording state */}
<div className="flex items-center gap-2">
{recording ? (
<>
<span
className="relative flex h-2.5 w-2.5"
role="status"
aria-label="Recording in progress"
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-rig-danger" />
</span>
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rig-danger">
REC
</span>
</>
) : (
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rig-dark-400">
IDLE
</span>
)}
<span className="text-[11px] text-rig-dark-400">{mode}</span>
</div>
{/* Battery */}
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1 text-rig-dark-400">
<Battery className="h-3 w-3" />
Battery
</span>
<span className={`font-mono text-xs ${batt.text}`}>
{battery_pct !== null ? `${Math.max(0, battery_pct)}%` : 'N/A'}
</span>
</div>
<div
className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700"
role="progressbar"
aria-valuenow={battery_pct ?? 0}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Battery ${battery_pct !== null ? battery_pct + '%' : 'unknown'}`}
>
<div
className={`h-full rounded-full transition-all ${batt.bar}`}
style={{ width: battery_pct !== null ? `${Math.min(100, Math.max(0, battery_pct))}%` : '0%' }}
/>
</div>
</div>
</div>
{/* ── Footer ── */}
<div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2">
<div className="flex items-center gap-1.5 text-xs">
{online ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
<span className="text-rig-success">Live</span>
</>
) : (
<span className="text-rig-dark-400">Offline</span>
)}
<span className="text-rig-dark-500">·</span>
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
</div>
{video_remaining_sec !== null && (
<div className="flex items-center gap-1 text-xs text-rig-dark-400">
<Radio className="h-3 w-3" />
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span>
</div>
)}
</div>
</article>
)
}
+1
View File
@@ -0,0 +1 @@
export { default as CameraCard } from './CameraCard'
+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>
+4
View File
@@ -0,0 +1,4 @@
export { useCameraStatus } from './useCameraStatus'
export { useSystemHealth } from './useSystemHealth'
export { useSSE } from './useSSE'
export type { SSEConnectionState } from './useSSE'
+23
View File
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react'
import type { Camera } from '../types'
const MOCK_CAMERAS: Camera[] = [
{ id: 'cam-1', name: 'Front View', status: 'online', position: 'North', fps: 30, resolution: '1920x1080', streamUrl: '/api/cameras/cam-1/stream', recording: true },
{ id: 'cam-2', name: 'Side View', status: 'online', position: 'East', fps: 30, resolution: '1920x1080', streamUrl: '/api/cameras/cam-2/stream', recording: false },
{ id: 'cam-3', name: 'Top View', status: 'connecting', position: 'Center', fps: 15, streamUrl: '/api/cameras/cam-3/stream', recording: false },
]
export function useCameraStatus() {
const [cameras, setCameras] = useState<Camera[]>(MOCK_CAMERAS)
useEffect(() => {
const interval = setInterval(() => {
setCameras((prev) =>
prev.map((c) => ({ ...c })),
)
}, 5000)
return () => clearInterval(interval)
}, [])
return { cameras }
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useCallback } from 'react'
import { useCameraStore } from '../store/useCameraStore'
import type { CameraStatus } from '../types'
/** Shape of events sent by the Go SSE hub */
interface SSEEvent {
type: string
ts: string
payload?: unknown
}
/** Connection states for the SSE stream */
export type SSEConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
interface UseSSEOptions {
/** Override the SSE endpoint (defaults to VITE_API_URL + /events/stream) */
endpoint?: string
}
interface UseSSEReturn {
/** Current connection state */
connectionState: SSEConnectionState
/** Last event timestamp */
lastEventTs: string | null
/** Manually close and reopen the connection */
reconnect: () => void
}
const DEFAULT_ENDPOINT = `${import.meta.env.VITE_API_URL || '/api/v1'}/events/stream`
/**
* `useSSE` connects to the RemoteRig SSE endpoint at `/api/v1/events/stream`
* and updates the shared camera store (`useCameraStore`) with live events.
*
* Handled event types:
* - `connected` — initial handshake
* - `camera_status` — full camera status snapshot
* - `camera_online` / `camera_offline` — online/offline state change
* - `recording_event` — recording started or stopped
*
* EventSource auto-reconnects natively; the hook also exposes a `reconnect()`
* helper for manual reconnect.
*/
export function useSSE(opts: UseSSEOptions = {}): UseSSEReturn {
const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT
const store = useCameraStore()
const connectionStateRef = useRef<SSEConnectionState>('disconnected')
const lastEventTsRef = useRef<string | null>(null)
const esRef = useRef<EventSource | null>(null)
// Derive a stable reference for connection state that doesn't trigger re-renders
// across every event. We return it via a getter callback but the store change
// re-renders are handled by Zustand selectors in consuming components.
const handleEvent = useCallback(
(event: MessageEvent) => {
try {
const data: SSEEvent = JSON.parse(event.data)
lastEventTsRef.current = data.ts
switch (data.type) {
case 'connected': {
// Initial handshake — no state update needed
connectionStateRef.current = 'connected'
break
}
case 'camera_status': {
// Full camera status payload — upsert into the store
const cam = data.payload as CameraStatus | undefined
if (cam?.camera_id) {
store.updateCamera(cam)
}
break
}
case 'camera_online': {
const pl = data.payload as { camera_id?: string } | undefined
if (pl?.camera_id) {
store.setOnline(pl.camera_id)
}
break
}
case 'camera_offline': {
const pl = data.payload as { camera_id?: string } | undefined
if (pl?.camera_id) {
store.setOffline(pl.camera_id)
}
break
}
case 'recording_event': {
const pl = data.payload as
| { camera_id?: string; recording?: boolean }
| undefined
if (pl?.camera_id && typeof pl.recording === 'boolean') {
store.setRecording(pl.camera_id, pl.recording)
}
break
}
default:
// Unknown event type — ignore gracefully
break
}
} catch {
// Malformed JSON — ignore
}
},
[store],
)
const connect = useCallback(() => {
// Close existing connection if any
esRef.current?.close()
connectionStateRef.current = 'connecting'
const es = new EventSource(endpoint)
esRef.current = es
es.onopen = () => {
connectionStateRef.current = 'connected'
}
es.onmessage = handleEvent
es.onerror = () => {
connectionStateRef.current =
es.readyState === EventSource.CLOSED ? 'disconnected' : 'error'
// EventSource auto-reconnects unless CLOSED; nothing extra needed here
}
}, [endpoint, handleEvent])
const reconnect = useCallback(() => {
esRef.current?.close()
connect()
}, [connect])
useEffect(() => {
connect()
return () => {
esRef.current?.close()
connectionStateRef.current = 'disconnected'
}
}, [connect])
return {
get connectionState() {
return connectionStateRef.current
},
get lastEventTs() {
return lastEventTsRef.current
},
reconnect,
}
}
+28
View File
@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react'
import type { SystemHealth } from '../types'
const MOCK_HEALTH: SystemHealth = {
cpuUsage: 23,
memoryUsage: 45,
gpuUsage: 12,
temperature: 58,
uptime: '2d 14h 32m',
}
export function useSystemHealth() {
const [health, setHealth] = useState<SystemHealth>(MOCK_HEALTH)
useEffect(() => {
const interval = setInterval(() => {
setHealth((prev) => ({
...prev,
cpuUsage: Math.min(100, Math.max(0, prev.cpuUsage + Math.round((Math.random() - 0.5) * 10))),
memoryUsage: Math.min(100, Math.max(0, prev.memoryUsage + Math.round((Math.random() - 0.5) * 4))),
temperature: Math.min(95, Math.max(30, prev.temperature + Math.round((Math.random() - 0.5) * 3))),
}))
}, 3000)
return () => clearInterval(interval)
}, [])
return { health }
}
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Dashboard-specific resets */
html {
@apply antialiased;
}
body {
@apply bg-rig-dark-900 text-rig-dark-100;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-rig-dark-800;
}
::-webkit-scrollbar-thumb {
@apply bg-rig-dark-600 rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-rig-dark-500;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+20
View File
@@ -0,0 +1,20 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api'
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export const api = {
getCameras: () => request<[]>('/cameras'),
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
getSystemHealth: () => request<[]>('/system/health'),
toggleRecording: (cameraId: string) =>
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
}
+1
View File
@@ -0,0 +1 @@
export { useCameraStore } from './useCameraStore'
+86
View File
@@ -0,0 +1,86 @@
import { create } from 'zustand'
import type { CameraStatus } from '../types'
interface CameraState {
/** All cameras with latest status from SSE events */
cameras: Map<string, CameraStatus>
/** Replace the full camera list (e.g. on initial SSE or polling fallback) */
setCameras: (cameras: CameraStatus[]) => void
/** Upsert a single camera status from an SSE event */
updateCamera: (camera: CameraStatus) => void
/** Mark a camera offline */
setOffline: (cameraId: string) => void
/** Mark a camera online */
setOnline: (cameraId: string) => void
/** Update recording state from a recording_event */
setRecording: (cameraId: string, recording: boolean) => void
/** Derived: camera array (for UI consumption) */
getCameras: () => CameraStatus[]
/** Derived: online count */
getOnlineCount: () => number
/** Derived: recording count */
getRecordingCount: () => number
}
export const useCameraStore = create<CameraState>((set, get) => ({
cameras: new Map(),
setCameras: (list) =>
set((state) => {
const next = new Map(state.cameras)
for (const cam of list) {
next.set(cam.camera_id, cam)
}
return { cameras: next }
}),
updateCamera: (camera) =>
set((state) => {
const next = new Map(state.cameras)
next.set(camera.camera_id, camera)
return { cameras: next }
}),
setOffline: (cameraId) =>
set((state) => {
const cam = state.cameras.get(cameraId)
if (!cam) return state
const next = new Map(state.cameras)
next.set(cameraId, { ...cam, online: false })
return { cameras: next }
}),
setOnline: (cameraId) =>
set((state) => {
const cam = state.cameras.get(cameraId)
if (!cam) return state
const next = new Map(state.cameras)
next.set(cameraId, { ...cam, online: true })
return { cameras: next }
}),
setRecording: (cameraId, recording) =>
set((state) => {
const cam = state.cameras.get(cameraId)
if (!cam) return state
const next = new Map(state.cameras)
next.set(cameraId, { ...cam, recording })
return { cameras: next }
}),
getCameras: () => Array.from(get().cameras.values()),
getOnlineCount: () =>
Array.from(get().cameras.values()).filter((c) => c.online).length,
getRecordingCount: () =>
Array.from(get().cameras.values()).filter((c) => c.recording).length,
}))
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
+56
View File
@@ -0,0 +1,56 @@
// RemoteRig TypeScript types
/** Full camera status from GET /api/v1/cameras and SSE events */
export interface CameraStatus {
camera_id: string
friendly_name: string
battery_pct: number | null
video_remaining_sec: number | null
recording: boolean
mode: string
resolution: string
fps: number
online: boolean
last_seen: string // ISO 8601
}
/** SSE event envelope from /api/v1/events/stream */
export interface SSEEvent {
type: string
ts: string
payload?: unknown
}
export interface Camera {
id: string
name: string
streamUrl: string
status: 'online' | 'offline' | 'error' | 'connecting'
position?: string
fps: number
resolution?: string
recording: boolean
}
export interface CameraFeed {
cameraId: string
thumbnailUrl?: string
frameRate: number
bitrate?: string
}
export interface SystemHealth {
cpuUsage: number
memoryUsage: number
gpuUsage: number
temperature: number
uptime: string
}
export interface StreamConfig {
width: number
height: number
fps: number
codec: string
bitrate: string
}
+25
View File
@@ -0,0 +1,25 @@
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
parts.push(`${minutes}m`)
return parts.join(' ')
}
export function statusColor(status: string): string {
switch (status) {
case 'online':
return 'bg-rig-success'
case 'offline':
return 'bg-rig-danger'
case 'error':
return 'bg-rig-danger'
case 'connecting':
return 'bg-rig-warning'
default:
return 'bg-rig-dark-500'
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+40
View File
@@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Dark dashboard palette
'rig-dark': {
50: '#f3f4f6',
100: '#e5e7eb',
200: '#d1d5db',
300: '#9ca3af',
400: '#6b7280',
500: '#4b5563',
600: '#374151',
700: '#1f2937',
800: '#111827',
900: '#030712',
},
'rig-accent': {
DEFAULT: '#22d3ee',
light: '#67e8f9',
dark: '#06b6d4',
},
'rig-danger': '#ef4444',
'rig-success': '#22c55e',
'rig-warning': '#eab308',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
},
},
plugins: [],
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
},
})