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:
|
env:
|
||||||
GO_VERSION: "1.23"
|
GO_VERSION: "1.23"
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
BINARY_NAME: remoterig
|
BINARY_NAME: openclaw
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -29,13 +29,20 @@ jobs:
|
|||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Build React frontend
|
- name: Build React frontend
|
||||||
|
working-directory: web
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Build Go binary (ARM64 cross-compile)
|
- name: Embed frontend into Go binary
|
||||||
run: |
|
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}" \
|
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
|
||||||
-o ${{ env.BINARY_NAME }} ./cmd/server
|
-o ${{ env.BINARY_NAME }} ./cmd/server
|
||||||
|
|
||||||
|
|||||||
+10
-16
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-typecheck:
|
lint-and-typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: go-react
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
needs: lint-and-typecheck
|
needs: lint-and-typecheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: go-react
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: go-react
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -42,24 +42,18 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- name: Upload build artifacts
|
- name: Verify build output
|
||||||
uses: actions/upload-artifact@v4
|
run: |
|
||||||
with:
|
du -sh dist/
|
||||||
name: dist
|
echo "Build successful"
|
||||||
path: dist/
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: go-react
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
environment: production
|
environment: production
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- name: Deploy placeholder
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist/
|
|
||||||
- name: Deploy static files
|
|
||||||
run: |
|
run: |
|
||||||
echo "Deploying to production..."
|
|
||||||
echo "Deploy target: /var/www/remote-rig/"
|
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:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BINARY_NAME: remoterig
|
BINARY_NAME: openclaw
|
||||||
DEV_HOST: ${{ secrets.DEV_HOST }}
|
DEV_HOST: ${{ secrets.DEV_HOST }}
|
||||||
DEV_USER: ${{ secrets.DEV_USER }}
|
DEV_USER: ${{ secrets.DEV_USER }}
|
||||||
DEPLOY_PATH: /opt/remoterig/remoterig
|
DEPLOY_PATH: /opt/openclaw/openclaw
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
@@ -32,9 +32,9 @@ jobs:
|
|||||||
cat > deploy.sh <<'SCRIPT'
|
cat > deploy.sh <<'SCRIPT'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BINARY="${1:-remoterig}"
|
BINARY="${1:-openclaw}"
|
||||||
DEPLOY_PATH="${2:-/opt/remoterig/remoterig}"
|
DEPLOY_PATH="${2:-/opt/openclaw/openclaw}"
|
||||||
SERVICE="${3:-remoterig}"
|
SERVICE="${3:-openclaw}"
|
||||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||||
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
|
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
|
||||||
|
|
||||||
@@ -69,23 +69,14 @@ jobs:
|
|||||||
SCRIPT
|
SCRIPT
|
||||||
chmod +x deploy.sh
|
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
|
- name: Deploy to dev server
|
||||||
uses: appleboy/scp-action@v0.1.7
|
uses: appleboy/scp-action@v0.1.7
|
||||||
with:
|
with:
|
||||||
host: ${{ env.DEV_HOST }}
|
host: ${{ env.DEV_HOST }}
|
||||||
username: ${{ env.DEV_USER }}
|
username: ${{ env.DEV_USER }}
|
||||||
key: ${{ secrets.DEV_SSH_KEY }}
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
source: "${{ env.BINARY_NAME }},deploy.sh,config.yaml"
|
source: "${{ env.BINARY_NAME }},deploy.sh"
|
||||||
target: "/tmp/remoterig-deploy"
|
target: "/tmp/openclaw-deploy"
|
||||||
|
|
||||||
- name: Execute deploy on dev server
|
- name: Execute deploy on dev server
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
@@ -95,14 +86,9 @@ jobs:
|
|||||||
key: ${{ secrets.DEV_SSH_KEY }}
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd /tmp/remoterig-deploy
|
cd /tmp/openclaw-deploy
|
||||||
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "remoterig"
|
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "openclaw"
|
||||||
if [ -f config.yaml ]; then
|
rm -rf /tmp/openclaw-deploy
|
||||||
echo "::config:: deploying config.yaml"
|
|
||||||
sudo mkdir -p "$(dirname "${{ env.DEPLOY_PATH }}")"
|
|
||||||
sudo cp config.yaml "$(dirname "${{ env.DEPLOY_PATH }}")/config.yaml"
|
|
||||||
fi
|
|
||||||
rm -rf /tmp/remoterig-deploy
|
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -112,4 +98,4 @@ jobs:
|
|||||||
username: ${{ env.DEV_USER }}
|
username: ${{ env.DEV_USER }}
|
||||||
key: ${{ secrets.DEV_SSH_KEY }}
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
script: |
|
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
|
dist-ssr
|
||||||
*.local
|
*.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
|
# Environment files
|
||||||
.env
|
.env
|
||||||
.env.local
|
.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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,16 +15,12 @@ import (
|
|||||||
"github.com/cubecraft/remoterig/internal/auth"
|
"github.com/cubecraft/remoterig/internal/auth"
|
||||||
"github.com/cubecraft/remoterig/internal/db"
|
"github.com/cubecraft/remoterig/internal/db"
|
||||||
"github.com/cubecraft/remoterig/internal/events"
|
"github.com/cubecraft/remoterig/internal/events"
|
||||||
"github.com/cubecraft/remoterig/internal/mqtt"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:src/dist
|
|
||||||
var frontendFS embed.FS
|
|
||||||
|
|
||||||
// Config holds the application configuration.
|
// Config holds the application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DBPath string `yaml:"db_path"`
|
DBPath string `yaml:"db_path"`
|
||||||
@@ -65,13 +59,6 @@ func main() {
|
|||||||
// Create SSE hub for real-time updates
|
// Create SSE hub for real-time updates
|
||||||
sseHub := events.NewHub()
|
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
|
// Set up router
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
@@ -89,9 +76,6 @@ func main() {
|
|||||||
// API routes (auth required if API key is configured)
|
// API routes (auth required if API key is configured)
|
||||||
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
|
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
|
||||||
|
|
||||||
// Serve embedded React frontend with SPA fallback
|
|
||||||
r.Mount("/", frontendHandler())
|
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
@@ -167,36 +151,3 @@ func loadConfig(path string) (*Config, error) {
|
|||||||
|
|
||||||
return &cfg, nil
|
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: 192.168.4.0/24 │
|
|
||||||
│ DHCP pool: .100-.200 │
|
|
||||||
└──────┬──────────┬──────────┬──────────────┘
|
|
||||||
│ │ │
|
|
||||||
┌───────────────┘ │ └───────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
|
||||||
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
|
|
||||||
│ DHCP addr │ │ DHCP addr │ │ 192.168.4.10 │
|
|
||||||
│ │ │ │ │ (static IP) │
|
|
||||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │ │
|
|
||||||
│ STA→Router │ │ STA→Router │ │ Mosquitto :1883 │
|
|
||||||
│ │ │ │ │ Go API :8080 │
|
|
||||||
│ MQTT→:1883 │ │ MQTT→:1883 │ │ React UI │
|
|
||||||
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
|
|
||||||
│ │ └──────────────────┘
|
|
||||||
▼ ▼ │
|
|
||||||
┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │ SSE /api/v1/events/stream
|
|
||||||
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │ │
|
|
||||||
│ Wi-Fi only │ │ Wi-Fi only │ ▼
|
|
||||||
└──────────────┘ └──────────────┘ ┌──────────────────┐
|
|
||||||
│ User Device │
|
|
||||||
│ (laptop/kiosk) │
|
|
||||||
│ http://.4.10 │
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP32s bridge the GoPro's AP to the LAN via MQTT.
|
|
||||||
|
|
||||||
### Key Architecture Decisions (revised)
|
|
||||||
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `192.168.4.0/24`.
|
|
||||||
- **ESP32 dual-STA** — One STA to GoPro AP (10.5.5.1), one STA to travel router. No channel-hopping concerns on closed network.
|
|
||||||
- **ESP32 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable.
|
|
||||||
- **MQTT for ESP32 → Hub** — Lightweight, designed for IoT. Mosquitto on Pi. QoS 1 for status, QoS 2 for commands. Full contract: [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
|
|
||||||
- **SQLite over PostgreSQL** — Single-node Pi Zero 2 W deployment. WAL mode for concurrent read/write.
|
|
||||||
- **SSE over WebSocket** — Unidirectional hub → browser updates. Simpler, sufficient for status dashboard.
|
|
||||||
- **Chi router** — Lightweight Go HTTP router with middleware support.
|
|
||||||
- **Zustand over Redux** — Minimal boilerplate for camera status store.
|
|
||||||
- **API key auth** — Simple bearer token; closed LAN, not internet-facing.
|
|
||||||
- **Camera auto-discovery** — ESP32 publishes `announce` message on first MQTT connect. Hub auto-registers by MAC, assigns sequential `cam-NNN` ID.
|
|
||||||
|
|
||||||
## Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
remote-rig/
|
|
||||||
├── cmd/server/main.go # Entry point — config load, router setup, graceful shutdown
|
|
||||||
├── config.yaml # Runtime configuration
|
|
||||||
├── go.mod / go.sum # Go dependencies
|
|
||||||
├── internal/
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── api.go # Package doc
|
|
||||||
│ │ ├── cameras.go # GET /cameras, POST /cameras, GET /cameras/:id
|
|
||||||
│ │ ├── recording.go # POST /cameras/:id/start, POST /cameras/:id/stop
|
|
||||||
│ │ └── status.go # POST /cameras/:id/status (push from ESP32)
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ └── middleware.go # API key auth middleware
|
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── db.go # Open, migrations, WAL mode
|
|
||||||
│ │ └── migrations/
|
|
||||||
│ │ └── 001_create_tables.sql
|
|
||||||
│ └── events/
|
|
||||||
│ └── sse.go # SSE hub (subscribe, broadcast)
|
|
||||||
├── pkg/models/
|
|
||||||
│ └── camera.go # Camera, StatusLog, RecordingEvent, CameraStatus, Settings
|
|
||||||
├── src/ # React frontend
|
|
||||||
│ ├── App.tsx # Main app — header, camera grid, footer
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── CameraCard.tsx # Single camera status card
|
|
||||||
│ │ ├── CameraCard.test.tsx # Unit tests
|
|
||||||
│ │ └── index.ts
|
|
||||||
│ ├── hooks/
|
|
||||||
│ │ ├── useSSE.ts # SSE connection hook
|
|
||||||
│ │ ├── useCameraStatus.ts # Camera status hook
|
|
||||||
│ │ ├── useSystemHealth.ts # System health hook
|
|
||||||
│ │ └── index.ts
|
|
||||||
│ ├── services/
|
|
||||||
│ │ └── api.ts # API client
|
|
||||||
│ ├── store/
|
|
||||||
│ │ ├── useCameraStore.ts # Zustand store
|
|
||||||
│ │ └── index.ts
|
|
||||||
│ ├── types/
|
|
||||||
│ │ └── index.ts # TypeScript interfaces
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ └── index.ts
|
|
||||||
│ └── main.tsx
|
|
||||||
├── docs/
|
|
||||||
│ ├── CONTEXT.md # ← this file
|
|
||||||
│ └── plans/
|
|
||||||
│ └── 2026-05-21-cub-196-cameracard.md # CameraCard implementation plan
|
|
||||||
├── .gitea/workflows/
|
|
||||||
│ ├── ci.yaml # PR CI: lint → typecheck → test → build
|
|
||||||
│ ├── build-dev.yaml # Go binary build on dev push
|
|
||||||
│ └── deploy-dev.yaml # SCP + SSH deploy with rollback
|
|
||||||
├── .env.example # VITE_API_URL=http://localhost:8080/api
|
|
||||||
├── package.json
|
|
||||||
├── vite.config.ts
|
|
||||||
├── tailwind.config.js
|
|
||||||
└── tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema (SQLite)
|
|
||||||
|
|
||||||
### cameras
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| camera_id | TEXT PK | Unique camera identifier |
|
|
||||||
| friendly_name | TEXT NOT NULL | Human-readable name |
|
|
||||||
| mac_address | TEXT UNIQUE | MAC address (optional) |
|
|
||||||
| created_at | DATETIME | Default now |
|
|
||||||
| updated_at | DATETIME | Default now |
|
|
||||||
|
|
||||||
### status_logs
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| id | INTEGER PK AUTO | |
|
|
||||||
| camera_id | TEXT FK → cameras | |
|
|
||||||
| recorded_at | DATETIME | Default now |
|
|
||||||
| battery_pct | INTEGER | Nullable |
|
|
||||||
| video_remaining_sec | INTEGER | Nullable |
|
|
||||||
| recording_state | INTEGER | 0=idle, 1=recording |
|
|
||||||
| mode | TEXT | e.g. "video" |
|
|
||||||
| resolution | TEXT | e.g. "1080p" |
|
|
||||||
| fps | INTEGER | |
|
|
||||||
| online | INTEGER | 0=offline, 1=online |
|
|
||||||
| raw_battery_pct | REAL | Float precision |
|
|
||||||
|
|
||||||
Index: `(camera_id, recorded_at DESC)`
|
|
||||||
|
|
||||||
### recording_events
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| id | INTEGER PK AUTO | |
|
|
||||||
| camera_id | TEXT FK → cameras | |
|
|
||||||
| started_at | DATETIME NOT NULL | |
|
|
||||||
| stopped_at | DATETIME | Null while recording |
|
|
||||||
| reason | TEXT | e.g. "manual" |
|
|
||||||
| duration | INTEGER | Seconds |
|
|
||||||
|
|
||||||
Index: `(camera_id, started_at DESC)`
|
|
||||||
|
|
||||||
### settings
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| key | TEXT PK | |
|
|
||||||
| value | TEXT NOT NULL | |
|
|
||||||
| updated_at | DATETIME | Default now |
|
|
||||||
|
|
||||||
**Default seeds:** `poll_interval_sec=30`, `low_battery_threshold=15`, `low_storage_alert_sec=300`
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| GET | /health | No | Health check → `{"status":"ok"}` |
|
|
||||||
| GET | /api/v1/cameras | Yes | List all cameras with latest status |
|
|
||||||
| POST | /api/v1/cameras | Yes | Register a new camera |
|
|
||||||
| GET | /api/v1/cameras/:id | Yes | Camera detail + latest status + 24h history |
|
|
||||||
| POST | /api/v1/cameras/:id/start | Yes | Start recording + MQTT publish |
|
|
||||||
| POST | /api/v1/cameras/:id/stop | Yes | Stop recording |
|
|
||||||
| POST | /api/v1/cameras/:id/status | Yes | Push status from ESP32 node |
|
|
||||||
| GET | /api/v1/events/stream | No (SSE) | Real-time camera status stream |
|
|
||||||
|
|
||||||
## Configuration (`config.yaml`)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
db_path: "remoterig.db" # SQLite database path
|
|
||||||
api_key: "changeme" # Bearer token for API auth
|
|
||||||
port: 8080 # HTTP listen port
|
|
||||||
read_timeout: 5s
|
|
||||||
write_timeout: 10s
|
|
||||||
idle_timeout: 120s
|
|
||||||
mqtt:
|
|
||||||
broker: "localhost:1883" # Mosquitto on Pi Zero 2 W
|
|
||||||
client_id: "remoterig-hub"
|
|
||||||
platform:
|
|
||||||
type: "pi-zero-2w"
|
|
||||||
max_cameras: 16
|
|
||||||
network:
|
|
||||||
subnet: "192.168.4.0/24" # Travel router subnet
|
|
||||||
hub_ip: "192.168.4.10" # Pi Zero 2 W static IP
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Component Tree
|
|
||||||
|
|
||||||
```
|
|
||||||
App
|
|
||||||
├── Header
|
|
||||||
│ ├── Logo + Title ("RemoteRig Dashboard")
|
|
||||||
│ └── Stats bar (online count, recording count)
|
|
||||||
├── CameraGrid
|
|
||||||
│ └── CameraCard[] (responsive: 1→2→3→4 columns)
|
|
||||||
│ ├── Camera name + online/offline badge
|
|
||||||
│ ├── Resolution + FPS display
|
|
||||||
│ ├── Recording indicator (pulsing dot + REC/IDLE badge)
|
|
||||||
│ ├── Battery bar (color-coded: green/yellow/red)
|
|
||||||
│ └── Footer (Live/Last seen + video remaining)
|
|
||||||
└── Footer
|
|
||||||
└── "RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Empty state:** "Waiting for cameras..." with pulsing radio icon when no cameras connected.
|
|
||||||
**Offline state:** Camera card dimmed with dashed border, shows "Last seen Xm ago".
|
|
||||||
|
|
||||||
## Color Palette (Tailwind — dark dashboard theme)
|
|
||||||
|
|
||||||
Custom theme in `tailwind.config.js`:
|
|
||||||
- `rig-dark-900` (background), `rig-dark-800`, `rig-dark-700` (cards), etc.
|
|
||||||
- `rig-accent` (accent color)
|
|
||||||
- `rig-success` (green — battery ≥50%, online)
|
|
||||||
- `rig-warning` (yellow — battery 15-49%)
|
|
||||||
- `rig-danger` (red — battery <15%, offline, recording)
|
|
||||||
|
|
||||||
## Linear Issue Map
|
|
||||||
|
|
||||||
**Last synced:** 2026-05-21 (evening)
|
|
||||||
|
|
||||||
| CUB | Title | Status | Agent |
|
|
||||||
|-----|-------|--------|-------|
|
|
||||||
| 198 | **Epic: Multi-camera remote monitoring system** | Backlog | — |
|
|
||||||
| 238 | Define MQTT message format contract | ✅ Done | Dex |
|
|
||||||
| 228 | Add battery_calibration_offset to cameras table | Backlog | Hex |
|
|
||||||
| 230 | ESP32 offline status buffering and replay | Backlog | Pip |
|
|
||||||
| 232 | Implement MQTT subscriber in Go hub | Backlog | Dex |
|
|
||||||
| 229 | Design camera auto-discovery and registration flow | Backlog | Dex |
|
|
||||||
| 231 | Mosquitto MQTT broker setup on Pi Zero 2 W | Backlog | Dex |
|
|
||||||
| 233 | Verify and harden SSE endpoint | Backlog | Dex |
|
|
||||||
| 234 | Verify and harden all camera API endpoints | Backlog | Dex |
|
|
||||||
| 235 | Implement GET /api/v1/cameras/:id with 24h history | Backlog | Dex |
|
|
||||||
| 236 | Implement POST /api/v1/cameras registration | Backlog | Dex |
|
|
||||||
| 237 | Update CONTEXT.md to actual state | Backlog | Otto |
|
|
||||||
| — | — | — | — |
|
|
||||||
| 173 | Confirm GoPro Hero 3 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,273 +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 (192.168.4.1) │
|
|
||||||
│ DHCP: .100-.200 │
|
|
||||||
└──────┬──────────┬──────────┬──────┘
|
|
||||||
│ │ │
|
|
||||||
┌───────────────┘ │ └───────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
||||||
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
|
|
||||||
│ 192.168.4.101│ │ 192.168.4.102│ │ 192.168.4.10 │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │ Mosquitto │
|
|
||||||
│ STA→Router │ │ STA→Router │ │ Go backend │
|
|
||||||
└──────┬───────┘ └──────┬───────┘ │ React UI │
|
|
||||||
│ │ └──────────────┘
|
|
||||||
▼ ▼
|
|
||||||
┌──────────────┐ ┌──────────────┐
|
|
||||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │
|
|
||||||
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │
|
|
||||||
└──────────────┘ └──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Travel router:** Self-contained, no internet. DHCP pool: `192.168.4.100-200`
|
|
||||||
- **Pi Zero 2 W:** Static IP `192.168.4.10`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
|
|
||||||
- **ESP32s:** DHCP from router. Each has dual STA: one to GoPro AP, one to router
|
|
||||||
- **User device:** Connects to router, opens `http://192.168.4.10:8080` for dashboard
|
|
||||||
|
|
||||||
## MQTT Broker
|
|
||||||
|
|
||||||
- **Host:** `192.168.4.10` (Pi Zero 2 W)
|
|
||||||
- **Port:** `1883` (default MQTT, no TLS — closed network)
|
|
||||||
- **Auth:** None (closed network, no external access)
|
|
||||||
- **Client ID format:** `remoterig-<esp32_mac_last6>` (e.g., `remoterig-a1b2c3`)
|
|
||||||
- **QoS:** 1 (at least once) for status/heartbeat. 2 (exactly once) for commands.
|
|
||||||
- **Retain:** Status messages use `retain: true` so new subscribers get latest state immediately
|
|
||||||
|
|
||||||
## Topic Hierarchy
|
|
||||||
|
|
||||||
```
|
|
||||||
remoterig/
|
|
||||||
├── cameras/
|
|
||||||
│ └── <camera_id>/
|
|
||||||
│ ├── status ← ESP32 publishes (retained, QoS 1)
|
|
||||||
│ ├── heartbeat ← ESP32 publishes (QoS 1, not retained)
|
|
||||||
│ ├── command → Hub publishes (QoS 2)
|
|
||||||
│ └── announce ← ESP32 publishes on first boot (QoS 2, retained)
|
|
||||||
└── hub/
|
|
||||||
└── status ← Hub publishes (retained, QoS 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Topic: `remoterig/cameras/<camera_id>/status`
|
|
||||||
|
|
||||||
**Direction:** ESP32 → Hub
|
|
||||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
|
||||||
|
|
||||||
Published by the ESP32 every 30s with the latest GoPro status.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"camera_id": "cam-001",
|
|
||||||
"timestamp": "2026-05-21T18:30:00Z",
|
|
||||||
"battery_pct": 85,
|
|
||||||
"battery_raw": 217,
|
|
||||||
"video_remaining_sec": 3420,
|
|
||||||
"recording": true,
|
|
||||||
"mode": "video",
|
|
||||||
"resolution": "1080p",
|
|
||||||
"fps": 30,
|
|
||||||
"online": true,
|
|
||||||
"rssi": -52,
|
|
||||||
"uptime_sec": 1247
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `camera_id` | string | ✅ | Unique camera identifier (set during registration) |
|
|
||||||
| `timestamp` | ISO 8601 | ✅ | ESP32 clock time when status was read |
|
|
||||||
| `battery_pct` | int 0-100 | ✅ | Calibrated battery percentage (null if uncalibrated → omit) |
|
|
||||||
| `battery_raw` | int 0-255 | — | Raw byte from GoPro status offset 57 |
|
|
||||||
| `video_remaining_sec` | int | — | Estimated remaining recording seconds (null if unavailable) |
|
|
||||||
| `recording` | bool | ✅ | Whether camera is currently recording |
|
|
||||||
| `mode` | string | — | Current mode (e.g., "video", "photo", "burst") |
|
|
||||||
| `resolution` | string | — | Current resolution string |
|
|
||||||
| `fps` | int | — | Current frames per second |
|
|
||||||
| `online` | bool | ✅ | ESP32 can reach the GoPro (false if GoPro AP unreachable) |
|
|
||||||
| `rssi` | int | — | Wi-Fi RSSI to GoPro AP (dBm, negative) |
|
|
||||||
| `uptime_sec` | int | — | ESP32 uptime in seconds |
|
|
||||||
|
|
||||||
### Topic: `remoterig/cameras/<camera_id>/heartbeat`
|
|
||||||
|
|
||||||
**Direction:** ESP32 → Hub
|
|
||||||
**QoS:** 1 | **Retain:** false | **Interval:** 60 seconds
|
|
||||||
|
|
||||||
Lightweight keepalive so the hub can detect dead ESP32s.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"camera_id": "cam-001",
|
|
||||||
"timestamp": "2026-05-21T18:31:00Z",
|
|
||||||
"uptime_sec": 1307,
|
|
||||||
"free_heap": 28672
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `camera_id` | string | Camera identifier |
|
|
||||||
| `timestamp` | ISO 8601 | Current ESP32 time |
|
|
||||||
| `uptime_sec` | int | ESP32 uptime |
|
|
||||||
| `free_heap` | int | Free heap in bytes (diagnostic) |
|
|
||||||
|
|
||||||
**Hub behavior:** If no heartbeat for 120 seconds, mark camera as offline (`online: false` in SSE broadcast).
|
|
||||||
|
|
||||||
### Topic: `remoterig/cameras/<camera_id>/command`
|
|
||||||
|
|
||||||
**Direction:** Hub → ESP32
|
|
||||||
**QoS:** 2 | **Retain:** false
|
|
||||||
|
|
||||||
Commands sent from the dashboard to individual cameras.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"command": "start_recording",
|
|
||||||
"request_id": "req-abc123",
|
|
||||||
"timestamp": "2026-05-21T18:32:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Supported commands:**
|
|
||||||
|
|
||||||
| `command` | Description | Response topic |
|
|
||||||
|-----------|-------------|----------------|
|
|
||||||
| `start_recording` | Start GoPro recording | status (updated on next poll) |
|
|
||||||
| `stop_recording` | Stop GoPro recording | status (updated on next poll) |
|
|
||||||
| `reboot` | Reboot the ESP32 | — (ESP32 reconnects after boot) |
|
|
||||||
|
|
||||||
**ESP32 behavior:**
|
|
||||||
- On receipt, execute command against GoPro
|
|
||||||
- Next status publish will reflect the new state
|
|
||||||
- If command fails (GoPro unreachable), publish status with `online: false`
|
|
||||||
|
|
||||||
### Topic: `remoterig/cameras/<camera_id>/announce`
|
|
||||||
|
|
||||||
**Direction:** ESP32 → Hub
|
|
||||||
**QoS:** 2 | **Retain:** true
|
|
||||||
|
|
||||||
Published once on ESP32 first boot (or factory reset). Used for auto-registration.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mac_address": "AA:BB:CC:DD:EE:FF",
|
|
||||||
"firmware_version": "0.1.0",
|
|
||||||
"capabilities": ["start_stop", "status"],
|
|
||||||
"friendly_name": "ESP32-AA-BB-CC"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `mac_address` | string | ESP32 Wi-Fi MAC address |
|
|
||||||
| `firmware_version` | string | ESP32 firmware semver |
|
|
||||||
| `capabilities` | string[] | Supported features |
|
|
||||||
| `friendly_name` | string | Default human-readable name |
|
|
||||||
|
|
||||||
**Hub behavior on first announce:**
|
|
||||||
1. Check if MAC already registered → if yes, update `friendly_name` and log
|
|
||||||
2. If new MAC → create camera with auto-generated `camera_id = "cam-<NNN>"` (zero-padded sequential)
|
|
||||||
3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
|
|
||||||
4. Broadcast via SSE that a new camera appeared
|
|
||||||
|
|
||||||
### Topic: `remoterig/hub/status`
|
|
||||||
|
|
||||||
**Direction:** Hub → All
|
|
||||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
|
||||||
|
|
||||||
Hub health status broadcast.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"uptime_sec": 86400,
|
|
||||||
"cameras_online": 3,
|
|
||||||
"cameras_total": 4,
|
|
||||||
"mqtt_connected": true,
|
|
||||||
"db_size_bytes": 1048576
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Message Validation Rules
|
|
||||||
|
|
||||||
### Hub-side (incoming from ESP32)
|
|
||||||
|
|
||||||
1. **Required fields:** `camera_id` and `timestamp` must be present in all messages
|
|
||||||
2. **Timestamp sanity:** Reject if timestamp is > 5 minutes in the future or > 24 hours in the past
|
|
||||||
3. **Duplicate detection:** Status messages with same `(camera_id, timestamp)` are ignored (idempotent)
|
|
||||||
4. **Schema validation:** Unknown fields are ignored (forward-compatible), missing required fields → log warning + reject
|
|
||||||
5. **battery_pct bounds:** If present, must be 0–100. Out of range → clamp to [0,100] with warning
|
|
||||||
|
|
||||||
### ESP32-side (incoming from hub)
|
|
||||||
|
|
||||||
1. **Acknowledge commands:** After processing a command, the next status publish reflects the new state
|
|
||||||
2. **Unknown commands:** Log and ignore
|
|
||||||
3. **Malformed JSON:** Log error, ignore message
|
|
||||||
|
|
||||||
## Session Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
ESP32 boots
|
|
||||||
│
|
|
||||||
├── Connects to travel router Wi-Fi
|
|
||||||
├── Connects to MQTT broker (192.168.4.10:1883)
|
|
||||||
├── Publishes announce (retained) on cameras/<id>/announce
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Main loop (every 30s): │
|
|
||||||
│ 1. HTTP GET GoPro status (10.5.5.1) │
|
|
||||||
│ 2. Parse 60-byte status blob │
|
|
||||||
│ 3. Publish status (retained) │
|
|
||||||
│ 4. Every 60s: publish heartbeat │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
|
|
||||||
├── On GoPro unreachable → publish status with online: false
|
|
||||||
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
ESP32 shutdown / watchdog reboot
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow: Start Recording Example
|
|
||||||
|
|
||||||
```
|
|
||||||
1. User clicks "Start" on dashboard
|
|
||||||
2. Browser → HTTP POST /api/v1/cameras/cam-001/start → Go backend
|
|
||||||
3. Go backend → MQTT publish remoterig/cameras/cam-001/command {command: "start_recording"}
|
|
||||||
4. ESP32 receives command, sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
|
|
||||||
5. GoPro starts recording
|
|
||||||
6. Next 30s poll: ESP32 publishes status with recording: true
|
|
||||||
7. Go backend receives status, updates SQLite, fans out via SSE
|
|
||||||
8. Dashboard updates with pulsing REC indicator
|
|
||||||
```
|
|
||||||
|
|
||||||
## Offline Buffering (future — CUB-230)
|
|
||||||
|
|
||||||
When ESP32 loses connection to travel router:
|
|
||||||
|
|
||||||
1. **Buffer:** Store status snapshots in SPIFFS (LittleFS), max 100 entries (~6KB)
|
|
||||||
2. **Eviction:** FIFO — oldest dropped when buffer full
|
|
||||||
3. **Replay:** On MQTT reconnect, publish buffered messages in chronological order with original timestamps
|
|
||||||
4. **Dedup:** Hub ignores duplicates via `(camera_id, recorded_at)` unique constraint in status_logs
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
- **Adding fields:** Safe — unknown fields ignored by both sides
|
|
||||||
- **Removing fields:** Mark as optional first, remove in next major version
|
|
||||||
- **Changing field types:** New topic path (e.g., `status/v2`) or new field name
|
|
||||||
- **New topics:** Add freely — old clients ignore unknown topics
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **NTP/time sync:** How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. **Recommendation:** Pi runs NTPd, ESP32s use SNTP from `192.168.4.10`.
|
|
||||||
2. **Camera naming:** Should `friendly_name` be configurable from dashboard after auto-registration? **Recommendation:** Yes — allow rename via UI, stored in cameras table.
|
|
||||||
3. **Firmware OTA:** Should ESP32 firmware updates be possible over this network? **Recommendation:** Yes but out of scope for MVP.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# RemoteRig — ESP32 Camera Node Firmware
|
|
||||||
|
|
||||||
> **Platform:** PlatformIO (esp32dev) | **Framework:** Arduino
|
|
||||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
|
|
||||||
> **Hardware:** [hardware/README.md](../hardware/README.md)
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install PlatformIO (if not already)
|
|
||||||
pip install platformio
|
|
||||||
|
|
||||||
# Build
|
|
||||||
cd firmware
|
|
||||||
pio run
|
|
||||||
|
|
||||||
# Upload to ESP32 (USB connected)
|
|
||||||
pio run --target upload
|
|
||||||
|
|
||||||
# Upload SPIFFS config (first time only, or after config changes)
|
|
||||||
pio run --target uploadfs
|
|
||||||
|
|
||||||
# Serial monitor
|
|
||||||
pio device monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The ESP32 stores configuration in SPIFFS (`data/config.json`):
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
|-----|---------|-------------|
|
|
||||||
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
|
|
||||||
| `wifi_password` | `""` | Travel router password |
|
|
||||||
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP prefix (auto-discovered) |
|
|
||||||
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
|
|
||||||
| `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W static IP |
|
|
||||||
| `mqtt_port` | `1883` | Mosquitto port |
|
|
||||||
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
|
|
||||||
| `poll_interval_sec` | `30` | GoPro status poll frequency |
|
|
||||||
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
|
|
||||||
|
|
||||||
**First boot:** Leave `camera_id` empty. The ESP32 will auto-announce to the hub, which assigns a `cam-NNN` ID. The assigned ID is saved to SPIFFS automatically.
|
|
||||||
|
|
||||||
## LED Status Codes
|
|
||||||
|
|
||||||
| Pattern | Meaning |
|
|
||||||
|---------|---------|
|
|
||||||
| Slow blink (1s) | Connected to router + MQTT, normal operation |
|
|
||||||
| Fast blink (200ms) | No Wi-Fi connection — reconnecting |
|
|
||||||
| Solid on | Connected but GoPro unreachable |
|
|
||||||
| Off | Boot/shutdown |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ ESP32 (Arduino) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
|
|
||||||
│ │ WiFi STA │ │ WiFi STA │ │ MQTT │ │
|
|
||||||
│ │ (Router) │ │ (GoPro) │ │ Client │ │
|
|
||||||
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ ┌────────┘ │ │
|
|
||||||
│ ▼ ▼ ▼ │
|
|
||||||
│ ┌─────────────────────────────────┐ │
|
|
||||||
│ │ Main Loop │ │
|
|
||||||
│ │ Every 30s: │ │
|
|
||||||
│ │ HTTP GET GoPro status │ │
|
|
||||||
│ │ Parse 60-byte blob │ │
|
|
||||||
│ │ MQTT publish status │ │
|
|
||||||
│ │ Every 60s: │ │
|
|
||||||
│ │ MQTT publish heartbeat │ │
|
|
||||||
│ └─────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ SPIFFS: /config.json (persistent) │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Boot Sequence
|
|
||||||
|
|
||||||
1. Load config from SPIFFS
|
|
||||||
2. Connect to travel router Wi-Fi (STA mode)
|
|
||||||
3. Connect to GoPro AP Wi-Fi (STA mode — simultaneous)
|
|
||||||
4. Connect to MQTT broker (192.168.4.10)
|
|
||||||
5. If no `camera_id` → publish announce → hub registers us
|
|
||||||
6. Subscribe to `remoterig/cameras/{camera_id}/command`
|
|
||||||
7. Enter main loop
|
|
||||||
|
|
||||||
## GoPro API Notes (Hero 3 Black/Silver)
|
|
||||||
|
|
||||||
- **IP:** Always `10.5.5.1` (GoPro's own AP)
|
|
||||||
- **Status endpoint:** `GET /bacpac/SH?t={password}&p=%01`
|
|
||||||
- **Start recording:** `GET /bacpac/SH?t={password}&p=%01` (mode byte = 1)
|
|
||||||
- **Stop recording:** `GET /bacpac/SH?t={password}&p=%00` (mode byte = 0)
|
|
||||||
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
|
|
||||||
- **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets
|
|
||||||
|
|
||||||
## ESP8266 Compatibility
|
|
||||||
|
|
||||||
To target ESP8266 instead:
|
|
||||||
1. Change `platformio.ini`: `board = d1_mini` under `[env:d1_mini]`
|
|
||||||
2. Change `WiFi.h` → `ESP8266WiFi.h`
|
|
||||||
3. ESP8266 doesn't do true simultaneous STA — use single STA to travel router, HTTP to GoPro via router bridge
|
|
||||||
4. SPIFFS → LittleFS on some boards
|
|
||||||
|
|
||||||
ESP32 is recommended for dual-STA capability.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Check |
|
|
||||||
|---------|-------|
|
|
||||||
| No serial output | Baud rate: 115200. Hold BOOT, press EN, release BOOT for flash mode |
|
|
||||||
| Can't connect to router | Verify SSID/password in SPIFFS config, check router DHCP range |
|
|
||||||
| GoPro unreachable | GoPro must be ON and Wi-Fi enabled. Password defaults to "goprohero" |
|
|
||||||
| MQTT connect fails | Verify Mosquitto running on Pi: `systemctl status mosquitto` |
|
|
||||||
| Camera never registers | Watch serial for "announce" message, check hub logs for registration |
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"wifi_ssid": "RemoteRig",
|
|
||||||
"wifi_password": "",
|
|
||||||
"camera_ssid": "GOPRO-BP-",
|
|
||||||
"camera_password": "goprohero",
|
|
||||||
"mqtt_broker": "192.168.4.10",
|
|
||||||
"mqtt_port": 1883,
|
|
||||||
"camera_id": "",
|
|
||||||
"poll_interval_sec": 30,
|
|
||||||
"heartbeat_interval_sec": 60
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
; RemoteRig — ESP32 Camera Node Firmware
|
|
||||||
; Platform: ESP32 (ESP8266 compatible with minor changes)
|
|
||||||
; Framework: Arduino
|
|
||||||
;
|
|
||||||
; Build: pio run
|
|
||||||
; Upload: pio run --target upload
|
|
||||||
; SPIFFS: pio run --target uploadfs
|
|
||||||
; Monitor: pio device monitor
|
|
||||||
|
|
||||||
[env:esp32dev]
|
|
||||||
platform = espressif32
|
|
||||||
board = esp32dev
|
|
||||||
framework = arduino
|
|
||||||
monitor_speed = 115200
|
|
||||||
upload_speed = 921600
|
|
||||||
|
|
||||||
lib_deps =
|
|
||||||
knolleary/PubSubClient @ ^2.8
|
|
||||||
bblanchon/ArduinoJson @ ^7.3
|
|
||||||
|
|
||||||
build_flags =
|
|
||||||
-D CORE_DEBUG_LEVEL=0
|
|
||||||
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
/**
|
|
||||||
* RemoteRig — ESP32 Camera Node Firmware
|
|
||||||
* =======================================
|
|
||||||
* One ESP32 per GoPro Hero 3. Bridges the camera's Wi-Fi AP (10.5.5.1)
|
|
||||||
* to the travel router LAN via MQTT (Mosquitto on Pi Zero 2 W).
|
|
||||||
*
|
|
||||||
* MQTT Contract: docs/MQTT_CONTRACT.md
|
|
||||||
* Hardware: hardware/README.md
|
|
||||||
* Platform: PlatformIO (esp32dev)
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
#include <WiFi.h>
|
|
||||||
#include <WiFiClient.h>
|
|
||||||
#include <HTTPClient.h>
|
|
||||||
#include <PubSubClient.h>
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <SPIFFS.h>
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// Configuration (overridden by SPIFFS /data/config.json)
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
struct Config {
|
|
||||||
// Travel router Wi-Fi
|
|
||||||
String wifi_ssid = "RemoteRig";
|
|
||||||
String wifi_password = "";
|
|
||||||
|
|
||||||
// GoPro Hero 3 Wi-Fi AP
|
|
||||||
String camera_ssid = "GOPRO-BP-"; // prefix — auto-discovered
|
|
||||||
String camera_password = "goprohero";
|
|
||||||
|
|
||||||
// MQTT broker (Pi Zero 2 W on travel router)
|
|
||||||
String mqtt_broker = "192.168.4.10";
|
|
||||||
int mqtt_port = 1883;
|
|
||||||
|
|
||||||
// Assigned by hub on first announce; empty until registered
|
|
||||||
String camera_id = "";
|
|
||||||
|
|
||||||
// Polling
|
|
||||||
int poll_interval_sec = 30;
|
|
||||||
int heartbeat_interval_sec = 60;
|
|
||||||
|
|
||||||
// Stored in SPIFFS
|
|
||||||
bool dirty = false;
|
|
||||||
} cfg;
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// Network clients
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WiFiClient wifiClient; // for HTTP to GoPro
|
|
||||||
WiFiClient mqttWifiClient; // for MQTT via travel router
|
|
||||||
PubSubClient mqtt(mqttWifiClient);
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// State
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
unsigned long lastPollMs = 0;
|
|
||||||
unsigned long lastHeartbeatMs = 0;
|
|
||||||
unsigned long lastReconnectMs = 0;
|
|
||||||
unsigned long bootMs = 0;
|
|
||||||
int reconnectDelay = 1; // exponential backoff (seconds)
|
|
||||||
bool goproOnline = false;
|
|
||||||
|
|
||||||
// Heartbeat sequence
|
|
||||||
unsigned int heartbeatSeq = 0;
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// LED Pin (built-in on most ESP32 dev boards = GPIO 2)
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const int LED_PIN = 2;
|
|
||||||
|
|
||||||
enum LedMode { LED_OFF, LED_SLOW, LED_FAST, LED_ON };
|
|
||||||
LedMode ledMode = LED_SLOW;
|
|
||||||
|
|
||||||
void setLed(LedMode mode) {
|
|
||||||
ledMode = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// SPIFFS Config
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
bool loadConfig() {
|
|
||||||
if (!SPIFFS.begin(true)) {
|
|
||||||
Serial.println("[CFG] SPIFFS mount failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
File f = SPIFFS.open("/config.json", "r");
|
|
||||||
if (!f) {
|
|
||||||
Serial.println("[CFG] No /config.json — using defaults");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
DeserializationError err = deserializeJson(doc, f);
|
|
||||||
f.close();
|
|
||||||
if (err) {
|
|
||||||
Serial.printf("[CFG] JSON parse error: %s\n", err.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
|
||||||
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
|
||||||
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
|
|
||||||
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
|
|
||||||
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
|
||||||
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
|
||||||
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
|
||||||
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
|
|
||||||
cfg.heartbeat_interval_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_interval_sec;
|
|
||||||
|
|
||||||
Serial.println("[CFG] Loaded from /config.json");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool saveConfig() {
|
|
||||||
File f = SPIFFS.open("/config.json", "w");
|
|
||||||
if (!f) return false;
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["wifi_ssid"] = cfg.wifi_ssid;
|
|
||||||
doc["wifi_password"] = cfg.wifi_password;
|
|
||||||
doc["camera_ssid"] = cfg.camera_ssid;
|
|
||||||
doc["camera_password"] = cfg.camera_password;
|
|
||||||
doc["mqtt_broker"] = cfg.mqtt_broker;
|
|
||||||
doc["mqtt_port"] = cfg.mqtt_port;
|
|
||||||
doc["camera_id"] = cfg.camera_id;
|
|
||||||
doc["poll_interval_sec"] = cfg.poll_interval_sec;
|
|
||||||
doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec;
|
|
||||||
|
|
||||||
serializeJson(doc, f);
|
|
||||||
f.close();
|
|
||||||
Serial.println("[CFG] Saved config");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// Wi-Fi — Dual STA (GoPro AP + Travel Router)
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
bool connectCameraWiFi() {
|
|
||||||
Serial.printf("[WIFI] Connecting to GoPro AP: %s\n", cfg.camera_ssid.c_str());
|
|
||||||
|
|
||||||
// Use WiFi.begin with a second AP config — ESP32 supports this
|
|
||||||
// We connect to travel router first, then GoPro
|
|
||||||
// GoPro AP: static IP on 10.5.5.x subnet
|
|
||||||
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
|
|
||||||
|
|
||||||
int attempts = 0;
|
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
Serial.printf("\n[WIFI] Connected to GoPro AP. IP: %s\n", WiFi.localIP().toString().c_str());
|
|
||||||
goproOnline = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.println("\n[WIFI] Failed to connect to GoPro AP");
|
|
||||||
goproOnline = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// GoPro Hero 3 HTTP API
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// GoPro AP gateway (always 10.5.5.1 for Hero 3)
|
|
||||||
const char* GOPRO_IP = "10.5.5.1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the GoPro camera password.
|
|
||||||
* Hero 3 exposes it via GET /bacpac/sd (no auth required).
|
|
||||||
* Default is "goprohero" but user may have changed it.
|
|
||||||
*/
|
|
||||||
String fetchGoProPassword() {
|
|
||||||
HTTPClient http;
|
|
||||||
http.begin(wifiClient, String("http://") + GOPRO_IP + "/bacpac/sd");
|
|
||||||
int code = http.GET();
|
|
||||||
String body = http.getString();
|
|
||||||
http.end();
|
|
||||||
|
|
||||||
if (code == 200 && body.length() > 0) {
|
|
||||||
// Password is in plain text in the response body
|
|
||||||
body.trim();
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
return cfg.camera_password; // fallback to config value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the GoPro status blob (60 bytes binary).
|
|
||||||
* Returns empty string on failure.
|
|
||||||
*/
|
|
||||||
String fetchGoProStatus() {
|
|
||||||
String url = String("http://") + GOPRO_IP +
|
|
||||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
|
|
||||||
HTTPClient http;
|
|
||||||
http.begin(wifiClient, url);
|
|
||||||
http.setTimeout(5000);
|
|
||||||
int code = http.GET();
|
|
||||||
|
|
||||||
if (code != 200) {
|
|
||||||
http.end();
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// GoPro returns raw binary — use getString() which handles it
|
|
||||||
String raw = http.getString();
|
|
||||||
http.end();
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the 60-byte GoPro status blob into structured data.
|
|
||||||
* Hero 3 status format (offsets are 0-based):
|
|
||||||
* [25-26] video_remaining_sec (uint16 LE)
|
|
||||||
* [29] recording state (0=idle, 1=recording)
|
|
||||||
* [30] mode
|
|
||||||
* [31-32] resolution
|
|
||||||
* [33-34] fps
|
|
||||||
* [57] battery_raw (uint8)
|
|
||||||
*/
|
|
||||||
struct GoProStatus {
|
|
||||||
bool valid = false;
|
|
||||||
int video_remaining_sec = 0;
|
|
||||||
bool recording = false;
|
|
||||||
int mode = 0;
|
|
||||||
int fps = 0;
|
|
||||||
int battery_raw = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
GoProStatus parseStatus(const String& raw) {
|
|
||||||
GoProStatus s;
|
|
||||||
if (raw.length() < 58) {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint8_t* buf = (const uint8_t*)raw.c_str();
|
|
||||||
|
|
||||||
s.valid = true;
|
|
||||||
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
|
||||||
s.recording = (buf[29] == 1);
|
|
||||||
s.mode = buf[30];
|
|
||||||
s.fps = buf[33] | (buf[34] << 8);
|
|
||||||
s.battery_raw = buf[57];
|
|
||||||
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool sendGoProCommand(const String& command) {
|
|
||||||
String param;
|
|
||||||
if (command == "start_recording") {
|
|
||||||
param = "%01"; // mode 1 = record
|
|
||||||
} else if (command == "stop_recording") {
|
|
||||||
param = "%00"; // mode 0 = stop
|
|
||||||
} else {
|
|
||||||
Serial.printf("[GOPRO] Unknown command: %s\n", command.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String url = String("http://") + GOPRO_IP +
|
|
||||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
|
|
||||||
|
|
||||||
HTTPClient http;
|
|
||||||
http.begin(wifiClient, url);
|
|
||||||
http.setTimeout(5000);
|
|
||||||
int code = http.GET();
|
|
||||||
http.end();
|
|
||||||
|
|
||||||
Serial.printf("[GOPRO] Command %s → HTTP %d\n", command.c_str(), code);
|
|
||||||
return (code == 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// MQTT
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
String clientID() {
|
|
||||||
uint8_t mac[6];
|
|
||||||
WiFi.macAddress(mac);
|
|
||||||
char buf[32];
|
|
||||||
snprintf(buf, sizeof(buf), "remoterig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
|
||||||
return String(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
String statusTopic() { return "remoterig/cameras/" + cfg.camera_id + "/status"; }
|
|
||||||
String heartbeatTopic() { return "remoterig/cameras/" + cfg.camera_id + "/heartbeat"; }
|
|
||||||
String announceTopic() { return "remoterig/cameras/" + cfg.camera_id + "/announce"; }
|
|
||||||
String commandTopic() { return "remoterig/cameras/" + cfg.camera_id + "/command"; }
|
|
||||||
|
|
||||||
void mqttCallback(char* topic, byte* payload, unsigned int length) {
|
|
||||||
// Null-terminate payload
|
|
||||||
char buf[256];
|
|
||||||
unsigned int len = length < 255 ? length : 255;
|
|
||||||
memcpy(buf, payload, len);
|
|
||||||
buf[len] = 0;
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] ← %s: %s\n", topic, buf);
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
DeserializationError err = deserializeJson(doc, buf);
|
|
||||||
if (err) {
|
|
||||||
Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String cmd = doc["command"] | "";
|
|
||||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
|
||||||
sendGoProCommand(cmd);
|
|
||||||
} else if (cmd == "reboot") {
|
|
||||||
Serial.println("[MQTT] Reboot command received");
|
|
||||||
ESP.restart();
|
|
||||||
} else if (cmd == "registered") {
|
|
||||||
// Hub assigned us a camera_id on announce
|
|
||||||
String newID = doc["camera_id"] | "";
|
|
||||||
if (newID.length() > 0 && newID != cfg.camera_id) {
|
|
||||||
cfg.camera_id = newID;
|
|
||||||
cfg.dirty = true;
|
|
||||||
Serial.printf("[MQTT] Registered as %s\n", newID.c_str());
|
|
||||||
// Re-subscribe to our new command topic
|
|
||||||
mqtt.unsubscribe(commandTopic().c_str());
|
|
||||||
mqtt.subscribe(commandTopic().c_str(), 2);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool connectMQTT() {
|
|
||||||
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
|
|
||||||
mqtt.setCallback(mqttCallback);
|
|
||||||
mqtt.setKeepAlive(60);
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] Connecting to %s:%d as %s...\n",
|
|
||||||
cfg.mqtt_broker.c_str(), cfg.mqtt_port, clientID().c_str());
|
|
||||||
|
|
||||||
if (mqtt.connect(clientID().c_str())) {
|
|
||||||
Serial.println("[MQTT] Connected");
|
|
||||||
|
|
||||||
// Subscribe to command topic
|
|
||||||
mqtt.subscribe(commandTopic().c_str(), 2);
|
|
||||||
Serial.printf("[MQTT] Subscribed to %s\n", commandTopic().c_str());
|
|
||||||
|
|
||||||
// If we have no camera_id yet, announce ourselves
|
|
||||||
if (cfg.camera_id.length() == 0) {
|
|
||||||
publishAnnounce();
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnectDelay = 1; // reset backoff
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] Connection failed (state=%d)\n", mqtt.state());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void publishAnnounce() {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["mac_address"] = WiFi.macAddress();
|
|
||||||
doc["firmware_version"] = "0.1.0";
|
|
||||||
doc["friendly_name"] = "ESP32-" + clientID().substring(9);
|
|
||||||
|
|
||||||
JsonArray caps = doc["capabilities"].to<JsonArray>();
|
|
||||||
caps.add("start_stop");
|
|
||||||
caps.add("status");
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
// Publish on a temporary announce topic (using MAC as ID until registered)
|
|
||||||
String tempAnnounce = "remoterig/cameras/announce-" + clientID().substring(9);
|
|
||||||
mqtt.publish(tempAnnounce.c_str(), payload.c_str(), true);
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] Published announce: %s\n", payload.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
void publishStatus(const GoProStatus& s) {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["camera_id"] = cfg.camera_id;
|
|
||||||
doc["timestamp"] = millis(); // milliseconds since boot — hub converts to ISO
|
|
||||||
doc["battery_raw"] = s.battery_raw;
|
|
||||||
doc["video_remaining_sec"] = s.video_remaining_sec;
|
|
||||||
doc["recording"] = s.recording;
|
|
||||||
doc["online"] = goproOnline;
|
|
||||||
|
|
||||||
if (s.recording) {
|
|
||||||
doc["mode"] = "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
bool ok = mqtt.publish(statusTopic().c_str(), payload.c_str(), true);
|
|
||||||
if (ok) {
|
|
||||||
Serial.printf("[MQTT] → status (batt=%d, rec=%d, online=%d)\n",
|
|
||||||
s.battery_raw, s.recording, goproOnline);
|
|
||||||
} else {
|
|
||||||
Serial.println("[MQTT] Status publish failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void publishHeartbeat() {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["camera_id"] = cfg.camera_id;
|
|
||||||
doc["timestamp"] = millis();
|
|
||||||
doc["uptime_sec"] = (millis() - bootMs) / 1000;
|
|
||||||
doc["free_heap"] = ESP.getFreeHeap();
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
mqtt.publish(heartbeatTopic().c_str(), payload.c_str(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// Setup
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
delay(1000);
|
|
||||||
Serial.println("\n\nRemoteRig ESP32 Camera Node v0.1.0");
|
|
||||||
Serial.println("===================================");
|
|
||||||
|
|
||||||
bootMs = millis();
|
|
||||||
|
|
||||||
pinMode(LED_PIN, OUTPUT);
|
|
||||||
digitalWrite(LED_PIN, LOW);
|
|
||||||
|
|
||||||
// Load config from SPIFFS
|
|
||||||
loadConfig();
|
|
||||||
Serial.printf("[CFG] camera_id: %s (empty = not yet registered)\n",
|
|
||||||
cfg.camera_id.length() > 0 ? cfg.camera_id.c_str() : "(none)");
|
|
||||||
|
|
||||||
// Connect to travel router Wi-Fi
|
|
||||||
Serial.printf("[WIFI] Connecting to travel router: %s\n", cfg.wifi_ssid.c_str());
|
|
||||||
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
|
|
||||||
|
|
||||||
int wifiAttempts = 0;
|
|
||||||
while (WiFi.status() != WL_CONNECTED && wifiAttempts < 40) {
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
wifiAttempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
|
||||||
setLed(LED_SLOW); // connected to router
|
|
||||||
} else {
|
|
||||||
Serial.println("\n[WIFI] Failed to connect to travel router — will retry in loop");
|
|
||||||
setLed(LED_FAST); // no router connection
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to GoPro AP
|
|
||||||
if (!connectCameraWiFi()) {
|
|
||||||
Serial.println("[WIFI] GoPro not reachable — will retry");
|
|
||||||
setLed(LED_FAST);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect MQTT
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
connectMQTT();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// Main Loop
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
unsigned long now = millis();
|
|
||||||
|
|
||||||
// ── LED heartbeat ──
|
|
||||||
static unsigned long lastLedToggle = 0;
|
|
||||||
int ledInterval = (ledMode == LED_FAST) ? 200 : (ledMode == LED_SLOW) ? 1000 : 0;
|
|
||||||
if (ledInterval > 0 && now - lastLedToggle > ledInterval) {
|
|
||||||
lastLedToggle = now;
|
|
||||||
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
|
|
||||||
}
|
|
||||||
if (ledMode == LED_ON) digitalWrite(LED_PIN, HIGH);
|
|
||||||
if (ledMode == LED_OFF) digitalWrite(LED_PIN, LOW);
|
|
||||||
|
|
||||||
// ── Wi-Fi reconnection ──
|
|
||||||
if (WiFi.status() != WL_CONNECTED) {
|
|
||||||
setLed(LED_FAST);
|
|
||||||
if (now - lastReconnectMs > 5000) {
|
|
||||||
lastReconnectMs = now;
|
|
||||||
Serial.println("[WIFI] Reconnecting...");
|
|
||||||
WiFi.reconnect();
|
|
||||||
}
|
|
||||||
delay(100);
|
|
||||||
return; // skip everything else until Wi-Fi is back
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MQTT reconnection ──
|
|
||||||
if (!mqtt.connected()) {
|
|
||||||
setLed(LED_SLOW);
|
|
||||||
if (now - lastReconnectMs > (unsigned long)(reconnectDelay * 1000)) {
|
|
||||||
lastReconnectMs = now;
|
|
||||||
if (connectMQTT()) {
|
|
||||||
reconnectDelay = 1;
|
|
||||||
} else {
|
|
||||||
reconnectDelay = min(reconnectDelay * 2, 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mqtt.loop();
|
|
||||||
delay(100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLed(LED_SLOW);
|
|
||||||
mqtt.loop();
|
|
||||||
|
|
||||||
// ── GoPro reconnection ──
|
|
||||||
static unsigned long lastGoProRetry = 0;
|
|
||||||
if (!goproOnline && now - lastGoProRetry > 30000) {
|
|
||||||
lastGoProRetry = now;
|
|
||||||
connectCameraWiFi();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status polling (every cfg.poll_interval_sec) ──
|
|
||||||
if (now - lastPollMs > (unsigned long)(cfg.poll_interval_sec * 1000)) {
|
|
||||||
lastPollMs = now;
|
|
||||||
|
|
||||||
String raw = fetchGoProStatus();
|
|
||||||
GoProStatus status = parseStatus(raw);
|
|
||||||
|
|
||||||
if (status.valid) {
|
|
||||||
goproOnline = true;
|
|
||||||
if (cfg.camera_id.length() > 0) {
|
|
||||||
publishStatus(status);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
goproOnline = false;
|
|
||||||
if (cfg.camera_id.length() > 0) {
|
|
||||||
GoProStatus offline = {};
|
|
||||||
offline.valid = true;
|
|
||||||
publishStatus(offline); // publish with online=false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Heartbeat (every heartbeat_interval_sec) ──
|
|
||||||
if (cfg.camera_id.length() > 0 &&
|
|
||||||
now - lastHeartbeatMs > (unsigned long)(cfg.heartbeat_interval_sec * 1000)) {
|
|
||||||
lastHeartbeatMs = now;
|
|
||||||
publishHeartbeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Save config if dirty ──
|
|
||||||
if (cfg.dirty) {
|
|
||||||
cfg.dirty = false;
|
|
||||||
saveConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(100);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ module github.com/cubecraft/remoterig
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.50.1
|
modernc.org/sqlite v1.50.1
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
# RemoteRig — Camera Node Hardware Design
|
|
||||||
|
|
||||||
> **Version:** 0.1.0 | **Status:** Draft
|
|
||||||
> **Target:** GoPro Hero 3 Black/Silver + ESP32 D1 Mini + 1000mAh LiPo
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Each camera node is a self-contained unit clipped onto a GoPro Hero 3. It provides:
|
|
||||||
- Camera control (start/stop recording) via Wi-Fi
|
|
||||||
- Status monitoring (battery, storage, recording state)
|
|
||||||
- MQTT communication to the central Pi Zero 2 W hub
|
|
||||||
- Battery power for both the ESP32 and GoPro
|
|
||||||
|
|
||||||
## Physical Assembly
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────┐
|
|
||||||
│ GoPro Hero 3 │
|
|
||||||
│ ┌─────────────────────────┐ │
|
|
||||||
│ │ Lens (front) │ │
|
|
||||||
│ └─────────────────────────┘ │
|
|
||||||
│ ┌─────────────────────────┐ │
|
|
||||||
│ │ Screen │ │
|
|
||||||
│ └─────────────────────────┘ │
|
|
||||||
│ ┌──────────┐ │
|
|
||||||
│ 3D Sleeve ─────→│ ESP32 │ │ ← clips onto back/bottom
|
|
||||||
│ │ D1 Mini │ │
|
|
||||||
│ └──────────┘ │
|
|
||||||
│ ┌──────────┐ │
|
|
||||||
│ │ LiPo │ │ ← slides under GoPro
|
|
||||||
│ │ 1000mAh │ │
|
|
||||||
│ └──────────┘ │
|
|
||||||
└─────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bill of Materials
|
|
||||||
|
|
||||||
| Item | Qty | Cost | Notes |
|
|
||||||
|------|-----|------|-------|
|
|
||||||
| GoPro Hero 3 Black/Silver | 1 | Already owned | Target camera |
|
|
||||||
| ESP32 D1 Mini | 1 | ~$4 | Or NodeMCU-32S (~$5) |
|
|
||||||
| LiPo 3.7V 1000mAh | 1 | ~$8 | 50x34x8mm typical |
|
|
||||||
| 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB |
|
|
||||||
| 3.3V buck converter | 1 | ~$1 | LiPo → ESP32 VIN |
|
|
||||||
| JST-XH 2-pin connectors | 2 | ~$1 | Battery quick-disconnect |
|
|
||||||
| Micro-USB right-angle cable | 1 | ~$2 | Buck → GoPro |
|
|
||||||
| Velcro strap (20cm) | 1 | ~$0.50 | Secure to GoPro |
|
|
||||||
| PETG filament | ~30g | ~$0.60 | 3D printed case |
|
|
||||||
|
|
||||||
**Total per node:** ~$20
|
|
||||||
|
|
||||||
## 3D Printed Case
|
|
||||||
|
|
||||||
The case consists of three parts (see `hardware/case/remoterig-case.scad`):
|
|
||||||
|
|
||||||
### Part 1: GoPro Sleeve
|
|
||||||
Wraps around the GoPro body with cutouts for:
|
|
||||||
- Lens (front)
|
|
||||||
- Screen/viewfinder (back)
|
|
||||||
- USB port (side)
|
|
||||||
- Bottom mounting fingers
|
|
||||||
- Mounting ears for electronics compartment
|
|
||||||
|
|
||||||
### Part 2: Electronics Compartment
|
|
||||||
Clips onto the sleeve's mounting ears. Holds:
|
|
||||||
- ESP32 D1 Mini board (recessed fit)
|
|
||||||
- USB cable routing (in → ESP32, out → GoPro)
|
|
||||||
- Ventilation slots (top)
|
|
||||||
- LED visibility window
|
|
||||||
|
|
||||||
### Part 3: Battery Compartment
|
|
||||||
Slides under the GoPro. Contains:
|
|
||||||
- LiPo battery cavity
|
|
||||||
- Cable exits (to ESP32, to GoPro buck converter)
|
|
||||||
- Velcro strap slots
|
|
||||||
|
|
||||||
### Print Settings
|
|
||||||
- **Material:** PETG (outdoor/heat resistant) or PLA+
|
|
||||||
- **Layer height:** 0.2mm
|
|
||||||
- **Infill:** 20% gyroid
|
|
||||||
- **Supports:** Yes (for cable channels)
|
|
||||||
- **Bed adhesion:** Brim (5mm) for sleeve
|
|
||||||
- **Orientation:** Print sleeve on its back, compartments flat
|
|
||||||
|
|
||||||
## Wiring
|
|
||||||
|
|
||||||
```
|
|
||||||
LiPo 3.7V
|
|
||||||
├── JST-XH connector
|
|
||||||
│
|
|
||||||
├──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port
|
|
||||||
│ (power only — no data over USB)
|
|
||||||
│
|
|
||||||
└──→ 3.3V Buck Converter → ESP32 VIN + GND
|
|
||||||
(or ESP32 D1 Mini has built-in regulator — connect directly to 5V pin)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** ESP32 D1 Mini has an onboard 3.3V regulator. You can feed it 5V directly to the 5V pin if using a single 5V buck converter. This simplifies wiring:
|
|
||||||
```
|
|
||||||
LiPo → 5V Buck → ├── ESP32 5V pin
|
|
||||||
└── GoPro USB port
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wi-Fi Topology (No Cables for Camera Control)
|
|
||||||
|
|
||||||
```
|
|
||||||
GoPro Hero 3 ──(Wi-Fi AP @ 10.5.5.1)──→ ESP32 STA #1
|
|
||||||
│
|
|
||||||
Travel Router ──(Wi-Fi AP)─────────────────→ ESP32 STA #2
|
|
||||||
(192.168.4.1) │
|
|
||||||
│
|
|
||||||
└──→ MQTT → Pi Hub (192.168.4.10)
|
|
||||||
```
|
|
||||||
|
|
||||||
The ESP32 has **no wired data connection** to the GoPro. All camera control is over Wi-Fi. The USB cable is **power only**.
|
|
||||||
|
|
||||||
## Enclosure Dimensions
|
|
||||||
|
|
||||||
| Component | W × H × D (mm) |
|
|
||||||
|-----------|-----------------|
|
|
||||||
| GoPro Hero 3 | 60 × 42 × 30 |
|
|
||||||
| ESP32 D1 Mini | 34 × 26 × 5 |
|
|
||||||
| LiPo 1000mAh | 50 × 34 × 8 |
|
|
||||||
| Full assembly | ~70 × 60 × 55 |
|
|
||||||
|
|
||||||
## Usage in the Field
|
|
||||||
|
|
||||||
1. **Pre-show:** Charge LiPos, flash ESP32 firmware, verify MQTT connectivity
|
|
||||||
2. **At venue:** Mount cameras, power on ESP32s (they auto-connect to travel router)
|
|
||||||
3. **Monitoring:** Open `http://192.168.4.10:8080` on laptop/kiosk
|
|
||||||
4. **Control:** Start/stop recording from dashboard
|
|
||||||
5. **Post-show:** Stop recording, power down, swap batteries for next session
|
|
||||||
|
|
||||||
## Future Improvements
|
|
||||||
|
|
||||||
- **Hot-swap battery:** Quick-release battery tray with spring contacts
|
|
||||||
- **Weather sealing:** O-ring groove in sleeve for outdoor rain protection
|
|
||||||
- **Lens hood:** Integrated sun shield for outdoor daytime recording
|
|
||||||
- **Mount adapter:** 1/4"-20 tripod mount thread on bottom
|
|
||||||
- **Antenna routing:** External antenna connector for improved Wi-Fi range in stadiums
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
// RemoteRig — GoPro Hero 3 + ESP32 Camera Case
|
|
||||||
// ==============================================
|
|
||||||
// Sleeve that wraps around GoPro Hero 3 body with ESP32 + LiPo compartment.
|
|
||||||
// Designed for: ESP32 D1 Mini, 1000mAh LiPo, GoPro Hero 3 Black/Silver.
|
|
||||||
//
|
|
||||||
// Print settings:
|
|
||||||
// Material: PETG (outdoor/heat) or PLA+ (indoor)
|
|
||||||
// Layer: 0.2mm | Infill: 20% gyroid | Supports: yes (for cable channels)
|
|
||||||
// Nozzle: 0.4mm | Bed: 60°C (PLA) / 80°C (PETG)
|
|
||||||
|
|
||||||
// ── GoPro Hero 3 Body (approximate) ──
|
|
||||||
gopro_width = 60; // mm — body width
|
|
||||||
gopro_height = 42; // mm — body height (top to bottom)
|
|
||||||
gopro_depth = 30; // mm — body depth (front to back)
|
|
||||||
gopro_lens_dia = 28; // mm — lens protrusion diameter
|
|
||||||
gopro_lens_offset = 18; // mm — lens center from top
|
|
||||||
|
|
||||||
// ── ESP32 D1 Mini ──
|
|
||||||
esp_width = 34.2;
|
|
||||||
esp_height = 25.6;
|
|
||||||
esp_thick = 5; // board + components
|
|
||||||
usb_cutout_w = 10;
|
|
||||||
usb_cutout_h = 5;
|
|
||||||
|
|
||||||
// ── LiPo Battery (1000mAh typical) ──
|
|
||||||
lipo_width = 35;
|
|
||||||
lipo_height = 25;
|
|
||||||
lipo_thick = 8;
|
|
||||||
|
|
||||||
// ── Case parameters ──
|
|
||||||
wall = 2.0; // case wall thickness
|
|
||||||
tolerance = 0.3; // print tolerance for friction fit
|
|
||||||
compartment_height = max(esp_thick, lipo_thick) + 3; // internal compartment height
|
|
||||||
|
|
||||||
// ── Cable channels ──
|
|
||||||
cable_dia = 4; // USB cable diameter
|
|
||||||
cable_channel_depth = 3;
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// MAIN ASSEMBLY
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// Uncomment the part you want to export:
|
|
||||||
gopro_sleeve();
|
|
||||||
// translate([0, -20, 0]) electronics_compartment();
|
|
||||||
// translate([0, 20, 0]) battery_compartment();
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// GoPro Sleeve — wraps around the GoPro body
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module gopro_sleeve() {
|
|
||||||
union() {
|
|
||||||
// Main sleeve body — wraps around GoPro
|
|
||||||
difference() {
|
|
||||||
// Outer shell
|
|
||||||
rounded_cube(
|
|
||||||
gopro_width + wall*2,
|
|
||||||
gopro_height + wall*2,
|
|
||||||
gopro_depth + wall*2,
|
|
||||||
4 // corner radius
|
|
||||||
);
|
|
||||||
|
|
||||||
// Inner cavity (GoPro body)
|
|
||||||
translate([0, 0, wall])
|
|
||||||
rounded_cube(
|
|
||||||
gopro_width + tolerance,
|
|
||||||
gopro_height + tolerance,
|
|
||||||
gopro_depth + tolerance,
|
|
||||||
3
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lens cutout (front face)
|
|
||||||
translate([0, gopro_height/2 - gopro_lens_offset, 0])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=gopro_lens_dia + 4, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Front screen/viewfinder cutout
|
|
||||||
translate([0, gopro_height/2 - gopro_lens_offset - 18, wall*2])
|
|
||||||
cube([gopro_width - 10, gopro_height - 20, wall*4], center=true);
|
|
||||||
|
|
||||||
// Bottom cutout (for GoPro mounting fingers)
|
|
||||||
translate([0, 0, gopro_depth/2 + wall])
|
|
||||||
cube([gopro_width - 10, wall*4, wall*4], center=true);
|
|
||||||
|
|
||||||
// USB port access (side)
|
|
||||||
translate([gopro_width/2 + wall, 0, -5])
|
|
||||||
cube([wall*4, 16, 10], center=true);
|
|
||||||
|
|
||||||
// Cable channel from ESP32 compartment to GoPro USB
|
|
||||||
translate([gopro_width/2 - 5, -gopro_height/2 + 10, -gopro_depth/2 + 5])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mounting ears for electronics compartment
|
|
||||||
for (x = [-1, 1]) {
|
|
||||||
translate([x * (gopro_width/2 - 6), -gopro_height/2 - 6, 0])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=8, h=10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// Electronics Compartment — holds ESP32 + routes cables
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module electronics_compartment() {
|
|
||||||
comp_w = max(esp_width, esp_height) + wall*2 + 10;
|
|
||||||
comp_h = compartment_height + wall*2;
|
|
||||||
comp_d = gopro_depth + wall*2;
|
|
||||||
|
|
||||||
difference() {
|
|
||||||
union() {
|
|
||||||
// Main box
|
|
||||||
rounded_cube(comp_w, comp_d, comp_h, 3);
|
|
||||||
|
|
||||||
// Mounting tabs (match GoPro sleeve ears)
|
|
||||||
for (x = [-1, 1]) {
|
|
||||||
translate([x * (gopro_width/2 - 6), 0, comp_h/2])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=6, h=4, center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inner cavity
|
|
||||||
translate([0, 0, wall])
|
|
||||||
rounded_cube(comp_w - wall*2, comp_d - wall*2, comp_h - wall, 2);
|
|
||||||
|
|
||||||
// ESP32 board recess
|
|
||||||
translate([0, 5, wall + 1])
|
|
||||||
cube([esp_width + tolerance, esp_height + tolerance, esp_thick + 1], center=true);
|
|
||||||
|
|
||||||
// USB cable entry (side hole)
|
|
||||||
translate([comp_w/2, 15, comp_h/2])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=usb_cutout_w, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// USB cable exit (to GoPro)
|
|
||||||
translate([comp_w/2, -15, comp_h/2])
|
|
||||||
rotate([0, 90, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Ventilation slots
|
|
||||||
for (y = [-1:2:1]) {
|
|
||||||
for (i = [-15:10:15]) {
|
|
||||||
translate([i, y * comp_d/3, comp_h - 2])
|
|
||||||
cube([6, 1.5, wall*2], center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LED window (thin wall for ESP32 LED visibility)
|
|
||||||
translate([0, 0, wall])
|
|
||||||
cube([5, 5, wall], center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// Battery Compartment — holds LiPo under GoPro
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module battery_compartment() {
|
|
||||||
bat_w = lipo_width + wall*2 + tolerance;
|
|
||||||
bat_d = lipo_height + wall*2 + tolerance;
|
|
||||||
bat_h = lipo_thick + wall*2 + 4;
|
|
||||||
|
|
||||||
difference() {
|
|
||||||
// Shell
|
|
||||||
rounded_cube(bat_w, bat_d, bat_h, 3);
|
|
||||||
|
|
||||||
// Battery cavity
|
|
||||||
translate([0, 0, wall])
|
|
||||||
rounded_cube(lipo_width + tolerance, lipo_height + tolerance, lipo_thick + tolerance, 1);
|
|
||||||
|
|
||||||
// Cable exit (to ESP32 compartment)
|
|
||||||
translate([0, bat_d/2, bat_h/2])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Cable exit (to GoPro USB)
|
|
||||||
translate([bat_w/3, -bat_d/2, bat_h/2])
|
|
||||||
rotate([90, 0, 0])
|
|
||||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
|
||||||
|
|
||||||
// Strap slots (velcro strap to secure to GoPro)
|
|
||||||
for (x = [-bat_w/3, bat_w/3]) {
|
|
||||||
translate([x, -bat_d/2, bat_h/2])
|
|
||||||
cube([8, wall*4, 3], center=true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// Utility: Rounded cube (positive X/Y/Z = full dimensions)
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
module rounded_cube(w, d, h, r) {
|
|
||||||
hull() {
|
|
||||||
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
|
|
||||||
translate([x * (w/2 - r), y * (d/2 - r), z * (h/2 - r)])
|
|
||||||
sphere(r=r, $fn=20);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-29
@@ -7,7 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
type Hub struct {
|
type Hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
clients map[*Client]bool
|
clients map[*Client]bool
|
||||||
eventSeq atomic.Int64 // monotonic event ID for Last-Event-ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates a new SSE hub.
|
// 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("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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
|
// Get flusher
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
@@ -94,21 +85,12 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
|
|||||||
client.Close()
|
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
|
// Send initial connection event
|
||||||
seq := h.eventSeq.Add(1)
|
data, _ := json.Marshal(map[string]string{
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"type": "connected",
|
"type": "connected",
|
||||||
"id": seq,
|
|
||||||
"ts": time.Now().Format(time.RFC3339),
|
"ts": time.Now().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
eventLine := fmt.Sprintf("id: %d\nevent: connected\ndata: %s\n\n", seq, string(data))
|
if !client.Write(data) {
|
||||||
if !client.Write([]byte(eventLine)) {
|
|
||||||
return // client disconnected
|
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.
|
// Broadcast sends an event to all connected clients.
|
||||||
// eventType becomes the "event:" field, enabling client-side filtering.
|
|
||||||
// Each event gets a monotonic ID for Last-Event-ID reconnection support.
|
|
||||||
func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
seq := h.eventSeq.Add(1)
|
|
||||||
|
|
||||||
event := map[string]interface{}{
|
event := map[string]interface{}{
|
||||||
"type": eventType,
|
"type": eventType,
|
||||||
"id": seq,
|
|
||||||
"ts": time.Now().Format(time.RFC3339),
|
"ts": time.Now().Format(time.RFC3339),
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
}
|
}
|
||||||
@@ -155,10 +132,8 @@ func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
eventLine := fmt.Sprintf("id: %d\nevent: %s\ndata: %s\n\n", seq, eventType, string(data))
|
|
||||||
|
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
if !client.Write([]byte(eventLine)) {
|
if !client.Write(data) {
|
||||||
log.Println("SSE client buffer full, dropping event")
|
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: 192.168.4.10/24)
|
|
||||||
# --gateway IP Gateway for wlan0 (default: 192.168.4.1)
|
|
||||||
# --help Show this help
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Defaults
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
CONFIG_TEMPLATE=""
|
|
||||||
SERVICE_USER="pi"
|
|
||||||
STATIC_IP="192.168.4.10/24"
|
|
||||||
GATEWAY="192.168.4.1"
|
|
||||||
MOSQUITTO_PKG="mosquitto mosquitto-clients"
|
|
||||||
DEPLOY_DIR="/opt/remoterig"
|
|
||||||
SERVICE_NAME="remoterig"
|
|
||||||
SERVICE_FILE="scripts/remoterig.service"
|
|
||||||
MOSQUITTO_CONF="/etc/mosquitto/conf.d/remoterig.conf"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Help
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
usage() {
|
|
||||||
sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# //'
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Parse args
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--config)
|
|
||||||
CONFIG_TEMPLATE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--service-user)
|
|
||||||
SERVICE_USER="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--static-ip)
|
|
||||||
STATIC_IP="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--gateway)
|
|
||||||
GATEWAY="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: unknown option: $1" >&2
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pre-flight checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
echo "ERROR: must run as root (sudo ./setup-pi.sh)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
info() { echo "[INFO] $*"; }
|
|
||||||
ok() { echo "[OK] $*"; }
|
|
||||||
skip() { echo "[SKIP] $*"; }
|
|
||||||
warn() { echo "[WARN] $*" >&2; }
|
|
||||||
|
|
||||||
echo "=============================================="
|
|
||||||
echo " RemoteRig Pi Zero 2 W Setup"
|
|
||||||
echo " Target: ${STATIC_IP} via ${GATEWAY}"
|
|
||||||
echo " Service user: ${SERVICE_USER}"
|
|
||||||
echo "=============================================="
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Update package list (always safe)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
info "Updating package list..."
|
|
||||||
apt-get update -qq
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Install Mosquitto MQTT broker + clients
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
info "Installing Mosquitto..."
|
|
||||||
if dpkg -l mosquitto mosquitto-clients >/dev/null 2>&1; then
|
|
||||||
# Already installed — ensure latest
|
|
||||||
apt-get install -y -qq ${MOSQUITTO_PKG} 2>/dev/null && \
|
|
||||||
ok "Mosquitto packages up to date" || \
|
|
||||||
warn "Mosquitto package update had warnings (non-fatal)"
|
|
||||||
else
|
|
||||||
apt-get install -y -qq ${MOSQUITTO_PKG}
|
|
||||||
ok "Mosquitto installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Configure Mosquitto — anonymous on localhost, listener on 0.0.0.0:1883
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
info "Configuring Mosquitto..."
|
|
||||||
mkdir -p /etc/mosquitto/conf.d
|
|
||||||
|
|
||||||
# Write idempotent config
|
|
||||||
cat > "${MOSQUITTO_CONF}" <<'MQTTEOF'
|
|
||||||
# RemoteRig Mosquitto configuration
|
|
||||||
# Closed travel-router LAN — anonymous access is intentional
|
|
||||||
|
|
||||||
# Listen on all interfaces (LAN + localhost)
|
|
||||||
listener 1883 0.0.0.0
|
|
||||||
|
|
||||||
# No authentication (closed network, no internet access)
|
|
||||||
allow_anonymous true
|
|
||||||
MQTTEOF
|
|
||||||
|
|
||||||
ok "Mosquitto config written: ${MOSQUITTO_CONF}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Create /opt/remoterig directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
info "Creating deploy directory..."
|
|
||||||
if [ -d "${DEPLOY_DIR}" ]; then
|
|
||||||
skip "${DEPLOY_DIR} already exists"
|
|
||||||
else
|
|
||||||
mkdir -p "${DEPLOY_DIR}"
|
|
||||||
ok "Created ${DEPLOY_DIR}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Copy config.yaml template (if provided)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [ -n "${CONFIG_TEMPLATE}" ] && [ -f "${CONFIG_TEMPLATE}" ]; then
|
|
||||||
info "Copying config.yaml template..."
|
|
||||||
if [ -f "${DEPLOY_DIR}/config.yaml" ]; then
|
|
||||||
skip "${DEPLOY_DIR}/config.yaml already exists (not overwriting)"
|
|
||||||
else
|
|
||||||
cp "${CONFIG_TEMPLATE}" "${DEPLOY_DIR}/config.yaml"
|
|
||||||
ok "Copied config.yaml to ${DEPLOY_DIR}/config.yaml"
|
|
||||||
fi
|
|
||||||
elif [ -n "${CONFIG_TEMPLATE}" ]; then
|
|
||||||
warn "Config template '${CONFIG_TEMPLATE}' not found — skipping"
|
|
||||||
else
|
|
||||||
info "No config template provided — skipping"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure service user owns the deploy directory
|
|
||||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DEPLOY_DIR}" 2>/dev/null || \
|
|
||||||
warn "Could not chown ${DEPLOY_DIR} to ${SERVICE_USER} (user may not exist yet)"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Install and enable systemd service
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
info "Installing systemd service..."
|
|
||||||
|
|
||||||
# Locate the service file relative to this script's directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
SRC_SERVICE="${SCRIPT_DIR}/remoterig.service"
|
|
||||||
|
|
||||||
if [ ! -f "${SRC_SERVICE}" ]; then
|
|
||||||
warn "Service file not found at ${SRC_SERVICE} — skipping service install"
|
|
||||||
warn "Run this script from the repository root (scripts/setup-pi.sh)"
|
|
||||||
else
|
|
||||||
DST_SERVICE="/etc/systemd/system/${SERVICE_NAME}.service"
|
|
||||||
|
|
||||||
# Copy if different
|
|
||||||
if [ -f "${DST_SERVICE}" ]; then
|
|
||||||
if cmp -s "${SRC_SERVICE}" "${DST_SERVICE}"; then
|
|
||||||
skip "systemd service already installed and up to date"
|
|
||||||
else
|
|
||||||
cp "${SRC_SERVICE}" "${DST_SERVICE}"
|
|
||||||
ok "systemd service updated"
|
|
||||||
RELOAD_SYSTEMD=1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
cp "${SRC_SERVICE}" "${DST_SERVICE}"
|
|
||||||
ok "systemd service installed"
|
|
||||||
RELOAD_SYSTEMD=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Substitute the service user
|
|
||||||
sed -i "s/^User=.*/User=${SERVICE_USER}/" "${DST_SERVICE}"
|
|
||||||
|
|
||||||
if [ "${RELOAD_SYSTEMD:-0}" -eq 1 ]; then
|
|
||||||
systemctl daemon-reload
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enable (idempotent)
|
|
||||||
if systemctl is-enabled --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
|
||||||
skip "systemd service already enabled"
|
|
||||||
else
|
|
||||||
systemctl enable "${SERVICE_NAME}"
|
|
||||||
ok "systemd service enabled"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. Set static IP on wlan0
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
info "Configuring static IP on wlan0..."
|
|
||||||
|
|
||||||
# Check if wlan0 exists
|
|
||||||
if ! ip link show wlan0 >/dev/null 2>&1; then
|
|
||||||
warn "wlan0 interface not found — skipping static IP configuration"
|
|
||||||
warn "Connect Wi-Fi first (raspi-config), then re-run this script"
|
|
||||||
else
|
|
||||||
STATIC_IP_SET=0
|
|
||||||
|
|
||||||
# --- Method A: NetworkManager (default on bookworm) ---
|
|
||||||
if command -v nmcli >/dev/null 2>&1; then
|
|
||||||
info "Using NetworkManager (nmcli)..."
|
|
||||||
|
|
||||||
# Find the Wi-Fi connection profile
|
|
||||||
WIFI_CON=$(nmcli -t -f NAME,TYPE con show 2>/dev/null | grep ':802-11-wireless' | cut -d: -f1 | head -1)
|
|
||||||
|
|
||||||
if [ -n "${WIFI_CON}" ]; then
|
|
||||||
CURRENT_IP=$(nmcli -t -f IP4.ADDRESS con show "${WIFI_CON}" 2>/dev/null | cut -d: -f2 | head -1 || true)
|
|
||||||
|
|
||||||
if [ "${CURRENT_IP}" = "${STATIC_IP}" ]; then
|
|
||||||
skip "wlan0 already set to ${STATIC_IP} via nmcli"
|
|
||||||
STATIC_IP_SET=1
|
|
||||||
else
|
|
||||||
nmcli con mod "${WIFI_CON}" ipv4.addresses "${STATIC_IP}"
|
|
||||||
nmcli con mod "${WIFI_CON}" ipv4.gateway "${GATEWAY}"
|
|
||||||
nmcli con mod "${WIFI_CON}" ipv4.dns "${GATEWAY}"
|
|
||||||
nmcli con mod "${WIFI_CON}" ipv4.method manual
|
|
||||||
nmcli con up "${WIFI_CON}" 2>/dev/null || true
|
|
||||||
ok "wlan0 set to ${STATIC_IP} via nmcli (connection: ${WIFI_CON})"
|
|
||||||
STATIC_IP_SET=1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "No Wi-Fi connection profile found in NetworkManager"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Method B: dhcpcd (fallback for older PiOS) ---
|
|
||||||
if [ ${STATIC_IP_SET} -eq 0 ] && command -v dhcpcd >/dev/null 2>&1; then
|
|
||||||
info "Using dhcpcd..."
|
|
||||||
|
|
||||||
DHCPCD_CONF="/etc/dhcpcd.conf"
|
|
||||||
|
|
||||||
if grep -q "interface wlan0" "${DHCPCD_CONF}" 2>/dev/null; then
|
|
||||||
skip "dhcpcd already has wlan0 config"
|
|
||||||
else
|
|
||||||
cat >> "${DHCPCD_CONF}" <<DHCPCDEOF
|
|
||||||
|
|
||||||
# RemoteRig static IP
|
|
||||||
interface wlan0
|
|
||||||
static ip_address=${STATIC_IP}
|
|
||||||
static routers=${GATEWAY}
|
|
||||||
static domain_name_servers=${GATEWAY}
|
|
||||||
DHCPCDEOF
|
|
||||||
ok "dhcpcd configured for wlan0 static IP"
|
|
||||||
fi
|
|
||||||
STATIC_IP_SET=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Method C: /etc/network/interfaces (last resort) ---
|
|
||||||
if [ ${STATIC_IP_SET} -eq 0 ]; then
|
|
||||||
warn "Neither nmcli nor dhcpcd found — attempting /etc/network/interfaces"
|
|
||||||
|
|
||||||
INTERFACES_FILE="/etc/network/interfaces"
|
|
||||||
if ! grep -q "iface wlan0 inet static" "${INTERFACES_FILE}" 2>/dev/null; then
|
|
||||||
cat >> "${INTERFACES_FILE}" <<NETEOF
|
|
||||||
|
|
||||||
# RemoteRig static IP
|
|
||||||
auto wlan0
|
|
||||||
iface wlan0 inet static
|
|
||||||
address ${STATIC_IP%/*}
|
|
||||||
netmask 255.255.255.0
|
|
||||||
gateway ${GATEWAY}
|
|
||||||
NETEOF
|
|
||||||
ok "wlan0 static IP configured in ${INTERFACES_FILE}"
|
|
||||||
else
|
|
||||||
skip "${INTERFACES_FILE} already has static wlan0 config"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Enable and start Mosquitto
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
info "Enabling and starting Mosquitto..."
|
|
||||||
|
|
||||||
if systemctl is-active --quiet mosquitto 2>/dev/null; then
|
|
||||||
skip "Mosquitto already running"
|
|
||||||
else
|
|
||||||
systemctl enable mosquitto 2>/dev/null || true
|
|
||||||
systemctl restart mosquitto
|
|
||||||
ok "Mosquitto started"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify Mosquitto is listening
|
|
||||||
sleep 1
|
|
||||||
if systemctl is-active --quiet mosquitto 2>/dev/null; then
|
|
||||||
ok "Mosquitto is running and listening on :1883"
|
|
||||||
else
|
|
||||||
warn "Mosquitto may not have started — check: sudo systemctl status mosquitto"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Summary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
echo ""
|
|
||||||
echo "=============================================="
|
|
||||||
echo " Setup complete!"
|
|
||||||
echo "=============================================="
|
|
||||||
echo " Mosquitto: $(systemctl is-active mosquitto 2>/dev/null || echo 'unknown')"
|
|
||||||
echo " Service: ${SERVICE_NAME} (systemctl status ${SERVICE_NAME})"
|
|
||||||
echo " Deploy dir: ${DEPLOY_DIR}"
|
|
||||||
echo " Static IP: ${STATIC_IP} on wlan0"
|
|
||||||
echo ""
|
|
||||||
echo " Next steps:"
|
|
||||||
echo " 1. Build the remoterig binary for ARM64:"
|
|
||||||
echo " GOOS=linux GOARCH=arm64 go build -o remoterig ./cmd/server"
|
|
||||||
echo " 2. Copy binary to Pi:"
|
|
||||||
echo " scp remoterig pi@192.168.4.10:/opt/remoterig/"
|
|
||||||
echo " 3. Copy config if needed:"
|
|
||||||
echo " scp config.yaml pi@192.168.4.10:/opt/remoterig/"
|
|
||||||
echo " 4. Start the service:"
|
|
||||||
echo " sudo systemctl start remoterig"
|
|
||||||
echo " 5. Check health:"
|
|
||||||
echo " curl http://192.168.4.10:8080/health"
|
|
||||||
echo ""
|
|
||||||
echo " To deploy updates, use: scripts/deploy.sh"
|
|
||||||
echo "=============================================="
|
|
||||||
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