generated from CubeCraft-Creations/Tracehound
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36db9477fb | |||
| be9e170484 | |||
| ac902b48c9 | |||
| d475960ce7 | |||
| f03fd84514 | |||
| 54ea265d11 | |||
| f046695b5e | |||
| d0f76ea4a9 | |||
| 4f817887ab |
@@ -9,7 +9,7 @@ on:
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
NODE_VERSION: "20"
|
||||
BINARY_NAME: remoterig
|
||||
BINARY_NAME: openclaw
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -29,13 +29,20 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Build React frontend
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build Go binary (ARM64 cross-compile)
|
||||
- name: Embed frontend into Go binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||
mkdir -p internal/web/dist
|
||||
cp -r web/dist/* internal/web/dist/
|
||||
go generate ./internal/web/...
|
||||
|
||||
- name: Build Go binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
|
||||
-o ${{ env.BINARY_NAME }} ./cmd/server
|
||||
|
||||
|
||||
+10
-16
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
lint-and-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: go-react
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
test:
|
||||
needs: lint-and-typecheck
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: go-react
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: go-react
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -42,24 +42,18 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Verify build output
|
||||
run: |
|
||||
du -sh dist/
|
||||
echo "Build successful"
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: go-react
|
||||
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
|
||||
- name: Deploy placeholder
|
||||
run: |
|
||||
echo "Deploying to production..."
|
||||
echo "Deploy target: /var/www/remote-rig/"
|
||||
echo "Placeholder — configure deploy target before merging to main"
|
||||
echo "Configure deploy command before merging to main"
|
||||
|
||||
@@ -7,10 +7,10 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
BINARY_NAME: remoterig
|
||||
BINARY_NAME: openclaw
|
||||
DEV_HOST: ${{ secrets.DEV_HOST }}
|
||||
DEV_USER: ${{ secrets.DEV_USER }}
|
||||
DEPLOY_PATH: /opt/remoterig/remoterig
|
||||
DEPLOY_PATH: /opt/openclaw/openclaw
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -32,9 +32,9 @@ jobs:
|
||||
cat > deploy.sh <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
BINARY="${1:-remoterig}"
|
||||
DEPLOY_PATH="${2:-/opt/remoterig/remoterig}"
|
||||
SERVICE="${3:-remoterig}"
|
||||
BINARY="${1:-openclaw}"
|
||||
DEPLOY_PATH="${2:-/opt/openclaw/openclaw}"
|
||||
SERVICE="${3:-openclaw}"
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
|
||||
|
||||
@@ -69,23 +69,14 @@ jobs:
|
||||
SCRIPT
|
||||
chmod +x deploy.sh
|
||||
|
||||
- name: Deploy config.yaml (if present)
|
||||
run: |
|
||||
if [ -f config.yaml ]; then
|
||||
echo "config.yaml found, will deploy alongside binary"
|
||||
echo "config.yaml" >> deploy-files.txt
|
||||
else
|
||||
echo "no config.yaml in repo, skipping"
|
||||
fi
|
||||
|
||||
- name: Deploy to dev server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ env.DEV_HOST }}
|
||||
username: ${{ env.DEV_USER }}
|
||||
key: ${{ secrets.DEV_SSH_KEY }}
|
||||
source: "${{ env.BINARY_NAME }},deploy.sh,config.yaml"
|
||||
target: "/tmp/remoterig-deploy"
|
||||
source: "${{ env.BINARY_NAME }},deploy.sh"
|
||||
target: "/tmp/openclaw-deploy"
|
||||
|
||||
- name: Execute deploy on dev server
|
||||
uses: appleboy/ssh-action@v1
|
||||
@@ -95,14 +86,9 @@ jobs:
|
||||
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
|
||||
cd /tmp/openclaw-deploy
|
||||
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "openclaw"
|
||||
rm -rf /tmp/openclaw-deploy
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
@@ -112,4 +98,4 @@ jobs:
|
||||
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
|
||||
echo "deploy failed for commit ${{ github.sha }} on ${{ github.repository }}" > /tmp/openclaw-deploy-failure.txt
|
||||
@@ -13,10 +13,6 @@ 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
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,156 +0,0 @@
|
||||
# RemoteRig Central Hub
|
||||
|
||||
A central hub for managing remote camera rigs, designed for Raspberry Pi Zero 2 W.
|
||||
|
||||
## Overview
|
||||
|
||||
RemoteRig Central Hub is the control plane for remote camera setups. It connects to camera rigs over MQTT, stores configuration and state in SQLite, and exposes a management API — all from a lightweight Go binary optimized for resource-constrained devices like the Raspberry Pi Zero 2 W.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
| -------------- | ----------------------------- |
|
||||
| Language | Go 1.24+ |
|
||||
| Database | SQLite |
|
||||
| Messaging | MQTT |
|
||||
| Configuration | YAML (`gopkg.in/yaml.v3`) |
|
||||
| Target Platform| Raspberry Pi Zero 2 W (ARMv6) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
remote-rig/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Application entry point
|
||||
├── internal/
|
||||
│ └── db/
|
||||
│ └── db.go # SQLite database initialization and schema
|
||||
├── config.yaml # Application configuration
|
||||
├── go.mod # Go module definition
|
||||
├── go.sum # Dependency checksums
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.24+** — [Download and install](https://go.dev/dl/)
|
||||
- **MQTT Broker** — e.g., [Mosquitto](https://mosquitto.org/) (default: `localhost:1883`)
|
||||
- **Raspberry Pi Zero 2 W** (or any Linux system — macOS and Windows also work for development)
|
||||
- **Git** — for cloning the repository
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://code.cubecraftcreations.com/CubeCraft-Creations/remote-rig.git
|
||||
cd remote-rig
|
||||
```
|
||||
|
||||
### 2. Install Go Dependencies
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
### 3. Configure
|
||||
|
||||
Edit `config.yaml` to match your environment:
|
||||
|
||||
```yaml
|
||||
# Database file path (SQLite)
|
||||
db_path: "remoterig.db"
|
||||
|
||||
# API key for endpoint authentication — CHANGE THIS
|
||||
api_key: "your-secure-api-key-here"
|
||||
|
||||
# HTTP server settings
|
||||
port: 8080
|
||||
read_timeout: 5s
|
||||
write_timeout: 10s
|
||||
idle_timeout: 120s
|
||||
|
||||
# MQTT broker connection
|
||||
mqtt:
|
||||
broker: "localhost:1883"
|
||||
client_id: "remoterig-hub"
|
||||
|
||||
# Target platform
|
||||
platform:
|
||||
type: "pi-zero-2w"
|
||||
max_cameras: 16
|
||||
```
|
||||
|
||||
Key settings to review:
|
||||
|
||||
| Setting | Description | Default |
|
||||
| ------- | ----------- | ------- |
|
||||
| `api_key` | API key for authenticating requests | `changeme` (**must change**) |
|
||||
| `port` | HTTP server listen port | `8080` |
|
||||
| `mqtt.broker` | MQTT broker address | `localhost:1883` |
|
||||
| `mqtt.client_id` | MQTT client identifier | `remoterig-hub` |
|
||||
| `platform.type` | Target platform identifier | `pi-zero-2w` |
|
||||
| `platform.max_cameras` | Maximum number of camera rigs | `16` |
|
||||
| `db_path` | SQLite database file path | `remoterig.db` |
|
||||
| `read_timeout` | HTTP read timeout | `5s` |
|
||||
| `write_timeout` | HTTP write timeout | `10s` |
|
||||
| `idle_timeout` | HTTP idle connection timeout | `120s` |
|
||||
|
||||
## Running Locally
|
||||
|
||||
Start the hub with:
|
||||
|
||||
```bash
|
||||
go run ./cmd/server/
|
||||
```
|
||||
|
||||
You should see output similar to:
|
||||
|
||||
```
|
||||
RemoteRig hub starting...
|
||||
Database: remoterig.db
|
||||
API key set: true
|
||||
Server port: 8080
|
||||
MQTT broker: localhost:1883
|
||||
Platform: pi-zero-2w (max 16 cameras)
|
||||
RemoteRig hub ready
|
||||
```
|
||||
|
||||
## Building for Raspberry Pi Zero 2 W
|
||||
|
||||
Cross-compile from your development machine:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=arm GOARM=6 go build -o remoterig-hub ./cmd/server/
|
||||
```
|
||||
|
||||
Copy the binary and `config.yaml` to your Pi:
|
||||
|
||||
```bash
|
||||
scp remoterig-hub config.yaml pi@raspberrypi:/home/pi/remoterig/
|
||||
```
|
||||
|
||||
Then run on the Pi:
|
||||
|
||||
```bash
|
||||
./remoterig-hub
|
||||
```
|
||||
|
||||
### Build Matrix
|
||||
|
||||
| Target | Command |
|
||||
| ------ | ------- |
|
||||
| Raspberry Pi Zero 2 W | `GOOS=linux GOARCH=arm GOARM=6 go build -o remoterig-hub ./cmd/server/` |
|
||||
| Local (same arch) | `go build -o remoterig-hub ./cmd/server/` |
|
||||
| Linux amd64 | `GOOS=linux GOARCH=amd64 go build -o remoterig-hub ./cmd/server/` |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — CubeCraft Creations.
|
||||
|
||||
@@ -3,9 +3,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -17,16 +15,12 @@ import (
|
||||
"github.com/cubecraft/remoterig/internal/auth"
|
||||
"github.com/cubecraft/remoterig/internal/db"
|
||||
"github.com/cubecraft/remoterig/internal/events"
|
||||
"github.com/cubecraft/remoterig/internal/mqtt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed all:src/dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
// Config holds the application configuration.
|
||||
type Config struct {
|
||||
DBPath string `yaml:"db_path"`
|
||||
@@ -65,13 +59,6 @@ func main() {
|
||||
// Create SSE hub for real-time updates
|
||||
sseHub := events.NewHub()
|
||||
|
||||
// Start MQTT subscriber for ESP32 camera status ingestion
|
||||
mqttSub := mqtt.NewSubscriber(cfg.MQTT.Broker, cfg.MQTT.ClientID, sqlDB, sseHub)
|
||||
if err := mqttSub.Connect(); err != nil {
|
||||
log.Printf("WARNING: MQTT subscriber failed to connect: %v (running without MQTT)", err)
|
||||
}
|
||||
defer mqttSub.Close()
|
||||
|
||||
// Set up router
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
@@ -89,9 +76,6 @@ func main() {
|
||||
// API routes (auth required if API key is configured)
|
||||
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
|
||||
|
||||
// Serve embedded React frontend with SPA fallback
|
||||
r.Mount("/", frontendHandler())
|
||||
|
||||
// Create server
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
@@ -167,36 +151,3 @@ func loadConfig(path string) (*Config, error) {
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// frontendHandler returns an http.Handler that serves the embedded React
|
||||
// frontend from src/dist/ with SPA-style fallback: any path that doesn't
|
||||
// match a static file serves index.html for client-side routing.
|
||||
//
|
||||
// The frontend is embedded via //go:embed all:src/dist at build time.
|
||||
// If src/dist/ is empty or missing at build time, the embedded fallback
|
||||
// index.html (committed to the repo) is served instead, showing a
|
||||
// "run npm run build" message.
|
||||
func frontendHandler() http.Handler {
|
||||
distFS, err := fs.Sub(frontendFS, "src/dist")
|
||||
if err != nil {
|
||||
// Shouldn't happen if embed worked, but be defensive.
|
||||
panic("embedded frontend filesystem not found: " + err.Error())
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to serve the requested file.
|
||||
f, err := distFS.Open(r.URL.Path[1:]) // strip leading "/"
|
||||
if err != nil {
|
||||
// File not found — serve index.html for SPA routing.
|
||||
r.URL.Path = "/"
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// File exists, serve it.
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
-364
@@ -1,364 +0,0 @@
|
||||
# RemoteRig — Project Context
|
||||
|
||||
> **Last updated:** 2026-05-21 (evening — post-planning sync)
|
||||
> **Repo:** `CubeCraft-Creations/remote-rig` | **Host:** `code.cubecraftcreations.com`
|
||||
> **Local clone:** `/mnt/ai-storage/projects/remote-rig` | **Default branch:** `dev`
|
||||
> **Discord:** `DISCORD_DEV_REMOTERIG_CHANNEL_ID`
|
||||
> **Linear Epic:** [CUB-198](https://linear.app/cubecraft-creations/issue/CUB-198)
|
||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
RemoteRig is a **multi-camera remote monitoring system**. It provides a camera grid dashboard for monitoring multiple GoPro cameras remotely. Cameras push status via MQTT/HTTP, the UI shows a live grid with SSE updates, and the system supports start/stop recording control.
|
||||
|
||||
**Target hardware:** Raspberry Pi Zero 2 W as the central hub, with ESP32 nodes attached to each GoPro camera for status collection and MQTT communication.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Notes |
|
||||
|-------|-----------|-------|
|
||||
| Backend | Go 1.25+ | Chi v5 router, SQLite (modernc.org/sqlite), go-yaml v3 |
|
||||
| Frontend | React 19 + TypeScript 5.7 | Vite 6, Tailwind CSS 3.4 |
|
||||
| State | Zustand 5 | Client-side camera state store |
|
||||
| Icons | lucide-react 0.469 | |
|
||||
| Real-time | SSE (Server-Sent Events) | `/api/v1/events/stream` |
|
||||
| Messaging | MQTT | Mosquitto broker for ESP32 → hub communication |
|
||||
| Database | SQLite | WAL mode, foreign keys enabled |
|
||||
| Auth | API key (Bearer token) | Middleware, configurable in `config.yaml` |
|
||||
| Testing | Vitest 4.1 + testing-library/react | Unit tests for React components |
|
||||
| Linting | ESLint 9 | `npm run lint` |
|
||||
| CI/CD | Gitea Actions | `.gitea/workflows/ci.yaml`, `build-dev.yaml`, `deploy-dev.yaml` |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Travel Router (self-contained LAN) │
|
||||
│ Subnet: 10.60.1.0/24 │
|
||||
│ DHCP pool: .100-.200 │
|
||||
└──────┬──────────┬──────────┬──────────────┘
|
||||
│ │ │
|
||||
┌───────────────┘ │ └───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
|
||||
│ DHCP addr │ │ DHCP addr │ │ 10.60.1.56 │
|
||||
│ STA→Router │ │ STA→Router │ │ (static IP) │
|
||||
│ MQTT→:1883 │ │ MQTT→:1883 │ │ Mosquitto :1883 │
|
||||
│ UART relay │ │ UART relay │ │ Go API :8080 │
|
||||
│ │ │ │ │ React UI │
|
||||
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
|
||||
│ UART │ UART └──────────────────┘
|
||||
▼ ▼ │
|
||||
┌──────────────┐ ┌──────────────┐ │
|
||||
│ ESP8266 #1 │ │ ESP8266 #N │ SSE /api/v1/events/stream
|
||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │
|
||||
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│ ▼
|
||||
└──────┬───────┘ └──────┬───────┘ ┌──────────────────┐
|
||||
▼ ▼ │ User Device │
|
||||
┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │
|
||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │ │ 10.60.1.56:8080 │
|
||||
└──────────────┘ └──────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP8266 boards talk to the GoPro AP over HTTP, then relay camera status/commands over UART to ESP32 boards. ESP32 boards stay on the travel-router LAN and bridge UART messages to MQTT.
|
||||
|
||||
### Key Architecture Decisions (revised)
|
||||
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `10.60.1.0/24`.
|
||||
- **Two-board camera node** — ESP8266 handles GoPro AP/HTTP; ESP32 stays on the travel-router LAN for MQTT. This avoids ESP32 dual-STA/channel switching complexity.
|
||||
- **ESP8266 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable.
|
||||
- **UART bridge between boards** — ESP8266 reports GoPro status and receives commands over UART; ESP32 relays those messages to/from MQTT.
|
||||
- **MQTT for ESP32 → Hub** — Lightweight, designed for IoT. Mosquitto on Pi. QoS 1 for status, QoS 2 for commands. Full contract: [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
|
||||
- **SQLite over PostgreSQL** — Single-node Pi Zero 2 W deployment. WAL mode for concurrent read/write.
|
||||
- **SSE over WebSocket** — Unidirectional hub → browser updates. Simpler, sufficient for status dashboard.
|
||||
- **Chi router** — Lightweight Go HTTP router with middleware support.
|
||||
- **Zustand over Redux** — Minimal boilerplate for camera status store.
|
||||
- **API key auth** — Simple bearer token; closed LAN, not internet-facing.
|
||||
- **Camera auto-discovery** — ESP32 publishes `announce` message on first MQTT connect. Hub auto-registers by MAC, assigns sequential `cam-NNN` ID.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
remote-rig/
|
||||
├── cmd/server/main.go # Entry point — config load, router setup, graceful shutdown
|
||||
├── config.yaml # Runtime configuration
|
||||
├── go.mod / go.sum # Go dependencies
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── api.go # Package doc
|
||||
│ │ ├── cameras.go # GET /cameras, POST /cameras, GET /cameras/:id
|
||||
│ │ ├── recording.go # POST /cameras/:id/start, POST /cameras/:id/stop
|
||||
│ │ └── status.go # POST /cameras/:id/status (push from ESP32)
|
||||
│ ├── auth/
|
||||
│ │ └── middleware.go # API key auth middleware
|
||||
│ ├── db/
|
||||
│ │ ├── db.go # Open, migrations, WAL mode
|
||||
│ │ └── migrations/
|
||||
│ │ └── 001_create_tables.sql
|
||||
│ └── events/
|
||||
│ └── sse.go # SSE hub (subscribe, broadcast)
|
||||
├── pkg/models/
|
||||
│ └── camera.go # Camera, StatusLog, RecordingEvent, CameraStatus, Settings
|
||||
├── src/ # React frontend
|
||||
│ ├── App.tsx # Main app — header, camera grid, footer
|
||||
│ ├── components/
|
||||
│ │ ├── CameraCard.tsx # Single camera status card
|
||||
│ │ ├── CameraCard.test.tsx # Unit tests
|
||||
│ │ └── index.ts
|
||||
│ ├── hooks/
|
||||
│ │ ├── useSSE.ts # SSE connection hook
|
||||
│ │ ├── useCameraStatus.ts # Camera status hook
|
||||
│ │ ├── useSystemHealth.ts # System health hook
|
||||
│ │ └── index.ts
|
||||
│ ├── services/
|
||||
│ │ └── api.ts # API client
|
||||
│ ├── store/
|
||||
│ │ ├── useCameraStore.ts # Zustand store
|
||||
│ │ └── index.ts
|
||||
│ ├── types/
|
||||
│ │ └── index.ts # TypeScript interfaces
|
||||
│ ├── utils/
|
||||
│ │ └── index.ts
|
||||
│ └── main.tsx
|
||||
├── docs/
|
||||
│ ├── CONTEXT.md # ← this file
|
||||
│ └── plans/
|
||||
│ └── 2026-05-21-cub-196-cameracard.md # CameraCard implementation plan
|
||||
├── .gitea/workflows/
|
||||
│ ├── ci.yaml # PR CI: lint → typecheck → test → build
|
||||
│ ├── build-dev.yaml # Go binary build on dev push
|
||||
│ └── deploy-dev.yaml # SCP + SSH deploy with rollback
|
||||
├── .env.example # VITE_API_URL=http://localhost:8080/api
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.js
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Database Schema (SQLite)
|
||||
|
||||
### cameras
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| camera_id | TEXT PK | Unique camera identifier |
|
||||
| friendly_name | TEXT NOT NULL | Human-readable name |
|
||||
| mac_address | TEXT UNIQUE | MAC address (optional) |
|
||||
| created_at | DATETIME | Default now |
|
||||
| updated_at | DATETIME | Default now |
|
||||
|
||||
### status_logs
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | INTEGER PK AUTO | |
|
||||
| camera_id | TEXT FK → cameras | |
|
||||
| recorded_at | DATETIME | Default now |
|
||||
| battery_pct | INTEGER | Nullable |
|
||||
| video_remaining_sec | INTEGER | Nullable |
|
||||
| recording_state | INTEGER | 0=idle, 1=recording |
|
||||
| mode | TEXT | e.g. "video" |
|
||||
| resolution | TEXT | e.g. "1080p" |
|
||||
| fps | INTEGER | |
|
||||
| online | INTEGER | 0=offline, 1=online |
|
||||
| raw_battery_pct | REAL | Float precision |
|
||||
|
||||
Index: `(camera_id, recorded_at DESC)`
|
||||
|
||||
### recording_events
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | INTEGER PK AUTO | |
|
||||
| camera_id | TEXT FK → cameras | |
|
||||
| started_at | DATETIME NOT NULL | |
|
||||
| stopped_at | DATETIME | Null while recording |
|
||||
| reason | TEXT | e.g. "manual" |
|
||||
| duration | INTEGER | Seconds |
|
||||
|
||||
Index: `(camera_id, started_at DESC)`
|
||||
|
||||
### settings
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| key | TEXT PK | |
|
||||
| value | TEXT NOT NULL | |
|
||||
| updated_at | DATETIME | Default now |
|
||||
|
||||
**Default seeds:** `poll_interval_sec=30`, `low_battery_threshold=15`, `low_storage_alert_sec=300`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | /health | No | Health check → `{"status":"ok"}` |
|
||||
| GET | /api/v1/cameras | Yes | List all cameras with latest status |
|
||||
| POST | /api/v1/cameras | Yes | Register a new camera |
|
||||
| GET | /api/v1/cameras/:id | Yes | Camera detail + latest status + 24h history |
|
||||
| POST | /api/v1/cameras/:id/start | Yes | Start recording + MQTT publish |
|
||||
| POST | /api/v1/cameras/:id/stop | Yes | Stop recording |
|
||||
| POST | /api/v1/cameras/:id/status | Yes | Push status from ESP32 node |
|
||||
| GET | /api/v1/events/stream | No (SSE) | Real-time camera status stream |
|
||||
|
||||
## Configuration (`config.yaml`)
|
||||
|
||||
```yaml
|
||||
db_path: "remoterig.db" # SQLite database path
|
||||
api_key: "changeme" # Bearer token for API auth
|
||||
port: 8080 # HTTP listen port
|
||||
read_timeout: 5s
|
||||
write_timeout: 10s
|
||||
idle_timeout: 120s
|
||||
mqtt:
|
||||
broker: "localhost:1883" # Mosquitto on Pi Zero 2 W
|
||||
client_id: "remoterig-hub"
|
||||
platform:
|
||||
type: "pi-zero-2w"
|
||||
max_cameras: 16
|
||||
network:
|
||||
subnet: "10.60.1.0/24" # Travel router subnet
|
||||
hub_ip: "10.60.1.56" # Pi Zero 2 W static IP
|
||||
```
|
||||
|
||||
## Frontend Component Tree
|
||||
|
||||
```
|
||||
App
|
||||
├── Header
|
||||
│ ├── Logo + Title ("RemoteRig Dashboard")
|
||||
│ └── Stats bar (online count, recording count)
|
||||
├── CameraGrid
|
||||
│ └── CameraCard[] (responsive: 1→2→3→4 columns)
|
||||
│ ├── Camera name + online/offline badge
|
||||
│ ├── Resolution + FPS display
|
||||
│ ├── Recording indicator (pulsing dot + REC/IDLE badge)
|
||||
│ ├── Battery bar (color-coded: green/yellow/red)
|
||||
│ └── Footer (Live/Last seen + video remaining)
|
||||
└── Footer
|
||||
└── "RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System"
|
||||
```
|
||||
|
||||
**Empty state:** "Waiting for cameras..." with pulsing radio icon when no cameras connected.
|
||||
**Offline state:** Camera card dimmed with dashed border, shows "Last seen Xm ago".
|
||||
|
||||
## Color Palette (Tailwind — dark dashboard theme)
|
||||
|
||||
Custom theme in `tailwind.config.js`:
|
||||
- `rig-dark-900` (background), `rig-dark-800`, `rig-dark-700` (cards), etc.
|
||||
- `rig-accent` (accent color)
|
||||
- `rig-success` (green — battery ≥50%, online)
|
||||
- `rig-warning` (yellow — battery 15-49%)
|
||||
- `rig-danger` (red — battery <15%, offline, recording)
|
||||
|
||||
## Linear Issue Map
|
||||
|
||||
**Last synced:** 2026-05-21 (evening)
|
||||
|
||||
| CUB | Title | Status | Agent |
|
||||
|-----|-------|--------|-------|
|
||||
| 198 | **Epic: Multi-camera remote monitoring system** | Backlog | — |
|
||||
| 238 | Define MQTT message format contract | ✅ Done | Dex |
|
||||
| 228 | Add battery_calibration_offset to cameras table | Backlog | Hex |
|
||||
| 230 | ESP32 offline status buffering and replay | Backlog | Pip |
|
||||
| 232 | Implement MQTT subscriber in Go hub | Backlog | Dex |
|
||||
| 229 | Design camera auto-discovery and registration flow | Backlog | Dex |
|
||||
| 231 | Mosquitto MQTT broker setup on Pi Zero 2 W | Backlog | Dex |
|
||||
| 233 | Verify and harden SSE endpoint | Backlog | Dex |
|
||||
| 234 | Verify and harden all camera API endpoints | Backlog | Dex |
|
||||
| 235 | Implement GET /api/v1/cameras/:id with 24h history | Backlog | Dex |
|
||||
| 236 | Implement POST /api/v1/cameras registration | Backlog | Dex |
|
||||
| 237 | Update CONTEXT.md to actual state | Backlog | Otto |
|
||||
| — | — | — | — |
|
||||
| 173 | Confirm GoPro Hero 3 Wi‑Fi control API | ✅ Done | Otto |
|
||||
| 174 | ESP32 firmware baseline | ✅ Done | Pip |
|
||||
| 175 | Central hub backend (Go service) | ✅ Done | Dex |
|
||||
| 177 | Database schema (cameras, events, status_logs) | ✅ Done | Hex |
|
||||
| 180 | Risk mitigation checklist | ✅ Done | — |
|
||||
| 181 | Scaffold Go module, directory layout, config | ✅ Done | — |
|
||||
| 187 | POST start recording + MQTT publish | ✅ Done | Dex |
|
||||
| 194 | Scaffold Vite + React + TypeScript + Tailwind | ✅ Done | — |
|
||||
| 195 | React SSE hook (useSSE.ts) + Zustand store | ✅ Done | Rex |
|
||||
| 196 | CameraCard component + 16 unit tests | ✅ Done | Rex |
|
||||
| 179 | Logging & persistence strategy | ✅ Done | — |
|
||||
| 208 | Add README with project overview | ✅ Done | Hermes |
|
||||
| 182 | ~~Duplicate of CUB-181~~ | ❌ Canceled | — |
|
||||
| — | — | — | — |
|
||||
| 183 | SQLite schema migration + DB init | Backlog | Hex |
|
||||
| 184 | API key auth middleware | Backlog | Dex |
|
||||
| 185 | Camera/StatusLog/RecordingEvent Go models | Backlog | Hex |
|
||||
| 186 | GET /api/v1/cameras (list with live status) | Backlog | Dex |
|
||||
| 188 | POST stop recording | Backlog | Dex |
|
||||
| 189 | POST register new camera | Backlog | Dex |
|
||||
| 190 | GET camera detail + history | Backlog | Dex |
|
||||
| 191 | POST push-status (HTTP ingestion) | Backlog | Dex |
|
||||
| 192 | MQTT subscriber | Backlog | Dex |
|
||||
| 193 | SSE /api/v1/events/stream endpoint | Backlog | Dex |
|
||||
| 178 | UX/UI design mockups | Backlog | Sketch |
|
||||
| 197 | Dashboard camera grid wireframe | In Review | Sketch |
|
||||
| 176 | Frontend umbrella (React + Tailwind) | Backlog | Rex |
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### ci.yaml (PR gate)
|
||||
Runs on push/PR to `dev` and `main`:
|
||||
1. `lint-and-typecheck`: npm ci → eslint → tsc --noEmit
|
||||
2. `test` (needs lint): npm test (vitest)
|
||||
3. `build` (needs test): npm run build → upload dist artifact
|
||||
|
||||
### build-dev.yaml
|
||||
Triggered by `repository_dispatch: dev-build-success`:
|
||||
- Checks out, downloads artifact, builds Go binary
|
||||
|
||||
### deploy-dev.yaml
|
||||
Triggered by `workflow_dispatch`:
|
||||
- SCP binary + deploy script to dev host
|
||||
- Deploy with backup/rollback, systemctl restart
|
||||
- Failure notification
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **SQLite chosen over PostgreSQL** — Single-node Pi Zero 2 W deployment; no need for a separate DB server. WAL mode for concurrent read/write.
|
||||
2. **SSE chosen over WebSocket** — Unidirectional updates (hub → browser) are sufficient for status dashboard; SSE is simpler to implement and maintain.
|
||||
3. **Chi router** — Lightweight, idiomatic Go HTTP router with middleware support.
|
||||
4. **Zustand over Redux** — Minimal boilerplate for the camera status store.
|
||||
5. **API key auth** — Simple bearer token; hub is on a local network, not internet-facing.
|
||||
6. **MQTT** — Standard IoT protocol for ESP32 communication; Mosquitto broker runs locally on the Pi.
|
||||
7. **Recording state tracking** — Status push handler detects recording state changes and automatically opens/closes recording_events rows.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **MQTT subscriber not yet implemented** (CUB-232) — ESP32→hub communication backbone is designed (see [MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)) but not built. Currently only HTTP status push is wired up.
|
||||
- **SSE endpoint needs verification** (CUB-233) — Frontend SSE hook exists (merged PR #2), backend SSE hub code exists (140 lines) but needs heartbeat, reconnection, and integration testing.
|
||||
- **No camera auto-discovery** (CUB-229) — Cameras must be manually registered. ESP32 announce protocol designed in MQTT contract but not implemented.
|
||||
- **No battery calibration** (CUB-228) — GoPro Hero 3 reports raw byte; per-camera calibration offset column not yet added to schema.
|
||||
- **No offline buffering on ESP32** (CUB-230) — If travel router Wi-Fi drops, status data is lost. SPIFFS buffer designed but not implemented.
|
||||
- **CameraCard wireframe pending** (CUB-197) — Dashboard UI has live code but needs UX review/wireframe sign-off.
|
||||
- **remoterig.db committed to repo** — Should be in `.gitignore` for production. Low priority (convenient for dev).
|
||||
- **Time sync TBD** — ESP32s need accurate timestamps without internet. Options: Pi as NTP server, GPS module, or HTTP time endpoint. See MQTT contract open questions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
cd /mnt/ai-storage/projects/remote-rig
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
# Backend
|
||||
go run cmd/server/main.go
|
||||
# → runs on :8080
|
||||
|
||||
# Frontend
|
||||
cp .env.example .env # edit if needed
|
||||
npm install
|
||||
npm run dev # → Vite dev server with API proxy
|
||||
```
|
||||
|
||||
## Default Agent Assignments
|
||||
|
||||
| Area | Agent | Notes |
|
||||
|------|-------|-------|
|
||||
| Backend (Go API, MQTT, SSE) | Dex | gitea-dex MCP |
|
||||
| Database (SQLite schema/migrations) | Hex | gitea-hex MCP |
|
||||
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
|
||||
| Hardware (ESP32 firmware, GPIO) | Pip | gitea-pip MCP |
|
||||
| Design (wireframes, UX) | Sketch | |
|
||||
@@ -1,280 +0,0 @@
|
||||
# MQTT Message Format Contract — RemoteRig
|
||||
|
||||
> **Version:** 1.0.0 | **Status:** Draft | **Blocks:** CUB-232 (MQTT subscriber), CUB-174 (ESP32 firmware)
|
||||
> **Last updated:** 2026-05-21
|
||||
|
||||
## Network Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Travel Router (10.60.1.1) │
|
||||
│ DHCP: .100-.200 │
|
||||
└──────┬──────────┬──────────┬──────┘
|
||||
│ │ │
|
||||
┌───────────────┘ │ └───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
|
||||
│ 10.60.1.101 │ │ 10.60.1.102 │ │ 10.60.1.56 │
|
||||
│ STA→Router │ │ STA→Router │ │ Mosquitto │
|
||||
│ MQTT relay │ │ MQTT relay │ │ Go backend │
|
||||
└──────┬───────┘ └──────┬───────┘ │ React UI │
|
||||
│ UART │ UART └──────────────┘
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ ESP8266 #1 │ │ ESP8266 #2 │
|
||||
│ STA→GoPro AP │ │ STA→GoPro AP │
|
||||
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- **Travel router:** Self-contained, no internet. Gateway `10.60.1.1`. DHCP pool: `10.60.1.100-200`
|
||||
- **Pi Zero 2 W:** Static IP `10.60.1.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
|
||||
- **ESP32s:** DHCP from router. Each stays on the travel-router LAN, relays MQTT to/from its paired ESP8266 over UART
|
||||
- **User device:** Connects to router, opens `http://10.60.1.56:8080` for dashboard
|
||||
|
||||
## MQTT Broker
|
||||
|
||||
- **Host:** `10.60.1.56` (Pi Zero 2 W)
|
||||
- **Port:** `1883` (default MQTT, no TLS — closed network)
|
||||
- **Auth:** None (closed network, no external access)
|
||||
- **Client ID format:** `remoterig-<esp32_mac_last6>` (e.g., `remoterig-a1b2c3`)
|
||||
- **QoS:** 1 (at least once) for status/heartbeat. 2 (exactly once) for commands.
|
||||
- **Retain:** Status messages use `retain: true` so new subscribers get latest state immediately
|
||||
|
||||
## Topic Hierarchy
|
||||
|
||||
```
|
||||
remoterig/
|
||||
├── cameras/
|
||||
│ └── <camera_id>/
|
||||
│ ├── status ← ESP32 publishes (retained, QoS 1)
|
||||
│ ├── heartbeat ← ESP32 publishes (QoS 1, not retained)
|
||||
│ ├── command → Hub publishes (QoS 2)
|
||||
│ └── announce ← ESP32 publishes on first boot (QoS 2, retained)
|
||||
└── hub/
|
||||
└── status ← Hub publishes (retained, QoS 1)
|
||||
```
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/status`
|
||||
|
||||
**Direction:** ESP32 → Hub
|
||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
||||
|
||||
Published by the ESP32 every 30s using the latest GoPro status received from the paired ESP8266 over UART.
|
||||
|
||||
```json
|
||||
{
|
||||
"camera_id": "cam-001",
|
||||
"timestamp": "2026-05-21T18:30:00Z",
|
||||
"battery_pct": 85,
|
||||
"battery_raw": 217,
|
||||
"video_remaining_sec": 3420,
|
||||
"recording": true,
|
||||
"mode": "video",
|
||||
"resolution": "1080p",
|
||||
"fps": 30,
|
||||
"online": true,
|
||||
"rssi": -52,
|
||||
"uptime_sec": 1247
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `camera_id` | string | ✅ | Unique camera identifier (set during registration) |
|
||||
| `timestamp` | ISO 8601 | ✅ | ESP32 clock time when status was read |
|
||||
| `battery_pct` | int 0-100 | ✅ | Calibrated battery percentage (null if uncalibrated → omit) |
|
||||
| `battery_raw` | int 0-255 | — | Raw byte from GoPro status offset 57 |
|
||||
| `video_remaining_sec` | int | — | Estimated remaining recording seconds (null if unavailable) |
|
||||
| `recording` | bool | ✅ | Whether camera is currently recording |
|
||||
| `mode` | string | — | Current mode (e.g., "video", "photo", "burst") |
|
||||
| `resolution` | string | — | Current resolution string |
|
||||
| `fps` | int | — | Current frames per second |
|
||||
| `online` | bool | ✅ | ESP32 can reach the GoPro (false if GoPro AP unreachable) |
|
||||
| `rssi` | int | — | Wi-Fi RSSI to GoPro AP (dBm, negative) |
|
||||
| `uptime_sec` | int | — | ESP32 uptime in seconds |
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/heartbeat`
|
||||
|
||||
**Direction:** ESP32 → Hub
|
||||
**QoS:** 1 | **Retain:** false | **Interval:** 60 seconds
|
||||
|
||||
Lightweight keepalive so the hub can detect dead ESP32s.
|
||||
|
||||
```json
|
||||
{
|
||||
"camera_id": "cam-001",
|
||||
"timestamp": "2026-05-21T18:31:00Z",
|
||||
"uptime_sec": 1307,
|
||||
"free_heap": 28672
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `camera_id` | string | Camera identifier |
|
||||
| `timestamp` | ISO 8601 | Current ESP32 time |
|
||||
| `uptime_sec` | int | ESP32 uptime |
|
||||
| `free_heap` | int | Free heap in bytes (diagnostic) |
|
||||
|
||||
**Hub behavior:** If no heartbeat for 120 seconds, mark camera as offline (`online: false` in SSE broadcast).
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/command`
|
||||
|
||||
**Direction:** Hub → ESP32
|
||||
**QoS:** 2 | **Retain:** false
|
||||
|
||||
Commands sent from the dashboard to individual cameras.
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "start_recording",
|
||||
"request_id": "req-abc123",
|
||||
"timestamp": "2026-05-21T18:32:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Supported commands:**
|
||||
|
||||
| `command` | Description | Response topic |
|
||||
|-----------|-------------|----------------|
|
||||
| `start_recording` | Start GoPro recording | status (updated on next poll) |
|
||||
| `stop_recording` | Stop GoPro recording | status (updated on next poll) |
|
||||
| `reboot` | Reboot the ESP32 | — (ESP32 reconnects after boot) |
|
||||
|
||||
**ESP32 / ESP8266 behavior:**
|
||||
- ESP32 receives the MQTT command and forwards it over UART to the paired ESP8266
|
||||
- ESP8266 executes the corresponding HTTP command against the GoPro AP
|
||||
- Next status publish will reflect the new state
|
||||
- If command fails (GoPro unreachable), publish status with `online: false`
|
||||
|
||||
### Topic: `remoterig/cameras/<camera_id>/announce`
|
||||
|
||||
**Direction:** ESP32 → Hub
|
||||
**QoS:** 2 | **Retain:** true
|
||||
|
||||
Published once on ESP32 first boot (or factory reset). Used for auto-registration.
|
||||
|
||||
```json
|
||||
{
|
||||
"mac_address": "AA:BB:CC:DD:EE:FF",
|
||||
"firmware_version": "0.1.0",
|
||||
"capabilities": ["start_stop", "status"],
|
||||
"friendly_name": "ESP32-AA-BB-CC"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `mac_address` | string | ESP32 Wi-Fi MAC address |
|
||||
| `firmware_version` | string | ESP32 firmware semver |
|
||||
| `capabilities` | string[] | Supported features |
|
||||
| `friendly_name` | string | Default human-readable name |
|
||||
|
||||
**Hub behavior on first announce:**
|
||||
1. Check if MAC already registered → if yes, update `friendly_name` and log
|
||||
2. If new MAC → create camera with auto-generated `camera_id = "cam-<NNN>"` (zero-padded sequential)
|
||||
3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
|
||||
4. Broadcast via SSE that a new camera appeared
|
||||
|
||||
### Topic: `remoterig/hub/status`
|
||||
|
||||
**Direction:** Hub → All
|
||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
||||
|
||||
Hub health status broadcast.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"uptime_sec": 86400,
|
||||
"cameras_online": 3,
|
||||
"cameras_total": 4,
|
||||
"mqtt_connected": true,
|
||||
"db_size_bytes": 1048576
|
||||
}
|
||||
```
|
||||
|
||||
## Message Validation Rules
|
||||
|
||||
### Hub-side (incoming from ESP32)
|
||||
|
||||
1. **Required fields:** `camera_id` and `timestamp` must be present in all messages
|
||||
2. **Timestamp sanity:** Reject if timestamp is > 5 minutes in the future or > 24 hours in the past
|
||||
3. **Duplicate detection:** Status messages with same `(camera_id, timestamp)` are ignored (idempotent)
|
||||
4. **Schema validation:** Unknown fields are ignored (forward-compatible), missing required fields → log warning + reject
|
||||
5. **battery_pct bounds:** If present, must be 0–100. Out of range → clamp to [0,100] with warning
|
||||
|
||||
### ESP32-side (incoming from hub)
|
||||
|
||||
1. **Acknowledge commands:** After processing a command, the next status publish reflects the new state
|
||||
2. **Unknown commands:** Log and ignore
|
||||
3. **Malformed JSON:** Log error, ignore message
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
```
|
||||
ESP32 boots
|
||||
│
|
||||
├── Connects to travel router Wi-Fi
|
||||
├── Connects to MQTT broker (10.60.1.56:1883)
|
||||
├── Publishes announce (retained) on cameras/<id>/announce
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Main loop (every 30s): │
|
||||
│ 1. ESP32 requests/receives status via UART │
|
||||
│ 2. ESP8266 polls GoPro HTTP (10.5.5.1) │
|
||||
│ 3. ESP8266 returns parsed status over UART │
|
||||
│ 4. ESP32 publishes MQTT status (retained) │
|
||||
│ 5. Every 60s: ESP32 publishes heartbeat │
|
||||
└───────────────────────────────────────────────┘
|
||||
│
|
||||
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
|
||||
├── On ESP8266/GoPro unreachable → publish status with online: false
|
||||
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
|
||||
│
|
||||
▼
|
||||
ESP32 shutdown / watchdog reboot
|
||||
```
|
||||
|
||||
## Data Flow: Start Recording Example
|
||||
|
||||
```
|
||||
1. User clicks "Start" on dashboard
|
||||
2. Browser → HTTP POST /api/v1/cameras/cam-001/start → Go backend
|
||||
3. Go backend → MQTT publish remoterig/cameras/cam-001/command {command: "start_recording"}
|
||||
4. ESP32 receives command and forwards it to ESP8266 over UART
|
||||
5. ESP8266 sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
|
||||
6. GoPro starts recording
|
||||
7. Next 30s poll: ESP8266 reports status over UART; ESP32 publishes status with recording: true
|
||||
8. Go backend receives status, updates SQLite, fans out via SSE
|
||||
9. Dashboard updates with pulsing REC indicator
|
||||
```
|
||||
|
||||
## Offline Buffering (future — CUB-230)
|
||||
|
||||
When ESP32 loses connection to travel router:
|
||||
|
||||
1. **Buffer:** Store status snapshots in SPIFFS (LittleFS), max 100 entries (~6KB)
|
||||
2. **Eviction:** FIFO — oldest dropped when buffer full
|
||||
3. **Replay:** On MQTT reconnect, publish buffered messages in chronological order with original timestamps
|
||||
4. **Dedup:** Hub ignores duplicates via `(camera_id, recorded_at)` unique constraint in status_logs
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- **Adding fields:** Safe — unknown fields ignored by both sides
|
||||
- **Removing fields:** Mark as optional first, remove in next major version
|
||||
- **Changing field types:** New topic path (e.g., `status/v2`) or new field name
|
||||
- **New topics:** Add freely — old clients ignore unknown topics
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **NTP/time sync:** How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. **Recommendation:** Pi runs NTPd, ESP32s use SNTP from `10.60.1.56`.
|
||||
2. **Camera naming:** Should `friendly_name` be configurable from dashboard after auto-registration? **Recommendation:** Yes — allow rename via UI, stored in cameras table.
|
||||
3. **Firmware OTA:** Should ESP32 firmware updates be possible over this network? **Recommendation:** Yes but out of scope for MVP.
|
||||
@@ -1,134 +0,0 @@
|
||||
# RemoteRig — Dual-Board Camera Node Firmware
|
||||
|
||||
> **Platform:** PlatformIO (esp8266-camera + esp32-mqtt)
|
||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
|
||||
> **Hardware:** [hardware/README.md](../hardware/README.md)
|
||||
|
||||
## Architecture
|
||||
|
||||
Each camera node uses **two boards** connected via UART — zero network switching:
|
||||
|
||||
```
|
||||
┌─────────────────────┐ UART ┌─────────────────────┐
|
||||
│ ESP8266 D1 Mini │ TX──────→RX │ ESP32 Dev Board │
|
||||
│ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │
|
||||
│ │ 115200 │ │
|
||||
│ STA → GoPro AP │ 8N1 │ STA → Travel Router │
|
||||
│ HTTP → 10.5.5.1 │ │ MQTT → 10.60.1.56│
|
||||
│ Start/stop/status │ │ Hub registration │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
| Board | Job | Network | Protocol |
|
||||
|-------|-----|---------|----------|
|
||||
| ESP8266 | Camera control | GoPro AP only (10.5.5.1) | HTTP → UART JSON |
|
||||
| ESP32 | Hub relay | Travel router only (10.60.1.x) | UART JSON → MQTT |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pip install platformio
|
||||
cd firmware
|
||||
|
||||
# Build both
|
||||
pio run -e esp8266-camera
|
||||
pio run -e esp32-mqtt
|
||||
|
||||
# Upload to boards (connect one at a time via USB)
|
||||
pio run -e esp8266-camera --target upload
|
||||
pio run -e esp32-mqtt --target upload
|
||||
|
||||
# Upload configs (each board needs its own)
|
||||
# ESP8266: copy esp8266-config.json to data/config.json, then:
|
||||
pio run -e esp8266-camera --target uploadfs
|
||||
# ESP32: copy esp32-config.json to data/config.json, then:
|
||||
pio run -e esp32-mqtt --target uploadfs
|
||||
```
|
||||
|
||||
## UART Protocol (ESP8266 ↔ ESP32)
|
||||
|
||||
JSON-per-line at 115200 8N1. GPIO16 on both boards.
|
||||
|
||||
| Direction | Type | Format | Purpose |
|
||||
|-----------|------|--------|---------|
|
||||
| ESP8266 → ESP32 | `status` | `{"type":"status","battery_raw":217,...}` | Camera poll result |
|
||||
| ESP8266 → ESP32 | `ack` | `{"type":"ack","cmd":"start_recording"}` | Command confirmation |
|
||||
| ESP8266 → ESP32 | `pong` | `{"type":"pong","uptime_ms":12345}` | Ping response |
|
||||
| ESP8266 → ESP32 | `error` | `{"type":"error","msg":"camera unreachable"}` | Error report |
|
||||
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"start_recording"}` | Hub command |
|
||||
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"ping"}` | Link health check |
|
||||
|
||||
## Configuration
|
||||
|
||||
### ESP8266 (`data/esp8266-config.json`)
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP name |
|
||||
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
|
||||
| `camera_ip` | `"10.5.5.1"` | Camera IP (change for Akaso to 192.168.1.1) |
|
||||
| `poll_interval_sec` | `30` | How often to poll camera |
|
||||
|
||||
### ESP32 (`data/esp32-config.json`)
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
|
||||
| `wifi_password` | `""` | Travel router password |
|
||||
| `mqtt_broker` | `"10.60.1.56"` | Pi Zero 2 W IP |
|
||||
| `mqtt_port` | `1883` | Mosquitto port |
|
||||
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
|
||||
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
|
||||
|
||||
## Wiring
|
||||
|
||||
```
|
||||
ESP8266 D1 Mini ESP32 Dev Board
|
||||
┌────────────┐ ┌────────────┐
|
||||
│ │ │ │
|
||||
│ TX (GPIO1)│──────────→│ RX (GPIO16)│
|
||||
│ RX (GPIO3)│←──────────│ TX (GPIO17)│
|
||||
│ GND │───────────│ GND │
|
||||
│ 3.3V │ │ 3.3V │
|
||||
│ │ │ │
|
||||
└────────────┘ └────────────┘
|
||||
│ │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
LiPo → 3.3V Buck
|
||||
(shared power)
|
||||
```
|
||||
|
||||
## Boot Sequence
|
||||
|
||||
1. **ESP8266:** Connect to GoPro AP → wait for UART commands
|
||||
2. **ESP32:** Connect to travel router → connect MQTT → announce if new
|
||||
3. **ESP8266:** Poll camera every 30s → send status over UART
|
||||
4. **ESP32:** Receive status → publish MQTT
|
||||
5. **Hub → MQTT command → ESP32 → UART → ESP8266 → HTTP → GoPro**
|
||||
|
||||
## Camera Compatibility
|
||||
|
||||
| Camera | `camera_ip` | Protocol | Status |
|
||||
|--------|------------|----------|--------|
|
||||
| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support |
|
||||
| Akaso Brave 7 | `192.168.1.1` | Varies | 🔬 Set `camera_ip`, test |
|
||||
|
||||
For non-GoPro cameras: only the ESP8266 firmware needs changes — the ESP32 stays the same.
|
||||
|
||||
## LED Status (ESP8266)
|
||||
|
||||
| LED | Meaning |
|
||||
|-----|---------|
|
||||
| Solid on | Connected to camera AP, camera responding |
|
||||
| Slow blink (500ms) | Connected to AP but camera not responding |
|
||||
| Off | Wi-Fi disconnected |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| No UART communication | Verify TX→RX crossover. Both boards at 115200. Shared GND. |
|
||||
| ESP8266 can't connect | GoPro must be ON with Wi-Fi enabled. Default password: `goprohero` |
|
||||
| ESP32 can't connect MQTT | `systemctl status mosquitto` on Pi. Port 1883 open. |
|
||||
| Camera never registers | Watch ESP32 serial for "Announced" message. Check hub logs. |
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"wifi_ssid": "RemoteRig",
|
||||
"wifi_password": "",
|
||||
"mqtt_broker": "10.60.1.56",
|
||||
"mqtt_port": 1883,
|
||||
"camera_id": "",
|
||||
"heartbeat_interval_sec": 60
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"camera_ssid": "GOPRO-BP-",
|
||||
"camera_password": "goprohero",
|
||||
"camera_ip": "10.5.5.1",
|
||||
"poll_interval_sec": 30
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
; RemoteRig — Dual-Board Camera Node Firmware
|
||||
; ============================================
|
||||
; Each camera node has TWO boards connected via UART:
|
||||
;
|
||||
; ESP8266 (Camera Bridge): Connects to GoPro AP → HTTP status/control
|
||||
; ESP32 (MQTT Bridge): Connects to travel router → MQTT to hub
|
||||
;
|
||||
; ESP8266 ←──UART──→ ESP32
|
||||
; (TX/RX) (RX16/TX17)
|
||||
;
|
||||
; Build:
|
||||
; pio run -e esp8266-camera (ESP8266 D1 Mini — camera bridge)
|
||||
; pio run -e esp32-mqtt (ESP32 Dev Board — MQTT bridge)
|
||||
;
|
||||
; Upload:
|
||||
; pio run -e esp8266-camera --target upload
|
||||
; pio run -e esp32-mqtt --target upload
|
||||
;
|
||||
; Filesystem:
|
||||
; pio run -e esp8266-camera --target uploadfs
|
||||
; pio run -e esp32-mqtt --target uploadfs
|
||||
|
||||
[common]
|
||||
lib_deps =
|
||||
knolleary/PubSubClient @ ^2.8
|
||||
bblanchon/ArduinoJson @ ^7.3
|
||||
build_flags =
|
||||
-D CORE_DEBUG_LEVEL=0
|
||||
|
||||
; ── ESP8266: Camera Bridge ──────────────────────────────────
|
||||
; Flashed onto D1 Mini. Talks to GoPro over Wi-Fi, relays to
|
||||
; ESP32 over UART (TX/RX pins). No MQTT, no router connection.
|
||||
|
||||
[env:esp8266-camera]
|
||||
platform = espressif8266
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_flags = ${common.build_flags}
|
||||
-D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED
|
||||
board_build.flash_mode = dio
|
||||
board_build.f_cpu = 160000000L
|
||||
build_src_filter =
|
||||
+<../lib/>
|
||||
+<esp8266-camera-bridge.cpp>
|
||||
-<*.cpp>
|
||||
|
||||
; ── ESP32: MQTT Bridge ─────────────────────────────────────
|
||||
; Flashed onto ESP32 Dev Board. Connects to travel router,
|
||||
; publishes MQTT to Pi hub. Reads camera status from ESP8266
|
||||
; over UART2 (RX16/TX17). No direct camera communication.
|
||||
|
||||
[env:esp32-mqtt]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_flags = ${common.build_flags}
|
||||
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
|
||||
build_src_filter =
|
||||
+<../lib/>
|
||||
+<esp32-mqtt-bridge.cpp>
|
||||
-<*.cpp>
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* RemoteRig — ESP32 MQTT Bridge Firmware
|
||||
* ======================================
|
||||
* Dedicated board per camera node. Connects the ESP8266 camera bridge
|
||||
* to the RemoteRig MQTT hub.
|
||||
*
|
||||
* ONE JOB: relay between UART (ESP8266) and MQTT (Pi hub).
|
||||
* - Connects to travel router Wi-Fi
|
||||
* - Reads status JSON from ESP8266 over UART → publishes via MQTT
|
||||
* - Receives commands via MQTT from hub → forwards to ESP8266 over UART
|
||||
* - Handles auto-registration (announce on first boot)
|
||||
* - Heartbeat publishing
|
||||
* - Zero camera communication, zero network switching
|
||||
*
|
||||
* UART Protocol: JSON-per-line at 115200 8N1
|
||||
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
|
||||
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
|
||||
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
|
||||
* ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n
|
||||
*
|
||||
* Hardware:
|
||||
* - ESP32 Dev Board (or D1 Mini ESP32)
|
||||
* - UART2: RX=GPIO16, TX=GPIO17 (connected to ESP8266)
|
||||
* - Shared GND between boards
|
||||
* - LiPo → 3.3V buck → VIN on both boards
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <SPIFFS.h>
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Configuration (SPIFFS)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
struct Config {
|
||||
String wifi_ssid = "RemoteRig";
|
||||
String wifi_password = "";
|
||||
String mqtt_broker = "10.60.1.56";
|
||||
int mqtt_port = 1883;
|
||||
String camera_id = ""; // assigned by hub
|
||||
int heartbeat_sec = 60;
|
||||
} cfg;
|
||||
|
||||
bool loadConfig() {
|
||||
if (!SPIFFS.begin(true)) { Serial.println("[CFG] SPIFFS mount failed"); return false; }
|
||||
File f = SPIFFS.open("/config.json", "r");
|
||||
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
|
||||
|
||||
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
||||
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
||||
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
||||
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
||||
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
||||
cfg.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool saveConfig() {
|
||||
File f = SPIFFS.open("/config.json", "w");
|
||||
if (!f) return false;
|
||||
JsonDocument doc;
|
||||
doc["wifi_ssid"] = cfg.wifi_ssid;
|
||||
doc["wifi_password"] = cfg.wifi_password;
|
||||
doc["mqtt_broker"] = cfg.mqtt_broker;
|
||||
doc["mqtt_port"] = cfg.mqtt_port;
|
||||
doc["camera_id"] = cfg.camera_id;
|
||||
doc["heartbeat_interval_sec"] = cfg.heartbeat_sec;
|
||||
serializeJson(doc, f);
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// UART to ESP8266 (HardwareSerial2)
|
||||
// ────────────────────────────────────────────
|
||||
// ESP32 UART2: RX=GPIO16, TX=GPIO17
|
||||
// Connect: ESP32 RX(16) ← ESP8266 TX
|
||||
// ESP32 TX(17) → ESP8266 RX
|
||||
|
||||
#define UART_ESP8266 Serial2
|
||||
|
||||
void sendCmdToESP8266(const String& command) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "cmd";
|
||||
doc["command"] = command;
|
||||
String line;
|
||||
serializeJson(doc, line);
|
||||
UART_ESP8266.println(line);
|
||||
UART_ESP8266.flush();
|
||||
}
|
||||
|
||||
String uartLine;
|
||||
bool readFromESP8266(String& line) {
|
||||
while (UART_ESP8266.available()) {
|
||||
char c = UART_ESP8266.read();
|
||||
if (c == '\n') {
|
||||
line = uartLine;
|
||||
uartLine = "";
|
||||
return true;
|
||||
}
|
||||
if (c != '\r') uartLine += c;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// MQTT
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
WiFiClient routerClient;
|
||||
PubSubClient mqtt(routerClient);
|
||||
|
||||
unsigned long bootMs = 0;
|
||||
bool cameraOnline = false;
|
||||
unsigned long lastStatusMs = 0;
|
||||
|
||||
String clientID() {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "rig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
||||
return String(buf);
|
||||
}
|
||||
|
||||
String mqttTopic(const char* t) {
|
||||
return "remoterig/cameras/" + cfg.camera_id + "/" + t;
|
||||
}
|
||||
|
||||
void mqttCallback(char* topic, byte* payload, unsigned int len) {
|
||||
char buf[256];
|
||||
unsigned int n = len < 255 ? len : 255;
|
||||
memcpy(buf, payload, n); buf[n] = 0;
|
||||
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, buf)) return;
|
||||
|
||||
String cmd = doc["command"] | "";
|
||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||
Serial.printf("[MQTT] Forwarding command: %s → ESP8266\n", cmd.c_str());
|
||||
sendCmdToESP8266(cmd);
|
||||
} else if (cmd == "reboot") {
|
||||
ESP.restart();
|
||||
} else if (cmd == "registered") {
|
||||
String id = doc["camera_id"] | "";
|
||||
if (id.length() > 0 && id != cfg.camera_id) {
|
||||
cfg.camera_id = id;
|
||||
saveConfig();
|
||||
mqtt.unsubscribe(mqttTopic("command").c_str());
|
||||
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
||||
Serial.printf("[MQTT] Registered as %s\n", id.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool connectMQTT() {
|
||||
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
|
||||
mqtt.setCallback(mqttCallback);
|
||||
mqtt.setKeepAlive(60);
|
||||
|
||||
if (!mqtt.connect(clientID().c_str())) {
|
||||
Serial.printf("[MQTT] Connect fail (state=%d)\n", mqtt.state());
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.println("[MQTT] Connected");
|
||||
|
||||
// Subscribe to commands (if registered)
|
||||
if (cfg.camera_id.length() > 0) {
|
||||
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
||||
}
|
||||
|
||||
// Announce if new
|
||||
if (cfg.camera_id.length() == 0) {
|
||||
JsonDocument doc;
|
||||
doc["mac_address"] = WiFi.macAddress();
|
||||
doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge";
|
||||
doc["friendly_name"] = "Cam-" + clientID();
|
||||
JsonArray caps = doc["capabilities"].to<JsonArray>();
|
||||
caps.add("start_stop"); caps.add("status");
|
||||
String payload; serializeJson(doc, payload);
|
||||
mqtt.publish("remoterig/cameras/announce-" + clientID(), payload.c_str(), true);
|
||||
Serial.println("[MQTT] Announced for registration");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Setup
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0");
|
||||
|
||||
bootMs = millis();
|
||||
pinMode(2, OUTPUT); // built-in LED
|
||||
digitalWrite(2, LOW);
|
||||
|
||||
loadConfig();
|
||||
|
||||
// UART to ESP8266
|
||||
UART_ESP8266.begin(115200, SERIAL_8N1, 16, 17); // RX=16, TX=17
|
||||
Serial.println("[UART] ESP8266 link on RX16/TX17 @ 115200");
|
||||
|
||||
// Connect to travel router — the ONLY network we touch
|
||||
Serial.printf("[WIFI] Connecting to: %s\n", cfg.wifi_ssid.c_str());
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
|
||||
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
|
||||
delay(500); Serial.print("."); attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
} else {
|
||||
Serial.println("\n[WIFI] FAILED — will retry");
|
||||
}
|
||||
|
||||
// MQTT
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
connectMQTT();
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Main Loop
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
static unsigned long lastBeat = 0, lastRecon = 0;
|
||||
static int reconDelay = 1;
|
||||
|
||||
// ── Wi-Fi watchdog ──
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); }
|
||||
delay(100); return;
|
||||
}
|
||||
|
||||
// ── MQTT watchdog ──
|
||||
if (!mqtt.connected()) {
|
||||
if (now - lastRecon > (unsigned long)(reconDelay * 1000)) {
|
||||
lastRecon = now;
|
||||
if (connectMQTT()) reconDelay = 1;
|
||||
else reconDelay = min(reconDelay * 2, 30);
|
||||
}
|
||||
mqtt.loop(); delay(100); return;
|
||||
}
|
||||
|
||||
mqtt.loop();
|
||||
|
||||
// ── Read status from ESP8266 over UART → publish via MQTT ──
|
||||
String line;
|
||||
while (readFromESP8266(line)) {
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, line);
|
||||
if (err) { Serial.printf("[UART] Bad JSON: %s\n", line.c_str()); continue; }
|
||||
|
||||
String type = doc["type"] | "";
|
||||
|
||||
if (type == "status") {
|
||||
// Relay camera status to MQTT hub
|
||||
lastStatusMs = now;
|
||||
bool online = doc["online"] | false;
|
||||
|
||||
if (online != cameraOnline) {
|
||||
cameraOnline = online;
|
||||
digitalWrite(2, online ? HIGH : LOW);
|
||||
}
|
||||
|
||||
if (cfg.camera_id.length() > 0) {
|
||||
// Build the MQTT status payload per contract
|
||||
JsonDocument mqttDoc;
|
||||
mqttDoc["camera_id"] = cfg.camera_id;
|
||||
mqttDoc["timestamp"] = millis();
|
||||
mqttDoc["battery_raw"] = doc["battery_raw"] | 0;
|
||||
mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0;
|
||||
mqttDoc["recording"] = doc["recording"] | false;
|
||||
mqttDoc["online"] = online;
|
||||
|
||||
String payload;
|
||||
serializeJson(mqttDoc, payload);
|
||||
mqtt.publish(mqttTopic("status").c_str(), payload.c_str(), true);
|
||||
}
|
||||
}
|
||||
else if (type == "ack") {
|
||||
Serial.printf("[UART] ESP8266 ack: %s\n", (doc["cmd"] | "?").c_str());
|
||||
}
|
||||
else if (type == "pong") {
|
||||
Serial.printf("[UART] ESP8266 pong (uptime=%d)\n", doc["uptime_ms"] | 0);
|
||||
}
|
||||
else if (type == "error") {
|
||||
Serial.printf("[UART] ESP8266 error: %s\n", (doc["msg"] | "?").c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat to hub (every heartbeat_sec) ──
|
||||
if (cfg.camera_id.length() > 0 &&
|
||||
now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) {
|
||||
lastBeat = now;
|
||||
JsonDocument doc;
|
||||
doc["camera_id"] = cfg.camera_id;
|
||||
doc["timestamp"] = millis();
|
||||
doc["uptime_sec"] = (now - bootMs) / 1000;
|
||||
doc["free_heap"] = ESP.getFreeHeap();
|
||||
doc["status_age_ms"] = now - lastStatusMs;
|
||||
String payload; serializeJson(doc, payload);
|
||||
mqtt.publish(mqttTopic("heartbeat").c_str(), payload.c_str(), false);
|
||||
}
|
||||
|
||||
// ── Periodic ping to ESP8266 to verify UART link ──
|
||||
static unsigned long lastPing = 0;
|
||||
if (now - lastPing > 30000) {
|
||||
lastPing = now;
|
||||
sendCmdToESP8266("ping");
|
||||
}
|
||||
|
||||
delay(50);
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
/**
|
||||
* RemoteRig — ESP8266 Camera Bridge Firmware
|
||||
* ==========================================
|
||||
* Dedicated board clipped to each GoPro Hero 3.
|
||||
*
|
||||
* ONE JOB: talk to the camera.
|
||||
* - Connects to GoPro Wi-Fi AP (10.5.5.1)
|
||||
* - Polls status every 30s → sends JSON over UART to ESP32
|
||||
* - Receives commands from ESP32 over UART → executes against camera
|
||||
* - Zero network switching, zero MQTT, zero cloud
|
||||
*
|
||||
* UART Protocol: JSON-per-line at 115200 8N1
|
||||
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
|
||||
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
|
||||
* ESP8266 → ESP32: {"type":"error","msg":"..."}\n
|
||||
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
|
||||
*
|
||||
* Hardware:
|
||||
* - ESP8266 D1 Mini (or NodeMCU)
|
||||
* - UART TX → ESP32 RX (GPIO 16)
|
||||
* - UART RX → ESP32 TX (GPIO 16)
|
||||
* - Shared GND between boards
|
||||
* - LiPo → 3.3V buck → VIN on both boards
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <LittleFS.h>
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Configuration (SPIFFS via LittleFS)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
struct Config {
|
||||
String camera_ssid = "GOPRO-BP-";
|
||||
String camera_password = "goprohero";
|
||||
String camera_ip = "10.5.5.1";
|
||||
int poll_interval_sec = 30;
|
||||
} cfg;
|
||||
|
||||
bool loadConfig() {
|
||||
if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; }
|
||||
File f = LittleFS.open("/config.json", "r");
|
||||
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
|
||||
|
||||
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
|
||||
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
|
||||
cfg.camera_ip = doc["camera_ip"] | cfg.camera_ip;
|
||||
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Camera HTTP Client (GoPro Hero 3)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
WiFiClient goproClient;
|
||||
|
||||
struct CamStatus {
|
||||
bool valid = false;
|
||||
int video_remaining_sec = 0;
|
||||
bool recording = false;
|
||||
int battery_raw = 0;
|
||||
};
|
||||
|
||||
CamStatus fetchStatus() {
|
||||
CamStatus s;
|
||||
|
||||
String url = "http://" + cfg.camera_ip +
|
||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
|
||||
|
||||
HTTPClient http;
|
||||
http.useHTTP10(true);
|
||||
http.begin(goproClient, url);
|
||||
http.setTimeout(5000);
|
||||
int code = http.GET();
|
||||
|
||||
if (code != 200) { http.end(); return s; }
|
||||
|
||||
String raw = http.getString();
|
||||
http.end();
|
||||
if (raw.length() < 58) return s;
|
||||
|
||||
const uint8_t* buf = (const uint8_t*)raw.c_str();
|
||||
s.valid = true;
|
||||
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
||||
s.recording = (buf[29] == 1);
|
||||
s.battery_raw = buf[57];
|
||||
return s;
|
||||
}
|
||||
|
||||
bool sendCommand(const String& cmd) {
|
||||
String param = (cmd == "start_recording") ? "%01" : "%00";
|
||||
String url = "http://" + cfg.camera_ip +
|
||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
|
||||
|
||||
HTTPClient http;
|
||||
http.useHTTP10(true);
|
||||
http.begin(goproClient, url);
|
||||
http.setTimeout(5000);
|
||||
int code = http.GET();
|
||||
http.end();
|
||||
return (code == 200);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// UART Protocol (to ESP32)
|
||||
// ────────────────────────────────────────────
|
||||
// Using HardwareSerial on GPIO1/3 (D1 Mini default TX/RX)
|
||||
// On D1 Mini: TX=GPIO1, RX=GPIO3 (labeled TX/RX on board)
|
||||
|
||||
// Send JSON line to ESP32
|
||||
void sendToESP32(const JsonDocument& doc) {
|
||||
String line;
|
||||
serializeJson(doc, line);
|
||||
Serial.println(line); // newline-terminated for framing
|
||||
Serial.flush();
|
||||
}
|
||||
|
||||
// Send status update
|
||||
void sendStatus(const CamStatus& s) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "status";
|
||||
doc["valid"] = s.valid;
|
||||
doc["battery_raw"] = s.battery_raw;
|
||||
doc["video_remaining_sec"] = s.video_remaining_sec;
|
||||
doc["recording"] = s.recording;
|
||||
doc["online"] = s.valid;
|
||||
doc["uptime_ms"] = millis();
|
||||
sendToESP32(doc);
|
||||
}
|
||||
|
||||
// Send acknowledgment
|
||||
void sendAck(const String& cmd) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "ack";
|
||||
doc["cmd"] = cmd;
|
||||
sendToESP32(doc);
|
||||
}
|
||||
|
||||
// Send error
|
||||
void sendError(const String& msg) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "error";
|
||||
doc["msg"] = msg;
|
||||
sendToESP32(doc);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Command handling (from ESP32 over UART)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void handleCommand(const JsonDocument& doc) {
|
||||
String cmd = doc["command"] | "";
|
||||
|
||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||
bool ok = sendCommand(cmd);
|
||||
if (ok) {
|
||||
sendAck(cmd);
|
||||
} else {
|
||||
sendError("Camera unreachable — command failed");
|
||||
}
|
||||
} else if (cmd == "ping") {
|
||||
JsonDocument pong;
|
||||
pong["type"] = "pong";
|
||||
pong["uptime_ms"] = millis();
|
||||
sendToESP32(pong);
|
||||
} else {
|
||||
sendError("Unknown command: " + cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// UART line reader (non-blocking)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
String serialLine;
|
||||
|
||||
bool readLine(String& line) {
|
||||
while (Serial.available()) {
|
||||
char c = Serial.read();
|
||||
if (c == '\n') {
|
||||
line = serialLine;
|
||||
serialLine = "";
|
||||
return true;
|
||||
}
|
||||
if (c != '\r') serialLine += c;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// LED
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
const int LED = LED_BUILTIN; // active-low on ESP8266 D1 Mini
|
||||
|
||||
void ledOn() { digitalWrite(LED, LOW); }
|
||||
void ledOff() { digitalWrite(LED, HIGH); }
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Setup
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
Serial.println("\n[BRIDGE] ESP8266 Camera Bridge v1.0");
|
||||
|
||||
pinMode(LED, OUTPUT);
|
||||
ledOff();
|
||||
|
||||
loadConfig();
|
||||
|
||||
// Connect to GoPro AP — this is the ONLY network we touch
|
||||
Serial.printf("[WIFI] Connecting to camera AP: %s\n", cfg.camera_ssid.c_str());
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
|
||||
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
|
||||
delay(500); Serial.print("."); attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
ledOn(); // Solid = connected
|
||||
} else {
|
||||
Serial.println("\n[WIFI] FAILED — will retry in loop");
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Main Loop — poll camera, relay over UART
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
static unsigned long lastPoll = 0;
|
||||
static unsigned long lastWiFiRetry = 0;
|
||||
static bool cameraOnline = false;
|
||||
|
||||
// ── Wi-Fi reconnection ──
|
||||
if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) {
|
||||
lastWiFiRetry = now;
|
||||
Serial.println("[WIFI] Reconnecting...");
|
||||
WiFi.reconnect();
|
||||
}
|
||||
|
||||
// ── Poll camera ──
|
||||
if (now - lastPoll > (unsigned long)(cfg.poll_interval_sec * 1000)) {
|
||||
lastPoll = now;
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
CamStatus s = fetchStatus();
|
||||
|
||||
if (s.valid && !cameraOnline) {
|
||||
cameraOnline = true;
|
||||
ledOn();
|
||||
} else if (!s.valid && cameraOnline) {
|
||||
cameraOnline = false;
|
||||
ledOff();
|
||||
}
|
||||
|
||||
sendStatus(s);
|
||||
} else {
|
||||
// Offline — send empty status so ESP32 knows we're alive but camera is down
|
||||
CamStatus s;
|
||||
sendStatus(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read commands from ESP32 over UART ──
|
||||
String line;
|
||||
if (readLine(line)) {
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, line);
|
||||
if (!err) {
|
||||
String type = doc["type"] | "";
|
||||
if (type == "cmd") {
|
||||
handleCommand(doc);
|
||||
}
|
||||
// Ignore other message types — they're for the ESP32
|
||||
}
|
||||
}
|
||||
|
||||
// ── LED blink when offline ──
|
||||
if (!cameraOnline) {
|
||||
static unsigned long lastBlink = 0;
|
||||
if (now - lastBlink > 500) {
|
||||
lastBlink = now;
|
||||
digitalWrite(LED, !digitalRead(LED));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ module github.com/cubecraft/remoterig
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
@@ -12,12 +11,9 @@ require (
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -20,8 +16,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# RemoteRig Hardware Design Pipeline
|
||||
|
||||
> Living queue for 3D-printed / physical hardware design work.
|
||||
|
||||
## Active / Ready for prototype print
|
||||
|
||||
### Tripod electronics case v3
|
||||
|
||||
**Status:** STL generated and validated watertight.
|
||||
|
||||
**Files:**
|
||||
- `hardware/case/tripod-case-v3.scad`
|
||||
- `hardware/case/case-body-v3.stl`
|
||||
- `hardware/case/case-lid-v3.stl`
|
||||
- `hardware/case/tripod-clamp-v3.stl`
|
||||
- `hardware/case/full-case-preview-v3.stl`
|
||||
|
||||
**Design notes:**
|
||||
- Holds ESP32 + ESP8266 stack.
|
||||
- Screw-on lid with vent slots.
|
||||
- Rear dovetail-style rail/socket interface.
|
||||
- Separate screw-tightened tripod clamp sized around a 35 mm stand/pole.
|
||||
- Clamp uses M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
|
||||
|
||||
**Prototype questions:**
|
||||
- Does the clamp close enough on smaller tripod legs, or do we need swappable inserts?
|
||||
- Does the dovetail hold under vibration without a retention screw?
|
||||
- Are USB/LED/UART cutouts in the correct orientation for the actual boards?
|
||||
|
||||
## Backlog
|
||||
|
||||
### 10.1-inch touchscreen + Raspberry Pi Zero case
|
||||
|
||||
**Status:** Specific display identified; mechanical measurements needed before CAD.
|
||||
|
||||
**Goal:** A printable enclosure for the RemoteRig hub/control panel using a 10.1-inch touchscreen and Raspberry Pi Zero / Zero 2 W.
|
||||
|
||||
**Display target:**
|
||||
- Vendor/model: HZWDONE Raspberry Pi Screen 10.1" Touchscreen
|
||||
- Resolution: 1024×600
|
||||
- Interface: HDMI portable monitor
|
||||
- Mounting: includes fixing holes
|
||||
- Compatibility listing: Raspberry Pi 5/4/3B/B+ and Windows 11/10/8
|
||||
|
||||
**Initial assumptions to validate:**
|
||||
- Compute: Raspberry Pi Zero / Zero 2 W mounted behind or below the display.
|
||||
- Use case: RemoteRig local monitor/control panel at field recording setup.
|
||||
- Likely needs: front bezel, rear electronics cavity, Pi mounting posts, HDMI/USB/power cable exits, strain relief, ventilation, and optional tripod/stand mounting.
|
||||
- Because this is a 10.1" panel, design should prioritize rigidity: thicker bezel ribs, rear standoffs, and possibly a two-piece shell instead of a small snap case.
|
||||
|
||||
**Required measurements before CAD:**
|
||||
- Product link or datasheet for the exact HZWDONE 10.1" variant.
|
||||
- Screen/PCB outer dimensions: width, height, thickness.
|
||||
- Active display opening dimensions.
|
||||
- Fixing-hole locations, hole diameter, and screw size.
|
||||
- Connector locations/orientation for HDMI, USB touch, and power.
|
||||
- Whether the driver/controller board is integrated with the display PCB or separate.
|
||||
- Pi Zero orientation, port access requirements, and whether GPIO/header must remain accessible.
|
||||
- Power connector position and desired cable routing.
|
||||
- Mounting preference: desktop kickstand, tripod clamp, VESA-style holes, handle, or combination.
|
||||
|
||||
**Proposed design approach:**
|
||||
1. Create `hardware/display-case/`.
|
||||
2. Build a parametric OpenSCAD model with measured display/Pi dimensions.
|
||||
3. Split into printable parts: front bezel, rear shell, Pi/controller tray, optional stand/tripod mount.
|
||||
4. Validate STLs with OpenSCAD + trimesh.
|
||||
5. Upload generated STL/SCAD artifacts to Seafile.
|
||||
@@ -1,112 +0,0 @@
|
||||
# RemoteRig — Camera Node Hardware Design
|
||||
|
||||
> **Version:** 0.2.0 | **Status:** Draft
|
||||
> **Target:** GoPro Hero 3 Black/Silver + ESP8266 + ESP32 + USB power bank
|
||||
|
||||
## Overview
|
||||
|
||||
Each camera node is two ESP boards in a small case that clips to the tripod/stand. The case **does not attach to the camera** — only to the stand. Powered by a standard USB power bank.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ USB Power Bank │── USB ──→ GoPro (power only)
|
||||
│ (off-the-shelf)│── USB ──→ ESP32 + ESP8266 (shared)
|
||||
└─────────────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ Tripod Case │ ← clips to stand leg
|
||||
│ ┌────────────┐ │
|
||||
│ │ ESP8266 │ │ ← Wi-Fi → GoPro AP (10.5.5.1)
|
||||
│ │ (camera) │ │
|
||||
│ ├────────────┤ │ ← UART between boards
|
||||
│ │ ESP32 │ │ ← Wi-Fi → Travel Router
|
||||
│ │ (MQTT) │ │
|
||||
│ └────────────┘ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Bill of Materials
|
||||
|
||||
| Item | Qty | Cost | Notes |
|
||||
|------|-----|------|-------|
|
||||
| ESP32 Dev Board | 1 | ~$5 | MQTT bridge — talks to hub |
|
||||
| ESP8266 D1 Mini | 1 | ~$3 | Camera bridge — talks to GoPro |
|
||||
| USB power bank (5000mAh+) | 1 | ~$10 | Powers both boards + GoPro |
|
||||
| Micro-USB cable (short) | 2 | ~$2 | Power bank → boards + GoPro |
|
||||
| Jumper wires F-F | 3 | ~$0.25 | UART TX/RX/GND between boards |
|
||||
| PETG filament | ~25g | ~$0.50 | 3D printed case |
|
||||
| Velcro strap (small) | 1 | ~$0.25 | Secure power bank to stand |
|
||||
|
||||
**Total per node:** ~$21 (+ GoPro already owned)
|
||||
|
||||
## 3D Printed Case
|
||||
|
||||
**Current source:** `hardware/case/tripod-case-v3.scad`
|
||||
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
|
||||
|
||||
Four exported prototype files:
|
||||
1. **Case body** — holds both boards stacked, cable ports, rear dovetail-style receiver
|
||||
2. **Case lid** — screw-on cover with ventilation
|
||||
3. **Tripod clamp** — separate screw-tightened C-clamp sized around a 35mm stand/pole
|
||||
4. **Full preview** — combined visualization STL only, not intended as the print job
|
||||
|
||||
### Print Settings
|
||||
- **Material:** PETG preferred for heat/outdoor use and clamp flex
|
||||
- **Layer:** 0.2mm | **Infill:** 20% gyroid minimum; 35%+ recommended for clamp
|
||||
- **Supports:** Likely yes for clamp ears / dovetail overhangs depending on slicer orientation
|
||||
- **Post-processing:** M3x8mm screws for lid (4x), one M3 screw + M3 nut for clamp tightening
|
||||
|
||||
## Wiring
|
||||
|
||||
```
|
||||
USB Power Bank
|
||||
├── USB-A → Micro-USB cable → ESP32 USB port
|
||||
│ (powers ESP32, shared 5V rail)
|
||||
│
|
||||
├── USB-A → Micro-USB cable → GoPro USB port
|
||||
│ (power only — no data)
|
||||
│
|
||||
└── (ESP8266 powered via ESP32 3.3V pin, or via shared USB)
|
||||
|
||||
UART (inside case):
|
||||
ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16)
|
||||
ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17)
|
||||
ESP8266 GND ─────────── ESP32 GND
|
||||
```
|
||||
|
||||
**Power note:** Both boards can be powered from a single USB cable if the ESP32's VIN/5V pin is bridged to the ESP8266's VIN. Alternatively, use a USB Y-splitter cable.
|
||||
|
||||
## Wi-Fi Topology
|
||||
|
||||
```
|
||||
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP8266 (camera bridge)
|
||||
│
|
||||
UART │ (inside case)
|
||||
│
|
||||
Travel Router ──(AP)─────────────────→ ESP32 (MQTT bridge)
|
||||
(10.60.1.1) │
|
||||
│
|
||||
MQTT │
|
||||
▼
|
||||
Pi Hub (10.60.1.56)
|
||||
```
|
||||
|
||||
The ESP8266 and GoPro talk over Wi-Fi — **no data cable between them**. The only cable to the GoPro is USB power from the battery pack.
|
||||
|
||||
## Field Setup
|
||||
|
||||
1. **Mount GoPro** on tripod/stand
|
||||
2. **Clip case** to tripod leg
|
||||
3. **Connect power bank** via USB to case + GoPro
|
||||
4. **Power on** — ESP32 auto-connects to travel router, ESP8266 auto-connects to GoPro
|
||||
5. **Monitor** from `http://10.60.1.56:8080`
|
||||
|
||||
## Case Dimensions
|
||||
|
||||
| | W × D × H (mm) |
|
||||
|---|---|
|
||||
| Case body external | ~56.8 × 38.2 × 19.0 |
|
||||
| Lid external | ~56.8 × 32.8 × 4.0 |
|
||||
| Tripod clamp | ~43.0 × 56.9 × 16.0 |
|
||||
| Clamp pole fit | Nominal 35mm; smaller poles TBD / may need inserts |
|
||||
| Total weight | TBD after prototype print |
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) case_body();
|
||||
@@ -1,2 +0,0 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) case_lid();
|
||||
@@ -1,2 +0,0 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) full_case();
|
||||
@@ -1,2 +0,0 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) tripod_clamp();
|
||||
Binary file not shown.
@@ -1,217 +0,0 @@
|
||||
// RemoteRig — Dual-ESP Tripod Case v3
|
||||
// v3 changes: screw-tightened tripod clamp + dovetail slide interface.
|
||||
// Coordinate system: all case/lid geometry uses bottom-origin Z.
|
||||
|
||||
$fn = 36;
|
||||
|
||||
// Board dimensions
|
||||
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
|
||||
esp32_w = 52; esp32_d = 28; esp32_h = 5;
|
||||
board_gap = 3;
|
||||
stack_h = esp8266_h + esp32_h + board_gap;
|
||||
inner_w = max(esp8266_w, esp32_w);
|
||||
inner_d = max(esp8266_d, esp32_d);
|
||||
inner_h = stack_h + 2;
|
||||
|
||||
// Case parameters
|
||||
wall = 2.0;
|
||||
tol = 0.4;
|
||||
outer_w = inner_w + wall*2 + tol*2; // 56.8mm
|
||||
outer_d = inner_d + wall*2 + tol*2; // 32.8mm
|
||||
outer_h = inner_h + wall*2; // 19mm
|
||||
corner_r = 2.5;
|
||||
|
||||
// Tripod clamp parameters
|
||||
pole_dia = 35; // nominal stand/pole diameter
|
||||
clamp_thick = 4.0; // ring wall thickness
|
||||
clamp_width = 16.0; // extrusion width along Z
|
||||
mouth_width = 13.0; // clamp opening
|
||||
m3_clearance = 3.4; // M3 screw clearance
|
||||
nut_flat = 6.4; // M3 nut trap flat-to-flat
|
||||
|
||||
// Dovetail slide interface
|
||||
// Male rail is on the case; matching female socket is on the tripod clamp.
|
||||
// This is easier to inspect and avoids the previous mismatched "two lips + tab" geometry.
|
||||
rail_z = outer_h * 0.78;
|
||||
rail_depth = 5.0;
|
||||
rail_neck_w = 12.0; // narrow width at case wall / slot opening
|
||||
rail_outer_w = 18.0; // wider retained edge
|
||||
rail_clearance = 0.45; // FDM sliding clearance per side-ish
|
||||
socket_wall = 2.2;
|
||||
|
||||
// Cable ports
|
||||
usb_port_w = 12; usb_port_h = 6;
|
||||
uart_port_w = 6; uart_port_h = 4;
|
||||
|
||||
// Uncomment one for manual OpenSCAD use
|
||||
// full_case();
|
||||
// case_body();
|
||||
// case_lid();
|
||||
// tripod_clamp();
|
||||
|
||||
module rounded_cube_centered(w, d, h, r) {
|
||||
hull() {
|
||||
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
|
||||
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
|
||||
sphere(r=r, $fn=24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module rounded_cube0(w, d, h, r) {
|
||||
translate([0, 0, h/2]) rounded_cube_centered(w, d, h, r);
|
||||
}
|
||||
|
||||
module hex_prism(d, h) {
|
||||
cylinder(d=d, h=h, center=true, $fn=6);
|
||||
}
|
||||
|
||||
module dovetail_prism(length_z, front_w, back_w, depth) {
|
||||
// 2D profile is X/Y, extruded along Z.
|
||||
rotate([0, 0, 0])
|
||||
linear_extrude(height=length_z, center=true, convexity=10)
|
||||
polygon(points=[
|
||||
[-front_w/2, 0], [front_w/2, 0],
|
||||
[back_w/2, depth], [-back_w/2, depth]
|
||||
]);
|
||||
}
|
||||
|
||||
module case_shell() {
|
||||
difference() {
|
||||
rounded_cube0(outer_w, outer_d, outer_h, corner_r);
|
||||
|
||||
// Open internal cavity: starts above bottom wall, extends past top.
|
||||
translate([0, 0, wall])
|
||||
rounded_cube0(inner_w + tol, inner_d + tol, outer_h + 2, 1.6);
|
||||
|
||||
// USB power IN / OUT ports through front/back walls.
|
||||
translate([0, outer_d/2 + 0.1, wall + 4])
|
||||
cube([usb_port_w, wall*3, usb_port_h], center=true);
|
||||
translate([0, -outer_d/2 - 0.1, wall + 4])
|
||||
cube([usb_port_w, wall*3, usb_port_h], center=true);
|
||||
|
||||
// UART side channel.
|
||||
translate([outer_w/2 + 0.1, 0, wall + 6])
|
||||
cube([wall*3, uart_port_w, uart_port_h], center=true);
|
||||
|
||||
// LED viewing window on front lower wall.
|
||||
translate([-outer_w/4, -outer_d/2 - 0.1, wall + 2])
|
||||
cube([6, wall*2, 3], center=true);
|
||||
}
|
||||
}
|
||||
|
||||
module screw_post(x, y) {
|
||||
difference() {
|
||||
translate([x, y, wall]) cylinder(d=5.0, h=outer_h-wall-0.5, center=false, $fn=24);
|
||||
translate([x, y, wall-0.5]) cylinder(d=2.1, h=outer_h+1, center=false, $fn=20);
|
||||
}
|
||||
}
|
||||
|
||||
module case_male_dovetail_rail() {
|
||||
// Positive tapered rail on the case back. Cross-section is narrow at the
|
||||
// wall and wider at the outside, so the clamp socket captures it.
|
||||
translate([0, outer_d/2 - 0.15, outer_h/2])
|
||||
dovetail_prism(rail_z, rail_neck_w, rail_outer_w, rail_depth);
|
||||
|
||||
// Bottom stop so the clamp socket cannot slide past the case.
|
||||
translate([0, outer_d/2 + rail_depth/2, outer_h*0.12])
|
||||
rounded_cube_centered(rail_outer_w + 3.0, rail_depth + 0.8, 2.4, 0.8);
|
||||
}
|
||||
|
||||
module case_body() {
|
||||
union() {
|
||||
case_shell();
|
||||
for (x = [-1, 1], y = [-1, 1])
|
||||
screw_post(x*(outer_w/2 - 5), y*(outer_d/2 - 5));
|
||||
case_male_dovetail_rail();
|
||||
}
|
||||
}
|
||||
|
||||
module case_lid() {
|
||||
difference() {
|
||||
rounded_cube0(outer_w, outer_d, wall*2, 1.8);
|
||||
|
||||
for (x = [-1, 1], y = [-1, 1]) {
|
||||
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), -0.5])
|
||||
cylinder(d=2.4, h=wall*2 + 1, center=false, $fn=20);
|
||||
}
|
||||
|
||||
for (x = [-outer_w/4, 0, outer_w/4]) {
|
||||
translate([x, 0, wall*2/2])
|
||||
cube([8, outer_d*0.6, wall*3], center=true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module clamp_ring_with_mouth() {
|
||||
outer_r = pole_dia/2 + clamp_thick;
|
||||
difference() {
|
||||
cylinder(r=outer_r, h=clamp_width, center=true, $fn=72);
|
||||
cylinder(r=pole_dia/2 + rail_clearance, h=clamp_width + 1, center=true, $fn=72);
|
||||
// Mouth opens toward +Y. Width is intentionally generous for snap-on placement before tightening.
|
||||
translate([0, outer_r, 0])
|
||||
cube([mouth_width, outer_r*2, clamp_width + 2], center=true);
|
||||
}
|
||||
}
|
||||
|
||||
module clamp_ears() {
|
||||
outer_r = pole_dia/2 + clamp_thick;
|
||||
ear_y = outer_r + 2.2;
|
||||
ear_z = 0;
|
||||
difference() {
|
||||
union() {
|
||||
translate([-mouth_width/2 - 3.2, ear_y, ear_z])
|
||||
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
|
||||
translate([ mouth_width/2 + 3.2, ear_y, ear_z])
|
||||
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
|
||||
}
|
||||
// M3 screw passes across the mouth along X.
|
||||
translate([0, ear_y, ear_z])
|
||||
rotate([0, 90, 0]) cylinder(d=m3_clearance, h=mouth_width + 24, center=true, $fn=24);
|
||||
// Nut trap on the right ear.
|
||||
translate([mouth_width/2 + 3.2, ear_y, ear_z])
|
||||
rotate([0, 90, 0]) hex_prism(nut_flat, 4.2);
|
||||
}
|
||||
}
|
||||
|
||||
module clamp_dovetail_socket() {
|
||||
outer_r = pole_dia/2 + clamp_thick;
|
||||
socket_outer_w = rail_outer_w + socket_wall*2;
|
||||
socket_depth = rail_depth + socket_wall*2;
|
||||
|
||||
// Solid boss on the rear of the clamp, opposite the tightening mouth.
|
||||
// A matching dovetail void is cut through it along Z so the case rail
|
||||
// slides in from the top/bottom with practical FDM clearance.
|
||||
difference() {
|
||||
translate([0, -outer_r - socket_depth/2 + socket_wall, 0])
|
||||
rounded_cube_centered(socket_outer_w, socket_depth, clamp_width, 1.2);
|
||||
|
||||
translate([0, -outer_r - 0.15, 0])
|
||||
dovetail_prism(
|
||||
clamp_width + 1.0,
|
||||
rail_neck_w + rail_clearance,
|
||||
rail_outer_w + rail_clearance,
|
||||
rail_depth + 0.6
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module tripod_clamp() {
|
||||
union() {
|
||||
clamp_ring_with_mouth();
|
||||
clamp_ears();
|
||||
clamp_dovetail_socket();
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible alias for earlier export scripts.
|
||||
module tripod_clip() {
|
||||
tripod_clamp();
|
||||
}
|
||||
|
||||
module full_case() {
|
||||
case_body();
|
||||
translate([0, 0, outer_h + 2]) case_lid();
|
||||
translate([0, outer_d/2 + pole_dia/2 + clamp_thick + 8, outer_h/2])
|
||||
rotate([90, 0, 0]) tripod_clamp();
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
// RemoteRig — Dual-ESP Tripod Case
|
||||
// =================================
|
||||
// Small box that clips onto a tripod leg or light stand pole.
|
||||
// Holds ESP8266 D1 Mini + ESP32 Dev Board (stacked).
|
||||
// Powered by standard USB battery pack. No camera sleeve needed.
|
||||
//
|
||||
// Print settings:
|
||||
// Material: PETG | Layer: 0.2mm | Infill: 20% gyroid
|
||||
// Supports: yes (for clip overhang) | Brim: 5mm
|
||||
|
||||
// ── Board dimensions ──
|
||||
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
|
||||
esp32_w = 52; esp32_d = 28; esp32_h = 5;
|
||||
board_gap = 3; // air gap between stacked boards
|
||||
stack_h = esp8266_h + esp32_h + board_gap;
|
||||
inner_w = max(esp8266_w, esp32_w);
|
||||
inner_d = max(esp8266_d, esp32_d);
|
||||
inner_h = stack_h + 2;
|
||||
|
||||
// ── Case parameters ──
|
||||
wall = 2.0;
|
||||
tol = 0.4;
|
||||
outer_w = inner_w + wall*2 + tol*2;
|
||||
outer_d = inner_d + wall*2 + tol*2;
|
||||
outer_h = inner_h + wall*2;
|
||||
|
||||
// ── Tripod clip parameters ──
|
||||
pole_min_dia = 20; // smallest pole
|
||||
pole_max_dia = 35; // largest pole
|
||||
clip_width = 12; // clip width
|
||||
clip_thick = 3; // clip arm thickness
|
||||
clip_grip = 2; // grip ridges
|
||||
|
||||
// ── Cable ports ──
|
||||
usb_port_w = 12; usb_port_h = 6;
|
||||
uart_port_w = 6; uart_port_h = 4;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MAIN — render the full case
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// Uncomment to render individual parts:
|
||||
full_case();
|
||||
// case_body();
|
||||
// case_lid();
|
||||
// tripod_clip();
|
||||
|
||||
module full_case() {
|
||||
case_body();
|
||||
// Lid positioned above (for visualization)
|
||||
translate([0, 0, outer_h + 2])
|
||||
case_lid();
|
||||
// Clip on the back
|
||||
translate([0, outer_d/2 + pole_max_dia/2 + clip_thick, outer_h/2])
|
||||
tripod_clip();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Case Body — holds both boards, cable ports
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module case_body() {
|
||||
difference() {
|
||||
// Outer shell
|
||||
rounded_cube(outer_w, outer_d, outer_h, 3);
|
||||
|
||||
// Inner cavity
|
||||
translate([0, 0, wall])
|
||||
rounded_cube(inner_w + tol, inner_d + tol, inner_h + tol, 2);
|
||||
|
||||
// ── Board recesses ──
|
||||
|
||||
// Bottom: ESP32 (larger board)
|
||||
translate([0, 0, wall + 1])
|
||||
cube([esp32_w + tol, esp32_d + tol, esp32_h + 1], center=true);
|
||||
|
||||
// Top: ESP8266 (smaller board)
|
||||
translate([0, 0, wall + esp32_h + board_gap + 1])
|
||||
cube([esp8266_w + tol, esp8266_d + tol, esp8266_h + 1], center=true);
|
||||
|
||||
// ── Cable ports ──
|
||||
|
||||
// USB power IN (from battery pack → ESP32)
|
||||
translate([0, outer_d/2, outer_h/3])
|
||||
cube([usb_port_w, wall*3, usb_port_h], center=true);
|
||||
|
||||
// USB power OUT (from battery pack → GoPro)
|
||||
translate([0, -outer_d/2, outer_h/3])
|
||||
cube([usb_port_w, wall*3, usb_port_h], center=true);
|
||||
|
||||
// UART wire channel (ESP8266 → ESP32 internal)
|
||||
translate([outer_w/2, 0, outer_h/2])
|
||||
cube([wall*3, uart_port_w, uart_port_h], center=true);
|
||||
|
||||
// ── Ventilation slots (top edge) ──
|
||||
for (x = [-outer_w/4, 0, outer_w/4]) {
|
||||
translate([x, 0, outer_h - wall])
|
||||
cube([8, outer_d*0.6, 2], center=true);
|
||||
}
|
||||
|
||||
// ── Screw posts for lid ──
|
||||
for (x = [-1, 1], y = [-1, 1]) {
|
||||
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), outer_h/2])
|
||||
cylinder(d=3.2, h=outer_h, center=true, $fn=16);
|
||||
}
|
||||
|
||||
// ── LED window (thin spot to see board LEDs) ──
|
||||
translate([-outer_w/4, -outer_d/2, wall])
|
||||
cube([6, 1, 3], center=true);
|
||||
}
|
||||
|
||||
// ── Tripod clip mount (rail on back) ──
|
||||
translate([0, outer_d/2, outer_h/2])
|
||||
rotate([90, 0, 0])
|
||||
difference() {
|
||||
cube([clip_width + 4, outer_h*0.7, 6], center=true);
|
||||
// T-slot for clip to slide in
|
||||
cube([clip_width + 1, outer_h*0.7 + 1, 4], center=true);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Case Lid — snap-fit or screw-on cover
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module case_lid() {
|
||||
difference() {
|
||||
rounded_cube(outer_w, outer_d, wall*2, 2);
|
||||
|
||||
// Screw holes (match body posts)
|
||||
for (x = [-1, 1], y = [-1, 1]) {
|
||||
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), 0])
|
||||
cylinder(d=3.2, h=wall*3, center=true, $fn=16);
|
||||
}
|
||||
|
||||
// Ventilation slots (match body)
|
||||
for (x = [-outer_w/4, 0, outer_w/4]) {
|
||||
translate([x, 0, 0])
|
||||
cube([8, outer_d*0.6, 3], center=true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Tripod Clip — C-clamp for pole mounting
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module tripod_clip() {
|
||||
difference() {
|
||||
union() {
|
||||
// Main body
|
||||
hull() {
|
||||
translate([0, -pole_max_dia/2 - clip_thick, 0])
|
||||
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
|
||||
|
||||
translate([0, pole_max_dia/2 + clip_thick, 0])
|
||||
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
|
||||
}
|
||||
|
||||
// Top arm (flexible)
|
||||
translate([0, -pole_max_dia/2 - clip_thick, outer_h*0.35])
|
||||
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
|
||||
|
||||
// Bottom arm
|
||||
translate([0, -pole_max_dia/2 - clip_thick, -outer_h*0.35])
|
||||
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
|
||||
|
||||
// Mounting tab (slides into case rail)
|
||||
translate([0, -pole_max_dia/2 - clip_thick*3, 0])
|
||||
cube([clip_width + 1, clip_thick*2, outer_h*0.7], center=true);
|
||||
}
|
||||
|
||||
// Pole hole
|
||||
cylinder(d=pole_max_dia + 2, h=outer_h*1.5, center=true, $fn=32);
|
||||
|
||||
// Grip ridges on inner surface
|
||||
for (z = [-outer_h*0.25, 0, outer_h*0.25]) {
|
||||
translate([0, 0, z])
|
||||
rotate_extrude(angle=180, $fn=32)
|
||||
translate([pole_max_dia/2 + 0.5, 0])
|
||||
circle(d=1);
|
||||
}
|
||||
|
||||
// Entry slot (pole slides in from front)
|
||||
translate([0, pole_max_dia/2 + clip_thick, 0])
|
||||
cube([clip_width + 2, pole_max_dia + 10, outer_h*0.7], center=true);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Utility: rounded cube
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module rounded_cube(w, d, h, r) {
|
||||
hull() {
|
||||
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
|
||||
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
|
||||
sphere(r=r, $fn=20);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,274 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RemoteRig Case — 3D Viewer</title>
|
||||
<style>
|
||||
body { margin: 0; overflow: hidden; background: #1a1a2e; font-family: system-ui; }
|
||||
canvas { display: block; }
|
||||
#info {
|
||||
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
color: #888; font-size: 13px; pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script>
|
||||
// ── Scene setup ──
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a2e);
|
||||
scene.fog = new THREE.Fog(0x1a1a2e, 8, 25);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.5, 50);
|
||||
camera.position.set(5, 3.5, 7);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.2;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
// ── Lighting ──
|
||||
const ambient = new THREE.AmbientLight(0x404060, 0.6);
|
||||
scene.add(ambient);
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
key.position.set(8, 10, 5);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(2048, 2048);
|
||||
key.shadow.camera.near = 0.5; key.shadow.camera.far = 50;
|
||||
key.shadow.camera.left = -10; key.shadow.camera.right = 10;
|
||||
key.shadow.camera.top = 10; key.shadow.camera.bottom = -10;
|
||||
scene.add(key);
|
||||
const fill = new THREE.DirectionalLight(0x8899cc, 0.4);
|
||||
fill.position.set(-3, 2, -2);
|
||||
scene.add(fill);
|
||||
const rim = new THREE.DirectionalLight(0xaaccff, 0.5);
|
||||
rim.position.set(0, 1, -5);
|
||||
scene.add(rim);
|
||||
|
||||
// ── Ground ──
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 20),
|
||||
new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.8 })
|
||||
);
|
||||
ground.rotation.x = -Math.PI/2;
|
||||
ground.position.y = -3;
|
||||
ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
|
||||
// ── Materials ──
|
||||
const petgMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x3d3d4a, roughness: 0.35, metalness: 0.1,
|
||||
});
|
||||
const accentMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xf59e0b, roughness: 0.3, metalness: 0.2, emissive: 0x331100, emissiveIntensity: 0.3
|
||||
});
|
||||
const boardMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x1a6630, roughness: 0.6
|
||||
});
|
||||
const metalMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x888899, roughness: 0.3, metalness: 0.8
|
||||
});
|
||||
|
||||
// ── Create rounded box with bevel ──
|
||||
function createRoundedBox(w, h, d, r, segments = 3) {
|
||||
const shape = new THREE.Shape();
|
||||
const hw = w/2 - r, hh = h/2 - r;
|
||||
shape.moveTo(-hw, -hh + r);
|
||||
shape.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
|
||||
shape.lineTo(hw - r, -hh);
|
||||
shape.quadraticCurveTo(hw, -hh, hw, -hh + r);
|
||||
shape.lineTo(hw, hh - r);
|
||||
shape.quadraticCurveTo(hw, hh, hw - r, hh);
|
||||
shape.lineTo(-hw + r, hh);
|
||||
shape.quadraticCurveTo(-hw, hh, -hw, hh - r);
|
||||
shape.closePath();
|
||||
|
||||
const extrudeSettings = { depth: d - r*2, bevelEnabled: true, bevelThickness: r, bevelSize: r, bevelSegments: segments };
|
||||
const geom = new THREE.ExtrudeGeometry(shape, extrudeSettings);
|
||||
geom.translate(0, 0, -d/2 + r);
|
||||
return geom;
|
||||
}
|
||||
|
||||
// ── Case Body ──
|
||||
const caseW = 2.5, caseH = 1.5, caseD = 1.1;
|
||||
const bodyGeom = createRoundedBox(caseW, caseD, caseH, 0.12);
|
||||
const body = new THREE.Mesh(bodyGeom, petgMat);
|
||||
body.castShadow = true; body.receiveShadow = true;
|
||||
scene.add(body);
|
||||
|
||||
// ── Lid (slightly offset) ──
|
||||
const lidGeom = createRoundedBox(caseW, caseD, 0.15, 0.08);
|
||||
const lid = new THREE.Mesh(lidGeom, petgMat);
|
||||
lid.position.y = caseH/2 + 0.07;
|
||||
lid.castShadow = true;
|
||||
scene.add(lid);
|
||||
|
||||
// ── Ventilation slots ──
|
||||
for (let i = -0.6; i <= 0.6; i += 0.6) {
|
||||
const slot = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.4, 0.04, caseD * 0.7),
|
||||
new THREE.MeshStandardMaterial({ color: 0x1a1a2e })
|
||||
);
|
||||
slot.position.set(i, caseH/2 + 0.15, 0);
|
||||
scene.add(slot);
|
||||
}
|
||||
|
||||
// ── Screws ──
|
||||
for (let x = -1; x <= 1; x += 2) {
|
||||
for (let z = -0.35; z <= 0.35; z += 0.7) {
|
||||
const screw = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.05, 0.05, 0.04, 8),
|
||||
metalMat
|
||||
);
|
||||
screw.position.set(x * (caseW/2 - 0.2), caseH/2 + 0.15, z);
|
||||
scene.add(screw);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boards inside (semi-visible) ──
|
||||
const esp32Board = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(caseW - 0.3, 0.04, caseD - 0.2),
|
||||
boardMat
|
||||
);
|
||||
esp32Board.position.set(0, caseH/2 - 0.15, 0);
|
||||
esp32Board.castShadow = true;
|
||||
scene.add(esp32Board);
|
||||
|
||||
const esp8266Board = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(caseW - 0.5, 0.04, caseD - 0.3),
|
||||
boardMat
|
||||
);
|
||||
esp8266Board.position.set(0, caseH/2 - 0.08, 0);
|
||||
esp8266Board.castShadow = true;
|
||||
scene.add(esp8266Board);
|
||||
|
||||
// Chip on ESP32
|
||||
const chip = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.3, 0.03, 0.3),
|
||||
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
|
||||
);
|
||||
chip.position.set(0, caseH/2 - 0.12, 0);
|
||||
scene.add(chip);
|
||||
|
||||
// LED
|
||||
const led = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.03, 8, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x00ff44, roughness: 0.2, emissive: 0x00ff44, emissiveIntensity: 1.5 })
|
||||
);
|
||||
led.position.set(-0.8, caseH/2 - 0.12, -0.3);
|
||||
scene.add(led);
|
||||
|
||||
// ── USB Port (front face) ──
|
||||
const usbPort = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.35, 0.02, 0.15),
|
||||
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
|
||||
);
|
||||
usbPort.position.set(0, 0.2, caseD/2);
|
||||
scene.add(usbPort);
|
||||
|
||||
// ── Tripod Clip ──
|
||||
const clipGroup = new THREE.Group();
|
||||
clipGroup.position.set(0, 0, -caseD/2 - 0.7);
|
||||
|
||||
// Clip arms
|
||||
for (let y = -0.4; y <= 0.4; y += 0.8) {
|
||||
const arm = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.4, 0.08, 0.8),
|
||||
petgMat
|
||||
);
|
||||
arm.position.set(0, y, 0.3);
|
||||
arm.castShadow = true;
|
||||
clipGroup.add(arm);
|
||||
}
|
||||
|
||||
// Clip body
|
||||
const clipBody = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.4, 1.0, 0.15),
|
||||
petgMat
|
||||
);
|
||||
clipBody.position.set(0, 0, -0.1);
|
||||
clipBody.castShadow = true;
|
||||
clipGroup.add(clipBody);
|
||||
|
||||
scene.add(clipGroup);
|
||||
|
||||
// ── Tripod Pole ──
|
||||
const poleGeom = new THREE.CylinderGeometry(0.35, 0.35, 6, 24);
|
||||
const poleMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.4, metalness: 0.3 });
|
||||
const pole = new THREE.Mesh(poleGeom, poleMat);
|
||||
pole.position.set(0, 0, -caseD/2 - 1.2);
|
||||
pole.castShadow = true; pole.receiveShadow = true;
|
||||
scene.add(pole);
|
||||
|
||||
// ── USB Cables ──
|
||||
function createCable(start, end, color = 0x222233) {
|
||||
const curve = new THREE.CubicBezierCurve3(
|
||||
start,
|
||||
new THREE.Vector3(start.x + 0.5, start.y - 0.5, start.z + 0.2),
|
||||
new THREE.Vector3(end.x - 0.3, end.y - 0.3, end.z + 0.1),
|
||||
end
|
||||
);
|
||||
const geom = new THREE.TubeGeometry(curve, 20, 0.03, 8, false);
|
||||
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6 });
|
||||
return new THREE.Mesh(geom, mat);
|
||||
}
|
||||
|
||||
const cable1 = createCable(
|
||||
new THREE.Vector3(0, 0.2, caseD/2),
|
||||
new THREE.Vector3(-2, -1, 1)
|
||||
);
|
||||
cable1.castShadow = true;
|
||||
scene.add(cable1);
|
||||
|
||||
const cable2 = createCable(
|
||||
new THREE.Vector3(0.1, 0.2, caseD/2),
|
||||
new THREE.Vector3(2, -1.5, 1.2),
|
||||
0x332222
|
||||
);
|
||||
cable2.castShadow = true;
|
||||
scene.add(cable2);
|
||||
|
||||
// ── Interaction ──
|
||||
let isDragging = false, prevMouse = { x: 0, y: 0 };
|
||||
let rotY = 0.4, rotX = 0.3, zoom = 7;
|
||||
|
||||
document.addEventListener('mousedown', e => { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
|
||||
document.addEventListener('mouseup', () => isDragging = false);
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!isDragging) return;
|
||||
rotY += (e.clientX - prevMouse.x) * 0.005;
|
||||
rotX += (e.clientY - prevMouse.y) * 0.005;
|
||||
rotX = Math.max(-0.8, Math.min(1.2, rotX));
|
||||
prevMouse = { x: e.clientX, y: e.clientY };
|
||||
});
|
||||
document.addEventListener('wheel', e => {
|
||||
zoom += e.deltaY * 0.005;
|
||||
zoom = Math.max(3, Math.min(15, zoom));
|
||||
});
|
||||
|
||||
// ── Render loop ──
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
camera.position.x = zoom * Math.sin(rotY) * Math.cos(rotX);
|
||||
camera.position.y = zoom * Math.sin(rotX);
|
||||
camera.position.z = zoom * Math.cos(rotY) * Math.cos(rotX);
|
||||
camera.lookAt(0, -0.1, 0);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
// Resize
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+6
-31
@@ -7,15 +7,13 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Hub manages SSE client connections and event broadcasting.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
eventSeq atomic.Int64 // monotonic event ID for Last-Event-ID
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
}
|
||||
|
||||
// NewHub creates a new SSE hub.
|
||||
@@ -60,13 +58,6 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Last-Event-ID")
|
||||
|
||||
// Handle preflight
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Get flusher
|
||||
flusher, ok := w.(http.Flusher)
|
||||
@@ -94,21 +85,12 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||
client.Close()
|
||||
}()
|
||||
|
||||
// Acknowledge Last-Event-ID if sent by client on reconnect
|
||||
if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" {
|
||||
fmt.Fprintf(w, "id: %s\nevent: reconnected\ndata: {\"type\":\"reconnected\",\"last_event_id\":\"%s\"}\n\n", lastEventID, lastEventID)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
seq := h.eventSeq.Add(1)
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
data, _ := json.Marshal(map[string]string{
|
||||
"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)) {
|
||||
if !client.Write(data) {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
@@ -133,18 +115,13 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Broadcast sends an event to all connected clients.
|
||||
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,
|
||||
}
|
||||
@@ -155,10 +132,8 @@ func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
||||
return
|
||||
}
|
||||
|
||||
eventLine := fmt.Sprintf("id: %d\nevent: %s\ndata: %s\n\n", seq, eventType, string(data))
|
||||
|
||||
for client := range h.clients {
|
||||
if !client.Write([]byte(eventLine)) {
|
||||
if !client.Write(data) {
|
||||
log.Println("SSE client buffer full, dropping event")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/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 ""
|
||||
@@ -1,29 +0,0 @@
|
||||
[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
|
||||
@@ -1,336 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# RemoteRig — First-Time Raspberry Pi Zero 2 W Setup
|
||||
# Target: Debian/Raspberry Pi OS (bookworm)
|
||||
# Idempotent: safe to run multiple times
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./setup-pi.sh [--config PATH] [--service-user USER]
|
||||
#
|
||||
# Options:
|
||||
# --config PATH Path to config.yaml template to copy to /opt/remoterig/
|
||||
# --service-user USER Systemd service user (default: pi)
|
||||
# --static-ip IP Static IP for wlan0 (default: 10.60.1.56/24)
|
||||
# --gateway IP Gateway for wlan0 (default: 10.60.1.1)
|
||||
# --help Show this help
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_TEMPLATE=""
|
||||
SERVICE_USER="pi"
|
||||
STATIC_IP="10.60.1.56/24"
|
||||
GATEWAY="10.60.1.1"
|
||||
MOSQUITTO_PKG="mosquitto mosquitto-clients"
|
||||
DEPLOY_DIR="/opt/remoterig"
|
||||
SERVICE_NAME="remoterig"
|
||||
SERVICE_FILE="scripts/remoterig.service"
|
||||
MOSQUITTO_CONF="/etc/mosquitto/conf.d/remoterig.conf"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Help
|
||||
# ---------------------------------------------------------------------------
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# //'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse args
|
||||
# ---------------------------------------------------------------------------
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--config)
|
||||
CONFIG_TEMPLATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--service-user)
|
||||
SERVICE_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--static-ip)
|
||||
STATIC_IP="$2"
|
||||
shift 2
|
||||
;;
|
||||
--gateway)
|
||||
GATEWAY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown option: $1" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight checks
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: must run as root (sudo ./setup-pi.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info() { echo "[INFO] $*"; }
|
||||
ok() { echo "[OK] $*"; }
|
||||
skip() { echo "[SKIP] $*"; }
|
||||
warn() { echo "[WARN] $*" >&2; }
|
||||
|
||||
echo "=============================================="
|
||||
echo " RemoteRig Pi Zero 2 W Setup"
|
||||
echo " Target: ${STATIC_IP} via ${GATEWAY}"
|
||||
echo " Service user: ${SERVICE_USER}"
|
||||
echo "=============================================="
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Update package list (always safe)
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Updating package list..."
|
||||
apt-get update -qq
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Install Mosquitto MQTT broker + clients
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing Mosquitto..."
|
||||
if dpkg -l mosquitto mosquitto-clients >/dev/null 2>&1; then
|
||||
# Already installed — ensure latest
|
||||
apt-get install -y -qq ${MOSQUITTO_PKG} 2>/dev/null && \
|
||||
ok "Mosquitto packages up to date" || \
|
||||
warn "Mosquitto package update had warnings (non-fatal)"
|
||||
else
|
||||
apt-get install -y -qq ${MOSQUITTO_PKG}
|
||||
ok "Mosquitto installed"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Configure Mosquitto — anonymous on localhost, listener on 0.0.0.0:1883
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Configuring Mosquitto..."
|
||||
mkdir -p /etc/mosquitto/conf.d
|
||||
|
||||
# Write idempotent config
|
||||
cat > "${MOSQUITTO_CONF}" <<'MQTTEOF'
|
||||
# RemoteRig Mosquitto configuration
|
||||
# Closed travel-router LAN — anonymous access is intentional
|
||||
|
||||
# Listen on all interfaces (LAN + localhost)
|
||||
listener 1883 0.0.0.0
|
||||
|
||||
# No authentication (closed network, no internet access)
|
||||
allow_anonymous true
|
||||
MQTTEOF
|
||||
|
||||
ok "Mosquitto config written: ${MOSQUITTO_CONF}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Create /opt/remoterig directory
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Creating deploy directory..."
|
||||
if [ -d "${DEPLOY_DIR}" ]; then
|
||||
skip "${DEPLOY_DIR} already exists"
|
||||
else
|
||||
mkdir -p "${DEPLOY_DIR}"
|
||||
ok "Created ${DEPLOY_DIR}"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Copy config.yaml template (if provided)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -n "${CONFIG_TEMPLATE}" ] && [ -f "${CONFIG_TEMPLATE}" ]; then
|
||||
info "Copying config.yaml template..."
|
||||
if [ -f "${DEPLOY_DIR}/config.yaml" ]; then
|
||||
skip "${DEPLOY_DIR}/config.yaml already exists (not overwriting)"
|
||||
else
|
||||
cp "${CONFIG_TEMPLATE}" "${DEPLOY_DIR}/config.yaml"
|
||||
ok "Copied config.yaml to ${DEPLOY_DIR}/config.yaml"
|
||||
fi
|
||||
elif [ -n "${CONFIG_TEMPLATE}" ]; then
|
||||
warn "Config template '${CONFIG_TEMPLATE}' not found — skipping"
|
||||
else
|
||||
info "No config template provided — skipping"
|
||||
fi
|
||||
|
||||
# Ensure service user owns the deploy directory
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DEPLOY_DIR}" 2>/dev/null || \
|
||||
warn "Could not chown ${DEPLOY_DIR} to ${SERVICE_USER} (user may not exist yet)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Install and enable systemd service
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing systemd service..."
|
||||
|
||||
# Locate the service file relative to this script's directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SRC_SERVICE="${SCRIPT_DIR}/remoterig.service"
|
||||
|
||||
if [ ! -f "${SRC_SERVICE}" ]; then
|
||||
warn "Service file not found at ${SRC_SERVICE} — skipping service install"
|
||||
warn "Run this script from the repository root (scripts/setup-pi.sh)"
|
||||
else
|
||||
DST_SERVICE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
# Copy if different
|
||||
if [ -f "${DST_SERVICE}" ]; then
|
||||
if cmp -s "${SRC_SERVICE}" "${DST_SERVICE}"; then
|
||||
skip "systemd service already installed and up to date"
|
||||
else
|
||||
cp "${SRC_SERVICE}" "${DST_SERVICE}"
|
||||
ok "systemd service updated"
|
||||
RELOAD_SYSTEMD=1
|
||||
fi
|
||||
else
|
||||
cp "${SRC_SERVICE}" "${DST_SERVICE}"
|
||||
ok "systemd service installed"
|
||||
RELOAD_SYSTEMD=1
|
||||
fi
|
||||
|
||||
# Substitute the service user
|
||||
sed -i "s/^User=.*/User=${SERVICE_USER}/" "${DST_SERVICE}"
|
||||
|
||||
if [ "${RELOAD_SYSTEMD:-0}" -eq 1 ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
|
||||
# Enable (idempotent)
|
||||
if systemctl is-enabled --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
||||
skip "systemd service already enabled"
|
||||
else
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
ok "systemd service enabled"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Set static IP on wlan0
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Configuring static IP on wlan0..."
|
||||
|
||||
# Check if wlan0 exists
|
||||
if ! ip link show wlan0 >/dev/null 2>&1; then
|
||||
warn "wlan0 interface not found — skipping static IP configuration"
|
||||
warn "Connect Wi-Fi first (raspi-config), then re-run this script"
|
||||
else
|
||||
STATIC_IP_SET=0
|
||||
|
||||
# --- Method A: NetworkManager (default on bookworm) ---
|
||||
if command -v nmcli >/dev/null 2>&1; then
|
||||
info "Using NetworkManager (nmcli)..."
|
||||
|
||||
# Find the Wi-Fi connection profile
|
||||
WIFI_CON=$(nmcli -t -f NAME,TYPE con show 2>/dev/null | grep ':802-11-wireless' | cut -d: -f1 | head -1)
|
||||
|
||||
if [ -n "${WIFI_CON}" ]; then
|
||||
CURRENT_IP=$(nmcli -t -f IP4.ADDRESS con show "${WIFI_CON}" 2>/dev/null | cut -d: -f2 | head -1 || true)
|
||||
|
||||
if [ "${CURRENT_IP}" = "${STATIC_IP}" ]; then
|
||||
skip "wlan0 already set to ${STATIC_IP} via nmcli"
|
||||
STATIC_IP_SET=1
|
||||
else
|
||||
nmcli con mod "${WIFI_CON}" ipv4.addresses "${STATIC_IP}"
|
||||
nmcli con mod "${WIFI_CON}" ipv4.gateway "${GATEWAY}"
|
||||
nmcli con mod "${WIFI_CON}" ipv4.dns "${GATEWAY}"
|
||||
nmcli con mod "${WIFI_CON}" ipv4.method manual
|
||||
nmcli con up "${WIFI_CON}" 2>/dev/null || true
|
||||
ok "wlan0 set to ${STATIC_IP} via nmcli (connection: ${WIFI_CON})"
|
||||
STATIC_IP_SET=1
|
||||
fi
|
||||
else
|
||||
warn "No Wi-Fi connection profile found in NetworkManager"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Method B: dhcpcd (fallback for older PiOS) ---
|
||||
if [ ${STATIC_IP_SET} -eq 0 ] && command -v dhcpcd >/dev/null 2>&1; then
|
||||
info "Using dhcpcd..."
|
||||
|
||||
DHCPCD_CONF="/etc/dhcpcd.conf"
|
||||
|
||||
if grep -q "interface wlan0" "${DHCPCD_CONF}" 2>/dev/null; then
|
||||
skip "dhcpcd already has wlan0 config"
|
||||
else
|
||||
cat >> "${DHCPCD_CONF}" <<DHCPCDEOF
|
||||
|
||||
# RemoteRig static IP
|
||||
interface wlan0
|
||||
static ip_address=${STATIC_IP}
|
||||
static routers=${GATEWAY}
|
||||
static domain_name_servers=${GATEWAY}
|
||||
DHCPCDEOF
|
||||
ok "dhcpcd configured for wlan0 static IP"
|
||||
fi
|
||||
STATIC_IP_SET=1
|
||||
fi
|
||||
|
||||
# --- Method C: /etc/network/interfaces (last resort) ---
|
||||
if [ ${STATIC_IP_SET} -eq 0 ]; then
|
||||
warn "Neither nmcli nor dhcpcd found — attempting /etc/network/interfaces"
|
||||
|
||||
INTERFACES_FILE="/etc/network/interfaces"
|
||||
if ! grep -q "iface wlan0 inet static" "${INTERFACES_FILE}" 2>/dev/null; then
|
||||
cat >> "${INTERFACES_FILE}" <<NETEOF
|
||||
|
||||
# RemoteRig static IP
|
||||
auto wlan0
|
||||
iface wlan0 inet static
|
||||
address ${STATIC_IP%/*}
|
||||
netmask 255.255.255.0
|
||||
gateway ${GATEWAY}
|
||||
NETEOF
|
||||
ok "wlan0 static IP configured in ${INTERFACES_FILE}"
|
||||
else
|
||||
skip "${INTERFACES_FILE} already has static wlan0 config"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Enable and start Mosquitto
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Enabling and starting Mosquitto..."
|
||||
|
||||
if systemctl is-active --quiet mosquitto 2>/dev/null; then
|
||||
skip "Mosquitto already running"
|
||||
else
|
||||
systemctl enable mosquitto 2>/dev/null || true
|
||||
systemctl restart mosquitto
|
||||
ok "Mosquitto started"
|
||||
fi
|
||||
|
||||
# Verify Mosquitto is listening
|
||||
sleep 1
|
||||
if systemctl is-active --quiet mosquitto 2>/dev/null; then
|
||||
ok "Mosquitto is running and listening on :1883"
|
||||
else
|
||||
warn "Mosquitto may not have started — check: sudo systemctl status mosquitto"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo " Setup complete!"
|
||||
echo "=============================================="
|
||||
echo " Mosquitto: $(systemctl is-active mosquitto 2>/dev/null || echo 'unknown')"
|
||||
echo " Service: ${SERVICE_NAME} (systemctl status ${SERVICE_NAME})"
|
||||
echo " Deploy dir: ${DEPLOY_DIR}"
|
||||
echo " Static IP: ${STATIC_IP} on wlan0"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Build the remoterig binary for ARM64:"
|
||||
echo " GOOS=linux GOARCH=arm64 go build -o remoterig ./cmd/server"
|
||||
echo " 2. Copy binary to Pi:"
|
||||
echo " scp remoterig pi@10.60.1.56:/opt/remoterig/"
|
||||
echo " 3. Copy config if needed:"
|
||||
echo " scp config.yaml pi@10.60.1.56:/opt/remoterig/"
|
||||
echo " 4. Start the service:"
|
||||
echo " sudo systemctl start remoterig"
|
||||
echo " 5. Check health:"
|
||||
echo " curl http://10.60.1.56:8080/health"
|
||||
echo ""
|
||||
echo " To deploy updates, use: scripts/deploy.sh"
|
||||
echo "=============================================="
|
||||
Vendored
-42
@@ -1,42 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RemoteRig - 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>
|
||||
Reference in New Issue
Block a user