diff --git a/RocketControlUnitGUI/src/routes/live-feed/+page.svelte b/RocketControlUnitGUI/src/routes/live-feed/+page.svelte index f841b62..0d7ab65 100644 --- a/RocketControlUnitGUI/src/routes/live-feed/+page.svelte +++ b/RocketControlUnitGUI/src/routes/live-feed/+page.svelte @@ -12,6 +12,7 @@ switchCommand: string; enableCommand: string; disableCommand: string; + settings?: any; }; const timestamps = initTimestamps(); @@ -46,7 +47,30 @@ } ]; - $: selectedCamera = cameras.find((camera) => camera.id === selectedCameraId) ?? cameras[0]; +// per-camera settings map (keeps settings separate so each camera is independent) +const defaultCameraSettings = (id: number) => ({ + recording: false, + frequencyMHz: 1258, + power: '25mW' +}); + +let cameraSettings: Record = {}; +// ensure defaults are available during SSR to avoid undefined template reads +for (const c of cameras) cameraSettings[c.id] = defaultCameraSettings(c.id); + +// initialize per-camera defaults for SSR so template bindings are safe +// per-camera busy flags for pending commands (not persisted) +let cameraBusy: Record = {}; +for (const c of cameras) cameraBusy[c.id] = false; + +let loadedPersistedState = false; +let globalVideoTxOn = false; +let globalVideoFrequencyMHz = 1258; +let globalVideoPower = '25mW'; + +// settings will be initialized on mount (browser-only) + +$: selectedCamera = cameras.find((camera) => camera.id === selectedCameraId) ?? cameras[0]; onMount(() => { let heartbeatInterval: ReturnType; @@ -63,6 +87,46 @@ handleAuth(); + // load camera persisted states (browser-only) + try { + if (typeof window !== 'undefined') { + const raw = localStorage.getItem('camera_states'); + if (raw) { + const parsed = JSON.parse(raw); + // support legacy format where parsed is a map of id -> {enabled,settings} + const data = parsed.cameras ? parsed : { cameras: parsed, selectedCameraId: parsed.selectedCameraId }; + // restore enabled flags and separate settings map + cameraSettings = {}; + cameras = cameras.map((c) => { + const saved = data.cameras[c.id] ?? {}; + cameraSettings[c.id] = { ...defaultCameraSettings(c.id), ...(saved.settings ?? saved) }; + return { ...c, enabled: saved.enabled ?? c.enabled }; + }); + // restore selectedCameraId if present + if (data.selectedCameraId) selectedCameraId = data.selectedCameraId; + // restore global video tx state if present + if (data.videoTxOn !== undefined) globalVideoTxOn = data.videoTxOn; + if (data.videoFrequencyMHz !== undefined) globalVideoFrequencyMHz = data.videoFrequencyMHz; + if (data.videoPower !== undefined) globalVideoPower = data.videoPower; + } else { + cameraSettings = {}; + cameras = cameras.map((c) => { + cameraSettings[c.id] = defaultCameraSettings(c.id); + return c; + }); + } + } + } catch (e) { + cameraSettings = {}; + cameras = cameras.map((c) => { + cameraSettings[c.id] = defaultCameraSettings(c.id); + return c; + }); + } + + // mark that initial load finished so reactive persist can start + loadedPersistedState = true; + return () => { clearInterval(heartbeatInterval); }; @@ -74,6 +138,9 @@ ); await writeArbitraryCommand('NODE_FCB', enabled ? camera.enableCommand : camera.disableCommand); + + // persist camera enabled state and settings + if (loadedPersistedState) persistCameraStates(); }; const handleCameraToggle = async (event: Event, camera: Camera) => { @@ -87,6 +154,92 @@ selectedCameraId = camera.id; await writeArbitraryCommand('NODE_FCB', camera.switchCommand); }; + +const persistCameraStates = () => { + try { + const map: Record = {}; + for (const c of cameras) map[c.id] = { enabled: c.enabled, settings: cameraSettings[c.id] }; + const payload = { + selectedCameraId, + cameras: map, + videoTxOn: globalVideoTxOn, + videoFrequencyMHz: globalVideoFrequencyMHz, + videoPower: globalVideoPower + }; + localStorage.setItem('camera_states', JSON.stringify(payload)); + } catch (e) { + // ignore + } +}; + +const updateCameraSetting = async (cameraId: number, partial: Partial) => { + cameraSettings = { ...cameraSettings, [cameraId]: { ...cameraSettings[cameraId], ...partial } }; + // persist immediately + if (loadedPersistedState) persistCameraStates(); +}; + +const sendCameraCommand = async (cameraId: number, command: string) => { + // forward to backend / proto via existing command mechanism + await writeArbitraryCommand('NODE_FCB', command); +}; + +// reactive: persist when cameraSettings or cameras or selectedCameraId change after initial load +$: if (loadedPersistedState) { + // reference these so Svelte tracks them + cameraSettings; cameras; selectedCameraId; + persistCameraStates(); +} + +const pressPowerButton = async (cameraId: number) => { + await sendCameraCommand(cameraId, `RSC_CAM${cameraId}_POWER_BUTTON`); +}; + +const pressWifiButton = async (cameraId: number) => { + await sendCameraCommand(cameraId, `RSC_CAM${cameraId}_WIFI_BUTTON`); +}; + +const toggleRecording = async (cameraId: number) => { + const prev = cameraSettings[cameraId]?.recording; + const next = !prev; + + // immediate toggle for UI + cameraSettings = { ...cameraSettings, [cameraId]: { ...cameraSettings[cameraId], recording: next } }; + persistCameraStates(); + + // send command in background (do not block UI) + sendCameraCommand(cameraId, next ? `RSC_CAM${cameraId}_REC_START` : `RSC_CAM${cameraId}_REC_STOP`).catch(() => { + // optionally revert on failure — for now, leave UI toggled + }); +}; + +const toggleVideoTx = async (cameraId: number) => { + // legacy per-camera toggle — map to global toggle + globalVideoTxOn = !globalVideoTxOn; + persistCameraStates(); + // send global command + sendCameraCommand(0, globalVideoTxOn ? `RSC_CAM_TX_ON` : `RSC_CAM_TX_OFF`).catch(() => {}); +}; + +const toggleVideoTxGlobal = async () => { + globalVideoTxOn = !globalVideoTxOn; + persistCameraStates(); + sendCameraCommand(0, globalVideoTxOn ? `RSC_CAM_TX_ON` : `RSC_CAM_TX_OFF`).catch(() => {}); +}; + +const setGlobalVideoFrequency = async (freqMHz: number) => { + const clamped = Math.max(1258, Math.min(1280, Math.round(freqMHz))); + // send global freq command + sendCameraCommand(0, `RSC_CAM_TX_FREQ_${clamped}`).catch(() => {}); + globalVideoFrequencyMHz = clamped; + if (loadedPersistedState) persistCameraStates(); +}; + +const setGlobalVideoPower = async (power: string) => { + const numeric = power.replace(/[^0-9]/g, '') || '25'; + sendCameraCommand(0, `RSC_CAM_TX_POWER_${numeric}`).catch(() => {}); + globalVideoPower = power; + if (loadedPersistedState) persistCameraStates(); +}; @@ -103,9 +256,27 @@
-
-
{selectedCamera.id}
-

