generated from CubeCraftLabs/Tracehound
Compare commits
15 Commits
4823b746ca
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c07338707 | |||
| d538dd3b70 | |||
| b1ed8cdb20 | |||
| cb549a8803 | |||
| 832dd7cbf2 | |||
| ee947485d1 | |||
| f03dbb056d | |||
| 8e6cd11d9c | |||
| e00c8dce85 | |||
| 5239346eaa | |||
| 18db26c265 | |||
| 7929d1d969 | |||
| 9fc80a27c9 | |||
| c6d812cca2 | |||
| d2222d4947 |
@@ -9,10 +9,28 @@ import { createHash } from 'node:crypto';
|
|||||||
|
|
||||||
const { TOKEN, SERVER, REPO, SHA } = process.env;
|
const { TOKEN, SERVER, REPO, SHA } = process.env;
|
||||||
const BIN = 'remoterig';
|
const BIN = 'remoterig';
|
||||||
|
// Rolling release tag. NOT "dev" — that would collide with the dev branch
|
||||||
|
// and make refs ambiguous (git push/checkout dev breaks).
|
||||||
|
const TAG = 'dev-latest';
|
||||||
const VERSION = SHA.slice(0, 8);
|
const VERSION = SHA.slice(0, 8);
|
||||||
const API = `${SERVER}/api/v1/repos/${REPO}`;
|
const API = `${SERVER}/api/v1/repos/${REPO}`;
|
||||||
const H = { Authorization: `token ${TOKEN}` };
|
const H = { Authorization: `token ${TOKEN}` };
|
||||||
|
|
||||||
|
// The runner's network to Gitea is flaky (ECONNRESET mid-publish leaves a
|
||||||
|
// half-created release). Retry transient fetch failures so the multi-step
|
||||||
|
// publish is atomic-enough in practice.
|
||||||
|
const rfetch = async (url, opts = {}, tries = 4) => {
|
||||||
|
for (let i = 1; ; i++) {
|
||||||
|
try {
|
||||||
|
return await fetch(url, opts);
|
||||||
|
} catch (e) {
|
||||||
|
if (i >= tries) throw e;
|
||||||
|
console.log(`fetch ${url} failed (${e.cause?.code || e.message}); retry ${i}/${tries - 1}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 1000 * i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ok = async (r) => {
|
const ok = async (r) => {
|
||||||
if (!r.ok) throw new Error(`${r.status} ${r.url}\n${await r.text()}`);
|
if (!r.ok) throw new Error(`${r.status} ${r.url}\n${await r.text()}`);
|
||||||
const t = await r.text();
|
const t = await r.text();
|
||||||
@@ -27,21 +45,21 @@ const files = {
|
|||||||
'version.txt': Buffer.from(VERSION + '\n'),
|
'version.txt': Buffer.from(VERSION + '\n'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Roll the "dev" release forward to this commit: delete the old release + tag.
|
// Roll the release forward to this commit: delete the old release + tag.
|
||||||
const existing = await fetch(`${API}/releases/tags/dev`, { headers: H });
|
const existing = await rfetch(`${API}/releases/tags/${TAG}`, { headers: H });
|
||||||
if (existing.ok) {
|
if (existing.ok) {
|
||||||
const rel = await existing.json();
|
const rel = await existing.json();
|
||||||
await fetch(`${API}/releases/${rel.id}`, { method: 'DELETE', headers: H });
|
await rfetch(`${API}/releases/${rel.id}`, { method: 'DELETE', headers: H });
|
||||||
}
|
}
|
||||||
await fetch(`${API}/tags/dev`, { method: 'DELETE', headers: H }); // ignore if absent
|
await rfetch(`${API}/tags/${TAG}`, { method: 'DELETE', headers: H }); // ignore if absent
|
||||||
|
|
||||||
const rel = await ok(await fetch(`${API}/releases`, {
|
const rel = await ok(await rfetch(`${API}/releases`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...H, 'Content-Type': 'application/json' },
|
headers: { ...H, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tag_name: 'dev',
|
tag_name: TAG,
|
||||||
target_commitish: SHA,
|
target_commitish: SHA,
|
||||||
name: `dev (${VERSION})`,
|
name: `${TAG} (${VERSION})`,
|
||||||
body: `Rolling dev build ${SHA}`,
|
body: `Rolling dev build ${SHA}`,
|
||||||
prerelease: true,
|
prerelease: true,
|
||||||
}),
|
}),
|
||||||
@@ -50,7 +68,7 @@ const rel = await ok(await fetch(`${API}/releases`, {
|
|||||||
for (const [name, buf] of Object.entries(files)) {
|
for (const [name, buf] of Object.entries(files)) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('attachment', new Blob([buf]), name);
|
fd.append('attachment', new Blob([buf]), name);
|
||||||
await ok(await fetch(`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, {
|
await ok(await rfetch(`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, {
|
||||||
method: 'POST', headers: H, body: fd,
|
method: 'POST', headers: H, body: fd,
|
||||||
}));
|
}));
|
||||||
console.log(`uploaded ${name}`);
|
console.log(`uploaded ${name}`);
|
||||||
|
|||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
# RemoteRig — Project Working Context
|
||||||
|
|
||||||
|
> **Purpose of this file:** a living, high-signal context + decision log for the
|
||||||
|
> RemoteRig project. It's the primary onboarding doc for humans and for any LLM
|
||||||
|
> working on the repo. Keep it updated as decisions are made.
|
||||||
|
> **Last updated:** 2026-06-05.
|
||||||
|
>
|
||||||
|
> Deeper references: `docs/CONTEXT.md` (system architecture detail),
|
||||||
|
> `docs/MQTT_CONTRACT.md` (MQTT topics/payloads), `docs/design/` (design notes),
|
||||||
|
> `hardware/README.md` (case/wiring/BOM), `README.md` (hub build/deploy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this is
|
||||||
|
|
||||||
|
RemoteRig is a multi-camera **GoPro Hero 3 monitoring & control** system for field
|
||||||
|
recording (large concerts in auditoriums, high-school marching band at stadiums).
|
||||||
|
Founder: **Joshua / CubeCraft Creations**.
|
||||||
|
|
||||||
|
Scope: **monitor status (battery, recording, link) and start/stop multiple GoPros**
|
||||||
|
over a closed, self-contained travel-router network. **No video flows through the
|
||||||
|
hub** — footage records locally to each GoPro's SD card. This keeps the hub a
|
||||||
|
lightweight control plane.
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
GoPro Hero 3 ──Wi-Fi(10.5.5.1)── ESP-01S ──UART(JSON)── XIAO ESP32-C6 ──Wi-Fi/MQTT── Pi hub ── Dashboard
|
||||||
|
(per camera) (GoPro bridge) (MQTT bridge + OLED/LED) (Mosquitto+Go+SQLite)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Camera node** (one per GoPro), two boards:
|
||||||
|
- **XIAO ESP32-C6** — main MCU / MQTT bridge. Joins the RemoteRig Wi-Fi, talks MQTT
|
||||||
|
to the hub, drives the OLED + RGB status LED, reads camera status from the ESP-01S
|
||||||
|
over UART. Powered from the 5V rail.
|
||||||
|
- **ESP-01S (ESP8266)** — GoPro Wi-Fi bridge. Joins the GoPro's AP (`10.5.5.1`),
|
||||||
|
relays status/commands to the XIAO over UART. Powered from its **own 3.3V buck**
|
||||||
|
(not the XIAO 3V3 pin — Wi-Fi TX spikes ~300 mA).
|
||||||
|
|
||||||
|
**Hub** — Raspberry Pi Zero 2 W (hostname `remote-rig-hub`, user `overseer`):
|
||||||
|
- **Mosquitto** MQTT broker (`:1883`, anonymous, listens on `0.0.0.0`).
|
||||||
|
- **Go controller** (`remoterig`, systemd service) — MQTT subscriber → SQLite,
|
||||||
|
REST API + SSE, serves the embedded React dashboard on `:8080`.
|
||||||
|
- **SQLite** (`/opt/remoterig/remoterig.db`).
|
||||||
|
- Decided to **stay on the Zero 2 W** (workload is tiny; a Pi 5 only makes sense if
|
||||||
|
video preview/ingest is ever added — not planned).
|
||||||
|
|
||||||
|
## 3. Network
|
||||||
|
|
||||||
|
- **RemoteRig travel router**, subnet **`192.168.8.0/24`**, gateway `192.168.8.1`,
|
||||||
|
has a WAN uplink (internet available — used for the Pi to pull builds).
|
||||||
|
- **Hub static IP: `192.168.8.56`** (`:1883` MQTT, `:8080` dashboard/API).
|
||||||
|
- Cameras get DHCP `192.168.8.x`.
|
||||||
|
- The GoPro AP network (`10.5.5.1`) is separate and only the ESP-01S touches it.
|
||||||
|
- **History:** the project was originally designed around `10.60.1.0/24`; it was
|
||||||
|
re-addressed to `192.168.8.0/24` to match the actual travel router (commit
|
||||||
|
`b0062f1`). Wi-Fi SSID `RemoteRig` (creds in `firmware/data/config.json`).
|
||||||
|
|
||||||
|
## 4. Repository & workflow
|
||||||
|
|
||||||
|
- **Gitea:** `ssh://sc-gitea@code.cubecraftcreations.com:2288/CubeCraft-Creations/remote-rig`
|
||||||
|
(web/API on `https://code.cubecraftcreations.com`, private repo).
|
||||||
|
- **Default/integration branch: `dev`** (work lands here; merges from `main`).
|
||||||
|
- Layout: `firmware/` (PlatformIO), `cmd/`,`internal/`,`pkg/` (Go hub),
|
||||||
|
`src/` (React/Vite/TS dashboard), `scripts/` (Pi setup/deploy), `.gitea/workflows/`
|
||||||
|
(CI), `hardware/` (CAD/wiring), `docs/`.
|
||||||
|
|
||||||
|
## 5. Tech stack
|
||||||
|
|
||||||
|
| Area | Choice |
|
||||||
|
|------|--------|
|
||||||
|
| Camera firmware | PlatformIO / Arduino |
|
||||||
|
| C6 env | `seeed_xiao_esp32c6` — **pioarduino** platform fork, **LittleFS**, U8g2 |
|
||||||
|
| ESP-01S env | `esp8266-camera` — board `esp01_1m`, flash `dout` |
|
||||||
|
| Hub | **Go 1.25** (single static binary, `//go:embed` frontend) |
|
||||||
|
| Dashboard | React + Vite + TypeScript + Tailwind (Vitest) |
|
||||||
|
| Storage | **SQLite** (not Postgres) |
|
||||||
|
| Broker | **Mosquitto** |
|
||||||
|
| CI/CD | **Gitea Actions** (pull-based deploy) |
|
||||||
|
|
||||||
|
## 6. Camera node hardware (XIAO ESP32-C6 pin map)
|
||||||
|
|
||||||
|
| Pin | Use |
|
||||||
|
|-----|-----|
|
||||||
|
| 5V/VIN | rocker → 5V rail |
|
||||||
|
| D4/SDA, D5/SCL | 1.3" **SH1106** OLED (I2C @ `0x3C`) |
|
||||||
|
| D0 / D1 / D2 | RGB STAT LED R/G/B (220Ω each), **common-anode** |
|
||||||
|
| D6 (TX) / D7 (RX) | UART (`Serial1`) to ESP-01S (crossed) |
|
||||||
|
| D8 / D10 | **reserved** for ESP-01S UART-OTA control (RST / GPIO0) — not driven yet |
|
||||||
|
| 5V rail (330Ω) | PWR LED (not an MCU pin) |
|
||||||
|
|
||||||
|
Canonical wiring: Notion "XIAO ESP32-C6 Pin-to-Pin Wiring Diagram" + `hardware/README.md`.
|
||||||
|
|
||||||
|
## 7. Firmware behavior
|
||||||
|
|
||||||
|
**XIAO ESP32-C6 (`firmware/src/esp32-mqtt-bridge.cpp`)** — fw `0.4.0`:
|
||||||
|
- Loads config from LittleFS `/config.json` (Wi-Fi, broker, camera_id, battery cal).
|
||||||
|
- **Self-assigned camera_id** = device id `rig-<last3 MAC>` (e.g. `rig-86d978`) — see
|
||||||
|
decision #7. Subscribes `remoterig/cameras/<id>/command`, announces on
|
||||||
|
`remoterig/cameras/<id>/announce`, publishes `.../status`.
|
||||||
|
- OLED status panel (CAM id / REC + session timer / BAT / LINK / CAM reachability).
|
||||||
|
- RGB STAT LED health colors: blue=boot, red=offline, magenta=Wi-Fi-no-hub,
|
||||||
|
yellow=hub-no-camera, green=healthy.
|
||||||
|
- Battery calibration: two-point linear (raw offset-57 → %), persisted; `battery_pct`
|
||||||
|
emitted only when calibrated.
|
||||||
|
- No-reflash config: `set_camera_config` (MQTT) → forwarded to ESP-01S as `set_config`.
|
||||||
|
|
||||||
|
**ESP-01S (`firmware/src/esp8266-camera-bridge.cpp`)**:
|
||||||
|
- Joins GoPro AP, polls status, relays JSON over UART; `set_config` persists to LittleFS.
|
||||||
|
- No status LED (GPIO1 is the UART TX).
|
||||||
|
- ⚠️ **Known bug:** `fetchStatus()` GETs the **shutter** endpoint
|
||||||
|
(`/bacpac/SH?...&p=%01`) instead of a real status read — would *start recording*
|
||||||
|
each poll. Needs the GoPro Hero 3 protocol corrected + validated against a real
|
||||||
|
camera (also verify password/SSID). **Do not point at a live GoPro until fixed.**
|
||||||
|
|
||||||
|
**Provisioning:** `firmware/data/config.json` is flashed to the C6's LittleFS via
|
||||||
|
`pio run -e seeed_xiao_esp32c6 -t uploadfs`. Per maintainer decision the real Wi-Fi
|
||||||
|
password lives in this tracked file (private repo, low-sensitivity closed network).
|
||||||
|
|
||||||
|
## 8. Hub & CI/CD (pull-based deploy)
|
||||||
|
|
||||||
|
**Flow:** `push to dev` → Gitea Actions `build-dev.yaml` builds the React frontend
|
||||||
|
(into `cmd/server/src/dist`, embedded) + cross-compiles the **arm64** Go binary →
|
||||||
|
publishes a rolling **`dev-latest`** release (binary + `sha256` + `version.txt`) via
|
||||||
|
a Node script (`.gitea/scripts/publish-release.mjs`). The Pi's
|
||||||
|
`remoterig-update.timer` runs `scripts/pi-update.sh` every ~5 min → compares
|
||||||
|
`version.txt`, downloads + checksum-verifies, **atomically replaces** the binary,
|
||||||
|
restarts, health-checks (rolls back on failure).
|
||||||
|
|
||||||
|
**Why pull, not push:** the Pi is on a closed travel-router LAN the CI runner can't
|
||||||
|
reach; the Pi pulls instead.
|
||||||
|
|
||||||
|
- `ci.yaml` — frontend quality gates (lint/typecheck/test/build), single job.
|
||||||
|
- First-time Pi setup: `sudo bash scripts/setup-pi.sh --config config.yaml` (installs
|
||||||
|
Mosquitto, the service, the updater timer, static IP).
|
||||||
|
- Pi files: `/opt/remoterig/{remoterig, config.yaml, update.env, VERSION, deploy.sh, pi-update.sh}`.
|
||||||
|
- Future ESP-01S firmware OTA: `docs/design/esp01s-uart-ota.md` ("XIAO as flasher").
|
||||||
|
|
||||||
|
### Gitea Actions runner notes (important)
|
||||||
|
- Runner `remote-rig-runner`, label **`go-react`** (Dockerized act_runner). Workflows
|
||||||
|
must use `runs-on: go-react`.
|
||||||
|
- The `go-react` image has **Node but not Go** → use `setup-go` (its static binary
|
||||||
|
runs); get Node from the image (**don't** use `setup-node` — its dynamically-linked
|
||||||
|
Node won't execute here, "cannot execute: required file not found").
|
||||||
|
- Gitea doesn't support `actions/upload-artifact@v4`. No `curl`/`jq`/`sudo` on the
|
||||||
|
runner — the release publish is done in Node.
|
||||||
|
- The runner's network to github.com/Gitea is flaky (ECONNRESET) → keep few action
|
||||||
|
clones; `publish-release.mjs` retries.
|
||||||
|
- Rolling release tag is **`dev-latest`**, NOT `dev` (a `dev` tag collides with the
|
||||||
|
`dev` branch → ambiguous refs).
|
||||||
|
- Inspect CI from a dev machine with the **`tea` CLI**: `tea actions runs list|view|logs`,
|
||||||
|
`tea release list` (note "completed" ≠ success — check Conclusion).
|
||||||
|
|
||||||
|
## 9. Key decisions & gotchas (log)
|
||||||
|
|
||||||
|
1. **MCU:** ESP32-C3 Super Mini → **XIAO ESP32-C6**; C6 needs the **pioarduino**
|
||||||
|
platform fork. USB-CDC-on-boot for `Serial` over native USB.
|
||||||
|
2. **Mac build toolchain:** use `~/.platformio/penv/bin/pio` (Python 3.11), **not**
|
||||||
|
the pyenv 3.9.21 shim (too old for pioarduino).
|
||||||
|
3. **C6 filesystem = LittleFS** (pioarduino `uploadfs` builds LittleFS, not SPIFFS) —
|
||||||
|
the firmware reads `/config.json` (data file must be named `config.json`).
|
||||||
|
4. **Network re-addressed** `10.60.1.0/24` → `192.168.8.0/24`.
|
||||||
|
5. **Wi-Fi password kept in git** (`firmware/data/config.json`) — maintainer decision
|
||||||
|
(private repo, low-sensitivity, closed net).
|
||||||
|
6. **RGB LED is common-anode** (`RGB_COMMON_ANODE 1`); OLED is **SH1106** @ `0x3C`.
|
||||||
|
7. **Camera registration = "Option B" self-assigned IDs:** node uses `rig-<MAC>` as a
|
||||||
|
stable `camera_id` from first boot; the hub registers under that id. No `cam-NNN`
|
||||||
|
assignment, no `registered`-reply handshake. (`docs/MQTT_CONTRACT.md` updated.)
|
||||||
|
8. **Hub tolerates clockless status timestamps** — nodes have no RTC; firmware omits
|
||||||
|
`timestamp`, the hub stamps server-side (it used to reject the status).
|
||||||
|
9. **ESP-01S updates:** settings change live via `set_config` (no reflash); full
|
||||||
|
firmware OTA is the future XIAO-as-flasher path (`docs/design/esp01s-uart-ota.md`).
|
||||||
|
10. **Pull-based deploy** via rolling `dev-latest` release + atomic binary replace +
|
||||||
|
network retries.
|
||||||
|
11. **Pi systemd service user = `overseer`** (this Pi has no `pi` user); `setup-pi.sh`
|
||||||
|
now defaults the service user to `$SUDO_USER`.
|
||||||
|
12. **Hub embeds the frontend** via `//go:embed all:src/dist`; Vite builds into
|
||||||
|
`cmd/server/src/dist` (a committed `index.html` placeholder keeps the embed valid).
|
||||||
|
13. **SQLite/modernc datetime:** `modernc.org/sqlite` returns a `COALESCE()`/expression
|
||||||
|
as a raw string (no type affinity) → can't scan into `time.Time`. Scan plain
|
||||||
|
`DATETIME` columns (returned as `time.Time`) via `sql.NullTime`; `ListCameras`
|
||||||
|
`COALESCE`s NULL int/bool status columns. Nodes send no usable timestamp on
|
||||||
|
status/heartbeat (numeric `millis()`) — the hub ignores it / stamps server-side.
|
||||||
|
14. **Legacy id migration:** `handleAnnounce` migrates a MAC registered under a
|
||||||
|
different `camera_id` (e.g. a pre-self-id `cam-NNN`) to the node's self-id.
|
||||||
|
15. **Kiosk API auth:** `api_key: ""` in `config.yaml` = no auth on `/api/v1/*`
|
||||||
|
(closed LAN, consistent with anonymous MQTT). A non-empty key requires the SPA
|
||||||
|
to send `X-API-Key` too, or the dashboard 401s and shows no cameras.
|
||||||
|
16. **Ops gotcha:** the pull updater swaps only the **binary**. `config.yaml` is NOT
|
||||||
|
auto-deployed — change it on the Pi (`/opt/remoterig/config.yaml` + restart).
|
||||||
|
17. **GoPro Hero 3 protocol** (validated on a Silver): API host `10.5.5.9`, status
|
||||||
|
read `GET /camera/se?t=<pwd>` (binary, starts with 0x00 — read the stream, not
|
||||||
|
Arduino String), recording = byte 29, battery = byte 19; record start/stop =
|
||||||
|
`/bacpac/SH?t=<pwd>&p=%01/%00`. ESP-01S flashing needs RST tied HIGH (RST→GND
|
||||||
|
holds it in reset) and a known-good UART adapter (verify with a TX↔RX loopback).
|
||||||
|
18. **Control path:** `/cameras/{id}/start|stop` publish `{"command":...}` to
|
||||||
|
`remoterig/cameras/<id>/command` via `Subscriber.PublishCommand`; the XIAO forwards
|
||||||
|
it over UART to the ESP-01S. (The handlers used to only write a DB row — no command
|
||||||
|
was ever sent.)
|
||||||
|
19. **SSE longevity:** no global `middleware.Timeout` and `WriteTimeout: 0` — a write
|
||||||
|
deadline terminates the long-lived `/events/stream` (it was dying at 10s). The SPA
|
||||||
|
also **seeds** the list via `GET /api/v1/cameras` on mount (SSE only pushes on change).
|
||||||
|
20. **Nullable status JSON:** `battery_pct`/`video_remaining_sec` serialized as `null`
|
||||||
|
(not `omitempty`) — omitting them became `undefined` in the SPA → "NaN%".
|
||||||
|
21. **UART is two independent wires:** status (ESP `TX/GPIO1` → XIAO `D7`) and commands
|
||||||
|
(XIAO `D6` → ESP `RX/GPIO3`) are separate paths — receiving status does NOT prove
|
||||||
|
the command direction works. Verify the command path with the `set_config`
|
||||||
|
poll-interval test (status rate should change).
|
||||||
|
|
||||||
|
## 10. Conventions
|
||||||
|
|
||||||
|
- Production hub/controller in **Go**; Python fine for diagnostics/experiments/migrations.
|
||||||
|
- **SQLite**, not Postgres. **Timezone: US Eastern.**
|
||||||
|
- Work on `dev`; commit messages end with a `Co-Authored-By` trailer.
|
||||||
|
- Canonical design docs live in **Notion** (Remote Rig parent page) and the repo
|
||||||
|
`docs/`; CAD in Seafile; code/build in Gitea.
|
||||||
|
|
||||||
|
## 11. Current status & open items (2026-06-05)
|
||||||
|
|
||||||
|
**Working / proven on hardware:**
|
||||||
|
- Hub up on the Pi (Mosquitto + `remoterig` + SQLite); **dashboard renders live**
|
||||||
|
(kiosk mode `api_key:""`, SSE kept alive, list seeded via REST on mount).
|
||||||
|
- Full CI/CD loop proven: commit → CI build → `dev-latest` → Pi self-update
|
||||||
|
(checksum, atomic replace, health-check) → service active.
|
||||||
|
- C6 (fw `0.4.0`) self-IDs as `rig-86d978`, registered + listed.
|
||||||
|
- **GoPro monitoring works (Hero 3 Silver):** ESP-01S joins `goprosilver-1`, reads
|
||||||
|
`/camera/se`, and `online:true` + `battery_raw` + `video_remaining_sec` flow
|
||||||
|
GoPro → ESP-01S → XIAO → MQTT → hub → SQLite → API/SSE → dashboard.
|
||||||
|
- Hub publishes start/stop commands to `…/<id>/command` (verified on the bus).
|
||||||
|
|
||||||
|
**In progress / unresolved:**
|
||||||
|
- **Camera CONTROL not working — XIAO→ESP command wire is faulty.** Status (ESP→XIAO,
|
||||||
|
`GPIO1→D7`) works, but the command direction (XIAO `D6` → ESP `RX/GPIO3`) does not,
|
||||||
|
so `start_recording`/`set_config` never reach the ESP. Confirmed via the `set_config`
|
||||||
|
poll-interval test (status rate didn't change). Fix/re-seat that one jumper; then
|
||||||
|
Record + live config will work. (See decision #21.)
|
||||||
|
- **Battery calibration:** `battery_raw` (~56–59) flows; set `set_battery_cal`
|
||||||
|
(`raw_min/raw_max`, provisionally 0/100) for `battery_pct` — but this is a *command*,
|
||||||
|
so it's blocked by the same XIAO→ESP wire above. `video_remaining` offset (25-26)
|
||||||
|
provisional. SPA now shows "N/A" (not "NaN%") when `battery_pct` is null.
|
||||||
|
- **Pi SD-card health:** a transient `Input/output error` on core binaries cleared on
|
||||||
|
reboot — watch for recurrence (failing card); re-image via `setup-pi.sh` if it
|
||||||
|
returns (everything is reproducible from git).
|
||||||
|
- **Rotate the Gitea runner registration token** (was exposed in a setup paste).
|
||||||
|
- Gitea repo **default-branch HEAD** points at a nonexistent ref — set default branch.
|
||||||
|
- Optional: clear the stale **retained** MQTT message at
|
||||||
|
`remoterig/cameras/announce-rig-86d978` (from old firmware).
|
||||||
|
|
||||||
|
## 12. Handy commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build/flash C6 firmware (Mac):
|
||||||
|
~/.platformio/penv/bin/pio run -d firmware -e seeed_xiao_esp32c6 -t upload
|
||||||
|
~/.platformio/penv/bin/pio run -d firmware -e seeed_xiao_esp32c6 -t uploadfs # provision /config.json
|
||||||
|
# ESP-01S: needs a 3.3V USB-UART adapter with GPIO0->GND for flash mode.
|
||||||
|
|
||||||
|
# Inspect Gitea CI (Mac, tea CLI logged in as 'overseer'):
|
||||||
|
tea actions runs list --repo CubeCraft-Creations/remote-rig
|
||||||
|
tea actions runs view <id> --repo … ; tea actions runs logs <id> --repo …
|
||||||
|
tea release list --repo CubeCraft-Creations/remote-rig
|
||||||
|
|
||||||
|
# On the Pi:
|
||||||
|
sudo systemctl start remoterig-update.service # force a pull/deploy
|
||||||
|
cat /opt/remoterig/VERSION ; systemctl is-active remoterig
|
||||||
|
journalctl -u remoterig -n 40 --no-pager
|
||||||
|
mosquitto_sub -h localhost -t 'remoterig/#' -v
|
||||||
|
curl -s -H "X-API-Key: changeme" http://localhost:8080/api/v1/cameras
|
||||||
|
```
|
||||||
+13
-10
@@ -78,7 +78,9 @@ func main() {
|
|||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(cfg.WriteTimeout))
|
// No global request timeout: it cancels the long-lived SSE stream
|
||||||
|
// (/api/v1/events/stream) — that's why the dashboard never received
|
||||||
|
// camera events. Closed-LAN kiosk, so dropping it is fine.
|
||||||
|
|
||||||
// Health check (no auth)
|
// Health check (no auth)
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -87,18 +89,19 @@ 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, mqttSub)))
|
||||||
|
|
||||||
// Serve embedded React frontend with SPA fallback
|
// Serve embedded React frontend with SPA fallback
|
||||||
r.Mount("/", frontendHandler())
|
r.Mount("/", frontendHandler())
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
ReadTimeout: cfg.ReadTimeout,
|
ReadTimeout: cfg.ReadTimeout,
|
||||||
WriteTimeout: cfg.WriteTimeout,
|
// WriteTimeout intentionally 0: SSE responses are long-lived and a
|
||||||
IdleTimeout: cfg.IdleTimeout,
|
// write deadline would terminate them mid-stream.
|
||||||
|
IdleTimeout: cfg.IdleTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
@@ -119,7 +122,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiRouter creates the API route tree.
|
// apiRouter creates the API route tree.
|
||||||
func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
|
func apiRouter(sseHub *events.Hub, database *db.DB, pub api.CommandPublisher) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Camera management routes
|
// Camera management routes
|
||||||
@@ -128,8 +131,8 @@ func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
|
|||||||
r.Get("/cameras/{id}", api.GetCameraDetail(database))
|
r.Get("/cameras/{id}", api.GetCameraDetail(database))
|
||||||
|
|
||||||
// Recording control routes
|
// Recording control routes
|
||||||
r.Post("/cameras/{id}/start", api.StartRecording(database))
|
r.Post("/cameras/{id}/start", api.StartRecording(database, pub))
|
||||||
r.Post("/cameras/{id}/stop", api.StopRecording(database))
|
r.Post("/cameras/{id}/stop", api.StopRecording(database, pub))
|
||||||
|
|
||||||
// Status ingestion (from ESP32 nodes)
|
// Status ingestion (from ESP32 nodes)
|
||||||
r.Post("/cameras/{id}/status", api.PushStatus(database))
|
r.Post("/cameras/{id}/status", api.PushStatus(database))
|
||||||
|
|||||||
+5
-2
@@ -4,8 +4,11 @@
|
|||||||
# Database
|
# Database
|
||||||
db_path: "remoterig.db"
|
db_path: "remoterig.db"
|
||||||
|
|
||||||
# API Key for endpoint authentication
|
# API key for endpoint authentication. Empty = kiosk mode (no auth) —
|
||||||
api_key: "changeme"
|
# intended for the closed travel-router LAN, consistent with anonymous MQTT.
|
||||||
|
# Set a value to require the X-API-Key header on /api/v1/* (the SPA would
|
||||||
|
# then need it too).
|
||||||
|
api_key: ""
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
+12
-4
@@ -176,11 +176,19 @@ Published once on ESP32 first boot (or factory reset). Used for auto-registratio
|
|||||||
| `capabilities` | string[] | Supported features |
|
| `capabilities` | string[] | Supported features |
|
||||||
| `friendly_name` | string | Default human-readable name |
|
| `friendly_name` | string | Default human-readable name |
|
||||||
|
|
||||||
**Hub behavior on first announce:**
|
**Camera IDs (self-assigned — "Option B"):** the node uses a stable
|
||||||
|
device-derived id (`rig-<last3 MAC bytes>`, e.g. `rig-86d978`) as its
|
||||||
|
`camera_id` from first boot, and uses it for all topics
|
||||||
|
(`announce`/`status`/`heartbeat`/`command`). There is no hub-assigned
|
||||||
|
`cam-NNN` and no `registered` reply handshake.
|
||||||
|
|
||||||
|
**Hub behavior on announce:**
|
||||||
1. Check if MAC already registered → if yes, update `friendly_name` and log
|
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)
|
2. If new MAC → insert the camera using the node's self-assigned `camera_id`
|
||||||
3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
|
3. Broadcast via SSE that a new camera appeared
|
||||||
4. Broadcast via SSE that a new camera appeared
|
|
||||||
|
> Note: nodes have no real-time clock, so `timestamp` may be absent; the hub
|
||||||
|
> stamps received-time server-side.
|
||||||
|
|
||||||
### Topic: `remoterig/hub/status`
|
### Topic: `remoterig/hub/status`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"camera_ssid": "GOPRO-BP-",
|
"camera_ssid": "goprosilver-1",
|
||||||
"camera_password": "goprohero",
|
"camera_password": "Bzyeatn421",
|
||||||
"camera_ip": "10.5.5.1",
|
"camera_ip": "10.5.5.9",
|
||||||
"poll_interval_sec": 30
|
"poll_interval_sec": 30
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,23 +311,25 @@ bool connectMQTT() {
|
|||||||
|
|
||||||
Serial.println("[MQTT] Connected");
|
Serial.println("[MQTT] Connected");
|
||||||
|
|
||||||
// Subscribe to commands (if registered)
|
// Option B: self-assigned, stable camera_id derived from the device id.
|
||||||
if (cfg.camera_id.length() > 0) {
|
if (cfg.camera_id.length() == 0) {
|
||||||
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
cfg.camera_id = clientID(); // e.g. "rig-86d978"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Announce if new
|
// Subscribe to our command topic.
|
||||||
if (cfg.camera_id.length() == 0) {
|
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
||||||
|
|
||||||
|
// Announce (retained) on the contract topic so the hub registers/tracks us.
|
||||||
|
{
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
doc["mac_address"] = WiFi.macAddress();
|
doc["mac_address"] = WiFi.macAddress();
|
||||||
doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge";
|
doc["firmware_version"] = "0.4.0-esp32-mqtt-bridge";
|
||||||
doc["friendly_name"] = "Cam-" + clientID();
|
doc["friendly_name"] = "Cam-" + cfg.camera_id;
|
||||||
JsonArray caps = doc["capabilities"].to<JsonArray>();
|
JsonArray caps = doc["capabilities"].to<JsonArray>();
|
||||||
caps.add("start_stop"); caps.add("status");
|
caps.add("start_stop"); caps.add("status");
|
||||||
String payload; serializeJson(doc, payload);
|
String payload; serializeJson(doc, payload);
|
||||||
String announceTopic = "remoterig/cameras/announce-" + clientID();
|
mqtt.publish(mqttTopic("announce").c_str(), payload.c_str(), true);
|
||||||
mqtt.publish(announceTopic.c_str(), payload.c_str(), true);
|
Serial.printf("[MQTT] Announced as %s\n", cfg.camera_id.c_str());
|
||||||
Serial.println("[MQTT] Announced for registration");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -489,7 +491,7 @@ void loop() {
|
|||||||
// Build the MQTT status payload per contract
|
// Build the MQTT status payload per contract
|
||||||
JsonDocument mqttDoc;
|
JsonDocument mqttDoc;
|
||||||
mqttDoc["camera_id"] = cfg.camera_id;
|
mqttDoc["camera_id"] = cfg.camera_id;
|
||||||
mqttDoc["timestamp"] = millis();
|
// No timestamp: the node has no real clock; the hub stamps on receipt.
|
||||||
mqttDoc["battery_raw"] = dispBatteryRaw;
|
mqttDoc["battery_raw"] = dispBatteryRaw;
|
||||||
int pct = batteryPct(dispBatteryRaw);
|
int pct = batteryPct(dispBatteryRaw);
|
||||||
if (pct >= 0) mqttDoc["battery_pct"] = pct; // omit when uncalibrated
|
if (pct >= 0) mqttDoc["battery_pct"] = pct; // omit when uncalibrated
|
||||||
|
|||||||
@@ -41,9 +41,11 @@
|
|||||||
// ────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
String camera_ssid = "GOPRO-BP-";
|
// Defaults validated against a GoPro Hero 3 Silver. Per-camera values can
|
||||||
String camera_password = "goprohero";
|
// be overridden at runtime via the set_config command (no reflash).
|
||||||
String camera_ip = "10.5.5.1";
|
String camera_ssid = "goprosilver-1";
|
||||||
|
String camera_password = "Bzyeatn421";
|
||||||
|
String camera_ip = "10.5.5.9"; // Hero 3 HTTP API host (not .1)
|
||||||
int poll_interval_sec = 30;
|
int poll_interval_sec = 30;
|
||||||
} cfg;
|
} cfg;
|
||||||
|
|
||||||
@@ -96,26 +98,37 @@ struct CamStatus {
|
|||||||
CamStatus fetchStatus() {
|
CamStatus fetchStatus() {
|
||||||
CamStatus s;
|
CamStatus s;
|
||||||
|
|
||||||
String url = "http://" + cfg.camera_ip +
|
// READ status — must NOT be the shutter endpoint. Hero 3 status blob
|
||||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
|
// (validated on a Hero 3 Silver, ~31 bytes):
|
||||||
|
// [29] recording flag (0 idle / 1 recording) — confirmed
|
||||||
|
// [19] battery level (raw; drains with charge) — calibrate on the hub
|
||||||
|
// [25..26] video-remaining (provisional)
|
||||||
|
// The body is binary and starts with 0x00, so read the stream directly —
|
||||||
|
// Arduino String truncates at the first null byte.
|
||||||
|
String url = "http://" + cfg.camera_ip + "/camera/se?t=" + cfg.camera_password;
|
||||||
|
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
http.useHTTP10(true);
|
http.useHTTP10(true);
|
||||||
http.begin(goproClient, url);
|
http.begin(goproClient, url);
|
||||||
http.setTimeout(5000);
|
http.setTimeout(5000);
|
||||||
int code = http.GET();
|
int code = http.GET();
|
||||||
|
|
||||||
if (code != 200) { http.end(); return s; }
|
if (code != 200) { http.end(); return s; }
|
||||||
|
|
||||||
String raw = http.getString();
|
uint8_t buf[40] = {0};
|
||||||
|
WiFiClient* stream = http.getStreamPtr();
|
||||||
|
size_t n = 0;
|
||||||
|
unsigned long t0 = millis();
|
||||||
|
while (n < sizeof(buf) && millis() - t0 < 1500) {
|
||||||
|
if (stream && stream->available()) buf[n++] = (uint8_t)stream->read();
|
||||||
|
else delay(5);
|
||||||
|
}
|
||||||
http.end();
|
http.end();
|
||||||
if (raw.length() < 58) return s;
|
if (n < 30) return s;
|
||||||
|
|
||||||
const uint8_t* buf = (const uint8_t*)raw.c_str();
|
s.valid = true;
|
||||||
s.valid = true;
|
s.recording = (buf[29] == 1);
|
||||||
|
s.battery_raw = buf[19];
|
||||||
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
||||||
s.recording = (buf[29] == 1);
|
|
||||||
s.battery_raw = buf[57];
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ func setupTestRouter(t *testing.T) (*db.DB, chi.Router) {
|
|||||||
r.Get("/cameras", ListCameras(database))
|
r.Get("/cameras", ListCameras(database))
|
||||||
r.Post("/cameras", RegisterCamera(database))
|
r.Post("/cameras", RegisterCamera(database))
|
||||||
r.Get("/cameras/{id}", GetCameraDetail(database))
|
r.Get("/cameras/{id}", GetCameraDetail(database))
|
||||||
r.Post("/cameras/{id}/start", StartRecording(database))
|
r.Post("/cameras/{id}/start", StartRecording(database, nil))
|
||||||
r.Post("/cameras/{id}/stop", StopRecording(database))
|
r.Post("/cameras/{id}/stop", StopRecording(database, nil))
|
||||||
r.Post("/cameras/{id}/status", PushStatus(database))
|
r.Post("/cameras/{id}/status", PushStatus(database))
|
||||||
|
|
||||||
return database, r
|
return database, r
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
|||||||
c.friendly_name,
|
c.friendly_name,
|
||||||
s.battery_pct,
|
s.battery_pct,
|
||||||
s.video_remaining_sec,
|
s.video_remaining_sec,
|
||||||
s.recording_state,
|
COALESCE(s.recording_state, 0),
|
||||||
s.mode,
|
s.mode,
|
||||||
s.resolution,
|
s.resolution,
|
||||||
s.fps,
|
s.fps,
|
||||||
s.online,
|
COALESCE(s.online, 0),
|
||||||
s.recorded_at
|
s.recorded_at
|
||||||
FROM cameras c
|
FROM cameras c
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@@ -52,15 +52,17 @@ func ListCameras(database *db.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sl models.StatusLog
|
var sl models.StatusLog
|
||||||
var c models.Camera
|
var c models.Camera
|
||||||
|
var recordedAt sql.NullTime // NULL for a camera with no status yet
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.CameraID, &c.FriendlyName,
|
&c.CameraID, &c.FriendlyName,
|
||||||
&sl.BatteryPct, &sl.VideoRemainingSec,
|
&sl.BatteryPct, &sl.VideoRemainingSec,
|
||||||
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
|
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
|
||||||
&sl.Online, &sl.RecordedAt,
|
&sl.Online, &recordedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Printf("Error scanning camera row: %v", err)
|
log.Printf("Error scanning camera row: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
sl.RecordedAt = recordedAt.Time // zero time if no status
|
||||||
statuses = append(statuses, models.NewCameraStatus(c, sl))
|
statuses = append(statuses, models.NewCameraStatus(c, sl))
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CommandPublisher sends a command to a camera (implemented by the MQTT
|
||||||
|
// subscriber). Nil is allowed (e.g. in tests) — the command is then skipped.
|
||||||
|
type CommandPublisher interface {
|
||||||
|
PublishCommand(cameraID, command string) error
|
||||||
|
}
|
||||||
|
|
||||||
// StartRecording returns a handler for POST /cameras/{id}/start.
|
// StartRecording returns a handler for POST /cameras/{id}/start.
|
||||||
func StartRecording(database *db.DB) http.HandlerFunc {
|
func StartRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if !validateCameraID(w, cameraID) {
|
if !validateCameraID(w, cameraID) {
|
||||||
@@ -45,6 +51,15 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
|||||||
rowsAffected, _ := result.RowsAffected()
|
rowsAffected, _ := result.RowsAffected()
|
||||||
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
|
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
|
||||||
|
|
||||||
|
// Send the actual command to the camera over MQTT.
|
||||||
|
if pub != nil {
|
||||||
|
if err := pub.PublishCommand(cameraID, "start_recording"); err != nil {
|
||||||
|
log.Printf("Error sending start_recording to %s: %v", cameraID, err)
|
||||||
|
respondError(w, http.StatusBadGateway, "failed to send command to camera", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"status": "recording_started",
|
"status": "recording_started",
|
||||||
"camera_id": cameraID,
|
"camera_id": cameraID,
|
||||||
@@ -53,7 +68,7 @@ func StartRecording(database *db.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StopRecording returns a handler for POST /cameras/{id}/stop.
|
// StopRecording returns a handler for POST /cameras/{id}/stop.
|
||||||
func StopRecording(database *db.DB) http.HandlerFunc {
|
func StopRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cameraID := chi.URLParam(r, "id")
|
cameraID := chi.URLParam(r, "id")
|
||||||
if !validateCameraID(w, cameraID) {
|
if !validateCameraID(w, cameraID) {
|
||||||
@@ -88,6 +103,15 @@ func StopRecording(database *db.DB) http.HandlerFunc {
|
|||||||
rowsAffected, _ := result.RowsAffected()
|
rowsAffected, _ := result.RowsAffected()
|
||||||
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
|
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
|
||||||
|
|
||||||
|
// Send the actual command to the camera over MQTT.
|
||||||
|
if pub != nil {
|
||||||
|
if err := pub.PublishCommand(cameraID, "stop_recording"); err != nil {
|
||||||
|
log.Printf("Error sending stop_recording to %s: %v", cameraID, err)
|
||||||
|
respondError(w, http.StatusBadGateway, "failed to send command to camera", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"status": "recording_stopped",
|
"status": "recording_stopped",
|
||||||
"camera_id": cameraID,
|
"camera_id": cameraID,
|
||||||
|
|||||||
+36
-28
@@ -143,6 +143,18 @@ type statusPayload struct {
|
|||||||
UptimeSec *int `json:"uptime_sec"`
|
UptimeSec *int `json:"uptime_sec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishCommand sends a command (e.g. "start_recording") to a camera's
|
||||||
|
// command topic, which its ESP32 bridge subscribes to and forwards over UART.
|
||||||
|
func (s *Subscriber) PublishCommand(cameraID, command string) error {
|
||||||
|
topic := "remoterig/cameras/" + cameraID + "/command"
|
||||||
|
payload, _ := json.Marshal(map[string]string{"command": command})
|
||||||
|
tok := s.client.Publish(topic, 2, false, payload)
|
||||||
|
if !tok.WaitTimeout(3 * time.Second) {
|
||||||
|
return fmt.Errorf("publish to %s timed out", topic)
|
||||||
|
}
|
||||||
|
return tok.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
|
func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
|
||||||
var sp statusPayload
|
var sp statusPayload
|
||||||
if err := json.Unmarshal(payload, &sp); err != nil {
|
if err := json.Unmarshal(payload, &sp); err != nil {
|
||||||
@@ -151,22 +163,20 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if sp.CameraID == "" || sp.Timestamp == "" {
|
if sp.CameraID == "" {
|
||||||
log.Printf("MQTT status missing required fields (camera_id, timestamp) from %s", cameraID)
|
log.Printf("MQTT status missing camera_id from %s", cameraID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate timestamp sanity (reject >5min future, >24h past)
|
// Nodes have no real clock, so tolerate an empty/invalid timestamp by
|
||||||
|
// stamping server-side. Still clamp obviously-bad supplied times below.
|
||||||
|
now := time.Now()
|
||||||
ts, err := time.Parse(time.RFC3339, sp.Timestamp)
|
ts, err := time.Parse(time.RFC3339, sp.Timestamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try ISO8601 without timezone
|
if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil {
|
||||||
ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp)
|
ts = now
|
||||||
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)) {
|
if ts.After(now.Add(5 * time.Minute)) {
|
||||||
log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID)
|
log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID)
|
||||||
ts = now
|
ts = now
|
||||||
@@ -284,10 +294,12 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
|
|||||||
// ── Heartbeat handler ───────────────────────────────────────────────────
|
// ── Heartbeat handler ───────────────────────────────────────────────────
|
||||||
|
|
||||||
type heartbeatPayload struct {
|
type heartbeatPayload struct {
|
||||||
CameraID string `json:"camera_id"`
|
CameraID string `json:"camera_id"`
|
||||||
Timestamp string `json:"timestamp"`
|
// No Timestamp field: the node sends a numeric millis() value and the
|
||||||
UptimeSec *int `json:"uptime_sec"`
|
// handler doesn't use it; omitting the field lets it be ignored instead
|
||||||
FreeHeap *int `json:"free_heap"`
|
// of failing JSON unmarshal (number into string).
|
||||||
|
UptimeSec *int `json:"uptime_sec"`
|
||||||
|
FreeHeap *int `json:"free_heap"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subscriber) handleHeartbeat(cameraID string, payload []byte) {
|
func (s *Subscriber) handleHeartbeat(cameraID string, payload []byte) {
|
||||||
@@ -362,8 +374,8 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
|
|||||||
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
|
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
|
||||||
).Scan(&existingID)
|
).Scan(&existingID)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil && existingID == cameraID {
|
||||||
// Already registered — just update friendly_name
|
// Same self-id re-connecting — just refresh friendly_name.
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
"UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?",
|
"UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?",
|
||||||
ap.FriendlyName, existingID,
|
ap.FriendlyName, existingID,
|
||||||
@@ -374,30 +386,26 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
|
|||||||
}
|
}
|
||||||
log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName)
|
log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName)
|
||||||
} else {
|
} else {
|
||||||
// New camera — generate sequential cam-NNN ID
|
// MAC known under a different id (legacy cam-NNN from before self-IDs)
|
||||||
var maxID string
|
// → drop the old row so we re-register under the node's self-id.
|
||||||
s.db.QueryRow("SELECT MAX(camera_id) FROM cameras").Scan(&maxID)
|
if err == nil && existingID != cameraID {
|
||||||
|
s.db.Exec("DELETE FROM cameras WHERE camera_id = ?", existingID)
|
||||||
seq := 1
|
log.Printf("MQTT announce: migrating %s -> %s (%s)", existingID, cameraID, ap.FriendlyName)
|
||||||
if maxID != "" {
|
|
||||||
fmt.Sscanf(maxID, "cam-%d", &seq)
|
|
||||||
seq++
|
|
||||||
}
|
}
|
||||||
|
// Option B: the node self-assigns its camera_id (the announce topic id).
|
||||||
newID := fmt.Sprintf("cam-%03d", seq)
|
|
||||||
_, err = s.db.Exec(`
|
_, err = s.db.Exec(`
|
||||||
INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at)
|
INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
||||||
`, newID, ap.FriendlyName, ap.MacAddress)
|
`, cameraID, ap.FriendlyName, ap.MacAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("MQTT announce insert error for %s: %v", ap.MacAddress, err)
|
log.Printf("MQTT announce insert error for %s: %v", ap.MacAddress, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("MQTT announce: new camera registered as %s (%s)", newID, ap.FriendlyName)
|
log.Printf("MQTT announce: new camera registered as %s (%s)", cameraID, ap.FriendlyName)
|
||||||
|
|
||||||
// Broadcast new camera via SSE
|
// Broadcast new camera via SSE
|
||||||
cam, err := getCamera(s.db, newID)
|
cam, err := getCamera(s.db, cameraID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.hub.Broadcast("camera_registered", cam)
|
s.hub.Broadcast("camera_registered", cam)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ type StatusLog struct {
|
|||||||
type CameraStatus struct {
|
type CameraStatus struct {
|
||||||
CameraID string `json:"camera_id"`
|
CameraID string `json:"camera_id"`
|
||||||
FriendlyName string `json:"friendly_name"`
|
FriendlyName string `json:"friendly_name"`
|
||||||
BatteryPct *int `json:"battery_pct,omitempty"`
|
// Not omitempty: the SPA expects these as `number | null`. Omitting them
|
||||||
VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
|
// makes the field `undefined` in JS, which slips past null checks and
|
||||||
|
// renders as "NaN%".
|
||||||
|
BatteryPct *int `json:"battery_pct"`
|
||||||
|
VideoRemainingSec *int `json:"video_remaining_sec"`
|
||||||
Recording bool `json:"recording"`
|
Recording bool `json:"recording"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
|
|||||||
+9
-4
@@ -72,8 +72,12 @@ fi
|
|||||||
# 2. Deploy new binary
|
# 2. Deploy new binary
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
info "Deploying new binary..."
|
info "Deploying new binary..."
|
||||||
cp "${BINARY}" "${DEPLOY_PATH}"
|
# Atomic replace: copy alongside then rename over the target. A plain
|
||||||
chmod +x "${DEPLOY_PATH}"
|
# cp over a running executable fails with "Text file busy"; rename swaps
|
||||||
|
# the directory entry and works while the old binary is still running.
|
||||||
|
cp "${BINARY}" "${DEPLOY_PATH}.new"
|
||||||
|
chmod +x "${DEPLOY_PATH}.new"
|
||||||
|
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
|
||||||
ok "Binary installed at ${DEPLOY_PATH}"
|
ok "Binary installed at ${DEPLOY_PATH}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -116,8 +120,9 @@ else
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
if [ -f "${BACKUP}" ]; then
|
if [ -f "${BACKUP}" ]; then
|
||||||
info "Restoring backup: ${BACKUP}"
|
info "Restoring backup: ${BACKUP}"
|
||||||
cp "${BACKUP}" "${DEPLOY_PATH}"
|
cp "${BACKUP}" "${DEPLOY_PATH}.new"
|
||||||
chmod +x "${DEPLOY_PATH}"
|
chmod +x "${DEPLOY_PATH}.new"
|
||||||
|
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
|
||||||
|
|
||||||
systemctl restart "${SERVICE}" 2>/dev/null || true
|
systemctl restart "${SERVICE}" 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ REPO="${REPO:-CubeCraft-Creations/remote-rig}"
|
|||||||
DEPLOY_DIR="/opt/remoterig"
|
DEPLOY_DIR="/opt/remoterig"
|
||||||
DEPLOY_PATH="${DEPLOY_PATH:-$DEPLOY_DIR/remoterig}"
|
DEPLOY_PATH="${DEPLOY_PATH:-$DEPLOY_DIR/remoterig}"
|
||||||
SERVICE="${SERVICE:-remoterig}"
|
SERVICE="${SERVICE:-remoterig}"
|
||||||
TAG="dev"
|
TAG="dev-latest"
|
||||||
DL="$GITEA_BASE/$REPO/releases/download/$TAG"
|
DL="$GITEA_BASE/$REPO/releases/download/$TAG"
|
||||||
VERSION_FILE="$DEPLOY_DIR/VERSION"
|
VERSION_FILE="$DEPLOY_DIR/VERSION"
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
# --config PATH Path to config.yaml template to copy to /opt/remoterig/
|
# --config PATH Path to config.yaml template to copy to /opt/remoterig/
|
||||||
# --service-user USER Systemd service user (default: pi)
|
# --service-user USER Systemd service user (default: invoking sudo user, else pi)
|
||||||
# --static-ip IP Static IP for wlan0 (default: 192.168.8.56/24)
|
# --static-ip IP Static IP for wlan0 (default: 192.168.8.56/24)
|
||||||
# --gateway IP Gateway for wlan0 (default: 192.168.8.1)
|
# --gateway IP Gateway for wlan0 (default: 192.168.8.1)
|
||||||
# --help Show this help
|
# --help Show this help
|
||||||
@@ -19,7 +19,7 @@ set -euo pipefail
|
|||||||
# Defaults
|
# Defaults
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
CONFIG_TEMPLATE=""
|
CONFIG_TEMPLATE=""
|
||||||
SERVICE_USER="pi"
|
SERVICE_USER="${SUDO_USER:-pi}" # default to the invoking user (not every Pi has a 'pi' user)
|
||||||
STATIC_IP="192.168.8.56/24"
|
STATIC_IP="192.168.8.56/24"
|
||||||
GATEWAY="192.168.8.1"
|
GATEWAY="192.168.8.1"
|
||||||
MOSQUITTO_PKG="mosquitto mosquitto-clients"
|
MOSQUITTO_PKG="mosquitto mosquitto-clients"
|
||||||
|
|||||||
+9
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||||
import { Camera, Play, Square, Wifi, WifiOff, AlertTriangle } from 'lucide-react'
|
import { Camera, Play, Square, Wifi, WifiOff, AlertTriangle } from 'lucide-react'
|
||||||
import { useSSE } from './hooks/useSSE'
|
import { useSSE } from './hooks/useSSE'
|
||||||
import { useCameraStore } from './store/useCameraStore'
|
import { useCameraStore } from './store/useCameraStore'
|
||||||
@@ -15,6 +15,14 @@ function App() {
|
|||||||
// SSE connection + live store
|
// SSE connection + live store
|
||||||
const { connectionState } = useSSE()
|
const { connectionState } = useSSE()
|
||||||
|
|
||||||
|
// Seed the list once on mount via the REST API. SSE only pushes on change,
|
||||||
|
// so without this the dashboard is empty until the next status event.
|
||||||
|
useEffect(() => {
|
||||||
|
api.getCameras()
|
||||||
|
.then((list) => useCameraStore.getState().setCameras(list))
|
||||||
|
.catch(() => { /* SSE will fill in shortly */ })
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Subscribe to full camera state — dashboard needs every change
|
// Subscribe to full camera state — dashboard needs every change
|
||||||
const camerasMap = useCameraStore((s) => s.cameras)
|
const camerasMap = useCameraStore((s) => s.cameras)
|
||||||
const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap])
|
const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap])
|
||||||
|
|||||||
Reference in New Issue
Block a user