59 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
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
69 changed files with 212531 additions and 1468 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}`);
+20 -30
View File
@@ -7,26 +7,27 @@ on:
workflow_dispatch:
env:
GO_VERSION: "1.23"
NODE_VERSION: "20"
BINARY_NAME: remoterig
jobs:
build:
runs-on: ubuntu-latest
runs-on: go-react
steps:
- name: Checkout
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
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
go-version: "1.25"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Toolchain versions
run: |
go version
node --version
- name: Build React frontend
run: |
@@ -39,25 +40,14 @@ jobs:
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
-o ${{ env.BINARY_NAME }} ./cmd/server
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.BINARY_NAME }}
path: ${{ env.BINARY_NAME }}
retention-days: 5
- name: Trigger deploy workflow
if: success()
uses: actions/github-script@v7
with:
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
}
})
# Pull-based deploy: publish the binary to a rolling "dev" release.
# The Pi polls this release and self-updates (scripts/pi-update.sh);
# the runner never needs to reach the closed RemoteRig network.
# Done in Node (runner image has no curl/jq/sudo; Node is present).
- name: Publish to rolling dev release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: node .gitea/scripts/publish-release.mjs
+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:
push:
@@ -7,59 +12,12 @@ on:
branches: [dev, main]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
quality:
runs-on: go-react
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
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:
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:
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"
-115
View File
@@ -1,115 +0,0 @@
name: Deploy (Dev)
on:
repository_dispatch:
types:
- dev-build-success
workflow_dispatch:
env:
BINARY_NAME: remoterig
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_PATH: /opt/remoterig/remoterig
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:-remoterig}"
DEPLOY_PATH="${2:-/opt/remoterig/remoterig}"
SERVICE="${3:-remoterig}"
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 config.yaml (if present)
run: |
if [ -f config.yaml ]; then
echo "config.yaml found, will deploy alongside binary"
echo "config.yaml" >> deploy-files.txt
else
echo "no config.yaml in repo, skipping"
fi
- name: Deploy to dev server
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,config.yaml"
target: "/tmp/remoterig-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/remoterig-deploy
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "remoterig"
if [ -f config.yaml ]; then
echo "::config:: deploying config.yaml"
sudo mkdir -p "$(dirname "${{ env.DEPLOY_PATH }}")"
sudo cp config.yaml "$(dirname "${{ env.DEPLOY_PATH }}")/config.yaml"
fi
rm -rf /tmp/remoterig-deploy
- name: Notify on failure
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/remoterig-deploy-failure.txt
+6 -4
View File
@@ -9,13 +9,15 @@ lerna-debug.log*
# Dependencies
node_modules
dist
/dist
dist-ssr
*.local
# Frontend build output (embedded at Go build time)
# Allow the fallback placeholder so embed always has at least index.html
!src/dist/index.html
# 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
.env
+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
```
+24 -16
View File
@@ -117,31 +117,39 @@ Platform: pi-zero-2w (max 16 cameras)
RemoteRig hub ready
```
## Building for Raspberry Pi Zero 2 W
## Deployment (CI/CD — pull-based)
Cross-compile from your development machine:
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=arm GOARM=6 go build -o remoterig-hub ./cmd/server/
```
Copy the binary and `config.yaml` to your Pi:
```bash
scp remoterig-hub config.yaml pi@raspberrypi:/home/pi/remoterig/
```
Then run on the Pi:
```bash
./remoterig-hub
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 | `GOOS=linux GOARCH=arm GOARM=6 go build -o remoterig-hub ./cmd/server/` |
| 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/` |
+13 -10
View File
@@ -78,7 +78,9 @@ func main() {
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(cfg.WriteTimeout))
// No global request timeout: it cancels the long-lived SSE stream
// (/api/v1/events/stream) — that's why the dashboard never received
// camera events. Closed-LAN kiosk, so dropping it is fine.
// Health check (no auth)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
@@ -87,18 +89,19 @@ func main() {
})
// API routes (auth required if API key is configured)
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB, mqttSub)))
// Serve embedded React frontend with SPA fallback
r.Mount("/", frontendHandler())
// Create server
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: cfg.ReadTimeout,
// WriteTimeout intentionally 0: SSE responses are long-lived and a
// write deadline would terminate them mid-stream.
IdleTimeout: cfg.IdleTimeout,
}
// Graceful shutdown
@@ -119,7 +122,7 @@ func main() {
}
// apiRouter creates the API route tree.
func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
func apiRouter(sseHub *events.Hub, database *db.DB, pub api.CommandPublisher) http.Handler {
r := chi.NewRouter()
// Camera management routes
@@ -128,8 +131,8 @@ func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
r.Get("/cameras/{id}", api.GetCameraDetail(database))
// Recording control routes
r.Post("/cameras/{id}/start", api.StartRecording(database))
r.Post("/cameras/{id}/stop", api.StopRecording(database))
r.Post("/cameras/{id}/start", api.StartRecording(database, pub))
r.Post("/cameras/{id}/stop", api.StopRecording(database, pub))
// Status ingestion (from ESP32 nodes)
r.Post("/cameras/{id}/status", api.PushStatus(database))
+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
db_path: "remoterig.db"
# API Key for endpoint authentication
api_key: "changeme"
# API key for endpoint authentication. Empty = kiosk mode (no auth) —
# intended for the closed travel-router LAN, consistent with anonymous MQTT.
# Set a value to require the X-API-Key header on /api/v1/* (the SPA would
# then need it too).
api_key: ""
# Server settings
port: 8080
+6 -6
View File
@@ -36,7 +36,7 @@ RemoteRig is a **multi-camera remote monitoring system**. It provides a camera g
```
┌──────────────────────────────────────────┐
│ Travel Router (self-contained LAN) │
│ Subnet: 10.60.1.0/24 │
│ Subnet: 192.168.8.0/24 │
│ DHCP pool: .100-.200 │
└──────┬──────────┬──────────┬──────────────┘
│ │ │
@@ -44,7 +44,7 @@ RemoteRig is a **multi-camera remote monitoring system**. It provides a camera g
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ ESP32 #1 │ │ ESP32 #N │ │ Pi Zero 2 W │
│ DHCP addr │ │ DHCP addr │ │ 10.60.1.56 │
│ 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 │
@@ -59,14 +59,14 @@ RemoteRig is a **multi-camera remote monitoring system**. It provides a camera g
└──────┬───────┘ └──────┬───────┘ ┌──────────────────┐
▼ ▼ │ User Device │
┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │
│ GoPro Hero 3 │ │ GoPro Hero 3 │ │ 10.60.1.56:8080 │
│ 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 `10.60.1.0/24`.
- **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.
@@ -215,8 +215,8 @@ platform:
type: "pi-zero-2w"
max_cameras: 16
network:
subnet: "10.60.1.0/24" # Travel router subnet
hub_ip: "10.60.1.56" # Pi Zero 2 W static IP
subnet: "192.168.8.0/24" # Travel router subnet
hub_ip: "192.168.8.56" # Pi Zero 2 W static IP
```
## Frontend Component Tree
+20 -12
View File
@@ -7,7 +7,7 @@
```
┌──────────────────────────────────┐
│ Travel Router (10.60.1.1) │
│ Travel Router (192.168.8.1) │
│ DHCP: .100-.200 │
└──────┬──────────┬──────────┬──────┘
│ │ │
@@ -15,7 +15,7 @@
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
│ 10.60.1.101 │ │ 10.60.1.102 │ │ 10.60.1.56 │
│ 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 │
@@ -32,14 +32,14 @@
└──────────────┘ └──────────────┘
```
- **Travel router:** Self-contained, no internet. Gateway `10.60.1.1`. DHCP pool: `10.60.1.100-200`
- **Pi Zero 2 W:** Static IP `10.60.1.56`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
- **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://10.60.1.56:8080` for dashboard
- **User device:** Connects to router, opens `http://192.168.8.56:8080` for dashboard
## MQTT Broker
- **Host:** `10.60.1.56` (Pi Zero 2 W)
- **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`)
@@ -176,11 +176,19 @@ Published once on ESP32 first boot (or factory reset). Used for auto-registratio
| `capabilities` | string[] | Supported features |
| `friendly_name` | string | Default human-readable name |
**Hub behavior on first announce:**
**Camera IDs (self-assigned — "Option B"):** the node uses a stable
device-derived id (`rig-<last3 MAC bytes>`, e.g. `rig-86d978`) as its
`camera_id` from first boot, and uses it for all topics
(`announce`/`status`/`heartbeat`/`command`). There is no hub-assigned
`cam-NNN` and no `registered` reply handshake.
**Hub behavior on announce:**
1. Check if MAC already registered → if yes, update `friendly_name` and log
2. If new MAC → create camera with auto-generated `camera_id = "cam-<NNN>"` (zero-padded sequential)
3. Respond by publishing: `remoterig/cameras/<camera_id>/command` with `command: "registered"` payload containing the assigned `camera_id`
4. Broadcast via SSE that a new camera appeared
2. If new MAC → insert the camera using the node's self-assigned `camera_id`
3. Broadcast via SSE that a new camera appeared
> Note: nodes have no real-time clock, so `timestamp` may be absent; the hub
> stamps received-time server-side.
### Topic: `remoterig/hub/status`
@@ -222,7 +230,7 @@ Hub health status broadcast.
ESP32 boots
├── Connects to travel router Wi-Fi
├── Connects to MQTT broker (10.60.1.56:1883)
├── Connects to MQTT broker (192.168.8.56:1883)
├── Publishes announce (retained) on cameras/<id>/announce
@@ -275,6 +283,6 @@ When ESP32 loses connection to travel router:
## 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 `10.60.1.56`.
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".
+3 -3
View File
@@ -14,7 +14,7 @@ Each camera node uses **two boards** connected via UART — zero network switchi
│ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │
│ │ 115200 │ │
│ STA → GoPro AP │ 8N1 │ STA → Travel Router │
│ HTTP → 10.5.5.1 │ │ MQTT → 10.60.1.56│
│ HTTP → 10.5.5.1 │ │ MQTT → 192.168.8.56│
│ Start/stop/status │ │ Hub registration │
└─────────────────────┘ └──────────────────────┘
```
@@ -22,7 +22,7 @@ Each camera node uses **two boards** connected via UART — zero network switchi
| Board | Job | Network | Protocol |
|-------|-----|---------|----------|
| ESP8266 | Camera control | GoPro AP only (10.5.5.1) | HTTP → UART JSON |
| ESP32 | Hub relay | Travel router only (10.60.1.x) | UART JSON → MQTT |
| ESP32 | Hub relay | Travel router only (192.168.8.x) | UART JSON → MQTT |
## Quick Start
@@ -75,7 +75,7 @@ JSON-per-line at 115200 8N1. GPIO16 on both boards.
|-----|---------|-------------|
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
| `wifi_password` | `""` | Travel router password |
| `mqtt_broker` | `"10.60.1.56"` | Pi Zero 2 W IP |
| `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 |
+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
}
-8
View File
@@ -1,8 +0,0 @@
{
"wifi_ssid": "RemoteRig",
"wifi_password": "",
"mqtt_broker": "10.60.1.56",
"mqtt_port": 1883,
"camera_id": "",
"heartbeat_interval_sec": 60
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"camera_ssid": "GOPRO-BP-",
"camera_password": "goprohero",
"camera_ip": "10.5.5.1",
"camera_ssid": "goprosilver-1",
"camera_password": "Bzyeatn421",
"camera_ip": "10.5.5.9",
"poll_interval_sec": 30
}
+39 -23
View File
@@ -9,16 +9,16 @@
; (TX/RX) (RX16/TX17)
;
; Build:
; pio run -e esp8266-camera (ESP8266 D1 Mini — camera bridge)
; pio run -e esp32-mqtt (ESP32 Dev Board — MQTT bridge)
; 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 esp32-mqtt --target upload
; pio run -e seeed_xiao_esp32c6 --target upload
;
; Filesystem:
; pio run -e esp8266-camera --target uploadfs
; pio run -e esp32-mqtt --target uploadfs
; pio run -e seeed_xiao_esp32c6 --target uploadfs
[common]
lib_deps =
@@ -27,41 +27,57 @@ lib_deps =
build_flags =
-D CORE_DEBUG_LEVEL=0
; ── ESP8266: Camera Bridge ──────────────────────────────────
; Flashed onto D1 Mini. Talks to GoPro over Wi-Fi, relays to
; ESP32 over UART (TX/RX pins). No MQTT, no router connection.
; ── 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 = d1_mini
board = esp01_1m
framework = arduino
monitor_speed = 115200
upload_speed = 921600
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 = dio
board_build.flash_mode = dout
board_build.f_cpu = 160000000L
build_src_filter =
+<../lib/>
+<esp8266-camera-bridge.cpp>
-<*.cpp>
+<esp8266-camera-bridge.cpp>
+<../lib/>
; ── ESP32: MQTT Bridge ─────────────────────────────────────
; Flashed onto ESP32 Dev Board. Connects to travel router,
; publishes MQTT to Pi hub. Reads camera status from ESP8266
; over UART2 (RX16/TX17). No direct camera communication.
; ── 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:esp32-mqtt]
platform = espressif32
board = esp32dev
[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
upload_speed = 921600
lib_deps = ${common.lib_deps}
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 =
+<../lib/>
+<esp32-mqtt-bridge.cpp>
-<*.cpp>
+<esp32-mqtt-bridge.cpp>
+<../lib/>
+247 -39
View File
@@ -19,10 +19,10 @@
* ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n
*
* Hardware:
* - ESP32 Dev Board (or D1 Mini ESP32)
* - UART2: RX=GPIO16, TX=GPIO17 (connected to ESP8266)
* - Seeed Studio XIAO ESP32-C6
* - Serial1: RX=D7, TX=D6 (crossed to the ESP-01S TX/RX)
* - Shared GND between boards
* - LiPo → 3.3V buck → VIN on both boards
* - 5V rail → XIAO 5V/VIN; ESP-01S on its own 3.3V buck
*/
#include <Arduino.h>
@@ -30,24 +30,32 @@
#include <WiFiClient.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <SPIFFS.h>
#include <LittleFS.h>
#include <Wire.h>
#include <U8g2lib.h>
// ────────────────────────────────────────────
// Configuration (SPIFFS)
// Configuration (LittleFS)
// ────────────────────────────────────────────
struct Config {
String wifi_ssid = "RemoteRig";
String wifi_password = "";
String mqtt_broker = "10.60.1.56";
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 (!SPIFFS.begin(true)) { Serial.println("[CFG] SPIFFS mount failed"); return false; }
File f = SPIFFS.open("/config.json", "r");
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;
@@ -61,11 +69,16 @@ bool loadConfig() {
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 = SPIFFS.open("/config.json", "w");
File f = LittleFS.open("/config.json", "w");
if (!f) return false;
JsonDocument doc;
doc["wifi_ssid"] = cfg.wifi_ssid;
@@ -74,19 +87,116 @@ bool saveConfig() {
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;
}
// ────────────────────────────────────────────
// UART to ESP8266 (HardwareSerial2)
// ────────────────────────────────────────────
// ESP32 UART2: RX=GPIO16, TX=GPIO17
// Connect: ESP32 RX(16) ← ESP8266 TX
// ESP32 TX(17) → ESP8266 RX
// 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;
}
#define UART_ESP8266 Serial2
// ────────────────────────────────────────────
// 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;
@@ -149,6 +259,34 @@ void mqttCallback(char* topic, byte* payload, unsigned int len) {
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) {
@@ -173,27 +311,86 @@ bool connectMQTT() {
Serial.println("[MQTT] Connected");
// Subscribe to commands (if registered)
if (cfg.camera_id.length() > 0) {
mqtt.subscribe(mqttTopic("command").c_str(), 2);
// Option B: self-assigned, stable camera_id derived from the device id.
if (cfg.camera_id.length() == 0) {
cfg.camera_id = clientID(); // e.g. "rig-86d978"
}
// Announce if new
if (cfg.camera_id.length() == 0) {
// Subscribe to our command topic.
mqtt.subscribe(mqttTopic("command").c_str(), 2);
// Announce (retained) on the contract topic so the hub registers/tracks us.
{
JsonDocument doc;
doc["mac_address"] = WiFi.macAddress();
doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge";
doc["friendly_name"] = "Cam-" + clientID();
doc["firmware_version"] = "0.4.0-esp32-mqtt-bridge";
doc["friendly_name"] = "Cam-" + cfg.camera_id;
JsonArray caps = doc["capabilities"].to<JsonArray>();
caps.add("start_stop"); caps.add("status");
String payload; serializeJson(doc, payload);
mqtt.publish("remoterig/cameras/announce-" + clientID(), payload.c_str(), true);
Serial.println("[MQTT] Announced for registration");
mqtt.publish(mqttTopic("announce").c_str(), payload.c_str(), true);
Serial.printf("[MQTT] Announced as %s\n", cfg.camera_id.c_str());
}
return true;
}
// ────────────────────────────────────────────
// 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
// ────────────────────────────────────────────
@@ -204,14 +401,15 @@ void setup() {
Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0");
bootMs = millis();
pinMode(2, OUTPUT); // built-in LED
digitalWrite(2, LOW);
rgbInit(); // RGB STAT LED — blue during boot
displayInit(); // I2C scan + OLED splash
loadConfig();
// UART to ESP8266
UART_ESP8266.begin(115200, SERIAL_8N1, 16, 17); // RX=16, TX=17
Serial.println("[UART] ESP8266 link on RX16/TX17 @ 115200");
// 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());
@@ -244,6 +442,10 @@ void loop() {
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(); }
@@ -275,18 +477,24 @@ void loop() {
// Relay camera status to MQTT hub
lastStatusMs = now;
bool online = doc["online"] | false;
if (online != cameraOnline) {
cameraOnline = online;
digitalWrite(2, online ? HIGH : LOW);
}
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;
mqttDoc["timestamp"] = millis();
mqttDoc["battery_raw"] = doc["battery_raw"] | 0;
// 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;
@@ -297,13 +505,13 @@ void loop() {
}
}
else if (type == "ack") {
Serial.printf("[UART] ESP8266 ack: %s\n", (doc["cmd"] | "?").c_str());
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"] | "?").c_str());
Serial.printf("[UART] ESP8266 error: %s\n", doc["msg"] | "?");
}
}
+72 -52
View File
@@ -16,17 +16,23 @@
* ESP32 ESP8266: {"type":"cmd","command":"start_recording"}\n
*
* Hardware:
* - ESP8266 D1 Mini (or NodeMCU)
* - UART TX ESP32 RX (GPIO 16)
* - UART RX ESP32 TX (GPIO 16)
* - 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
* - LiPo 3.3V buck VIN on both 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 <HTTPClient.h>
#include <ESP8266HTTPClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
@@ -35,9 +41,11 @@
// ────────────────────────────────────────────
struct Config {
String camera_ssid = "GOPRO-BP-";
String camera_password = "goprohero";
String camera_ip = "10.5.5.1";
// Defaults validated against a GoPro Hero 3 Silver. Per-camera values can
// be overridden at runtime via the set_config command (no reflash).
String camera_ssid = "goprosilver-1";
String camera_password = "Bzyeatn421";
String camera_ip = "10.5.5.9"; // Hero 3 HTTP API host (not .1)
int poll_interval_sec = 30;
} cfg;
@@ -58,6 +66,22 @@ bool loadConfig() {
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)
// ────────────────────────────────────────────
@@ -73,27 +97,38 @@ struct CamStatus {
CamStatus fetchStatus() {
CamStatus s;
String url = "http://" + cfg.camera_ip +
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
// READ status — must NOT be the shutter endpoint. Hero 3 status blob
// (validated on a Hero 3 Silver, ~31 bytes):
// [29] recording flag (0 idle / 1 recording) — confirmed
// [19] battery level (raw; drains with charge) — calibrate on the hub
// [25..26] video-remaining (provisional)
// The body is binary and starts with 0x00, so read the stream directly —
// Arduino String truncates at the first null byte.
String url = "http://" + cfg.camera_ip + "/camera/se?t=" + cfg.camera_password;
HTTPClient http;
http.useHTTP10(true);
http.begin(goproClient, url);
http.setTimeout(5000);
int code = http.GET();
if (code != 200) { http.end(); return s; }
String raw = http.getString();
uint8_t buf[40] = {0};
WiFiClient* stream = http.getStreamPtr();
size_t n = 0;
unsigned long t0 = millis();
while (n < sizeof(buf) && millis() - t0 < 1500) {
if (stream && stream->available()) buf[n++] = (uint8_t)stream->read();
else delay(5);
}
http.end();
if (raw.length() < 58) return s;
if (n < 30) return s;
const uint8_t* buf = (const uint8_t*)raw.c_str();
s.valid = true;
s.valid = true;
s.recording = (buf[29] == 1);
s.battery_raw = buf[19];
s.video_remaining_sec = buf[25] | (buf[26] << 8);
s.recording = (buf[29] == 1);
s.battery_raw = buf[57];
return s;
}
@@ -173,6 +208,21 @@ void handleCommand(const JsonDocument& doc) {
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);
}
@@ -197,26 +247,16 @@ bool readLine(String& line) {
return false;
}
// ────────────────────────────────────────────
// LED
// ────────────────────────────────────────────
const int LED = LED_BUILTIN; // active-low on ESP8266 D1 Mini
void ledOn() { digitalWrite(LED, LOW); }
void ledOff() { digitalWrite(LED, HIGH); }
// ────────────────────────────────────────────
// 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] ESP8266 Camera Bridge v1.0");
pinMode(LED, OUTPUT);
ledOff();
Serial.println("\n[BRIDGE] ESP-01S Camera Bridge v1.0");
loadConfig();
@@ -232,7 +272,6 @@ void setup() {
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
ledOn(); // Solid = connected
} else {
Serial.println("\n[WIFI] FAILED — will retry in loop");
}
@@ -246,7 +285,6 @@ void loop() {
unsigned long now = millis();
static unsigned long lastPoll = 0;
static unsigned long lastWiFiRetry = 0;
static bool cameraOnline = false;
// ── Wi-Fi reconnection ──
if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) {
@@ -261,15 +299,6 @@ void loop() {
if (WiFi.status() == WL_CONNECTED) {
CamStatus s = fetchStatus();
if (s.valid && !cameraOnline) {
cameraOnline = true;
ledOn();
} else if (!s.valid && cameraOnline) {
cameraOnline = false;
ledOff();
}
sendStatus(s);
} else {
// Offline — send empty status so ESP32 knows we're alive but camera is down
@@ -291,13 +320,4 @@ void loop() {
// Ignore other message types — they're for the ESP32
}
}
// ── LED blink when offline ──
if (!cameraOnline) {
static unsigned long lastBlink = 0;
if (now - lastBlink > 500) {
lastBlink = now;
digitalWrite(LED, !digitalRead(LED));
}
}
}
+3
View File
@@ -12,9 +12,12 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // 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/ncruces/go-strftime v1.0.0 // 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
modernc.org/libc v1.72.3 // 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/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/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/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/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/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+115 -15
View File
@@ -2,30 +2,126 @@
> Living queue for 3D-printed / physical hardware design work.
## Active / Ready for prototype print
## 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:** STL generated and validated watertight.
**Status:** Historical design reference. In this checkout, previous v3 SCAD/STL files are not present; v4 starts a new `hardware/case/` CAD source set.
**Files:**
- `hardware/case/tripod-case-v3.scad`
- `hardware/case/case-body-v3.stl`
- `hardware/case/case-lid-v3.stl`
- `hardware/case/tripod-clamp-v3.stl`
- `hardware/case/full-case-preview-v3.stl`
**Previous design notes:**
**Design notes:**
- Holds ESP32 + ESP8266 stack.
- 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 uses M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
- Clamp used M3 hardware: one M3 screw across the clamp mouth, with an M3 nut trap.
**Prototype questions:**
- Does the clamp close enough on smaller tripod legs, or do we need swappable inserts?
- Does the dovetail hold under vibration without a retention screw?
- Are USB/LED/UART cutouts in the correct orientation for the actual boards?
**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
@@ -36,6 +132,7 @@
**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
@@ -43,12 +140,14 @@
- 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.
@@ -60,6 +159,7 @@
- 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.
+155 -74
View File
@@ -1,115 +1,196 @@
# RemoteRig — Camera Node Hardware Design
> **Version:** 0.2.0 | **Status:** Draft
> **Target:** GoPro Hero 3 Black/Silver + ESP-01S/ESP8266 + ESP32-C3 Super Mini + USB power bank
> **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 case that clips to the tripod/stand. The case **does not attach to the camera** — only to the stand. Powered by a standard USB power bank.
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 │── USB ──→ GoPro (power only)
│ (off-the-shelf)│── USB ──→ ESP32 + ESP8266 (shared)
└────────────────┘
┌────────┴────────┐
│ Tripod Case │ ← clips to stand leg
┌────────────┐ │
│ ESP8266 │ │ ← Wi-Fi → GoPro AP (10.5.5.1)
│ │ (camera) │ │
├────────────┤ │ ← UART between boards
│ │ ESP32 │ │ ← Wi-Fi → Travel Router
│ │ (MQTT) │ │
│ └────────────
└─────────────────┘
│ 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 | ~$5 | MQTT bridge — talks to hub; board footprint 22.5 × 18.0mm |
| ESP-01S / ESP8266 | 1 | ~$3 | Camera bridge — talks to GoPro; module envelope 24.7 × 14.3 × 12.0mm |
| USB power bank (5000mAh+) | 1 | ~$10 | Powers both boards + GoPro |
| Micro-USB cable (short) | 2 | ~$2 | Power bank → boards + GoPro |
| Jumper wires F-F | 3 | ~$0.25 | UART TX/RX/GND between boards |
| PETG filament | ~25g | ~$0.50 | 3D printed case |
| Velcro strap (small) | 1 | ~$0.25 | Secure power bank to stand |
| 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:** ~$21 (+ GoPro already owned)
**Total per node:** roughly ~$25$35 plus GoPro and power bank, depending on display/switch choice.
## 3D Printed Case
**Current source:** `hardware/case/tripod-case-v3.scad`
**Current source:** `hardware/case/camera-node-case-v4.scad`
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
Four exported prototype files:
1. **Case body** — holds both boards side-by-side with extra wiring/service clearance, cable ports, rear mounting boss
2. **Case lid** — screw-on cover with ventilation and underside locating lip for flush seating
3. **Tripod clamp** — separate screw-tightened C-clamp sized around a 35mm stand/pole
4. **Full preview** — combined visualization STL only, not intended as the print job
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 clamp flex
- **Layer:** 0.2mm | **Infill:** 20% gyroid minimum; 35%+ recommended for clamp
- **Supports:** Likely yes for clamp ears / dovetail overhangs depending on slicer orientation
- **Post-processing:** M3x8mm screws for lid (4x), one M3 screw + M3 nut for clamp tightening
- **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-A → Micro-USB cable → ESP32 USB port
(powers ESP32, shared 5V rail)
├── USB-A → Micro-USB cable → GoPro USB port
│ (power only — no data)
└── (ESP8266 powered via ESP32 3.3V pin, or via shared USB)
── 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 (inside case):
ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16)
ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17)
ESP8266 GND ─────────── ESP32 GND
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:** Both boards can be powered from a single USB cable if the ESP32's VIN/5V pin is bridged to the ESP8266's VIN. Alternatively, use a USB Y-splitter cable.
**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
```
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP8266 (camera bridge)
UART │ (inside case)
Travel Router ──(AP)─────────────────→ ESP32 (MQTT bridge)
(10.60.1.1)
MQTT │
Pi Hub (10.60.1.56)
```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 and GoPro talk over Wi-Fi**no data cable between them**. The only cable to the GoPro is USB power from the battery pack.
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. **Clip case** to tripod leg
3. **Connect power bank** via USB to case + GoPro
4. **Power on** — ESP32 auto-connects to travel router, ESP8266 auto-connects to GoPro
5. **Monitor** from `http://10.60.1.56:8080`
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
| | W × D × H (mm) |
Prototype v4 nominal CAD dimensions:
| Part / feature | W × D × H (mm) |
|---|---|
| Board envelope basis | ESP32-C3 Super Mini: 22.5 × 18.0; ESP-01S: 24.7 × 14.3 × 12.0 |
| Internal CAD allowance | ~71.2 × 34.0 × 22.0; intentionally includes wiring gutters and vertical connector clearance |
| Case body external | ~76.0 × 42.6 × 26.0 including rear mount boss depth; main shell ~76.0 × 38.8 × 26.0 |
| Lid external | ~76.0 × 38.8 × 3.6; includes 1.6mm underside locating lip |
| Tripod clamp | ~43.0 × 53.5 × 16.0 |
| Clamp-to-case mount | Two side-by-side M3 screws through flat mounting plate |
| Clamp pole fit | Nominal 35mm; smaller poles TBD / may need inserts |
| Total weight | TBD after prototype print |
| 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();
}
Binary file not shown.
Binary file not shown.
-2
View File
@@ -1,2 +0,0 @@
include <tripod-case-v3.scad>;
render(convexity=10) case_body();
-2
View File
@@ -1,2 +0,0 @@
include <tripod-case-v3.scad>;
render(convexity=10) case_lid();
@@ -1,2 +0,0 @@
include <tripod-case-v3.scad>;
render(convexity=10) full_case();
@@ -1,2 +0,0 @@
include <tripod-case-v3.scad>;
render(convexity=10) tripod_clamp();
Binary file not shown.
-258
View File
@@ -1,258 +0,0 @@
// RemoteRig Dual-ESP Tripod Case v3
// v3e changes: board-specific envelope for ESP32-C3 Super Mini + ESP-01S with wiring clearance.
// Coordinate system: all case/lid geometry uses bottom-origin Z.
$fn = 36;
// Board dimensions from selected modules.
// ESP32-C3 Super Mini: 22.5 x 18.0 mm footprint.
// ESP-01S / ESP8266: 24.7 x 14.3 x 12.0 mm envelope.
// The case is intentionally larger than board footprints because the field
// build needs room for Dupont/UART wiring, power leads, bend radius, and fingers.
esp32c3_w = 22.5; esp32c3_d = 18.0; esp32c3_h = 6.0; // height allowance includes headers/pins TBD
esp01s_w = 24.7; esp01s_d = 14.3; esp01s_h = 12.0;
board_gap = 8.0; // side-by-side service gap between boards
wire_x = 8.0; // wiring gutter at left/right ends
wire_y = 8.0; // wiring gutter along front/back edges
wire_z = 10.0; // vertical wiring/connector clearance above tallest module
inner_w = esp32c3_w + esp01s_w + board_gap + wire_x*2;
inner_d = max(esp32c3_d, esp01s_d) + wire_y*2;
inner_h = max(esp32c3_h, esp01s_h) + wire_z;
// Case parameters
wall = 2.0;
tol = 0.4;
outer_w = inner_w + wall*2 + tol*2; // 76.0mm with current board/wiring envelope
outer_d = inner_d + wall*2 + tol*2; // 38.8mm with current board/wiring envelope
outer_h = inner_h + wall*2; // 26.0mm with current board/wiring envelope
corner_r = 2.5;
// Lid fit parameters
lid_top_thick = 2.0;
lid_lip_h = 1.6;
lid_clearance = 0.6; // clearance around underside locating lip
lid_lip_wall = 1.2; // thickness of perimeter lip/frame
// Tripod clamp parameters
pole_dia = 35; // nominal stand/pole diameter
clamp_thick = 4.0; // ring wall thickness
clamp_width = 16.0; // extrusion width along Z
mouth_width = 13.0; // clamp opening
m3_clearance = 3.4; // M3 screw clearance
nut_flat = 6.4; // M3 nut trap flat-to-flat
// Case clamp interface
// v3b removes the dovetail: use a flat two-screw mounting plate instead.
// This is simpler to print, easier to inspect, and field-serviceable.
mount_plate_w = 24.0;
mount_plate_h = 16.0;
mount_plate_thick = 4.0;
mount_hole_spacing = 14.0; // side-by-side M3 case-mount screws
mount_screw_clear = 3.4; // M3 clearance through clamp plate
mount_case_pilot = 2.7; // pilot/insert hole through case boss
mount_boss_r = 1.2;
// Cable ports
usb_port_w = 12; usb_port_h = 6;
uart_port_w = 6; uart_port_h = 4;
// Uncomment one for manual OpenSCAD use
// full_case();
// case_body();
// case_lid();
// tripod_clamp();
module rounded_cube_centered(w, d, h, r) {
hull() {
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
sphere(r=r, $fn=24);
}
}
}
module rounded_cube0(w, d, h, r) {
translate([0, 0, h/2]) rounded_cube_centered(w, d, h, r);
}
module hex_prism(d, h) {
cylinder(d=d, h=h, center=true, $fn=6);
}
module dovetail_prism(length_z, front_w, back_w, depth) {
// 2D profile is X/Y, extruded along Z.
rotate([0, 0, 0])
linear_extrude(height=length_z, center=true, convexity=10)
polygon(points=[
[-front_w/2, 0], [front_w/2, 0],
[back_w/2, depth], [-back_w/2, depth]
]);
}
module case_shell() {
difference() {
rounded_cube0(outer_w, outer_d, outer_h, corner_r);
// Open internal cavity: starts above bottom wall, extends past top.
translate([0, 0, wall])
rounded_cube0(inner_w + tol, inner_d + tol, outer_h + 2, 1.6);
// USB power IN / OUT ports through front/back walls.
translate([0, outer_d/2 + 0.1, wall + 4])
cube([usb_port_w, wall*3, usb_port_h], center=true);
translate([0, -outer_d/2 - 0.1, wall + 4])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// UART side channel.
translate([outer_w/2 + 0.1, 0, wall + 6])
cube([wall*3, uart_port_w, uart_port_h], center=true);
// LED viewing window on front lower wall.
translate([-outer_w/4, -outer_d/2 - 0.1, wall + 2])
cube([6, wall*2, 3], center=true);
}
}
module screw_post(x, y) {
difference() {
translate([x, y, wall]) cylinder(d=5.0, h=outer_h-wall-0.5, center=false, $fn=24);
translate([x, y, wall-0.5]) cylinder(d=2.1, h=outer_h+1, center=false, $fn=20);
}
}
module case_mount_boss() {
// Flat rear boss on the case. The clamp plate bolts directly to this face.
// Holes run front/back (Y axis) for M3 screws, heat-set inserts, or nuts.
boss_y = outer_d/2 + mount_plate_thick/2 - 0.2;
difference() {
translate([0, boss_y, outer_h/2])
rounded_cube_centered(mount_plate_w, mount_plate_thick, mount_plate_h, mount_boss_r);
for (xoff = [-mount_hole_spacing/2, mount_hole_spacing/2]) {
translate([xoff, outer_d/2 + mount_plate_thick/2, outer_h/2])
rotate([90, 0, 0])
cylinder(d=mount_case_pilot, h=mount_plate_thick + wall*3, center=true, $fn=24);
}
}
}
module case_body() {
union() {
case_shell();
for (x = [-1, 1], y = [-1, 1])
screw_post(x*(outer_w/2 - 5), y*(outer_d/2 - 5));
case_mount_boss();
}
}
module lid_locating_lip() {
// Thin underside frame that drops into the case opening. This registers the
// lid so it cannot skate/rock on the rim, while staying clear of screw posts.
lip_outer_w = inner_w + tol - lid_clearance;
lip_outer_d = inner_d + tol - lid_clearance;
lip_inner_w = lip_outer_w - lid_lip_wall*2;
lip_inner_d = lip_outer_d - lid_lip_wall*2;
translate([0, 0, -lid_lip_h])
difference() {
rounded_cube0(lip_outer_w, lip_outer_d, lid_lip_h, 1.0);
translate([0, 0, -0.1])
rounded_cube0(lip_inner_w, lip_inner_d, lid_lip_h + 0.2, 0.7);
// Corner reliefs so the lip doesn't interfere with screw posts.
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), lid_lip_h/2])
cylinder(d=7.0, h=lid_lip_h + 0.4, center=true, $fn=24);
}
}
}
module case_lid() {
difference() {
union() {
// Thinner top cover; underside lip handles registration.
rounded_cube0(outer_w, outer_d, lid_top_thick, 0.8);
lid_locating_lip();
}
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), -lid_lip_h - 0.5])
cylinder(d=2.4, h=lid_top_thick + lid_lip_h + 1, center=false, $fn=20);
}
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, lid_top_thick/2])
cube([8, outer_d*0.6, lid_top_thick*3], center=true);
}
}
}
module clamp_ring_with_mouth() {
outer_r = pole_dia/2 + clamp_thick;
difference() {
cylinder(r=outer_r, h=clamp_width, center=true, $fn=72);
cylinder(r=pole_dia/2 + tol, h=clamp_width + 1, center=true, $fn=72);
// Mouth opens toward +Y. Width is intentionally generous for snap-on placement before tightening.
translate([0, outer_r, 0])
cube([mouth_width, outer_r*2, clamp_width + 2], center=true);
}
}
module clamp_ears() {
outer_r = pole_dia/2 + clamp_thick;
ear_y = outer_r + 2.2;
ear_z = 0;
difference() {
union() {
translate([-mouth_width/2 - 3.2, ear_y, ear_z])
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
translate([ mouth_width/2 + 3.2, ear_y, ear_z])
rounded_cube_centered(7.0, 9.0, clamp_width, 1.4);
}
// M3 screw passes across the mouth along X.
translate([0, ear_y, ear_z])
rotate([0, 90, 0]) cylinder(d=m3_clearance, h=mouth_width + 24, center=true, $fn=24);
// Nut trap on the right ear.
translate([mouth_width/2 + 3.2, ear_y, ear_z])
rotate([0, 90, 0]) hex_prism(nut_flat, 4.2);
}
}
module clamp_mount_plate() {
outer_r = pole_dia/2 + clamp_thick;
plate_y = -outer_r - mount_plate_thick/2 + 0.2;
// Flat plate matching the case boss. Two M3 clearance holes pass through
// along Y so the clamp bolts to the case with ordinary hardware.
difference() {
translate([0, plate_y, 0])
rounded_cube_centered(mount_plate_w, mount_plate_thick, mount_plate_h, mount_boss_r);
for (xoff = [-mount_hole_spacing/2, mount_hole_spacing/2]) {
translate([xoff, plate_y, 0])
rotate([90, 0, 0])
cylinder(d=mount_screw_clear, h=mount_plate_thick + 2, center=true, $fn=24);
}
}
}
module tripod_clamp() {
union() {
clamp_ring_with_mouth();
clamp_ears();
clamp_mount_plate();
}
}
// Backward-compatible alias for earlier export scripts.
module tripod_clip() {
tripod_clamp();
}
module full_case() {
case_body();
translate([0, 0, outer_h]) case_lid();
translate([0, outer_d/2 + pole_dia/2 + clamp_thick + 8, outer_h/2])
rotate([90, 0, 0]) tripod_clamp();
}
-201
View File
@@ -1,201 +0,0 @@
// RemoteRig Dual-ESP Tripod Case
// =================================
// Small box that clips onto a tripod leg or light stand pole.
// Holds ESP8266 D1 Mini + ESP32 Dev Board (stacked).
// Powered by standard USB battery pack. No camera sleeve needed.
//
// Print settings:
// Material: PETG | Layer: 0.2mm | Infill: 20% gyroid
// Supports: yes (for clip overhang) | Brim: 5mm
// Board dimensions
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
esp32_w = 52; esp32_d = 28; esp32_h = 5;
board_gap = 3; // air gap between stacked boards
stack_h = esp8266_h + esp32_h + board_gap;
inner_w = max(esp8266_w, esp32_w);
inner_d = max(esp8266_d, esp32_d);
inner_h = stack_h + 2;
// Case parameters
wall = 2.0;
tol = 0.4;
outer_w = inner_w + wall*2 + tol*2;
outer_d = inner_d + wall*2 + tol*2;
outer_h = inner_h + wall*2;
// Tripod clip parameters
pole_min_dia = 20; // smallest pole
pole_max_dia = 35; // largest pole
clip_width = 12; // clip width
clip_thick = 3; // clip arm thickness
clip_grip = 2; // grip ridges
// Cable ports
usb_port_w = 12; usb_port_h = 6;
uart_port_w = 6; uart_port_h = 4;
//
// MAIN render the full case
//
// Uncomment to render individual parts:
full_case();
// case_body();
// case_lid();
// tripod_clip();
module full_case() {
case_body();
// Lid positioned above (for visualization)
translate([0, 0, outer_h + 2])
case_lid();
// Clip on the back
translate([0, outer_d/2 + pole_max_dia/2 + clip_thick, outer_h/2])
tripod_clip();
}
//
// Case Body holds both boards, cable ports
//
module case_body() {
difference() {
// Outer shell
rounded_cube(outer_w, outer_d, outer_h, 3);
// Inner cavity
translate([0, 0, wall])
rounded_cube(inner_w + tol, inner_d + tol, inner_h + tol, 2);
// Board recesses
// Bottom: ESP32 (larger board)
translate([0, 0, wall + 1])
cube([esp32_w + tol, esp32_d + tol, esp32_h + 1], center=true);
// Top: ESP8266 (smaller board)
translate([0, 0, wall + esp32_h + board_gap + 1])
cube([esp8266_w + tol, esp8266_d + tol, esp8266_h + 1], center=true);
// Cable ports
// USB power IN (from battery pack ESP32)
translate([0, outer_d/2, outer_h/3])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// USB power OUT (from battery pack GoPro)
translate([0, -outer_d/2, outer_h/3])
cube([usb_port_w, wall*3, usb_port_h], center=true);
// UART wire channel (ESP8266 ESP32 internal)
translate([outer_w/2, 0, outer_h/2])
cube([wall*3, uart_port_w, uart_port_h], center=true);
// Ventilation slots (top edge)
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, outer_h - wall])
cube([8, outer_d*0.6, 2], center=true);
}
// Screw posts for lid
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), outer_h/2])
cylinder(d=3.2, h=outer_h, center=true, $fn=16);
}
// LED window (thin spot to see board LEDs)
translate([-outer_w/4, -outer_d/2, wall])
cube([6, 1, 3], center=true);
}
// Tripod clip mount (rail on back)
translate([0, outer_d/2, outer_h/2])
rotate([90, 0, 0])
difference() {
cube([clip_width + 4, outer_h*0.7, 6], center=true);
// T-slot for clip to slide in
cube([clip_width + 1, outer_h*0.7 + 1, 4], center=true);
}
}
//
// Case Lid snap-fit or screw-on cover
//
module case_lid() {
difference() {
rounded_cube(outer_w, outer_d, wall*2, 2);
// Screw holes (match body posts)
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), 0])
cylinder(d=3.2, h=wall*3, center=true, $fn=16);
}
// Ventilation slots (match body)
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, 0])
cube([8, outer_d*0.6, 3], center=true);
}
}
}
//
// Tripod Clip C-clamp for pole mounting
//
module tripod_clip() {
difference() {
union() {
// Main body
hull() {
translate([0, -pole_max_dia/2 - clip_thick, 0])
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
translate([0, pole_max_dia/2 + clip_thick, 0])
cube([clip_width, clip_thick*2, outer_h*0.7], center=true);
}
// Top arm (flexible)
translate([0, -pole_max_dia/2 - clip_thick, outer_h*0.35])
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
// Bottom arm
translate([0, -pole_max_dia/2 - clip_thick, -outer_h*0.35])
cube([clip_width, pole_max_dia + clip_thick*4, clip_thick], center=true);
// Mounting tab (slides into case rail)
translate([0, -pole_max_dia/2 - clip_thick*3, 0])
cube([clip_width + 1, clip_thick*2, outer_h*0.7], center=true);
}
// Pole hole
cylinder(d=pole_max_dia + 2, h=outer_h*1.5, center=true, $fn=32);
// Grip ridges on inner surface
for (z = [-outer_h*0.25, 0, outer_h*0.25]) {
translate([0, 0, z])
rotate_extrude(angle=180, $fn=32)
translate([pole_max_dia/2 + 0.5, 0])
circle(d=1);
}
// Entry slot (pole slides in from front)
translate([0, pole_max_dia/2 + clip_thick, 0])
cube([clip_width + 2, pole_max_dia + 10, outer_h*0.7], center=true);
}
}
//
// Utility: rounded cube
//
module rounded_cube(w, d, h, r) {
hull() {
for (x = [-1, 1], y = [-1, 1], z = [-1, 1]) {
translate([x*(w/2 - r), y*(d/2 - r), z*(h/2 - r)])
sphere(r=r, $fn=20);
}
}
}
Binary file not shown.
-274
View File
@@ -1,274 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteRig Case — 3D Viewer</title>
<style>
body { margin: 0; overflow: hidden; background: #1a1a2e; font-family: system-ui; }
canvas { display: block; }
#info {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
color: #888; font-size: 13px; pointer-events: none;
}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ── Scene setup ──
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
scene.fog = new THREE.Fog(0x1a1a2e, 8, 25);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.5, 50);
camera.position.set(5, 3.5, 7);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ── Lighting ──
const ambient = new THREE.AmbientLight(0x404060, 0.6);
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 1.2);
key.position.set(8, 10, 5);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 0.5; key.shadow.camera.far = 50;
key.shadow.camera.left = -10; key.shadow.camera.right = 10;
key.shadow.camera.top = 10; key.shadow.camera.bottom = -10;
scene.add(key);
const fill = new THREE.DirectionalLight(0x8899cc, 0.4);
fill.position.set(-3, 2, -2);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xaaccff, 0.5);
rim.position.set(0, 1, -5);
scene.add(rim);
// ── Ground ──
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.8 })
);
ground.rotation.x = -Math.PI/2;
ground.position.y = -3;
ground.receiveShadow = true;
scene.add(ground);
// ── Materials ──
const petgMat = new THREE.MeshStandardMaterial({
color: 0x3d3d4a, roughness: 0.35, metalness: 0.1,
});
const accentMat = new THREE.MeshStandardMaterial({
color: 0xf59e0b, roughness: 0.3, metalness: 0.2, emissive: 0x331100, emissiveIntensity: 0.3
});
const boardMat = new THREE.MeshStandardMaterial({
color: 0x1a6630, roughness: 0.6
});
const metalMat = new THREE.MeshStandardMaterial({
color: 0x888899, roughness: 0.3, metalness: 0.8
});
// ── Create rounded box with bevel ──
function createRoundedBox(w, h, d, r, segments = 3) {
const shape = new THREE.Shape();
const hw = w/2 - r, hh = h/2 - r;
shape.moveTo(-hw, -hh + r);
shape.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
shape.lineTo(hw - r, -hh);
shape.quadraticCurveTo(hw, -hh, hw, -hh + r);
shape.lineTo(hw, hh - r);
shape.quadraticCurveTo(hw, hh, hw - r, hh);
shape.lineTo(-hw + r, hh);
shape.quadraticCurveTo(-hw, hh, -hw, hh - r);
shape.closePath();
const extrudeSettings = { depth: d - r*2, bevelEnabled: true, bevelThickness: r, bevelSize: r, bevelSegments: segments };
const geom = new THREE.ExtrudeGeometry(shape, extrudeSettings);
geom.translate(0, 0, -d/2 + r);
return geom;
}
// ── Case Body ──
const caseW = 2.5, caseH = 1.5, caseD = 1.1;
const bodyGeom = createRoundedBox(caseW, caseD, caseH, 0.12);
const body = new THREE.Mesh(bodyGeom, petgMat);
body.castShadow = true; body.receiveShadow = true;
scene.add(body);
// ── Lid (slightly offset) ──
const lidGeom = createRoundedBox(caseW, caseD, 0.15, 0.08);
const lid = new THREE.Mesh(lidGeom, petgMat);
lid.position.y = caseH/2 + 0.07;
lid.castShadow = true;
scene.add(lid);
// ── Ventilation slots ──
for (let i = -0.6; i <= 0.6; i += 0.6) {
const slot = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.04, caseD * 0.7),
new THREE.MeshStandardMaterial({ color: 0x1a1a2e })
);
slot.position.set(i, caseH/2 + 0.15, 0);
scene.add(slot);
}
// ── Screws ──
for (let x = -1; x <= 1; x += 2) {
for (let z = -0.35; z <= 0.35; z += 0.7) {
const screw = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.05, 0.04, 8),
metalMat
);
screw.position.set(x * (caseW/2 - 0.2), caseH/2 + 0.15, z);
scene.add(screw);
}
}
// ── Boards inside (semi-visible) ──
const esp32Board = new THREE.Mesh(
new THREE.BoxGeometry(caseW - 0.3, 0.04, caseD - 0.2),
boardMat
);
esp32Board.position.set(0, caseH/2 - 0.15, 0);
esp32Board.castShadow = true;
scene.add(esp32Board);
const esp8266Board = new THREE.Mesh(
new THREE.BoxGeometry(caseW - 0.5, 0.04, caseD - 0.3),
boardMat
);
esp8266Board.position.set(0, caseH/2 - 0.08, 0);
esp8266Board.castShadow = true;
scene.add(esp8266Board);
// Chip on ESP32
const chip = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.03, 0.3),
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
);
chip.position.set(0, caseH/2 - 0.12, 0);
scene.add(chip);
// LED
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.03, 8, 8),
new THREE.MeshStandardMaterial({ color: 0x00ff44, roughness: 0.2, emissive: 0x00ff44, emissiveIntensity: 1.5 })
);
led.position.set(-0.8, caseH/2 - 0.12, -0.3);
scene.add(led);
// ── USB Port (front face) ──
const usbPort = new THREE.Mesh(
new THREE.BoxGeometry(0.35, 0.02, 0.15),
new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.2 })
);
usbPort.position.set(0, 0.2, caseD/2);
scene.add(usbPort);
// ── Tripod Clip ──
const clipGroup = new THREE.Group();
clipGroup.position.set(0, 0, -caseD/2 - 0.7);
// Clip arms
for (let y = -0.4; y <= 0.4; y += 0.8) {
const arm = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.08, 0.8),
petgMat
);
arm.position.set(0, y, 0.3);
arm.castShadow = true;
clipGroup.add(arm);
}
// Clip body
const clipBody = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 1.0, 0.15),
petgMat
);
clipBody.position.set(0, 0, -0.1);
clipBody.castShadow = true;
clipGroup.add(clipBody);
scene.add(clipGroup);
// ── Tripod Pole ──
const poleGeom = new THREE.CylinderGeometry(0.35, 0.35, 6, 24);
const poleMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.4, metalness: 0.3 });
const pole = new THREE.Mesh(poleGeom, poleMat);
pole.position.set(0, 0, -caseD/2 - 1.2);
pole.castShadow = true; pole.receiveShadow = true;
scene.add(pole);
// ── USB Cables ──
function createCable(start, end, color = 0x222233) {
const curve = new THREE.CubicBezierCurve3(
start,
new THREE.Vector3(start.x + 0.5, start.y - 0.5, start.z + 0.2),
new THREE.Vector3(end.x - 0.3, end.y - 0.3, end.z + 0.1),
end
);
const geom = new THREE.TubeGeometry(curve, 20, 0.03, 8, false);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6 });
return new THREE.Mesh(geom, mat);
}
const cable1 = createCable(
new THREE.Vector3(0, 0.2, caseD/2),
new THREE.Vector3(-2, -1, 1)
);
cable1.castShadow = true;
scene.add(cable1);
const cable2 = createCable(
new THREE.Vector3(0.1, 0.2, caseD/2),
new THREE.Vector3(2, -1.5, 1.2),
0x332222
);
cable2.castShadow = true;
scene.add(cable2);
// ── Interaction ──
let isDragging = false, prevMouse = { x: 0, y: 0 };
let rotY = 0.4, rotX = 0.3, zoom = 7;
document.addEventListener('mousedown', e => { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
document.addEventListener('mouseup', () => isDragging = false);
document.addEventListener('mousemove', e => {
if (!isDragging) return;
rotY += (e.clientX - prevMouse.x) * 0.005;
rotX += (e.clientY - prevMouse.y) * 0.005;
rotX = Math.max(-0.8, Math.min(1.2, rotX));
prevMouse = { x: e.clientX, y: e.clientY };
});
document.addEventListener('wheel', e => {
zoom += e.deltaY * 0.005;
zoom = Math.max(3, Math.min(15, zoom));
});
// ── Render loop ──
function animate() {
requestAnimationFrame(animate);
camera.position.x = zoom * Math.sin(rotY) * Math.cos(rotX);
camera.position.y = zoom * Math.sin(rotX);
camera.position.z = zoom * Math.cos(rotY) * Math.cos(rotX);
camera.lookAt(0, -0.1, 0);
renderer.render(scene, camera);
}
animate();
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
+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"
"log"
"net/http"
"strings"
"github.com/cubecraft/remoterig/internal/db"
"github.com/cubecraft/remoterig/pkg/models"
@@ -25,11 +26,11 @@ func ListCameras(database *db.DB) http.HandlerFunc {
c.friendly_name,
s.battery_pct,
s.video_remaining_sec,
s.recording_state,
COALESCE(s.recording_state, 0),
s.mode,
s.resolution,
s.fps,
s.online,
COALESCE(s.online, 0),
s.recorded_at
FROM cameras c
LEFT JOIN (
@@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc {
`)
if err != nil {
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
}
defer rows.Close()
@@ -51,20 +52,22 @@ func ListCameras(database *db.DB) http.HandlerFunc {
for rows.Next() {
var sl models.StatusLog
var c models.Camera
var recordedAt sql.NullTime // NULL for a camera with no status yet
if err := rows.Scan(
&c.CameraID, &c.FriendlyName,
&sl.BatteryPct, &sl.VideoRemainingSec,
&sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS,
&sl.Online, &sl.RecordedAt,
&sl.Online, &recordedAt,
); err != nil {
log.Printf("Error scanning camera row: %v", err)
continue
}
sl.RecordedAt = recordedAt.Time // zero time if no status
statuses = append(statuses, models.NewCameraStatus(c, sl))
}
if err := rows.Err(); err != nil {
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
}
@@ -84,13 +87,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
FriendlyName string `json:"friendly_name"`
MacAddress *string `json:"mac_address,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
if !decodeJSONBody(w, r, &req) {
return
}
if req.CameraID == "" || req.FriendlyName == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"})
if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) {
return
}
@@ -99,12 +99,12 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
VALUES (?, ?, ?)
`, req.CameraID, req.FriendlyName, req.MacAddress)
if err != nil {
if err.Error() == "UNIQUE constraint failed: cameras.mac_address" {
respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"})
if isUniqueConstraintErr(err) {
respondError(w, http.StatusConflict, "camera already registered", err.Error())
return
}
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
}
@@ -124,8 +124,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc {
func GetCameraDetail(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
@@ -139,12 +138,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
&c.CreatedAt, &c.UpdatedAt,
)
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
}
if err != nil {
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
}
@@ -165,7 +164,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
)
if err != nil && err != sql.ErrNoRows {
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
}
@@ -180,7 +179,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
`, cameraID)
if err != nil {
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
}
defer historyRows.Close()
@@ -203,13 +202,21 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"camera": c,
"camera": c,
"last_status": sl,
"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.
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
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"
)
// CommandPublisher sends a command to a camera (implemented by the MQTT
// subscriber). Nil is allowed (e.g. in tests) — the command is then skipped.
type CommandPublisher interface {
PublishCommand(cameraID, command string) error
}
// StartRecording returns a handler for POST /cameras/{id}/start.
func StartRecording(database *db.DB) http.HandlerFunc {
func StartRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
@@ -22,8 +27,13 @@ func StartRecording(database *db.DB) http.HandlerFunc {
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
if err != nil {
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
}
@@ -34,12 +44,21 @@ func StartRecording(database *db.DB) http.HandlerFunc {
`, cameraID)
if err != nil {
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
}
rows, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rows)
rowsAffected, _ := result.RowsAffected()
log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected)
// Send the actual command to the camera over MQTT.
if pub != nil {
if err := pub.PublishCommand(cameraID, "start_recording"); err != nil {
log.Printf("Error sending start_recording to %s: %v", cameraID, err)
respondError(w, http.StatusBadGateway, "failed to send command to camera", err.Error())
return
}
}
respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_started",
@@ -49,11 +68,10 @@ func StartRecording(database *db.DB) http.HandlerFunc {
}
// StopRecording returns a handler for POST /cameras/{id}/stop.
func StopRecording(database *db.DB) http.HandlerFunc {
func StopRecording(database *db.DB, pub CommandPublisher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
@@ -61,8 +79,13 @@ func StopRecording(database *db.DB) http.HandlerFunc {
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
if err != nil {
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
}
@@ -73,12 +96,21 @@ func StopRecording(database *db.DB) http.HandlerFunc {
`, cameraID)
if err != nil {
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
}
rows, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows)
rowsAffected, _ := result.RowsAffected()
log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected)
// Send the actual command to the camera over MQTT.
if pub != nil {
if err := pub.PublishCommand(cameraID, "stop_recording"); err != nil {
log.Printf("Error sending stop_recording to %s: %v", cameraID, err)
respondError(w, http.StatusBadGateway, "failed to send command to camera", err.Error())
return
}
}
respondJSON(w, http.StatusOK, map[string]string{
"status": "recording_stopped",
+28 -17
View File
@@ -2,7 +2,6 @@
package api
import (
"encoding/json"
"log"
"net/http"
@@ -14,24 +13,31 @@ import (
func PushStatus(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cameraID := chi.URLParam(r, "id")
if cameraID == "" {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"})
if !validateCameraID(w, cameraID) {
return
}
var req struct {
BatteryPct *int `json:"battery_pct"`
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"`
RawBatteryPct *float64 `json:"raw_battery_pct"`
Timestamp *string `json:"ts"`
BatteryPct *int `json:"battery_pct"`
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"`
RawBatteryPct *float64 `json:"raw_battery_pct"`
Timestamp *string `json:"ts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
if !decodeJSONBody(w, r, &req) {
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
}
@@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc {
var exists int
err := database.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists)
if err != nil || exists == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"})
if err != nil {
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
}
@@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc {
req.FPS, boolToInt(req.Online), req.RawBatteryPct)
if err != nil {
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
}
+75 -30
View File
@@ -4,9 +4,11 @@ package db
import (
"database/sql"
_ "embed"
"fmt"
"log"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
@@ -14,13 +16,16 @@ import (
//go:embed migrations/001_create_tables.sql
var migration001 string
//go:embed migrations/002_dedup_unique_index.sql
var migration002 string
// DB wraps the sql.DB with connection-level settings.
type DB struct {
*sql.DB
}
// 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) {
// Ensure the directory exists
dir := filepath.Dir(path)
@@ -45,34 +50,57 @@ func Open(path string) (*DB, error) {
return nil, err
}
// Check if tables already exist (idempotent migration)
var count int
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 {
// Ensure schema_version table exists for migration tracking
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
db.Close()
return nil, err
}
if count < 4 {
log.Printf("Running migrations for %s...", path)
if err := migrate(db, migration001); err != nil {
db.Close()
return nil, err
// Read current schema version (0 if table is empty)
var currentVersion int
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&currentVersion); err != nil {
db.Close()
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")
}
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 {
// Split on semicolons to handle multiple statements
statements := splitSQL(sql)
for _, stmt := range statements {
stmt = stripWhitespace(stmt)
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
@@ -83,8 +111,13 @@ func migrate(db *sql.DB, sql string) error {
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 {
// First, strip all line comments (--) to prevent them from swallowing
// subsequent SQL statements when newlines are collapsed.
sql = stripSQLLineComments(sql)
var stmts []string
var current string
inQuote := false
@@ -106,30 +139,42 @@ func splitSQL(sql string) []string {
case ';':
stmts = append(stmts, current)
current = ""
case '\r', '\n', '\t':
current += " "
default:
current += string(r)
}
}
if len(current) > 0 {
if strings.TrimSpace(current) != "" {
stmts = append(stmts, current)
}
return stmts
}
// stripWhitespace removes leading/trailing whitespace and normalizes newlines.
func stripWhitespace(s string) string {
result := ""
runningSpace := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !runningSpace {
result += " "
runningSpace = true
// stripSQLLineComments removes all -- single-line comments from SQL text.
func stripSQLLineComments(sql string) string {
var result strings.Builder
i := 0
runes := []rune(sql)
for i < len(runes) {
r := runes[i]
// 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 {
result += string(r)
runningSpace = false
// Replace comment with a newline (preserves statement boundaries)
result.WriteRune('\n')
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);
+51 -28
View File
@@ -143,6 +143,18 @@ type statusPayload struct {
UptimeSec *int `json:"uptime_sec"`
}
// PublishCommand sends a command (e.g. "start_recording") to a camera's
// command topic, which its ESP32 bridge subscribes to and forwards over UART.
func (s *Subscriber) PublishCommand(cameraID, command string) error {
topic := "remoterig/cameras/" + cameraID + "/command"
payload, _ := json.Marshal(map[string]string{"command": command})
tok := s.client.Publish(topic, 2, false, payload)
if !tok.WaitTimeout(3 * time.Second) {
return fmt.Errorf("publish to %s timed out", topic)
}
return tok.Error()
}
func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
var sp statusPayload
if err := json.Unmarshal(payload, &sp); err != nil {
@@ -151,22 +163,20 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
}
// Validate required fields
if sp.CameraID == "" || sp.Timestamp == "" {
log.Printf("MQTT status missing required fields (camera_id, timestamp) from %s", cameraID)
if sp.CameraID == "" {
log.Printf("MQTT status missing camera_id from %s", cameraID)
return
}
// Validate timestamp sanity (reject >5min future, >24h past)
// Nodes have no real clock, so tolerate an empty/invalid timestamp by
// stamping server-side. Still clamp obviously-bad supplied times below.
now := time.Now()
ts, err := time.Parse(time.RFC3339, sp.Timestamp)
if err != nil {
// Try ISO8601 without timezone
ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp)
if err != nil {
log.Printf("MQTT status invalid timestamp %q from %s", sp.Timestamp, cameraID)
return
if ts, err = time.Parse("2006-01-02T15:04:05", sp.Timestamp); err != nil {
ts = now
}
}
now := time.Now()
if ts.After(now.Add(5 * time.Minute)) {
log.Printf("MQTT status timestamp too far in future (%s) from %s — using now", ts, cameraID)
ts = now
@@ -209,6 +219,21 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
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,
@@ -269,10 +294,12 @@ func (s *Subscriber) handleStatus(cameraID string, payload []byte) {
// ── Heartbeat handler ───────────────────────────────────────────────────
type heartbeatPayload struct {
CameraID string `json:"camera_id"`
Timestamp string `json:"timestamp"`
UptimeSec *int `json:"uptime_sec"`
FreeHeap *int `json:"free_heap"`
CameraID string `json:"camera_id"`
// No Timestamp field: the node sends a numeric millis() value and the
// handler doesn't use it; omitting the field lets it be ignored instead
// of failing JSON unmarshal (number into string).
UptimeSec *int `json:"uptime_sec"`
FreeHeap *int `json:"free_heap"`
}
func (s *Subscriber) handleHeartbeat(cameraID string, payload []byte) {
@@ -347,8 +374,8 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
"SELECT camera_id FROM cameras WHERE mac_address = ?", ap.MacAddress,
).Scan(&existingID)
if err == nil {
// Already registered — just update friendly_name
if err == nil && existingID == cameraID {
// Same self-id re-connecting — just refresh friendly_name.
_, err = s.db.Exec(
"UPDATE cameras SET friendly_name = ?, updated_at = datetime('now') WHERE camera_id = ?",
ap.FriendlyName, existingID,
@@ -359,30 +386,26 @@ func (s *Subscriber) handleAnnounce(cameraID string, payload []byte) {
}
log.Printf("MQTT announce: camera %s (%s) re-connected", existingID, ap.FriendlyName)
} else {
// New camera — generate sequential cam-NNN ID
var maxID string
s.db.QueryRow("SELECT MAX(camera_id) FROM cameras").Scan(&maxID)
seq := 1
if maxID != "" {
fmt.Sscanf(maxID, "cam-%d", &seq)
seq++
// MAC known under a different id (legacy cam-NNN from before self-IDs)
// → drop the old row so we re-register under the node's self-id.
if err == nil && existingID != cameraID {
s.db.Exec("DELETE FROM cameras WHERE camera_id = ?", existingID)
log.Printf("MQTT announce: migrating %s -> %s (%s)", existingID, cameraID, ap.FriendlyName)
}
newID := fmt.Sprintf("cam-%03d", seq)
// Option B: the node self-assigns its camera_id (the announce topic id).
_, err = s.db.Exec(`
INSERT INTO cameras (camera_id, friendly_name, mac_address, created_at, updated_at)
VALUES (?, ?, ?, datetime('now'), datetime('now'))
`, newID, ap.FriendlyName, ap.MacAddress)
`, cameraID, ap.FriendlyName, ap.MacAddress)
if err != nil {
log.Printf("MQTT announce insert error for %s: %v", ap.MacAddress, err)
return
}
log.Printf("MQTT announce: new camera registered as %s (%s)", newID, ap.FriendlyName)
log.Printf("MQTT announce: new camera registered as %s (%s)", cameraID, ap.FriendlyName)
// Broadcast new camera via SSE
cam, err := getCamera(s.db, newID)
cam, err := getCamera(s.db, cameraID)
if err == nil {
s.hub.Broadcast("camera_registered", cam)
}
+6 -3
View File
@@ -9,7 +9,7 @@ import (
type Camera struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"`
MacAddress *string `json:"mac_address,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -33,8 +33,11 @@ type StatusLog struct {
type CameraStatus struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
BatteryPct *int `json:"battery_pct,omitempty"`
VideoRemainingSec *int `json:"video_remaining_sec,omitempty"`
// Not omitempty: the SPA expects these as `number | null`. Omitting them
// makes the field `undefined` in JS, which slips past null checks and
// renders as "NaN%".
BatteryPct *int `json:"battery_pct"`
VideoRemainingSec *int `json:"video_remaining_sec"`
Recording bool `json:"recording"`
Mode string `json:"mode"`
Resolution string `json:"resolution"`
+9 -4
View File
@@ -72,8 +72,12 @@ fi
# 2. Deploy new binary
# ---------------------------------------------------------------------------
info "Deploying new binary..."
cp "${BINARY}" "${DEPLOY_PATH}"
chmod +x "${DEPLOY_PATH}"
# Atomic replace: copy alongside then rename over the target. A plain
# cp over a running executable fails with "Text file busy"; rename swaps
# the directory entry and works while the old binary is still running.
cp "${BINARY}" "${DEPLOY_PATH}.new"
chmod +x "${DEPLOY_PATH}.new"
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
ok "Binary installed at ${DEPLOY_PATH}"
# ---------------------------------------------------------------------------
@@ -116,8 +120,9 @@ else
# -----------------------------------------------------------------------
if [ -f "${BACKUP}" ]; then
info "Restoring backup: ${BACKUP}"
cp "${BACKUP}" "${DEPLOY_PATH}"
chmod +x "${DEPLOY_PATH}"
cp "${BACKUP}" "${DEPLOY_PATH}.new"
chmod +x "${DEPLOY_PATH}.new"
mv -f "${DEPLOY_PATH}.new" "${DEPLOY_PATH}"
systemctl restart "${SERVICE}" 2>/dev/null || true
+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
+67 -18
View File
@@ -8,9 +8,9 @@
#
# Options:
# --config PATH Path to config.yaml template to copy to /opt/remoterig/
# --service-user USER Systemd service user (default: pi)
# --static-ip IP Static IP for wlan0 (default: 10.60.1.56/24)
# --gateway IP Gateway for wlan0 (default: 10.60.1.1)
# --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
@@ -19,9 +19,9 @@ set -euo pipefail
# Defaults
# ---------------------------------------------------------------------------
CONFIG_TEMPLATE=""
SERVICE_USER="pi"
STATIC_IP="10.60.1.56/24"
GATEWAY="10.60.1.1"
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"
@@ -204,6 +204,54 @@ else
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
# ---------------------------------------------------------------------------
@@ -317,20 +365,21 @@ 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 " Next steps:"
echo " 1. Build the remoterig binary for ARM64:"
echo " GOOS=linux GOARCH=arm64 go build -o remoterig ./cmd/server"
echo " 2. Copy binary to Pi:"
echo " scp remoterig pi@10.60.1.56:/opt/remoterig/"
echo " 3. Copy config if needed:"
echo " scp config.yaml pi@10.60.1.56:/opt/remoterig/"
echo " 4. Start the service:"
echo " sudo systemctl start remoterig"
echo " 5. Check health:"
echo " curl http://10.60.1.56:8080/health"
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 " To deploy updates, use: scripts/deploy.sh"
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 { 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() {
// Connect to SSE endpoint — auto-updates the camera store
useSSE()
const [commandBusy, setCommandBusy] = useState(false)
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.
// getCameras / getOnlineCount / getRecordingCount pull from live state.
const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore()
const cameras = getCameras()
const onlineCount = getOnlineCount()
const recordingCount = getRecordingCount()
// SSE connection + live store
const { connectionState } = useSSE()
// Seed the list once on mount via the REST API. SSE only pushes on change,
// so without this the dashboard is empty until the next status event.
useEffect(() => {
api.getCameras()
.then((list) => useCameraStore.getState().setCameras(list))
.catch(() => { /* SSE will fill in shortly */ })
}, [])
// Subscribe to full camera state — dashboard needs every change
const camerasMap = useCameraStore((s) => s.cameras)
const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap])
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 (
<div className="min-h-screen bg-rig-dark-900">
<div className="min-h-screen bg-rig-dark-900 flex flex-col">
{/* Header */}
<header className="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="flex items-center gap-3">
<Camera className="h-7 w-7 text-rig-accent" />
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
RemoteRig
</h1>
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
Dashboard
</span>
{/* 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
<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-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Camera className="h-6 w-6 shrink-0 text-rig-accent" />
<h1 className="text-lg font-bold tracking-tight text-rig-dark-50 truncate">
RemoteRig
</h1>
<span className="hidden sm:inline rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
Dashboard
</span>
</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>
</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 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 ? (
/* 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">
<span className="relative mb-4 inline-flex">
<Radio className="h-12 w-12 animate-pulse text-rig-accent" />
</span>
<Camera className="mb-4 h-12 w-12 text-rig-dark-500" />
<h2 className="text-lg font-semibold text-rig-dark-200">
Waiting for cameras&hellip;
No Cameras Connected
</h2>
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
Connect cameras to your RemoteRig server and they will appear here
automatically.
Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.
</p>
</div>
) : (
/* Camera grid */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cameras.map((camera) => (
<CameraCard key={camera.camera_id} camera={camera} />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{cameras.map((cam) => (
<CameraCard
key={cam.camera_id}
camera={cam}
onStart={handleStart}
onStop={handleStop}
onViewHistory={handleViewHistory}
disabled={commandBusy}
/>
))}
</div>
)}
</main>
{/* History modal */}
<HistoryViewer
cameraId={historyCameraId}
cameraName={historyCameraName}
onClose={handleCloseHistory}
/>
{/* 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">
<p className="text-center text-xs text-rig-dark-500">
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 { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import CameraCard from './CameraCard'
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', () => {
// ── Basic rendering ────────────────────────────────────────────────────
it('renders camera name', () => {
render(<CameraCard camera={makeCamera()} />)
renderCard()
expect(screen.getByText('Front Camera')).toBeInTheDocument()
})
it('shows resolution and FPS', () => {
render(<CameraCard camera={makeCamera()} />)
renderCard()
expect(screen.getByText(/1080p/)).toBeInTheDocument()
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
})
it('shows battery percentage', () => {
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
renderCard({ battery_pct: 85 })
expect(screen.getByText('85%')).toBeInTheDocument()
})
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()
})
// ── Battery bar colors ─────────────────────────────────────────────────
it('uses green bar for high battery (>=50%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
)
const { container } = renderCard({ battery_pct: 85 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-success')
})
it('uses yellow bar for medium battery (15-49%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
)
const { container } = renderCard({ battery_pct: 30 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('uses red bar for low battery (<15%)', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
)
const { container } = renderCard({ battery_pct: 8 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-danger')
})
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
// ── Recording state ────────────────────────────────────────────────────
it('shows REC badge when recording', () => {
render(<CameraCard camera={makeCamera({ recording: true })} />)
renderCard({ recording: true })
expect(screen.getByText('REC')).toBeInTheDocument()
})
it('shows IDLE badge when not recording', () => {
render(<CameraCard camera={makeCamera({ recording: false })} />)
renderCard({ recording: false })
expect(screen.getByText('IDLE')).toBeInTheDocument()
})
// ── Online / Offline badges ────────────────────────────────────────────
it('shows Online badge when camera is online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
renderCard({ online: true })
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('shows Offline badge when camera is offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
renderCard({ online: false })
const offlineElements = screen.getAllByText('Offline')
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
})
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
// ── Video remaining ────────────────────────────────────────────────────
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"
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
})
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
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
})
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
// ── Footer ─────────────────────────────────────────────────────────────
it('shows Live + timestamp in footer when online', () => {
render(<CameraCard camera={makeCamera({ online: true })} />)
// Footer shows "Live" when online
renderCard({ online: true })
expect(screen.getByText('Live')).toBeInTheDocument()
})
it('shows Offline + timestamp in footer when offline', () => {
render(<CameraCard camera={makeCamera({ online: false })} />)
// Footer says "Offline" (the text appears both in the badge and footer)
// When offline, the footer specifically shows "Offline" text
it('shows Offline in footer when offline', () => {
renderCard({ online: false })
const offlineElements = screen.getAllByText('Offline')
// At least one should exist (badge + footer)
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
})
it('shows "unknown" when last_seen is malformed', () => {
render(
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
)
renderCard({ last_seen: 'not-a-date' })
expect(screen.getByText('unknown')).toBeInTheDocument()
})
it('shows "unknown" when last_seen is in the future', () => {
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()
})
// ── Edge cases ──────────────────────────────────────────────────────────
it('clamps negative battery_pct to 0%', () => {
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
renderCard({ battery_pct: -5 })
expect(screen.getByText('0%')).toBeInTheDocument()
})
it('shows exact boundary: 15% battery → yellow bar', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
)
const { container } = renderCard({ battery_pct: 15 })
const bar = container.querySelector('[role="progressbar"] div')
expect(bar?.className).toContain('bg-rig-warning')
})
it('shows exact boundary: 50% battery → green bar', () => {
const { container } = render(
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
)
const { container } = renderCard({ battery_pct: 50 })
const bar = container.querySelector('[role="progressbar"] div')
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'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -23,11 +23,11 @@ function formatRelativeTime(iso: string): string {
return `${diffDay}d ago`
}
function batteryColor(pct: number | null): { bar: string; text: string } {
if (pct === null) return { bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
function batteryColor(pct: number | null): { status: 'good' | 'low' | 'critical'; bar: string; text: string } {
if (pct === null) return { status: 'critical', bar: 'bg-rig-dark-600', text: 'text-rig-dark-400' }
if (pct >= 50) return { status: 'good', bar: 'bg-rig-success', text: 'text-rig-success' }
if (pct >= 15) return { status: 'low', bar: 'bg-rig-warning', text: 'text-rig-warning' }
return { status: 'critical', bar: 'bg-rig-danger', text: 'text-rig-danger' }
}
function formatTimeLeft(sec: number): string {
@@ -37,14 +37,33 @@ function formatTimeLeft(sec: number): string {
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 ──────────────────────────────────────────────────────────────
interface CameraCardProps {
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 {
camera_id,
friendly_name,
online,
resolution,
@@ -57,21 +76,23 @@ export default function CameraCard({ camera }: CameraCardProps) {
} = camera
const batt = batteryColor(battery_pct)
const status = cameraStatus(online, battery_pct)
const borderColor = STATUS_BORDER[status]
return (
<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
? 'border-rig-dark-600 hover:border-rig-accent/40'
: 'border-rig-dark-700 opacity-75'
? 'hover:border-rig-accent/40'
: 'opacity-75'
}`}
>
{/* ── Header ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
<div className="flex items-center gap-2 min-w-0">
<Video className="h-4 w-4 shrink-0 text-rig-accent" aria-hidden="true" />
<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}
>
{friendly_name}
@@ -82,7 +103,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
<span
role="status"
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
? 'bg-rig-success/15 text-rig-success'
: 'bg-rig-danger/15 text-rig-danger'
@@ -99,6 +120,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
{/* ── Body ── */}
<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 */}
<div className="flex items-center gap-1.5 text-xs text-rig-dark-300">
<Signal className="h-3.5 w-3.5" />
@@ -159,26 +183,60 @@ export default function CameraCard({ camera }: CameraCardProps) {
</div>
{/* ── 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="flex items-center gap-1.5 text-xs">
{online ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-rig-success" />
<span className="text-rig-success">Live</span>
</>
<div className="rounded-b-xl border-t border-rig-dark-700/50 bg-rig-dark-900/30">
{/* Controls row */}
<div className="flex items-center gap-1 px-3 py-2">
{recording ? (
<button
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>
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
<button
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>
{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>
{/* Status strip */}
<div className="flex items-center justify-between px-4 pb-2">
<div className="flex items-center gap-1.5 text-xs">
{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>
)}
{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>
</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 HistoryViewer } from './HistoryViewer'
+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> {
const response = await fetch(`${API_BASE}${endpoint}`, {
@@ -12,9 +12,22 @@ async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
}
export const api = {
getCameras: () => request<[]>('/cameras'),
getCameraStatus: (id: string) => request<[]>(`/cameras/${id}/status`),
getSystemHealth: () => request<[]>('/system/health'),
toggleRecording: (cameraId: string) =>
request<[]>(`/cameras/${cameraId}/recording`, { method: 'POST' }),
/** GET /api/v1/cameras — list all cameras with latest status */
getCameras: () => request<import('../types').CameraStatus[]>('/cameras'),
/** GET /api/v1/cameras/{id} — full detail + 24h history */
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
}
/** 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 {
id: string
name: string
+6
View File
@@ -5,6 +5,12 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
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: {
port: 3000,
proxy: {