15 Commits

Author SHA1 Message Date
Joshua King 7c07338707 docs: update CONTEXT.md — control-path wiring, dashboard, decisions 18-21
Build (Dev) / build (push) Successful in 13s
CI / quality (push) Successful in 12s
CI / quality (pull_request) Successful in 11s
- §11: dashboard now renders live (SSE/seed/kiosk); GoPro monitoring works;
  flag that camera CONTROL is blocked by the faulty XIAO->ESP command wire
  (status RX works, command TX doesn't). Dedupe the token/default-branch lines.
- §9: add decisions for the MQTT control path, SSE longevity + REST seed,
  nullable status JSON (NaN% fix), and UART being two independent wires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:57:07 -04:00
Joshua King d538dd3b70 hub: actually send start/stop commands over MQTT
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 11s
CI / quality (pull_request) Successful in 11s
The /cameras/{id}/start and /stop handlers only wrote a recording_events
row — they never published the command, so the camera never recorded.
Add Subscriber.PublishCommand (publishes {"command":...} to
remoterig/cameras/<id>/command, which the XIAO forwards to the ESP-01S),
thread a CommandPublisher into the recording handlers, and wire mqttSub in
via apiRouter. Tests pass nil (publish skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:28:26 -04:00
Joshua King b1ed8cdb20 hub: emit battery_pct/video_remaining as null, not omitted
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 11s
CI / quality (pull_request) Successful in 11s
The SPA types these as number|null and null-checks them, but omitempty
dropped the field entirely when uncalibrated → undefined in JS → "NaN%".
Always serialize the field (null when unknown) so the card shows "N/A".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:22:06 -04:00
Joshua King cb549a8803 fix(dashboard): keep SSE alive + seed camera list via REST
Build (Dev) / build (push) Successful in 19s
CI / quality (push) Successful in 18s
CI / quality (pull_request) Successful in 16s
The dashboard showed "No Cameras Connected" despite the API returning the
camera:
- middleware.Timeout + http.Server.WriteTimeout (10s) cancelled the
  long-lived /api/v1/events/stream every 10s, before any 30s status event
  could arrive — so the SSE-fed store never populated. Drop the global
  request timeout and set WriteTimeout=0 (closed-LAN kiosk).
- The SPA never seeded from GET /api/v1/cameras (SSE only pushes on change).
  Fetch the list once on mount and setCameras().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:14:13 -04:00
Joshua King 832dd7cbf2 hub: default to kiosk mode (empty api_key) for the closed LAN
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 11s
CI / quality (pull_request) Successful in 10s
The SPA doesn't send X-API-Key, so a non-empty api_key made the dashboard
401 and show no cameras. Default api_key to "" (no auth) for the closed
travel-router network, consistent with anonymous MQTT. Document the kiosk
decision, the GoPro Hero 3 protocol, and the gotcha that the pull updater
deploys only the binary (config.yaml must be changed on the Pi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:45:28 -04:00
Joshua King ee947485d1 firmware(esp-01s): real GoPro Hero 3 status read (was the shutter bug)
Build (Dev) / build (push) Successful in 10s
CI / quality (push) Successful in 10s
CI / quality (pull_request) Successful in 10s
Validated against a Hero 3 Silver:
- fetchStatus() now GETs the status endpoint /camera/se (was /bacpac/SH?p=%01,
  which *started recording* every poll), at the correct host 10.5.5.9.
- Read the response from the stream, not getString(): the blob is binary and
  starts with 0x00, which truncated the Arduino String to empty.
- Offsets: recording = byte 29 (confirmed by not-recording vs recording diff),
  battery_raw = byte 19 (drains with charge; calibrate on the hub),
  video_remaining = bytes 25-26 (provisional).
- Default config set to this camera (goprosilver-1 / 10.5.5.9); per-camera
  values can still be overridden at runtime via set_config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:21:14 -04:00
Joshua King f03dbb056d docs: CONTEXT.md — mark camera pipeline end-to-end verified
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 11s
CI / quality (pull_request) Successful in 11s
Camera rig-86d978 registers + lists in the API/dashboard with status
ingested. Add decisions for modernc/sqlite datetime scanning and legacy
camera-id migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:37:57 -04:00
Joshua King 8e6cd11d9c hub: scan recorded_at via sql.NullTime in ListCameras
Build (Dev) / build (push) Successful in 1m13s
CI / quality (push) Successful in 12s
CI / quality (pull_request) Failing after 0s
modernc/sqlite returns a COALESCE() expression as a raw string (no column
type affinity), which can't scan into *time.Time. Drop the COALESCE on the
timestamp and scan the plain DATETIME column (which modernc returns as
time.Time) through sql.NullTime, so a camera with no status row yet lists
with a zero time instead of erroring out the whole list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:34:08 -04:00
Joshua King e00c8dce85 hub: fix camera listing, heartbeat parse, and legacy-id migration
Build (Dev) / build (push) Successful in 10s
CI / quality (push) Successful in 10s
CI / quality (pull_request) Successful in 11s
Three bugs surfaced once the camera reported in:

- ListCameras LEFT JOIN returns NULL status columns for a camera with no
  status rows yet, which failed scanning into non-nullable int/time fields
  (recording_state, online, recorded_at) and emptied the whole list.
  COALESCE them (recorded_at falls back to the camera's created_at).
- handleHeartbeat rejected every heartbeat ("cannot unmarshal number into
  string") because the node sends a numeric millis() timestamp. The handler
  doesn't use it, so drop the Timestamp field and let it be ignored.
- handleAnnounce kept a stale cam-NNN row registered by MAC under the old
  (pre-self-id) scheme, so self-id status inserts hit a FOREIGN KEY error.
  When a MAC is known under a different id than the node's self-id, migrate:
  drop the old row and re-register under the self-id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:24:20 -04:00
Joshua King 5239346eaa docs: add root CONTEXT.md project working-context / decision log
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 11s
CI / quality (pull_request) Successful in 11s
Living context + decision log for humans and LLMs: architecture, network,
repo workflow, hardware pin map, firmware behavior, pull-based CI/CD,
key decisions/gotchas, current status, and handy commands. Cross-links the
deeper docs/ references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:06:11 -04:00
Joshua King 18db26c265 ci: retry transient network errors when publishing the release
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 10s
CI / quality (pull_request) Successful in 10s
The publish step died with "fetch failed: ECONNRESET" mid-run, leaving a
half-created release (no version.txt asset → the Pi got 404s). Wrap the
Gitea API calls in a small retry (rfetch) so a flaky connection doesn't
leave the rolling release incomplete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:44:38 -04:00
Joshua King 7929d1d969 registration: self-assigned camera IDs (Option B) + tolerate clockless status
Build (Dev) / build (push) Failing after 16s
CI / quality (push) Failing after 0s
CI / quality (pull_request) Successful in 11s
Auto-registration never completed: the firmware announced on the wrong
topic, the hub never replied, and an unregistered node couldn't receive a
reply anyway. Switch to self-assigned IDs:

firmware (esp32-mqtt-bridge.cpp):
- camera_id defaults to the device id (clientID, e.g. rig-86d978)
- always subscribe to <id>/command; announce on the contract topic
  remoterig/cameras/<id>/announce (was the unmatched announce-<id> form)
- drop the bogus numeric timestamp from status (node has no clock)

hub (subscriber.go):
- handleAnnounce registers new cameras under the node's self-assigned id
  (no cam-NNN, no registered reply)
- handleStatus tolerates an empty/invalid timestamp and stamps server-side
  (previously rejected the status outright)

docs/MQTT_CONTRACT.md updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:14:00 -04:00
Joshua King 9fc80a27c9 deploy: atomic binary replace (fix "Text file busy")
Build (Dev) / build (push) Successful in 10s
CI / quality (push) Successful in 10s
CI / quality (pull_request) Successful in 10s
cp over /opt/remoterig/remoterig fails with "Text file busy" once the
service is running. Copy to a .new file and rename over the target
(works on a live binary), in both the deploy and rollback paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:57:15 -04:00
Joshua King c6d812cca2 ci: rename rolling release tag dev -> dev-latest
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 12s
CI / quality (pull_request) Failing after 0s
A release tag named "dev" collides with the dev branch, making refs
ambiguous ("refname 'dev' is ambiguous") and breaking git push/checkout.
Publish the rolling build to tag "dev-latest" instead; pi-update.sh pulls
from there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:48:15 -04:00
Joshua King d2222d4947 setup-pi: default service user to invoking sudo user, not "pi"
The service unit hard-defaulted to User=pi, but not every Pi has a 'pi'
user (e.g. this hub uses 'overseer') — systemd then fails with 217/USER.
Default SERVICE_USER to ${SUDO_USER:-pi} so the service + /opt/remoterig
ownership match the actual operator. Override with --service-user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:47:09 -04:00
17 changed files with 462 additions and 97 deletions
+26 -8
View File
@@ -9,10 +9,28 @@ import { createHash } from 'node:crypto';
const { TOKEN, SERVER, REPO, SHA } = process.env;
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 API = `${SERVER}/api/v1/repos/${REPO}`;
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) => {
if (!r.ok) throw new Error(`${r.status} ${r.url}\n${await r.text()}`);
const t = await r.text();
@@ -27,21 +45,21 @@ const files = {
'version.txt': Buffer.from(VERSION + '\n'),
};
// Roll the "dev" release forward to this commit: delete the old release + tag.
const existing = await fetch(`${API}/releases/tags/dev`, { headers: H });
// Roll the release forward to this commit: delete the old release + tag.
const existing = await rfetch(`${API}/releases/tags/${TAG}`, { headers: H });
if (existing.ok) {
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',
headers: { ...H, 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: 'dev',
tag_name: TAG,
target_commitish: SHA,
name: `dev (${VERSION})`,
name: `${TAG} (${VERSION})`,
body: `Rolling dev build ${SHA}`,
prerelease: true,
}),
@@ -50,7 +68,7 @@ const rel = await ok(await fetch(`${API}/releases`, {
for (const [name, buf] of Object.entries(files)) {
const fd = new FormData();
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,
}));
console.log(`uploaded ${name}`);
+268
View File
@@ -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` (~5659) 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
View File
@@ -78,7 +78,9 @@ func main() {
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
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)
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)
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
r.Mount("/", frontendHandler())
// Create server
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: cfg.ReadTimeout,
// WriteTimeout intentionally 0: SSE responses are long-lived and a
// write deadline would terminate them mid-stream.
IdleTimeout: cfg.IdleTimeout,
}
// Graceful shutdown
@@ -119,7 +122,7 @@ func main() {
}
// 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()
// Camera management routes
@@ -128,8 +131,8 @@ func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
r.Get("/cameras/{id}", api.GetCameraDetail(database))
// Recording control routes
r.Post("/cameras/{id}/start", api.StartRecording(database))
r.Post("/cameras/{id}/stop", api.StopRecording(database))
r.Post("/cameras/{id}/start", api.StartRecording(database, pub))
r.Post("/cameras/{id}/stop", api.StopRecording(database, pub))
// Status ingestion (from ESP32 nodes)
r.Post("/cameras/{id}/status", api.PushStatus(database))
+5 -2
View File
@@ -4,8 +4,11 @@
# Database
db_path: "remoterig.db"
# API Key for endpoint authentication
api_key: "changeme"
# API key for endpoint authentication. Empty = kiosk mode (no auth) —
# 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
port: 8080
+12 -4
View File
@@ -176,11 +176,19 @@ Published once on ESP32 first boot (or factory reset). Used for auto-registratio
| `capabilities` | string[] | Supported features |
| `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
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
2. If new MAC → insert the camera using the node's self-assigned `camera_id`
3. 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`
+3 -3
View File
@@ -1,6 +1,6 @@
{
"camera_ssid": "GOPRO-BP-",
"camera_password": "goprohero",
"camera_ip": "10.5.5.1",
"camera_ssid": "goprosilver-1",
"camera_password": "Bzyeatn421",
"camera_ip": "10.5.5.9",
"poll_interval_sec": 30
}
+13 -11
View File
@@ -311,23 +311,25 @@ bool connectMQTT() {
Serial.println("[MQTT] Connected");
// Subscribe to commands (if registered)
if (cfg.camera_id.length() > 0) {
mqtt.subscribe(mqttTopic("command").c_str(), 2);
// Option B: self-assigned, stable camera_id derived from the device id.
if (cfg.camera_id.length() == 0) {
cfg.camera_id = clientID(); // e.g. "rig-86d978"
}
// Announce if new
if (cfg.camera_id.length() == 0) {
// Subscribe to our command topic.
mqtt.subscribe(mqttTopic("command").c_str(), 2);
// Announce (retained) on the contract topic so the hub registers/tracks us.
{
JsonDocument doc;
doc["mac_address"] = WiFi.macAddress();
doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge";
doc["friendly_name"] = "Cam-" + clientID();
doc["firmware_version"] = "0.4.0-esp32-mqtt-bridge";
doc["friendly_name"] = "Cam-" + cfg.camera_id;
JsonArray caps = doc["capabilities"].to<JsonArray>();
caps.add("start_stop"); caps.add("status");
String payload; serializeJson(doc, payload);
String announceTopic = "remoterig/cameras/announce-" + clientID();
mqtt.publish(announceTopic.c_str(), payload.c_str(), true);
Serial.println("[MQTT] Announced for registration");
mqtt.publish(mqttTopic("announce").c_str(), payload.c_str(), true);
Serial.printf("[MQTT] Announced as %s\n", cfg.camera_id.c_str());
}
return true;
@@ -489,7 +491,7 @@ void loop() {
// Build the MQTT status payload per contract
JsonDocument mqttDoc;
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;
int pct = batteryPct(dispBatteryRaw);
if (pct >= 0) mqttDoc["battery_pct"] = pct; // omit when uncalibrated
+25 -12
View File
@@ -41,9 +41,11 @@
// ────────────────────────────────────────────
struct Config {
String camera_ssid = "GOPRO-BP-";
String camera_password = "goprohero";
String camera_ip = "10.5.5.1";
// Defaults validated against a GoPro Hero 3 Silver. Per-camera values can
// be overridden at runtime via the set_config command (no reflash).
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;
} cfg;
@@ -96,26 +98,37 @@ struct CamStatus {
CamStatus fetchStatus() {
CamStatus s;
String url = "http://" + cfg.camera_ip +
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
// READ status — must NOT be the shutter endpoint. Hero 3 status blob
// (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;
http.useHTTP10(true);
http.begin(goproClient, url);
http.setTimeout(5000);
int code = http.GET();
if (code != 200) { http.end(); return s; }
String raw = http.getString();
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();
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.recording = (buf[29] == 1);
s.battery_raw = buf[57];
return s;
}
+2 -2
View File
@@ -26,8 +26,8 @@ func setupTestRouter(t *testing.T) (*db.DB, chi.Router) {
r.Get("/cameras", ListCameras(database))
r.Post("/cameras", RegisterCamera(database))
r.Get("/cameras/{id}", GetCameraDetail(database))
r.Post("/cameras/{id}/start", StartRecording(database))
r.Post("/cameras/{id}/stop", StopRecording(database))
r.Post("/cameras/{id}/start", StartRecording(database, nil))
r.Post("/cameras/{id}/stop", StopRecording(database, nil))
r.Post("/cameras/{id}/status", PushStatus(database))
return database, r
+5 -3
View File
@@ -26,11 +26,11 @@ func ListCameras(database *db.DB) http.HandlerFunc {
c.friendly_name,
s.battery_pct,
s.video_remaining_sec,
s.recording_state,
COALESCE(s.recording_state, 0),
s.mode,
s.resolution,
s.fps,
s.online,
COALESCE(s.online, 0),
s.recorded_at
FROM cameras c
LEFT JOIN (
@@ -52,15 +52,17 @@ func ListCameras(database *db.DB) http.HandlerFunc {
for rows.Next() {
var sl models.StatusLog
var c models.Camera
var recordedAt sql.NullTime // NULL for a camera with no status yet
if err := rows.Scan(
&c.CameraID, &c.FriendlyName,
&sl.BatteryPct, &sl.VideoRemainingSec,
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
&sl.Online, &sl.RecordedAt,
&sl.Online, &recordedAt,
); err != nil {
log.Printf("Error scanning camera row: %v", err)
continue
}
sl.RecordedAt = recordedAt.Time // zero time if no status
statuses = append(statuses, models.NewCameraStatus(c, sl))
}
if err := rows.Err(); err != nil {
+26 -2
View File
@@ -9,8 +9,14 @@ import (
"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.
func StartRecording(database *db.DB) http.HandlerFunc {
func StartRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if !validateCameraID(w, cameraID) {
@@ -45,6 +51,15 @@ func StartRecording(database *db.DB) http.HandlerFunc {
rowsAffected, _ := result.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{
"status": "recording_started",
"camera_id": cameraID,
@@ -53,7 +68,7 @@ func StartRecording(database *db.DB) http.HandlerFunc {
}
// 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) {
cameraID := chi.URLParam(r, "id")
if !validateCameraID(w, cameraID) {
@@ -88,6 +103,15 @@ func StopRecording(database *db.DB) http.HandlerFunc {
rowsAffected, _ := result.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{
"status": "recording_stopped",
"camera_id": cameraID,
+36 -28
View File
@@ -143,6 +143,18 @@ type statusPayload struct {
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) {
var sp statusPayload
if err := json.Unmarshal(payload, &sp); err != nil {
@@ -151,22 +163,20 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
}
// Validate required fields
if sp.CameraID == "" || sp.Timestamp == "" {
log.Printf("MQTT status missing required fields (camera_id, timestamp) from %s", cameraID)
if sp.CameraID == "" {
log.Printf("MQTT status missing camera_id from %s", cameraID)
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)
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
if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil {
ts = now
}
}
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
@@ -284,10 +294,12 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
// ── Heartbeat handler ───────────────────────────────────────────────────
type heartbeatPayload struct {
CameraID string `json:"camera_id"`
Timestamp string `json:"timestamp"`
UptimeSec *int `json:"uptime_sec"`
FreeHeap *int `json:"free_heap"`
CameraID string `json:"camera_id"`
// No Timestamp field: the node sends a numeric millis() value and the
// handler doesn't use it; omitting the field lets it be ignored instead
// 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) {
@@ -362,8 +374,8 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
).Scan(&existingID)
if err == nil {
// Already registered — just update friendly_name
if err == nil && existingID == cameraID {
// Same self-id re-connecting — just refresh friendly_name.
_, err = s.db.Exec(
"UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?",
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)
} 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++
// MAC known under a different id (legacy cam-NNN from before self-IDs)
// → drop the old row so we re-register under the node's self-id.
if err == nil && existingID != cameraID {
s.db.Exec("DELETE FROM cameras WHERE camera_id = ?", existingID)
log.Printf("MQTT announce: migrating %s -> %s (%s)", existingID, cameraID, ap.FriendlyName)
}
newID := fmt.Sprintf("cam-%03d", seq)
// Option B: the node self-assigns its camera_id (the announce topic id).
_, 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)
`, cameraID, 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)
log.Printf("MQTT announce: new camera registered as %s (%s)", cameraID, ap.FriendlyName)
// Broadcast new camera via SSE
cam, err := getCamera(s.db, newID)
cam, err := getCamera(s.db, cameraID)
if err == nil {
s.hub.Broadcast("camera_registered", cam)
}
+5 -2
View File
@@ -33,8 +33,11 @@ type StatusLog struct {
type CameraStatus struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
BatteryPct *int `json:"battery_pct,omitempty"`
VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
// Not omitempty: the SPA expects these as `number | null`. Omitting them
// 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"`
Mode string `json:"mode"`
Resolution string `json:"resolution"`
+9 -4
View File
@@ -72,8 +72,12 @@ fi
# 2. Deploy new binary
# ---------------------------------------------------------------------------
info "Deploying new binary..."
cp "${BINARY}" "${DEPLOY_PATH}"
chmod +x "${DEPLOY_PATH}"
# Atomic replace: copy alongside then rename over the target. A plain
# 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}"
# ---------------------------------------------------------------------------
@@ -116,8 +120,9 @@ else
# -----------------------------------------------------------------------
if [ -f "${BACKUP}" ]; then
info "Restoring backup: ${BACKUP}"
cp "${BACKUP}" "${DEPLOY_PATH}"
chmod +x "${DEPLOY_PATH}"
cp "${BACKUP}" "${DEPLOY_PATH}.new"
chmod +x "${DEPLOY_PATH}.new"
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
systemctl restart "${SERVICE}" 2>/dev/null || true
+1 -1
View File
@@ -24,7 +24,7 @@ REPO="${REPO:-CubeCraft-Creations/remote-rig}"
DEPLOY_DIR="/opt/remoterig"
DEPLOY_PATH="${DEPLOY_PATH:-$DEPLOY_DIR/remoterig}"
SERVICE="${SERVICE:-remoterig}"
TAG="dev"
TAG="dev-latest"
DL="$GITEA_BASE/$REPO/releases/download/$TAG"
VERSION_FILE="$DEPLOY_DIR/VERSION"
+2 -2
View File
@@ -8,7 +8,7 @@
#
# Options:
# --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)
# --gateway IP Gateway for wlan0 (default: 192.168.8.1)
# --help Show this help
@@ -19,7 +19,7 @@ set -euo pipefail
# Defaults
# ---------------------------------------------------------------------------
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"
GATEWAY="192.168.8.1"
MOSQUITTO_PKG="mosquitto mosquitto-clients"
+9 -1
View File
@@ -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 { useSSE } from './hooks/useSSE'
import { useCameraStore } from './store/useCameraStore'
@@ -15,6 +15,14 @@ function App() {
// SSE connection + live store
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
const camerasMap = useCameraStore((s) => s.cameras)
const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap])