generated from CubeCraft-Creations/Tracehound
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1704d8a833 | |||
| 1a8f67a392 | |||
| 4c4368a79f | |||
| 0e2e94a4cf | |||
| c5cbeabd92 | |||
| f4bf37d6a3 | |||
| 893574ee79 | |||
| b3d4226b1c | |||
| 324402f268 |
+22
-22
@@ -36,7 +36,7 @@ RemoteRig is a **multi-camera remote monitoring system**. It provides a camera g
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Travel Router (self-contained LAN) │
|
||||
│ Subnet: 192.168.4.0/24 │
|
||||
│ Subnet: 10.60.1.0/24 │
|
||||
│ DHCP pool: .100-.200 │
|
||||
└──────┬──────────┬──────────┬──────────────┘
|
||||
│ │ │
|
||||
@@ -44,32 +44,32 @@ 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 │ │ 192.168.4.10 │
|
||||
│ │ │ │ │ (static IP) │
|
||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │ │
|
||||
│ STA→Router │ │ STA→Router │ │ Mosquitto :1883 │
|
||||
│ │ │ │ │ Go API :8080 │
|
||||
│ MQTT→:1883 │ │ MQTT→:1883 │ │ React UI │
|
||||
│ DHCP addr │ │ DHCP addr │ │ 10.60.1.56 │
|
||||
│ STA→Router │ │ STA→Router │ │ (static IP) │
|
||||
│ MQTT→:1883 │ │ MQTT→:1883 │ │ Mosquitto :1883 │
|
||||
│ UART relay │ │ UART relay │ │ Go API :8080 │
|
||||
│ │ │ │ │ React UI │
|
||||
└──────┬───────┘ └──────┬───────┘ │ SQLite DB │
|
||||
│ │ └──────────────────┘
|
||||
│ UART │ UART └──────────────────┘
|
||||
▼ ▼ │
|
||||
┌──────────────┐ ┌──────────────┐ │
|
||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │ SSE /api/v1/events/stream
|
||||
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │ │
|
||||
│ Wi-Fi only │ │ Wi-Fi only │ ▼
|
||||
└──────────────┘ └──────────────┘ ┌──────────────────┐
|
||||
│ User Device │
|
||||
│ (laptop/kiosk) │
|
||||
│ http://.4.10 │
|
||||
└──────────────────┘
|
||||
│ ESP8266 #1 │ │ ESP8266 #N │ SSE /api/v1/events/stream
|
||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │
|
||||
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│ ▼
|
||||
└──────┬───────┘ └──────┬───────┘ ┌──────────────────┐
|
||||
▼ ▼ │ User Device │
|
||||
┌──────────────┐ ┌──────────────┐ │ (laptop/kiosk) │
|
||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │ │ 10.60.1.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). ESP32s bridge the GoPro's AP to the LAN via MQTT.
|
||||
**Network is fully self-contained — no internet dependency.** The travel router creates the LAN. All devices connect to it. The Pi runs all services (Mosquitto, Go API, React UI, SQLite). ESP8266 boards talk to the GoPro AP over HTTP, then relay camera status/commands over UART to ESP32 boards. ESP32 boards stay on the travel-router LAN and bridge UART messages to MQTT.
|
||||
|
||||
### Key Architecture Decisions (revised)
|
||||
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `192.168.4.0/24`.
|
||||
- **ESP32 dual-STA** — One STA to GoPro AP (10.5.5.1), one STA to travel router. No channel-hopping concerns on closed network.
|
||||
- **ESP32 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable.
|
||||
- **Closed travel router network** — No venue Wi-Fi dependency. User brings their own router. All devices on `10.60.1.0/24`.
|
||||
- **Two-board camera node** — ESP8266 handles GoPro AP/HTTP; ESP32 stays on the travel-router LAN for MQTT. This avoids ESP32 dual-STA/channel switching complexity.
|
||||
- **ESP8266 → GoPro over Wi-Fi** — Bacpac I²C route rejected (30-pin Herobus connector too complex). HTTP to GoPro AP is proven and reliable.
|
||||
- **UART bridge between boards** — ESP8266 reports GoPro status and receives commands over UART; ESP32 relays those messages to/from MQTT.
|
||||
- **MQTT for ESP32 → Hub** — Lightweight, designed for IoT. Mosquitto on Pi. QoS 1 for status, QoS 2 for commands. Full contract: [docs/MQTT_CONTRACT.md](./docs/MQTT_CONTRACT.md)
|
||||
- **SQLite over PostgreSQL** — Single-node Pi Zero 2 W deployment. WAL mode for concurrent read/write.
|
||||
- **SSE over WebSocket** — Unidirectional hub → browser updates. Simpler, sufficient for status dashboard.
|
||||
@@ -215,8 +215,8 @@ platform:
|
||||
type: "pi-zero-2w"
|
||||
max_cameras: 16
|
||||
network:
|
||||
subnet: "192.168.4.0/24" # Travel router subnet
|
||||
hub_ip: "192.168.4.10" # Pi Zero 2 W static IP
|
||||
subnet: "10.60.1.0/24" # Travel router subnet
|
||||
hub_ip: "10.60.1.56" # Pi Zero 2 W static IP
|
||||
```
|
||||
|
||||
## Frontend Component Tree
|
||||
|
||||
+36
-29
@@ -7,7 +7,7 @@
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Travel Router (192.168.4.1) │
|
||||
│ Travel Router (10.60.1.1) │
|
||||
│ DHCP: .100-.200 │
|
||||
└──────┬──────────┬──────────┬──────┘
|
||||
│ │ │
|
||||
@@ -15,27 +15,31 @@
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ ESP32 #1 │ │ ESP32 #2 │ │ Pi Zero 2 W │
|
||||
│ 192.168.4.101│ │ 192.168.4.102│ │ 192.168.4.10 │
|
||||
│ │ │ │ │ │
|
||||
│ STA→GoPro AP │ │ STA→GoPro AP │ │ Mosquitto │
|
||||
│ STA→Router │ │ STA→Router │ │ Go backend │
|
||||
│ 10.60.1.101 │ │ 10.60.1.102 │ │ 10.60.1.56 │
|
||||
│ STA→Router │ │ STA→Router │ │ Mosquitto │
|
||||
│ MQTT relay │ │ MQTT relay │ │ Go backend │
|
||||
└──────┬───────┘ └──────┬───────┘ │ React UI │
|
||||
│ │ └──────────────┘
|
||||
│ UART │ UART └──────────────┘
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ ESP8266 #1 │ │ ESP8266 #2 │
|
||||
│ STA→GoPro AP │ │ STA→GoPro AP │
|
||||
│ HTTP→10.5.5.1│ │ HTTP→10.5.5.1│
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ GoPro Hero 3 │ │ GoPro Hero 3 │
|
||||
│ AP: 10.5.5.1 │ │ AP: 10.5.5.1 │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- **Travel router:** Self-contained, no internet. DHCP pool: `192.168.4.100-200`
|
||||
- **Pi Zero 2 W:** Static IP `192.168.4.10`. Runs Mosquitto (port 1883), Go backend (port 8080), serves React UI
|
||||
- **ESP32s:** DHCP from router. Each has dual STA: one to GoPro AP, one to router
|
||||
- **User device:** Connects to router, opens `http://192.168.4.10:8080` for dashboard
|
||||
- **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
|
||||
- **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
|
||||
|
||||
## MQTT Broker
|
||||
|
||||
- **Host:** `192.168.4.10` (Pi Zero 2 W)
|
||||
- **Host:** `10.60.1.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`)
|
||||
@@ -61,7 +65,7 @@ remoterig/
|
||||
**Direction:** ESP32 → Hub
|
||||
**QoS:** 1 | **Retain:** true | **Interval:** 30 seconds
|
||||
|
||||
Published by the ESP32 every 30s with the latest GoPro status.
|
||||
Published by the ESP32 every 30s using the latest GoPro status received from the paired ESP8266 over UART.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -143,8 +147,9 @@ Commands sent from the dashboard to individual cameras.
|
||||
| `stop_recording` | Stop GoPro recording | status (updated on next poll) |
|
||||
| `reboot` | Reboot the ESP32 | — (ESP32 reconnects after boot) |
|
||||
|
||||
**ESP32 behavior:**
|
||||
- On receipt, execute command against GoPro
|
||||
**ESP32 / ESP8266 behavior:**
|
||||
- ESP32 receives the MQTT command and forwards it over UART to the paired ESP8266
|
||||
- ESP8266 executes the corresponding HTTP command against the GoPro AP
|
||||
- Next status publish will reflect the new state
|
||||
- If command fails (GoPro unreachable), publish status with `online: false`
|
||||
|
||||
@@ -217,20 +222,21 @@ Hub health status broadcast.
|
||||
ESP32 boots
|
||||
│
|
||||
├── Connects to travel router Wi-Fi
|
||||
├── Connects to MQTT broker (192.168.4.10:1883)
|
||||
├── Connects to MQTT broker (10.60.1.56:1883)
|
||||
├── Publishes announce (retained) on cameras/<id>/announce
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Main loop (every 30s): │
|
||||
│ 1. HTTP GET GoPro status (10.5.5.1) │
|
||||
│ 2. Parse 60-byte status blob │
|
||||
│ 3. Publish status (retained) │
|
||||
│ 4. Every 60s: publish heartbeat │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 1. ESP32 requests/receives status via UART │
|
||||
│ 2. ESP8266 polls GoPro HTTP (10.5.5.1) │
|
||||
│ 3. ESP8266 returns parsed status over UART │
|
||||
│ 4. ESP32 publishes MQTT status (retained) │
|
||||
│ 5. Every 60s: ESP32 publishes heartbeat │
|
||||
└───────────────────────────────────────────────┘
|
||||
│
|
||||
├── On MQTT disconnect → reconnect with 1s/2s/4s/8s/16s/30s backoff
|
||||
├── On GoPro unreachable → publish status with online: false
|
||||
├── On ESP8266/GoPro unreachable → publish status with online: false
|
||||
├── On Wi-Fi loss → buffer status locally, replay on reconnect (CUB-230)
|
||||
│
|
||||
▼
|
||||
@@ -243,11 +249,12 @@ ESP32 shutdown / watchdog reboot
|
||||
1. User clicks "Start" on dashboard
|
||||
2. Browser → HTTP POST /api/v1/cameras/cam-001/start → Go backend
|
||||
3. Go backend → MQTT publish remoterig/cameras/cam-001/command {command: "start_recording"}
|
||||
4. ESP32 receives command, sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
|
||||
5. GoPro starts recording
|
||||
6. Next 30s poll: ESP32 publishes status with recording: true
|
||||
7. Go backend receives status, updates SQLite, fans out via SSE
|
||||
8. Dashboard updates with pulsing REC indicator
|
||||
4. ESP32 receives command and forwards it to ESP8266 over UART
|
||||
5. ESP8266 sends HTTP GET to 10.5.5.1/bacpac/SH?t=<password>&p=%01
|
||||
6. GoPro starts recording
|
||||
7. Next 30s poll: ESP8266 reports status over UART; ESP32 publishes status with recording: true
|
||||
8. Go backend receives status, updates SQLite, fans out via SSE
|
||||
9. Dashboard updates with pulsing REC indicator
|
||||
```
|
||||
|
||||
## Offline Buffering (future — CUB-230)
|
||||
@@ -268,6 +275,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 `192.168.4.10`.
|
||||
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`.
|
||||
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.
|
||||
|
||||
+93
-77
@@ -1,118 +1,134 @@
|
||||
# RemoteRig — ESP32 Camera Node Firmware
|
||||
# RemoteRig — Dual-Board Camera Node Firmware
|
||||
|
||||
> **Platform:** PlatformIO (esp32dev) | **Framework:** Arduino
|
||||
> **Platform:** PlatformIO (esp8266-camera + esp32-mqtt)
|
||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
|
||||
> **Hardware:** [hardware/README.md](../hardware/README.md)
|
||||
|
||||
## Architecture
|
||||
|
||||
Each camera node uses **two boards** connected via UART — zero network switching:
|
||||
|
||||
```
|
||||
┌─────────────────────┐ UART ┌─────────────────────┐
|
||||
│ ESP8266 D1 Mini │ TX──────→RX │ ESP32 Dev Board │
|
||||
│ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │
|
||||
│ │ 115200 │ │
|
||||
│ STA → GoPro AP │ 8N1 │ STA → Travel Router │
|
||||
│ HTTP → 10.5.5.1 │ │ MQTT → 10.60.1.56│
|
||||
│ Start/stop/status │ │ Hub registration │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
| Board | Job | Network | Protocol |
|
||||
|-------|-----|---------|----------|
|
||||
| ESP8266 | Camera control | GoPro AP only (10.5.5.1) | HTTP → UART JSON |
|
||||
| ESP32 | Hub relay | Travel router only (10.60.1.x) | UART JSON → MQTT |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install PlatformIO (if not already)
|
||||
pip install platformio
|
||||
|
||||
# Build
|
||||
cd firmware
|
||||
pio run
|
||||
|
||||
# Upload to ESP32 (USB connected)
|
||||
pio run --target upload
|
||||
# Build both
|
||||
pio run -e esp8266-camera
|
||||
pio run -e esp32-mqtt
|
||||
|
||||
# Upload SPIFFS config (first time only, or after config changes)
|
||||
pio run --target uploadfs
|
||||
# Upload to boards (connect one at a time via USB)
|
||||
pio run -e esp8266-camera --target upload
|
||||
pio run -e esp32-mqtt --target upload
|
||||
|
||||
# Serial monitor
|
||||
pio device monitor
|
||||
# Upload configs (each board needs its own)
|
||||
# ESP8266: copy esp8266-config.json to data/config.json, then:
|
||||
pio run -e esp8266-camera --target uploadfs
|
||||
# ESP32: copy esp32-config.json to data/config.json, then:
|
||||
pio run -e esp32-mqtt --target uploadfs
|
||||
```
|
||||
|
||||
## UART Protocol (ESP8266 ↔ ESP32)
|
||||
|
||||
JSON-per-line at 115200 8N1. GPIO16 on both boards.
|
||||
|
||||
| Direction | Type | Format | Purpose |
|
||||
|-----------|------|--------|---------|
|
||||
| ESP8266 → ESP32 | `status` | `{"type":"status","battery_raw":217,...}` | Camera poll result |
|
||||
| ESP8266 → ESP32 | `ack` | `{"type":"ack","cmd":"start_recording"}` | Command confirmation |
|
||||
| ESP8266 → ESP32 | `pong` | `{"type":"pong","uptime_ms":12345}` | Ping response |
|
||||
| ESP8266 → ESP32 | `error` | `{"type":"error","msg":"camera unreachable"}` | Error report |
|
||||
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"start_recording"}` | Hub command |
|
||||
| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"ping"}` | Link health check |
|
||||
|
||||
## Configuration
|
||||
|
||||
The ESP32 stores configuration in SPIFFS (`data/config.json`):
|
||||
### ESP8266 (`data/esp8266-config.json`)
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP name |
|
||||
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
|
||||
| `camera_ip` | `"10.5.5.1"` | Camera IP (change for Akaso to 192.168.1.1) |
|
||||
| `poll_interval_sec` | `30` | How often to poll camera |
|
||||
|
||||
### ESP32 (`data/esp32-config.json`)
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
|
||||
| `wifi_password` | `""` | Travel router password |
|
||||
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP prefix (auto-discovered) |
|
||||
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
|
||||
| `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W static IP |
|
||||
| `mqtt_broker` | `"10.60.1.56"` | Pi Zero 2 W IP |
|
||||
| `mqtt_port` | `1883` | Mosquitto port |
|
||||
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
|
||||
| `poll_interval_sec` | `30` | GoPro status poll frequency |
|
||||
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
|
||||
|
||||
**First boot:** Leave `camera_id` empty. The ESP32 will auto-announce to the hub, which assigns a `cam-NNN` ID. The assigned ID is saved to SPIFFS automatically.
|
||||
|
||||
## LED Status Codes
|
||||
|
||||
| Pattern | Meaning |
|
||||
|---------|---------|
|
||||
| Slow blink (1s) | Connected to router + MQTT, normal operation |
|
||||
| Fast blink (200ms) | No Wi-Fi connection — reconnecting |
|
||||
| Solid on | Connected but GoPro unreachable |
|
||||
| Off | Boot/shutdown |
|
||||
|
||||
## Architecture
|
||||
## Wiring
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ ESP32 (Arduino) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
|
||||
│ │ WiFi STA │ │ WiFi STA │ │ MQTT │ │
|
||||
│ │ (Router) │ │ (GoPro) │ │ Client │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌────────┘ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Main Loop │ │
|
||||
│ │ Every 30s: │ │
|
||||
│ │ HTTP GET GoPro status │ │
|
||||
│ │ Parse 60-byte blob │ │
|
||||
│ │ MQTT publish status │ │
|
||||
│ │ Every 60s: │ │
|
||||
│ │ MQTT publish heartbeat │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ SPIFFS: /config.json (persistent) │
|
||||
└──────────────────────────────────────────┘
|
||||
ESP8266 D1 Mini ESP32 Dev Board
|
||||
┌────────────┐ ┌────────────┐
|
||||
│ │ │ │
|
||||
│ TX (GPIO1)│──────────→│ RX (GPIO16)│
|
||||
│ RX (GPIO3)│←──────────│ TX (GPIO17)│
|
||||
│ GND │───────────│ GND │
|
||||
│ 3.3V │ │ 3.3V │
|
||||
│ │ │ │
|
||||
└────────────┘ └────────────┘
|
||||
│ │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
LiPo → 3.3V Buck
|
||||
(shared power)
|
||||
```
|
||||
|
||||
## Boot Sequence
|
||||
|
||||
1. Load config from SPIFFS
|
||||
2. Connect to travel router Wi-Fi (STA mode)
|
||||
3. Connect to GoPro AP Wi-Fi (STA mode — simultaneous)
|
||||
4. Connect to MQTT broker (192.168.4.10)
|
||||
5. If no `camera_id` → publish announce → hub registers us
|
||||
6. Subscribe to `remoterig/cameras/{camera_id}/command`
|
||||
7. Enter main loop
|
||||
1. **ESP8266:** Connect to GoPro AP → wait for UART commands
|
||||
2. **ESP32:** Connect to travel router → connect MQTT → announce if new
|
||||
3. **ESP8266:** Poll camera every 30s → send status over UART
|
||||
4. **ESP32:** Receive status → publish MQTT
|
||||
5. **Hub → MQTT command → ESP32 → UART → ESP8266 → HTTP → GoPro**
|
||||
|
||||
## GoPro API Notes (Hero 3 Black/Silver)
|
||||
## Camera Compatibility
|
||||
|
||||
- **IP:** Always `10.5.5.1` (GoPro's own AP)
|
||||
- **Status endpoint:** `GET /bacpac/SH?t={password}&p=%01`
|
||||
- **Start recording:** `GET /bacpac/SH?t={password}&p=%01` (mode byte = 1)
|
||||
- **Stop recording:** `GET /bacpac/SH?t={password}&p=%00` (mode byte = 0)
|
||||
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
|
||||
- **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets
|
||||
| Camera | `camera_ip` | Protocol | Status |
|
||||
|--------|------------|----------|--------|
|
||||
| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support |
|
||||
| Akaso Brave 7 | `192.168.1.1` | Varies | 🔬 Set `camera_ip`, test |
|
||||
|
||||
## ESP8266 Compatibility
|
||||
For non-GoPro cameras: only the ESP8266 firmware needs changes — the ESP32 stays the same.
|
||||
|
||||
To target ESP8266 instead:
|
||||
1. Change `platformio.ini`: `board = d1_mini` under `[env:d1_mini]`
|
||||
2. Change `WiFi.h` → `ESP8266WiFi.h`
|
||||
3. ESP8266 doesn't do true simultaneous STA — use single STA to travel router, HTTP to GoPro via router bridge
|
||||
4. SPIFFS → LittleFS on some boards
|
||||
## LED Status (ESP8266)
|
||||
|
||||
ESP32 is recommended for dual-STA capability.
|
||||
| LED | Meaning |
|
||||
|-----|---------|
|
||||
| Solid on | Connected to camera AP, camera responding |
|
||||
| Slow blink (500ms) | Connected to AP but camera not responding |
|
||||
| Off | Wi-Fi disconnected |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| No serial output | Baud rate: 115200. Hold BOOT, press EN, release BOOT for flash mode |
|
||||
| Can't connect to router | Verify SSID/password in SPIFFS config, check router DHCP range |
|
||||
| GoPro unreachable | GoPro must be ON and Wi-Fi enabled. Password defaults to "goprohero" |
|
||||
| MQTT connect fails | Verify Mosquitto running on Pi: `systemctl status mosquitto` |
|
||||
| Camera never registers | Watch serial for "announce" message, check hub logs for registration |
|
||||
| No UART communication | Verify TX→RX crossover. Both boards at 115200. Shared GND. |
|
||||
| ESP8266 can't connect | GoPro must be ON with Wi-Fi enabled. Default password: `goprohero` |
|
||||
| ESP32 can't connect MQTT | `systemctl status mosquitto` on Pi. Port 1883 open. |
|
||||
| Camera never registers | Watch ESP32 serial for "Announced" message. Check hub logs. |
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"wifi_ssid": "RemoteRig",
|
||||
"wifi_password": "",
|
||||
"camera_ssid": "GOPRO-BP-",
|
||||
"camera_password": "goprohero",
|
||||
"mqtt_broker": "192.168.4.10",
|
||||
"mqtt_broker": "10.60.1.56",
|
||||
"mqtt_port": 1883,
|
||||
"camera_id": "",
|
||||
"poll_interval_sec": 30,
|
||||
"heartbeat_interval_sec": 60
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"camera_ssid": "GOPRO-BP-",
|
||||
"camera_password": "goprohero",
|
||||
"camera_ip": "10.5.5.1",
|
||||
"poll_interval_sec": 30
|
||||
}
|
||||
+59
-15
@@ -1,23 +1,67 @@
|
||||
; RemoteRig — ESP32 Camera Node Firmware
|
||||
; Platform: ESP32 (ESP8266 compatible with minor changes)
|
||||
; Framework: Arduino
|
||||
; RemoteRig — Dual-Board Camera Node Firmware
|
||||
; ============================================
|
||||
; Each camera node has TWO boards connected via UART:
|
||||
;
|
||||
; Build: pio run
|
||||
; Upload: pio run --target upload
|
||||
; SPIFFS: pio run --target uploadfs
|
||||
; Monitor: pio device monitor
|
||||
; ESP8266 (Camera Bridge): Connects to GoPro AP → HTTP status/control
|
||||
; ESP32 (MQTT Bridge): Connects to travel router → MQTT to hub
|
||||
;
|
||||
; ESP8266 ←──UART──→ ESP32
|
||||
; (TX/RX) (RX16/TX17)
|
||||
;
|
||||
; Build:
|
||||
; pio run -e esp8266-camera (ESP8266 D1 Mini — camera bridge)
|
||||
; pio run -e esp32-mqtt (ESP32 Dev Board — MQTT bridge)
|
||||
;
|
||||
; Upload:
|
||||
; pio run -e esp8266-camera --target upload
|
||||
; pio run -e esp32-mqtt --target upload
|
||||
;
|
||||
; Filesystem:
|
||||
; pio run -e esp8266-camera --target uploadfs
|
||||
; pio run -e esp32-mqtt --target uploadfs
|
||||
|
||||
[env:esp32dev]
|
||||
[common]
|
||||
lib_deps =
|
||||
knolleary/PubSubClient @ ^2.8
|
||||
bblanchon/ArduinoJson @ ^7.3
|
||||
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.
|
||||
|
||||
[env:esp8266-camera]
|
||||
platform = espressif8266
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
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.f_cpu = 160000000L
|
||||
build_src_filter =
|
||||
+<../lib/>
|
||||
+<esp8266-camera-bridge.cpp>
|
||||
-<*.cpp>
|
||||
|
||||
; ── 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.
|
||||
|
||||
[env:esp32-mqtt]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
|
||||
lib_deps =
|
||||
knolleary/PubSubClient @ ^2.8
|
||||
bblanchon/ArduinoJson @ ^7.3
|
||||
|
||||
build_flags =
|
||||
-D CORE_DEBUG_LEVEL=0
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_flags = ${common.build_flags}
|
||||
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
|
||||
build_src_filter =
|
||||
+<../lib/>
|
||||
+<esp32-mqtt-bridge.cpp>
|
||||
-<*.cpp>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* RemoteRig — ESP32 MQTT Bridge Firmware
|
||||
* ======================================
|
||||
* Dedicated board per camera node. Connects the ESP8266 camera bridge
|
||||
* to the RemoteRig MQTT hub.
|
||||
*
|
||||
* ONE JOB: relay between UART (ESP8266) and MQTT (Pi hub).
|
||||
* - Connects to travel router Wi-Fi
|
||||
* - Reads status JSON from ESP8266 over UART → publishes via MQTT
|
||||
* - Receives commands via MQTT from hub → forwards to ESP8266 over UART
|
||||
* - Handles auto-registration (announce on first boot)
|
||||
* - Heartbeat publishing
|
||||
* - Zero camera communication, zero network switching
|
||||
*
|
||||
* UART Protocol: JSON-per-line at 115200 8N1
|
||||
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
|
||||
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
|
||||
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
|
||||
* ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n
|
||||
*
|
||||
* Hardware:
|
||||
* - ESP32 Dev Board (or D1 Mini ESP32)
|
||||
* - UART2: RX=GPIO16, TX=GPIO17 (connected to ESP8266)
|
||||
* - Shared GND between boards
|
||||
* - LiPo → 3.3V buck → VIN on both boards
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <SPIFFS.h>
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Configuration (SPIFFS)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
struct Config {
|
||||
String wifi_ssid = "RemoteRig";
|
||||
String wifi_password = "";
|
||||
String mqtt_broker = "10.60.1.56";
|
||||
int mqtt_port = 1883;
|
||||
String camera_id = ""; // assigned by hub
|
||||
int heartbeat_sec = 60;
|
||||
} cfg;
|
||||
|
||||
bool loadConfig() {
|
||||
if (!SPIFFS.begin(true)) { Serial.println("[CFG] SPIFFS mount failed"); return false; }
|
||||
File f = SPIFFS.open("/config.json", "r");
|
||||
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
|
||||
|
||||
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
||||
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
||||
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
||||
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
||||
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
||||
cfg.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool saveConfig() {
|
||||
File f = SPIFFS.open("/config.json", "w");
|
||||
if (!f) return false;
|
||||
JsonDocument doc;
|
||||
doc["wifi_ssid"] = cfg.wifi_ssid;
|
||||
doc["wifi_password"] = cfg.wifi_password;
|
||||
doc["mqtt_broker"] = cfg.mqtt_broker;
|
||||
doc["mqtt_port"] = cfg.mqtt_port;
|
||||
doc["camera_id"] = cfg.camera_id;
|
||||
doc["heartbeat_interval_sec"] = cfg.heartbeat_sec;
|
||||
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
|
||||
|
||||
#define UART_ESP8266 Serial2
|
||||
|
||||
void sendCmdToESP8266(const String& command) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "cmd";
|
||||
doc["command"] = command;
|
||||
String line;
|
||||
serializeJson(doc, line);
|
||||
UART_ESP8266.println(line);
|
||||
UART_ESP8266.flush();
|
||||
}
|
||||
|
||||
String uartLine;
|
||||
bool readFromESP8266(String& line) {
|
||||
while (UART_ESP8266.available()) {
|
||||
char c = UART_ESP8266.read();
|
||||
if (c == '\n') {
|
||||
line = uartLine;
|
||||
uartLine = "";
|
||||
return true;
|
||||
}
|
||||
if (c != '\r') uartLine += c;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// MQTT
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
WiFiClient routerClient;
|
||||
PubSubClient mqtt(routerClient);
|
||||
|
||||
unsigned long bootMs = 0;
|
||||
bool cameraOnline = false;
|
||||
unsigned long lastStatusMs = 0;
|
||||
|
||||
String clientID() {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "rig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
||||
return String(buf);
|
||||
}
|
||||
|
||||
String mqttTopic(const char* t) {
|
||||
return "remoterig/cameras/" + cfg.camera_id + "/" + t;
|
||||
}
|
||||
|
||||
void mqttCallback(char* topic, byte* payload, unsigned int len) {
|
||||
char buf[256];
|
||||
unsigned int n = len < 255 ? len : 255;
|
||||
memcpy(buf, payload, n); buf[n] = 0;
|
||||
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, buf)) return;
|
||||
|
||||
String cmd = doc["command"] | "";
|
||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||
Serial.printf("[MQTT] Forwarding command: %s → ESP8266\n", cmd.c_str());
|
||||
sendCmdToESP8266(cmd);
|
||||
} else if (cmd == "reboot") {
|
||||
ESP.restart();
|
||||
} else if (cmd == "registered") {
|
||||
String id = doc["camera_id"] | "";
|
||||
if (id.length() > 0 && id != cfg.camera_id) {
|
||||
cfg.camera_id = id;
|
||||
saveConfig();
|
||||
mqtt.unsubscribe(mqttTopic("command").c_str());
|
||||
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
||||
Serial.printf("[MQTT] Registered as %s\n", id.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool connectMQTT() {
|
||||
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
|
||||
mqtt.setCallback(mqttCallback);
|
||||
mqtt.setKeepAlive(60);
|
||||
|
||||
if (!mqtt.connect(clientID().c_str())) {
|
||||
Serial.printf("[MQTT] Connect fail (state=%d)\n", mqtt.state());
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.println("[MQTT] Connected");
|
||||
|
||||
// Subscribe to commands (if registered)
|
||||
if (cfg.camera_id.length() > 0) {
|
||||
mqtt.subscribe(mqttTopic("command").c_str(), 2);
|
||||
}
|
||||
|
||||
// Announce if new
|
||||
if (cfg.camera_id.length() == 0) {
|
||||
JsonDocument doc;
|
||||
doc["mac_address"] = WiFi.macAddress();
|
||||
doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge";
|
||||
doc["friendly_name"] = "Cam-" + clientID();
|
||||
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");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Setup
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0");
|
||||
|
||||
bootMs = millis();
|
||||
pinMode(2, OUTPUT); // built-in LED
|
||||
digitalWrite(2, LOW);
|
||||
|
||||
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");
|
||||
|
||||
// Connect to travel router — the ONLY network we touch
|
||||
Serial.printf("[WIFI] Connecting to: %s\n", cfg.wifi_ssid.c_str());
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
|
||||
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
|
||||
delay(500); Serial.print("."); attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
} else {
|
||||
Serial.println("\n[WIFI] FAILED — will retry");
|
||||
}
|
||||
|
||||
// MQTT
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
connectMQTT();
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Main Loop
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
static unsigned long lastBeat = 0, lastRecon = 0;
|
||||
static int reconDelay = 1;
|
||||
|
||||
// ── Wi-Fi watchdog ──
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); }
|
||||
delay(100); return;
|
||||
}
|
||||
|
||||
// ── MQTT watchdog ──
|
||||
if (!mqtt.connected()) {
|
||||
if (now - lastRecon > (unsigned long)(reconDelay * 1000)) {
|
||||
lastRecon = now;
|
||||
if (connectMQTT()) reconDelay = 1;
|
||||
else reconDelay = min(reconDelay * 2, 30);
|
||||
}
|
||||
mqtt.loop(); delay(100); return;
|
||||
}
|
||||
|
||||
mqtt.loop();
|
||||
|
||||
// ── Read status from ESP8266 over UART → publish via MQTT ──
|
||||
String line;
|
||||
while (readFromESP8266(line)) {
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, line);
|
||||
if (err) { Serial.printf("[UART] Bad JSON: %s\n", line.c_str()); continue; }
|
||||
|
||||
String type = doc["type"] | "";
|
||||
|
||||
if (type == "status") {
|
||||
// Relay camera status to MQTT hub
|
||||
lastStatusMs = now;
|
||||
bool online = doc["online"] | false;
|
||||
|
||||
if (online != cameraOnline) {
|
||||
cameraOnline = online;
|
||||
digitalWrite(2, online ? HIGH : LOW);
|
||||
}
|
||||
|
||||
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;
|
||||
mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0;
|
||||
mqttDoc["recording"] = doc["recording"] | false;
|
||||
mqttDoc["online"] = online;
|
||||
|
||||
String payload;
|
||||
serializeJson(mqttDoc, payload);
|
||||
mqtt.publish(mqttTopic("status").c_str(), payload.c_str(), true);
|
||||
}
|
||||
}
|
||||
else if (type == "ack") {
|
||||
Serial.printf("[UART] ESP8266 ack: %s\n", (doc["cmd"] | "?").c_str());
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat to hub (every heartbeat_sec) ──
|
||||
if (cfg.camera_id.length() > 0 &&
|
||||
now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) {
|
||||
lastBeat = now;
|
||||
JsonDocument doc;
|
||||
doc["camera_id"] = cfg.camera_id;
|
||||
doc["timestamp"] = millis();
|
||||
doc["uptime_sec"] = (now - bootMs) / 1000;
|
||||
doc["free_heap"] = ESP.getFreeHeap();
|
||||
doc["status_age_ms"] = now - lastStatusMs;
|
||||
String payload; serializeJson(doc, payload);
|
||||
mqtt.publish(mqttTopic("heartbeat").c_str(), payload.c_str(), false);
|
||||
}
|
||||
|
||||
// ── Periodic ping to ESP8266 to verify UART link ──
|
||||
static unsigned long lastPing = 0;
|
||||
if (now - lastPing > 30000) {
|
||||
lastPing = now;
|
||||
sendCmdToESP8266("ping");
|
||||
}
|
||||
|
||||
delay(50);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* RemoteRig — ESP8266 Camera Bridge Firmware
|
||||
* ==========================================
|
||||
* Dedicated board clipped to each GoPro Hero 3.
|
||||
*
|
||||
* ONE JOB: talk to the camera.
|
||||
* - Connects to GoPro Wi-Fi AP (10.5.5.1)
|
||||
* - Polls status every 30s → sends JSON over UART to ESP32
|
||||
* - Receives commands from ESP32 over UART → executes against camera
|
||||
* - Zero network switching, zero MQTT, zero cloud
|
||||
*
|
||||
* UART Protocol: JSON-per-line at 115200 8N1
|
||||
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
|
||||
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
|
||||
* ESP8266 → ESP32: {"type":"error","msg":"..."}\n
|
||||
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
|
||||
*
|
||||
* Hardware:
|
||||
* - ESP8266 D1 Mini (or NodeMCU)
|
||||
* - UART TX → ESP32 RX (GPIO 16)
|
||||
* - UART RX → ESP32 TX (GPIO 16)
|
||||
* - Shared GND between boards
|
||||
* - LiPo → 3.3V buck → VIN on both boards
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <LittleFS.h>
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Configuration (SPIFFS via LittleFS)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
struct Config {
|
||||
String camera_ssid = "GOPRO-BP-";
|
||||
String camera_password = "goprohero";
|
||||
String camera_ip = "10.5.5.1";
|
||||
int poll_interval_sec = 30;
|
||||
} cfg;
|
||||
|
||||
bool loadConfig() {
|
||||
if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; }
|
||||
File f = LittleFS.open("/config.json", "r");
|
||||
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
|
||||
|
||||
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
|
||||
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
|
||||
cfg.camera_ip = doc["camera_ip"] | cfg.camera_ip;
|
||||
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Camera HTTP Client (GoPro Hero 3)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
WiFiClient goproClient;
|
||||
|
||||
struct CamStatus {
|
||||
bool valid = false;
|
||||
int video_remaining_sec = 0;
|
||||
bool recording = false;
|
||||
int battery_raw = 0;
|
||||
};
|
||||
|
||||
CamStatus fetchStatus() {
|
||||
CamStatus s;
|
||||
|
||||
String url = "http://" + cfg.camera_ip +
|
||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
|
||||
|
||||
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();
|
||||
http.end();
|
||||
if (raw.length() < 58) return s;
|
||||
|
||||
const uint8_t* buf = (const uint8_t*)raw.c_str();
|
||||
s.valid = true;
|
||||
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
||||
s.recording = (buf[29] == 1);
|
||||
s.battery_raw = buf[57];
|
||||
return s;
|
||||
}
|
||||
|
||||
bool sendCommand(const String& cmd) {
|
||||
String param = (cmd == "start_recording") ? "%01" : "%00";
|
||||
String url = "http://" + cfg.camera_ip +
|
||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
|
||||
|
||||
HTTPClient http;
|
||||
http.useHTTP10(true);
|
||||
http.begin(goproClient, url);
|
||||
http.setTimeout(5000);
|
||||
int code = http.GET();
|
||||
http.end();
|
||||
return (code == 200);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// UART Protocol (to ESP32)
|
||||
// ────────────────────────────────────────────
|
||||
// Using HardwareSerial on GPIO1/3 (D1 Mini default TX/RX)
|
||||
// On D1 Mini: TX=GPIO1, RX=GPIO3 (labeled TX/RX on board)
|
||||
|
||||
// Send JSON line to ESP32
|
||||
void sendToESP32(const JsonDocument& doc) {
|
||||
String line;
|
||||
serializeJson(doc, line);
|
||||
Serial.println(line); // newline-terminated for framing
|
||||
Serial.flush();
|
||||
}
|
||||
|
||||
// Send status update
|
||||
void sendStatus(const CamStatus& s) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "status";
|
||||
doc["valid"] = s.valid;
|
||||
doc["battery_raw"] = s.battery_raw;
|
||||
doc["video_remaining_sec"] = s.video_remaining_sec;
|
||||
doc["recording"] = s.recording;
|
||||
doc["online"] = s.valid;
|
||||
doc["uptime_ms"] = millis();
|
||||
sendToESP32(doc);
|
||||
}
|
||||
|
||||
// Send acknowledgment
|
||||
void sendAck(const String& cmd) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "ack";
|
||||
doc["cmd"] = cmd;
|
||||
sendToESP32(doc);
|
||||
}
|
||||
|
||||
// Send error
|
||||
void sendError(const String& msg) {
|
||||
JsonDocument doc;
|
||||
doc["type"] = "error";
|
||||
doc["msg"] = msg;
|
||||
sendToESP32(doc);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Command handling (from ESP32 over UART)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void handleCommand(const JsonDocument& doc) {
|
||||
String cmd = doc["command"] | "";
|
||||
|
||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||
bool ok = sendCommand(cmd);
|
||||
if (ok) {
|
||||
sendAck(cmd);
|
||||
} else {
|
||||
sendError("Camera unreachable — command failed");
|
||||
}
|
||||
} else if (cmd == "ping") {
|
||||
JsonDocument pong;
|
||||
pong["type"] = "pong";
|
||||
pong["uptime_ms"] = millis();
|
||||
sendToESP32(pong);
|
||||
} else {
|
||||
sendError("Unknown command: " + cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// UART line reader (non-blocking)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
String serialLine;
|
||||
|
||||
bool readLine(String& line) {
|
||||
while (Serial.available()) {
|
||||
char c = Serial.read();
|
||||
if (c == '\n') {
|
||||
line = serialLine;
|
||||
serialLine = "";
|
||||
return true;
|
||||
}
|
||||
if (c != '\r') serialLine += c;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// LED
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
const int LED = LED_BUILTIN; // active-low on ESP8266 D1 Mini
|
||||
|
||||
void ledOn() { digitalWrite(LED, LOW); }
|
||||
void ledOff() { digitalWrite(LED, HIGH); }
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Setup
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
Serial.println("\n[BRIDGE] ESP8266 Camera Bridge v1.0");
|
||||
|
||||
pinMode(LED, OUTPUT);
|
||||
ledOff();
|
||||
|
||||
loadConfig();
|
||||
|
||||
// Connect to GoPro AP — this is the ONLY network we touch
|
||||
Serial.printf("[WIFI] Connecting to camera AP: %s\n", cfg.camera_ssid.c_str());
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
|
||||
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
|
||||
delay(500); Serial.print("."); attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
ledOn(); // Solid = connected
|
||||
} else {
|
||||
Serial.println("\n[WIFI] FAILED — will retry in loop");
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Main Loop — poll camera, relay over UART
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
static unsigned long lastPoll = 0;
|
||||
static unsigned long lastWiFiRetry = 0;
|
||||
static bool cameraOnline = false;
|
||||
|
||||
// ── Wi-Fi reconnection ──
|
||||
if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) {
|
||||
lastWiFiRetry = now;
|
||||
Serial.println("[WIFI] Reconnecting...");
|
||||
WiFi.reconnect();
|
||||
}
|
||||
|
||||
// ── Poll camera ──
|
||||
if (now - lastPoll > (unsigned long)(cfg.poll_interval_sec * 1000)) {
|
||||
lastPoll = now;
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
CamStatus s = fetchStatus();
|
||||
|
||||
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
|
||||
CamStatus s;
|
||||
sendStatus(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read commands from ESP32 over UART ──
|
||||
String line;
|
||||
if (readLine(line)) {
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, line);
|
||||
if (!err) {
|
||||
String type = doc["type"] | "";
|
||||
if (type == "cmd") {
|
||||
handleCommand(doc);
|
||||
}
|
||||
// Ignore other message types — they're for the ESP32
|
||||
}
|
||||
}
|
||||
|
||||
// ── LED blink when offline ──
|
||||
if (!cameraOnline) {
|
||||
static unsigned long lastBlink = 0;
|
||||
if (now - lastBlink > 500) {
|
||||
lastBlink = now;
|
||||
digitalWrite(LED, !digitalRead(LED));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,566 +0,0 @@
|
||||
/**
|
||||
* RemoteRig — ESP32 Camera Node Firmware
|
||||
* =======================================
|
||||
* One ESP32 per GoPro Hero 3. Bridges the camera's Wi-Fi AP (10.5.5.1)
|
||||
* to the travel router LAN via MQTT (Mosquitto on Pi Zero 2 W).
|
||||
*
|
||||
* MQTT Contract: docs/MQTT_CONTRACT.md
|
||||
* Hardware: hardware/README.md
|
||||
* Platform: PlatformIO (esp32dev)
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <SPIFFS.h>
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Configuration (overridden by SPIFFS /data/config.json)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
struct Config {
|
||||
// Travel router Wi-Fi
|
||||
String wifi_ssid = "RemoteRig";
|
||||
String wifi_password = "";
|
||||
|
||||
// GoPro Hero 3 Wi-Fi AP
|
||||
String camera_ssid = "GOPRO-BP-"; // prefix — auto-discovered
|
||||
String camera_password = "goprohero";
|
||||
|
||||
// MQTT broker (Pi Zero 2 W on travel router)
|
||||
String mqtt_broker = "192.168.4.10";
|
||||
int mqtt_port = 1883;
|
||||
|
||||
// Assigned by hub on first announce; empty until registered
|
||||
String camera_id = "";
|
||||
|
||||
// Polling
|
||||
int poll_interval_sec = 30;
|
||||
int heartbeat_interval_sec = 60;
|
||||
|
||||
// Stored in SPIFFS
|
||||
bool dirty = false;
|
||||
} cfg;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Network clients
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
WiFiClient wifiClient; // for HTTP to GoPro
|
||||
WiFiClient mqttWifiClient; // for MQTT via travel router
|
||||
PubSubClient mqtt(mqttWifiClient);
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
unsigned long lastPollMs = 0;
|
||||
unsigned long lastHeartbeatMs = 0;
|
||||
unsigned long lastReconnectMs = 0;
|
||||
unsigned long bootMs = 0;
|
||||
int reconnectDelay = 1; // exponential backoff (seconds)
|
||||
bool goproOnline = false;
|
||||
|
||||
// Heartbeat sequence
|
||||
unsigned int heartbeatSeq = 0;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// LED Pin (built-in on most ESP32 dev boards = GPIO 2)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const int LED_PIN = 2;
|
||||
|
||||
enum LedMode { LED_OFF, LED_SLOW, LED_FAST, LED_ON };
|
||||
LedMode ledMode = LED_SLOW;
|
||||
|
||||
void setLed(LedMode mode) {
|
||||
ledMode = mode;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// SPIFFS Config
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
bool loadConfig() {
|
||||
if (!SPIFFS.begin(true)) {
|
||||
Serial.println("[CFG] SPIFFS mount failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
File f = SPIFFS.open("/config.json", "r");
|
||||
if (!f) {
|
||||
Serial.println("[CFG] No /config.json — using defaults");
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
if (err) {
|
||||
Serial.printf("[CFG] JSON parse error: %s\n", err.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
||||
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
||||
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
|
||||
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
|
||||
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
||||
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
||||
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
||||
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
|
||||
cfg.heartbeat_interval_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_interval_sec;
|
||||
|
||||
Serial.println("[CFG] Loaded from /config.json");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool saveConfig() {
|
||||
File f = SPIFFS.open("/config.json", "w");
|
||||
if (!f) return false;
|
||||
|
||||
JsonDocument doc;
|
||||
doc["wifi_ssid"] = cfg.wifi_ssid;
|
||||
doc["wifi_password"] = cfg.wifi_password;
|
||||
doc["camera_ssid"] = cfg.camera_ssid;
|
||||
doc["camera_password"] = cfg.camera_password;
|
||||
doc["mqtt_broker"] = cfg.mqtt_broker;
|
||||
doc["mqtt_port"] = cfg.mqtt_port;
|
||||
doc["camera_id"] = cfg.camera_id;
|
||||
doc["poll_interval_sec"] = cfg.poll_interval_sec;
|
||||
doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec;
|
||||
|
||||
serializeJson(doc, f);
|
||||
f.close();
|
||||
Serial.println("[CFG] Saved config");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Wi-Fi — Dual STA (GoPro AP + Travel Router)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
bool connectCameraWiFi() {
|
||||
Serial.printf("[WIFI] Connecting to GoPro AP: %s\n", cfg.camera_ssid.c_str());
|
||||
|
||||
// Use WiFi.begin with a second AP config — ESP32 supports this
|
||||
// We connect to travel router first, then GoPro
|
||||
// GoPro AP: static IP on 10.5.5.x subnet
|
||||
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
|
||||
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[WIFI] Connected to GoPro AP. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
goproOnline = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.println("\n[WIFI] Failed to connect to GoPro AP");
|
||||
goproOnline = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// GoPro Hero 3 HTTP API
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// GoPro AP gateway (always 10.5.5.1 for Hero 3)
|
||||
const char* GOPRO_IP = "10.5.5.1";
|
||||
|
||||
/**
|
||||
* Get the GoPro camera password.
|
||||
* Hero 3 exposes it via GET /bacpac/sd (no auth required).
|
||||
* Default is "goprohero" but user may have changed it.
|
||||
*/
|
||||
String fetchGoProPassword() {
|
||||
HTTPClient http;
|
||||
http.begin(wifiClient, String("http://") + GOPRO_IP + "/bacpac/sd");
|
||||
int code = http.GET();
|
||||
String body = http.getString();
|
||||
http.end();
|
||||
|
||||
if (code == 200 && body.length() > 0) {
|
||||
// Password is in plain text in the response body
|
||||
body.trim();
|
||||
return body;
|
||||
}
|
||||
return cfg.camera_password; // fallback to config value
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the GoPro status blob (60 bytes binary).
|
||||
* Returns empty string on failure.
|
||||
*/
|
||||
String fetchGoProStatus() {
|
||||
String url = String("http://") + GOPRO_IP +
|
||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
|
||||
HTTPClient http;
|
||||
http.begin(wifiClient, url);
|
||||
http.setTimeout(5000);
|
||||
int code = http.GET();
|
||||
|
||||
if (code != 200) {
|
||||
http.end();
|
||||
return "";
|
||||
}
|
||||
|
||||
// GoPro returns raw binary — use getString() which handles it
|
||||
String raw = http.getString();
|
||||
http.end();
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the 60-byte GoPro status blob into structured data.
|
||||
* Hero 3 status format (offsets are 0-based):
|
||||
* [25-26] video_remaining_sec (uint16 LE)
|
||||
* [29] recording state (0=idle, 1=recording)
|
||||
* [30] mode
|
||||
* [31-32] resolution
|
||||
* [33-34] fps
|
||||
* [57] battery_raw (uint8)
|
||||
*/
|
||||
struct GoProStatus {
|
||||
bool valid = false;
|
||||
int video_remaining_sec = 0;
|
||||
bool recording = false;
|
||||
int mode = 0;
|
||||
int fps = 0;
|
||||
int battery_raw = 0;
|
||||
};
|
||||
|
||||
GoProStatus parseStatus(const String& raw) {
|
||||
GoProStatus s;
|
||||
if (raw.length() < 58) {
|
||||
return s;
|
||||
}
|
||||
|
||||
const uint8_t* buf = (const uint8_t*)raw.c_str();
|
||||
|
||||
s.valid = true;
|
||||
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
||||
s.recording = (buf[29] == 1);
|
||||
s.mode = buf[30];
|
||||
s.fps = buf[33] | (buf[34] << 8);
|
||||
s.battery_raw = buf[57];
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
bool sendGoProCommand(const String& command) {
|
||||
String param;
|
||||
if (command == "start_recording") {
|
||||
param = "%01"; // mode 1 = record
|
||||
} else if (command == "stop_recording") {
|
||||
param = "%00"; // mode 0 = stop
|
||||
} else {
|
||||
Serial.printf("[GOPRO] Unknown command: %s\n", command.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
String url = String("http://") + GOPRO_IP +
|
||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(wifiClient, url);
|
||||
http.setTimeout(5000);
|
||||
int code = http.GET();
|
||||
http.end();
|
||||
|
||||
Serial.printf("[GOPRO] Command %s → HTTP %d\n", command.c_str(), code);
|
||||
return (code == 200);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// MQTT
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
String clientID() {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "remoterig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
||||
return String(buf);
|
||||
}
|
||||
|
||||
String statusTopic() { return "remoterig/cameras/" + cfg.camera_id + "/status"; }
|
||||
String heartbeatTopic() { return "remoterig/cameras/" + cfg.camera_id + "/heartbeat"; }
|
||||
String announceTopic() { return "remoterig/cameras/" + cfg.camera_id + "/announce"; }
|
||||
String commandTopic() { return "remoterig/cameras/" + cfg.camera_id + "/command"; }
|
||||
|
||||
void mqttCallback(char* topic, byte* payload, unsigned int length) {
|
||||
// Null-terminate payload
|
||||
char buf[256];
|
||||
unsigned int len = length < 255 ? length : 255;
|
||||
memcpy(buf, payload, len);
|
||||
buf[len] = 0;
|
||||
|
||||
Serial.printf("[MQTT] ← %s: %s\n", topic, buf);
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, buf);
|
||||
if (err) {
|
||||
Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
String cmd = doc["command"] | "";
|
||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||
sendGoProCommand(cmd);
|
||||
} else if (cmd == "reboot") {
|
||||
Serial.println("[MQTT] Reboot command received");
|
||||
ESP.restart();
|
||||
} else if (cmd == "registered") {
|
||||
// Hub assigned us a camera_id on announce
|
||||
String newID = doc["camera_id"] | "";
|
||||
if (newID.length() > 0 && newID != cfg.camera_id) {
|
||||
cfg.camera_id = newID;
|
||||
cfg.dirty = true;
|
||||
Serial.printf("[MQTT] Registered as %s\n", newID.c_str());
|
||||
// Re-subscribe to our new command topic
|
||||
mqtt.unsubscribe(commandTopic().c_str());
|
||||
mqtt.subscribe(commandTopic().c_str(), 2);
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool connectMQTT() {
|
||||
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
|
||||
mqtt.setCallback(mqttCallback);
|
||||
mqtt.setKeepAlive(60);
|
||||
|
||||
Serial.printf("[MQTT] Connecting to %s:%d as %s...\n",
|
||||
cfg.mqtt_broker.c_str(), cfg.mqtt_port, clientID().c_str());
|
||||
|
||||
if (mqtt.connect(clientID().c_str())) {
|
||||
Serial.println("[MQTT] Connected");
|
||||
|
||||
// Subscribe to command topic
|
||||
mqtt.subscribe(commandTopic().c_str(), 2);
|
||||
Serial.printf("[MQTT] Subscribed to %s\n", commandTopic().c_str());
|
||||
|
||||
// If we have no camera_id yet, announce ourselves
|
||||
if (cfg.camera_id.length() == 0) {
|
||||
publishAnnounce();
|
||||
}
|
||||
|
||||
reconnectDelay = 1; // reset backoff
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("[MQTT] Connection failed (state=%d)\n", mqtt.state());
|
||||
return false;
|
||||
}
|
||||
|
||||
void publishAnnounce() {
|
||||
JsonDocument doc;
|
||||
doc["mac_address"] = WiFi.macAddress();
|
||||
doc["firmware_version"] = "0.1.0";
|
||||
doc["friendly_name"] = "ESP32-" + clientID().substring(9);
|
||||
|
||||
JsonArray caps = doc["capabilities"].to<JsonArray>();
|
||||
caps.add("start_stop");
|
||||
caps.add("status");
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
// Publish on a temporary announce topic (using MAC as ID until registered)
|
||||
String tempAnnounce = "remoterig/cameras/announce-" + clientID().substring(9);
|
||||
mqtt.publish(tempAnnounce.c_str(), payload.c_str(), true);
|
||||
|
||||
Serial.printf("[MQTT] Published announce: %s\n", payload.c_str());
|
||||
}
|
||||
|
||||
void publishStatus(const GoProStatus& s) {
|
||||
JsonDocument doc;
|
||||
doc["camera_id"] = cfg.camera_id;
|
||||
doc["timestamp"] = millis(); // milliseconds since boot — hub converts to ISO
|
||||
doc["battery_raw"] = s.battery_raw;
|
||||
doc["video_remaining_sec"] = s.video_remaining_sec;
|
||||
doc["recording"] = s.recording;
|
||||
doc["online"] = goproOnline;
|
||||
|
||||
if (s.recording) {
|
||||
doc["mode"] = "video";
|
||||
}
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
bool ok = mqtt.publish(statusTopic().c_str(), payload.c_str(), true);
|
||||
if (ok) {
|
||||
Serial.printf("[MQTT] → status (batt=%d, rec=%d, online=%d)\n",
|
||||
s.battery_raw, s.recording, goproOnline);
|
||||
} else {
|
||||
Serial.println("[MQTT] Status publish failed");
|
||||
}
|
||||
}
|
||||
|
||||
void publishHeartbeat() {
|
||||
JsonDocument doc;
|
||||
doc["camera_id"] = cfg.camera_id;
|
||||
doc["timestamp"] = millis();
|
||||
doc["uptime_sec"] = (millis() - bootMs) / 1000;
|
||||
doc["free_heap"] = ESP.getFreeHeap();
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
mqtt.publish(heartbeatTopic().c_str(), payload.c_str(), false);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Setup
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
Serial.println("\n\nRemoteRig ESP32 Camera Node v0.1.0");
|
||||
Serial.println("===================================");
|
||||
|
||||
bootMs = millis();
|
||||
|
||||
pinMode(LED_PIN, OUTPUT);
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
|
||||
// Load config from SPIFFS
|
||||
loadConfig();
|
||||
Serial.printf("[CFG] camera_id: %s (empty = not yet registered)\n",
|
||||
cfg.camera_id.length() > 0 ? cfg.camera_id.c_str() : "(none)");
|
||||
|
||||
// Connect to travel router Wi-Fi
|
||||
Serial.printf("[WIFI] Connecting to travel router: %s\n", cfg.wifi_ssid.c_str());
|
||||
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
|
||||
|
||||
int wifiAttempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && wifiAttempts < 40) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
wifiAttempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
setLed(LED_SLOW); // connected to router
|
||||
} else {
|
||||
Serial.println("\n[WIFI] Failed to connect to travel router — will retry in loop");
|
||||
setLed(LED_FAST); // no router connection
|
||||
}
|
||||
|
||||
// Connect to GoPro AP
|
||||
if (!connectCameraWiFi()) {
|
||||
Serial.println("[WIFI] GoPro not reachable — will retry");
|
||||
setLed(LED_FAST);
|
||||
}
|
||||
|
||||
// Connect MQTT
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
connectMQTT();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Main Loop
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
|
||||
// ── LED heartbeat ──
|
||||
static unsigned long lastLedToggle = 0;
|
||||
int ledInterval = (ledMode == LED_FAST) ? 200 : (ledMode == LED_SLOW) ? 1000 : 0;
|
||||
if (ledInterval > 0 && now - lastLedToggle > ledInterval) {
|
||||
lastLedToggle = now;
|
||||
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
|
||||
}
|
||||
if (ledMode == LED_ON) digitalWrite(LED_PIN, HIGH);
|
||||
if (ledMode == LED_OFF) digitalWrite(LED_PIN, LOW);
|
||||
|
||||
// ── Wi-Fi reconnection ──
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
setLed(LED_FAST);
|
||||
if (now - lastReconnectMs > 5000) {
|
||||
lastReconnectMs = now;
|
||||
Serial.println("[WIFI] Reconnecting...");
|
||||
WiFi.reconnect();
|
||||
}
|
||||
delay(100);
|
||||
return; // skip everything else until Wi-Fi is back
|
||||
}
|
||||
|
||||
// ── MQTT reconnection ──
|
||||
if (!mqtt.connected()) {
|
||||
setLed(LED_SLOW);
|
||||
if (now - lastReconnectMs > (unsigned long)(reconnectDelay * 1000)) {
|
||||
lastReconnectMs = now;
|
||||
if (connectMQTT()) {
|
||||
reconnectDelay = 1;
|
||||
} else {
|
||||
reconnectDelay = min(reconnectDelay * 2, 30);
|
||||
}
|
||||
}
|
||||
mqtt.loop();
|
||||
delay(100);
|
||||
return;
|
||||
}
|
||||
|
||||
setLed(LED_SLOW);
|
||||
mqtt.loop();
|
||||
|
||||
// ── GoPro reconnection ──
|
||||
static unsigned long lastGoProRetry = 0;
|
||||
if (!goproOnline && now - lastGoProRetry > 30000) {
|
||||
lastGoProRetry = now;
|
||||
connectCameraWiFi();
|
||||
}
|
||||
|
||||
// ── Status polling (every cfg.poll_interval_sec) ──
|
||||
if (now - lastPollMs > (unsigned long)(cfg.poll_interval_sec * 1000)) {
|
||||
lastPollMs = now;
|
||||
|
||||
String raw = fetchGoProStatus();
|
||||
GoProStatus status = parseStatus(raw);
|
||||
|
||||
if (status.valid) {
|
||||
goproOnline = true;
|
||||
if (cfg.camera_id.length() > 0) {
|
||||
publishStatus(status);
|
||||
}
|
||||
} else {
|
||||
goproOnline = false;
|
||||
if (cfg.camera_id.length() > 0) {
|
||||
GoProStatus offline = {};
|
||||
offline.valid = true;
|
||||
publishStatus(offline); // publish with online=false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat (every heartbeat_interval_sec) ──
|
||||
if (cfg.camera_id.length() > 0 &&
|
||||
now - lastHeartbeatMs > (unsigned long)(cfg.heartbeat_interval_sec * 1000)) {
|
||||
lastHeartbeatMs = now;
|
||||
publishHeartbeat();
|
||||
}
|
||||
|
||||
// ── Save config if dirty ──
|
||||
if (cfg.dirty) {
|
||||
cfg.dirty = false;
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
delay(100);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# RemoteRig Hardware Design Pipeline
|
||||
|
||||
> Living queue for 3D-printed / physical hardware design work.
|
||||
|
||||
## Active / Ready for prototype print
|
||||
|
||||
### Tripod electronics case v3
|
||||
|
||||
**Status:** STL generated and validated watertight.
|
||||
|
||||
**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`
|
||||
|
||||
**Design notes:**
|
||||
- Holds 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.
|
||||
|
||||
**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?
|
||||
|
||||
## Backlog
|
||||
|
||||
### 10.1-inch touchscreen + Raspberry Pi Zero case
|
||||
|
||||
**Status:** Specific display identified; mechanical measurements needed before CAD.
|
||||
|
||||
**Goal:** A printable enclosure for the RemoteRig hub/control panel using a 10.1-inch touchscreen and Raspberry Pi Zero / Zero 2 W.
|
||||
|
||||
**Display target:**
|
||||
- Vendor/model: HZWDONE Raspberry Pi Screen 10.1" Touchscreen
|
||||
- Resolution: 1024×600
|
||||
- Interface: HDMI portable monitor
|
||||
- Mounting: includes fixing holes
|
||||
- Compatibility listing: Raspberry Pi 5/4/3B/B+ and Windows 11/10/8
|
||||
|
||||
**Initial assumptions to validate:**
|
||||
- Compute: Raspberry Pi Zero / Zero 2 W mounted behind or below the display.
|
||||
- Use case: RemoteRig local monitor/control panel at field recording setup.
|
||||
- Likely needs: front bezel, rear electronics cavity, Pi mounting posts, HDMI/USB/power cable exits, strain relief, ventilation, and optional tripod/stand mounting.
|
||||
- Because this is a 10.1" panel, design should prioritize rigidity: thicker bezel ribs, rear standoffs, and possibly a two-piece shell instead of a small snap case.
|
||||
|
||||
**Required measurements before CAD:**
|
||||
- Product link or datasheet for the exact HZWDONE 10.1" variant.
|
||||
- Screen/PCB outer dimensions: width, height, thickness.
|
||||
- Active display opening dimensions.
|
||||
- Fixing-hole locations, hole diameter, and screw size.
|
||||
- Connector locations/orientation for HDMI, USB touch, and power.
|
||||
- Whether the driver/controller board is integrated with the display PCB or separate.
|
||||
- Pi Zero orientation, port access requirements, and whether GPIO/header must remain accessible.
|
||||
- Power connector position and desired cable routing.
|
||||
- Mounting preference: desktop kickstand, tripod clamp, VESA-style holes, handle, or combination.
|
||||
|
||||
**Proposed design approach:**
|
||||
1. Create `hardware/display-case/`.
|
||||
2. Build a parametric OpenSCAD model with measured display/Pi dimensions.
|
||||
3. Split into printable parts: front bezel, rear shell, Pi/controller tray, optional stand/tripod mount.
|
||||
4. Validate STLs with OpenSCAD + trimesh.
|
||||
5. Upload generated STL/SCAD artifacts to Seafile.
|
||||
+73
-101
@@ -1,140 +1,112 @@
|
||||
# RemoteRig — Camera Node Hardware Design
|
||||
|
||||
> **Version:** 0.1.0 | **Status:** Draft
|
||||
> **Target:** GoPro Hero 3 Black/Silver + ESP32 D1 Mini + 1000mAh LiPo
|
||||
> **Version:** 0.2.0 | **Status:** Draft
|
||||
> **Target:** GoPro Hero 3 Black/Silver + ESP8266 + ESP32 + USB power bank
|
||||
|
||||
## Overview
|
||||
|
||||
Each camera node is a self-contained unit clipped onto a GoPro Hero 3. It provides:
|
||||
- Camera control (start/stop recording) via Wi-Fi
|
||||
- Status monitoring (battery, storage, recording state)
|
||||
- MQTT communication to the central Pi Zero 2 W hub
|
||||
- Battery power for both the ESP32 and GoPro
|
||||
|
||||
## Physical Assembly
|
||||
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.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ GoPro Hero 3 │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ Lens (front) │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ Screen │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ ┌──────────┐ │
|
||||
│ 3D Sleeve ─────→│ ESP32 │ │ ← clips onto back/bottom
|
||||
│ │ D1 Mini │ │
|
||||
│ └──────────┘ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ LiPo │ │ ← slides under GoPro
|
||||
│ │ 1000mAh │ │
|
||||
│ └──────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
┌─────────────────┐
|
||||
│ 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) │ │
|
||||
│ └────────────┘ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Bill of Materials
|
||||
|
||||
| Item | Qty | Cost | Notes |
|
||||
|------|-----|------|-------|
|
||||
| GoPro Hero 3 Black/Silver | 1 | Already owned | Target camera |
|
||||
| ESP32 D1 Mini | 1 | ~$4 | Or NodeMCU-32S (~$5) |
|
||||
| LiPo 3.7V 1000mAh | 1 | ~$8 | 50x34x8mm typical |
|
||||
| 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB |
|
||||
| 3.3V buck converter | 1 | ~$1 | LiPo → ESP32 VIN |
|
||||
| JST-XH 2-pin connectors | 2 | ~$1 | Battery quick-disconnect |
|
||||
| Micro-USB right-angle cable | 1 | ~$2 | Buck → GoPro |
|
||||
| Velcro strap (20cm) | 1 | ~$0.50 | Secure to GoPro |
|
||||
| PETG filament | ~30g | ~$0.60 | 3D printed case |
|
||||
| ESP32 Dev Board | 1 | ~$5 | MQTT bridge — talks to hub |
|
||||
| ESP8266 D1 Mini | 1 | ~$3 | Camera bridge — talks to GoPro |
|
||||
| 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 |
|
||||
|
||||
**Total per node:** ~$20
|
||||
**Total per node:** ~$21 (+ GoPro already owned)
|
||||
|
||||
## 3D Printed Case
|
||||
|
||||
The case consists of three parts (see `hardware/case/remoterig-case.scad`):
|
||||
**Current source:** `hardware/case/tripod-case-v3.scad`
|
||||
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
|
||||
|
||||
### Part 1: GoPro Sleeve
|
||||
Wraps around the GoPro body with cutouts for:
|
||||
- Lens (front)
|
||||
- Screen/viewfinder (back)
|
||||
- USB port (side)
|
||||
- Bottom mounting fingers
|
||||
- Mounting ears for electronics compartment
|
||||
|
||||
### Part 2: Electronics Compartment
|
||||
Clips onto the sleeve's mounting ears. Holds:
|
||||
- ESP32 D1 Mini board (recessed fit)
|
||||
- USB cable routing (in → ESP32, out → GoPro)
|
||||
- Ventilation slots (top)
|
||||
- LED visibility window
|
||||
|
||||
### Part 3: Battery Compartment
|
||||
Slides under the GoPro. Contains:
|
||||
- LiPo battery cavity
|
||||
- Cable exits (to ESP32, to GoPro buck converter)
|
||||
- Velcro strap slots
|
||||
Four exported prototype files:
|
||||
1. **Case body** — holds both boards stacked, cable ports, rear dovetail-style receiver
|
||||
2. **Case lid** — screw-on cover with ventilation
|
||||
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
|
||||
|
||||
### Print Settings
|
||||
- **Material:** PETG (outdoor/heat resistant) or PLA+
|
||||
- **Layer height:** 0.2mm
|
||||
- **Infill:** 20% gyroid
|
||||
- **Supports:** Yes (for cable channels)
|
||||
- **Bed adhesion:** Brim (5mm) for sleeve
|
||||
- **Orientation:** Print sleeve on its back, compartments flat
|
||||
- **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
|
||||
|
||||
## Wiring
|
||||
|
||||
```
|
||||
LiPo 3.7V
|
||||
├── JST-XH connector
|
||||
USB Power Bank
|
||||
├── USB-A → Micro-USB cable → ESP32 USB port
|
||||
│ (powers ESP32, shared 5V rail)
|
||||
│
|
||||
├──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port
|
||||
│ (power only — no data over USB)
|
||||
├── USB-A → Micro-USB cable → GoPro USB port
|
||||
│ (power only — no data)
|
||||
│
|
||||
└──→ 3.3V Buck Converter → ESP32 VIN + GND
|
||||
(or ESP32 D1 Mini has built-in regulator — connect directly to 5V pin)
|
||||
└── (ESP8266 powered via ESP32 3.3V pin, or via shared USB)
|
||||
|
||||
UART (inside case):
|
||||
ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16)
|
||||
ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17)
|
||||
ESP8266 GND ─────────── ESP32 GND
|
||||
```
|
||||
|
||||
**Note:** ESP32 D1 Mini has an onboard 3.3V regulator. You can feed it 5V directly to the 5V pin if using a single 5V buck converter. This simplifies wiring:
|
||||
```
|
||||
LiPo → 5V Buck → ├── ESP32 5V pin
|
||||
└── GoPro USB port
|
||||
```
|
||||
**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.
|
||||
|
||||
## Wi-Fi Topology (No Cables for Camera Control)
|
||||
## Wi-Fi Topology
|
||||
|
||||
```
|
||||
GoPro Hero 3 ──(Wi-Fi AP @ 10.5.5.1)──→ ESP32 STA #1
|
||||
GoPro Hero 3 ──(AP @ 10.5.5.1)──→ ESP8266 (camera bridge)
|
||||
│
|
||||
Travel Router ──(Wi-Fi AP)─────────────────→ ESP32 STA #2
|
||||
(192.168.4.1) │
|
||||
UART │ (inside case)
|
||||
│
|
||||
└──→ MQTT → Pi Hub (192.168.4.10)
|
||||
Travel Router ──(AP)─────────────────→ ESP32 (MQTT bridge)
|
||||
(10.60.1.1) │
|
||||
│
|
||||
MQTT │
|
||||
▼
|
||||
Pi Hub (10.60.1.56)
|
||||
```
|
||||
|
||||
The ESP32 has **no wired data connection** to the GoPro. All camera control is over Wi-Fi. The USB cable is **power only**.
|
||||
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.
|
||||
|
||||
## Enclosure Dimensions
|
||||
## Field Setup
|
||||
|
||||
| Component | W × H × D (mm) |
|
||||
|-----------|-----------------|
|
||||
| GoPro Hero 3 | 60 × 42 × 30 |
|
||||
| ESP32 D1 Mini | 34 × 26 × 5 |
|
||||
| LiPo 1000mAh | 50 × 34 × 8 |
|
||||
| Full assembly | ~70 × 60 × 55 |
|
||||
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`
|
||||
|
||||
## Usage in the Field
|
||||
## Case Dimensions
|
||||
|
||||
1. **Pre-show:** Charge LiPos, flash ESP32 firmware, verify MQTT connectivity
|
||||
2. **At venue:** Mount cameras, power on ESP32s (they auto-connect to travel router)
|
||||
3. **Monitoring:** Open `http://192.168.4.10:8080` on laptop/kiosk
|
||||
4. **Control:** Start/stop recording from dashboard
|
||||
5. **Post-show:** Stop recording, power down, swap batteries for next session
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- **Hot-swap battery:** Quick-release battery tray with spring contacts
|
||||
- **Weather sealing:** O-ring groove in sleeve for outdoor rain protection
|
||||
- **Lens hood:** Integrated sun shield for outdoor daytime recording
|
||||
- **Mount adapter:** 1/4"-20 tripod mount thread on bottom
|
||||
- **Antenna routing:** External antenna connector for improved Wi-Fi range in stadiums
|
||||
| | W × D × H (mm) |
|
||||
|---|---|
|
||||
| Case body external | ~56.8 × 38.2 × 19.0 |
|
||||
| Lid external | ~56.8 × 32.8 × 4.0 |
|
||||
| Tripod clamp | ~43.0 × 56.9 × 16.0 |
|
||||
| Clamp pole fit | Nominal 35mm; smaller poles TBD / may need inserts |
|
||||
| Total weight | TBD after prototype print |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) case_body();
|
||||
@@ -0,0 +1,2 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) case_lid();
|
||||
@@ -0,0 +1,2 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) full_case();
|
||||
@@ -0,0 +1,2 @@
|
||||
include <tripod-case-v3.scad>;
|
||||
render(convexity=10) tripod_clamp();
|
||||
Binary file not shown.
@@ -1,205 +0,0 @@
|
||||
// RemoteRig — GoPro Hero 3 + ESP32 Camera Case
|
||||
// ==============================================
|
||||
// Sleeve that wraps around GoPro Hero 3 body with ESP32 + LiPo compartment.
|
||||
// Designed for: ESP32 D1 Mini, 1000mAh LiPo, GoPro Hero 3 Black/Silver.
|
||||
//
|
||||
// Print settings:
|
||||
// Material: PETG (outdoor/heat) or PLA+ (indoor)
|
||||
// Layer: 0.2mm | Infill: 20% gyroid | Supports: yes (for cable channels)
|
||||
// Nozzle: 0.4mm | Bed: 60°C (PLA) / 80°C (PETG)
|
||||
|
||||
// ── GoPro Hero 3 Body (approximate) ──
|
||||
gopro_width = 60; // mm — body width
|
||||
gopro_height = 42; // mm — body height (top to bottom)
|
||||
gopro_depth = 30; // mm — body depth (front to back)
|
||||
gopro_lens_dia = 28; // mm — lens protrusion diameter
|
||||
gopro_lens_offset = 18; // mm — lens center from top
|
||||
|
||||
// ── ESP32 D1 Mini ──
|
||||
esp_width = 34.2;
|
||||
esp_height = 25.6;
|
||||
esp_thick = 5; // board + components
|
||||
usb_cutout_w = 10;
|
||||
usb_cutout_h = 5;
|
||||
|
||||
// ── LiPo Battery (1000mAh typical) ──
|
||||
lipo_width = 35;
|
||||
lipo_height = 25;
|
||||
lipo_thick = 8;
|
||||
|
||||
// ── Case parameters ──
|
||||
wall = 2.0; // case wall thickness
|
||||
tolerance = 0.3; // print tolerance for friction fit
|
||||
compartment_height = max(esp_thick, lipo_thick) + 3; // internal compartment height
|
||||
|
||||
// ── Cable channels ──
|
||||
cable_dia = 4; // USB cable diameter
|
||||
cable_channel_depth = 3;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MAIN ASSEMBLY
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// Uncomment the part you want to export:
|
||||
gopro_sleeve();
|
||||
// translate([0, -20, 0]) electronics_compartment();
|
||||
// translate([0, 20, 0]) battery_compartment();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// GoPro Sleeve — wraps around the GoPro body
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module gopro_sleeve() {
|
||||
union() {
|
||||
// Main sleeve body — wraps around GoPro
|
||||
difference() {
|
||||
// Outer shell
|
||||
rounded_cube(
|
||||
gopro_width + wall*2,
|
||||
gopro_height + wall*2,
|
||||
gopro_depth + wall*2,
|
||||
4 // corner radius
|
||||
);
|
||||
|
||||
// Inner cavity (GoPro body)
|
||||
translate([0, 0, wall])
|
||||
rounded_cube(
|
||||
gopro_width + tolerance,
|
||||
gopro_height + tolerance,
|
||||
gopro_depth + tolerance,
|
||||
3
|
||||
);
|
||||
|
||||
// Lens cutout (front face)
|
||||
translate([0, gopro_height/2 - gopro_lens_offset, 0])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d=gopro_lens_dia + 4, h=wall*3, center=true);
|
||||
|
||||
// Front screen/viewfinder cutout
|
||||
translate([0, gopro_height/2 - gopro_lens_offset - 18, wall*2])
|
||||
cube([gopro_width - 10, gopro_height - 20, wall*4], center=true);
|
||||
|
||||
// Bottom cutout (for GoPro mounting fingers)
|
||||
translate([0, 0, gopro_depth/2 + wall])
|
||||
cube([gopro_width - 10, wall*4, wall*4], center=true);
|
||||
|
||||
// USB port access (side)
|
||||
translate([gopro_width/2 + wall, 0, -5])
|
||||
cube([wall*4, 16, 10], center=true);
|
||||
|
||||
// Cable channel from ESP32 compartment to GoPro USB
|
||||
translate([gopro_width/2 - 5, -gopro_height/2 + 10, -gopro_depth/2 + 5])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
||||
}
|
||||
|
||||
// Mounting ears for electronics compartment
|
||||
for (x = [-1, 1]) {
|
||||
translate([x * (gopro_width/2 - 6), -gopro_height/2 - 6, 0])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d=8, h=10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Electronics Compartment — holds ESP32 + routes cables
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module electronics_compartment() {
|
||||
comp_w = max(esp_width, esp_height) + wall*2 + 10;
|
||||
comp_h = compartment_height + wall*2;
|
||||
comp_d = gopro_depth + wall*2;
|
||||
|
||||
difference() {
|
||||
union() {
|
||||
// Main box
|
||||
rounded_cube(comp_w, comp_d, comp_h, 3);
|
||||
|
||||
// Mounting tabs (match GoPro sleeve ears)
|
||||
for (x = [-1, 1]) {
|
||||
translate([x * (gopro_width/2 - 6), 0, comp_h/2])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(d=6, h=4, center=true);
|
||||
}
|
||||
}
|
||||
|
||||
// Inner cavity
|
||||
translate([0, 0, wall])
|
||||
rounded_cube(comp_w - wall*2, comp_d - wall*2, comp_h - wall, 2);
|
||||
|
||||
// ESP32 board recess
|
||||
translate([0, 5, wall + 1])
|
||||
cube([esp_width + tolerance, esp_height + tolerance, esp_thick + 1], center=true);
|
||||
|
||||
// USB cable entry (side hole)
|
||||
translate([comp_w/2, 15, comp_h/2])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(d=usb_cutout_w, h=wall*3, center=true);
|
||||
|
||||
// USB cable exit (to GoPro)
|
||||
translate([comp_w/2, -15, comp_h/2])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
||||
|
||||
// Ventilation slots
|
||||
for (y = [-1:2:1]) {
|
||||
for (i = [-15:10:15]) {
|
||||
translate([i, y * comp_d/3, comp_h - 2])
|
||||
cube([6, 1.5, wall*2], center=true);
|
||||
}
|
||||
}
|
||||
|
||||
// LED window (thin wall for ESP32 LED visibility)
|
||||
translate([0, 0, wall])
|
||||
cube([5, 5, wall], center=true);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Battery Compartment — holds LiPo under GoPro
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module battery_compartment() {
|
||||
bat_w = lipo_width + wall*2 + tolerance;
|
||||
bat_d = lipo_height + wall*2 + tolerance;
|
||||
bat_h = lipo_thick + wall*2 + 4;
|
||||
|
||||
difference() {
|
||||
// Shell
|
||||
rounded_cube(bat_w, bat_d, bat_h, 3);
|
||||
|
||||
// Battery cavity
|
||||
translate([0, 0, wall])
|
||||
rounded_cube(lipo_width + tolerance, lipo_height + tolerance, lipo_thick + tolerance, 1);
|
||||
|
||||
// Cable exit (to ESP32 compartment)
|
||||
translate([0, bat_d/2, bat_h/2])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
||||
|
||||
// Cable exit (to GoPro USB)
|
||||
translate([bat_w/3, -bat_d/2, bat_h/2])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(d=cable_dia, h=wall*3, center=true);
|
||||
|
||||
// Strap slots (velcro strap to secure to GoPro)
|
||||
for (x = [-bat_w/3, bat_w/3]) {
|
||||
translate([x, -bat_d/2, bat_h/2])
|
||||
cube([8, wall*4, 3], center=true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Utility: Rounded cube (positive X/Y/Z = full dimensions)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// RemoteRig — Dual-ESP Tripod Case v3
|
||||
// v3 changes: screw-tightened tripod clamp + dovetail slide interface.
|
||||
// Coordinate system: all case/lid geometry uses bottom-origin Z.
|
||||
|
||||
$fn = 36;
|
||||
|
||||
// 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;
|
||||
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; // 56.8mm
|
||||
outer_d = inner_d + wall*2 + tol*2; // 32.8mm
|
||||
outer_h = inner_h + wall*2; // 19mm
|
||||
corner_r = 2.5;
|
||||
|
||||
// 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
|
||||
|
||||
// Dovetail slide interface
|
||||
// Male rail is on the case; matching female socket is on the tripod clamp.
|
||||
// This is easier to inspect and avoids the previous mismatched "two lips + tab" geometry.
|
||||
rail_z = outer_h * 0.78;
|
||||
rail_depth = 5.0;
|
||||
rail_neck_w = 12.0; // narrow width at case wall / slot opening
|
||||
rail_outer_w = 18.0; // wider retained edge
|
||||
rail_clearance = 0.45; // FDM sliding clearance per side-ish
|
||||
socket_wall = 2.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_male_dovetail_rail() {
|
||||
// Positive tapered rail on the case back. Cross-section is narrow at the
|
||||
// wall and wider at the outside, so the clamp socket captures it.
|
||||
translate([0, outer_d/2 - 0.15, outer_h/2])
|
||||
dovetail_prism(rail_z, rail_neck_w, rail_outer_w, rail_depth);
|
||||
|
||||
// Bottom stop so the clamp socket cannot slide past the case.
|
||||
translate([0, outer_d/2 + rail_depth/2, outer_h*0.12])
|
||||
rounded_cube_centered(rail_outer_w + 3.0, rail_depth + 0.8, 2.4, 0.8);
|
||||
}
|
||||
|
||||
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_male_dovetail_rail();
|
||||
}
|
||||
}
|
||||
|
||||
module case_lid() {
|
||||
difference() {
|
||||
rounded_cube0(outer_w, outer_d, wall*2, 1.8);
|
||||
|
||||
for (x = [-1, 1], y = [-1, 1]) {
|
||||
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), -0.5])
|
||||
cylinder(d=2.4, h=wall*2 + 1, center=false, $fn=20);
|
||||
}
|
||||
|
||||
for (x = [-outer_w/4, 0, outer_w/4]) {
|
||||
translate([x, 0, wall*2/2])
|
||||
cube([8, outer_d*0.6, wall*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 + rail_clearance, 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_dovetail_socket() {
|
||||
outer_r = pole_dia/2 + clamp_thick;
|
||||
socket_outer_w = rail_outer_w + socket_wall*2;
|
||||
socket_depth = rail_depth + socket_wall*2;
|
||||
|
||||
// Solid boss on the rear of the clamp, opposite the tightening mouth.
|
||||
// A matching dovetail void is cut through it along Z so the case rail
|
||||
// slides in from the top/bottom with practical FDM clearance.
|
||||
difference() {
|
||||
translate([0, -outer_r - socket_depth/2 + socket_wall, 0])
|
||||
rounded_cube_centered(socket_outer_w, socket_depth, clamp_width, 1.2);
|
||||
|
||||
translate([0, -outer_r - 0.15, 0])
|
||||
dovetail_prism(
|
||||
clamp_width + 1.0,
|
||||
rail_neck_w + rail_clearance,
|
||||
rail_outer_w + rail_clearance,
|
||||
rail_depth + 0.6
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module tripod_clamp() {
|
||||
union() {
|
||||
clamp_ring_with_mouth();
|
||||
clamp_ears();
|
||||
clamp_dovetail_socket();
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible alias for earlier export scripts.
|
||||
module tripod_clip() {
|
||||
tripod_clamp();
|
||||
}
|
||||
|
||||
module full_case() {
|
||||
case_body();
|
||||
translate([0, 0, outer_h + 2]) case_lid();
|
||||
translate([0, outer_d/2 + pole_dia/2 + clamp_thick + 8, outer_h/2])
|
||||
rotate([90, 0, 0]) tripod_clamp();
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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.
@@ -0,0 +1,274 @@
|
||||
<!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>
|
||||
@@ -132,10 +132,10 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
|
||||
// Get camera info
|
||||
var c models.Camera
|
||||
err := database.QueryRowContext(r.Context(), `
|
||||
SELECT camera_id, friendly_name, mac_address, created_at, updated_at
|
||||
SELECT camera_id, friendly_name, mac_address, battery_calibration_offset, created_at, updated_at
|
||||
FROM cameras WHERE camera_id = ?
|
||||
`, cameraID).Scan(
|
||||
&c.CameraID, &c.FriendlyName, &c.MacAddress,
|
||||
&c.CameraID, &c.FriendlyName, &c.MacAddress, &c.BatteryCalibrationOffset,
|
||||
&c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
//go:embed migrations/001_create_tables.sql
|
||||
var migration001 string
|
||||
|
||||
//go:embed migrations/002_add_camera_calibration.sql
|
||||
var migration002 string
|
||||
|
||||
// DB wraps the sql.DB with connection-level settings.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
@@ -62,6 +65,12 @@ func Open(path string) (*DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
log.Println("Migrations complete")
|
||||
} else {
|
||||
// Run incremental migrations on existing databases
|
||||
if err := runIncrementalMigrations(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &DB{db}, nil
|
||||
@@ -83,6 +92,25 @@ func migrate(db *sql.DB, sql string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runIncrementalMigrations applies migrations that haven't been run yet on
|
||||
// an existing database (one where the 001 schema already exists).
|
||||
func runIncrementalMigrations(db *sql.DB) error {
|
||||
// Migration 002: add battery_calibration_offset if it doesn't exist
|
||||
var colCount int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('cameras') WHERE name = 'battery_calibration_offset'`).Scan(&colCount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if colCount == 0 {
|
||||
log.Println("Running migration 002: add battery_calibration_offset")
|
||||
if err := migrate(db, migration002); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
|
||||
func splitSQL(sql string) []string {
|
||||
var stmts []string
|
||||
|
||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS cameras (
|
||||
camera_id TEXT PRIMARY KEY,
|
||||
friendly_name TEXT NOT NULL,
|
||||
mac_address TEXT UNIQUE,
|
||||
battery_calibration_offset REAL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration 002: Add battery_calibration_offset to cameras table
|
||||
-- This column stores a per-camera calibration value for converting raw battery
|
||||
-- readings (e.g. GoPro Hero 3 byte at offset 57) into percentage values.
|
||||
|
||||
ALTER TABLE cameras ADD COLUMN battery_calibration_offset REAL;
|
||||
@@ -404,9 +404,9 @@ func extractCameraID(topic string) string {
|
||||
func getCamera(db *db.DB, cameraID string) (models.Camera, error) {
|
||||
var cam models.Camera
|
||||
err := db.QueryRow(
|
||||
"SELECT camera_id, friendly_name, COALESCE(mac_address,''), created_at, updated_at FROM cameras WHERE camera_id = ?",
|
||||
"SELECT camera_id, friendly_name, COALESCE(mac_address,''), COALESCE(battery_calibration_offset, NULL), created_at, updated_at FROM cameras WHERE camera_id = ?",
|
||||
cameraID,
|
||||
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.CreatedAt, &cam.UpdatedAt)
|
||||
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.BatteryCalibrationOffset, &cam.CreatedAt, &cam.UpdatedAt)
|
||||
return cam, err
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ type Camera struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
BatteryCalibrationOffset *float64 `json:"battery_calibration_offset,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
+7
-7
@@ -9,8 +9,8 @@
|
||||
# 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: 192.168.4.10/24)
|
||||
# --gateway IP Gateway for wlan0 (default: 192.168.4.1)
|
||||
# --static-ip IP Static IP for wlan0 (default: 10.60.1.56/24)
|
||||
# --gateway IP Gateway for wlan0 (default: 10.60.1.1)
|
||||
# --help Show this help
|
||||
|
||||
set -euo pipefail
|
||||
@@ -20,8 +20,8 @@ set -euo pipefail
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_TEMPLATE=""
|
||||
SERVICE_USER="pi"
|
||||
STATIC_IP="192.168.4.10/24"
|
||||
GATEWAY="192.168.4.1"
|
||||
STATIC_IP="10.60.1.56/24"
|
||||
GATEWAY="10.60.1.1"
|
||||
MOSQUITTO_PKG="mosquitto mosquitto-clients"
|
||||
DEPLOY_DIR="/opt/remoterig"
|
||||
SERVICE_NAME="remoterig"
|
||||
@@ -324,13 +324,13 @@ 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@192.168.4.10:/opt/remoterig/"
|
||||
echo " scp remoterig pi@10.60.1.56:/opt/remoterig/"
|
||||
echo " 3. Copy config if needed:"
|
||||
echo " scp config.yaml pi@192.168.4.10:/opt/remoterig/"
|
||||
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://192.168.4.10:8080/health"
|
||||
echo " curl http://10.60.1.56:8080/health"
|
||||
echo ""
|
||||
echo " To deploy updates, use: scripts/deploy.sh"
|
||||
echo "=============================================="
|
||||
|
||||
Reference in New Issue
Block a user