generated from CubeCraftLabs/Tracehound
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c07338707 | |||
| d538dd3b70 | |||
| b1ed8cdb20 | |||
| cb549a8803 | |||
| 832dd7cbf2 | |||
| ee947485d1 | |||
| f03dbb056d | |||
| 8e6cd11d9c | |||
| e00c8dce85 | |||
| 5239346eaa | |||
| 18db26c265 | |||
| 7929d1d969 | |||
| 9fc80a27c9 |
@@ -16,6 +16,21 @@ 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();
|
||||
@@ -31,14 +46,14 @@ const files = {
|
||||
};
|
||||
|
||||
// Roll the release forward to this commit: delete the old release + tag.
|
||||
const existing = await fetch(`${API}/releases/tags/${TAG}`, { headers: H });
|
||||
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/${TAG}`, { 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({
|
||||
@@ -53,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
@@ -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.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
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -95,27 +97,38 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+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 { 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])
|
||||
|
||||
Reference in New Issue
Block a user