{selectedCamera.name}

+ {#if selectedCamera.enabled} +
+
{selectedCamera.id}
+

{selectedCamera.name}

+
+ {/if} + +
+

{selectedCamera.name} — Settings

+
+ + + +
+
@@ -125,6 +296,33 @@ {/each}
+
+

Video TX

+ toggleVideoTxGlobal()} + /> +
+ + +
+
+
{#each cameras as camera}
@@ -286,6 +484,46 @@ font-size: 0.8rem; } + .camera-settings { + display: grid; + gap: 0.75rem; + color: rgba(255,255,255,0.9); + padding: 1rem; + } + + .settings-row { + display: flex; + gap: 1rem; + align-items: center; + } + + .btn { + padding: 0.5rem 0.75rem; + border-radius: 6px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.08); + color: inherit; + font-weight:700; + } + + .btn.recording { + background: rgb(var(--color-success-400)); + color: white; + border-color: rgb(var(--color-success-400)); + } + + /* make power and frequency text black (camera settings and global controls) */ + .camera-settings .numeric-input, + .camera-settings .power-select, + .global-tx-controls .numeric-input, + .global-tx-controls .power-select { + color: black; + background: white; + border-radius: 4px; + padding: 0.25rem 0.4rem; + border: 1px solid rgba(0,0,0,0.15); + } + @media (max-width: 900px) { .live-feed-page { grid-template-columns: 1fr; diff --git a/SoarCommunications b/SoarCommunications index 9fb7b8a..3c0b56a 160000 --- a/SoarCommunications +++ b/SoarCommunications @@ -1 +1 @@ -Subproject commit 9fb7b8a5c4e1b40baff4d47704290b106aa0e2cc +Subproject commit 3c0b56abb689fb924aba1352be8f9488d31d7c82