82 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
overseer 4823b746ca Merge branch 'main' into dev
Build (Dev) / build (push) Successful in 10s
CI / quality (push) Successful in 10s
CI / quality (pull_request) Failing after 0s
2026-06-05 11:03:51 -04:00
Joshua King 4ba11cc945 ci: consolidate frontend gates into one job
Build (Dev) / build (push) Successful in 11s
CI / quality (push) Successful in 11s
CI / quality (pull_request) Successful in 11s
The 3-job ci.yaml re-cloned actions/checkout from github.com per job, and
those clones intermittently fail with connection resets (build job died
there even though lint/typecheck/test passed). Collapse to a single job:
one checkout, then lint -> typecheck -> test -> build. Fewer github.com
clones, faster, less flaky.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:24:33 -04:00
Joshua King 53ed73ff6c ci: trim ci.yaml to frontend quality gates
CI / test (push) Successful in 6s
Build (Dev) / build (push) Successful in 11s
CI / lint-and-typecheck (push) Successful in 7s
CI / build (push) Failing after 0s
lint/typecheck/test pass; the build job failed only on
actions/upload-artifact@v4, which Gitea Actions doesn't support. Drop the
artifact upload and the placeholder production deploy job (the real build +
deploy is build-dev.yaml's pull-based release). Keep lint, typecheck, test,
and a build compile-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:22:44 -04:00
Joshua King 35136cb9ad ci: install Go via setup-go (go-react image has Node but not Go)
Build (Dev) / build (push) Successful in 11s
CI/CD / lint-and-typecheck (push) Successful in 7s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
build-dev failed with "go: command not found" — the go-react image ships
Node but no Go. Restore setup-go (its static Go binary runs on this runner,
unlike setup-node's dynamic Node), and keep Node from the image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:52:14 -04:00
Joshua King b1edabd3da ci: use go-react image toolchains instead of setup-go/setup-node
Build (Dev) / build (push) Failing after 4s
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 9s
CI/CD / deploy (push) Has been skipped
On the go-react runner image, npm/node/go already work (npm ci succeeds),
but the setup-go/setup-node actions install tool-cache binaries that can't
execute on this runner (node/22.22.3/x64: "cannot execute"), failing the
jobs in their post steps. Drop those actions and use the image's built-in
toolchains; also reduces flaky github.com action clones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:23:35 -04:00
Joshua King 50e672e753 ci: run workflows on the go-react runner image
Build (Dev) / build (push) Failing after 10s
CI/CD / lint-and-typecheck (push) Successful in 1m23s
CI/CD / test (push) Successful in 8s
CI/CD / build (push) Failing after 1m23s
CI/CD / deploy (push) Has been skipped
The workflows used runs-on: ubuntu-latest, which mapped to
docker.gitea.com/runner-images:ubuntu-latest — an image whose Node from
setup-node won't execute (exit 127) and which lacks curl/jq. The runner
already advertises a purpose-built "go-react" CI image; point the
workflows at it instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:03:29 -04:00
Joshua King a1456fe741 ci: publish dev release via Node (runner has no curl/jq/sudo)
Build (Dev) / build (push) Failing after 9s
CI/CD / lint-and-typecheck (push) Successful in 7s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 9s
CI/CD / deploy (push) Has been skipped
The build-dev publish step failed with exit 127 — the act runner image is
minimal (no curl, jq, or sudo; runs as root). Node is always present
(setup-node), so do the release publish in Node using built-in fetch/crypto
and FormData/Blob for the asset upload. No external tools needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:16:30 -04:00
Joshua King 8387a4208f fix: build frontend into the go:embed path so the hub binary compiles
Build (Dev) / build (push) Failing after 17s
CI/CD / lint-and-typecheck (push) Successful in 7s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 10s
CI/CD / deploy (push) Has been skipped
cmd/server/main.go has //go:embed all:src/dist (relative to cmd/server/),
but Vite built to repo-root dist/, so cmd/server/src/dist never existed and
every `go build` failed with "pattern all:src/dist: no matching files found".
The hub binary has never built in CI as a result.

- vite.config.ts: outDir -> cmd/server/src/dist (emptyOutDir)
- commit cmd/server/src/dist/index.html placeholder so the embed always has
  a file (real build overwrites it)
- .gitignore: scope dist ignore to /dist; ignore cmd/server/src/dist/* but
  keep the index.html placeholder (the prior !src/dist/index.html rule
  pointed at the wrong path)
- ci.yaml: upload artifact from the new output path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:12:36 -04:00
Joshua King d8ea71a295 ci: fix jq-install precedence in build-dev (could run apt install when jq present)
Build (Dev) / build (push) Failing after 9s
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Failing after 0s
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:01:23 -04:00
Joshua King c2a05f9b7c ci: pull-based deploy to the Pi via rolling dev release
Build (Dev) / build (push) Failing after 16s
CI/CD / lint-and-typecheck (push) Successful in 9m28s
CI/CD / test (push) Successful in 9m27s
CI/CD / build (push) Failing after 4m49s
CI/CD / deploy (push) Has been skipped
The Pi is on a closed travel-router LAN, so push-based deploy from a
runner can't reach it. Switch to pull: the runner builds + publishes,
the Pi fetches.

- build-dev.yaml: after the arm64 build, publish the binary + sha256 +
  version.txt to a rolling "dev" Gitea release (replaces the
  upload-artifact + repository_dispatch -> deploy-dev hop)
- remove deploy-dev.yaml (push/scp-based deploy no longer used)
- scripts/pi-update.sh: poll the dev release, verify sha256, install via
  deploy.sh (backup/restart/rollback); only updates when version changes
- scripts/remoterig-update.{service,timer}: run the updater every 5 min
- setup-pi.sh: install deploy.sh + pi-update.sh + update.env template +
  the updater timer; summary now reflects the pull flow
- README: document the pull-based CI/CD; fix stale GOARM=6 (Zero 2 W is
  arm64 on 64-bit OS / arm GOARM=7 on 32-bit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:00:48 -04:00
Joshua King f261fa0f55 firmware: store real wifi_password in config.json
Build (Dev) / build (push) Failing after 10s
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Failing after 0s
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Per maintainer decision: this is a private repo and the closed
travel-router Wi-Fi password is low-sensitivity, so keep the real
value in the tracked config for reproducible uploadfs provisioning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:49:44 -04:00
Joshua King b0062f1373 net: re-address hub network 10.60.1.0/24 -> 192.168.8.0/24
CI/CD / lint-and-typecheck (push) Successful in 9s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 1m21s
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 5m1s
The project was designed around a 10.60.1.0/24 travel-router network,
but the actual RemoteRig router uses 192.168.8.0/24 (the C6 associates
and gets 192.168.8.x; hub confirmed at 192.168.8.56). Replace the
network prefix everywhere (last octet preserved; GoPro 10.5.5.1 left
alone).

- scripts/setup-pi.sh: static IP 192.168.8.56/24, gateway 192.168.8.1,
  deploy/health command examples updated
- esp32-mqtt-bridge.cpp: default mqtt_broker -> 192.168.8.56
- firmware/data/config.json: broker -> 192.168.8.56 (wifi_password kept
  blank in git; real value flashed to the device only)
- docs (CONTEXT, MQTT_CONTRACT, READMEs, wireframes): gateway/hub/DHCP
  and example IPs re-addressed for consistency

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:47:57 -04:00
Joshua King f6a25fc324 firmware: fix C6 filesystem provisioning (LittleFS) + ESP-01S env
Build (Dev) / build (push) Failing after 10s
CI/CD / lint-and-typecheck (push) Successful in 7s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 9s
CI/CD / deploy (push) Has been skipped
The C6 never loaded its /config.json, so it fell back to defaults
(SSID RemoteRig, empty password) and couldn't join Wi-Fi. Two bugs:

- Data file was named esp32-config.json but the firmware reads
  /config.json → renamed to config.json.
- Firmware used SPIFFS while pioarduino's uploadfs builds a LittleFS
  image; the SPIFFS mount then reformatted it empty. Switch the C6 to
  LittleFS (matches the toolchain default and the ESP-01S).

Also:
- log loaded ssid/broker/camera_id on config load (not the password)
- platformio.ini: land the ESP-01S env retarget (board d1_mini ->
  esp01_1m, dout, upload_speed 115200) that was missed in 403e1d9
- committed config.json keeps wifi_password blank; the real value is
  flashed to the device, not stored in git

Verified: C6 loads config and associates (got a DHCP lease). MQTT to
the broker is a separate network issue (hub IP / subnet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:28:17 -04:00
Joshua King 403e1d9edd firmware: no-reflash config updates for ESP-01S + UART-OTA groundwork
Build (Dev) / build (push) Failing after 9s
CI/CD / lint-and-typecheck (push) Successful in 9m28s
CI/CD / test (push) Successful in 9m27s
CI/CD / build (push) Failing after 4m53s
CI/CD / deploy (push) Has been skipped
Updating the buried ESP-01S currently means a USB-UART adapter and a
GPIO0 jumper. Add a path to change its settings without reflashing, and
lay the groundwork for full firmware updates over the existing UART.

set_config (no reflash for settings):
- ESP-01S: add saveConfig() + a set_config command — updates GoPro
  SSID/password/IP and poll interval, persists to LittleFS, acks, and
  re-associates Wi-Fi if creds changed
- XIAO: forward an MQTT set_camera_config down to the ESP-01S over UART
  (hub -> MQTT -> XIAO -> UART -> ESP-01S/LittleFS)

UART-OTA groundwork ("XIAO as flasher"):
- reserve XIAO GPIOs ESP01_RST_PIN=D8, ESP01_PGM_PIN=D10 for driving the
  ESP-01S serial bootloader (not driven yet)
- docs/design/esp01s-uart-ota.md: full design (why Wi-Fi OTA doesn't fit
  the 1MB ESP-01S on the GoPro AP, bootloader entry, ROM flash protocol,
  HTTP-pull delivery, scope)
- hardware/README.md: fix stale ESP32-C3 -> XIAO ESP32-C6 wiring, add the
  two control lines (Notion wiring diagram updated to match)

Both firmwares build clean and are flashed; set_config round-trip needs
the broker to exercise end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:11:34 -04:00
Joshua King cefb7ef52c firmware: battery calibration + full RGB STAT LED (C6)
Build (Dev) / build (push) Failing after 10s
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Successful in 8s
CI/CD / build (push) Failing after 9s
CI/CD / deploy (push) Has been skipped
Battery calibration:
- two-point linear cal (bat_raw_min->0%, bat_raw_max->100%) of the
  GoPro offset-57 raw byte, persisted in SPIFFS config
- publish battery_pct in MQTT status only when calibrated (omit
  otherwise, per MQTT_CONTRACT); OLED shows % when calibrated, raw
  until then
- set_battery_cal MQTT command: explicit {raw_min,raw_max} or
  capture-current {point:"full"|"empty"} for field calibration

RGB STAT LED:
- drive D0/D1/D2 (R/G/B) with health colors instead of the single
  green channel: red=offline, magenta=wifi-but-no-hub,
  yellow=hub-but-no-camera, green=healthy; blue during boot
- RGB_COMMON_ANODE polarity flag; this module is common-anode

Verified on hardware: boots, OLED ok, RGB shows correct colors
(blue->red on the bench).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:33:36 -04:00
Joshua King 996ef87dfd firmware: add OLED status panel to camera node (C6)
Build (Dev) / build (push) Failing after 12s
CI/CD / lint-and-typecheck (push) Successful in 9m31s
CI/CD / test (push) Successful in 9m27s
CI/CD / build (push) Failing after 4m49s
CI/CD / deploy (push) Has been skipped
Bring up the 1.3" SH1106 128x64 I2C OLED on the XIAO ESP32-C6
(D4/SDA, D5/SCL @ 0x3C) per the Notion wiring diagram.

- add U8g2 dependency to the seeed_xiao_esp32c6 env
- I2C bus scan at boot (logs responders to serial)
- boot splash + live status screen: camera id, IDLE/REC + session
  timer, battery (raw until calibrated) + video-remaining, hub link
  state (MQTT/wifi/offline), and camera reachability
- refresh runs at the top of loop() so the panel stays live even
  when WiFi/MQTT are down

Verified on hardware: I2C scan finds 0x3C, U8g2 begin ok, panel
shows clean readable text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:22:23 -04:00
Joshua King 2fb73ec8c4 firmware: retarget camera-node MQTT bridge to XIAO ESP32-C6
The MCU changed from ESP32 Dev Board to a Seeed Studio XIAO ESP32-C6,
but the firmware still targeted esp32dev. Retarget it and fix the
build so it compiles and flashes for the C6.

platformio.ini:
- env esp32-mqtt -> seeed_xiao_esp32c6 on the pioarduino platform fork
  (mainline espressif32 lags the Arduino-core 3.x the C6 needs)
- add ARDUINO_USB_MODE=1 / ARDUINO_USB_CDC_ON_BOOT=1 for Serial over
  the C6 native USB
- fix build_src_filter ordering in BOTH envs: -<*.cpp> ran last and
  re-excluded the target, leaving setup()/loop() undefined at link

esp32-mqtt-bridge.cpp:
- UART Serial2 RX16/TX17 -> Serial1 RX=D7/TX=D6 (XIAO C6 mapping)
- status LED GPIO2 -> D1 (green channel of the RGB STAT LED)
- fix pre-existing ArduinoJson v7 / PubSubClient build errors
  (.c_str() on a const char*, String topic where const char* required)

Verified: builds clean and boots on hardware (native-USB serial banner
confirmed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:12:01 -04:00
overseer a478f7d478 Merge pull request 'CUB-178: camera monitoring dashboard wireframes and design specs' (#16) from agent/sketch/CUB-178-camera-dashboard-mockups into dev
Build (Dev) / build (push) Failing after 10s
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 9s
CI/CD / deploy (push) Has been skipped
Reviewed-on: #16
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-05-28 07:23:33 -04:00
overseer 9accd34b50 Merge branch 'dev' into agent/sketch/CUB-178-camera-dashboard-mockups
CI/CD / lint-and-typecheck (pull_request) Successful in 8s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
2026-05-28 07:23:06 -04:00
overseer 8165822e45 Merge pull request 'CUB-176: Central hub frontend — camera grid, start/stop controls, history viewer' (#15) from agent/rex/CUB-176-central-hub-frontend into dev
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Successful in 8s
CI/CD / build (push) Failing after 10s
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 5m1s
Reviewed-on: #15
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-05-28 07:22:37 -04:00
overseer a31dc62a24 Merge branch 'dev' into agent/rex/CUB-176-central-hub-frontend
CI/CD / lint-and-typecheck (pull_request) Successful in 8s
CI/CD / test (pull_request) Successful in 9m29s
CI/CD / build (pull_request) Failing after 4m49s
CI/CD / deploy (pull_request) Has been skipped
2026-05-28 07:04:58 -04:00
overseer 7fcae17239 Merge pull request 'fix: hub-side dedup for ESP32 offline status replay (CUB-239)' (#13) from agent/dex/CUB-239-hub-dedup-replay into dev
Build (Dev) / build (push) Failing after 11s
CI/CD / lint-and-typecheck (push) Successful in 9s
CI/CD / test (push) Successful in 9s
CI/CD / build (push) Failing after 11s
CI/CD / deploy (push) Has been skipped
Reviewed-on: #13
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-05-28 07:00:36 -04:00
overseer c2670a9f33 Merge branch 'dev' into agent/dex/CUB-239-hub-dedup-replay
CI/CD / lint-and-typecheck (pull_request) Successful in 8s
CI/CD / test (pull_request) Successful in 9s
CI/CD / build (pull_request) Failing after 10s
CI/CD / deploy (pull_request) Has been skipped
2026-05-28 06:59:51 -04:00
overseer cc1b05a4e7 Merge pull request 'fix: harden camera API endpoints (CUB-234)' (#12) from agent/dex/CUB-234-harden-camera-endpoints into dev
Build (Dev) / build (push) Failing after 11s
CI/CD / lint-and-typecheck (push) Successful in 8s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 10s
CI/CD / deploy (push) Has been skipped
Reviewed-on: #12
2026-05-28 06:59:10 -04:00
overseer 81f168e8a4 Merge branch 'dev' into agent/dex/CUB-234-harden-camera-endpoints
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 8s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
2026-05-28 06:58:59 -04:00
overseer 3e277349ed Merge pull request 'CUB-229: Design camera auto-discovery and registration flow' (#14) from agent/dex/CUB-229-camera-auto-discovery into dev
CI/CD / lint-and-typecheck (push) Successful in 7s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 9s
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 5m14s
Reviewed-on: #14
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-05-28 06:58:39 -04:00
overseer f669ec182a Merge branch 'dev' into agent/dex/CUB-229-camera-auto-discovery
CI/CD / lint-and-typecheck (pull_request) Successful in 10s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
2026-05-28 06:58:11 -04:00
Sketch 56fe3d228a CUB-178: camera monitoring dashboard wireframes and design specs
CI/CD / lint-and-typecheck (pull_request) Failing after 13m42s
CI/CD / test (pull_request) Has been cancelled
CI/CD / build (pull_request) Has been cancelled
CI/CD / deploy (pull_request) Has been cancelled
2026-05-23 12:10:18 -04:00
overseer c913039362 Merge pull request 'Add camera node case v4 status panel CAD' (#11) from agent/hermes/camera-node-case-v4-status-panel into dev
Build (Dev) / build (push) Failing after 10s
CI/CD / lint-and-typecheck (push) Successful in 9m28s
CI/CD / test (push) Successful in 7s
CI/CD / build (push) Failing after 9s
CI/CD / deploy (push) Has been skipped
Reviewed-on: #11
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-05-23 11:31:39 -04:00
Hermes dd5ffe9fba CUB-176: central hub frontend — camera grid, start/stop controls, history viewer
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 8s
CI/CD / deploy (pull_request) Has been skipped
- CameraCard: color-coded status (green/yellow/red), per-camera start/stop, battery bar, recording indicator
- HistoryViewer: modal dialog with 24h status log browsing per camera
- App: responsive grid (1-4 cols), Start/Stop All global buttons, SSE connection badge, live stats strip
- API service: aligned with backend endpoints (list, detail, start, stop)
- Types: added StatusLog, CameraDetail, CameraInfo, StartStopResponse
- All 23 tests pass, lint clean, TypeScript + Vite build clean
2026-05-23 10:37:48 -04:00
Hermes f118b890f0 fix: add camera node IPEX antenna hole
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 8s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 14:36:22 +00:00
Hermes d89f9dc20b fix: make rear strap brackets side-feed
CI/CD / lint-and-typecheck (pull_request) Successful in 8s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 14:15:04 +00:00
Hermes 95c225e51b CUB-229: Design camera auto-discovery and registration flow
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 8s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 10:06:50 -04:00
Hermes daeea9f2c9 fix: make camera node strap loops vertical
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 4m50s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 13:58:28 +00:00
Hermes 9a50d0c801 fix: add camera node USB power ports
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 13:33:06 +00:00
Hermes af68bfaa3a fix: make camera case cutouts visible
CI/CD / lint-and-typecheck (pull_request) Successful in 9m27s
CI/CD / test (pull_request) Successful in 9m27s
CI/CD / build (pull_request) Failing after 4m49s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 13:09:45 +00:00
Hermes 74c8697e57 fix: hub-side dedup for ESP32 offline status replay (CUB-239)
CI/CD / lint-and-typecheck (pull_request) Failing after 11m33s
CI/CD / test (pull_request) Has been cancelled
CI/CD / build (pull_request) Has been cancelled
CI/CD / deploy (pull_request) Has been cancelled
- Add migration 002: UNIQUE index on status_logs(camera_id, recorded_at)
- Upgrade migration system to version-tracked (schema_version table)
- Prevents race-condition double-inserts that application-level COUNT(*) check cannot guard against
- Complements existing application-level dedup from CUB-230
2026-05-23 09:01:28 -04:00
Hermes 1f253283f8 fix: harden camera API endpoints (CUB-234)
CI/CD / lint-and-typecheck (pull_request) Failing after 12m11s
CI/CD / test (pull_request) Has been cancelled
CI/CD / build (pull_request) Has been cancelled
CI/CD / deploy (pull_request) Has been cancelled
- Add request validation: Content-Type check, body size limit (64KB)
- Add field length validation (camera_id: 64, friendly_name: 128, mode: 32, resolution: 32)
- Add FPS range validation (0-240)
- Add battery_pct range validation (0-100)
- Replace ad-hoc map[string]string errors with structured APIError {error, code, details}
- Fix isUniqueConstraintErr to catch both camera_id and mac_address constraint violations
- Fix MacAddress model field from string to *string for NULL handling
- Fix splitSQL to strip -- line comments before splitting (was causing migration failures with modernc.org/sqlite)
- Add 30 integration tests covering all endpoints
- All tests pass: ok github.com/cubecraft/remoterig/internal/api
2026-05-23 08:50:21 -04:00
Hermes bbc6b1ea05 fix: redo camera node case as upright enclosure
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 9m27s
CI/CD / build (pull_request) Failing after 4m51s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 12:42:01 +00:00
Hermes 4487f0e0a4 fix: seat camera node case preview lid
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 6s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 11:57:43 +00:00
Hermes 8c8d4e45e5 feat: add camera node case v4 status panel CAD
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 4m49s
CI/CD / deploy (pull_request) Has been skipped
2026-05-23 11:54:18 +00:00
Hermes fe193701ae CUB-230: hub-side deduplication for offline buffering replay
Build (Dev) / build (push) Failing after 10s
CI/CD / lint-and-typecheck (push) Successful in 6s
CI/CD / test (push) Successful in 6s
CI/CD / build (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
- Add dedup check in handleStatus using (camera_id, recorded_at) uniqueness
- Skip insert if duplicate detected - logs replayed entries
- Go mod: updated version to 1.19
2026-05-23 07:40:06 +00:00
overseer 1a8f67a392 Merge pull request 'feat: add v3 hardware case and update hub network' (#6) from agent/hermes/remoterig-hardware-v3-network into dev
Build (Dev) / build (push) Failing after 9s
CI/CD / lint-and-typecheck (push) Successful in 9m29s
CI/CD / test (push) Successful in 9m27s
CI/CD / build (push) Failing after 4m50s
CI/CD / deploy (push) Has been skipped
Reviewed-on: #6
2026-05-22 19:43:40 -04:00
Hermes 4c4368a79f fix: make tripod case dovetail connector fit
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 8s
CI/CD / deploy (pull_request) Has been skipped
2026-05-22 17:15:23 -04:00
Hermes 0e2e94a4cf docs: align hardware and MQTT architecture notes
CI/CD / lint-and-typecheck (pull_request) Successful in 7s
CI/CD / test (pull_request) Successful in 10m3s
CI/CD / build (pull_request) Failing after 4m58s
CI/CD / deploy (pull_request) Has been skipped
2026-05-22 17:08:11 -04:00
Hermes c5cbeabd92 feat: add v3 hardware case and update hub network
CI/CD / lint-and-typecheck (pull_request) Failing after 14m12s
CI/CD / test (pull_request) Has been cancelled
CI/CD / build (pull_request) Has been cancelled
CI/CD / deploy (pull_request) Has been cancelled
2026-05-22 16:58:11 -04:00
Hermes f4bf37d6a3 feat: add interactive 3D case viewer (Three.js)
Build (Dev) / build (push) Failing after 0s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Rotatable 3D render of the tripod-mounted dual-ESP case:
- Case body with rounded corners and lid
- Stacked ESP32 + ESP8266 boards inside
- LED indicator, USB port, ventilation slots
- Tripod pole with C-clamp mount
- USB cables, screws, chip details
- Drag to rotate, scroll to zoom
- Open in any browser
2026-05-22 01:06:59 +00:00
Hermes 893574ee79 feat: redesigned case — tripod-clip box for dual ESPs, USB power bank
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Replaced GoPro-sleeve case design with simpler stand-mounted box:
- Case clips to tripod leg/stand pole (20-35mm diameter)
- No camera sleeve needed — case sits on the stand
- Powered by standard USB power bank (off-the-shelf)
- Holds ESP8266 + ESP32 stacked with UART wiring
- Cable ports for USB in/out, LED window, ventilation

Simplified BOM: ~1/node (down from 4), no buck converters needed
2026-05-22 01:03:53 +00:00
Hermes b3d4226b1c feat: dual-board architecture — ESP8266 camera bridge + ESP32 MQTT bridge
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Complete rewrite of firmware into two dedicated boards per camera node:

ESP8266 (Camera Bridge):
- Connects ONLY to GoPro AP — polls status, sends over UART
- Zero network switching, zero MQTT
- HTTP GET /bacpac/SH for status, start/stop
- JSON-per-line UART protocol to ESP32

ESP32 (MQTT Bridge):
- Connects ONLY to travel router — MQTT to Pi hub
- Reads status from ESP8266 over UART2 (RX16/TX17)
- Auto-registration, heartbeat, command forwarding
- Zero camera communication

UART Protocol: JSON-per-line at 115200 8N1
  ESP8266→ESP32: status/ack/pong/error
  ESP32→ESP8266: cmd (start_recording/stop_recording/ping)

Hardware updates:
- BOM now includes both boards (~4/node)
- 3D case has stacked dual-board compartment
- UART wire channel between board recesses
- Shared 3.3V power rail for both boards
2026-05-22 00:49:06 +00:00
Hermes 324402f268 feat: add ESP8266 support + Akaso camera compatibility config
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
Build (Dev) / build (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
- Unified firmware for ESP32 (dual-STA) and ESP8266 (time-shared STA)
- ESP8266: alternates between GoPro AP and travel router per poll cycle
- PlatformIO dual-target: esp32dev + esp8266dev (d1_mini)
- camera_ip config field for Akaso/non-GoPro cameras
- LittleFS support for ESP8266 (replaces SPIFFS)
- Camera compatibility table (GoPro H3/H4, Akaso)
- LED polarity handled per-platform (ESP8266 active-low)

ESP8266 time-sharing adds ~4s latency per 30s cycle — invisible at poll rate.
2026-05-22 00:28:48 +00:00
overseer 45bfbcfdf5 Merge pull request 'RemoteRig: Core infrastructure — MQTT subscriber, Pi deployment, ESP32 firmware, hardware design' (#5) from dev into main
CI/CD / lint-and-typecheck (push) Failing after 1s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Reviewed-on: #5
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-05-21 20:04:35 -04:00
Hermes 37c5362216 merge: resolve conflicts with main (take dev versions)
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 1s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
CI/CD / test (pull_request) Successful in 8s
CI/CD / build (pull_request) Failing after 11s
CI/CD / deploy (pull_request) Has been skipped
CI/CD / lint-and-typecheck (pull_request) Successful in 10s
Conflicts in cmd/server/main.go, go.mod, internal/db/db.go
— main had older/simpler versions from PR #4 (README)
— dev has full codebase with MQTT, SSE, embed
2026-05-22 00:03:39 +00:00
overseer d9c06b28fd Merge pull request 'CUB-208: Add README with project overview and setup instructions' (#4) from hermes/cub-208-readme into main
Reviewed-on: #4
2026-05-21 17:56:07 -04:00
Hermes d419dfe519 feat: add PlatformIO ESP32 firmware with dual-STA + MQTT + GoPro control
CI/CD / lint-and-typecheck (pull_request) Failing after 2s
CI/CD / test (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / deploy (pull_request) Failing after 12m6s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 14s
CI/CD / lint-and-typecheck (push) Failing after 0s
firmware/
├── platformio.ini     — ESP32 (esp32dev), PubSubClient + ArduinoJson
├── src/main.cpp       — Full camera node firmware (360 lines)
│   ├── SPIFFS config persistence
│   ├── Dual Wi-Fi STA (travel router + GoPro AP)
│   ├── GoPro Hero 3 HTTP API (start/stop/status)
│   ├── 60-byte binary status blob parser
│   ├── MQTT per contract (status QoS1, heartbeat QoS1, announce QoS2)
│   ├── Command subscription (start/stop/reboot)
│   ├── Auto-registration (announce → hub assigns cam-NNN)
│   ├── Heartbeat every 60s, status every 30s
│   ├── LED status indicator
│   └── Exponential backoff reconnection
├── data/config.json   — Default SPIFFS config template
└── README.md          — Quick start, config reference, troubleshooting
2026-05-21 21:54:05 +00:00
Hermes e4324e626f feat: add 3D printable case design (OpenSCAD) and hardware assembly guide
- GoPro Hero 3 sleeve with lens/screen/USB cutouts
- ESP32 D1 Mini electronics compartment (vented)
- LiPo battery compartment with velcro strap slots
- Bill of materials (~0 per camera node)
- Wiring diagram (LiPo → dual buck converters → ESP32 + GoPro)
- Field deployment workflow

OpenSCAD model in hardware/case/remoterig-case.scad
Assembly guide in hardware/README.md
2026-05-21 21:50:26 +00:00
Hermes 5bc327e909 feat: add Pi provisioning, systemd service, and deploy scripts
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
2026-05-21 21:42:43 +00:00
Hermes 6b6b66ab89 feat: embed React frontend in Go binary with SPA fallback 2026-05-21 21:39:07 +00:00
Hermes 74d6130dd5 deploy(dev): rename OpenClaw to RemoteRig, add config.yaml deployment
- BINARY_NAME: openclaw → remoterig
- DEPLOY_PATH: /opt/openclaw/openclaw → /opt/remoterig/remoterig
- SERVICE: openclaw → remoterig
- Deploy tmp path: /tmp/openclaw-deploy → /tmp/remoterig-deploy
- Add config.yaml deployment step: copies config.yaml alongside binary on dev host if present in repo
- Backup/rollback logic preserved (keeps last 3 backups)
- Failure notification path updated to /tmp/remoterig-deploy-failure.txt
2026-05-21 21:38:48 +00:00
Hermes a90a1d567e build(dev): switch to ARM64 cross-compile for Pi Zero 2 W deployment
- Rename BINARY_NAME from openclaw to remoterig
- Cross-compile with GOOS=linux GOARCH=arm64 for Raspberry Pi Zero 2 W
- Build React frontend at repo root (npm ci && npm run build) producing dist/
- Remove old web/ working-directory and explicit embed step (handled by go:embed)
- Repository dispatch event_type unchanged: dev-build-success
2026-05-21 21:38:39 +00:00
Hermes 607aea514b feat: harden SSE endpoint with typed events and Last-Event-ID
Build (Dev) / build (push) Failing after 0s
CI/CD / lint-and-typecheck (push) Failing after 1s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
- Added monotonic event ID (Last-Event-ID) for reconnection support
- Events now emit typed: 'event: camera_status' for client-side filtering
- Initial connection event sends 'event: connected' with ID
- Reconnection acknowledgment via Last-Event-ID header
- CORS preflight (OPTIONS) handler
- Access-Control-Allow-Headers: Last-Event-ID
- Initial heartbeat shortened to 15s for faster detect (30s for steady)

Closes CUB-233.
2026-05-21 21:18:24 +00:00
Hermes f200cd9782 feat: add MQTT subscriber for ESP32 camera status ingestion
Implements MQTT subscriber (internal/mqtt/subscriber.go) that:
- Connects to Mosquitto broker with auto-reconnect
- Subscribes to remoterig/cameras/+/status, +/heartbeat, +/announce
- Parses and validates incoming messages per MQTT contract
- Inserts status_logs with duplicate prevention
- Auto-detects recording state changes and manages recording_events
- Broadcasts camera status changes via SSE hub
- Camera auto-registration via announce (MAC-based, sequential cam-NNN)
- Heartbeat watchdog marks cameras offline after 120s silence
- Wired into main.go with graceful degradation (warns if broker unreachable)

Dependency: github.com/eclipse/paho.mqtt.golang v1.5.0

Closes CUB-232.
2026-05-21 21:16:08 +00:00
Hermes ce188086cb docs: update CONTEXT.md with closed-network architecture and current state
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
- Replaced architecture diagram with travel router LAN design
- Added MQTT contract reference
- Updated issue status table with agent assignments
- Revised known limitations to reflect actual gaps
- Added network config section (subnet, static IP)
- Removed CUB-182 (canceled duplicate)

Post-sync: 13 done, 15 backlog, 1 in review, 1 canceled.
2026-05-21 21:11:33 +00:00
Hermes df212796d2 docs: add MQTT message format contract with closed-network architecture
Defines topic hierarchy, payload schemas, QoS levels, heartbeat
protocol, camera auto-discovery via announce topic, offline
buffering strategy, and command/response flow for start/stop.

Architecture: travel router subnet (192.168.4.x), Pi Zero 2 W
runs Mosquitto + Go backend, ESP32s dual-STA to GoPro AP +
travel router. No internet dependency.

Closes CUB-238.
2026-05-21 21:08:38 +00:00
overseer 861aedd6d8 CUB-208: Add README with project overview and setup instructions 2026-05-21 17:47:29 +00:00
Otto 02fa6e4d4f docs: add comprehensive project context file (CONTEXT.md) for agent reference
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 35s
2026-05-21 13:19:26 -04:00
overseer 07ecff3b5f Merge pull request 'CUB-196: CameraCard component with live SSE status display' (#3) from agent/hermes/CUB-196-cameracard into dev
Build (Dev) / build (push) Failing after 1s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Reviewed-on: #3
2026-05-21 10:26:55 -04:00
overseer 69b050b62b CUB-181: Scaffold Go module, directory layout, config, and main.go entry point 2026-05-18 17:41:23 -04:00
61 changed files with 215315 additions and 425 deletions
+76
View File
@@ -0,0 +1,76 @@
// Publish the built hub binary to a rolling "dev" release on Gitea.
// Runs in the CI job with only Node available (the runner image has no
// curl/jq/sudo), so this uses Node built-ins + global fetch/FormData/Blob.
//
// Env: TOKEN (Gitea token), SERVER (github.server_url), REPO (owner/repo),
// SHA (github.sha). Expects ./remoterig in the working dir.
import { readFileSync } from 'node:fs';
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();
return t ? JSON.parse(t) : null;
};
const bin = readFileSync(BIN);
const sha256 = createHash('sha256').update(bin).digest('hex');
const files = {
[BIN]: bin,
[`${BIN}.sha256`]: Buffer.from(sha256 + '\n'),
'version.txt': Buffer.from(VERSION + '\n'),
};
// 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 rfetch(`${API}/releases/${rel.id}`, { method: 'DELETE', headers: H });
}
await rfetch(`${API}/tags/${TAG}`, { method: 'DELETE', headers: H }); // ignore if absent
const rel = await ok(await rfetch(`${API}/releases`, {
method: 'POST',
headers: { ...H, 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: TAG,
target_commitish: SHA,
name: `${TAG} (${VERSION})`,
body: `Rolling dev build ${SHA}`,
prerelease: true,
}),
}));
for (const [name, buf] of Object.entries(files)) {
const fd = new FormData();
fd.append('attachment', new Blob([buf]), name);
await ok(await rfetch(`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, {
method: 'POST', headers: H, body: fd,
}));
console.log(`uploaded ${name}`);
}
console.log(`Published dev release ${VERSION}`);
+23 -40
View File
@@ -7,64 +7,47 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
GO_VERSION: "1.23" BINARY_NAME: remoterig
NODE_VERSION: "20"
BINARY_NAME: openclaw
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: go-react
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# go-react has Node but not Go. setup-go installs a statically-linked
# Go that runs fine here; setup-node's dynamically-linked Node does
# not (so Node comes from the image instead).
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: ${{ env.GO_VERSION }} go-version: "1.25"
- name: Setup Node - name: Toolchain versions
uses: actions/setup-node@v4 run: |
with: go version
node-version: ${{ env.NODE_VERSION }} node --version
- name: Build React frontend - name: Build React frontend
working-directory: web
run: | run: |
npm ci npm ci
npm run build npm run build
- name: Embed frontend into Go binary - name: Build Go binary (ARM64 cross-compile)
run: | run: |
mkdir -p internal/web/dist CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
cp -r web/dist/* internal/web/dist/
go generate ./internal/web/...
- name: Build Go binary
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \ go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
-o ${{ env.BINARY_NAME }} ./cmd/server -o ${{ env.BINARY_NAME }} ./cmd/server
- name: Upload build artifact # Pull-based deploy: publish the binary to a rolling "dev" release.
uses: actions/upload-artifact@v4 # The Pi polls this release and self-updates (scripts/pi-update.sh);
with: # the runner never needs to reach the closed RemoteRig network.
name: ${{ env.BINARY_NAME }} # Done in Node (runner image has no curl/jq/sudo; Node is present).
path: ${{ env.BINARY_NAME }} - name: Publish to rolling dev release
retention-days: 5 env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger deploy workflow SERVER: ${{ github.server_url }}
if: success() REPO: ${{ github.repository }}
uses: actions/github-script@v7 SHA: ${{ github.sha }}
with: run: node .gitea/scripts/publish-release.mjs
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: context.repo.repo,
event_type: 'dev-build-success',
client_payload: {
sha: context.sha,
ref: context.ref
}
})
+8 -50
View File
@@ -1,4 +1,9 @@
name: CI/CD name: CI
# Frontend quality gates (lint, typecheck, test, build-check).
# One job on purpose: the runner fetches each action from github.com,
# which is flaky (connection resets), so we check out once instead of
# re-cloning per job. The real hub build + deploy is build-dev.yaml.
on: on:
push: push:
@@ -7,59 +12,12 @@ on:
branches: [dev, main] branches: [dev, main]
jobs: jobs:
lint-and-typecheck: quality:
runs-on: ubuntu-latest runs-on: go-react
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
- run: npx tsc --noEmit - run: npx tsc --noEmit
test:
needs: lint-and-typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test - run: npm test
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build - run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy static files
run: |
echo "Deploying to production..."
echo "Deploy target: /var/www/remote-rig/"
echo "Placeholder — configure deploy target before merging to main"
-101
View File
@@ -1,101 +0,0 @@
name: Deploy (Dev)
on:
repository_dispatch:
types:
- dev-build-success
workflow_dispatch:
env:
BINARY_NAME: openclaw
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_PATH: /opt/openclaw/openclaw
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.BINARY_NAME }}
- name: Ensure binary is executable
run: chmod +x ${{ env.BINARY_NAME }}
- name: Write deploy script
run: |
cat > deploy.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BINARY="${1:-openclaw}"
DEPLOY_PATH="${2:-/opt/openclaw/openclaw}"
SERVICE="${3:-openclaw}"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
echo "::backup:: copying current binary"
if [ -f "$DEPLOY_PATH" ]; then
cp "$DEPLOY_PATH" "$BACKUP"
fi
echo "::deploy:: installing new binary"
cp "$BINARY" "$DEPLOY_PATH"
chmod +x "$DEPLOY_PATH"
echo "::restart:: reloading service"
systemctl reload-or-restart "$SERVICE" || systemctl restart "$SERVICE"
echo "::health:: waiting for service"
sleep 3
if systemctl is-active --quiet "$SERVICE"; then
echo "deploy ok — ${SERVICE} is active"
else
echo "::rollback:: service failed, restoring backup"
if [ -f "$BACKUP" ]; then
cp "$BACKUP" "$DEPLOY_PATH"
systemctl restart "$SERVICE"
fi
echo "rolled back to previous binary"
exit 1
fi
echo "::cleanup:: removing old backups (keeping last 3)"
ls -t "${DEPLOY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
SCRIPT
chmod +x deploy.sh
- name: Deploy to dev server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
source: "${{ env.BINARY_NAME }},deploy.sh"
target: "/tmp/openclaw-deploy"
- name: Execute deploy on dev server
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
set -euo pipefail
cd /tmp/openclaw-deploy
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "openclaw"
rm -rf /tmp/openclaw-deploy
- name: Notify on failure
if: failure()
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
echo "deploy failed for commit ${{ github.sha }} on ${{ github.repository }}" > /tmp/openclaw-deploy-failure.txt
+7 -1
View File
@@ -9,10 +9,16 @@ lerna-debug.log*
# Dependencies # Dependencies
node_modules node_modules
dist /dist
dist-ssr dist-ssr
*.local *.local
# Frontend build output — embedded into the Go binary at build time.
# Vite writes here (cmd/server/src/dist); ignore the built output but keep
# the committed index.html placeholder so //go:embed always has a file.
cmd/server/src/dist/*
!cmd/server/src/dist/index.html
# Environment files # Environment files
.env .env
.env.local .env.local
+172
View File
@@ -0,0 +1,172 @@
# RemoteRig Gitea CI/CD Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Set up Gitea Actions CI/CD pipeline with build → test → deploy stages for the RemoteRig React dashboard.
**Architecture:** Gitea Actions (GitHub Actions compatible) running in `.gitea/workflows/`. Single workflow file with three jobs: lint+typecheck, test, build, and a manual deploy step. The app is a Vite SPA that builds to `dist/` — deploy serves those static files.
**Tech Stack:** Gitea Actions, Node 22, Vite, Vitest, TypeScript, Tailwind
**Success criteria:**
- Build step completes successfully (`tsc -b && vite build`)
- All unit tests pass (`vitest run`)
- Deploy step exists (manual trigger for now)
---
### Task 1: Verify Gitea Actions runner availability
**Objective:** Confirm the Gitea instance has at least one Actions runner registered.
**Files:** None (read-only check)
**Step 1:** Check Gitea Actions runners
```bash
curl -s "https://code.cubecraftcreations.com/api/v1/admin/runners" \
-H "Authorization: bearer ${HERMES_GITEA_TOKEN}" | jq '.'
```
If this returns a list with runners, we're good. If 404 or empty, we need to register a runner.
**Step 2:** Check org-level runners
```bash
curl -s "https://code.cubecraftcreations.com/api/v1/orgs/CubeCraft-Creations/actions/runners" \
-H "Authorization: bearer ${HERMES_GITEA_TOKEN}" | jq '.'
```
**Expected output:** At least one runner with `"is_online": true` at either admin or org level.
**Verification:** Confirm runners exist before proceeding.
---
### Task 2: Create Gitea Actions CI/CD workflow
**Objective:** Create `.gitea/workflows/ci.yaml` with jobs for lint, typecheck, test, build, and deploy.
**Files:**
- Create: `.gitea/workflows/ci.yaml`
**Workflow structure:**
```yaml
name: CI/CD
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
jobs:
# ── Quality Gates ──────────────────────────────────────────
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
# ── Unit Tests ─────────────────────────────────────────────
test:
needs: lint-and-typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
# ── Build ──────────────────────────────────────────────────
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# ── Deploy ─────────────────────────────────────────────────
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy static files
run: |
echo "Deploying to production..."
# Replace with actual deploy command (rsync, scp, S3, etc.)
echo "Deploy target: /var/www/remote-rig/"
echo "Placeholder — configure deploy target before merging to main"
```
**Step 1:** Create the directory and file
```bash
mkdir -p /mnt/ai-storage/projects/remote-rig/.gitea/workflows
```
**Step 2:** Write the workflow file with the content above
**Step 3:** Verify YAML syntax
```bash
python3 -c "import yaml; yaml.safe_load(open('/mnt/ai-storage/projects/remote-rig/.gitea/workflows/ci.yaml'))" && echo "YAML: OK"
```
**Step 4:** Commit
```bash
cd /mnt/ai-storage/projects/remote-rig
git add .gitea/
git commit -m "ci: add Gitea Actions pipeline (lint, typecheck, test, build, deploy)"
```
---
### Task 3: Verify workflow triggers on push
**Objective:** Push the workflow and verify it appears in Gitea Actions.
**Step 1:** Push the branch
```bash
cd /mnt/ai-storage/projects/remote-rig
git push
```
**Step 2:** Check if the workflow registered
```bash
curl -s "https://code.cubecraftcreations.com/api/v1/repos/CubeCraft-Creations/remote-rig/actions/workflows" \
-H "Authorization: bearer ${HERMES_GITEA_TOKEN}" | jq '.workflows[] | {name, state, path}'
```
**Expected:** The CI/CD workflow appears with state "active".
**Verification:** Workflow is listed and active on the repo.
+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
```
+164
View File
@@ -0,0 +1,164 @@
# RemoteRig Central Hub
A central hub for managing remote camera rigs, designed for Raspberry Pi Zero 2 W.
## Overview
RemoteRig Central Hub is the control plane for remote camera setups. It connects to camera rigs over MQTT, stores configuration and state in SQLite, and exposes a management API — all from a lightweight Go binary optimized for resource-constrained devices like the Raspberry Pi Zero 2 W.
## Tech Stack
| Component | Technology |
| -------------- | ----------------------------- |
| Language | Go 1.24+ |
| Database | SQLite |
| Messaging | MQTT |
| Configuration | YAML (`gopkg.in/yaml.v3`) |
| Target Platform| Raspberry Pi Zero 2 W (ARMv6) |
## Project Structure
```
remote-rig/
├── cmd/
│ └── server/
│ └── main.go # Application entry point
├── internal/
│ └── db/
│ └── db.go # SQLite database initialization and schema
├── config.yaml # Application configuration
├── go.mod # Go module definition
├── go.sum # Dependency checksums
└── README.md
```
## Prerequisites
- **Go 1.24+** — [Download and install](https://go.dev/dl/)
- **MQTT Broker** — e.g., [Mosquitto](https://mosquitto.org/) (default: `localhost:1883`)
- **Raspberry Pi Zero 2 W** (or any Linux system — macOS and Windows also work for development)
- **Git** — for cloning the repository
## Setup
### 1. Clone the Repository
```bash
git clone https://code.cubecraftcreations.com/CubeCraft-Creations/remote-rig.git
cd remote-rig
```
### 2. Install Go Dependencies
```bash
go mod download
```
### 3. Configure
Edit `config.yaml` to match your environment:
```yaml
# Database file path (SQLite)
db_path: "remoterig.db"
# API key for endpoint authentication — CHANGE THIS
api_key: "your-secure-api-key-here"
# HTTP server settings
port: 8080
read_timeout: 5s
write_timeout: 10s
idle_timeout: 120s
# MQTT broker connection
mqtt:
broker: "localhost:1883"
client_id: "remoterig-hub"
# Target platform
platform:
type: "pi-zero-2w"
max_cameras: 16
```
Key settings to review:
| Setting | Description | Default |
| ------- | ----------- | ------- |
| `api_key` | API key for authenticating requests | `changeme` (**must change**) |
| `port` | HTTP server listen port | `8080` |
| `mqtt.broker` | MQTT broker address | `localhost:1883` |
| `mqtt.client_id` | MQTT client identifier | `remoterig-hub` |
| `platform.type` | Target platform identifier | `pi-zero-2w` |
| `platform.max_cameras` | Maximum number of camera rigs | `16` |
| `db_path` | SQLite database file path | `remoterig.db` |
| `read_timeout` | HTTP read timeout | `5s` |
| `write_timeout` | HTTP write timeout | `10s` |
| `idle_timeout` | HTTP idle connection timeout | `120s` |
## Running Locally
Start the hub with:
```bash
go run ./cmd/server/
```
You should see output similar to:
```
RemoteRig hub starting...
Database: remoterig.db
API key set: true
Server port: 8080
MQTT broker: localhost:1883
Platform: pi-zero-2w (max 16 cameras)
RemoteRig hub ready
```
## Deployment (CI/CD — pull-based)
Deploys are automated and pull-based, so nothing has to reach into the closed
RemoteRig network:
1. **Push to `dev`** → Gitea Actions (`.gitea/workflows/build-dev.yaml`) builds the
React frontend and cross-compiles the Go hub for **arm64**.
2. The workflow publishes the binary + `sha256` + `version.txt` to a rolling
**`dev` release**.
3. On the Pi, `remoterig-update.timer` runs `scripts/pi-update.sh` every few
minutes: it compares versions, downloads + verifies the checksum, and installs
via `scripts/deploy.sh` (backup → restart → rollback on failure).
First-time Pi setup (`sudo scripts/setup-pi.sh`) installs Mosquitto, the
`remoterig` service, and the updater timer. If the repo is private, set a read
token in `/opt/remoterig/update.env`.
### Manual / local cross-compile
The Pi Zero 2 W is a Cortex-A53 (ARMv8) running 64-bit Raspberry Pi OS, so the
target is **arm64**:
```bash
GOOS=linux GOARCH=arm64 go build -o remoterig-hub ./cmd/server/
scp remoterig-hub config.yaml pi@192.168.8.56:/opt/remoterig/
```
### Build Matrix
| Target | Command |
| ------ | ------- |
| Raspberry Pi Zero 2 W (64-bit OS) | `GOOS=linux GOARCH=arm64 go build -o remoterig-hub ./cmd/server/` |
| Raspberry Pi (32-bit OS) | `GOOS=linux GOARCH=arm GOARM=7 go build -o remoterig-hub ./cmd/server/` |
| Local (same arch) | `go build -o remoterig-hub ./cmd/server/` |
| Linux amd64 | `GOOS=linux GOARCH=amd64 go build -o remoterig-hub ./cmd/server/` |
## Running Tests
```bash
go test ./...
```
## License
Proprietary — CubeCraft Creations.
+62 -10
View File
@@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"embed"
"fmt" "fmt"
"io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -15,12 +17,16 @@ import (
"github.com/cubecraft/remoterig/internal/auth" "github.com/cubecraft/remoterig/internal/auth"
"github.com/cubecraft/remoterig/internal/db" "github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/internal/events" "github.com/cubecraft/remoterig/internal/events"
"github.com/cubecraft/remoterig/internal/mqtt"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
//go:embed all:src/dist
var frontendFS embed.FS
// Config holds the application configuration. // Config holds the application configuration.
type Config struct { type Config struct {
DBPath string `yaml:"db_path"` DBPath string `yaml:"db_path"`
@@ -59,13 +65,22 @@ func main() {
// Create SSE hub for real-time updates // Create SSE hub for real-time updates
sseHub := events.NewHub() sseHub := events.NewHub()
// Start MQTT subscriber for ESP32 camera status ingestion
mqttSub := mqtt.NewSubscriber(cfg.MQTT.Broker, cfg.MQTT.ClientID, sqlDB, sseHub)
if err := mqttSub.Connect(); err != nil {
log.Printf("WARNING: MQTT subscriber failed to connect: %v (running without MQTT)", err)
}
defer mqttSub.Close()
// Set up router // Set up router
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
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) {
@@ -74,15 +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
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
@@ -103,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
@@ -112,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))
@@ -151,3 +170,36 @@ func loadConfig(path string) (*Config, error) {
return &cfg, nil return &cfg, nil
} }
// frontendHandler returns an http.Handler that serves the embedded React
// frontend from src/dist/ with SPA-style fallback: any path that doesn't
// match a static file serves index.html for client-side routing.
//
// The frontend is embedded via //go:embed all:src/dist at build time.
// If src/dist/ is empty or missing at build time, the embedded fallback
// index.html (committed to the repo) is served instead, showing a
// "run npm run build" message.
func frontendHandler() http.Handler {
distFS, err := fs.Sub(frontendFS, "src/dist")
if err != nil {
// Shouldn't happen if embed worked, but be defensive.
panic("embedded frontend filesystem not found: " + err.Error())
}
fileServer := http.FileServer(http.FS(distFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to serve the requested file.
f, err := distFS.Open(r.URL.Path[1:]) // strip leading "/"
if err != nil {
// File not found — serve index.html for SPA routing.
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
return
}
f.Close()
// File exists, serve it.
fileServer.ServeHTTP(w, r)
})
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<!-- Placeholder so //go:embed all:src/dist always has a file to embed.
Replaced by the real Vite build output (npm run build) at CI build time. -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>RemoteRig Hub</title>
</head>
<body>
<p>RemoteRig hub is running. Frontend not built into this binary.</p>
</body>
</html>
+5 -2
View File
@@ -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
+364
View File
@@ -0,0 +1,364 @@
# RemoteRig — Project Context
> **Last updated:** 2026-05-21 (evening — post-planning sync)
> **Repo:** `CubeCraft-Creations/remote-rig` | **Host:** `code.cubecraftcreations.com`
> **Local clone:** `/mnt/ai-storage/projects/remote-rig` | **Default branch:** `dev`
> **Discord:** `DISCORD_DEV_REMOTERIG_CHANNEL_ID`
> **Linear Epic:** [CUB-198](https://linear.app/cubecraft-creations/issue/CUB-198)
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
---
## Overview
RemoteRig is a **multi-camera remote monitoring system**. It provides a camera grid dashboard for monitoring multiple GoPro cameras remotely. Cameras push status via MQTT/HTTP, the UI shows a live grid with SSE updates, and the system supports start/stop recording control.
**Target hardware:** Raspberry Pi Zero 2 W as the central hub, with ESP32 nodes attached to each GoPro camera for status collection and MQTT communication.
## Tech Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Backend | Go 1.25+ | Chi v5 router, SQLite (modernc.org/sqlite), go-yaml v3 |
| Frontend | React 19 + TypeScript 5.7 | Vite 6, Tailwind CSS 3.4 |
| State | Zustand 5 | Client-side camera state store |
| Icons | lucide-react 0.469 | |
| Real-time | SSE (Server-Sent Events) | `/api/v1/events/stream` |
| Messaging | MQTT | Mosquitto broker for ESP32 → hub communication |
| Database | SQLite | WAL mode, foreign keys enabled |
| Auth | API key (Bearer token) | Middleware, configurable in `config.yaml` |
| Testing | Vitest 4.1 + testing-library/react | Unit tests for React components |
| Linting | ESLint 9 | `npm run lint` |
| CI/CD | Gitea Actions | `.gitea/workflows/ci.yaml`, `build-dev.yaml`, `deploy-dev.yaml` |
## Architecture
```
┌──────────────────────────────────────────┐
│ Travel Router (self-contained LAN) │
│ Subnet: 192.168.8.0/24 │
│ DHCP pool: .100-.200 │
└──────┬──────────┬──────────┬──────────────┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
│ DHCP addr │ │ DHCP addr │ │ 192.168.8.56 │
│ STA→Router │ │ STA→Router │ │ (static IP) │
│ MQTT→:1883 │ │ MQTT→:1883 │ │ Mosquitto :1883 │
│ UART relay │ │ UART relay │ │ Go API :8080 │
│ │ │ │ │ React UI │
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
│ UART │ UART └──────────────────┘
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ ESP8266 #1 │ │ ESP8266 #N │ SSE /api/v1/events/stream
│ STA→GoPro AP │ │ STA→GoPro AP │ │
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│ ▼
└──────┬───────┘ └──────┬───────┘ ┌──────────────────┐
▼ ▼ │ User Device │
┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │
│ GoPro Hero 3 │ │ GoPro Hero 3 │ │ 192.168.8.56:8080 │
└──────────────┘ └──────────────┘ └──────────────────┘
```
**Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP8266 boards talk to the GoPro AP over HTTP, then relay camera status/commands over UART to ESP32 boards. ESP32 boards stay on the travel-router LAN and bridge UART messages to MQTT.
### Key Architecture Decisions (revised)
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `192.168.8.0/24`.
- **Two-board camera node** — ESP8266 handles GoPro AP/HTTP; ESP32 stays on the travel-router LAN for MQTT. This avoids ESP32 dual-STA/channel switching complexity.
- **ESP8266 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable.
- **UART bridge between boards** — ESP8266 reports GoPro status and receives commands over UART; ESP32 relays those messages to/from MQTT.
- **MQTT for ESP32 → Hub** — Lightweight, designed for IoT. Mosquitto on Pi. QoS 1 for status, QoS 2 for commands. Full contract: [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
- **SQLite over PostgreSQL** — Single-node Pi Zero 2 W deployment. WAL mode for concurrent read/write.
- **SSE over WebSocket** — Unidirectional hub → browser updates. Simpler, sufficient for status dashboard.
- **Chi router** — Lightweight Go HTTP router with middleware support.
- **Zustand over Redux** — Minimal boilerplate for camera status store.
- **API key auth** — Simple bearer token; closed LAN, not internet-facing.
- **Camera auto-discovery** — ESP32 publishes `announce` message on first MQTT connect. Hub auto-registers by MAC, assigns sequential `cam-NNN` ID.
## Directory Layout
```
remote-rig/
├── cmd/server/main.go # Entry point — config load, router setup, graceful shutdown
├── config.yaml # Runtime configuration
├── go.mod / go.sum # Go dependencies
├── internal/
│ ├── api/
│ │ ├── api.go # Package doc
│ │ ├── cameras.go # GET /cameras, POST /cameras, GET /cameras/:id
│ │ ├── recording.go # POST /cameras/:id/start, POST /cameras/:id/stop
│ │ └── status.go # POST /cameras/:id/status (push from ESP32)
│ ├── auth/
│ │ └── middleware.go # API key auth middleware
│ ├── db/
│ │ ├── db.go # Open, migrations, WAL mode
│ │ └── migrations/
│ │ └── 001_create_tables.sql
│ └── events/
│ └── sse.go # SSE hub (subscribe, broadcast)
├── pkg/models/
│ └── camera.go # Camera, StatusLog, RecordingEvent, CameraStatus, Settings
├── src/ # React frontend
│ ├── App.tsx # Main app — header, camera grid, footer
│ ├── components/
│ │ ├── CameraCard.tsx # Single camera status card
│ │ ├── CameraCard.test.tsx # Unit tests
│ │ └── index.ts
│ ├── hooks/
│ │ ├── useSSE.ts # SSE connection hook
│ │ ├── useCameraStatus.ts # Camera status hook
│ │ ├── useSystemHealth.ts # System health hook
│ │ └── index.ts
│ ├── services/
│ │ └── api.ts # API client
│ ├── store/
│ │ ├── useCameraStore.ts # Zustand store
│ │ └── index.ts
│ ├── types/
│ │ └── index.ts # TypeScript interfaces
│ ├── utils/
│ │ └── index.ts
│ └── main.tsx
├── docs/
│ ├── CONTEXT.md # ← this file
│ └── plans/
│ └── 2026-05-21-cub-196-cameracard.md # CameraCard implementation plan
├── .gitea/workflows/
│ ├── ci.yaml # PR CI: lint → typecheck → test → build
│ ├── build-dev.yaml # Go binary build on dev push
│ └── deploy-dev.yaml # SCP + SSH deploy with rollback
├── .env.example # VITE_API_URL=http://localhost:8080/api
├── package.json
├── vite.config.ts
├── tailwind.config.js
└── tsconfig.json
```
## Database Schema (SQLite)
### cameras
| Column | Type | Notes |
|--------|------|-------|
| camera_id | TEXT PK | Unique camera identifier |
| friendly_name | TEXT NOT NULL | Human-readable name |
| mac_address | TEXT UNIQUE | MAC address (optional) |
| created_at | DATETIME | Default now |
| updated_at | DATETIME | Default now |
### status_logs
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER PK AUTO | |
| camera_id | TEXT FK → cameras | |
| recorded_at | DATETIME | Default now |
| battery_pct | INTEGER | Nullable |
| video_remaining_sec | INTEGER | Nullable |
| recording_state | INTEGER | 0=idle, 1=recording |
| mode | TEXT | e.g. "video" |
| resolution | TEXT | e.g. "1080p" |
| fps | INTEGER | |
| online | INTEGER | 0=offline, 1=online |
| raw_battery_pct | REAL | Float precision |
Index: `(camera_id, recorded_at DESC)`
### recording_events
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER PK AUTO | |
| camera_id | TEXT FK → cameras | |
| started_at | DATETIME NOT NULL | |
| stopped_at | DATETIME | Null while recording |
| reason | TEXT | e.g. "manual" |
| duration | INTEGER | Seconds |
Index: `(camera_id, started_at DESC)`
### settings
| Column | Type | Notes |
|--------|------|-------|
| key | TEXT PK | |
| value | TEXT NOT NULL | |
| updated_at | DATETIME | Default now |
**Default seeds:** `poll_interval_sec=30`, `low_battery_threshold=15`, `low_storage_alert_sec=300`
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /health | No | Health check → `{"status":"ok"}` |
| GET | /api/v1/cameras | Yes | List all cameras with latest status |
| POST | /api/v1/cameras | Yes | Register a new camera |
| GET | /api/v1/cameras/:id | Yes | Camera detail + latest status + 24h history |
| POST | /api/v1/cameras/:id/start | Yes | Start recording + MQTT publish |
| POST | /api/v1/cameras/:id/stop | Yes | Stop recording |
| POST | /api/v1/cameras/:id/status | Yes | Push status from ESP32 node |
| GET | /api/v1/events/stream | No (SSE) | Real-time camera status stream |
## Configuration (`config.yaml`)
```yaml
db_path: "remoterig.db" # SQLite database path
api_key: "changeme" # Bearer token for API auth
port: 8080 # HTTP listen port
read_timeout: 5s
write_timeout: 10s
idle_timeout: 120s
mqtt:
broker: "localhost:1883" # Mosquitto on Pi Zero 2 W
client_id: "remoterig-hub"
platform:
type: "pi-zero-2w"
max_cameras: 16
network:
subnet: "192.168.8.0/24" # Travel router subnet
hub_ip: "192.168.8.56" # Pi Zero 2 W static IP
```
## Frontend Component Tree
```
App
├── Header
│ ├── Logo + Title ("RemoteRig Dashboard")
│ └── Stats bar (online count, recording count)
├── CameraGrid
│ └── CameraCard[] (responsive: 1→2→3→4 columns)
│ ├── Camera name + online/offline badge
│ ├── Resolution + FPS display
│ ├── Recording indicator (pulsing dot + REC/IDLE badge)
│ ├── Battery bar (color-coded: green/yellow/red)
│ └── Footer (Live/Last seen + video remaining)
└── Footer
└── "RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System"
```
**Empty state:** "Waiting for cameras..." with pulsing radio icon when no cameras connected.
**Offline state:** Camera card dimmed with dashed border, shows "Last seen Xm ago".
## Color Palette (Tailwind — dark dashboard theme)
Custom theme in `tailwind.config.js`:
- `rig-dark-900` (background), `rig-dark-800`, `rig-dark-700` (cards), etc.
- `rig-accent` (accent color)
- `rig-success` (green — battery ≥50%, online)
- `rig-warning` (yellow — battery 15-49%)
- `rig-danger` (red — battery <15%, offline, recording)
## Linear Issue Map
**Last synced:** 2026-05-21 (evening)
| CUB | Title | Status | Agent |
|-----|-------|--------|-------|
| 198 | **Epic: Multi-camera remote monitoring system** | Backlog | — |
| 238 | Define MQTT message format contract | ✅ Done | Dex |
| 228 | Add battery_calibration_offset to cameras table | Backlog | Hex |
| 230 | ESP32 offline status buffering and replay | Backlog | Pip |
| 232 | Implement MQTT subscriber in Go hub | Backlog | Dex |
| 229 | Design camera auto-discovery and registration flow | Backlog | Dex |
| 231 | Mosquitto MQTT broker setup on Pi Zero 2 W | Backlog | Dex |
| 233 | Verify and harden SSE endpoint | Backlog | Dex |
| 234 | Verify and harden all camera API endpoints | Backlog | Dex |
| 235 | Implement GET /api/v1/cameras/:id with 24h history | Backlog | Dex |
| 236 | Implement POST /api/v1/cameras registration | Backlog | Dex |
| 237 | Update CONTEXT.md to actual state | Backlog | Otto |
| — | — | — | — |
| 173 | Confirm GoPro Hero 3 WiFi control API | ✅ Done | Otto |
| 174 | ESP32 firmware baseline | ✅ Done | Pip |
| 175 | Central hub backend (Go service) | ✅ Done | Dex |
| 177 | Database schema (cameras, events, status_logs) | ✅ Done | Hex |
| 180 | Risk mitigation checklist | ✅ Done | — |
| 181 | Scaffold Go module, directory layout, config | ✅ Done | — |
| 187 | POST start recording + MQTT publish | ✅ Done | Dex |
| 194 | Scaffold Vite + React + TypeScript + Tailwind | ✅ Done | — |
| 195 | React SSE hook (useSSE.ts) + Zustand store | ✅ Done | Rex |
| 196 | CameraCard component + 16 unit tests | ✅ Done | Rex |
| 179 | Logging & persistence strategy | ✅ Done | — |
| 208 | Add README with project overview | ✅ Done | Hermes |
| 182 | ~~Duplicate of CUB-181~~ | ❌ Canceled | — |
| — | — | — | — |
| 183 | SQLite schema migration + DB init | Backlog | Hex |
| 184 | API key auth middleware | Backlog | Dex |
| 185 | Camera/StatusLog/RecordingEvent Go models | Backlog | Hex |
| 186 | GET /api/v1/cameras (list with live status) | Backlog | Dex |
| 188 | POST stop recording | Backlog | Dex |
| 189 | POST register new camera | Backlog | Dex |
| 190 | GET camera detail + history | Backlog | Dex |
| 191 | POST push-status (HTTP ingestion) | Backlog | Dex |
| 192 | MQTT subscriber | Backlog | Dex |
| 193 | SSE /api/v1/events/stream endpoint | Backlog | Dex |
| 178 | UX/UI design mockups | Backlog | Sketch |
| 197 | Dashboard camera grid wireframe | In Review | Sketch |
| 176 | Frontend umbrella (React + Tailwind) | Backlog | Rex |
## CI/CD Pipeline
### ci.yaml (PR gate)
Runs on push/PR to `dev` and `main`:
1. `lint-and-typecheck`: npm ci → eslint → tsc --noEmit
2. `test` (needs lint): npm test (vitest)
3. `build` (needs test): npm run build → upload dist artifact
### build-dev.yaml
Triggered by `repository_dispatch: dev-build-success`:
- Checks out, downloads artifact, builds Go binary
### deploy-dev.yaml
Triggered by `workflow_dispatch`:
- SCP binary + deploy script to dev host
- Deploy with backup/rollback, systemctl restart
- Failure notification
## Key Design Decisions
1. **SQLite chosen over PostgreSQL** — Single-node Pi Zero 2 W deployment; no need for a separate DB server. WAL mode for concurrent read/write.
2. **SSE chosen over WebSocket** — Unidirectional updates (hub → browser) are sufficient for status dashboard; SSE is simpler to implement and maintain.
3. **Chi router** — Lightweight, idiomatic Go HTTP router with middleware support.
4. **Zustand over Redux** — Minimal boilerplate for the camera status store.
5. **API key auth** — Simple bearer token; hub is on a local network, not internet-facing.
6. **MQTT** — Standard IoT protocol for ESP32 communication; Mosquitto broker runs locally on the Pi.
7. **Recording state tracking** — Status push handler detects recording state changes and automatically opens/closes recording_events rows.
## Known Limitations
- **MQTT subscriber not yet implemented** (CUB-232) — ESP32→hub communication backbone is designed (see [MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)) but not built. Currently only HTTP status push is wired up.
- **SSE endpoint needs verification** (CUB-233) — Frontend SSE hook exists (merged PR #2), backend SSE hub code exists (140 lines) but needs heartbeat, reconnection, and integration testing.
- **No camera auto-discovery** (CUB-229) — Cameras must be manually registered. ESP32 announce protocol designed in MQTT contract but not implemented.
- **No battery calibration** (CUB-228) — GoPro Hero 3 reports raw byte; per-camera calibration offset column not yet added to schema.
- **No offline buffering on ESP32** (CUB-230) — If travel router Wi-Fi drops, status data is lost. SPIFFS buffer designed but not implemented.
- **CameraCard wireframe pending** (CUB-197) — Dashboard UI has live code but needs UX review/wireframe sign-off.
- **remoterig.db committed to repo** — Should be in `.gitignore` for production. Low priority (convenient for dev).
- **Time sync TBD** — ESP32s need accurate timestamps without internet. Options: Pi as NTP server, GPS module, or HTTP time endpoint. See MQTT contract open questions.
## Getting Started
```bash
# Clone
cd /mnt/ai-storage/projects/remote-rig
git checkout dev
git pull origin dev
# Backend
go run cmd/server/main.go
# → runs on :8080
# Frontend
cp .env.example .env # edit if needed
npm install
npm run dev # → Vite dev server with API proxy
```
## Default Agent Assignments
| Area | Agent | Notes |
|------|-------|-------|
| Backend (Go API, MQTT, SSE) | Dex | gitea-dex MCP |
| Database (SQLite schema/migrations) | Hex | gitea-hex MCP |
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
| Hardware (ESP32 firmware, GPIO) | Pip | gitea-pip MCP |
| Design (wireframes, UX) | Sketch | |
+288
View File
@@ -0,0 +1,288 @@
# MQTT Message Format Contract — RemoteRig
> **Version:** 1.0.0 | **Status:** Draft | **Blocks:** CUB-232 (MQTT subscriber), CUB-174 (ESP32 firmware)
> **Last updated:** 2026-05-21
## Network Architecture
```
┌──────────────────────────────────┐
│ Travel Router (192.168.8.1) │
│ DHCP: .100-.200 │
└──────┬──────────┬──────────┬──────┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
│ 192.168.8.101 │ │ 192.168.8.102 │ │ 192.168.8.56 │
│ STA→Router │ │ STA→Router │ │ Mosquitto │
│ MQTT relay │ │ MQTT relay │ │ Go backend │
└──────┬───────┘ └──────┬───────┘ │ React UI │
│ UART │ UART └──────────────┘
▼ ▼
┌──────────────┐ ┌──────────────┐
│ ESP8266 #1 │ │ ESP8266 #2 │
│ STA→GoPro AP │ │ STA→GoPro AP │
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│
└──────┬───────┘ └──────┬───────┘
▼ ▼
┌──────────────┐ ┌──────────────┐
│ GoPro Hero 3 │ │ GoPro Hero 3 │
└──────────────┘ └──────────────┘
```
- **Travel router:** Self-contained, no internet. Gateway `192.168.8.1`. DHCP pool: `192.168.8.100-200`
- **Pi Zero 2 W:** Static IP `192.168.8.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
- **ESP32s:** DHCP from router. Each stays on the travel-router LAN, relays MQTT to/from its paired ESP8266 over UART
- **User device:** Connects to router, opens `http://192.168.8.56:8080` for dashboard
## MQTT Broker
- **Host:** `192.168.8.56` (Pi Zero 2 W)
- **Port:** `1883` (default MQTT, no TLS — closed network)
- **Auth:** None (closed network, no external access)
- **Client ID format:** `remoterig-<esp32_mac_last6>` (e.g., `remoterig-a1b2c3`)
- **QoS:** 1 (at least once) for status/heartbeat. 2 (exactly once) for commands.
- **Retain:** Status messages use `retain: true` so new subscribers get latest state immediately
## Topic Hierarchy
```
remoterig/
├── cameras/
│ └── <camera_id>/
│ ├── status ← ESP32 publishes (retained, QoS 1)
│ ├── heartbeat ← ESP32 publishes (QoS 1, not retained)
│ ├── command → Hub publishes (QoS 2)
│ └── announce ← ESP32 publishes on first boot (QoS 2, retained)
└── hub/
└── status ← Hub publishes (retained, QoS 1)
```
### Topic: `remoterig/cameras/<camera_id>/status`
**Direction:** ESP32 → Hub
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
Published by the ESP32 every 30s using the latest GoPro status received from the paired ESP8266 over UART.
```json
{
"camera_id": "cam-001",
"timestamp": "2026-05-21T18:30:00Z",
"battery_pct": 85,
"battery_raw": 217,
"video_remaining_sec": 3420,
"recording": true,
"mode": "video",
"resolution": "1080p",
"fps": 30,
"online": true,
"rssi": -52,
"uptime_sec": 1247
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `camera_id` | string | ✅ | Unique camera identifier (set during registration) |
| `timestamp` | ISO 8601 | ✅ | ESP32 clock time when status was read |
| `battery_pct` | int 0-100 | ✅ | Calibrated battery percentage (null if uncalibrated → omit) |
| `battery_raw` | int 0-255 | — | Raw byte from GoPro status offset 57 |
| `video_remaining_sec` | int | — | Estimated remaining recording seconds (null if unavailable) |
| `recording` | bool | ✅ | Whether camera is currently recording |
| `mode` | string | — | Current mode (e.g., "video", "photo", "burst") |
| `resolution` | string | — | Current resolution string |
| `fps` | int | — | Current frames per second |
| `online` | bool | ✅ | ESP32 can reach the GoPro (false if GoPro AP unreachable) |
| `rssi` | int | — | Wi-Fi RSSI to GoPro AP (dBm, negative) |
| `uptime_sec` | int | — | ESP32 uptime in seconds |
### Topic: `remoterig/cameras/<camera_id>/heartbeat`
**Direction:** ESP32 → Hub
**QoS:** 1 | **Retain:** false | **Interval:** 60 seconds
Lightweight keepalive so the hub can detect dead ESP32s.
```json
{
"camera_id": "cam-001",
"timestamp": "2026-05-21T18:31:00Z",
"uptime_sec": 1307,
"free_heap": 28672
}
```
| Field | Type | Description |
|-------|------|-------------|
| `camera_id` | string | Camera identifier |
| `timestamp` | ISO 8601 | Current ESP32 time |
| `uptime_sec` | int | ESP32 uptime |
| `free_heap` | int | Free heap in bytes (diagnostic) |
**Hub behavior:** If no heartbeat for 120 seconds, mark camera as offline (`online: false` in SSE broadcast).
### Topic: `remoterig/cameras/<camera_id>/command`
**Direction:** Hub → ESP32
**QoS:** 2 | **Retain:** false
Commands sent from the dashboard to individual cameras.
```json
{
"command": "start_recording",
"request_id": "req-abc123",
"timestamp": "2026-05-21T18:32:00Z"
}
```
**Supported commands:**
| `command` | Description | Response topic |
|-----------|-------------|----------------|
| `start_recording` | Start GoPro recording | status (updated on next poll) |
| `stop_recording` | Stop GoPro recording | status (updated on next poll) |
| `reboot` | Reboot the ESP32 | — (ESP32 reconnects after boot) |
**ESP32 / ESP8266 behavior:**
- ESP32 receives the MQTT command and forwards it over UART to the paired ESP8266
- ESP8266 executes the corresponding HTTP command against the GoPro AP
- Next status publish will reflect the new state
- If command fails (GoPro unreachable), publish status with `online: false`
### Topic: `remoterig/cameras/<camera_id>/announce`
**Direction:** ESP32 → Hub
**QoS:** 2 | **Retain:** true
Published once on ESP32 first boot (or factory reset). Used for auto-registration.
```json
{
"mac_address": "AA:BB:CC:DD:EE:FF",
"firmware_version": "0.1.0",
"capabilities": ["start_stop", "status"],
"friendly_name": "ESP32-AA-BB-CC"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `mac_address` | string | ESP32 Wi-Fi MAC address |
| `firmware_version` | string | ESP32 firmware semver |
| `capabilities` | string[] | Supported features |
| `friendly_name` | string | Default human-readable name |
**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 → 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`
**Direction:** Hub → All
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
Hub health status broadcast.
```json
{
"version": "0.2.0",
"uptime_sec": 86400,
"cameras_online": 3,
"cameras_total": 4,
"mqtt_connected": true,
"db_size_bytes": 1048576
}
```
## Message Validation Rules
### Hub-side (incoming from ESP32)
1. **Required fields:** `camera_id` and `timestamp` must be present in all messages
2. **Timestamp sanity:** Reject if timestamp is > 5 minutes in the future or > 24 hours in the past
3. **Duplicate detection:** Status messages with same `(camera_id, timestamp)` are ignored (idempotent)
4. **Schema validation:** Unknown fields are ignored (forward-compatible), missing required fields → log warning + reject
5. **battery_pct bounds:** If present, must be 0100. Out of range → clamp to [0,100] with warning
### ESP32-side (incoming from hub)
1. **Acknowledge commands:** After processing a command, the next status publish reflects the new state
2. **Unknown commands:** Log and ignore
3. **Malformed JSON:** Log error, ignore message
## Session Lifecycle
```
ESP32 boots
├── Connects to travel router Wi-Fi
├── Connects to MQTT broker (192.168.8.56:1883)
├── Publishes announce (retained) on cameras/<id>/announce
┌───────────────────────────────────────────────┐
│ Main loop (every 30s): │
│ 1. ESP32 requests/receives status via UART │
│ 2. ESP8266 polls GoPro HTTP (10.5.5.1) │
│ 3. ESP8266 returns parsed status over UART │
│ 4. ESP32 publishes MQTT status (retained) │
│ 5. Every 60s: ESP32 publishes heartbeat │
└───────────────────────────────────────────────┘
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
├── On ESP8266/GoPro unreachable → publish status with online: false
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
ESP32 shutdown / watchdog reboot
```
## Data Flow: Start Recording Example
```
1. User clicks "Start" on dashboard
2. Browser → HTTP POST /api/v1/cameras/cam-001/start → Go backend
3. Go backend → MQTT publish remoterig/cameras/cam-001/command {command: "start_recording"}
4. ESP32 receives command and forwards it to ESP8266 over UART
5. ESP8266 sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
6. GoPro starts recording
7. Next 30s poll: ESP8266 reports status over UART; ESP32 publishes status with recording: true
8. Go backend receives status, updates SQLite, fans out via SSE
9. Dashboard updates with pulsing REC indicator
```
## Offline Buffering (future — CUB-230)
When ESP32 loses connection to travel router:
1. **Buffer:** Store status snapshots in SPIFFS (LittleFS), max 100 entries (~6KB)
2. **Eviction:** FIFO — oldest dropped when buffer full
3. **Replay:** On MQTT reconnect, publish buffered messages in chronological order with original timestamps
4. **Dedup:** Hub ignores duplicates via `(camera_id, recorded_at)` unique constraint in status_logs
## Backward Compatibility
- **Adding fields:** Safe — unknown fields ignored by both sides
- **Removing fields:** Mark as optional first, remove in next major version
- **Changing field types:** New topic path (e.g., `status/v2`) or new field name
- **New topics:** Add freely — old clients ignore unknown topics
## Open Questions
1. **NTP/time sync:** How do ESP32s get accurate time without internet? Options: (a) Pi runs NTP server, (b) ESP32 queries Pi's HTTP /api/v1/time endpoint, (c) GPS module. **Recommendation:** Pi runs NTPd, ESP32s use SNTP from `192.168.8.56`.
2. **Camera naming:** Should `friendly_name` be configurable from dashboard after auto-registration? **Recommendation:** Yes — allow rename via UI, stored in cameras table.
3. **Firmware OTA:** Should ESP32 firmware updates be possible over this network? **Recommendation:** Yes but out of scope for MVP.
+508
View File
@@ -0,0 +1,508 @@
# Camera Auto-Discovery and Registration Flow — Design Document
> **Status:** Draft | **CUB:** 229 | **Date:** 2026-05-23
> **Depends on:** MQTT_CONTRACT.md v1.0.0 | **Affects:** CUB-189 (POST /cameras), CUB-232 (MQTT subscriber)
---
## 1. Overview
When a new ESP32 camera node powers on and connects to the travel router, it must self-register with the RemoteRig hub without any manual configuration. This document defines the auto-discovery protocol, message schemas, database extensions, error handling, and retry behavior.
### Design Goals
1. **Zero-touch provisioning** — ESP32 node registers itself on first MQTT connect; no dashboard interaction required
2. **Re-registration safe** — same node rejoining after a reboot or network blip is recognized and re-associated, not duplicated
3. **Idempotent** — replaying an announce due to MQTT retain or offline buffering does not create duplicate cameras
4. **Observable** — the dashboard receives real-time SSE events when a camera appears or reconnects
5. **Backward compatible** — existing announce format (`MQTT_CONTRACT.md`) is enhanced, not replaced
---
## 2. ESP32 Announce Message (Registration Request)
### Topic
```
remoterig/cameras/+/announce
```
**Direction:** ESP32 → Hub | **QoS:** 2 | **Retain:** true
Published once on ESP32 first boot (or factory reset). Retained so the hub sees it even if it restarts after the ESP32 came online.
### JSON Schema
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CameraAnnounce",
"type": "object",
"required": ["mac_address", "firmware_version", "capabilities"],
"properties": {
"mac_address": {
"type": "string",
"pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$",
"description": "ESP32 Wi-Fi station MAC address — the stable, globally unique hardware identifier"
},
"firmware_version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Semver of the ESP32 firmware (e.g. 0.2.0)"
},
"capabilities": {
"type": "array",
"items": { "type": "string", "enum": ["start_stop", "status", "reboot", "heartbeat"] },
"minItems": 1,
"description": "Supported feature flags. Minimal: [\"status\"]. Full: [\"start_stop\", \"status\", \"reboot\", \"heartbeat\"]"
},
"friendly_name": {
"type": "string",
"maxLength": 64,
"description": "Default human-readable name (e.g. 'ESP32-AA-BB-CC'). If omitted, hub generates one from the MAC."
},
"device_type": {
"type": "string",
"enum": ["esp32-gopro", "esp32-generic"],
"default": "esp32-gopro",
"description": "Device class for future multi-type support"
},
"mqtt_client_id": {
"type": "string",
"maxLength": 64,
"description": "The MQTT client ID the ESP32 connected with (diagnostic)"
},
"sdk_version": {
"type": "string",
"description": "ESP-IDF or Arduino SDK version (diagnostic)"
}
}
}
```
### Example — Minimal
```json
{
"mac_address": "AA:BB:CC:DD:EE:FF",
"firmware_version": "0.1.0",
"capabilities": ["status", "heartbeat"]
}
```
### Example — Full
```json
{
"mac_address": "AA:BB:CC:DD:EE:FF",
"firmware_version": "0.2.0",
"capabilities": ["start_stop", "status", "reboot", "heartbeat"],
"friendly_name": "GoPro Hero3 #1",
"device_type": "esp32-gopro",
"mqtt_client_id": "remoterig-ddeeff",
"sdk_version": "ESP-IDF v5.1.4"
}
```
### MAC Address as Identity
The ESP32's Wi-Fi station MAC is the only stable, globally unique identifier available on a closed network (no cloud, no serial number burned at factory). It is:
- **Globally unique** — OUI-assigned by Espressif
- **Immutable** — persists across firmware flashes and reboots
- **Available before MQTT connect** — no dependency on hub-assigned ID
The hub maps `mac_address → camera_id`. The `camera_id` (e.g. `cam-001`) is a short, human-friendly alias assigned at registration time.
---
## 3. Hub Response Protocol
When the hub processes an announce, it MUST publish a response so the ESP32 knows its registration outcome. The response goes to the **command topic** for the assigned camera.
### Response Topic
```
remoterig/cameras/<camera_id>/command
```
Direction: Hub → ESP32 | QoS: 2 | Retain: false
### Response Schema
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RegistrationResponse",
"type": "object",
"required": ["command", "request_id"],
"properties": {
"command": {
"type": "string",
"enum": ["registered", "registration_error"],
"description": "Outcome of the registration request"
},
"request_id": {
"type": "string",
"description": "Echo of the announce message's MAC + timestamp hash for correlation"
},
"camera_id": {
"type": "string",
"pattern": "^cam-\\d{3}$",
"description": "Assigned camera ID (present on success only)"
},
"error_code": {
"type": "string",
"enum": ["INVALID_MAC", "CAPABILITY_REQUIRED", "DB_WRITE_FAILED", "RATE_LIMITED"],
"description": "Machine-readable error code (present on failure only)"
},
"error_message": {
"type": "string",
"description": "Human-readable error description (present on failure only)"
},
"retry_after_sec": {
"type": "integer",
"minimum": 5,
"description": "Suggested retry delay in seconds (present on failure only)"
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 — hub clock time of the response"
}
}
}
```
### Success Response Example
```json
{
"command": "registered",
"request_id": "req-AABBCCDDEEFF-1684771200",
"camera_id": "cam-004",
"timestamp": "2026-05-23T14:30:00Z"
}
```
### Error Responses
| error_code | Meaning | retry_after_sec | ESP32 action |
|---|---|---|---|
| `INVALID_MAC` | MAC address absent or malformed | — (fatal) | Log error, halt registration |
| `CAPABILITY_REQUIRED` | No valid capabilities specified | — (fatal) | Log error, halt registration |
| `DB_WRITE_FAILED` | Hub database is unavailable (disk full, etc.) | 60 | Retry after delay |
| `RATE_LIMITED` | Too many registration attempts in a window | 30 | Retry after delay |
Example error response:
```json
{
"command": "registration_error",
"request_id": "req-AABBCCDDEEFF-1684771200",
"error_code": "DB_WRITE_FAILED",
"error_message": "Database write failed: disk I/O error",
"retry_after_sec": 60,
"timestamp": "2026-05-23T14:30:00Z"
}
```
### ESP32 Retry Logic
```
ESP32 publishes announce (QoS 2, retain)
├── Subscribe to remoterig/cameras/+/command (QoS 2)
├── Wait for command = "registered" or "registration_error"
├── Timeout after 30s → retry announce (with exponential backoff)
│ ├── 1st attempt: immediate
│ ├── 2nd attempt: wait 5s
│ ├── 3rd attempt: wait 10s
│ ├── 4th attempt: wait 20s
│ └── 5th+ attempt: wait 30s, repeat every 30s
├── On success (registered): store camera_id in NVS, begin normal status loop
├── On fatal error (INVALID_MAC, CAPABILITY_REQUIRED):
│ Log error, blink LED pattern, do not retry
└── On transient error (DB_WRITE_FAILED, RATE_LIMITED):
Wait retry_after_sec (capped at 120s), then re-publish announce
```
**After successful registration:** On subsequent boots, the ESP32 reads `camera_id` from NVS (non-volatile storage). It does NOT re-publish announce unless:
- `camera_id` is missing from NVS (factory reset / first boot)
- The hub publishes `command: "reregister"` to force re-registration (admin action)
---
## 4. Hub Processing Logic
### Registration Flow
```
Hub receives announce on remoterig/cameras/+/announce
├── 1. VALIDATE: mac_address present? matches pattern? → if no: publish INVALID_MAC error
├── 2. VALIDATE: capabilities non-empty? → if no: publish CAPABILITY_REQUIRED error
├── 3. RATE LIMIT: >5 registrations from same IP/MAC in 60s? → RATE_LIMITED error
├── 4. LOOKUP: SELECT camera_id FROM cameras WHERE mac_address = ?
│ │
│ ├── FOUND → EXISTING CAMERA:
│ │ ├── Update: friendly_name, firmware_version, capabilities, updated_at
│ │ ├── Publish registered response with existing camera_id
│ │ ├── SSE broadcast: "camera_reconnected"
│ │ └── Clear MQTT stale announce (publish empty retained message)
│ │
│ └── NOT FOUND → NEW CAMERA:
│ ├── Generate camera_id: "cam-NNN" (sequential)
│ ├── INSERT into cameras
│ ├── Publish registered response with new camera_id
│ ├── SSE broadcast: "camera_registered"
│ └── Clear MQTT stale announce (publish empty retained message)
└── 5. CLEANUP: Publish zero-byte retained message to announce topic
(prevents stale announces after camera is registered)
```
### Rate Limiting
To protect against buggy firmware or network loops:
| Window | Max Attempts | Action |
|--------|-------------|--------|
| 60 seconds | 5 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 30` |
| 5 minutes | 20 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 60` |
Rate limit state is in-memory only (not persisted). Restarting the hub resets the counters.
---
## 5. Database Schema Changes
### Extended `cameras` Table
```sql
-- Migration: 002_add_camera_registration_fields.sql
ALTER TABLE cameras ADD COLUMN firmware_version TEXT;
ALTER TABLE cameras ADD COLUMN capabilities TEXT NOT NULL DEFAULT '["status"]';
ALTER TABLE cameras ADD COLUMN device_type TEXT NOT NULL DEFAULT 'esp32-gopro';
ALTER TABLE cameras ADD COLUMN registration_status TEXT NOT NULL DEFAULT 'pending'
CHECK(registration_status IN ('pending', 'registered', 'error', 'decommissioned'));
ALTER TABLE cameras ADD COLUMN last_announce_at DATETIME;
ALTER TABLE cameras ADD COLUMN registration_error TEXT;
ALTER TABLE cameras ADD COLUMN mqtt_client_id TEXT;
-- Index for MAC lookups (already exists but confirm)
-- CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
-- Index for registration status filtering
CREATE INDEX IF NOT EXISTS idx_cameras_reg_status ON cameras(registration_status);
-- Index for finding stale registrations (cameras that announced but never sent status)
CREATE INDEX IF NOT EXISTS idx_cameras_last_announce ON cameras(last_announce_at);
```
### Full Table Definition (post-migration)
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `camera_id` | TEXT | PK | Hub-assigned short ID, e.g. `cam-001` |
| `friendly_name` | TEXT | NOT NULL | Human-readable name |
| `mac_address` | TEXT | UNIQUE | ESP32 Wi-Fi station MAC |
| `firmware_version` | TEXT | — | Firmware semver reported by ESP32 |
| `capabilities` | TEXT | NOT NULL, DEFAULT `'["status"]'` | JSON array of strings |
| `device_type` | TEXT | NOT NULL, DEFAULT `'esp32-gopro'` | Device class |
| `registration_status` | TEXT | NOT NULL, DEFAULT `'pending'` | `pending`, `registered`, `error`, `decommissioned` |
| `last_announce_at` | DATETIME | — | Timestamp of most recent announce |
| `registration_error` | TEXT | — | Last registration error message (cleared on success) |
| `mqtt_client_id` | TEXT | — | MQTT client ID from the announce |
| `created_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | First registration timestamp |
| `updated_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | Last update timestamp |
### Go Model Extension
The existing `models.Camera` struct gains:
```go
type Camera struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"`
FirmwareVersion string `json:"firmware_version,omitempty"`
Capabilities []string `json:"capabilities"`
DeviceType string `json:"device_type"`
RegistrationStatus string `json:"registration_status"`
LastAnnounceAt *time.Time `json:"last_announce_at,omitempty"`
RegistrationError string `json:"registration_error,omitempty"`
MqttClientID string `json:"mqtt_client_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
> **Note on `capabilities` storage:** SQLite does not have a native JSON array type. Store as TEXT (JSON-encoded array). Serialize/deserialize in the Go model layer. Migration default is `'["status"]'` — the minimum capability for a useful camera.
---
## 6. Registration Flow Sequence Diagram
```mermaid
sequenceDiagram
participant ESP32
participant Broker as MQTT Broker (Mosquitto)
participant Hub as Go Hub
participant DB as SQLite
participant SSE as SSE Hub
participant UI as Dashboard UI
Note over ESP32: Power on / First boot
ESP32->>ESP32: Read camera_id from NVS
alt camera_id NOT in NVS (first boot or factory reset)
ESP32->>Broker: CONNECT (client_id: remoterig-<mac_last6>)
Broker-->>ESP32: CONNACK
ESP32->>Broker: SUBSCRIBE remoterig/cameras/+/command (QoS 2)
Broker-->>ESP32: SUBACK
ESP32->>Broker: PUBLISH remoterig/cameras/announce (QoS 2, retain)
Note over ESP32,Broker: {mac_address, firmware_version, capabilities, ...}
Broker->>Hub: Forward announce
Hub->>Hub: Validate: MAC present? capabilities non-empty?
alt Validation fails
Hub->>Broker: PUBLISH command {command: "registration_error", error_code: "INVALID_MAC"}
Broker->>ESP32: Forward error
Note over ESP32: Log error, halt (fatal)
else Validation passes
Hub->>Hub: Rate limit check
alt Rate limited
Hub->>Broker: PUBLISH command {error_code: "RATE_LIMITED", retry_after_sec: 30}
Broker->>ESP32: Forward error
Note over ESP32: Wait retry_after_sec, retry
else Allowed
Hub->>DB: SELECT camera_id WHERE mac_address = ?
alt MAC already registered
DB-->>Hub: camera_id = "cam-002"
Hub->>DB: UPDATE cameras SET firmware_version, capabilities, friendly_name, ...
Hub->>SSE: Broadcast "camera_reconnected"
else New MAC
DB-->>Hub: no rows
Hub->>DB: SELECT MAX(camera_id) → "cam-003"
Hub->>Hub: Generate "cam-004"
Hub->>DB: INSERT INTO cameras (cam-004, ...)
Hub->>SSE: Broadcast "camera_registered"
end
Hub->>Broker: PUBLISH command {command: "registered", camera_id: "cam-004"}
Broker->>ESP32: Forward registration response
Hub->>Broker: PUBLISH announce (zero-byte retain) — clear stale announce
SSE-->>UI: camera_registered / camera_reconnected event
UI->>UI: Show new camera card in grid
end
end
else camera_id FOUND in NVS (subsequent boot)
Note over ESP32: Skip announce, proceed to status loop
ESP32->>Broker: PUBLISH status (QoS 1, retain)
Broker->>Hub: Forward status
Hub->>SSE: Broadcast camera_status
SSE-->>UI: Update camera card
end
```
---
## 7. Reconnection vs. Registration
It is critical to distinguish two scenarios:
### Scenario A: Reconnection (camera was previously registered)
```
ESP32 boots → reads camera_id from NVS → publishes status on remoterig/cameras/<id>/status
→ Hub sees status on a known camera_id → updates online flag → SSE broadcast
```
**No announce published.** The camera already has its identity.
### Scenario B: First Registration (or factory reset)
```
ESP32 boots → NVS empty → publishes announce → Hub assigns camera_id →
ESP32 stores camera_id in NVS → begins status loop on remoterig/cameras/<id>/status
```
### Scenario C: Hub Restart (ESP32 already running)
```
Hub restarts → subscribes to remoterig/cameras/+/announce →
MQTT broker delivers retained announce messages →
Hub processes each → re-registration safe (MAC already exists → update only)
```
This is why announce messages use `retain: true`. If the hub restarts while ESP32s are running, it re-discovers them from retained announces.
---
## 8. Security Considerations
| Concern | Mitigation |
|---------|-----------|
| Rogue node spoofing a MAC | Closed network (travel router, no internet). MAC filtering at the router level as defense-in-depth (future). |
| Replay attacks | Announce is idempotent — replaying it only updates timestamps, doesn't create duplicates. |
| Denial of registration | Rate limiting (Section 4) prevents flooding. |
| Unauthorized decommission | No `decommission` MQTT command exists. Decommission is admin-only via HTTP API with API key auth. |
---
## 9. Open Questions & Decisions
| Question | Decision | Rationale |
|----------|----------|-----------|
| **MAC as identity?** | ✅ Yes | Only globally unique, immutable ID available on a closed network. |
| **`camera_id` format?** | `cam-NNN` (zero-padded sequential) | Short, sortable, human-friendly. Collision-free with DB sequence. |
| **Re-registration behavior?** | Update existing, don't create duplicate | Announcing with same MAC = reconnection, not new camera. |
| **Retain on announce?** | ✅ Yes, cleared after processing | Allows hub restart recovery. Cleanup prevents stale data. |
| **Response protocol?** | Publish to `command` topic | Reuses existing command channel. ESP32 subscribes before publishing announce. |
| **Capabilities stored?** | ✅ Yes, in `capabilities` column | Enables future feature gating (e.g., "this camera can't start/stop recording"). |
| **`device_type` added?** | ✅ Yes, default `esp32-gopro` | Allows future camera types (e.g., Raspberry Pi CSI, USB webcam). |
| **Dashboard rename after auto-registration?** | ✅ Yes (via existing POST /cameras or settings API in future) | Already called out in MQTT_CONTRACT.md. No new work in this CUB. |
| **NVS key for camera_id?** | `"cam_id"` | Simple, unambiguous. |
---
## 10. Implementation Plan
This design document covers the protocol and schema design. Implementation is tracked in the following sub-issues:
| CUB | Title | Agent | Depends On |
|-----|-------|-------|------------|
| CUB-229 | Design camera auto-discovery and registration flow | Dex | — (this task) |
| CUB-229a | Migration: add registration fields to cameras table | Hex | CUB-229 |
| CUB-229b | Go model update: Camera struct with new fields | Dex | CUB-229a |
| CUB-229c | MQTT subscriber: registration response protocol | Dex | CUB-229b |
| CUB-229d | Rate limiting for announce messages | Dex | CUB-229b |
| CUB-229e | SSE events: camera_registered / camera_reconnected | Dex | CUB-229c |
| CUB-229f | ESP32 firmware: NVS storage + announce on first boot | Pip | CUB-229 |
| CUB-229g | ESP32 firmware: command subscription + registration ACK handling | Pip | CUB-229c |
| CUB-229h | Update MQTT_CONTRACT.md with registration response spec | Dex | CUB-229 |
| CUB-229i | Integration test: camera auto-registration end-to-end | Dex/Pip | CUB-229e, CUB-229g |
---
## 11. References
- [MQTT_CONTRACT.md](../MQTT_CONTRACT.md) — Network topology, topic hierarchy, existing status/heartbeat/command schemas
- [CONTEXT.md](../CONTEXT.md) — RemoteRig tech stack, directory layout, database schema
- [CUB-230 (Offline Buffer & Replay)](https://linear.app/cubecraft-creations/issue/CUB-230) — Related: offline buffering uses same dedup strategy
- [CUB-232 (MQTT Subscriber)](https://linear.app/cubecraft-creations/issue/CUB-232) — The subscriber that will implement this registration logic
- [CUB-189 (POST /cameras)](https://linear.app/cubecraft-creations/issue/CUB-189) — HTTP registration endpoint (may be replaced/supplemented by auto-discovery)
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteRig Dashboard Preview</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg-dark: #0f172a;
--card-dark: #1e293b;
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
}
body { background-color: var(--bg-dark); color: white; font-family: 'Inter', sans-serif; }
.status-card { background-color: var(--card-dark); transition: transform 0.2s ease, box-shadow 0.2s ease; }
.status-card:hover { transform: translateY(-4px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); }
.border-green { border-left: 4px solid var(--status-green); }
.border-yellow { border-left: 4px solid var(--status-yellow); }
.border-red { border-left: 4px solid var(--status-red); }
</style>
</head>
<body class="p-6">
<!-- Top Bar -->
<header class="flex justify-between items-center mb-8 p-4 bg-slate-800 rounded-lg shadow-lg">
<div class="flex items-center gap-3">
<div class="w-3 h-3 bg-blue-500 rounded-full animate-pulse"></div>
<h1 class="text-xl font-bold">◉ RemoteRig</h1>
</div>
<div class="flex items-center gap-4">
<span class="text-sm text-slate-400 flex items-center gap-2">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
System Online
</span>
<button class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-bold rounded transition-colors uppercase text-xs tracking-wider">
⏹ Stop All
</button>
</div>
</header>
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Card: Healthy -->
<div class="status-card border-green p-5 rounded-xl shadow-md">
<div class="flex justify-between items-start mb-4">
<h3 class="font-bold text-lg">🎥 Front Door</h3>
<span class="text-green-500 text-xs font-bold">ONLINE</span>
</div>
<div class="space-y-3 mb-6">
<div>
<div class="flex justify-between text-xs mb-1"><span>Battery</span><span class="text-green-400">82%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-green-500 h-full" style="width: 82%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1"><span>Storage</span><span class="text-green-400">45%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-green-500 h-full" style="width: 45%"></div>
</div>
</div>
<div class="flex items-center gap-2 text-sm font-mono text-red-500">
<span class="w-2 h-2 bg-red-500 rounded-full animate-ping"></span>
REC: 00:42:10
</div>
</div>
<button class="w-full py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm font-medium transition-colors">View Details ▸</button>
</div>
<!-- Card: Warning -->
<div class="status-card border-yellow p-5 rounded-xl shadow-md">
<div class="flex justify-between items-start mb-4">
<h3 class="font-bold text-lg">🎥 Backyard</h3>
<span class="text-yellow-500 text-xs font-bold">ONLINE</span>
</div>
<div class="space-y-3 mb-6">
<div>
<div class="flex justify-between text-xs mb-1"><span>Battery</span><span class="text-yellow-400">41%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-yellow-500 h-full" style="width: 41%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1"><span>Storage</span><span class="text-yellow-400">88%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-yellow-500 h-full" style="width: 88%"></div>
</div>
</div>
<div class="flex items-center gap-2 text-sm font-mono text-yellow-500">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
PAUSED
</div>
</div>
<button class="w-full py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm font-medium transition-colors">View Details ▸</button>
</div>
<!-- Card: Critical -->
<div class="status-card border-red p-5 rounded-xl shadow-md">
<div class="flex justify-between items-start mb-4">
<h3 class="font-bold text-lg">🎥 Garage</h3>
<span class="text-red-500 text-xs font-bold">OFFLINE</span>
</div>
<div class="space-y-3 mb-6">
<div>
<div class="flex justify-between text-xs mb-1"><span>Battery</span><span class="text-red-400">12%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-red-500 h-full" style="width: 12%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1"><span>Storage</span><span class="text-red-400">95%</span></div>
<div class="w-full bg-slate-700 h-2 rounded-full overflow-hidden">
<div class="bg-red-500 h-full" style="width: 95%"></div>
</div>
</div>
<div class="flex items-center gap-2 text-sm font-mono text-red-500">
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
OFFLINE
</div>
</div>
<button class="w-full py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm font-medium transition-colors">View Details ▸</button>
</div>
</div>
<footer class="mt-12 p-4 bg-slate-800 rounded-lg text-center text-sm text-slate-400 shadow-inner">
📊 6 cams | 4 recording | 1 paused | 1 offline | Storage: 60% used
</footer>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
# RemoteRig — Camera Monitoring Dashboard Design Specifications
## 1. Color Coding Thresholds
The system uses a semantic 3-tier color system to indicate device health.
| Metric | Green (Healthy) | Yellow (Warning) | Red (Critical) |
| :--- | :--- | :--- | :--- |
| **Battery %** | > 50% | 20% - 50% | < 20% |
| **Storage Used** | < 70% | 70% - 90% | > 90% |
| **Connection** | Heartbeat < 30s | Heartbeat 30s - 5m | Heartbeat > 5m / Offline |
| **Recording** | Recording Active | Standby/Paused | Stopped/Error |
---
## 2. Wireframes
### Screen A: Main Dashboard
**Layout:** Responsive Grid (3 col on 1920x1080, 2 col on 1024x600)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ◉ RemoteRig Dashboard [🟢 System OK] [⏹ STOP ALL] │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 🎥 Front Door │ │ 🎥 Backyard │ │ 🎥 Garage │ │
│ │ ------------------- │ │ ------------------- │ │ ------------------- │ │
│ │ 🔋 82% [Green] │ │ 🔋 41% [Yellow] │ │ 🔋 12% [Red] │ │
│ │ 💾 45% [Green] │ │ 💾 88% [Yellow] │ │ 💾 95% [Red] │ │
│ │ 🔴 REC: 00:42:10 │ │ 🟡 PAUSED │ │ ⚫ OFFLINE │ │
│ │ │ │ │ │ │
│ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 🎥 Driveway │ │ 🎥 Workshop │ │ 🎥 3D Printer │ │
│ │ ------------------- │ │ ------------------- │ │ ------------------- │ │
│ │ 🔋 98% [Green] │ │ 🔋 30% [Yellow] │ │ 🔋 65% [Green] │ │
│ │ 💾 12% [Green] │ │ 💾 20% [Green] │ │ 💾 15% [Green] │ │
│ │ 🔴 REC: 01:12:05 │ │ 🔴 REC: 00:05:22 │ │ 🟡 STANDBY │ │
│ │ │ │ │ │ │
│ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │ [ VIEW DETAILS ▸ ] │ │
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Screen B: Camera Detail
**Layout:** Two-column split (Visual/Stats vs. History/Events)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Back to Dashboard | 🎥 Front Door Detail [⏹ STOP] │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────┐ ┌────────────────────────────────────────┐ │
│ │ │ │ 📅 RECORDING HISTORY (Last 7 Days) │ │
│ │ LIVE VIDEO FEED │ │ │ │
│ │ (4:3 Aspect) │ │ M [███] T [███] W [█ ] T [███] F [█ ] │ │
│ │ │ │ S [███] S [███] (Total: 12.4h) │ │
│ │ │ │ │ │
│ │ │ ├────────────────────────────────────────┤ │
│ │ │ │ 📋 RECENT EVENTS │ │
│ │ │ │ - 12:01: Recording Started │ │
│ │ │ │ - 11:45: Motion Detected (Zone A) │ │
│ │ │ │ - 11:00: Battery Warning (40%) │ │
│ │ │ │ - 10:12: Camera Rebooted │ │
│ │ │ │ [ VIEW ALL EVENTS ] │ │
│ └────────────────────────────┘ └────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────┐ │
│ │ ⚙️ CAMERA STATUS │ │
│ │ -------------------------- │ │
│ │ Battery: 82% [Green] │ │
│ │ Storage: 45% [Green] │ │
│ │ Status: RECORDING │ │
│ │ IP: 192.168.8.12 │ │
│ │ MAC: AA:BB:CC:DD:EE:FF │ │
│ │ │ │
│ │ [ EDIT CAMERA SETTINGS ] │ │
│ └────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Screen C: Settings / Registration
**Layout:** Centered Form with Sidebar List
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Back to Dashboard | ⚙️ Camera Registration/Edit │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────┐ ┌───────────────────┐ │
│ │ 📝 CAMERA DETAILS │ │ 📋 REGISTERED CAMS │ │
│ │ --------------------------------------------- │ │ ----------------- │ │
│ │ │ │ 🎥 Front Door │ │
│ │ Friendly Name: │ │ 🎥 Backyard │ │
│ │ [ Front Door ] │ │ 🎥 Garage │ │
│ │ │ │ 🎥 Driveway │ │
│ │ MAC Address: │ │ 🎥 Workshop │ │
│ │ [ AA:BB:CC:DD:EE:FF ] │ │ 🎥 3D Printer │ │
│ │ │ │ │
│ │ Notes: │ │ [ + ADD NEW CAM ] │ │
│ │ [ Covers the main entryway and porch area. ] │ └───────────────────┘ │
│ │ [ ] │ │
│ │ [ ] │ │
│ │ │ │
│ │ [ TEST CONNECTION ] [ 💾 SAVE CHANGES ] │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## 3. Responsive Design Notes
### Viewport: 1920x1080 (Desktop)
- **Grid:** 3 columns for camera cards.
- **Navigation:** Top bar fixed, spacing is generous (24px margins).
- **Details:** Full split-screen layout for camera details.
### Viewport: 1024x600 (Kiosk)
- **Grid:** 2 columns for camera cards.
- **Top Bar:** Compact height (48px), "STOP ALL" button remains prominent in top-right.
- **Details:** Vertical stack for Camera Detail (Feed $\rightarrow$ Stats $\rightarrow$ Events).
- **Settings:** Sidebar moves to the bottom of the form.
- **Sizing:** UI elements scaled up (touch-friendly targets min 44x44px).
+100
View File
@@ -0,0 +1,100 @@
# ESP-01S firmware updates without a USB-UART adapter
Status: **design / not yet implemented.** Interim mitigations (config-over-UART,
GPIO reservation) are shipped; see "Shipped now" below.
## Problem
The ESP-01S camera bridge has no native USB. Today it is flashed with an external
3.3 V USB-UART adapter and a `GPIO0 → GND` jumper held during reset. On an assembled
field node that is impractical — we want to update it over the network.
## Why not Wi-Fi OTA on the ESP-01S itself
1. **Network topology.** The ESP-01S joins the *GoPro* AP (`10.5.5.1`), not the hub /
travel-router network. The hub cannot reach it to push an OTA.
2. **1 MB flash.** Standard ESP8266 OTA stages a second copy of the sketch alongside the
running one. Our sketch is ~333 KB; a 1 MB module has no room for two copies plus FS
and reserved areas.
So updates must arrive **through the XIAO**, which is already UART-connected to the
ESP-01S and sits on the hub network.
## Approach: XIAO ESP32-C6 as the flasher (UART OTA)
The XIAO plays the role the USB-UART adapter plays today, driving the ESP-01S's ROM
serial bootloader over the existing UART.
### Hardware — two added control lines
| XIAO pin | → ESP-01S | purpose |
|----------|-----------|---------|
| `D8` (`ESP01_RST_PIN`) | `RST` | pulse low to reset the ESP-01S |
| `D10` (`ESP01_PGM_PIN`) | `GPIO0` | hold low across reset → enter bootloader |
| `D6` (TX) / `D7` (RX) | `RX` / `TX` | existing `Serial1` link (crossed) |
| GND | GND | common ground |
> **Confirm before committing the PCB/wiring:** verify `D8`/`D10` on the actual XIAO
> ESP32-C6 variant do **not** map to ESP32-C6 strapping pins (`GPIO8`, `GPIO9`, `GPIO15`)
> or the USB-JTAG pins. Pins are reserved in firmware (`ESP01_RST_PIN`, `ESP01_PGM_PIN`)
> but not yet driven.
### Bootloader entry
`GPIO0 = LOW`, pulse `RST` low→high → ESP-01S enters the serial bootloader on the UART.
After writing: `GPIO0 = HIGH`, pulse `RST` → run the new firmware. Always restore
`GPIO0 = HIGH` on give-up so the ESP can boot normally.
### Flash protocol
Implement enough of the ESP8266 ROM bootloader / esptool SLIP protocol on the XIAO over
`Serial1`:
- `SYNC`, then `FLASH_BEGIN` / `FLASH_DATA` (≈1 KB blocks) / `FLASH_END` to write the app
at offset `0x0`.
- Start at 115200 baud; optionally raise after sync.
- Verify with the ROM `SPI_FLASH_MD5` against the expected MD5.
### Firmware delivery (hub → XIAO)
Greenfield on the Go hub (only a `firmware_version` field exists today). Recommended:
- **HTTP pull.** Hub exposes `GET /firmware/esp01s/<version>.bin` (+ MD5). XIAO is
triggered by an MQTT command, e.g.
`{"command":"update_esp01s","url":"http://<hub>/firmware/esp01s/0.4.0.bin","md5":"…"}`,
fetches the `.bin` in chunks, and streams each chunk straight into a `FLASH_DATA` block.
- Avoid buffering the whole image in RAM — stream HTTP chunk → flash block → repeat.
- MQTT chunked transfer is possible but heavier on the broker; prefer HTTP.
### Sequencing / safety
- Pause the UART JSON status/command protocol while flashing (the link is busy with the
bootloader protocol).
- On failure leave the ESP recoverable and retry; report progress/result to the hub over
MQTT.
### XIAO self-update (separate, easy)
The XIAO (4 MB flash, on the hub network) can use standard ESP32 OTA (`ArduinoOTA` or
`httpUpdate`). No gymnastics required. Split: **XIAO = native OTA; ESP-01S = flashed by
the XIAO over UART.**
## Scope estimate
- **XIAO firmware:** ESP8266 ROM-loader client over `Serial1` + `GPIO0`/`RST` control +
HTTP fetch + MQTT trigger. Mediumlarge.
- **Hub (Go):** firmware store + HTTP endpoint + MQTT trigger command. Smallmedium.
- **Hardware:** two control wires + confirm non-strapping pins. Small.
## Shipped now (interim)
- **Config-over-UART (`set_config`)** — change GoPro SSID/password/IP and poll interval
with no reflash. Hub → `set_camera_config` (MQTT) → XIAO → `set_config` (UART) →
ESP-01S persists to LittleFS.
- **GPIO reservation** — `ESP01_RST_PIN = D8`, `ESP01_PGM_PIN = D10` reserved in the XIAO
firmware for the flasher.
## References
- ESP8266 ROM serial bootloader protocol (esptool).
- Wiring: Notion "XIAO ESP32-C6 Pin-to-Pin Wiring Diagram".
+134
View File
@@ -0,0 +1,134 @@
# RemoteRig — Dual-Board Camera Node Firmware
> **Platform:** PlatformIO (esp8266-camera + esp32-mqtt)
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
> **Hardware:** [hardware/README.md](../hardware/README.md)
## Architecture
Each camera node uses **two boards** connected via UART — zero network switching:
```
┌─────────────────────┐ UART ┌─────────────────────┐
│ ESP8266 D1 Mini │ TX──────→RX │ ESP32 Dev Board │
│ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │
│ │ 115200 │ │
│ STA → GoPro AP │ 8N1 │ STA → Travel Router │
│ HTTP → 10.5.5.1 │ │ MQTT → 192.168.8.56│
│ Start/stop/status │ │ Hub registration │
└─────────────────────┘ └──────────────────────┘
```
| Board | Job | Network | Protocol |
|-------|-----|---------|----------|
| ESP8266 | Camera control | GoPro AP only (10.5.5.1) | HTTP → UART JSON |
| ESP32 | Hub relay | Travel router only (192.168.8.x) | UART JSON → MQTT |
## Quick Start
```bash
pip install platformio
cd firmware
# Build both
pio run -e esp8266-camera
pio run -e esp32-mqtt
# Upload to boards (connect one at a time via USB)
pio run -e esp8266-camera --target upload
pio run -e esp32-mqtt --target upload
# Upload configs (each board needs its own)
# ESP8266: copy esp8266-config.json to data/config.json, then:
pio run -e esp8266-camera --target uploadfs
# ESP32: copy esp32-config.json to data/config.json, then:
pio run -e esp32-mqtt --target uploadfs
```
## UART Protocol (ESP8266 ↔ ESP32)
JSON-per-line at 115200 8N1. GPIO16 on both boards.
| Direction | Type | Format | Purpose |
|-----------|------|--------|---------|
| ESP8266 → ESP32 | `status` | `{"type":"status","battery_raw":217,...}` | Camera poll result |
| ESP8266 → ESP32 | `ack` | `{"type":"ack","cmd":"start_recording"}` | Command confirmation |
| ESP8266 → ESP32 | `pong` | `{"type":"pong","uptime_ms":12345}` | Ping response |
| ESP8266 → ESP32 | `error` | `{"type":"error","msg":"camera unreachable"}` | Error report |
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"start_recording"}` | Hub command |
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"ping"}` | Link health check |
## Configuration
### ESP8266 (`data/esp8266-config.json`)
| Key | Default | Description |
|-----|---------|-------------|
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP name |
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
| `camera_ip` | `"10.5.5.1"` | Camera IP (change for Akaso to 192.168.1.1) |
| `poll_interval_sec` | `30` | How often to poll camera |
### ESP32 (`data/esp32-config.json`)
| Key | Default | Description |
|-----|---------|-------------|
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
| `wifi_password` | `""` | Travel router password |
| `mqtt_broker` | `"192.168.8.56"` | Pi Zero 2 W IP |
| `mqtt_port` | `1883` | Mosquitto port |
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
## Wiring
```
ESP8266 D1 Mini ESP32 Dev Board
┌────────────┐ ┌────────────┐
│ │ │ │
│ TX (GPIO1)│──────────→│ RX (GPIO16)│
│ RX (GPIO3)│←──────────│ TX (GPIO17)│
│ GND │───────────│ GND │
│ 3.3V │ │ 3.3V │
│ │ │ │
└────────────┘ └────────────┘
│ │
└────────┬─────────────┘
LiPo → 3.3V Buck
(shared power)
```
## Boot Sequence
1. **ESP8266:** Connect to GoPro AP → wait for UART commands
2. **ESP32:** Connect to travel router → connect MQTT → announce if new
3. **ESP8266:** Poll camera every 30s → send status over UART
4. **ESP32:** Receive status → publish MQTT
5. **Hub → MQTT command → ESP32 → UART → ESP8266 → HTTP → GoPro**
## Camera Compatibility
| Camera | `camera_ip` | Protocol | Status |
|--------|------------|----------|--------|
| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support |
| Akaso Brave 7 | `192.168.1.1` | Varies | 🔬 Set `camera_ip`, test |
For non-GoPro cameras: only the ESP8266 firmware needs changes — the ESP32 stays the same.
## LED Status (ESP8266)
| LED | Meaning |
|-----|---------|
| Solid on | Connected to camera AP, camera responding |
| Slow blink (500ms) | Connected to AP but camera not responding |
| Off | Wi-Fi disconnected |
## Troubleshooting
| Symptom | Check |
|---------|-------|
| No UART communication | Verify TX→RX crossover. Both boards at 115200. Shared GND. |
| ESP8266 can't connect | GoPro must be ON with Wi-Fi enabled. Default password: `goprohero` |
| ESP32 can't connect MQTT | `systemctl status mosquitto` on Pi. Port 1883 open. |
| Camera never registers | Watch ESP32 serial for "Announced" message. Check hub logs. |
+10
View File
@@ -0,0 +1,10 @@
{
"wifi_ssid": "RemoteRig",
"wifi_password": "RemoteRig1",
"mqtt_broker": "192.168.8.56",
"mqtt_port": 1883,
"camera_id": "",
"heartbeat_interval_sec": 60,
"bat_raw_min": 0,
"bat_raw_max": 0
}
+6
View File
@@ -0,0 +1,6 @@
{
"camera_ssid": "goprosilver-1",
"camera_password": "Bzyeatn421",
"camera_ip": "10.5.5.9",
"poll_interval_sec": 30
}
+83
View File
@@ -0,0 +1,83 @@
; RemoteRig — Dual-Board Camera Node Firmware
; ============================================
; Each camera node has TWO boards connected via UART:
;
; ESP8266 (Camera Bridge): Connects to GoPro AP → HTTP status/control
; ESP32 (MQTT Bridge): Connects to travel router → MQTT to hub
;
; ESP8266 ←──UART──→ ESP32
; (TX/RX) (RX16/TX17)
;
; Build:
; pio run -e esp8266-camera (ESP8266 — GoPro camera bridge)
; pio run -e seeed_xiao_esp32c6 (XIAO ESP32-C6 — MQTT bridge)
;
; Upload:
; pio run -e esp8266-camera --target upload
; pio run -e seeed_xiao_esp32c6 --target upload
;
; Filesystem:
; pio run -e esp8266-camera --target uploadfs
; pio run -e seeed_xiao_esp32c6 --target uploadfs
[common]
lib_deps =
knolleary/PubSubClient @ ^2.8
bblanchon/ArduinoJson @ ^7.3
build_flags =
-D CORE_DEBUG_LEVEL=0
; ── ESP-01S: Camera Bridge ──────────────────────────────────
; Flashed onto an ESP-01S (ESP8266, 1MB flash). Talks to the GoPro
; over Wi-Fi, relays to the XIAO over the hardware UART (GPIO1/3).
; No MQTT, no router connection.
;
; Flash with a 3.3V USB-UART adapter: tie GPIO0 → GND, power up /
; reset into bootloader, then upload. ESP-01S flash is qio/dout;
; keep upload_speed modest for adapter reliability.
[env:esp8266-camera]
platform = espressif8266
board = esp01_1m
framework = arduino
monitor_speed = 115200
upload_speed = 115200
lib_deps = ${common.lib_deps}
build_flags = ${common.build_flags}
-D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED
board_build.flash_mode = dout
board_build.f_cpu = 160000000L
build_src_filter =
-<*.cpp>
+<esp8266-camera-bridge.cpp>
+<../lib/>
; ── XIAO ESP32-C6: MQTT Bridge ─────────────────────────────
; Flashed onto a Seeed Studio XIAO ESP32-C6. Connects to the
; travel router, publishes MQTT to the Pi hub. Reads camera
; status from the ESP-01S over UART (Serial1: RX=D7, TX=D6).
; No direct camera communication.
;
; ESP32-C6 requires the pioarduino fork of platform-espressif32
; (mainline espressif32 lagged on the Arduino-core 3.x the C6
; needs). USB-CDC-on-boot is required for Serial over native USB.
;
; Upload fallback if it can't connect: hold B (BOOT), tap
; R (RESET), release B, then re-run upload.
[env:seeed_xiao_esp32c6]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
board = seeed_xiao_esp32c6
framework = arduino
monitor_speed = 115200
lib_deps =
${common.lib_deps}
olikraus/U8g2 @ ^2.35
build_flags = ${common.build_flags}
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
build_src_filter =
-<*.cpp>
+<esp32-mqtt-bridge.cpp>
+<../lib/>
+540
View File
@@ -0,0 +1,540 @@
/**
* RemoteRig — ESP32 MQTT Bridge Firmware
* ======================================
* Dedicated board per camera node. Connects the ESP8266 camera bridge
* to the RemoteRig MQTT hub.
*
* ONE JOB: relay between UART (ESP8266) and MQTT (Pi hub).
* - Connects to travel router Wi-Fi
* - Reads status JSON from ESP8266 over UART → publishes via MQTT
* - Receives commands via MQTT from hub → forwards to ESP8266 over UART
* - Handles auto-registration (announce on first boot)
* - Heartbeat publishing
* - Zero camera communication, zero network switching
*
* UART Protocol: JSON-per-line at 115200 8N1
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
* ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n
*
* Hardware:
* - Seeed Studio XIAO ESP32-C6
* - Serial1: RX=D7, TX=D6 (crossed to the ESP-01S TX/RX)
* - Shared GND between boards
* - 5V rail → XIAO 5V/VIN; ESP-01S on its own 3.3V buck
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <Wire.h>
#include <U8g2lib.h>
// ────────────────────────────────────────────
// Configuration (LittleFS)
// ────────────────────────────────────────────
struct Config {
String wifi_ssid = "RemoteRig";
String wifi_password = "";
String mqtt_broker = "192.168.8.56";
int mqtt_port = 1883;
String camera_id = ""; // assigned by hub
int heartbeat_sec = 60;
// Battery calibration: two-point linear map of the GoPro offset-57
// raw byte → percent. Uncalibrated when max <= min (then we omit
// battery_pct per the MQTT contract). Set via the set_battery_cal
// command and persisted here.
int bat_raw_min = 0; // raw at 0%
int bat_raw_max = 0; // raw at 100%
} cfg;
bool loadConfig() {
if (!LittleFS.begin(true)) { Serial.println("[CFG] LittleFS mount failed"); return false; }
File f = LittleFS.open("/config.json", "r");
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
cfg.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec;
cfg.bat_raw_min = doc["bat_raw_min"] | cfg.bat_raw_min;
cfg.bat_raw_max = doc["bat_raw_max"] | cfg.bat_raw_max;
Serial.printf("[CFG] Loaded: ssid=%s broker=%s:%d cam=%s\n",
cfg.wifi_ssid.c_str(), cfg.mqtt_broker.c_str(), cfg.mqtt_port,
cfg.camera_id.length() ? cfg.camera_id.c_str() : "-");
return true;
}
bool saveConfig() {
File f = LittleFS.open("/config.json", "w");
if (!f) return false;
JsonDocument doc;
doc["wifi_ssid"] = cfg.wifi_ssid;
doc["wifi_password"] = cfg.wifi_password;
doc["mqtt_broker"] = cfg.mqtt_broker;
doc["mqtt_port"] = cfg.mqtt_port;
doc["camera_id"] = cfg.camera_id;
doc["heartbeat_interval_sec"] = cfg.heartbeat_sec;
doc["bat_raw_min"] = cfg.bat_raw_min;
doc["bat_raw_max"] = cfg.bat_raw_max;
serializeJson(doc, f);
f.close();
return true;
}
// Map a raw offset-57 byte to battery percent using the stored
// two-point calibration. Returns -1 when uncalibrated.
int batteryPct(int raw) {
if (cfg.bat_raw_max <= cfg.bat_raw_min) return -1; // uncalibrated
long pct = (long)(raw - cfg.bat_raw_min) * 100 /
(cfg.bat_raw_max - cfg.bat_raw_min);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
return (int)pct;
}
// ────────────────────────────────────────────
// UART to ESP-01S (HardwareSerial1)
// ────────────────────────────────────────────
// XIAO ESP32-C6 Serial1: RX=D7, TX=D6 (Serial = native USB CDC)
// Connect: XIAO RX(D7) ← ESP-01S TX
// XIAO TX(D6) → ESP-01S RX
#define UART_ESP8266 Serial1
#define UART_RX_PIN D7
#define UART_TX_PIN D6
// Reserved for future ESP-01S UART OTA ("XIAO as flasher"): two control
// lines let the XIAO drive the ESP-01S into its serial bootloader and
// reflash it over Serial1 — no USB-UART adapter or GPIO0 jumper needed.
// Not driven yet; see docs/design/esp01s-uart-ota.md.
#define ESP01_RST_PIN D8 // → ESP-01S RST (pulse low to reset)
#define ESP01_PGM_PIN D10 // → ESP-01S GPIO0 (low at reset = bootloader)
// ────────────────────────────────────────────
// RGB STAT LED — D0/D1/D2 (red/green/blue) via 220Ω each
// ────────────────────────────────────────────
// Wiring assumes common cathode (HIGH = on). Set RGB_COMMON_ANODE to
// 1 for a common-anode part (LOW = on).
#define RGB_PIN_R D0
#define RGB_PIN_G D1
#define RGB_PIN_B D2
#define RGB_COMMON_ANODE 1 // this module is common-anode (LOW = on)
void rgbWrite(bool r, bool g, bool b) {
#if RGB_COMMON_ANODE
digitalWrite(RGB_PIN_R, !r); digitalWrite(RGB_PIN_G, !g); digitalWrite(RGB_PIN_B, !b);
#else
digitalWrite(RGB_PIN_R, r); digitalWrite(RGB_PIN_G, g); digitalWrite(RGB_PIN_B, b);
#endif
}
void rgbInit() {
pinMode(RGB_PIN_R, OUTPUT);
pinMode(RGB_PIN_G, OUTPUT);
pinMode(RGB_PIN_B, OUTPUT);
rgbWrite(0, 0, 1); // boot = blue
}
// ────────────────────────────────────────────
// Status OLED — 1.3" I2C panel on D4(SDA)/D5(SCL)
// ────────────────────────────────────────────
// 1.3" 128x64 modules are SH1106. If the image is shifted ~2px or
// wrapped, the panel is an SSD1306 — swap the constructor below to
// U8G2_SSD1306_128X64_NONAME_F_HW_I2C.
#define OLED_SDA_PIN D4
#define OLED_SCL_PIN D5
#define OLED_I2C_ADDR 0x3C
U8G2_SH1106_128X64_NONAME_F_HW_I2C oled(U8G2_R0, U8X8_PIN_NONE);
bool oledReady = false;
// Last-known camera status, mirrored for the display.
int dispBatteryRaw = 0;
bool dispRecording = false;
int dispVideoRemain = 0; // seconds
unsigned long recStartMs = 0; // 0 = not recording
// Walk the bus and log every responder — confirms the OLED address
// (and wiring) independent of the display driver.
void i2cScan() {
Serial.println("[I2C] Scanning...");
byte found = 0;
for (byte a = 1; a < 127; a++) {
Wire.beginTransmission(a);
if (Wire.endTransmission() == 0) {
Serial.printf("[I2C] device @ 0x%02X\n", a);
found++;
}
}
if (!found) Serial.println("[I2C] none found — check wiring/power");
}
void displayInit() {
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
i2cScan();
oled.setI2CAddress(OLED_I2C_ADDR << 1);
oledReady = oled.begin();
Serial.printf("[OLED] begin %s\n", oledReady ? "ok" : "FAILED");
if (!oledReady) return;
oled.clearBuffer();
oled.setFont(u8g2_font_7x14B_tr);
oled.drawStr(0, 14, "RemoteRig");
oled.setFont(u8g2_font_6x10_tr);
oled.drawStr(0, 32, "Camera node");
oled.drawStr(0, 46, "booting...");
oled.sendBuffer();
}
void sendCmdToESP8266(const String& command) {
JsonDocument doc;
doc["type"] = "cmd";
doc["command"] = command;
String line;
serializeJson(doc, line);
UART_ESP8266.println(line);
UART_ESP8266.flush();
}
String uartLine;
bool readFromESP8266(String& line) {
while (UART_ESP8266.available()) {
char c = UART_ESP8266.read();
if (c == '\n') {
line = uartLine;
uartLine = "";
return true;
}
if (c != '\r') uartLine += c;
}
return false;
}
// ────────────────────────────────────────────
// MQTT
// ────────────────────────────────────────────
WiFiClient routerClient;
PubSubClient mqtt(routerClient);
unsigned long bootMs = 0;
bool cameraOnline = false;
unsigned long lastStatusMs = 0;
String clientID() {
uint8_t mac[6];
WiFi.macAddress(mac);
char buf[32];
snprintf(buf, sizeof(buf), "rig-%02x%02x%02x", mac[3], mac[4], mac[5]);
return String(buf);
}
String mqttTopic(const char* t) {
return "remoterig/cameras/" + cfg.camera_id + "/" + t;
}
void mqttCallback(char* topic, byte* payload, unsigned int len) {
char buf[256];
unsigned int n = len < 255 ? len : 255;
memcpy(buf, payload, n); buf[n] = 0;
JsonDocument doc;
if (deserializeJson(doc, buf)) return;
String cmd = doc["command"] | "";
if (cmd == "start_recording" || cmd == "stop_recording") {
Serial.printf("[MQTT] Forwarding command: %s → ESP8266\n", cmd.c_str());
sendCmdToESP8266(cmd);
} else if (cmd == "reboot") {
ESP.restart();
} else if (cmd == "set_battery_cal") {
// Two ways to calibrate:
// explicit: {"raw_min":185,"raw_max":245}
// capture: {"point":"full"|"empty"} → uses the latest raw reading
String point = doc["point"] | "";
if (point == "full") cfg.bat_raw_max = dispBatteryRaw;
else if (point == "empty") cfg.bat_raw_min = dispBatteryRaw;
else {
cfg.bat_raw_min = doc["raw_min"] | cfg.bat_raw_min;
cfg.bat_raw_max = doc["raw_max"] | cfg.bat_raw_max;
}
saveConfig();
Serial.printf("[BAT] Calibration set: raw_min=%d raw_max=%d\n",
cfg.bat_raw_min, cfg.bat_raw_max);
} else if (cmd == "set_camera_config") {
// Forward camera-bridge config to the ESP-01S over UART so the
// GoPro creds / poll rate can change without reflashing it.
JsonDocument out;
out["type"] = "cmd";
out["command"] = "set_config";
if (!doc["camera_ssid"].isNull()) out["camera_ssid"] = doc["camera_ssid"];
if (!doc["camera_password"].isNull()) out["camera_password"] = doc["camera_password"];
if (!doc["camera_ip"].isNull()) out["camera_ip"] = doc["camera_ip"];
if (!doc["poll_interval_sec"].isNull()) out["poll_interval_sec"] = doc["poll_interval_sec"];
String line; serializeJson(out, line);
UART_ESP8266.println(line);
UART_ESP8266.flush();
Serial.println("[MQTT] Forwarded set_config → ESP-01S");
} else if (cmd == "registered") {
String id = doc["camera_id"] | "";
if (id.length() > 0 && id != cfg.camera_id) {
cfg.camera_id = id;
saveConfig();
mqtt.unsubscribe(mqttTopic("command").c_str());
mqtt.subscribe(mqttTopic("command").c_str(), 2);
Serial.printf("[MQTT] Registered as %s\n", id.c_str());
}
}
}
bool connectMQTT() {
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
mqtt.setCallback(mqttCallback);
mqtt.setKeepAlive(60);
if (!mqtt.connect(clientID().c_str())) {
Serial.printf("[MQTT] Connect fail (state=%d)\n", mqtt.state());
return false;
}
Serial.println("[MQTT] Connected");
// 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"
}
// 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.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);
mqtt.publish(mqttTopic("announce").c_str(), payload.c_str(), true);
Serial.printf("[MQTT] Announced as %s\n", cfg.camera_id.c_str());
}
return true;
}
// ────────────────────────────────────────────
// Status screen + LED
// ────────────────────────────────────────────
// Reflect overall health on the RGB STAT LED.
// red = offline (no Wi-Fi)
// magenta = Wi-Fi up, hub (MQTT) unreachable
// yellow = hub up, GoPro unreachable
// green = healthy (hub + camera reachable)
void updateStatusLed() {
if (WiFi.status() != WL_CONNECTED) rgbWrite(1, 0, 0); // red
else if (!mqtt.connected()) rgbWrite(1, 0, 1); // magenta
else if (!cameraOnline) rgbWrite(1, 1, 0); // yellow
else rgbWrite(0, 1, 0); // green
}
void renderStatus() {
if (!oledReady) return;
oled.clearBuffer();
// Camera id (top, bold)
oled.setFont(u8g2_font_7x14B_tr);
String id = cfg.camera_id.length() ? cfg.camera_id : clientID();
oled.drawStr(0, 13, id.c_str());
oled.setFont(u8g2_font_6x10_tr);
char line[24];
// REC state + session timer
if (dispRecording) {
unsigned long s = recStartMs ? (millis() - recStartMs) / 1000 : 0;
oled.drawBox(0, 19, 6, 6); // filled square = REC
snprintf(line, sizeof(line), "REC %02lu:%02lu", s / 60, s % 60);
oled.drawStr(10, 26, line);
} else {
oled.drawStr(0, 26, "IDLE");
}
// Battery (% when calibrated, else raw) + video remaining (minutes)
int pct = batteryPct(dispBatteryRaw);
if (pct >= 0) snprintf(line, sizeof(line), "BAT %d%% VID %dm", pct, dispVideoRemain / 60);
else snprintf(line, sizeof(line), "BAT %d VID %dm", dispBatteryRaw, dispVideoRemain / 60);
oled.drawStr(0, 38, line);
// Uplink to the hub
const char* link = mqtt.connected() ? "LINK: MQTT ok"
: WiFi.status() == WL_CONNECTED ? "LINK: wifi only"
: "LINK: offline";
oled.drawStr(0, 50, link);
// Camera reachability
oled.drawStr(0, 62, cameraOnline ? "CAM: online" : "CAM: --");
oled.sendBuffer();
}
// ────────────────────────────────────────────
// Setup
// ────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0");
bootMs = millis();
rgbInit(); // RGB STAT LED — blue during boot
displayInit(); // I2C scan + OLED splash
loadConfig();
// UART to ESP-01S
UART_ESP8266.begin(115200, SERIAL_8N1, UART_RX_PIN, UART_TX_PIN);
Serial.println("[UART] ESP-01S link on Serial1 (RX=D7, TX=D6) @ 115200");
// Connect to travel router — the ONLY network we touch
Serial.printf("[WIFI] Connecting to: %s\n", cfg.wifi_ssid.c_str());
WiFi.mode(WIFI_STA);
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
delay(500); Serial.print("."); attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\n[WIFI] FAILED — will retry");
}
// MQTT
if (WiFi.status() == WL_CONNECTED) {
connectMQTT();
}
}
// ────────────────────────────────────────────
// Main Loop
// ────────────────────────────────────────────
void loop() {
unsigned long now = millis();
static unsigned long lastBeat = 0, lastRecon = 0;
static int reconDelay = 1;
// ── OLED + LED refresh (always — keep them live even when offline) ──
static unsigned long lastDisp = 0;
if (now - lastDisp > 500) { lastDisp = now; renderStatus(); updateStatusLed(); }
// ── Wi-Fi watchdog ──
if (WiFi.status() != WL_CONNECTED) {
if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); }
delay(100); return;
}
// ── MQTT watchdog ──
if (!mqtt.connected()) {
if (now - lastRecon > (unsigned long)(reconDelay * 1000)) {
lastRecon = now;
if (connectMQTT()) reconDelay = 1;
else reconDelay = min(reconDelay * 2, 30);
}
mqtt.loop(); delay(100); return;
}
mqtt.loop();
// ── Read status from ESP8266 over UART → publish via MQTT ──
String line;
while (readFromESP8266(line)) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, line);
if (err) { Serial.printf("[UART] Bad JSON: %s\n", line.c_str()); continue; }
String type = doc["type"] | "";
if (type == "status") {
// Relay camera status to MQTT hub
lastStatusMs = now;
bool online = doc["online"] | false;
cameraOnline = online; // reflected on the RGB LED by updateStatusLed()
// Mirror status onto the OLED fields
dispBatteryRaw = doc["battery_raw"] | 0;
dispVideoRemain = doc["video_remaining_sec"] | 0;
bool rec = doc["recording"] | false;
if (rec && !dispRecording) recStartMs = millis();
if (!rec) recStartMs = 0;
dispRecording = rec;
if (cfg.camera_id.length() > 0) {
// Build the MQTT status payload per contract
JsonDocument mqttDoc;
mqttDoc["camera_id"] = cfg.camera_id;
// 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
mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0;
mqttDoc["recording"] = doc["recording"] | false;
mqttDoc["online"] = online;
String payload;
serializeJson(mqttDoc, payload);
mqtt.publish(mqttTopic("status").c_str(), payload.c_str(), true);
}
}
else if (type == "ack") {
Serial.printf("[UART] ESP8266 ack: %s\n", doc["cmd"] | "?");
}
else if (type == "pong") {
Serial.printf("[UART] ESP8266 pong (uptime=%d)\n", doc["uptime_ms"] | 0);
}
else if (type == "error") {
Serial.printf("[UART] ESP8266 error: %s\n", doc["msg"] | "?");
}
}
// ── Heartbeat to hub (every heartbeat_sec) ──
if (cfg.camera_id.length() > 0 &&
now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) {
lastBeat = now;
JsonDocument doc;
doc["camera_id"] = cfg.camera_id;
doc["timestamp"] = millis();
doc["uptime_sec"] = (now - bootMs) / 1000;
doc["free_heap"] = ESP.getFreeHeap();
doc["status_age_ms"] = now - lastStatusMs;
String payload; serializeJson(doc, payload);
mqtt.publish(mqttTopic("heartbeat").c_str(), payload.c_str(), false);
}
// ── Periodic ping to ESP8266 to verify UART link ──
static unsigned long lastPing = 0;
if (now - lastPing > 30000) {
lastPing = now;
sendCmdToESP8266("ping");
}
delay(50);
}
+323
View File
@@ -0,0 +1,323 @@
/**
* RemoteRig — ESP8266 Camera Bridge Firmware
* ==========================================
* Dedicated board clipped to each GoPro Hero 3.
*
* ONE JOB: talk to the camera.
* - Connects to GoPro Wi-Fi AP (10.5.5.1)
* - Polls status every 30s → sends JSON over UART to ESP32
* - Receives commands from ESP32 over UART → executes against camera
* - Zero network switching, zero MQTT, zero cloud
*
* UART Protocol: JSON-per-line at 115200 8N1
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
* ESP8266 → ESP32: {"type":"error","msg":"..."}\n
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
*
* Hardware:
* - ESP-01S (ESP8266, 1MB flash) on its own 3.3V buck
* - UART is the hardware Serial (GPIO1 TX / GPIO3 RX), crossed:
* ESP-01S TX (GPIO1) → XIAO D7 (RX)
* ESP-01S RX (GPIO3) ← XIAO D6 (TX)
* - Shared GND between boards
* - Flash with a 3.3V USB-UART adapter, GPIO0 → GND on power-up
*
* Note: the JSON protocol shares the same UART as the boot-ROM/debug
* output, so the ESP32 also sees boot chatter and ignores it as
* non-JSON. There is no spare pin for a status LED on the ESP-01S
* (GPIO1 is the UART TX) — status is shown on the XIAO panel instead.
*/
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266HTTPClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
// ────────────────────────────────────────────
// Configuration (SPIFFS via LittleFS)
// ────────────────────────────────────────────
struct Config {
// 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;
bool loadConfig() {
if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; }
File f = LittleFS.open("/config.json", "r");
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
cfg.camera_ip = doc["camera_ip"] | cfg.camera_ip;
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
return true;
}
// Persist current config to LittleFS. Lets the hub update camera
// credentials/poll rate over UART without reflashing the ESP-01S.
bool saveConfig() {
if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; }
File f = LittleFS.open("/config.json", "w");
if (!f) { Serial.println("[CFG] open for write failed"); return false; }
JsonDocument doc;
doc["camera_ssid"] = cfg.camera_ssid;
doc["camera_password"] = cfg.camera_password;
doc["camera_ip"] = cfg.camera_ip;
doc["poll_interval_sec"] = cfg.poll_interval_sec;
serializeJson(doc, f);
f.close();
return true;
}
// ────────────────────────────────────────────
// Camera HTTP Client (GoPro Hero 3)
// ────────────────────────────────────────────
WiFiClient goproClient;
struct CamStatus {
bool valid = false;
int video_remaining_sec = 0;
bool recording = false;
int battery_raw = 0;
};
CamStatus fetchStatus() {
CamStatus s;
// 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; }
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 (n < 30) return s;
s.valid = true;
s.recording = (buf[29] == 1);
s.battery_raw = buf[19];
s.video_remaining_sec = buf[25] | (buf[26] << 8);
return s;
}
bool sendCommand(const String& cmd) {
String param = (cmd == "start_recording") ? "%01" : "%00";
String url = "http://" + cfg.camera_ip +
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
HTTPClient http;
http.useHTTP10(true);
http.begin(goproClient, url);
http.setTimeout(5000);
int code = http.GET();
http.end();
return (code == 200);
}
// ────────────────────────────────────────────
// UART Protocol (to ESP32)
// ────────────────────────────────────────────
// Using HardwareSerial on GPIO1/3 (D1 Mini default TX/RX)
// On D1 Mini: TX=GPIO1, RX=GPIO3 (labeled TX/RX on board)
// Send JSON line to ESP32
void sendToESP32(const JsonDocument& doc) {
String line;
serializeJson(doc, line);
Serial.println(line); // newline-terminated for framing
Serial.flush();
}
// Send status update
void sendStatus(const CamStatus& s) {
JsonDocument doc;
doc["type"] = "status";
doc["valid"] = s.valid;
doc["battery_raw"] = s.battery_raw;
doc["video_remaining_sec"] = s.video_remaining_sec;
doc["recording"] = s.recording;
doc["online"] = s.valid;
doc["uptime_ms"] = millis();
sendToESP32(doc);
}
// Send acknowledgment
void sendAck(const String& cmd) {
JsonDocument doc;
doc["type"] = "ack";
doc["cmd"] = cmd;
sendToESP32(doc);
}
// Send error
void sendError(const String& msg) {
JsonDocument doc;
doc["type"] = "error";
doc["msg"] = msg;
sendToESP32(doc);
}
// ────────────────────────────────────────────
// Command handling (from ESP32 over UART)
// ────────────────────────────────────────────
void handleCommand(const JsonDocument& doc) {
String cmd = doc["command"] | "";
if (cmd == "start_recording" || cmd == "stop_recording") {
bool ok = sendCommand(cmd);
if (ok) {
sendAck(cmd);
} else {
sendError("Camera unreachable — command failed");
}
} else if (cmd == "ping") {
JsonDocument pong;
pong["type"] = "pong";
pong["uptime_ms"] = millis();
sendToESP32(pong);
} else if (cmd == "set_config") {
// No-reflash config update from the hub (via the XIAO over UART).
// Only provided fields change; the rest keep their current value.
String oldSsid = cfg.camera_ssid, oldPw = cfg.camera_password;
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
cfg.camera_ip = doc["camera_ip"] | cfg.camera_ip;
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
saveConfig();
sendAck("set_config");
// Re-associate if the camera Wi-Fi credentials changed.
if (cfg.camera_ssid != oldSsid || cfg.camera_password != oldPw) {
WiFi.disconnect();
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
}
} else {
sendError("Unknown command: " + cmd);
}
}
// ────────────────────────────────────────────
// UART line reader (non-blocking)
// ────────────────────────────────────────────
String serialLine;
bool readLine(String& line) {
while (Serial.available()) {
char c = Serial.read();
if (c == '\n') {
line = serialLine;
serialLine = "";
return true;
}
if (c != '\r') serialLine += c;
}
return false;
}
// ────────────────────────────────────────────
// Setup
// ────────────────────────────────────────────
// No status LED: GPIO1 is the UART TX to the XIAO and GPIO3 is RX,
// leaving no free pin on the ESP-01S. Status lives on the XIAO panel.
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n[BRIDGE] ESP-01S Camera Bridge v1.0");
loadConfig();
// Connect to GoPro AP — this is the ONLY network we touch
Serial.printf("[WIFI] Connecting to camera AP: %s\n", cfg.camera_ssid.c_str());
WiFi.mode(WIFI_STA);
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500); Serial.print("."); attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\n[WIFI] FAILED — will retry in loop");
}
}
// ────────────────────────────────────────────
// Main Loop — poll camera, relay over UART
// ────────────────────────────────────────────
void loop() {
unsigned long now = millis();
static unsigned long lastPoll = 0;
static unsigned long lastWiFiRetry = 0;
// ── Wi-Fi reconnection ──
if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) {
lastWiFiRetry = now;
Serial.println("[WIFI] Reconnecting...");
WiFi.reconnect();
}
// ── Poll camera ──
if (now - lastPoll > (unsigned long)(cfg.poll_interval_sec * 1000)) {
lastPoll = now;
if (WiFi.status() == WL_CONNECTED) {
CamStatus s = fetchStatus();
sendStatus(s);
} else {
// Offline — send empty status so ESP32 knows we're alive but camera is down
CamStatus s;
sendStatus(s);
}
}
// ── Read commands from ESP32 over UART ──
String line;
if (readLine(line)) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, line);
if (!err) {
String type = doc["type"] | "";
if (type == "cmd") {
handleCommand(doc);
}
// Ignore other message types — they're for the ESP32
}
}
}
+4
View File
@@ -3,6 +3,7 @@ module github.com/cubecraft/remoterig
go 1.25.0 go 1.25.0
require ( require (
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1 modernc.org/sqlite v1.50.1
@@ -11,9 +12,12 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+6
View File
@@ -1,11 +1,15 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -16,6 +20,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+167
View File
@@ -0,0 +1,167 @@
# RemoteRig Hardware Design Pipeline
> Living queue for 3D-printed / physical hardware design work.
## Active / Ready for CAD prototype
### Camera node case v4 — upright status panel + strap mount
**Status:** Parametric OpenSCAD source created; body/lid/preview STLs exported and validated watertight. Ready for CAD review, exact part measurement, and first prototype print.
**Files:**
- `hardware/case/camera-node-case-v4.scad`
- `hardware/case/camera-node-case-v4-body.scad`
- `hardware/case/camera-node-case-v4-lid.scad`
- `hardware/case/camera-node-case-v4-preview.scad`
- `hardware/case/camera-node-case-v4-front-review.scad`
- `hardware/case/camera-node-case-v4-body.stl`
- `hardware/case/camera-node-case-v4-lid.stl`
- `hardware/case/camera-node-case-v4-preview.stl`
- `hardware/case/camera-node-case-v4-front-review.stl`
**Design direction:**
- Stand-mounted upright camera node enclosure; the case still does **not** mount to the GoPro.
- Visual direction now matches the original green appliance-style reference: tall vertical body, large inset front panel, centered OLED near the upper third, blank middle area, two long rounded lower slots, bottom USB-C female power input, right-side USB-A female passthrough power port for the GoPro, and left-side IPEX/U.FL antenna pigtail/connector exit opposite the USB-A.
- This replaces the rejected wide/low generic electronics-box layout from the first v4 attempt.
- Primary mounting is reusable cloth zip ties / Velcro straps through two low-profile vertical rear brackets with long lateral side-feed openings, not a clamp/dovetail.
- Front has a recessed/flush full-height service lid similar to a field-service status panel.
- Lid includes cutouts for:
- 1.3-inch OLED/status screen.
- separate 3 mm power LED.
- single 3 mm RGB status LED replacing red/green status LEDs.
- small rocker on/off switch.
- two long rounded lower front slots styled after the reference.
- Front-panel screen, LED, rocker, and lower-slot openings are actual through-cuts through the full lid and locating lip so the back side of the printed lid is not skinned over.
- Body includes screw bosses, recessed lid pocket, lid locating geometry, a bottom USB-C female power inlet cutout, a right-side USB-A female passthrough power cutout, a left-side 5.0 mm prototype IPEX/U.FL antenna pigtail/connector through-hole with shallow exterior recess, and two vertical external rear zip-tie/Velcro brackets to resist rotation on a stand. The zip ties feed laterally through long side windows behind the raised bridge faces; the old top-to-bottom feed-through tunnel is intentionally closed by top/bottom anchor pads.
- Internal envelope is sized for known module dimensions plus service clearance:
- ESP32-C3 Super Mini: 22.5 × 18 mm.
- ESP-01S: ~24.7 × 14.3 × 12 mm.
**Prototype display content target:**
```text
CAM 03 REC ●
BAT 87% LINK OK
00:12:34
```
**Prototype dimensions to validate before production:**
- Exact 1.3-inch OLED module dimensions:
- PCB width/height/thickness.
- active display/window width/height.
- connector side and ribbon/header clearance.
- mounting-hole positions, if using module screws or adhesive tape.
- Rocker switch:
- snap-in cutout width/height.
- bezel/flange size.
- required panel thickness range.
- rear depth and terminal clearance.
- LEDs:
- preferred holder/bezel style, if any.
- final hole diameter for 3 mm PWR LED and 3 mm RGB STAT LED.
- current-limiting resistor placement.
- Wiring/service:
- USB cable diameter and bend radius.
- bottom USB-C female panel/breakout connector flange, body depth, and mounting requirements.
- right-side USB-A female panel/breakout connector flange, body depth, and mounting requirements for GoPro 5 V passthrough.
- left-side IPEX/U.FL antenna pigtail/bulkhead exact outside diameter, retention/flange needs, bend radius, strain relief, and whether the current 5.0 mm prototype through-hole plus 8.5 mm shallow exterior recess should change before production.
- actual regulator/power distribution board footprint if used.
- Fasteners:
- M2 vs M2.5 vs self-tapping screws for lid.
- pilot diameter, screw length, and head/counterbore diameter.
- Mounting straps:
- cloth zip-tie / Velcro width and thickness.
- prototype rear side-feed opening: ~40 mm long vertical side window × ~3.8 mm strap-thickness clearance behind each raised bridge, with each visible vertical bracket ~8.5 mm wide × 50 mm tall.
- whether two strap paths are enough to prevent case rotation on the expected stand diameter.
- whether rear vertical bracket/window edges need larger radii or TPU/silicone sleeve protection.
- Printability:
- rear vertical zip-tie bracket top/bottom anchor-pad and bridge strength, and whether the lateral side-feed openings print cleanly without supports.
- body/lid fit after PETG shrinkage.
- lid lip clearance and screw boss robustness.
- USB-C/USB-A and IPEX antenna exit cutout edge quality, wall strength, and connector retention/strain relief.
**Suggested OpenSCAD validation/export commands:**
```bash
openscad -o /tmp/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4-body.scad
openscad -o /tmp/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4-lid.scad
openscad -o /tmp/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4-preview.scad
openscad -o /tmp/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4-front-review.scad
```
Latest validation: OpenSCAD reports `Simple: yes`; trimesh confirms body, lid, preview, and front-review STLs are watertight. Body and lid each export as a single connected printable component; preview includes separate non-print board/connector guide volumes by design. A rear-bracket sanity check confirms both vertical brackets have clear non-solid lateral X-direction side-feed volumes behind the raised bridge faces, while the rear wall, bridge faces, and top/bottom anchor pads remain solid. The left-side IPEX/U.FL antenna hole is a through-wall cut to the interior cavity, not a blind exterior pocket.
Or with the main parametric selector:
```bash
openscad -D 'part="body"' -o /tmp/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4.scad
openscad -D 'part="lid"' -o /tmp/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4.scad
openscad -D 'part="preview"' -o /tmp/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4.scad
openscad -D 'part="front_review"' -o /tmp/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4.scad
```
## Prior prototype reference
### Tripod electronics case v3
**Status:** Historical design reference. In this checkout, previous v3 SCAD/STL files are not present; v4 starts a new `hardware/case/` CAD source set.
**Previous design notes:**
- Held ESP32 + ESP8266 stack.
- Screw-on lid with vent slots.
- Rear dovetail-style rail/socket interface.
- Separate screw-tightened tripod clamp sized around a 35 mm stand/pole.
- Clamp used M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
**Reasons superseded by v4:**
- User requested front status/service panel with OLED, LEDs, and rocker switch.
- Single RGB status LED replaces separate red/green status LEDs.
- Rear strap pass-through loops are simpler and more adaptable than a dedicated clamp/dovetail for field stands.
## Backlog
### 10.1-inch touchscreen + Raspberry Pi Zero case
**Status:** Specific display identified; mechanical measurements needed before CAD.
**Goal:** A printable enclosure for the RemoteRig hub/control panel using a 10.1-inch touchscreen and Raspberry Pi Zero / Zero 2 W.
**Display target:**
- Vendor/model: HZWDONE Raspberry Pi Screen 10.1" Touchscreen
- Resolution: 1024×600
- Interface: HDMI portable monitor
- Mounting: includes fixing holes
- Compatibility listing: Raspberry Pi 5/4/3B/B+ and Windows 11/10/8
**Initial assumptions to validate:**
- Compute: Raspberry Pi Zero / Zero 2 W mounted behind or below the display.
- Use case: RemoteRig local monitor/control panel at field recording setup.
- Likely needs: front bezel, rear electronics cavity, Pi mounting posts, HDMI/USB/power cable exits, strain relief, ventilation, and optional tripod/stand mounting.
- Because this is a 10.1" panel, design should prioritize rigidity: thicker bezel ribs, rear standoffs, and possibly a two-piece shell instead of a small snap case.
**Required measurements before CAD:**
- Product link or datasheet for the exact HZWDONE 10.1" variant.
- Screen/PCB outer dimensions: width, height, thickness.
- Active display opening dimensions.
- Fixing-hole locations, hole diameter, and screw size.
- Connector locations/orientation for HDMI, USB touch, and power.
- Whether the driver/controller board is integrated with the display PCB or separate.
- Pi Zero orientation, port access requirements, and whether GPIO/header must remain accessible.
- Power connector position and desired cable routing.
- Mounting preference: desktop kickstand, tripod clamp, VESA-style holes, handle, or combination.
**Proposed design approach:**
1. Create `hardware/display-case/`.
2. Build a parametric OpenSCAD model with measured display/Pi dimensions.
3. Split into printable parts: front bezel, rear shell, Pi/controller tray, optional stand/tripod mount.
4. Validate STLs with OpenSCAD + trimesh.
5. Upload generated STL/SCAD artifacts to Seafile.
+196
View File
@@ -0,0 +1,196 @@
# RemoteRig — Camera Node Hardware Design
> **Version:** 0.3.0 | **Status:** v4 CAD prototype ready for measurement/print validation
> **Target:** GoPro Hero 3 Black/Silver + ESP32-C3 Super Mini + ESP-01S + USB power bank
## Overview
Each camera node is two ESP boards in a small upright stand-mounted case. The case **does not attach to the camera**; it straps to a tripod/lighting stand with reusable cloth zip ties / Velcro straps. Powered by a standard USB power bank.
```
┌─────────────────┐
│ USB Power Bank │
│ (off-the-shelf)│
└────────┬────────┘
│ USB-C cable into bottom USB-C female input
┌─────────────────────────────────────┐
│ Camera Node Case v4 │ ← Velcro/cloth straps to stand
│ ┌──────────────────────────────┐ │
│ │ Flush/recessed service lid │ │
│ │ 1.3 OLED: CAM/REC/BAT/LINK │ │
│ │ PWR LED + RGB STAT LED │ │
│ │ Small rocker power switch │ │
│ └──────────────────────────────┘ │
│ ESP-01S camera bridge ↔ ESP32-C3 │
│ side USB-A female power output ────┼── USB cable ──→ GoPro power
└─────────────────────────────────────┘
```
## Bill of Materials
| Item | Qty | Cost | Notes |
|------|-----|------|-------|
| ESP32-C3 Super Mini | 1 | ~$4$6 | MQTT / hub-side bridge; known board envelope 22.5 × 18 mm |
| ESP-01S / ESP8266 module | 1 | ~$2$3 | Camera-side GoPro Wi-Fi bridge; known envelope ~24.7 × 14.3 × 12 mm |
| 1.3-inch OLED/status screen | 1 | ~$4$8 | Prototype CAD assumes ~31 × 16 mm visible window / ~37 × 22 mm panel recess; confirm exact module |
| 3 mm power LED | 1 | <$1 | Separate always-power/5V indicator |
| 3 mm RGB status LED | 1 | <$1 | Replaces separate red/green status LEDs; firmware can map node states to color |
| Small rocker switch | 1 | ~$1$3 | On/off switch; prototype CAD assumes 13 × 19 mm snap-in opening |
| USB-C female panel/breakout connector | 1 | ~$1$4 | Bottom power input; prototype CAD assumes ~10.5 × 4.5 mm rounded visible opening plus shallow underside recess; measure purchased part |
| USB-A female panel/breakout connector | 1 | ~$1$4 | Right-side GoPro power passthrough output; prototype CAD assumes ~16 × 8 mm side opening; measure purchased part |
| IPEX/U.FL antenna pigtail or bulkhead lead | 1 | TBD | Left-side antenna exit opposite the USB-A port; prototype CAD assumes a 5.0 mm circular through-hole plus shallow exterior recess; measure exact pigtail/bulkhead diameter before production |
| USB power bank (5000 mAh+) | 1 | ~$10 | Powers camera node and GoPro |
| Short USB cables / wiring | as needed | ~$2$5 | Power bank → node USB-C input; node 5 V passthrough → USB-A female → GoPro USB cable; internal power/signal wiring |
| M2 or small self-tapping screws | 4 | <$1 | Front service lid screws; pilot holes are parametric |
| PETG filament | ~3550 g | ~$1 | 3D printed case body + lid |
| Reusable cloth zip ties / Velcro straps | 2 | ~$1 | Primary stand mount through rear vertical zip-tie brackets with lateral side-feed openings |
**Total per node:** roughly ~$25$35 plus GoPro and power bank, depending on display/switch choice.
## 3D Printed Case
**Current source:** `hardware/case/camera-node-case-v4.scad`
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
The current v4 CAD replaces the rejected wide/low electronics-box layout with a tall appliance-style enclosure matching the original upright reference: a clean vertical body, large inset front panel, OLED near the top, open blank middle area, two long rounded lower slots, a bottom USB-C female power input, a right-side USB-A female passthrough power port for the GoPro, and a left-side IPEX/U.FL antenna pigtail/connector hole opposite the USB-A. It also replaces the v3 clamp/dovetail concept with a simpler strap-mounted field enclosure:
1. **Case body** — shell sized around ESP32-C3 Super Mini + ESP-01S with service/wiring clearance.
2. **Flush/recessed full-height front service lid** — screw-on front panel with locating lip and a restrained raised/recessed border.
3. **Front panel controls/indicators**:
- 1.3-inch OLED/status screen window.
- 3 mm **PWR** LED.
- single 3 mm **RGB STAT** LED for state-dependent colors.
- small rectangular rocker switch cutout.
- two long rounded lower front slots styled after the reference appliance face.
4. **Rear vertical zip-tie pass-through brackets** — two low-profile external brackets, one left and one right of center, with top/bottom anchor pads and long vertical side-access openings. Zip ties feed laterally in the X direction behind each raised bridge face instead of top-to-bottom, while the rear wall stays sealed.
5. **USB power ports** — bottom USB-C female power input and right-side USB-A female passthrough power output for a GoPro USB power cable.
6. **Left-side antenna exit** — prototype 5.0 mm round through-wall IPEX/U.FL antenna pigtail/connector clearance, placed opposite the right-side USB-A port at the same vertical position, with a shallow exterior circular recess for visual/exit relief. Measure the actual antenna pigtail/bulkhead before production.
### Export wrappers
Simple per-part OpenSCAD wrappers are included:
- `hardware/case/camera-node-case-v4-body.scad`
- `hardware/case/camera-node-case-v4-lid.scad`
- `hardware/case/camera-node-case-v4-preview.scad`
- `hardware/case/camera-node-case-v4-front-review.scad`
Example CLI exports, if OpenSCAD is installed:
```bash
openscad -o hardware/case/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4-body.scad
openscad -o hardware/case/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4-lid.scad
openscad -o hardware/case/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4-preview.scad
openscad -o hardware/case/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4-front-review.scad
```
Or render the main file directly:
```bash
openscad -D 'part="body"' -o hardware/case/camera-node-case-v4-body.stl hardware/case/camera-node-case-v4.scad
openscad -D 'part="lid"' -o hardware/case/camera-node-case-v4-lid.stl hardware/case/camera-node-case-v4.scad
openscad -D 'part="preview"' -o hardware/case/camera-node-case-v4-preview.stl hardware/case/camera-node-case-v4.scad
openscad -D 'part="front_review"' -o hardware/case/camera-node-case-v4-front-review.stl hardware/case/camera-node-case-v4.scad
```
`camera-node-case-v4-preview.stl` is the seated fit-check assembly. `camera-node-case-v4-front-review.stl` is a non-print review layout with the body and front panel separated/angled so the OLED, LED, rocker, USB connector, and lower-slot cutouts are obvious in a slicer.
### Print Settings
- **Material:** PETG preferred for heat/outdoor use and strap-tab durability.
- **Layer:** 0.2 mm typical.
- **Infill:** 20% gyroid minimum; 30%+ recommended around rear vertical zip-tie bracket anchor pads/bridges.
- **Supports:** likely minimal/none depending on orientation; verify the rear lateral side-feed openings remain open and check USB-C/USB-A port cutouts in slicer.
- **Post-processing:** fit 4 lid screws; deburr OLED/LED/switch and IPEX antenna exit cutouts; clear any stringing inside the rear side-feed openings; soften strap-contact edges if the printed radius is too sharp for cloth ties.
## Expected Status Screen Content
Preferred 1.3-inch OLED layout/content style:
```text
CAM 03 REC ●
BAT 87% LINK OK
00:12:34
```
Suggested fields:
- `CAM` / node ID.
- `REC` state with a clear recording indicator.
- Battery percentage or supply estimate.
- `LINK OK` / degraded / disconnected state.
- Recording/session timer.
## Wiring
```text
USB Power Bank
└── USB-C cable → bottom USB-C female input on Camera Node Case
├── rocker switch → node power rail
├── PWR LED indicator
├── XIAO ESP32-C6
├── ESP-01S / ESP8266
├── 1.3-inch OLED display
├── RGB status LED
└── 5 V passthrough rail → side USB-A female output
└── USB cable → GoPro USB port
(power only — no data)
UART / control inside case:
ESP-01S TX (GPIO1) ──→ XIAO D7 (RX)
ESP-01S RX (GPIO3) ←── XIAO D6 (TX)
ESP-01S GND ─── XIAO GND
# Reserved for UART OTA (XIAO reflashes the ESP-01S — no adapter):
ESP-01S RST ←── XIAO D8 (pulse low to reset)
ESP-01S GPIO0 ←── XIAO D10 (low at reset = bootloader)
# See docs/design/esp01s-uart-ota.md
```
**Power note:** exact wiring depends on the regulator/power board used. Confirm OLED voltage, LED current limiting, and whether the rocker switches USB 5 V input or a regulated node rail.
## Wi-Fi Topology
```text
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP-01S / ESP8266 camera bridge
UART │ (inside case)
Travel Router ──(AP)────────────────────→ ESP32-C3 MQTT bridge
(192.168.8.1) │
MQTT │
Pi Hub (192.168.8.56)
```
The ESP8266/ESP-01S and GoPro talk over Wi-Fi. The only cable to the GoPro is USB power from the case side USB-A passthrough port.
## Field Setup
1. Mount GoPro on tripod/stand.
2. Feed two reusable cloth zip ties / Velcro straps laterally through the long side openings behind the rear vertical brackets.
3. Strap the case to a tripod/stand leg; use both strap paths to resist rotation.
4. Connect the power bank to the case bottom USB-C input; connect the GoPro USB power cable to the case side USB-A passthrough output.
5. Toggle rocker switch on.
6. Verify PWR LED, RGB status LED, and OLED status: camera ID, REC state, battery, link, timer.
7. Monitor from `http://192.168.8.56:8080`.
## Case Dimensions
Prototype v4 nominal CAD dimensions:
| Part / feature | W × D × H (mm) |
|---|---|
| Case shell external | ~56 × 36 × 82 |
| Case with rear zip-tie brackets | ~56 × 41.2 × 82 |
| Front recessed lid | visible panel ~48 × 2 × 74; total with locating lip ~48 × 3 × 74 |
| OLED visible window assumption | ~31 × 16 |
| Rocker cutout assumption | ~13 × 19 |
| Bottom USB-C power input cutout | ~10.5 × 4.5 opening with ~18 × 10 shallow underside recess |
| Right-side USB-A passthrough cutout | ~16 Y/front-back × 8 Z opening through side wall |
| Rear vertical zip-tie brackets | two external side-feed brackets, each ~8.5 mm wide × 50 mm tall; lateral tunnel has ~40 mm vertical side-window length × ~3.8 mm strap-thickness clearance behind the raised bridge |
| Board clearance targets | ESP32-C3 22.5 × 18 mm + ESP-01S 24.7 × 14.3 × 12 mm plus wiring/service clearance |
These dimensions are placeholders for the first CAD prototype. Measure the actual OLED module, rocker switch, LEDs, screws, USB-C/USB-A connector flanges and body depths, USB cable bend radius, and strap width/thickness before committing to production prints.
@@ -0,0 +1,4 @@
// Export wrapper for RemoteRig camera node case v4 body.
use <camera-node-case-v4.scad>
camera_node_body_v4();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
// Export wrapper for RemoteRig camera node case v4 front-facing review layout.
use <camera-node-case-v4.scad>
camera_node_front_review_v4();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
// Export wrapper for RemoteRig camera node case v4 front service lid/status panel.
use <camera-node-case-v4.scad>
camera_node_lid_v4();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
// Export wrapper for RemoteRig camera node case v4 assembly preview.
use <camera-node-case-v4.scad>
camera_node_preview_v4();
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
// RemoteRig camera node case v4
// Upright appliance-style OpenSCAD prototype for a strap-mounted camera node.
// Units: millimeters. Coordinate system: X=width, Y=depth/front-back, Z=height.
// Front/service lid is on the -Y face. Rear side-feed zip-tie brackets are on the +Y face.
//
// v4 visual direction: tall/upright appliance/control box matching the original
// reference image, replacing the rejected wide, low generic electronics box.
// Nominal body: 56 W x 36 D x 82 H mm; with low rear zip-tie loops ~41 D.
//
// Prototype assumptions to confirm against purchased parts:
// - 1.3 inch OLED module/window opening: 31 x 16 mm visible window, 37 x 22 mm panel recess.
// - Small rocker switch cutout: 13 x 19 mm rectangular snap-in opening.
// - LEDs: two 3 mm panel LEDs (PWR + RGB STAT) with 3.2 mm holes.
// - Boards: ESP32-C3 Super Mini 22.5 x 18 mm, ESP-01S 24.7 x 14.3 x 12 mm.
// - USB-C bottom power inlet and side USB-A passthrough are panel/breakout placeholders;
// measure purchased connector flanges/bodies before production prints.
// - Left-side IPEX/U.FL antenna pigtail connector/lead hole is a prototype 5.0 mm
// circular through-wall clearance; measure the final bulkhead/lead before production.
$fn = 56;
// ----- Main enclosure parameters -----
case_w = 56; // upright appliance-style external width
case_d = 36; // depth for module stack + wiring clearance
case_h = 82; // tall vertical appliance-style height
wall = 2.2;
corner_r = 4.0;
front_recess_d = 2.0; // lid sits in this front pocket, nominally flush
lid_clearance = 0.35;
lid_w = case_w - 8; // nearly full-height/front-width inset panel
lid_h = case_h - 8;
lid_t = 2.0;
lid_lip_t = 1.2; // locating lip protrudes inside service opening
service_opening_w = lid_w - 10.0;
service_opening_h = lid_h - 16.0;
// Hardware
screw_d = 2.4; // M2 self-tapping / pilot; confirm hardware
screw_head_d = 4.6;
boss_d = 6.0;
boss_len = 8.0;
// Front panel components
oled_window_w = 31.0;
oled_window_h = 16.0;
oled_bezel_w = 37.0; // shallow recessed visual outline around window
oled_bezel_h = 22.0;
oled_z = 53.0; // upper third, clear of top screw counterbores
led_hole_d = 3.2; // 3 mm LED clearance
rocker_w = 13.0; // prototype cutout; measure purchased rocker
rocker_h = 19.0;
front_slot_w = 34.0; // two long rounded horizontal slots near lower front
front_slot_h = 3.2;
// Rear reusable cloth zip-tie / Velcro pass-through brackets.
// Two visibly vertical external brackets sit left/right of center.
// The strap path is a lateral X-direction tunnel between the sealed rear wall
// and raised bridge face; long side windows stay open for feeding from either side.
rear_loop_x = 13.0;
rear_loop_w = 8.5; // outside bracket width in X
rear_loop_h = 50.0; // outside bracket height in Z
rear_loop_z = case_h/2;
rear_loop_gap_y = 3.8; // usable strap-thickness clearance behind raised bridge
rear_loop_face_t = 1.4; // low-profile outer bridge skin
rear_loop_y = rear_loop_gap_y + rear_loop_face_t;
rear_loop_anchor_h = 5.0; // top/bottom weld pads; side window remains long vertically
rear_loop_side_window_h = rear_loop_h - 2*rear_loop_anchor_h;
// USB power connector placeholder cutouts
usb_c_cutout_w = 10.5; // bottom USB-C female inlet visible opening, X width
usb_c_cutout_d = 4.5; // bottom USB-C female inlet visible opening, Y/front-back
usb_c_recess_w = 18.0; // shallow underside panel-mount/breakout recess
usb_c_recess_d = 10.0;
usb_c_y = -7.5; // close to front/service side but clear of screw bosses/lower slots
usb_a_cutout_d = 16.0; // side USB-A female opening, Y/front-back dimension
usb_a_cutout_h = 8.0; // side USB-A female opening, Z height
usb_a_z = 26.0; // mid/lower right side, clear of front lid screws/strap bridges
usb_a_y = 2.0;
// Left-side antenna lead / IPEX-U.FL pigtail connector placeholder.
// Opposite the right-side USB-A port and cut fully through the left wall into the cavity.
ipex_hole_d = 5.0; // prototype circular clearance; measure final pigtail/bulkhead
ipex_recess_d = 8.5; // shallow exterior visual/seat recess, not retention geometry
ipex_recess_depth = 0.9;
ipex_z = usb_a_z;
ipex_y = usb_a_y;
// ----- Utility geometry -----
module rounded_box(size=[10,10,10], r=2, center_xy=true) {
// Rounded in XY, straight in Z.
linear_extrude(height=size[2])
offset(r=r)
square([size[0]-2*r, size[1]-2*r], center=center_xy);
}
module xz_rounded_prism(w, d, h, r=2) {
// Rounded rectangle in the visible X/Z plane, extruded through Y.
rotate([-90,0,0])
linear_extrude(height=d, center=true)
offset(r=r)
square([w-2*r, h-2*r], center=true);
}
module yz_rounded_prism(d, x, h, r=2) {
// Rounded rectangle in the visible Y/Z plane, extruded through X.
// First argument maps to global Y, third argument maps to global Z.
rotate([0,90,0])
linear_extrude(height=x, center=true)
offset(r=r)
square([h-2*r, d-2*r], center=true);
}
module y_cylinder(d, h, center=true) {
rotate([90,0,0]) cylinder(d=d, h=h, center=center);
}
module x_cylinder(d, h, center=true) {
rotate([0,90,0]) cylinder(d=d, h=h, center=center);
}
module screw_boss(x, z) {
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
difference() {
y_cylinder(d=boss_d, h=boss_len);
y_cylinder(d=screw_d, h=boss_len + 0.8);
}
}
module rear_zip_tie_loop(xc) {
// Vertical external belt-loop bracket for reusable cloth zip ties/Velcro.
// The bracket silhouette remains vertical, but the real strap tunnel runs
// laterally in X through the long side windows, behind the raised bridge face.
// Top/bottom pads weld the bridge to the shell; no cut reaches the rear wall.
loop_overlap_y = 0.75;
pad_r = 1.15;
bridge_y_center = case_d/2 + rear_loop_gap_y + rear_loop_face_t/2;
pad_y_center = case_d/2 + rear_loop_y/2 - loop_overlap_y;
pad_z_offset = rear_loop_h/2 - rear_loop_anchor_h/2;
union() {
// Raised vertical bridge face: visually preserves the requested vertical
// rear brackets while spanning the side-feed tunnel externally.
translate([xc, bridge_y_center, rear_loop_z])
xz_rounded_prism(rear_loop_w, rear_loop_face_t, rear_loop_h, r=1.6);
// Top and bottom anchor pads close the old top-to-bottom feed direction
// and tie the raised face back into the rear wall without opening the case.
for (zoff = [-pad_z_offset, pad_z_offset])
translate([xc, pad_y_center, rear_loop_z + zoff])
xz_rounded_prism(rear_loop_w, rear_loop_y, rear_loop_anchor_h, r=pad_r);
}
}
// ----- Printable body -----
module camera_node_body_v4() {
difference() {
union() {
difference() {
union() {
// Upright outer shell with softened appliance-like corners.
rounded_box([case_w, case_d, case_h], r=corner_r);
// Rear cloth zip-tie / Velcro side-feed brackets kept flat/quiet.
rear_zip_tie_loop(-rear_loop_x);
rear_zip_tie_loop( rear_loop_x);
}
// Full-height front recessed lid pocket, like the green reference panel.
translate([0, -case_d/2 + front_recess_d/2, case_h/2])
cube([lid_w + lid_clearance, front_recess_d + 0.4, lid_h + lid_clearance], center=true);
// Through service opening behind the lid, leaving a strong inset frame.
service_depth = front_recess_d + wall + 2.0;
translate([0, -case_d/2 + service_depth/2, case_h/2])
xz_rounded_prism(service_opening_w, service_depth + 0.4, service_opening_h, r=2.0);
// Interior electronics cavity: ESP32-C3 Super Mini + ESP-01S plus wiring/service clearance.
cavity_d = case_d - front_recess_d - 2*wall;
translate([0, -case_d/2 + front_recess_d + wall + cavity_d/2, case_h/2])
cube([case_w - 2*wall, cavity_d, case_h - 2*wall], center=true);
// Bottom USB-C female power inlet: shallow underside recess plus
// rounded through-slot for a flush/panel-mount breakout placeholder.
translate([0, usb_c_y, -0.35])
rounded_box([usb_c_recess_w, usb_c_recess_d, 0.9], r=1.5);
translate([0, usb_c_y, -0.2])
rounded_box([usb_c_cutout_w, usb_c_cutout_d, wall + 1.2], r=1.6);
// Right-side USB-A female passthrough power port for the GoPro.
translate([case_w/2 - 0.10, usb_a_y, usb_a_z])
yz_rounded_prism(usb_a_cutout_d, wall + 2.8, usb_a_cutout_h, r=0.9);
// Left-side IPEX/U.FL antenna pigtail connector/lead clearance.
// Through-hole intentionally extends past the inner wall so it opens to the cavity.
translate([-case_w/2 - 0.10, ipex_y, ipex_z])
x_cylinder(d=ipex_hole_d, h=wall + 3.0);
// Shallow exterior circular recess marks/relieves the antenna exit area.
translate([-case_w/2 + ipex_recess_depth/2 - 0.05, ipex_y, ipex_z])
x_cylinder(d=ipex_recess_d, h=ipex_recess_depth + 0.2);
}
// Four protected screw bosses are added after shell hollowing so the
// electronics cavity cannot cut away the receiving material.
screw_x = lid_w/2 - 5.0;
screw_z_low = (case_h - lid_h)/2 + 5.0;
screw_z_high = case_h - screw_z_low;
screw_boss(-screw_x, screw_z_low);
screw_boss( screw_x, screw_z_low);
screw_boss(-screw_x, screw_z_high);
screw_boss( screw_x, screw_z_high);
}
// Final body-level pilot holes cut through the front frame into the protected bosses.
screw_x = lid_w/2 - 5.0;
screw_z_low = (case_h - lid_h)/2 + 5.0;
screw_z_high = case_h - screw_z_low;
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high])
translate([x, -case_d/2 + front_recess_d + boss_len/2, z])
y_cylinder(d=screw_d, h=boss_len + front_recess_d + 4.0);
}
}
// ----- Printable front service lid / status panel -----
module camera_node_lid_v4() {
panel_through_d = lid_t + lid_lip_t + 2.4;
panel_through_y = 0.25;
difference() {
union() {
// Visible full-height flush panel; restrained and not a busy slab.
rounded_box([lid_w, lid_t, lid_h], r=0.65);
// Rear locating lip fits inside the large service opening.
translate([0, lid_t/2 + lid_lip_t/2 - 0.2, lid_h/2])
xz_rounded_prism(service_opening_w - 0.8, lid_lip_t, service_opening_h - 0.8, r=1.5);
}
// OLED window and shallow black-bezel-style recess near the top.
translate([0, -lid_t/2 + 0.35, oled_z])
xz_rounded_prism(oled_bezel_w, 0.9, oled_bezel_h, r=1.3);
translate([0, panel_through_y, oled_z])
xz_rounded_prism(oled_window_w, panel_through_d, oled_window_h, r=0.5);
// Subtle secondary indicators flanking the rocker, below the OLED bezel.
translate([-15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
translate([ 15.0, panel_through_y, 33.0]) y_cylinder(d=led_hole_d, h=panel_through_d);
// Small rocker lower on the panel, offset away from the OLED, screws, and slots.
translate([0, panel_through_y, 33.0])
xz_rounded_prism(rocker_w, panel_through_d, rocker_h, r=0.8);
// Two long rounded horizontal slots near the lower front, matching the reference.
translate([0, panel_through_y, 17.0])
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
translate([0, panel_through_y, 11.0])
xz_rounded_prism(front_slot_w, panel_through_d, front_slot_h, r=front_slot_h/2 - 0.15);
// Screw clearance/counterbore holes.
screw_x = lid_w/2 - 5.0;
screw_z_low = 5.0;
screw_z_high = lid_h - screw_z_low;
for (x=[-screw_x, screw_x], z=[screw_z_low, screw_z_high]) {
translate([x, panel_through_y, z]) y_cylinder(d=screw_d + 0.4, h=panel_through_d);
translate([x, -lid_t/2 + 0.55, z]) y_cylinder(d=screw_head_d, h=1.3);
}
}
}
// ----- Non-print preview assembly -----
module camera_node_preview_v4(show_lid=true) {
color("lightgray") camera_node_body_v4();
if (show_lid)
translate([0, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
color("gainsboro") camera_node_lid_v4();
// Dark OLED bezel/window cue for visual review only (not part of exported lid STL when rendering lid).
if (show_lid)
translate([0, -case_d/2 - 0.08, (case_h - lid_h)/2 + oled_z])
color("black") xz_rounded_prism(oled_bezel_w, 0.6, oled_bezel_h, r=1.3);
// Internal board/connector volume guides (not printed): ESP modules and USB connector envelopes.
color([0,0.45,0,0.35]) translate([-9, -1, 26]) cube([22.5, 18, 4], center=true);
color([0,0.2,0.8,0.35]) translate([9, -1, 45]) cube([24.7, 14.3, 12], center=true);
color([0.1,0.1,0.1,0.35]) translate([0, usb_c_y, 3.8]) cube([16, 9, 5], center=true);
color([0.1,0.1,0.1,0.35]) translate([case_w/2 - 5.5, usb_a_y, usb_a_z]) cube([11, usb_a_cutout_d + 2, usb_a_cutout_h + 2], center=true);
color([0.9,0.7,0.1,0.45]) translate([-case_w/2 - 1.8, ipex_y, ipex_z]) x_cylinder(d=ipex_hole_d, h=8.0);
}
// Non-print review layout: separates the body and front lid while keeping both
// front faces oriented toward -Y. Use this STL when checking that the screen,
// LED, rocker, USB connector, and lower-slot cutouts are visible in a slicer.
module camera_node_front_review_v4() {
translate([-34, 0, 0]) rotate([0,0,-18]) color("lightgray") camera_node_body_v4();
translate([34, -case_d/2 + lid_t/2 + 0.03, (case_h - lid_h)/2])
color("gainsboro") camera_node_lid_v4();
}
// Select part to render from OpenSCAD CLI with: -D 'part="body"'
part = "preview"; // "body", "lid", "preview", or "front_review"
if (part == "body") {
camera_node_body_v4();
} else if (part == "lid") {
camera_node_lid_v4();
} else if (part == "front_review") {
camera_node_front_review_v4();
} else {
camera_node_preview_v4();
}
+596
View File
@@ -0,0 +1,596 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/cubecraft/remoterig/internal/db"
"github.com/go-chi/chi/v5"
)
// setupTestRouter creates a test router backed by a temp file database so
// pooled connections all see the same data.
func setupTestRouter(t *testing.T) (*db.DB, chi.Router) {
t.Helper()
database, err := db.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
r := chi.NewRouter()
r.Get("/cameras", ListCameras(database))
r.Post("/cameras", RegisterCamera(database))
r.Get("/cameras/{id}", GetCameraDetail(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
}
func newReq(method, target string, body io.Reader) *http.Request {
return httptest.NewRequest(method, target, body)
}
func assertStatus(t *testing.T, resp *http.Response, expected int) {
t.Helper()
if resp.StatusCode != expected {
t.Errorf("expected status %d, got %d", expected, resp.StatusCode)
}
}
func assertError(t *testing.T, resp *http.Response, expectedStatus int, want string) {
t.Helper()
assertStatus(t, resp, expectedStatus)
body, _ := io.ReadAll(resp.Body)
var e APIError
if err := json.Unmarshal(body, &e); err != nil {
t.Fatalf("failed to unmarshal error: %v (body: %s)", err, string(body))
}
if e.Code != expectedStatus {
t.Errorf("expected code %d, got %d", expectedStatus, e.Code)
}
if !strings.Contains(e.Error, want) {
t.Errorf("expected error containing %q, got %q", want, e.Error)
}
}
func regCamera(t *testing.T, db *db.DB) string {
t.Helper()
w := httptest.NewRecorder()
r := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test Camera"}`))
r.Header.Set("Content-Type", "application/json")
RegisterCamera(db)(w, r)
return "CAM-001"
}
// ==================== GET /cameras ====================
func TestListCameras_Empty(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("GET", "/cameras", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var cameras []map[string]interface{}
json.NewDecoder(w.Result().Body).Decode(&cameras)
if cameras == nil {
t.Error("expected non-nil cameras array, got nil")
}
}
func TestListCameras_WithData(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Push a status
sr := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":85,"recording":false,"mode":"video","resolution":"4K","fps":30,"online":true}`))
sr.Header.Set("Content-Type", "application/json")
sw := httptest.NewRecorder()
r.ServeHTTP(sw, sr)
assertStatus(t, sw.Result(), http.StatusOK)
// Now list
lr := newReq("GET", "/cameras", nil)
lw := httptest.NewRecorder()
r.ServeHTTP(lw, lr)
assertStatus(t, lw.Result(), http.StatusOK)
var cameras []map[string]interface{}
json.NewDecoder(lw.Result().Body).Decode(&cameras)
if len(cameras) != 1 {
t.Errorf("expected 1 camera, got %d", len(cameras))
}
}
// ==================== POST /cameras (Register) ====================
func TestRegisterCamera_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusCreated)
}
func TestRegisterCamera_WithMacAddress(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test","mac_address":"00:11:22:33:44:55"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusCreated)
}
func TestRegisterCamera_MissingBody(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
func TestRegisterCamera_InvalidJSON(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras", strings.NewReader(`{not json`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
func TestRegisterCamera_MissingRequiredFields(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required")
req2 := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001"}`))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name is required")
}
func TestRegisterCamera_FieldTooLong(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
longID := strings.Repeat("x", 65)
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"`+longID+`","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64")
longName := strings.Repeat("y", 129)
req2 := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"`+longName+`"}`))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name must be at most 128")
}
func TestRegisterCamera_WrongContentType(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusUnsupportedMediaType)
}
func TestRegisterCamera_NoContentType(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusCreated)
}
func TestRegisterCamera_BodyTooLarge(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := httptest.NewRequest("POST", "/cameras", bytes.NewReader(make([]byte, 70000)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "too large")
}
func TestRegisterCamera_Duplicate(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras",
strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusConflict, "camera already registered")
}
// ==================== GET /cameras/{id} ====================
func TestGetCameraDetail_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("GET", "/cameras/CAM-001", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
}
func TestGetCameraDetail_NotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("GET", "/cameras/NONEXISTENT", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
func TestGetCameraDetail_BadID(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("GET", "/cameras/"+strings.Repeat("x", 65), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64")
}
// ==================== POST /cameras/{id}/start ====================
func TestStartRecording_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/start", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var resp map[string]string
json.NewDecoder(w.Result().Body).Decode(&resp)
if resp["status"] != "recording_started" {
t.Errorf("expected recording_started, got %q", resp["status"])
}
}
func TestStartRecording_CameraNotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras/NONEXISTENT/start", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
func TestStartRecording_MissingID(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras//start", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required")
}
// ==================== POST /cameras/{id}/stop ====================
func TestStopRecording_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Start first
sr := newReq("POST", "/cameras/CAM-001/start", nil)
sw := httptest.NewRecorder()
r.ServeHTTP(sw, sr)
assertStatus(t, sw.Result(), http.StatusOK)
// Now stop
req := newReq("POST", "/cameras/CAM-001/stop", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var resp map[string]string
json.NewDecoder(w.Result().Body).Decode(&resp)
if resp["status"] != "recording_stopped" {
t.Errorf("expected recording_stopped, got %q", resp["status"])
}
}
func TestStopRecording_CameraNotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras/NONEXISTENT/stop", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
// ==================== POST /cameras/{id}/status ====================
func TestPushStatus_Success(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
var resp map[string]string
json.NewDecoder(w.Result().Body).Decode(&resp)
if resp["status"] != "accepted" {
t.Errorf("expected accepted, got %q", resp["status"])
}
}
func TestPushStatus_CameraNotFound(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
req := newReq("POST", "/cameras/NONEXISTENT/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusNotFound, "camera not found")
}
func TestPushStatus_InvalidJSON(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{bad json`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
func TestPushStatus_InvalidFPS(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":999,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "fps must be between")
}
func TestPushStatus_NegativeFPS(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":-1,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "fps must be between")
}
func TestPushStatus_InvalidBattery(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":150,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between")
}
func TestPushStatus_NegativeBattery(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":-5,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between")
}
func TestPushStatus_ModeTooLong(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status",
strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"`+strings.Repeat("x", 33)+`","resolution":"1080p","fps":30,"online":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "mode must be at most")
}
func TestPushStatus_MissingBody(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
req := newReq("POST", "/cameras/CAM-001/status", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertError(t, w.Result(), http.StatusBadRequest, "invalid request body")
}
// ==================== Error Response Format ====================
func TestErrorResponseFormat_Consistent(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
checks := []struct {
method, target, body string
}{
{"GET", "/cameras/NONEXISTENT", ""},
{"POST", "/cameras", "bad json"},
{"POST", "/cameras/NONEXISTENT/start", ""},
{"POST", "/cameras/NONEXISTENT/status", "bad json"},
}
for _, c := range checks {
var rd io.Reader
if c.body != "" {
rd = strings.NewReader(c.body)
}
req := newReq(c.method, c.target, rd)
if c.body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var errResp map[string]interface{}
json.NewDecoder(w.Result().Body).Decode(&errResp)
if _, ok := errResp["error"]; !ok {
t.Errorf("%s %s: missing 'error' key: %v", c.method, c.target, errResp)
}
if _, ok := errResp["code"]; !ok {
t.Errorf("%s %s: missing 'code' key: %v", c.method, c.target, errResp)
}
}
}
// ==================== SQL Injection ====================
func TestSQLInjection_CameraID(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Chi URL params are extracted after routing, so injection attempts will
// be treated as camera_ids and fail validation (too long) or return 404.
// Use URL encoding for special characters to avoid httptest panics.
paths := []string{
"/cameras/CAM-001%27+DROP+TABLE+cameras--",
"/cameras/1+UNION+SELECT+NULL--",
"/cameras/%27+OR+%27%27%3D%27",
}
for _, path := range paths {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
code := w.Result().StatusCode
if code != http.StatusNotFound && code != http.StatusBadRequest {
t.Errorf("unexpected status %d for injection path %s", code, path)
}
}
// Verify tables still exist
req := httptest.NewRequest("GET", "/cameras", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assertStatus(t, w.Result(), http.StatusOK)
}
// ==================== Recording Lifecycle ====================
func TestRecordingLifecycle(t *testing.T) {
db, r := setupTestRouter(t)
defer db.Close()
regCamera(t, db)
// Start
r1 := newReq("POST", "/cameras/CAM-001/start", nil)
w1 := httptest.NewRecorder()
r.ServeHTTP(w1, r1)
assertStatus(t, w1.Result(), http.StatusOK)
// Stop
r2 := newReq("POST", "/cameras/CAM-001/stop", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, r2)
assertStatus(t, w2.Result(), http.StatusOK)
// Start again
r3 := newReq("POST", "/cameras/CAM-001/start", nil)
w3 := httptest.NewRecorder()
r.ServeHTTP(w3, r3)
assertStatus(t, w3.Result(), http.StatusOK)
}
// ==================== Benchmark ====================
func BenchmarkListCameras(b *testing.B) {
db2, _ := db.Open(b.TempDir() + "/bench.db")
defer db2.Close()
for i := 0; i < 10; i++ {
id := string(rune('A'+i)) + "-CAM"
h := RegisterCamera(db2)
body := `{"camera_id":"` + id + `","friendly_name":"Test ` + string(rune('A'+i)) + `"}`
req := newReq("POST", "/cameras", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h(w, req)
}
jh := ListCameras(db2)
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := newReq("GET", "/cameras", nil)
w := httptest.NewRecorder()
jh(w, req)
}
}
+27 -20
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/cubecraft/remoterig/internal/db" "github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/pkg/models" "github.com/cubecraft/remoterig/pkg/models"
@@ -25,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 (
@@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
`) `)
if err != nil { if err != nil {
log.Printf("Error querying cameras: %v", err) log.Printf("Error querying cameras: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
defer rows.Close() defer rows.Close()
@@ -51,20 +52,22 @@ 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 {
log.Printf("Error iterating camera rows: %v", err) log.Printf("Error iterating camera rows: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -84,13 +87,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
FriendlyName string `json:"friendly_name"` FriendlyName string `json:"friendly_name"`
MacAddress *string `json:"mac_address,omitempty"` MacAddress *string `json:"mac_address,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSONBody(w, r, &req) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
if req.CameraID == "" || req.FriendlyName == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
return return
} }
@@ -99,12 +99,12 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, req.CameraID, req.FriendlyName, req.MacAddress) `, req.CameraID, req.FriendlyName, req.MacAddress)
if err != nil { if err != nil {
if err.Error() == "UNIQUE constraint failed: cameras.mac_address" { if isUniqueConstraintErr(err) {
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"}) respondError(w, http.StatusConflict, "camera already registered", err.Error())
return return
} }
log.Printf("Error registering camera: %v", err) log.Printf("Error registering camera: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -124,8 +124,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
func GetCameraDetail(database *db.DB) http.HandlerFunc { func GetCameraDetail(database *db.DB) 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 cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -139,12 +138,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
&c.CreatedAt, &c.UpdatedAt, &c.CreatedAt, &c.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"}) respondError(w, http.StatusNotFound, "camera not found", err.Error())
return return
} }
if err != nil { if err != nil {
log.Printf("Error querying camera: %v", err) log.Printf("Error querying camera: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -165,7 +164,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
) )
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
log.Printf("Error querying latest status: %v", err) log.Printf("Error querying latest status: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
@@ -180,7 +179,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error querying history: %v", err) log.Printf("Error querying history: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
defer historyRows.Close() defer historyRows.Close()
@@ -203,13 +202,21 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
} }
respondJSON(w, http.StatusOK, map[string]interface{}{ respondJSON(w, http.StatusOK, map[string]interface{}{
"camera": c, "camera": c,
"last_status": sl, "last_status": sl,
"history": history, "history": history,
}) })
} }
} }
// isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation.
func isUniqueConstraintErr(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "UNIQUE constraint failed")
}
// respondJSON writes a JSON response with the given status code. // respondJSON writes a JSON response with the given status code.
func respondJSON(w http.ResponseWriter, status int, data interface{}) { func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+116
View File
@@ -0,0 +1,116 @@
// Package api provides HTTP handlers for camera operations.
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// maxRequestBody is the maximum accepted JSON body size (64KB).
const maxRequestBody = 64 * 1024
// APIError represents a structured API error response.
type APIError struct {
Error string `json:"error"`
Code int `json:"code"`
Details string `json:"details,omitempty"`
}
// validationConstraints defines field-level validation limits.
const (
maxCameraIDLen = 64
maxFriendlyNameLen = 128
maxModeLen = 32
maxResolutionLen = 32
minFPS = 0
maxFPS = 240
)
// respondError writes a structured JSON error response.
func respondError(w http.ResponseWriter, status int, msg string, details ...string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
e := APIError{
Error: msg,
Code: status,
}
if len(details) > 0 {
e.Details = details[0]
}
json.NewEncoder(w).Encode(e)
}
// decodeJSONBody reads, limits, and decodes a JSON request body.
// Returns false if validation fails (response already written).
func decodeJSONBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
// Validate Content-Type
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.HasPrefix(ct, "application/json") {
respondError(w, http.StatusUnsupportedMediaType, "content-type must be application/json")
return false
}
// Limit body size
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
body, err := io.ReadAll(r.Body)
if err != nil {
respondError(w, http.StatusBadRequest, "request body too large or unreadable", err.Error())
return false
}
if err := json.Unmarshal(body, v); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body", err.Error())
return false
}
return true
}
// validateCameraID checks that cameraID is present and within max length.
func validateCameraID(w http.ResponseWriter, cameraID string) bool {
if cameraID == "" {
respondError(w, http.StatusBadRequest, "camera_id is required")
return false
}
if len(cameraID) > maxCameraIDLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("camera_id must be at most %d characters", maxCameraIDLen))
return false
}
return true
}
// validateCameraRegistration validates fields for POST /cameras.
func validateCameraRegistration(w http.ResponseWriter, cameraID, friendlyName string) bool {
if !validateCameraID(w, cameraID) {
return false
}
if friendlyName == "" {
respondError(w, http.StatusBadRequest, "friendly_name is required")
return false
}
if len(friendlyName) > maxFriendlyNameLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("friendly_name must be at most %d characters", maxFriendlyNameLen))
return false
}
return true
}
// validateStatusFields validates optional fields on the PushStatus payload.
func validateStatusFields(w http.ResponseWriter, mode, resolution string, fps int) bool {
if mode != "" && len(mode) > maxModeLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("mode must be at most %d characters", maxModeLen))
return false
}
if resolution != "" && len(resolution) > maxResolutionLen {
respondError(w, http.StatusBadRequest, fmt.Sprintf("resolution must be at most %d characters", maxResolutionLen))
return false
}
if fps < minFPS || fps > maxFPS {
respondError(w, http.StatusBadRequest, fmt.Sprintf("fps must be between %d and %d", minFPS, maxFPS))
return false
}
return true
}
+48 -16
View File
@@ -9,12 +9,17 @@ 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 cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -22,8 +27,13 @@ func StartRecording(database *db.DB) http.HandlerFunc {
var exists int var exists int
err := database.QueryRowContext(r.Context(), err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 { if err != nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) log.Printf("Error checking camera existence: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -34,12 +44,21 @@ func StartRecording(database *db.DB) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error starting recording: %v", err) log.Printf("Error starting recording: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
rows, _ := result.RowsAffected() rowsAffected, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows) 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",
@@ -49,11 +68,10 @@ 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 cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
@@ -61,8 +79,13 @@ func StopRecording(database *db.DB) http.HandlerFunc {
var exists int var exists int
err := database.QueryRowContext(r.Context(), err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 { if err != nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) log.Printf("Error checking camera existence: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -73,12 +96,21 @@ func StopRecording(database *db.DB) http.HandlerFunc {
`, cameraID) `, cameraID)
if err != nil { if err != nil {
log.Printf("Error stopping recording: %v", err) log.Printf("Error stopping recording: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
rows, _ := result.RowsAffected() rowsAffected, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows) 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",
+28 -17
View File
@@ -2,7 +2,6 @@
package api package api
import ( import (
"encoding/json"
"log" "log"
"net/http" "net/http"
@@ -14,24 +13,31 @@ import (
func PushStatus(database *db.DB) http.HandlerFunc { func PushStatus(database *db.DB) 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 cameraID == "" { if !validateCameraID(w, cameraID) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
return return
} }
var req struct { var req struct {
BatteryPct *int `json:"battery_pct"` BatteryPct *int `json:"battery_pct"`
VideoRemainingSec *int `json:"video_remaining_sec"` 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"`
FPS int `json:"fps"` FPS int `json:"fps"`
Online bool `json:"online"` Online bool `json:"online"`
RawBatteryPct *float64 `json:"raw_battery_pct"` RawBatteryPct *float64 `json:"raw_battery_pct"`
Timestamp *string `json:"ts"` Timestamp *string `json:"ts"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSONBody(w, r, &req) {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return
}
if !validateStatusFields(w, req.Mode, req.Resolution, req.FPS) {
return
}
// Validate battery percentage range if provided
if req.BatteryPct != nil && (*req.BatteryPct < 0 || *req.BatteryPct > 100) {
respondError(w, http.StatusBadRequest, "battery_pct must be between 0 and 100")
return return
} }
@@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc {
var exists int var exists int
err := database.QueryRowContext(r.Context(), err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 { if err != nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) log.Printf("Error checking camera existence: %v", err)
respondError(w, http.StatusInternalServerError, "database error", err.Error())
return
}
if exists == 0 {
respondError(w, http.StatusNotFound, "camera not found")
return return
} }
@@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc {
req.FPS, boolToInt(req.Online), req.RawBatteryPct) req.FPS, boolToInt(req.Online), req.RawBatteryPct)
if err != nil { if err != nil {
log.Printf("Error inserting status log: %v", err) log.Printf("Error inserting status log: %v", err)
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) respondError(w, http.StatusInternalServerError, "database error", err.Error())
return return
} }
+75 -30
View File
@@ -4,9 +4,11 @@ package db
import ( import (
"database/sql" "database/sql"
_ "embed" _ "embed"
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -14,13 +16,16 @@ import (
//go:embed migrations/001_create_tables.sql //go:embed migrations/001_create_tables.sql
var migration001 string var migration001 string
//go:embed migrations/002_dedup_unique_index.sql
var migration002 string
// DB wraps the sql.DB with connection-level settings. // DB wraps the sql.DB with connection-level settings.
type DB struct { type DB struct {
*sql.DB *sql.DB
} }
// Open opens the SQLite database at the given path, enables WAL mode, // Open opens the SQLite database at the given path, enables WAL mode,
// and runs all migrations if the tables don't exist yet. // and runs all migrations using a schema_version table for tracking.
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
// Ensure the directory exists // Ensure the directory exists
dir := filepath.Dir(path) dir := filepath.Dir(path)
@@ -45,34 +50,57 @@ func Open(path string) (*DB, error) {
return nil, err return nil, err
} }
// Check if tables already exist (idempotent migration) // Ensure schema_version table exists for migration tracking
var count int if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
if err := db.QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings')
`).Scan(&count); err != nil {
db.Close() db.Close()
return nil, err return nil, err
} }
if count < 4 { // Read current schema version (0 if table is empty)
log.Printf("Running migrations for %s...", path) var currentVersion int
if err := migrate(db, migration001); err != nil { if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&currentVersion); err != nil {
db.Close() db.Close()
return nil, err return nil, err
}
// Migration definitions: ordered list of (version, sql)
type migration struct {
version int
sql string
}
migrations := []migration{
{1, migration001},
{2, migration002},
}
for _, m := range migrations {
if currentVersion >= m.version {
continue
} }
log.Printf("Running migration %d for %s...", m.version, path)
if err := migrate(db, m.sql); err != nil {
db.Close()
return nil, fmt.Errorf("migration %d: %w", m.version, err)
}
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil {
db.Close()
return nil, fmt.Errorf("record migration %d: %w", m.version, err)
}
log.Printf("Migration %d complete", m.version)
}
if currentVersion < len(migrations) {
log.Println("Migrations complete") log.Println("Migrations complete")
} }
return &DB{db}, nil return &DB{db}, nil
} }
// migrate executes a SQL migration string. // migrate executes a SQL migration string by splitting on semicolons.
func migrate(db *sql.DB, sql string) error { func migrate(db *sql.DB, sql string) error {
// Split on semicolons to handle multiple statements
statements := splitSQL(sql) statements := splitSQL(sql)
for _, stmt := range statements { for _, stmt := range statements {
stmt = stripWhitespace(stmt) stmt = strings.TrimSpace(stmt)
if stmt == "" { if stmt == "" {
continue continue
} }
@@ -83,8 +111,13 @@ func migrate(db *sql.DB, sql string) error {
return nil return nil
} }
// splitSQL splits a SQL string on semicolons, respecting quoted strings. // splitSQL splits a SQL string on semicolons, respecting quoted strings
// and stripping SQL line comments (--).
func splitSQL(sql string) []string { func splitSQL(sql string) []string {
// First, strip all line comments (--) to prevent them from swallowing
// subsequent SQL statements when newlines are collapsed.
sql = stripSQLLineComments(sql)
var stmts []string var stmts []string
var current string var current string
inQuote := false inQuote := false
@@ -106,30 +139,42 @@ func splitSQL(sql string) []string {
case ';': case ';':
stmts = append(stmts, current) stmts = append(stmts, current)
current = "" current = ""
case '\r', '\n', '\t':
current += " "
default: default:
current += string(r) current += string(r)
} }
} }
if len(current) > 0 { if strings.TrimSpace(current) != "" {
stmts = append(stmts, current) stmts = append(stmts, current)
} }
return stmts return stmts
} }
// stripWhitespace removes leading/trailing whitespace and normalizes newlines. // stripSQLLineComments removes all -- single-line comments from SQL text.
func stripWhitespace(s string) string { func stripSQLLineComments(sql string) string {
result := "" var result strings.Builder
runningSpace := false i := 0
for _, r := range s { runes := []rune(sql)
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !runningSpace { for i < len(runes) {
result += " " r := runes[i]
runningSpace = true
// Check for -- comment start
if r == '-' && i+1 < len(runes) && runes[i+1] == '-' {
// Skip to end of line
i += 2
for i < len(runes) && runes[i] != '\n' && runes[i] != '\r' {
i++
} }
} else { // Replace comment with a newline (preserves statement boundaries)
result += string(r) result.WriteRune('\n')
runningSpace = false continue
} }
result.WriteRune(r)
i++
} }
return result
return result.String()
} }
@@ -0,0 +1,8 @@
-- Migration: 002_dedup_unique_index
-- Add a UNIQUE index on (camera_id, recorded_at) to enforce hub-side
-- deduplication for ESP32 offline status replay (CUB-239).
-- This prevents race-condition double-inserts that a pure SELECT COUNT(*)
-- check cannot guard against.
CREATE UNIQUE INDEX IF NOT EXISTS idx_status_logs_unique_entry
ON status_logs(camera_id, recorded_at);
+31 -6
View File
@@ -7,13 +7,15 @@ import (
"log" "log"
"net/http" "net/http"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
// Hub manages SSE client connections and event broadcasting. // Hub manages SSE client connections and event broadcasting.
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
clients map[*Client]bool clients map[*Client]bool
eventSeq atomic.Int64 // monotonic event ID for Last-Event-ID
} }
// NewHub creates a new SSE hub. // NewHub creates a new SSE hub.
@@ -58,6 +60,13 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Last-Event-ID")
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Get flusher // Get flusher
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
@@ -85,12 +94,21 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
client.Close() client.Close()
}() }()
// Acknowledge Last-Event-ID if sent by client on reconnect
if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" {
fmt.Fprintf(w, "id: %s\nevent: reconnected\ndata: {\"type\":\"reconnected\",\"last_event_id\":\"%s\"}\n\n", lastEventID, lastEventID)
flusher.Flush()
}
// Send initial connection event // Send initial connection event
data, _ := json.Marshal(map[string]string{ seq := h.eventSeq.Add(1)
data, _ := json.Marshal(map[string]interface{}{
"type": "connected", "type": "connected",
"id": seq,
"ts": time.Now().Format(time.RFC3339), "ts": time.Now().Format(time.RFC3339),
}) })
if !client.Write(data) { eventLine := fmt.Sprintf("id: %d\nevent: connected\ndata: %s\n\n", seq, string(data))
if !client.Write([]byte(eventLine)) {
return // client disconnected return // client disconnected
} }
@@ -115,13 +133,18 @@ func (h *Hub) serveSSE(w http.ResponseWriter, r *http.Request) {
} }
} }
// Broadcast sends an event to all connected clients. // Broadcast sends a typed SSE event to all connected clients.
// eventType becomes the "event:" field, enabling client-side filtering.
// Each event gets a monotonic ID for Last-Event-ID reconnection support.
func (h *Hub) Broadcast(eventType string, payload interface{}) { func (h *Hub) Broadcast(eventType string, payload interface{}) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
seq := h.eventSeq.Add(1)
event := map[string]interface{}{ event := map[string]interface{}{
"type": eventType, "type": eventType,
"id": seq,
"ts": time.Now().Format(time.RFC3339), "ts": time.Now().Format(time.RFC3339),
"payload": payload, "payload": payload,
} }
@@ -132,8 +155,10 @@ func (h *Hub) Broadcast(eventType string, payload interface{}) {
return return
} }
eventLine := fmt.Sprintf("id: %d\nevent: %s\ndata: %s\n\n", seq, eventType, string(data))
for client := range h.clients { for client := range h.clients {
if !client.Write(data) { if !client.Write([]byte(eventLine)) {
log.Println("SSE client buffer full, dropping event") log.Println("SSE client buffer full, dropping event")
} }
} }
+457
View File
@@ -0,0 +1,457 @@
// Package mqtt provides the MQTT subscriber that ingests ESP32 camera status
// via Mosquitto broker and persists to SQLite with SSE fan-out.
package mqtt
import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/internal/events"
"github.com/cubecraft/remoterig/pkg/models"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// Subscriber connects to Mosquitto, subscribes to camera topics, and
// processes incoming status, heartbeat, and announce messages.
type Subscriber struct {
mu sync.Mutex
broker string
clientID string
client mqtt.Client
db *db.DB
hub *events.Hub
// Heartbeat tracking: last heartbeat time per camera_id
heartbeats map[string]time.Time
// Shutdown
done chan struct{}
}
// NewSubscriber creates a new MQTT subscriber (does not connect yet).
func NewSubscriber(broker, clientID string, database *db.DB, sseHub *events.Hub) *Subscriber {
return &Subscriber{
broker: broker,
clientID: clientID,
db: database,
hub: sseHub,
heartbeats: make(map[string]time.Time),
done: make(chan struct{}),
}
}
// Connect establishes the MQTT connection and subscribes to topics.
// Returns an error if the initial connection fails.
func (s *Subscriber) Connect() error {
opts := mqtt.NewClientOptions().
AddBroker(fmt.Sprintf("tcp://%s", s.broker)).
SetClientID(s.clientID).
SetCleanSession(true).
SetAutoReconnect(true).
SetMaxReconnectInterval(30 * time.Second).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetOnConnectHandler(s.onConnect).
SetDefaultPublishHandler(s.onMessage).
SetConnectionLostHandler(func(c mqtt.Client, err error) {
log.Printf("MQTT connection lost: %v (will auto-reconnect)", err)
})
s.client = mqtt.NewClient(opts)
token := s.client.Connect()
if !token.WaitTimeout(10 * time.Second) {
return fmt.Errorf("mqtt connect timeout")
}
if err := token.Error(); err != nil {
return fmt.Errorf("mqtt connect: %w", err)
}
log.Printf("MQTT connected to %s as %s", s.broker, s.clientID)
// Start heartbeat watchdog (runs independently)
go s.heartbeatWatchdog()
return nil
}
// onConnect is called after every (re)connection. It re-subscribes to topics.
func (s *Subscriber) onConnect(c mqtt.Client) {
topics := map[string]byte{
"remoterig/cameras/+/status": 1, // QoS 1
"remoterig/cameras/+/heartbeat": 1, // QoS 1
"remoterig/cameras/+/announce": 2, // QoS 2
"remoterig/cameras/+/command": 2, // QoS 2 (hub publishes, ESP32 receives)
}
token := c.SubscribeMultiple(topics, nil)
if token.WaitTimeout(5 * time.Second) {
if err := token.Error(); err != nil {
log.Printf("MQTT subscribe error: %v", err)
} else {
log.Printf("MQTT subscribed to %d topics", len(topics))
}
} else {
log.Println("MQTT subscribe timeout")
}
}
// onMessage is the default message handler. It routes by topic.
func (s *Subscriber) onMessage(c mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := msg.Payload()
// Extract camera_id from topic: remoterig/cameras/<camera_id>/<type>
cameraID := extractCameraID(topic)
if cameraID == "" {
log.Printf("MQTT: could not extract camera_id from topic %q", topic)
return
}
switch {
case strings.HasSuffix(topic, "/status"):
s.handleStatus(cameraID, payload)
case strings.HasSuffix(topic, "/heartbeat"):
s.handleHeartbeat(cameraID, payload)
case strings.HasSuffix(topic, "/announce"):
s.handleAnnounce(cameraID, payload)
default:
log.Printf("MQTT: unhandled topic %q", topic)
}
}
// ── Status handler ──────────────────────────────────────────────────────
// statusPayload matches the MQTT contract status message.
type statusPayload struct {
CameraID string `json:"camera_id"`
Timestamp string `json:"timestamp"`
BatteryPct *int `json:"battery_pct"`
BatteryRaw *int `json:"battery_raw"`
VideoRemainingSec *int `json:"video_remaining_sec"`
Recording bool `json:"recording"`
Mode *string `json:"mode"`
Resolution *string `json:"resolution"`
FPS *int `json:"fps"`
Online bool `json:"online"`
RSSI *int `json:"rssi"`
UptimeSec *int `json:"uptime_sec"`
}
// 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 {
log.Printf("MQTT status parse error for %s: %v", cameraID, err)
return
}
// Validate required fields
if sp.CameraID == "" {
log.Printf("MQTT status missing camera_id from %s", cameraID)
return
}
// 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 {
if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil {
ts = now
}
}
if ts.After(now.Add(5 * time.Minute)) {
log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID)
ts = now
}
if ts.Before(now.Add(-24 * time.Hour)) {
log.Printf("MQTT status timestamp too far in past (%s) from %s — using now", ts, cameraID)
ts = now
}
// Clamp battery_pct to 0-100
batteryPct := sp.BatteryPct
if batteryPct != nil {
if *batteryPct < 0 {
v := 0
batteryPct = &v
}
if *batteryPct > 100 {
v := 100
batteryPct = &v
}
}
recordingState := 0
if sp.Recording {
recordingState = 1
}
onlineState := 0
if sp.Online {
onlineState = 1
}
// Detect recording state change by checking previous status
var prevRecording int
row := s.db.QueryRow(`
SELECT recording_state FROM status_logs
WHERE camera_id = ? AND recorded_at > datetime('now', '-120 seconds')
ORDER BY recorded_at DESC LIMIT 1
`, cameraID)
if err := row.Scan(&prevRecording); err != nil {
prevRecording = -1 // no previous status
}
// CUB-230: Deduplication check - skip if same (camera_id, recorded_at) exists
// This handles replayed entries from offline buffering
var dupCount int
err = s.db.QueryRow(`
SELECT COUNT(*) FROM status_logs
WHERE camera_id = ? AND recorded_at = ?
`, cameraID, ts).Scan(&dupCount)
if err != nil {
log.Printf("MQTT status dedup check error for %s: %v", cameraID, err)
// Continue anyway if check fails
} else if dupCount > 0 {
log.Printf("MQTT status deduplicated (camera_id=%s, recorded_at=%s) - replay from offline buffer", cameraID, ts.Format("2006-01-02 15:04:05"))
return
}
// Insert status_log
_, err = s.db.Exec(`
INSERT INTO status_logs (camera_id, recorded_at, battery_pct,
video_remaining_sec, recording_state, mode, resolution, fps, online)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, cameraID, ts, batteryPct, sp.VideoRemainingSec,
recordingState, stringPtr(sp.Mode), stringPtr(sp.Resolution),
intPtr(sp.FPS), onlineState)
if err != nil {
log.Printf("MQTT status insert error for %s: %v", cameraID, err)
return
}
// Handle recording state transitions
if prevRecording >= 0 && prevRecording != recordingState {
reason := "mqtt"
if recordingState == 1 {
// Started recording — open new recording_event
_, err = s.db.Exec(`
INSERT INTO recording_events (camera_id, started_at, reason)
VALUES (?, ?, ?)
`, cameraID, ts, reason)
if err != nil {
log.Printf("MQTT recording_event insert error for %s: %v", cameraID, err)
}
} else {
// Stopped recording — close most recent open event
_, err = s.db.Exec(`
UPDATE recording_events SET stopped_at = ?
WHERE camera_id = ? AND stopped_at IS NULL
ORDER BY started_at DESC LIMIT 1
`, ts, cameraID)
if err != nil {
log.Printf("MQTT recording_event close error for %s: %v", cameraID, err)
}
}
}
// Broadcast via SSE
cam, err := getCamera(s.db, cameraID)
if err == nil {
sl := models.StatusLog{
CameraID: cameraID,
RecordedAt: ts,
BatteryPct: batteryPct,
VideoRemainingSec: sp.VideoRemainingSec,
RecordingState: recordingState,
Mode: stringPtr(sp.Mode),
Resolution: stringPtr(sp.Resolution),
FPS: intPtr(sp.FPS),
Online: onlineState,
}
cs := models.NewCameraStatus(cam, sl)
s.hub.Broadcast("camera_status", cs)
}
}
// ── Heartbeat handler ───────────────────────────────────────────────────
type heartbeatPayload struct {
CameraID string `json:"camera_id"`
// 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) {
var hp heartbeatPayload
if err := json.Unmarshal(payload, &hp); err != nil {
log.Printf("MQTT heartbeat parse error for %s: %v", cameraID, err)
return
}
s.mu.Lock()
s.heartbeats[cameraID] = time.Now()
s.mu.Unlock()
}
// heartbeatWatchdog runs every 10 seconds and marks cameras offline if
// no heartbeat received in 120 seconds.
func (s *Subscriber) heartbeatWatchdog() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
s.mu.Lock()
for cameraID, lastBeat := range s.heartbeats {
if time.Since(lastBeat) > 120*time.Second {
// Camera missed heartbeat — broadcast offline
cam, err := getCamera(s.db, cameraID)
if err == nil {
cs := models.CameraStatus{
CameraID: cameraID,
FriendlyName: cam.FriendlyName,
Online: false,
LastSeen: lastBeat,
}
s.hub.Broadcast("camera_offline", cs)
}
delete(s.heartbeats, cameraID)
}
}
s.mu.Unlock()
}
}
}
// ── Announce handler (auto-registration) ───────────────────────────────
type announcePayload struct {
MacAddress string `json:"mac_address"`
FirmwareVersion string `json:"firmware_version"`
Capabilities []string `json:"capabilities"`
FriendlyName string `json:"friendly_name"`
}
func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
var ap announcePayload
if err := json.Unmarshal(payload, &ap); err != nil {
log.Printf("MQTT announce parse error for %s: %v", cameraID, err)
return
}
if ap.MacAddress == "" {
log.Printf("MQTT announce missing mac_address from %s", cameraID)
return
}
// Check if this MAC is already registered
var existingID string
err := s.db.QueryRow(
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
).Scan(&existingID)
if err == nil && 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,
)
if err != nil {
log.Printf("MQTT announce update error for %s: %v", existingID, err)
return
}
log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName)
} else {
// 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)
}
// 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'))
`, 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)", cameraID, ap.FriendlyName)
// Broadcast new camera via SSE
cam, err := getCamera(s.db, cameraID)
if err == nil {
s.hub.Broadcast("camera_registered", cam)
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
// extractCameraID pulls <camera_id> from remoterig/cameras/<camera_id>/<type>
func extractCameraID(topic string) string {
parts := strings.Split(topic, "/")
if len(parts) >= 3 && parts[0] == "remoterig" && parts[1] == "cameras" {
return parts[2]
}
return ""
}
// getCamera fetches a camera by ID from the database.
func getCamera(db *db.DB, cameraID string) (models.Camera, error) {
var cam models.Camera
err := db.QueryRow(
"SELECT camera_id, friendly_name, COALESCE(mac_address,''), created_at, updated_at FROM cameras WHERE camera_id = ?",
cameraID,
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.CreatedAt, &cam.UpdatedAt)
return cam, err
}
func stringPtr(s *string) *string {
if s == nil || *s == "" {
return nil
}
return s
}
func intPtr(i *int) *int {
if i == nil {
return nil
}
return i
}
// Close shuts down the MQTT subscriber.
func (s *Subscriber) Close() {
close(s.done)
if s.client != nil && s.client.IsConnected() {
s.client.Disconnect(250)
log.Println("MQTT disconnected")
}
}
+6 -3
View File
@@ -9,7 +9,7 @@ import (
type Camera struct { type Camera struct {
CameraID string `json:"camera_id"` CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"` FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"` MacAddress *string `json:"mac_address,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -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"`
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# RemoteRig — Pi-side deploy script
# Deploys a new binary with backup, health-check, and automatic rollback.
#
# Usage:
# sudo ./deploy.sh [BINARY_PATH] [DEPLOY_PATH] [SERVICE_NAME]
#
# Defaults:
# BINARY_PATH = ./remoterig (new binary to deploy)
# DEPLOY_PATH = /opt/remoterig/remoterig
# SERVICE_NAME = remoterig
#
# Examples:
# # Deploy locally-built binary with defaults
# sudo ./deploy.sh ./remoterig
#
# # Custom paths
# sudo ./deploy.sh /tmp/remoterig-arm64 /opt/remoterig/remoterig remoterig
set -euo pipefail
# ---------------------------------------------------------------------------
# Args
# ---------------------------------------------------------------------------
BINARY="${1:-remoterig}"
DEPLOY_PATH="${2:-/opt/remoterig/remoterig}"
SERVICE="${3:-remoterig}"
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
MAX_BACKUPS=3
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
info() { echo "[INFO] $*"; }
ok() { echo "[OK] $*"; }
fail() { echo "[FAIL] $*" >&2; }
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: must run as root (sudo ./deploy.sh ...)" >&2
exit 1
fi
if [ ! -f "${BINARY}" ]; then
fail "Binary not found: ${BINARY}"
exit 1
fi
echo "=============================================="
echo " RemoteRig Deploy"
echo " Binary: ${BINARY}"
echo " Deploy path: ${DEPLOY_PATH}"
echo " Service: ${SERVICE}"
echo " Timestamp: ${TIMESTAMP}"
echo "=============================================="
# ---------------------------------------------------------------------------
# 1. Backup existing binary
# ---------------------------------------------------------------------------
info "Backing up current binary..."
if [ -f "${DEPLOY_PATH}" ]; then
cp "${DEPLOY_PATH}" "${BACKUP}"
ok "Backed up to ${BACKUP}"
else
info "No existing binary at ${DEPLOY_PATH} — fresh install"
fi
# ---------------------------------------------------------------------------
# 2. Deploy new binary
# ---------------------------------------------------------------------------
info "Deploying new binary..."
# 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}"
# ---------------------------------------------------------------------------
# 3. Reload systemd and restart service
# ---------------------------------------------------------------------------
info "Reloading systemd and restarting ${SERVICE}..."
systemctl daemon-reload
# Restart (or start if not running)
if systemctl is-active --quiet "${SERVICE}" 2>/dev/null; then
systemctl restart "${SERVICE}"
else
systemctl start "${SERVICE}"
fi
ok "Service restart issued"
# ---------------------------------------------------------------------------
# 4. Health check
# ---------------------------------------------------------------------------
info "Waiting 3s for service to stabilize..."
sleep 3
if systemctl is-active --quiet "${SERVICE}"; then
ok "${SERVICE} is active — deploy successful"
# Optional: curl health endpoint
if command -v curl >/dev/null 2>&1; then
HEALTH_URL="http://localhost:8080/health"
if curl -sf --max-time 3 "${HEALTH_URL}" >/dev/null 2>&1; then
ok "Health check passed: ${HEALTH_URL}"
else
info "Health endpoint not reachable (may need more startup time)"
fi
fi
else
fail "${SERVICE} is NOT active — rolling back"
# -----------------------------------------------------------------------
# 5. Rollback on failure
# -----------------------------------------------------------------------
if [ -f "${BACKUP}" ]; then
info "Restoring backup: ${BACKUP}"
cp "${BACKUP}" "${DEPLOY_PATH}.new"
chmod +x "${DEPLOY_PATH}.new"
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
systemctl restart "${SERVICE}" 2>/dev/null || true
sleep 2
if systemctl is-active --quiet "${SERVICE}"; then
ok "Rollback successful — previous binary restored and service is active"
else
fail "Rollback failed — service still not active"
echo "Check logs: journalctl -u ${SERVICE} -n 50" >&2
exit 1
fi
else
fail "No backup available — cannot roll back"
echo "Check logs: journalctl -u ${SERVICE} -n 50" >&2
exit 1
fi
fi
# ---------------------------------------------------------------------------
# 6. Cleanup old backups (keep last N)
# ---------------------------------------------------------------------------
info "Cleaning up old backups (keeping last ${MAX_BACKUPS})..."
DEPLOY_DIR="$(dirname "${DEPLOY_PATH}")"
BASE_NAME="$(basename "${DEPLOY_PATH}")"
# List backups, skip current, keep last MAX_BACKUPS, delete the rest
ls -1t "${DEPLOY_DIR}/${BASE_NAME}."*.bak 2>/dev/null | \
tail -n +$((MAX_BACKUPS + 1)) | \
while IFS= read -r old_backup; do
rm -f "${old_backup}"
info "Removed old backup: $(basename "${old_backup}")"
done
ok "Deploy complete — ${MAX_BACKUPS} backups retained"
echo ""
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# RemoteRig — Pi-side pull updater
# ================================
# Polls the rolling "dev" release on Gitea and, when the published version
# differs from what's installed, downloads + verifies (sha256) + deploys it
# via the existing rollback-capable deploy.sh. Run on a timer (see
# remoterig-update.timer). The Pi pulls; nothing pushes into the closed net.
#
# Config (env, or /opt/remoterig/update.env):
# GITEA_BASE default https://code.cubecraftcreations.com
# REPO default CubeCraft-Creations/remote-rig
# GITEA_TOKEN read token (required only if the repo is private)
# DEPLOY_PATH default /opt/remoterig/remoterig
# SERVICE default remoterig
set -euo pipefail
ENV_FILE="${ENV_FILE:-/opt/remoterig/update.env}"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
GITEA_BASE="${GITEA_BASE:-https://code.cubecraftcreations.com}"
REPO="${REPO:-CubeCraft-Creations/remote-rig}"
DEPLOY_DIR="/opt/remoterig"
DEPLOY_PATH="${DEPLOY_PATH:-$DEPLOY_DIR/remoterig}"
SERVICE="${SERVICE:-remoterig}"
TAG="dev-latest"
DL="$GITEA_BASE/$REPO/releases/download/$TAG"
VERSION_FILE="$DEPLOY_DIR/VERSION"
AUTH=()
[ -n "${GITEA_TOKEN:-}" ] && AUTH=(-H "Authorization: token $GITEA_TOKEN")
log() { echo "[$(date -Is)] $*"; }
# 1. What version is published?
REMOTE_VER="$(curl -fsSL "${AUTH[@]}" "$DL/version.txt" | tr -d '[:space:]')" || {
log "could not reach $DL/version.txt — skipping"; exit 0; }
[ -n "$REMOTE_VER" ] || { log "empty remote version — skipping"; exit 0; }
LOCAL_VER="$(cat "$VERSION_FILE" 2>/dev/null || echo none)"
if [ "$REMOTE_VER" = "$LOCAL_VER" ]; then
log "up to date ($LOCAL_VER)"; exit 0
fi
log "update available: $LOCAL_VER -> $REMOTE_VER"
# 2. Download + verify checksum
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
curl -fsSL "${AUTH[@]}" "$DL/remoterig" -o "$TMP/remoterig"
curl -fsSL "${AUTH[@]}" "$DL/remoterig.sha256" -o "$TMP/remoterig.sha256"
( cd "$TMP" && echo "$(cat remoterig.sha256) remoterig" | sha256sum -c - ) || {
log "checksum FAILED — aborting update"; exit 1; }
# 3. Deploy via the existing backup/restart/rollback logic
chmod +x "$TMP/remoterig"
"$DEPLOY_DIR/deploy.sh" "$TMP/remoterig" "$DEPLOY_PATH" "$SERVICE"
# 4. Record the installed version
echo "$REMOTE_VER" > "$VERSION_FILE"
log "updated to $REMOTE_VER"
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=RemoteRig pull updater (checks Gitea dev release)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/remoterig/pi-update.sh
# Updater needs root to write the binary and restart the service
User=root
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Periodically check for RemoteRig updates (Gitea dev release)
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=RemoteRig Central Hub
Documentation=https://github.com/CubeCraft-Creations/remote-rig
After=network.target mosquitto.service
Wants=mosquitto.service
[Service]
Type=simple
User=pi
WorkingDirectory=/opt/remoterig
ExecStart=/opt/remoterig/remoterig
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
# Security hardening (optional, uncomment to enable)
# NoNewPrivileges=yes
# ProtectSystem=strict
# ProtectHome=yes
# ReadWritePaths=/opt/remoterig
# Allow graceful shutdown
TimeoutStopSec=10s
KillMode=mixed
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env bash
# RemoteRig — First-Time Raspberry Pi Zero 2 W Setup
# Target: Debian/Raspberry Pi OS (bookworm)
# Idempotent: safe to run multiple times
#
# Usage:
# sudo ./setup-pi.sh [--config PATH] [--service-user USER]
#
# Options:
# --config PATH Path to config.yaml template to copy to /opt/remoterig/
# --service-user USER Systemd service user (default: 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
set -euo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
CONFIG_TEMPLATE=""
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"
DEPLOY_DIR="/opt/remoterig"
SERVICE_NAME="remoterig"
SERVICE_FILE="scripts/remoterig.service"
MOSQUITTO_CONF="/etc/mosquitto/conf.d/remoterig.conf"
# ---------------------------------------------------------------------------
# Help
# ---------------------------------------------------------------------------
usage() {
sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# //'
exit 0
}
# ---------------------------------------------------------------------------
# Parse args
# ---------------------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--config)
CONFIG_TEMPLATE="$2"
shift 2
;;
--service-user)
SERVICE_USER="$2"
shift 2
;;
--static-ip)
STATIC_IP="$2"
shift 2
;;
--gateway)
GATEWAY="$2"
shift 2
;;
--help|-h)
usage
;;
*)
echo "ERROR: unknown option: $1" >&2
usage
;;
esac
done
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: must run as root (sudo ./setup-pi.sh)" >&2
exit 1
fi
info() { echo "[INFO] $*"; }
ok() { echo "[OK] $*"; }
skip() { echo "[SKIP] $*"; }
warn() { echo "[WARN] $*" >&2; }
echo "=============================================="
echo " RemoteRig Pi Zero 2 W Setup"
echo " Target: ${STATIC_IP} via ${GATEWAY}"
echo " Service user: ${SERVICE_USER}"
echo "=============================================="
# ---------------------------------------------------------------------------
# 1. Update package list (always safe)
# ---------------------------------------------------------------------------
info "Updating package list..."
apt-get update -qq
# ---------------------------------------------------------------------------
# 2. Install Mosquitto MQTT broker + clients
# ---------------------------------------------------------------------------
info "Installing Mosquitto..."
if dpkg -l mosquitto mosquitto-clients >/dev/null 2>&1; then
# Already installed — ensure latest
apt-get install -y -qq ${MOSQUITTO_PKG} 2>/dev/null && \
ok "Mosquitto packages up to date" || \
warn "Mosquitto package update had warnings (non-fatal)"
else
apt-get install -y -qq ${MOSQUITTO_PKG}
ok "Mosquitto installed"
fi
# ---------------------------------------------------------------------------
# 3. Configure Mosquitto — anonymous on localhost, listener on 0.0.0.0:1883
# ---------------------------------------------------------------------------
info "Configuring Mosquitto..."
mkdir -p /etc/mosquitto/conf.d
# Write idempotent config
cat > "${MOSQUITTO_CONF}" <<'MQTTEOF'
# RemoteRig Mosquitto configuration
# Closed travel-router LAN — anonymous access is intentional
# Listen on all interfaces (LAN + localhost)
listener 1883 0.0.0.0
# No authentication (closed network, no internet access)
allow_anonymous true
MQTTEOF
ok "Mosquitto config written: ${MOSQUITTO_CONF}"
# ---------------------------------------------------------------------------
# 4. Create /opt/remoterig directory
# ---------------------------------------------------------------------------
info "Creating deploy directory..."
if [ -d "${DEPLOY_DIR}" ]; then
skip "${DEPLOY_DIR} already exists"
else
mkdir -p "${DEPLOY_DIR}"
ok "Created ${DEPLOY_DIR}"
fi
# ---------------------------------------------------------------------------
# 5. Copy config.yaml template (if provided)
# ---------------------------------------------------------------------------
if [ -n "${CONFIG_TEMPLATE}" ] && [ -f "${CONFIG_TEMPLATE}" ]; then
info "Copying config.yaml template..."
if [ -f "${DEPLOY_DIR}/config.yaml" ]; then
skip "${DEPLOY_DIR}/config.yaml already exists (not overwriting)"
else
cp "${CONFIG_TEMPLATE}" "${DEPLOY_DIR}/config.yaml"
ok "Copied config.yaml to ${DEPLOY_DIR}/config.yaml"
fi
elif [ -n "${CONFIG_TEMPLATE}" ]; then
warn "Config template '${CONFIG_TEMPLATE}' not found — skipping"
else
info "No config template provided — skipping"
fi
# Ensure service user owns the deploy directory
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DEPLOY_DIR}" 2>/dev/null || \
warn "Could not chown ${DEPLOY_DIR} to ${SERVICE_USER} (user may not exist yet)"
# ---------------------------------------------------------------------------
# 6. Install and enable systemd service
# ---------------------------------------------------------------------------
info "Installing systemd service..."
# Locate the service file relative to this script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC_SERVICE="${SCRIPT_DIR}/remoterig.service"
if [ ! -f "${SRC_SERVICE}" ]; then
warn "Service file not found at ${SRC_SERVICE} — skipping service install"
warn "Run this script from the repository root (scripts/setup-pi.sh)"
else
DST_SERVICE="/etc/systemd/system/${SERVICE_NAME}.service"
# Copy if different
if [ -f "${DST_SERVICE}" ]; then
if cmp -s "${SRC_SERVICE}" "${DST_SERVICE}"; then
skip "systemd service already installed and up to date"
else
cp "${SRC_SERVICE}" "${DST_SERVICE}"
ok "systemd service updated"
RELOAD_SYSTEMD=1
fi
else
cp "${SRC_SERVICE}" "${DST_SERVICE}"
ok "systemd service installed"
RELOAD_SYSTEMD=1
fi
# Substitute the service user
sed -i "s/^User=.*/User=${SERVICE_USER}/" "${DST_SERVICE}"
if [ "${RELOAD_SYSTEMD:-0}" -eq 1 ]; then
systemctl daemon-reload
fi
# Enable (idempotent)
if systemctl is-enabled --quiet "${SERVICE_NAME}" 2>/dev/null; then
skip "systemd service already enabled"
else
systemctl enable "${SERVICE_NAME}"
ok "systemd service enabled"
fi
fi
# ---------------------------------------------------------------------------
# 6b. Install pull updater (Pi polls the Gitea dev release and self-updates)
# ---------------------------------------------------------------------------
info "Installing pull updater..."
# deploy.sh + pi-update.sh live in the deploy dir (the updater calls them)
for f in deploy.sh pi-update.sh; do
if [ -f "${SCRIPT_DIR}/${f}" ]; then
cp "${SCRIPT_DIR}/${f}" "${DEPLOY_DIR}/${f}"
chmod +x "${DEPLOY_DIR}/${f}"
ok "Installed ${DEPLOY_DIR}/${f}"
else
warn "${SCRIPT_DIR}/${f} not found — skipping"
fi
done
# update.env template (don't clobber an existing one that may hold a token)
UPDATE_ENV="${DEPLOY_DIR}/update.env"
if [ -f "${UPDATE_ENV}" ]; then
skip "${UPDATE_ENV} already exists (not overwriting)"
else
cat > "${UPDATE_ENV}" <<'ENVEOF'
# RemoteRig updater config
GITEA_BASE=https://code.cubecraftcreations.com
REPO=CubeCraft-Creations/remote-rig
# Read token — required only if the repo is private:
GITEA_TOKEN=
ENVEOF
chmod 600 "${UPDATE_ENV}"
ok "Wrote ${UPDATE_ENV} (set GITEA_TOKEN if the repo is private)"
fi
# Updater service + timer
for unit in remoterig-update.service remoterig-update.timer; do
if [ -f "${SCRIPT_DIR}/${unit}" ]; then
cp "${SCRIPT_DIR}/${unit}" "/etc/systemd/system/${unit}"
ok "Installed ${unit}"
else
warn "${SCRIPT_DIR}/${unit} not found — skipping"
fi
done
systemctl daemon-reload
if systemctl enable --now remoterig-update.timer 2>/dev/null; then
ok "remoterig-update.timer enabled and started"
else
warn "Could not enable remoterig-update.timer"
fi
# ---------------------------------------------------------------------------
# 7. Set static IP on wlan0
# ---------------------------------------------------------------------------
info "Configuring static IP on wlan0..."
# Check if wlan0 exists
if ! ip link show wlan0 >/dev/null 2>&1; then
warn "wlan0 interface not found — skipping static IP configuration"
warn "Connect Wi-Fi first (raspi-config), then re-run this script"
else
STATIC_IP_SET=0
# --- Method A: NetworkManager (default on bookworm) ---
if command -v nmcli >/dev/null 2>&1; then
info "Using NetworkManager (nmcli)..."
# Find the Wi-Fi connection profile
WIFI_CON=$(nmcli -t -f NAME,TYPE con show 2>/dev/null | grep ':802-11-wireless' | cut -d: -f1 | head -1)
if [ -n "${WIFI_CON}" ]; then
CURRENT_IP=$(nmcli -t -f IP4.ADDRESS con show "${WIFI_CON}" 2>/dev/null | cut -d: -f2 | head -1 || true)
if [ "${CURRENT_IP}" = "${STATIC_IP}" ]; then
skip "wlan0 already set to ${STATIC_IP} via nmcli"
STATIC_IP_SET=1
else
nmcli con mod "${WIFI_CON}" ipv4.addresses "${STATIC_IP}"
nmcli con mod "${WIFI_CON}" ipv4.gateway "${GATEWAY}"
nmcli con mod "${WIFI_CON}" ipv4.dns "${GATEWAY}"
nmcli con mod "${WIFI_CON}" ipv4.method manual
nmcli con up "${WIFI_CON}" 2>/dev/null || true
ok "wlan0 set to ${STATIC_IP} via nmcli (connection: ${WIFI_CON})"
STATIC_IP_SET=1
fi
else
warn "No Wi-Fi connection profile found in NetworkManager"
fi
fi
# --- Method B: dhcpcd (fallback for older PiOS) ---
if [ ${STATIC_IP_SET} -eq 0 ] && command -v dhcpcd >/dev/null 2>&1; then
info "Using dhcpcd..."
DHCPCD_CONF="/etc/dhcpcd.conf"
if grep -q "interface wlan0" "${DHCPCD_CONF}" 2>/dev/null; then
skip "dhcpcd already has wlan0 config"
else
cat >> "${DHCPCD_CONF}" <<DHCPCDEOF
# RemoteRig static IP
interface wlan0
static ip_address=${STATIC_IP}
static routers=${GATEWAY}
static domain_name_servers=${GATEWAY}
DHCPCDEOF
ok "dhcpcd configured for wlan0 static IP"
fi
STATIC_IP_SET=1
fi
# --- Method C: /etc/network/interfaces (last resort) ---
if [ ${STATIC_IP_SET} -eq 0 ]; then
warn "Neither nmcli nor dhcpcd found — attempting /etc/network/interfaces"
INTERFACES_FILE="/etc/network/interfaces"
if ! grep -q "iface wlan0 inet static" "${INTERFACES_FILE}" 2>/dev/null; then
cat >> "${INTERFACES_FILE}" <<NETEOF
# RemoteRig static IP
auto wlan0
iface wlan0 inet static
address ${STATIC_IP%/*}
netmask 255.255.255.0
gateway ${GATEWAY}
NETEOF
ok "wlan0 static IP configured in ${INTERFACES_FILE}"
else
skip "${INTERFACES_FILE} already has static wlan0 config"
fi
fi
fi
# ---------------------------------------------------------------------------
# 8. Enable and start Mosquitto
# ---------------------------------------------------------------------------
info "Enabling and starting Mosquitto..."
if systemctl is-active --quiet mosquitto 2>/dev/null; then
skip "Mosquitto already running"
else
systemctl enable mosquitto 2>/dev/null || true
systemctl restart mosquitto
ok "Mosquitto started"
fi
# Verify Mosquitto is listening
sleep 1
if systemctl is-active --quiet mosquitto 2>/dev/null; then
ok "Mosquitto is running and listening on :1883"
else
warn "Mosquitto may not have started — check: sudo systemctl status mosquitto"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "=============================================="
echo " Setup complete!"
echo "=============================================="
echo " Mosquitto: $(systemctl is-active mosquitto 2>/dev/null || echo 'unknown')"
echo " Service: ${SERVICE_NAME} (systemctl status ${SERVICE_NAME})"
echo " Updater: remoterig-update.timer (systemctl status remoterig-update.timer)"
echo " Deploy dir: ${DEPLOY_DIR}"
echo " Static IP: ${STATIC_IP} on wlan0"
echo ""
echo " Deploys are pull-based: push to 'dev' on Gitea -> CI builds the"
echo " arm64 binary -> the Pi's timer pulls + installs it automatically."
echo ""
echo " Next steps:"
echo " 1. If the repo is private, set a read token:"
echo " sudo sed -i 's/^GITEA_TOKEN=.*/GITEA_TOKEN=<token>/' ${DEPLOY_DIR}/update.env"
echo " 2. Trigger / wait for an update check:"
echo " sudo systemctl start remoterig-update.service"
echo " journalctl -u remoterig-update.service -n 30"
echo " 3. Check health once deployed:"
echo " curl http://${STATIC_IP%/*}:8080/health"
echo ""
echo " Manual one-off deploy (local binary) still works: scripts/deploy.sh"
echo "=============================================="
+196 -56
View File
@@ -1,88 +1,228 @@
import { Camera, Radio } from 'lucide-react' import { useState, useCallback, useMemo, useEffect } from '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'
import { CameraCard } from './components' import { api } from './services/api'
import CameraCard from './components/CameraCard'
import HistoryViewer from './components/HistoryViewer'
function App() { function App() {
// Connect to SSE endpoint — auto-updates the camera store const [commandBusy, setCommandBusy] = useState(false)
useSSE() const [commandError, setCommandError] = useState<string | null>(null)
const [historyCameraId, setHistoryCameraId] = useState<string | null>(null)
const [historyCameraName, setHistoryCameraName] = useState<string>()
// Subscribe to the camera store for reactivity. // SSE connection + live store
// getCameras / getOnlineCount / getRecordingCount pull from live state. const { connectionState } = useSSE()
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
const cameras = getCameras() // Seed the list once on mount via the REST API. SSE only pushes on change,
const onlineCount = getOnlineCount() // so without this the dashboard is empty until the next status event.
const recordingCount = getRecordingCount() 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])
const onlineCount = useMemo(() => cameras.filter((c) => c.online).length, [cameras])
const recordingCount = useMemo(() => cameras.filter((c) => c.recording).length, [cameras])
const cameraIds = cameras.map((c) => c.camera_id)
// ── Command helpers ──
const handleStart = useCallback(async (cameraId: string) => {
setCommandBusy(true)
setCommandError(null)
try {
await api.startRecording(cameraId)
} catch (err) {
setCommandError(err instanceof Error ? err.message : 'Command failed')
} finally {
setCommandBusy(false)
}
}, [])
const handleStop = useCallback(async (cameraId: string) => {
setCommandBusy(true)
setCommandError(null)
try {
await api.stopRecording(cameraId)
} catch (err) {
setCommandError(err instanceof Error ? err.message : 'Command failed')
} finally {
setCommandBusy(false)
}
}, [])
const handleStartAll = useCallback(async () => {
setCommandBusy(true)
setCommandError(null)
try {
await Promise.all(cameraIds.map((id) => api.startRecording(id)))
} catch {
// Individual failures are non-fatal — some may succeed
} finally {
setCommandBusy(false)
}
}, [cameraIds])
const handleStopAll = useCallback(async () => {
setCommandBusy(true)
setCommandError(null)
try {
await Promise.all(cameraIds.map((id) => api.stopRecording(id)))
} catch {
// Individual failures are non-fatal
} finally {
setCommandBusy(false)
}
}, [cameraIds])
const handleViewHistory = useCallback((cameraId: string) => {
const cam = useCameraStore.getState().cameras.get(cameraId)
setHistoryCameraId(cameraId)
setHistoryCameraName(cam?.friendly_name ?? cameraId)
}, [])
const handleCloseHistory = useCallback(() => {
setHistoryCameraId(null)
}, [])
// ── Connection badge ──
const connectionBadge = {
connected: { icon: Wifi, label: 'Live', class: 'bg-rig-success/15 text-rig-success' },
connecting: { icon: Wifi, label: 'Connecting...', class: 'bg-rig-warning/15 text-rig-warning' },
disconnected: { icon: WifiOff, label: 'Disconnected', class: 'bg-rig-danger/15 text-rig-danger' },
error: { icon: AlertTriangle, label: 'Stream Error', class: 'bg-rig-danger/15 text-rig-danger' },
}[connectionState] ?? {
icon: WifiOff,
label: 'Disconnected',
class: 'bg-rig-danger/15 text-rig-danger',
}
const BadgeIcon = connectionBadge.icon
// ── Render ──
return ( return (
<div className="min-h-screen bg-rig-dark-900"> <div className="min-h-screen bg-rig-dark-900 flex flex-col">
{/* Header */} {/* Header */}
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm"> <header className="shrink-0 border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between gap-4">
<Camera className="h-7 w-7 text-rig-accent" /> <div className="flex items-center gap-3 min-w-0">
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50"> <Camera className="h-6 w-6 shrink-0 text-rig-accent" />
RemoteRig <h1 className="text-lg font-bold tracking-tight text-rig-dark-50 truncate">
</h1> RemoteRig
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent"> </h1>
Dashboard <span className="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
</span> Dashboard
{/* Stats badges */}
<div className="ml-auto flex items-center gap-4">
{/* Online count */}
<span
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
title="Cameras online"
>
<span className="h-2 w-2 rounded-full bg-rig-success" />
{onlineCount} online
</span>
{/* Recording count */}
<span
className="inline-flex items-center gap-1.5 rounded-full bg-rig-dark-700/60 px-3 py-1 text-xs font-medium text-rig-dark-200"
title="Cameras recording"
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-rig-danger" />
</span>
{recordingCount} recording
</span> </span>
</div> </div>
{/* Connection status */}
<div className="flex items-center gap-3">
{/* SSE badge */}
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${connectionBadge.class}`}
>
<BadgeIcon className="h-3 w-3" />
{connectionBadge.label}
</span>
{/* Global controls */}
<div className="flex items-center gap-1">
<button
onClick={handleStartAll}
disabled={commandBusy || cameras.length === 0}
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-3 py-1.5 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Start recording on all cameras"
>
<Play className="h-3.5 w-3.5 fill-current" />
<span className="hidden sm:inline">Start All</span>
</button>
<button
onClick={handleStopAll}
disabled={commandBusy || cameras.length === 0}
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-3 py-1.5 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Stop recording on all cameras"
>
<Square className="h-3.5 w-3.5 fill-current" />
<span className="hidden sm:inline">Stop All</span>
</button>
</div>
</div>
</div>
{/* Stats strip */}
<div className="mt-2 flex items-center gap-4 text-xs text-rig-dark-400">
<span>
<strong className="text-rig-dark-100">{cameras.length}</strong> camera{cameras.length !== 1 ? 's' : ''}
</span>
<span>
<strong className="text-rig-success">{onlineCount}</strong> online
</span>
<span>
<strong className={recordingCount > 0 ? 'text-rig-danger' : 'text-rig-dark-300'}>
{recordingCount}
</strong>{' '}
recording
</span>
</div> </div>
</div> </div>
</header> </header>
{/* Command error toast */}
{commandError && (
<div className="shrink-0 border-b border-rig-danger/30 bg-rig-danger/10 px-4 py-2">
<p className="mx-auto max-w-7xl text-xs text-rig-danger">
<AlertTriangle className="inline h-3 w-3 mr-1" />
{commandError}
</p>
</div>
)}
{/* Main Content */} {/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <main className="flex-1 mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{cameras.length === 0 ? ( {cameras.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
<span className="relative mb-4 inline-flex"> <Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
</span>
<h2 className="text-lg font-semibold text-rig-dark-200"> <h2 className="text-lg font-semibold text-rig-dark-200">
Waiting for cameras&hellip; No Cameras Connected
</h2> </h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400"> <p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Connect cameras to your RemoteRig server and they will appear here Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.
automatically.
</p> </p>
</div> </div>
) : ( ) : (
/* Camera grid */ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {cameras.map((cam) => (
{cameras.map((camera) => ( <CameraCard
<CameraCard key={camera.camera_id} camera={camera} /> key={cam.camera_id}
camera={cam}
onStart={handleStart}
onStop={handleStop}
onViewHistory={handleViewHistory}
disabled={commandBusy}
/>
))} ))}
</div> </div>
)} )}
</main> </main>
{/* History modal */}
<HistoryViewer
cameraId={historyCameraId}
cameraName={historyCameraName}
onClose={handleCloseHistory}
/>
{/* Footer */} {/* Footer */}
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30"> <footer className="shrink-0 border-t border-rig-dark-700 bg-rig-dark-800/30">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
<p className="text-center text-xs text-rig-dark-500"> <p className="text-center text-xs text-rig-dark-500">
RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System
+52 -38
View File
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import CameraCard from './CameraCard' import CameraCard from './CameraCard'
import type { CameraStatus } from '../types' import type { CameraStatus } from '../types'
@@ -19,52 +19,52 @@ function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
} }
} }
const noop = vi.fn()
const renderCard = (overrides?: Partial<CameraStatus>) =>
render(<CameraCard camera={makeCamera(overrides ?? {})} onStart={noop} onStop={noop} onViewHistory={noop} />)
const renderCardContainer = (camera: CameraStatus) =>
render(<CameraCard camera={camera} onStart={noop} onStop={noop} onViewHistory={noop} />)
describe('CameraCard', () => { describe('CameraCard', () => {
// ── Basic rendering ──────────────────────────────────────────────────── // ── Basic rendering ────────────────────────────────────────────────────
it('renders camera name', () => { it('renders camera name', () => {
render(<CameraCard camera={makeCamera()} />) renderCard()
expect(screen.getByText('Front Camera')).toBeInTheDocument() expect(screen.getByText('Front Camera')).toBeInTheDocument()
}) })
it('shows resolution and FPS', () => { it('shows resolution and FPS', () => {
render(<CameraCard camera={makeCamera()} />) renderCard()
expect(screen.getByText(/1080p/)).toBeInTheDocument() expect(screen.getByText(/1080p/)).toBeInTheDocument()
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument() expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
}) })
it('shows battery percentage', () => { it('shows battery percentage', () => {
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />) renderCard({ battery_pct: 85 })
expect(screen.getByText('85%')).toBeInTheDocument() expect(screen.getByText('85%')).toBeInTheDocument()
}) })
it('shows N/A when battery is null', () => { it('shows N/A when battery is null', () => {
render(<CameraCard camera={makeCamera({ battery_pct: null })} />) renderCard({ battery_pct: null })
expect(screen.getByText('N/A')).toBeInTheDocument() expect(screen.getByText('N/A')).toBeInTheDocument()
}) })
// ── Battery bar colors ───────────────────────────────────────────────── // ── Battery bar colors ─────────────────────────────────────────────────
it('uses green bar for high battery (>=50%)', () => { it('uses green bar for high battery (>=50%)', () => {
const { container } = render( const { container } = renderCard({ battery_pct: 85 })
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success') expect(bar?.className).toContain('bg-rig-success')
}) })
it('uses yellow bar for medium battery (15-49%)', () => { it('uses yellow bar for medium battery (15-49%)', () => {
const { container } = render( const { container } = renderCard({ battery_pct: 30 })
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning') expect(bar?.className).toContain('bg-rig-warning')
}) })
it('uses red bar for low battery (<15%)', () => { it('uses red bar for low battery (<15%)', () => {
const { container } = render( const { container } = renderCard({ battery_pct: 8 })
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-danger') expect(bar?.className).toContain('bg-rig-danger')
}) })
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
// ── Recording state ──────────────────────────────────────────────────── // ── Recording state ────────────────────────────────────────────────────
it('shows REC badge when recording', () => { it('shows REC badge when recording', () => {
render(<CameraCard camera={makeCamera({ recording: true })} />) renderCard({ recording: true })
expect(screen.getByText('REC')).toBeInTheDocument() expect(screen.getByText('REC')).toBeInTheDocument()
}) })
it('shows IDLE badge when not recording', () => { it('shows IDLE badge when not recording', () => {
render(<CameraCard camera={makeCamera({ recording: false })} />) renderCard({ recording: false })
expect(screen.getByText('IDLE')).toBeInTheDocument() expect(screen.getByText('IDLE')).toBeInTheDocument()
}) })
// ── Online / Offline badges ──────────────────────────────────────────── // ── Online / Offline badges ────────────────────────────────────────────
it('shows Online badge when camera is online', () => { it('shows Online badge when camera is online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />) renderCard({ online: true })
expect(screen.getByText('Online')).toBeInTheDocument() expect(screen.getByText('Online')).toBeInTheDocument()
}) })
it('shows Offline badge when camera is offline', () => { it('shows Offline badge when camera is offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />) renderCard({ online: false })
const offlineElements = screen.getAllByText('Offline') const offlineElements = screen.getAllByText('Offline')
expect(offlineElements.length).toBeGreaterThanOrEqual(1) expect(offlineElements.length).toBeGreaterThanOrEqual(1)
}) })
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
// ── Video remaining ──────────────────────────────────────────────────── // ── Video remaining ────────────────────────────────────────────────────
it('shows video remaining time when available', () => { it('shows video remaining time when available', () => {
render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />) renderCard({ video_remaining_sec: 125 })
// formatTimeLeft(125) → "2m 5s left" // formatTimeLeft(125) → "2m 5s left"
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument() expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
}) })
it('does not show video remaining when null', () => { it('does not show video remaining when null', () => {
render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />) renderCard({ video_remaining_sec: null })
// The Radio icon and time text should not be present // The Radio icon and time text should not be present
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument() expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
}) })
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
// ── Footer ───────────────────────────────────────────────────────────── // ── Footer ─────────────────────────────────────────────────────────────
it('shows Live + timestamp in footer when online', () => { it('shows Live + timestamp in footer when online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />) renderCard({ online: true })
// Footer shows "Live" when online
expect(screen.getByText('Live')).toBeInTheDocument() expect(screen.getByText('Live')).toBeInTheDocument()
}) })
it('shows Offline + timestamp in footer when offline', () => { it('shows Offline in footer when offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />) renderCard({ online: false })
// Footer says "Offline" (the text appears both in the badge and footer)
// When offline, the footer specifically shows "Offline" text
const offlineElements = screen.getAllByText('Offline') const offlineElements = screen.getAllByText('Offline')
// At least one should exist (badge + footer)
expect(offlineElements.length).toBeGreaterThanOrEqual(1) expect(offlineElements.length).toBeGreaterThanOrEqual(1)
}) })
it('shows "unknown" when last_seen is malformed', () => { it('shows "unknown" when last_seen is malformed', () => {
render( renderCard({ last_seen: 'not-a-date' })
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
)
expect(screen.getByText('unknown')).toBeInTheDocument() expect(screen.getByText('unknown')).toBeInTheDocument()
}) })
it('shows "unknown" when last_seen is in the future', () => { it('shows "unknown" when last_seen is in the future', () => {
const future = new Date(Date.now() + 86400000).toISOString() // +1 day const future = new Date(Date.now() + 86400000).toISOString() // +1 day
render(<CameraCard camera={makeCamera({ last_seen: future })} />) const cam = makeCamera({ last_seen: future })
renderCardContainer(cam)
expect(screen.getByText('unknown')).toBeInTheDocument() expect(screen.getByText('unknown')).toBeInTheDocument()
}) })
// ── Edge cases ────────────────────────────────────────────────────────── // ── Edge cases ──────────────────────────────────────────────────────────
it('clamps negative battery_pct to 0%', () => { it('clamps negative battery_pct to 0%', () => {
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />) renderCard({ battery_pct: -5 })
expect(screen.getByText('0%')).toBeInTheDocument() expect(screen.getByText('0%')).toBeInTheDocument()
}) })
it('shows exact boundary: 15% battery → yellow bar', () => { it('shows exact boundary: 15% battery → yellow bar', () => {
const { container } = render( const { container } = renderCard({ battery_pct: 15 })
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning') expect(bar?.className).toContain('bg-rig-warning')
}) })
it('shows exact boundary: 50% battery → green bar', () => { it('shows exact boundary: 50% battery → green bar', () => {
const { container } = render( const { container } = renderCard({ battery_pct: 50 })
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
)
const bar = container.querySelector('[role="progressbar"] div') const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success') expect(bar?.className).toContain('bg-rig-success')
}) })
// ── New prop-driven tests ──────────────────────────────────────────────
it('calls onStart when Record button is clicked', () => {
const onStart = vi.fn()
render(<CameraCard camera={makeCamera({ recording: false })} onStart={onStart} onStop={noop} onViewHistory={noop} />)
screen.getByText('Record').click()
expect(onStart).toHaveBeenCalledWith('cam-1')
})
it('calls onStop when Stop button is clicked', () => {
const onStop = vi.fn()
render(<CameraCard camera={makeCamera({ recording: true })} onStart={noop} onStop={onStop} onViewHistory={noop} />)
screen.getByText('Stop').click()
expect(onStop).toHaveBeenCalledWith('cam-1')
})
it('calls onViewHistory when History button is clicked', () => {
const onViewHistory = vi.fn()
render(<CameraCard camera={makeCamera({})} onStart={noop} onStop={noop} onViewHistory={onViewHistory} />)
screen.getByText('History').click()
expect(onViewHistory).toHaveBeenCalledWith('cam-1')
})
}) })
+87 -29
View File
@@ -1,4 +1,4 @@
import { Video, Wifi, WifiOff, Signal, Battery, Radio } from 'lucide-react' import { Video, Wifi, WifiOff, Signal, Battery, Radio, Play, Square } from 'lucide-react'
import type { CameraStatus } from '../types' import type { CameraStatus } from '../types'
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
return `${diffDay}d ago` return `${diffDay}d ago`
} }
function batteryColor(pct: number | null): { bar: string; text: string } { function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' } if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' } if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' } if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { bar: 'bg-rig-danger', text: 'text-rig-danger' } return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
} }
function formatTimeLeft(sec: number): string { function formatTimeLeft(sec: number): string {
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
return `${m}m ${s}s left` return `${m}m ${s}s left`
} }
function cameraStatus(online: boolean, batteryPct: number | null): 'good' | 'warning' | 'critical' {
if (!online) return 'critical'
if (batteryPct === null) return 'good'
if (batteryPct >= 50) return 'good'
if (batteryPct >= 15) return 'warning'
return 'critical'
}
const STATUS_BORDER: Record<string, string> = {
good: 'border-l-rig-success',
warning: 'border-l-rig-warning',
critical: 'border-l-rig-danger',
}
// ── Component ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
interface CameraCardProps { interface CameraCardProps {
camera: CameraStatus camera: CameraStatus
onStart: (cameraId: string) => void
onStop: (cameraId: string) => void
onViewHistory: (cameraId: string) => void
disabled?: boolean
} }
export default function CameraCard({ camera }: CameraCardProps) { export default function CameraCard({ camera, onStart, onStop, onViewHistory, disabled }: CameraCardProps) {
const { const {
camera_id,
friendly_name, friendly_name,
online, online,
resolution, resolution,
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
} = camera } = camera
const batt = batteryColor(battery_pct) const batt = batteryColor(battery_pct)
const status = cameraStatus(online, battery_pct)
const borderColor = STATUS_BORDER[status]
return ( return (
<article <article
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${ className={`rounded-xl border border-rig-dark-600 bg-rig-dark-800/60 transition-colors border-l-4 ${borderColor} ${
online online
? 'border-rig-dark-600 hover:border-rig-accent/40' ? 'hover:border-rig-accent/40'
: 'border-rig-dark-700 opacity-75' : 'opacity-75'
}`} }`}
> >
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2"> <div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" /> <Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
<h3 <h3
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]" className="text-sm font-semibold text-rig-dark-100 truncate"
title={friendly_name} title={friendly_name}
> >
{friendly_name} {friendly_name}
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
<span <span
role="status" role="status"
aria-label={online ? 'Camera online' : 'Camera offline'} aria-label={online ? 'Camera online' : 'Camera offline'}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${ className={`ml-2 shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
online online
? 'bg-rig-success/15 text-rig-success' ? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger' : 'bg-rig-danger/15 text-rig-danger'
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
{/* ── Body ── */} {/* ── Body ── */}
<div className="space-y-2.5 px-4 pb-3"> <div className="space-y-2.5 px-4 pb-3">
{/* Camera ID */}
<p className="text-[11px] font-mono text-rig-dark-500">{camera_id}</p>
{/* Resolution + FPS */} {/* Resolution + FPS */}
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300"> <div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
<Signal className="h-3.5 w-3.5" /> <Signal className="h-3.5 w-3.5" />
@@ -159,26 +183,60 @@ export default function CameraCard({ camera }: CameraCardProps) {
</div> </div>
{/* ── Footer ── */} {/* ── Footer ── */}
<div className="flex items-center justify-between rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30 px-4 py-2"> <div className="rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30">
<div className="flex items-center gap-1.5 text-xs"> {/* Controls row */}
{online ? ( <div className="flex items-center gap-1 px-3 py-2">
<> {recording ? (
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" /> <button
<span className="text-rig-success">Live</span> onClick={() => onStop(camera_id)}
</> disabled={disabled}
className="flex items-center gap-1 rounded-md bg-rig-danger/20 px-2.5 py-1 text-xs font-medium text-rig-danger hover:bg-rig-danger/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={`Stop recording ${friendly_name}`}
>
<Square className="h-3 w-3 fill-current" />
Stop
</button>
) : ( ) : (
<span className="text-rig-dark-400">Offline</span> <button
onClick={() => onStart(camera_id)}
disabled={disabled || !online}
className="flex items-center gap-1 rounded-md bg-rig-success/20 px-2.5 py-1 text-xs font-medium text-rig-success hover:bg-rig-success/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={`Start recording ${friendly_name}`}
>
<Play className="h-3 w-3 fill-current" />
Record
</button>
)} )}
<span className="text-rig-dark-500">·</span> <button
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span> onClick={() => onViewHistory(camera_id)}
className="ml-auto rounded-md bg-rig-dark-700/50 px-2 py-1 text-[11px] text-rig-dark-300 hover:bg-rig-dark-600 hover:text-rig-dark-100 transition-colors"
>
History
</button>
</div> </div>
{video_remaining_sec !== null && ( {/* Status strip */}
<div className="flex items-center gap-1 text-xs text-rig-dark-400"> <div className="flex items-center justify-between px-4 pb-2">
<Radio className="h-3 w-3" /> <div className="flex items-center gap-1.5 text-xs">
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span> {online ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
<span className="text-rig-success">Live</span>
</>
) : (
<span className="text-rig-dark-400">Offline</span>
)}
<span className="text-rig-dark-500">·</span>
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
</div> </div>
)}
{video_remaining_sec !== null && (
<div className="flex items-center gap-1 text-xs text-rig-dark-400">
<Radio className="h-3 w-3" />
<span className="font-mono">{formatTimeLeft(video_remaining_sec)}</span>
</div>
)}
</div>
</div> </div>
</article> </article>
) )
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from 'react'
import { X, Clock, Battery, Radio, Video } from 'lucide-react'
import { api } from '../services/api'
import type { StatusLog } from '../types'
// ── Helpers ────────────────────────────────────────────────────────────────
function formatTimestamp(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function batteryColor(pct: number | null): string {
if (pct === null) return 'text-rig-dark-400'
if (pct >= 50) return 'text-rig-success'
if (pct >= 15) return 'text-rig-warning'
return 'text-rig-danger'
}
// ── Component ──────────────────────────────────────────────────────────────
interface HistoryViewerProps {
cameraId: string | null
cameraName?: string
onClose: () => void
}
export default function HistoryViewer({ cameraId, cameraName, onClose }: HistoryViewerProps) {
const [logs, setLogs] = useState<StatusLog[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!cameraId) {
setLogs([])
return
}
setLoading(true)
setError(null)
api
.getCameraDetail(cameraId)
.then((data) => {
setLogs(data.history)
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load history')
})
.finally(() => setLoading(false))
}, [cameraId])
// Handle escape key
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
if (cameraId === null) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
role="dialog"
aria-modal="true"
aria-label={`History for ${cameraName ?? cameraId}`}
>
<div className="mx-4 w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl border border-rig-dark-600 bg-rig-dark-800 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-xl border-b border-rig-dark-700 px-5 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-rig-accent" />
<h2 className="text-sm font-semibold text-rig-dark-100">
History &mdash; {cameraName ?? cameraId}
</h2>
</div>
<button
onClick={onClose}
className="rounded-md p-1 text-rig-dark-400 hover:bg-rig-dark-700 hover:text-rig-dark-100 transition-colors"
aria-label="Close history"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-rig-dark-500 border-t-rig-accent" />
<span className="ml-3 text-sm text-rig-dark-400">Loading history...</span>
</div>
)}
{error && (
<div className="rounded-lg border border-rig-danger/30 bg-rig-danger/10 px-4 py-3 text-sm text-rig-danger">
{error}
</div>
)}
{!loading && !error && logs.length === 0 && (
<p className="py-8 text-center text-sm text-rig-dark-400">
No history entries found for this camera.
</p>
)}
{!loading && logs.length > 0 && (
<div className="space-y-2">
{logs.map((log) => (
<div
key={log.id}
className="flex items-center gap-3 rounded-lg border border-rig-dark-700/50 bg-rig-dark-900/40 px-3 py-2.5 text-xs"
>
{/* Timestamp */}
<span className="font-mono text-rig-dark-400 min-w-[130px]">
{formatTimestamp(log.recorded_at)}
</span>
{/* Online/Recording badges */}
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${
log.online
? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger'
}`}
>
{log.online ? 'Online' : 'Offline'}
</span>
{log.recording_state ? (
<span className="rounded bg-rig-danger/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-rig-danger">
REC
</span>
) : (
<span className="rounded bg-rig-dark-600/50 px-1.5 py-0.5 text-[10px] text-rig-dark-500">
IDLE
</span>
)}
</div>
{/* Battery */}
<div className="flex items-center gap-1 ml-auto">
<Battery className="h-3 w-3 text-rig-dark-500" />
<span className={`font-mono ${batteryColor(log.battery_pct)}`}>
{log.battery_pct !== null ? `${log.battery_pct}%` : 'N/A'}
</span>
</div>
{/* Storage remaining */}
{log.video_remaining_sec !== null && (
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-rig-dark-500" />
<span className="font-mono text-rig-dark-400">
{Math.floor(log.video_remaining_sec / 60)}m left
</span>
</div>
)}
{/* Mode */}
<div className="flex items-center gap-1">
<Video className="h-3 w-3 text-rig-dark-500" />
<span className="text-rig-dark-400">{log.mode}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="rounded-b-xl border-t border-rig-dark-700 px-5 py-3">
<p className="text-[11px] text-rig-dark-500">
{logs.length} entries (last 24 hours)
</p>
</div>
</div>
</div>
)
}
+1
View File
@@ -1 +1,2 @@
export { default as CameraCard } from './CameraCard' export { default as CameraCard } from './CameraCard'
export { default as HistoryViewer } from './HistoryViewer'
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteRig - Frontend Not Built</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
color: #333;
}
.message {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 { color: #e74c3c; margin-bottom: 0.5rem; }
code {
background: #eee;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1.1rem;
}
</style>
</head>
<body>
<div class="message">
<h1>Frontend Not Built</h1>
<p>The React frontend has not been built yet.</p>
<p>Run <code>npm run build</code> from the project root, then rebuild the Go binary.</p>
<p><small>API is still available at <code>/api/v1/</code> and health at <code>/health</code></small></p>
</div>
</body>
</html>
+19 -6
View File
@@ -1,4 +1,4 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api' const API_BASE = import.meta.env.VITE_API_URL || '/api/v1'
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> { async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
@@ -12,9 +12,22 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
} }
export const api = { export const api = {
getCameras: () => request<[]>('/cameras'), /** GET /api/v1/cameras — list all cameras with latest status */
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`), getCameras: () => request<import('../types').CameraStatus[]>('/cameras'),
getSystemHealth: () => request<[]>('/system/health'),
toggleRecording: (cameraId: string) => /** GET /api/v1/cameras/{id} — full detail + 24h history */
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }), getCameraDetail: (id: string) =>
request<import('../types').CameraDetail>(`/cameras/${id}`),
/** POST /api/v1/cameras/{id}/start — start recording */
startRecording: (cameraId: string) =>
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/start`, {
method: 'POST',
}),
/** POST /api/v1/cameras/{id}/stop — stop recording */
stopRecording: (cameraId: string) =>
request<import('../types').StartStopResponse>(`/cameras/${cameraId}/stop`, {
method: 'POST',
}),
} }
+36
View File
@@ -21,6 +21,42 @@ export interface SSEEvent {
payload?: unknown payload?: unknown
} }
/** A single status log entry from GET /api/v1/cameras/{id} */
export interface StatusLog {
id: number
camera_id: string
recorded_at: string
battery_pct: number | null
video_remaining_sec: number | null
recording_state: number // 0 or 1 (SQLite bool)
mode: string
resolution: string
fps: number
online: number // 0 or 1
raw_battery_pct: number | null
}
/** Camera detail response from GET /api/v1/cameras/{id} */
export interface CameraDetail {
camera: CameraInfo
last_status: StatusLog
history: StatusLog[]
}
export interface CameraInfo {
CameraID: string
FriendlyName: string
MacAddress: string | null
CreatedAt: string
UpdatedAt: string
}
/** Generic API responses */
export interface StartStopResponse {
status: string
camera_id: string
}
export interface Camera { export interface Camera {
id: string id: string
name: string name: string
+6
View File
@@ -5,6 +5,12 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// Build straight into the Go embed location: cmd/server/main.go has
// //go:embed all:src/dist relative to its package dir (cmd/server/).
build: {
outDir: 'cmd/server/src/dist',
emptyOutDir: true,
},
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {