RemoteRig: Core infrastructure — MQTT subscriber, Pi deployment, ESP32 firmware, hardware design #5

Merged
overseer merged 33 commits from dev into main 2026-05-21 20:04:36 -04:00
7 changed files with 303 additions and 4 deletions
Showing only changes of commit fa0956c6fd - Show all commits
+33 -3
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
@@ -1395,7 +1396,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2071,7 +2072,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true "devOptional": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
@@ -3855,6 +3856,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zustand": {
"version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }
+2 -1
View File
@@ -12,7 +12,8 @@
"dependencies": { "dependencies": {
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
+2
View File
@@ -1,2 +1,4 @@
export { useCameraStatus } from './useCameraStatus' export { useCameraStatus } from './useCameraStatus'
export { useSystemHealth } from './useSystemHealth' export { useSystemHealth } from './useSystemHealth'
export { useSSE } from './useSSE'
export type { SSEConnectionState } from './useSSE'
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useCallback } from 'react'
import { useCameraStore } from '../store/useCameraStore'
import type { CameraStatus } from '../types'
/** Shape of events sent by the Go SSE hub */
interface SSEEvent {
type: string
ts: string
payload?: unknown
}
/** Connection states for the SSE stream */
export type SSEConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
interface UseSSEOptions {
/** Override the SSE endpoint (defaults to VITE_API_URL + /events/stream) */
endpoint?: string
}
interface UseSSEReturn {
/** Current connection state */
connectionState: SSEConnectionState
/** Last event timestamp */
lastEventTs: string | null
/** Manually close and reopen the connection */
reconnect: () => void
}
const DEFAULT_ENDPOINT = `${import.meta.env.VITE_API_URL || '/api/v1'}/events/stream`
/**
* `useSSE` connects to the RemoteRig SSE endpoint at `/api/v1/events/stream`
* and updates the shared camera store (`useCameraStore`) with live events.
*
* Handled event types:
* - `connected` — initial handshake
* - `camera_status` — full camera status snapshot
* - `camera_online` / `camera_offline` — online/offline state change
* - `recording_event` — recording started or stopped
*
* EventSource auto-reconnects natively; the hook also exposes a `reconnect()`
* helper for manual reconnect.
*/
export function useSSE(opts: UseSSEOptions = {}): UseSSEReturn {
const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT
const store = useCameraStore()
const connectionStateRef = useRef<SSEConnectionState>('disconnected')
const lastEventTsRef = useRef<string | null>(null)
const esRef = useRef<EventSource | null>(null)
// Derive a stable reference for connection state that doesn't trigger re-renders
// across every event. We return it via a getter callback but the store change
// re-renders are handled by Zustand selectors in consuming components.
const handleEvent = useCallback(
(event: MessageEvent) => {
try {
const data: SSEEvent = JSON.parse(event.data)
lastEventTsRef.current = data.ts
switch (data.type) {
case 'connected': {
// Initial handshake — no state update needed
connectionStateRef.current = 'connected'
break
}
case 'camera_status': {
// Full camera status payload — upsert into the store
const cam = data.payload as CameraStatus | undefined
if (cam?.camera_id) {
store.updateCamera(cam)
}
break
}
case 'camera_online': {
const pl = data.payload as { camera_id?: string } | undefined
if (pl?.camera_id) {
store.setOnline(pl.camera_id)
}
break
}
case 'camera_offline': {
const pl = data.payload as { camera_id?: string } | undefined
if (pl?.camera_id) {
store.setOffline(pl.camera_id)
}
break
}
case 'recording_event': {
const pl = data.payload as
| { camera_id?: string; recording?: boolean }
| undefined
if (pl?.camera_id && typeof pl.recording === 'boolean') {
store.setRecording(pl.camera_id, pl.recording)
}
break
}
default:
// Unknown event type — ignore gracefully
break
}
} catch {
// Malformed JSON — ignore
}
},
[store],
)
const connect = useCallback(() => {
// Close existing connection if any
esRef.current?.close()
connectionStateRef.current = 'connecting'
const es = new EventSource(endpoint)
esRef.current = es
es.onopen = () => {
connectionStateRef.current = 'connected'
}
es.onmessage = handleEvent
es.onerror = () => {
connectionStateRef.current =
es.readyState === EventSource.CLOSED ? 'disconnected' : 'error'
// EventSource auto-reconnects unless CLOSED; nothing extra needed here
}
}, [endpoint, handleEvent])
const reconnect = useCallback(() => {
esRef.current?.close()
connect()
}, [connect])
useEffect(() => {
connect()
return () => {
esRef.current?.close()
connectionStateRef.current = 'disconnected'
}
}, [connect])
return {
get connectionState() {
return connectionStateRef.current
},
get lastEventTs() {
return lastEventTsRef.current
},
reconnect,
}
}
+1
View File
@@ -0,0 +1 @@
export { useCameraStore } from './useCameraStore'
+86
View File
@@ -0,0 +1,86 @@
import { create } from 'zustand'
import type { CameraStatus } from '../types'
interface CameraState {
/** All cameras with latest status from SSE events */
cameras: Map<string, CameraStatus>
/** Replace the full camera list (e.g. on initial SSE or polling fallback) */
setCameras: (cameras: CameraStatus[]) => void
/** Upsert a single camera status from an SSE event */
updateCamera: (camera: CameraStatus) => void
/** Mark a camera offline */
setOffline: (cameraId: string) => void
/** Mark a camera online */
setOnline: (cameraId: string) => void
/** Update recording state from a recording_event */
setRecording: (cameraId: string, recording: boolean) => void
/** Derived: camera array (for UI consumption) */
getCameras: () => CameraStatus[]
/** Derived: online count */
getOnlineCount: () => number
/** Derived: recording count */
getRecordingCount: () => number
}
export const useCameraStore = create<CameraState>((set, get) => ({
cameras: new Map(),
setCameras: (list) =>
set((state) => {
const next = new Map(state.cameras)
for (const cam of list) {
next.set(cam.camera_id, cam)
}
return { cameras: next }
}),
updateCamera: (camera) =>
set((state) => {
const next = new Map(state.cameras)
next.set(camera.camera_id, camera)
return { cameras: next }
}),
setOffline: (cameraId) =>
set((state) => {
const cam = state.cameras.get(cameraId)
if (!cam) return state
const next = new Map(state.cameras)
next.set(cameraId, { ...cam, online: false })
return { cameras: next }
}),
setOnline: (cameraId) =>
set((state) => {
const cam = state.cameras.get(cameraId)
if (!cam) return state
const next = new Map(state.cameras)
next.set(cameraId, { ...cam, online: true })
return { cameras: next }
}),
setRecording: (cameraId, recording) =>
set((state) => {
const cam = state.cameras.get(cameraId)
if (!cam) return state
const next = new Map(state.cameras)
next.set(cameraId, { ...cam, recording })
return { cameras: next }
}),
getCameras: () => Array.from(get().cameras.values()),
getOnlineCount: () =>
Array.from(get().cameras.values()).filter((c) => c.online).length,
getRecordingCount: () =>
Array.from(get().cameras.values()).filter((c) => c.recording).length,
}))
+21
View File
@@ -1,5 +1,26 @@
// RemoteRig TypeScript types // RemoteRig TypeScript types
/** Full camera status from GET /api/v1/cameras and SSE events */
export interface CameraStatus {
camera_id: string
friendly_name: string
battery_pct: number | null
video_remaining_sec: number | null
recording: boolean
mode: string
resolution: string
fps: number
online: boolean
last_seen: string // ISO 8601
}
/** SSE event envelope from /api/v1/events/stream */
export interface SSEEvent {
type: string
ts: string
payload?: unknown
}
export interface Camera { export interface Camera {
id: string id: string
name: string name: string