From d9d594362f1f7187913070ec4ecf06893b89b73a Mon Sep 17 00:00:00 2001 From: brandonhs Date: Tue, 5 May 2026 16:36:38 -0700 Subject: [PATCH 01/15] Add frame drop stats to backend --- backend_py/src/services/cameras/device.py | 23 +++++++++++++++- .../src/services/cameras/device_manager.py | 26 ++++++++++++++++--- backend_py/src/services/cameras/ehd.py | 6 +++-- .../src/services/cameras/pydantic_schemas.py | 5 ++++ backend_py/src/services/cameras/shd.py | 4 +-- .../stream_engines/base_stream_engine.py | 4 ++- .../synchronized_stream_engine.py | 1 + .../src/services/cameras/stream_runner.py | 3 ++- .../cameras/synchronized_camera/lib.py | 8 +++++- 9 files changed, 69 insertions(+), 11 deletions(-) diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 7c417804..4cf4e390 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -10,6 +10,7 @@ import fcntl import logging import struct +import time from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any @@ -28,6 +29,7 @@ ControlTypeEnum, DeviceType, FormatSizeModel, + FrameDropStats, IntervalModel, MenuItemModel, StreamEncodeTypeEnum, @@ -312,8 +314,11 @@ def _clear(self) -> None: class Device(events.EventEmitter): - def __init__(self, device_info: DeviceInfo) -> None: + def __init__(self, device_info: DeviceInfo, event_bus: events.EventEmitter) -> None: super().__init__() + + self.event_bus = event_bus + self.cameras: list[Camera] = [] for device_path in device_info.device_paths: self.cameras.append(Camera(device_path)) @@ -334,9 +339,12 @@ def __init__(self, device_info: DeviceInfo) -> None: self.nickname = "" self.stream = Stream() + self.frame_stats = FrameDropStats(num_drops=0, drops_per_second=0) + # each device has a streamrunner, but not all of them are used if # they are a follower (shd) self.stream_runner = StreamRunner(self.stream) + self.stream_runner.on("frame_drop", self._update_drop_stats) for camera in self.cameras: for encoding in camera.formats: @@ -369,6 +377,15 @@ def __init__(self, device_info: DeviceInfo) -> None: self._get_controls() + self._stream_start_time = 0 + + def _update_drop_stats(self) -> None: + self.frame_stats.num_drops += 1 + self.frame_stats.drops_per_second = self.frame_stats.num_drops / float( + time.time_ns() - self._stream_start_time + ) + self.emit("drop_stats") + def _on_stream_error(self, err: str) -> None: self.logger.error(err) # TODO @@ -521,6 +538,9 @@ def start_stream(self) -> None: self.stream.enabled = True self.stream_runner.start() + self._stream_start_time = time.time_ns() + self.frame_stats = FrameDropStats(num_drops=0, drops_per_second=0) + def stop_stream(self) -> None: self.stream.enabled = False self.stream_runner.stop() @@ -529,6 +549,7 @@ def close(self) -> None: """ Cleanup resources of the device """ + self.stream_runner.stop() for camera in self.cameras: camera.close() self.v4l2_device.close() diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index 98fa341c..0cdf8b6b 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -25,6 +25,7 @@ from .pwm.serial_pwm_controller import SerialPWMController from .pydantic_schemas import ( DeviceModel, + FrameDropStats, StreamEncodeTypeEnum, StreamInfoModel, StreamTypeEnum, @@ -70,6 +71,7 @@ def __init__( serial: SerialPWMController, ) -> None: self.devices: list[Device] = [] + self.event_bus = events.EventEmitter() self.sio = sio self.settings_manager = settings_manager self._is_monitoring = False @@ -80,6 +82,10 @@ def __init__( self.logger = logging.getLogger("dwe_os_2.cameras.DeviceManager") + self.dropped_frames: dict[str, int] = {} + + # Initialize event bus + def start_monitoring(self) -> None: """ Begin monitoring for devices in the background @@ -109,11 +115,11 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: device = None match device_type: case DeviceType.EXPLOREHD: - device = EHDDevice(device_info) + device = EHDDevice(device_info, event_bus=self.event_bus) case DeviceType.STELLARHD_LEADER: - device = SHDDevice(device_info) + device = SHDDevice(device_info, event_bus=self.event_bus) case DeviceType.STELLARHD_FOLLOWER: - device = SHDDevice(device_info) + device = SHDDevice(device_info, event_bus=self.event_bus) case _: # Not a DWE device return None @@ -125,6 +131,10 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: lambda _: self._append_stream_error(DeviceModel.model_validate(device)), ) + device.on( + "frame_stats", lambda: asyncio.create_task(self._emit_frame_stats(device)) + ) + if self.serial: device.on("pwm_frequency", lambda fps: self.serial.apply_from_fps(fps)) @@ -366,6 +376,16 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: return devices_info + async def _emit_frame_stats(self, device: Device) -> None: + """ + Emit frame stats to the frontend via SocketIO + """ + # TODO: switch more to use namespace + await self.sio.emit( + "device.frame_stats", + {"bus_info": device.bus_info, "frame_stats": device.frame_stats}, + ) + async def _monitor(self) -> None: """ Internal code to monitor devices for changes diff --git a/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py index 552b79d9..acb95673 100644 --- a/backend_py/src/services/cameras/ehd.py +++ b/backend_py/src/services/cameras/ehd.py @@ -9,6 +9,8 @@ from typing import cast +from event_emitter import EventEmitter + from . import xu_controls as xu from .device import BaseOption, ControlTypeEnum, Device, Option from .enumeration import DeviceInfo @@ -20,8 +22,8 @@ class EHDDevice(Device): Class for exploreHD devices """ - def __init__(self, device_info: DeviceInfo) -> None: - super().__init__(device_info) + def __init__(self, device_info: DeviceInfo, event_bus: EventEmitter) -> None: + super().__init__(device_info, event_bus) self.add_control_from_option("vbr", False, ControlTypeEnum.BOOLEAN) diff --git a/backend_py/src/services/cameras/pydantic_schemas.py b/backend_py/src/services/cameras/pydantic_schemas.py index 35e773d1..4f1df57c 100644 --- a/backend_py/src/services/cameras/pydantic_schemas.py +++ b/backend_py/src/services/cameras/pydantic_schemas.py @@ -165,6 +165,11 @@ class Config: from_attributes = True +class FrameDropStats(BaseModel): + num_drops: int + drops_per_second: float + + class DeviceModel(BaseModel): # List of cameras, e.g. /dev/video0, /dev/video2 cameras: list[CameraModel] | None = None diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 96029f05..8cc40387 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -78,7 +78,7 @@ class SHDDevice(Device): ASIC_COMMAND_DELAY = 0.001 - def __init__(self, device_info: DeviceInfo) -> None: + def __init__(self, device_info: DeviceInfo, event_bus: EventEmitter) -> None: # Specifies if SHD device is Stellar Pro self.is_pro = True # self.pid == 0x6369 @@ -92,7 +92,7 @@ def __init__(self, device_info: DeviceInfo) -> None: ) self._asic_thread.start() - super().__init__(device_info) + super().__init__(device_info, event_bus) # Copy MJPEG over to Software H264, since they are the same thing mjpg_camera = self.find_camera_with_format("MJPG") diff --git a/backend_py/src/services/cameras/stream_engines/base_stream_engine.py b/backend_py/src/services/cameras/stream_engines/base_stream_engine.py index 97e975c6..eae7af7a 100644 --- a/backend_py/src/services/cameras/stream_engines/base_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/base_stream_engine.py @@ -2,10 +2,12 @@ from abc import ABC, abstractmethod from collections.abc import Callable +from event_emitter import EventEmitter + from .stream import Stream -class BaseStreamEngine(ABC): +class BaseStreamEngine(ABC, EventEmitter): """ Abstract class for any streaming backend """ diff --git a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py index 6c3a648c..8edd1e5a 100644 --- a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py @@ -41,6 +41,7 @@ def __init__(self, streams, error_callback) -> None: ] self.synchronized_camera = SynchronizedCamera(self.cameras) + self.synchronized_camera.on("frame_drop", lambda: self.emit("frame_drop")) except OSError as e: self.logger.error("Unable to open synchronized camera: '%s'", e) if e.strerror: diff --git a/backend_py/src/services/cameras/stream_runner.py b/backend_py/src/services/cameras/stream_runner.py index b0e01a51..e8142554 100644 --- a/backend_py/src/services/cameras/stream_runner.py +++ b/backend_py/src/services/cameras/stream_runner.py @@ -47,7 +47,6 @@ def _select_engine(self) -> BaseStreamEngine: def _on_engine_error(self, error_data) -> None: """Callback to bubble up errors from the engine to the runner's listeners.""" - # TODO: change to general stream error self.emit("stream_error", error_data) self.stop() @@ -64,6 +63,8 @@ def start(self) -> None: # constructor self.engine: BaseStreamEngine = self._select_engine() + self.engine.on("frame_drop", lambda: self.emit("frame_drop")) + self.started = True # We don't need to catch exceptions, maybe remove later try: diff --git a/backend_py/src/services/cameras/synchronized_camera/lib.py b/backend_py/src/services/cameras/synchronized_camera/lib.py index 9e5f4710..8ea7f486 100644 --- a/backend_py/src/services/cameras/synchronized_camera/lib.py +++ b/backend_py/src/services/cameras/synchronized_camera/lib.py @@ -11,6 +11,8 @@ from collections import deque from dataclasses import dataclass +from event_emitter import EventEmitter + from .. import v4l2 @@ -248,12 +250,15 @@ def __del__(self) -> None: self.close() -class SynchronizedCamera: +class SynchronizedCamera(EventEmitter): """ Synchronized Camera Class """ def __init__(self, cameras: list[V4L2Camera], queue_cap: int = 8) -> None: + # Initialize EventEmitter + super().__init__() + for camera in cameras: if camera.critical_error: raise AssertionError( @@ -333,6 +338,7 @@ def grab(self) -> list[CopiedFrame] | None: min_index = timestamps.index(min_ts) self.logger.info(f"Dropping frame of difference: {max_ts - min_ts}") self.queues[min_index].popleft() + self.emit("frame_drop") # Not enough frames anymore to sync return None From bd7c1cae1049920531775deca7471cb5d14a7c22 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 11:27:38 -0700 Subject: [PATCH 02/15] Add frame drop stats and fix missed unplug events --- backend_py/src/services/cameras/device.py | 11 +- .../src/services/cameras/device_manager.py | 115 ++++++++++-------- .../src/services/cameras/pydantic_schemas.py | 4 +- .../synchronized_stream_engine.py | 4 +- .../components/dwe/cameras/camera-card.tsx | 18 ++- .../components/dwe/cameras/device-list.tsx | 1 + .../dwe/cameras/frame-drop-indicator.tsx | 99 +++++++++++++++ frontend/src/schemas/dwe_os_2.d.ts | 37 ++++-- 8 files changed, 212 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/dwe/cameras/frame-drop-indicator.tsx diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 4cf4e390..d32dfbe6 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -339,7 +339,7 @@ def __init__(self, device_info: DeviceInfo, event_bus: events.EventEmitter) -> N self.nickname = "" self.stream = Stream() - self.frame_stats = FrameDropStats(num_drops=0, drops_per_second=0) + self.frame_stats = FrameDropStats(num_drops=0) # each device has a streamrunner, but not all of them are used if # they are a follower (shd) @@ -377,14 +377,9 @@ def __init__(self, device_info: DeviceInfo, event_bus: events.EventEmitter) -> N self._get_controls() - self._stream_start_time = 0 - def _update_drop_stats(self) -> None: self.frame_stats.num_drops += 1 - self.frame_stats.drops_per_second = self.frame_stats.num_drops / float( - time.time_ns() - self._stream_start_time - ) - self.emit("drop_stats") + self.emit("frame_stats") def _on_stream_error(self, err: str) -> None: self.logger.error(err) @@ -539,7 +534,7 @@ def start_stream(self) -> None: self.stream_runner.start() self._stream_start_time = time.time_ns() - self.frame_stats = FrameDropStats(num_drops=0, drops_per_second=0) + self.frame_stats = FrameDropStats(num_drops=0) def stop_stream(self) -> None: self.stream.enabled = False diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index 0cdf8b6b..a57e547e 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -84,6 +84,9 @@ def __init__( self.dropped_frames: dict[str, int] = {} + # Captured in start_monitoring + self._loop: asyncio.AbstractEventLoop | None = None + # Initialize event bus def start_monitoring(self) -> None: @@ -91,6 +94,7 @@ def start_monitoring(self) -> None: Begin monitoring for devices in the background """ self._is_monitoring = True + self._loop = asyncio.get_running_loop() asyncio.create_task(self._monitor()) def stop_monitoring(self) -> None: @@ -131,9 +135,7 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: lambda _: self._append_stream_error(DeviceModel.model_validate(device)), ) - device.on( - "frame_stats", lambda: asyncio.create_task(self._emit_frame_stats(device)) - ) + device.on("frame_stats", lambda: self._schedule_emit_frame_stats(device)) if self.serial: device.on("pwm_frequency", lambda fps: self.serial.apply_from_fps(fps)) @@ -288,7 +290,7 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: new_devices = list_diff(devices_info, old_devices) # find the removed devices - removed_devices = list_diff(old_devices, devices_info) + removed_devices: list[DeviceInfo] = list_diff(old_devices, devices_info) device_added = False @@ -323,51 +325,49 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: # remove the old devices for device_info in removed_devices: - for device in self.devices: - if device.device_info == device_info: - device.stream_runner.stop() - - # What to do when a device is unplugged - # Remove unplugged followers from leaders, and unplugged leaders - # as leaders - if ( - device.device_type == DeviceType.STELLARHD_LEADER - or device.device_type == DeviceType.STELLARHD_FOLLOWER - ): - leader_casted = cast(SHDDevice, device) - for follower_bus_info in leader_casted.followers: - # This can be optimized, but it truly does not matter - follower = self._find_device_with_bus_info( - follower_bus_info - ) - # Remember, follower might not exist now - never inherent - # truth to its existance - if follower: - follower_casted = cast(SHDDevice, follower) + removed_device = find_device_with_bus_info( + self.devices, device_info.bus_info + ) + + if not removed_device: + continue + + removed_device.stream_runner.stop() + + # What to do when a device is unplugged + # Remove unplugged followers from leaders, and unplugged leaders + # as leaders + if ( + removed_device.device_type == DeviceType.STELLARHD_LEADER + or removed_device.device_type == DeviceType.STELLARHD_FOLLOWER + ): + leader_casted = cast(SHDDevice, removed_device) + for follower_bus_info in leader_casted.followers: + # This can be optimized, but it truly does not matter + follower = self._find_device_with_bus_info(follower_bus_info) + # Remember, follower might not exist now - never inherent + # truth to its existance + if follower: + follower_casted = cast(SHDDevice, follower) + leader_casted.remove_follower(follower_casted) + self.settings_manager.save_device(leader_casted) + if removed_device.device_type == DeviceType.STELLARHD_FOLLOWER: + follower_casted = cast(SHDDevice, removed_device) + if follower_casted.is_managed: + for device in self.devices: + if ( + device.device_type == DeviceType.STELLARHD_LEADER + or device.device_type == DeviceType.STELLARHD_FOLLOWER + ): + leader_casted = cast(SHDDevice, device) + if follower_casted.bus_info in leader_casted.followers: leader_casted.remove_follower(follower_casted) self.settings_manager.save_device(leader_casted) - if device.device_type == DeviceType.STELLARHD_FOLLOWER: - follower_casted = cast(SHDDevice, device) - if follower_casted.is_managed: - # TODO: Fix this - for device in self.devices: - if ( - device.device_type == DeviceType.STELLARHD_LEADER - or device.device_type - == DeviceType.STELLARHD_FOLLOWER - ): - leader_casted = cast(SHDDevice, device) - if ( - follower_casted.bus_info - in leader_casted.followers - ): - leader_casted.remove_follower(follower_casted) - self.settings_manager.save_device(leader_casted) - - self.devices.remove(device) - self.logger.info(f"Device Removed: {device_info.bus_info}") - - await self.sio.emit("device_removed", device_info.bus_info) + + self.devices.remove(removed_device) + self.logger.info(f"Device Removed: {device_info.bus_info}") + + await self.sio.emit("device_removed", device_info.bus_info) if device_added: # FIXME: Issue where sometimes frontend updates too quickly before the @@ -376,14 +376,33 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: return devices_info + def _schedule_emit_frame_stats(self, device: Device) -> None: + """ + Schedule a frame_stats emit from any thread onto the main asyncio loop. + """ + loop = self._loop + if loop is None or loop.is_closed(): + return + try: + asyncio.run_coroutine_threadsafe(self._emit_frame_stats(device), loop) + except RuntimeError: + return + async def _emit_frame_stats(self, device: Device) -> None: """ Emit frame stats to the frontend via SocketIO """ + frame_stats = device.frame_stats + + frame_stats_payload: Any = frame_stats.model_dump() + # TODO: switch more to use namespace await self.sio.emit( "device.frame_stats", - {"bus_info": device.bus_info, "frame_stats": device.frame_stats}, + { + "bus_info": device.bus_info, + "frame_stats": frame_stats_payload, + }, ) async def _monitor(self) -> None: diff --git a/backend_py/src/services/cameras/pydantic_schemas.py b/backend_py/src/services/cameras/pydantic_schemas.py index 4f1df57c..12d6638f 100644 --- a/backend_py/src/services/cameras/pydantic_schemas.py +++ b/backend_py/src/services/cameras/pydantic_schemas.py @@ -167,7 +167,6 @@ class Config: class FrameDropStats(BaseModel): num_drops: int - drops_per_second: float class DeviceModel(BaseModel): @@ -196,6 +195,9 @@ class DeviceModel(BaseModel): followers: list[str] = [] # True if is a follower and stream is managed by the leader is_managed: bool = False + # Per-stream drop stats. Resets every time the stream is restarted so + # the count is "drops in the current stream", not cumulative. + frame_stats: FrameDropStats = FrameDropStats(num_drops=0) class Config: from_attributes = True diff --git a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py index 8edd1e5a..a17bf55b 100644 --- a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py @@ -141,9 +141,9 @@ def stop(self) -> None: self._running = False if self.capture_thread: - self.capture_thread.join(timeout=1000) + self.capture_thread.join(timeout=1) if self.stream_thread: - self.stream_thread.join(timeout=1000) + self.stream_thread.join(timeout=1) except TimeoutError as e: self.logger.error(f"Timeout exceeded while joining capture thread: {e}") diff --git a/frontend/src/components/dwe/cameras/camera-card.tsx b/frontend/src/components/dwe/cameras/camera-card.tsx index 498a047d..3c5c8fc0 100644 --- a/frontend/src/components/dwe/cameras/camera-card.tsx +++ b/frontend/src/components/dwe/cameras/camera-card.tsx @@ -8,6 +8,7 @@ import { import { CameraNickname } from "./nickname"; import { CameraStream } from "./stream"; +import { FrameDropIndicator } from "./frame-drop-indicator"; import { proxy, useSnapshot } from "valtio"; import { useContext } from "react"; import DeviceContext from "@/contexts/DeviceContext"; @@ -34,12 +35,17 @@ export function CameraCard({ return ( - {deviceState.device_info?.device_name} - - Manufacturer: {deviceState.manufacturer} -
- USB Port ID: {deviceState.bus_info} -
+
+
+ {deviceState.device_info?.device_name} + + Manufacturer: {deviceState.manufacturer} +
+ USB Port ID: {deviceState.bus_info} +
+
+ +
diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index 11f2e1ab..be08a3b5 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -34,6 +34,7 @@ const DEMO_DEVICE: DeviceModel = { is_managed: false, followers: [], + frame_stats: { num_drops: 0 }, device_info: { device_name: "exploreHD Demo", bus_info: "demo-device", diff --git a/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx b/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx new file mode 100644 index 00000000..74cadb5e --- /dev/null +++ b/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx @@ -0,0 +1,99 @@ +import { useContext, useEffect, useState } from "react"; +import { ClockArrowDown } from "lucide-react"; +import { useSnapshot } from "valtio"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import DeviceContext from "@/contexts/DeviceContext"; +import WebsocketContext from "@/contexts/WebsocketContext"; +import { cn } from "@/lib/utils"; + +/** + * The payload sent over the websocket + */ +type FrameStatsPayload = { + bus_info: string; + frame_stats?: { num_drops: number }; +}; + +/** + * Small indicator that lives in the top-right of the camera card showing the + * dropped frame count for the device's current stream. + * + * The count comes from `device.frame_stats.num_drops`, which the backend + * resets on every `start_stream`. The proxy is seeded from /api/devices so + * the count survives a frontend page reload, and updated in-place from + * `device.frame_stats` socket events for live changes. + */ +export function FrameDropIndicator() { + const device = useContext(DeviceContext); + const ws = useContext(WebsocketContext); + const socket = ws?.socket; + const connected = ws?.connected ?? false; + + const deviceState = useSnapshot(device!); + + const [frameStats, setFrameStats] = useState(deviceState.frame_stats); + + useEffect(() => { + if (!device || !connected || !socket) return; + + const onFrameStats = (payload: FrameStatsPayload) => { + if ( + !payload || + payload.bus_info !== device.bus_info || + !payload.frame_stats + ) + return; + + setFrameStats(payload.frame_stats); + }; + + socket.on("device.frame_stats", onFrameStats); + return () => { + socket.off("device.frame_stats", onFrameStats); + }; + }, [device, socket, connected]); + + if (!device) return null; + + const total = frameStats?.num_drops ?? 0; + const hasDrops = total > 0; + + return ( + <> + {hasDrops ? ( + + + +
+ + {total > 9999 ? "9999+" : total} +
+
+ +
Frame drops
+
+
+ This stream: {total} +
+
+
+
+
+ ) : undefined} + + ); +} diff --git a/frontend/src/schemas/dwe_os_2.d.ts b/frontend/src/schemas/dwe_os_2.d.ts index d355b364..2f2d416c 100644 --- a/frontend/src/schemas/dwe_os_2.d.ts +++ b/frontend/src/schemas/dwe_os_2.d.ts @@ -554,6 +554,10 @@ export interface components { * @default false */ is_managed: boolean; + /** @default { + * "num_drops": 0 + * } */ + frame_stats: components["schemas"]["FrameDropStats"]; }; /** DeviceNicknameModel */ DeviceNicknameModel: { @@ -606,6 +610,11 @@ export interface components { /** Intervals */ intervals: components["schemas"]["IntervalModel"][]; }; + /** FrameDropStats */ + FrameDropStats: { + /** Num Drops */ + num_drops: number; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -864,7 +873,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; /** @description Validation Error */ @@ -897,7 +906,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; /** @description Validation Error */ @@ -930,7 +939,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; /** @description Validation Error */ @@ -1029,7 +1038,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; /** @description Validation Error */ @@ -1082,7 +1091,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; /** @description Validation Error */ @@ -1133,7 +1142,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; }; @@ -1153,7 +1162,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; }; @@ -1197,7 +1206,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["SimpleRequestStatusModel"]; }; }; /** @description Validation Error */ @@ -1299,7 +1308,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["RecordingInfo"][]; }; }; /** @description Validation Error */ @@ -1331,7 +1340,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["RecordingInfo"][]; }; }; /** @description Validation Error */ @@ -1426,7 +1435,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": { + [key: string]: unknown; + }; }; }; /** @description Validation Error */ @@ -1458,7 +1469,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": { + [key: string]: unknown; + }; }; }; /** @description Validation Error */ From 7384d3d9aeb83bc0611a6212326e318810391b3a Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 11:46:25 -0700 Subject: [PATCH 03/15] Remove dead code --- backend_py/src/services/cameras/device.py | 6 ++---- backend_py/src/services/cameras/device_manager.py | 11 +++-------- backend_py/src/services/cameras/ehd.py | 4 ++-- backend_py/src/services/cameras/shd.py | 4 ++-- .../stream_engines/synchronized_stream_engine.py | 15 ++++++--------- 5 files changed, 15 insertions(+), 25 deletions(-) diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index d32dfbe6..94bbe248 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -314,11 +314,9 @@ def _clear(self) -> None: class Device(events.EventEmitter): - def __init__(self, device_info: DeviceInfo, event_bus: events.EventEmitter) -> None: + def __init__(self, device_info: DeviceInfo) -> None: super().__init__() - self.event_bus = event_bus - self.cameras: list[Camera] = [] for device_path in device_info.device_paths: self.cameras.append(Camera(device_path)) @@ -533,8 +531,8 @@ def start_stream(self) -> None: self.stream.enabled = True self.stream_runner.start() - self._stream_start_time = time.time_ns() self.frame_stats = FrameDropStats(num_drops=0) + self.emit("frame_stats", self.frame_stats) def stop_stream(self) -> None: self.stream.enabled = False diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index a57e547e..572b3faf 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -71,7 +71,6 @@ def __init__( serial: SerialPWMController, ) -> None: self.devices: list[Device] = [] - self.event_bus = events.EventEmitter() self.sio = sio self.settings_manager = settings_manager self._is_monitoring = False @@ -82,13 +81,9 @@ def __init__( self.logger = logging.getLogger("dwe_os_2.cameras.DeviceManager") - self.dropped_frames: dict[str, int] = {} - # Captured in start_monitoring self._loop: asyncio.AbstractEventLoop | None = None - # Initialize event bus - def start_monitoring(self) -> None: """ Begin monitoring for devices in the background @@ -119,11 +114,11 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: device = None match device_type: case DeviceType.EXPLOREHD: - device = EHDDevice(device_info, event_bus=self.event_bus) + device = EHDDevice(device_info) case DeviceType.STELLARHD_LEADER: - device = SHDDevice(device_info, event_bus=self.event_bus) + device = SHDDevice(device_info) case DeviceType.STELLARHD_FOLLOWER: - device = SHDDevice(device_info, event_bus=self.event_bus) + device = SHDDevice(device_info) case _: # Not a DWE device return None diff --git a/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py index acb95673..6b1679fa 100644 --- a/backend_py/src/services/cameras/ehd.py +++ b/backend_py/src/services/cameras/ehd.py @@ -22,8 +22,8 @@ class EHDDevice(Device): Class for exploreHD devices """ - def __init__(self, device_info: DeviceInfo, event_bus: EventEmitter) -> None: - super().__init__(device_info, event_bus) + def __init__(self, device_info: DeviceInfo) -> None: + super().__init__(device_info) self.add_control_from_option("vbr", False, ControlTypeEnum.BOOLEAN) diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 8cc40387..96029f05 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -78,7 +78,7 @@ class SHDDevice(Device): ASIC_COMMAND_DELAY = 0.001 - def __init__(self, device_info: DeviceInfo, event_bus: EventEmitter) -> None: + def __init__(self, device_info: DeviceInfo) -> None: # Specifies if SHD device is Stellar Pro self.is_pro = True # self.pid == 0x6369 @@ -92,7 +92,7 @@ def __init__(self, device_info: DeviceInfo, event_bus: EventEmitter) -> None: ) self._asic_thread.start() - super().__init__(device_info, event_bus) + super().__init__(device_info) # Copy MJPEG over to Software H264, since they are the same thing mjpg_camera = self.find_camera_with_format("MJPG") diff --git a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py index a17bf55b..84550e22 100644 --- a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py @@ -137,15 +137,12 @@ def start(self) -> None: self.stream_thread.start() def stop(self) -> None: - try: - self._running = False - - if self.capture_thread: - self.capture_thread.join(timeout=1) - if self.stream_thread: - self.stream_thread.join(timeout=1) - except TimeoutError as e: - self.logger.error(f"Timeout exceeded while joining capture thread: {e}") + self._running = False + + if self.capture_thread: + self.capture_thread.join(timeout=1) + if self.stream_thread: + self.stream_thread.join(timeout=1) def capture_loop_(self) -> None: if not self.synchronized_camera: From 12334b5f0204ff192e2c7d09ca45914db74a0431 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 11:49:57 -0700 Subject: [PATCH 04/15] Cleanup frame drop indicator component --- .../dwe/cameras/frame-drop-indicator.tsx | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx b/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx index 74cadb5e..c9003112 100644 --- a/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx +++ b/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx @@ -59,41 +59,35 @@ export function FrameDropIndicator() { }; }, [device, socket, connected]); - if (!device) return null; - const total = frameStats?.num_drops ?? 0; const hasDrops = total > 0; + if (!hasDrops) return null; + return ( - <> - {hasDrops ? ( - - - -
- - {total > 9999 ? "9999+" : total} -
-
- -
Frame drops
-
-
- This stream: {total} -
-
-
-
-
- ) : undefined} - + + + +
+ + {total > 9999 ? "9999+" : total} +
+
+ +
Frame drops
+
+
+ This stream: {total} +
+
+
+
+
); } From 4124b62ac0e006da5ef4869e1c3a38573ebd566d Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 12:40:06 -0700 Subject: [PATCH 05/15] Fix race condition and add hw bitrate control to sensor controls --- backend_py/src/services/cameras/device.py | 11 +- .../src/services/cameras/device_manager.py | 8 +- backend_py/src/services/cameras/shd.py | 2 +- .../dwe/cameras/cam-control-map.json | 3 +- .../src/components/dwe/cameras/stream.tsx | 101 +++++++++++------- 5 files changed, 75 insertions(+), 50 deletions(-) diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 94bbe248..0e5459c2 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -10,6 +10,7 @@ import fcntl import logging import struct +import threading import time from abc import ABC, abstractmethod from collections.abc import Callable @@ -337,6 +338,8 @@ def __init__(self, device_info: DeviceInfo) -> None: self.nickname = "" self.stream = Stream() + # frame stats is touched by both the main thread and the capture thread + self._frame_stats_lock = threading.Lock() self.frame_stats = FrameDropStats(num_drops=0) # each device has a streamrunner, but not all of them are used if @@ -376,7 +379,8 @@ def __init__(self, device_info: DeviceInfo) -> None: self._get_controls() def _update_drop_stats(self) -> None: - self.frame_stats.num_drops += 1 + with self._frame_stats_lock: + self.frame_stats.num_drops += 1 self.emit("frame_stats") def _on_stream_error(self, err: str) -> None: @@ -531,8 +535,9 @@ def start_stream(self) -> None: self.stream.enabled = True self.stream_runner.start() - self.frame_stats = FrameDropStats(num_drops=0) - self.emit("frame_stats", self.frame_stats) + with self._frame_stats_lock: + self.frame_stats = FrameDropStats(num_drops=0) + self.emit("frame_stats") def stop_stream(self) -> None: self.stream.enabled = False diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index 572b3faf..f9011c8f 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -387,9 +387,11 @@ async def _emit_frame_stats(self, device: Device) -> None: """ Emit frame stats to the frontend via SocketIO """ - frame_stats = device.frame_stats - - frame_stats_payload: Any = frame_stats.model_dump() + # Snapshot under the lock so we don't race with the capture thread's + # increment or with start_stream's reset. + # NOTE: This may cause minor perf issues when dropping a lot of frames + with device._frame_stats_lock: + frame_stats_payload = device.frame_stats.model_dump() # TODO: switch more to use namespace await self.sio.emit( diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 96029f05..22b0a0cb 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -132,7 +132,7 @@ def __init__(self, device_info: DeviceInfo) -> None: ) self.add_control_from_option( - "hw_bitrate", 5000, ControlTypeEnum.INTEGER, 65535, 0, 1 + "hw_bitrate", 5000, ControlTypeEnum.INTEGER, 13000, 0, 1 ) # self.add_control_from_option( diff --git a/frontend/src/components/dwe/cameras/cam-control-map.json b/frontend/src/components/dwe/cameras/cam-control-map.json index 598923a6..20fbbaa7 100644 --- a/frontend/src/components/dwe/cameras/cam-control-map.json +++ b/frontend/src/components/dwe/cameras/cam-control-map.json @@ -21,7 +21,6 @@ "Power Line Frequency", "Bitrate", "Group of Pictures", - "Variable Bitrate", - "HW Bitrate" + "Variable Bitrate" ] } diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx index dba1a542..b598a469 100644 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream.tsx @@ -59,6 +59,7 @@ export const SensorControls = () => { const isoControl = controlMap.get("ISO"); const shutterControl = controlMap.get("Shutter Speed"); const strobeWidthControl = controlMap.get("Strobe Width"); + const hwBitrateControl = controlMap.get("HW Bitrate"); const [exposureTime, setExposureTime] = useState(shutterControl?.value || 0); // 0x3501 const [autoExposure, setAutoExposure] = useState( @@ -68,6 +69,7 @@ export const SensorControls = () => { const [strobeWidth, setStrobeWidth] = useState( strobeWidthControl?.value || 0, ); + const [hwBitrate, setHwBitrate] = useState(hwBitrateControl?.value); const strobeMax = exposureTime!; @@ -121,6 +123,11 @@ export const SensorControls = () => { [gain], ); + useDidMountEffect( + () => setUVCControl(hwBitrateControl as ControlModel, hwBitrate!), + [hwBitrate], + ); + // Set both at the same time to fix fw bug useDidMountEffect( () => setUVCControl(strobeWidthControl as ControlModel, strobeWidth), @@ -129,10 +136,11 @@ export const SensorControls = () => { useEffect(() => { setMatchExposure(localStorage.getItem(device.bus_info) === "matched"); - }, []); + }, [device.bus_info]); // TODO: replace with is_pro? - if (!exposureControl || !isoControl || !shutterControl) return <>; + if (!exposureControl || !isoControl || !shutterControl || !hwBitrateControl) + return <>; return ( @@ -167,47 +175,58 @@ export const SensorControls = () => { {/* Manual Controls */} - {!autoExposure && ( -
-
- { - if (matchExposure) { - setMatchedExposure(newValue); +
+
+ {!autoExposure && ( + <> + - { - if (matchExposure) { - setMatchedISO(newValue); - } - setGain(newValue); - }} - /> - -
+ onChange={(newValue) => { + if (matchExposure) { + setMatchedExposure(newValue); + } + setExposureTime(newValue); + }} + /> + { + if (matchExposure) { + setMatchedISO(newValue); + } + setGain(newValue); + }} + /> + + + )} + { + setHwBitrate(newValue); + }} + />
- )} +
From 9a45c85c3bcc5270f19cb0770b22ee2ab6241590 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 13:45:50 -0700 Subject: [PATCH 06/15] Remove asic gets in favor of default value --- backend_py/src/services/cameras/shd.py | 37 +++++++++++++++---- .../src/components/dwe/cameras/stream.tsx | 6 ++- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 22b0a0cb..f6c6e343 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -48,20 +48,27 @@ def __init__( name: str, setter: Callable[[Any], None], getter: Callable[[], int | float | None], + default_value: int | float | None = None, is_integer_only=True, ) -> None: BaseOption.__init__(self, name) self.setter = setter self.is_integer_only = is_integer_only + self.logger = logging.getLogger("dwe_os_2.CustomOption") + # FIXME: I did this since the getter seems to be unreliable for asic controls, # so we just trust the value stored self.getter = getter - self.value: int | float | None = getter() - self.logger = logging.getLogger("CustomOption") + + self.set_value(default_value) + + if default_value is None: + default_value = getter() + + self.value: int | float | None = default_value def set_value(self, value) -> None: - self.logger.info(f"{self.name}: {value}") if self.is_integer_only: value = int(value) self.setter(value) @@ -545,26 +552,40 @@ def update_bitrate() -> None: if self.is_pro: options["ae"] = CustomOption( - "Auto Exposure (ASIC)", self.set_asic_ae, self.get_asic_ae + "Auto Exposure (ASIC)", + self.set_asic_ae, + self.get_asic_ae, + default_value=1, ) # UVC shutter speed control options["shutter"] = CustomOption( - "Shutter Speed", self.set_shutter_speed, self.get_shutter_speed + "Shutter Speed", + self.set_shutter_speed, + self.get_shutter_speed, + default_value=10, ) # UVC ISO control - options["iso"] = CustomOption("ISO", self.set_iso, self.get_iso) + options["iso"] = CustomOption( + "ISO", self.set_iso, self.get_iso, default_value=0 + ) # options['strobe_enabled'] = CustomOption( # "Strobe Enabled", self.set_strobe_enabled, self.get_strobe_enabled) options["strobe_width"] = CustomOption( - "Strobe Width", self.set_strobe_width, self.get_strobe_width + "Strobe Width", + self.set_strobe_width, + self.get_strobe_width, + default_value=0, ) options["hw_bitrate"] = CustomOption( - "HW Bitrate", self.set_hw_bitrate, self.get_hw_bitrate + "HW Bitrate", + self.set_hw_bitrate, + self.get_hw_bitrate, + default_value=13000, ) return options diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx index b598a469..c2d565ab 100644 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream.tsx @@ -61,6 +61,8 @@ export const SensorControls = () => { const strobeWidthControl = controlMap.get("Strobe Width"); const hwBitrateControl = controlMap.get("HW Bitrate"); + console.log(hwBitrateControl); + const [exposureTime, setExposureTime] = useState(shutterControl?.value || 0); // 0x3501 const [autoExposure, setAutoExposure] = useState( exposureControl?.value === 1, @@ -69,7 +71,7 @@ export const SensorControls = () => { const [strobeWidth, setStrobeWidth] = useState( strobeWidthControl?.value || 0, ); - const [hwBitrate, setHwBitrate] = useState(hwBitrateControl?.value); + const [hwBitrate, setHwBitrate] = useState(hwBitrateControl?.value ?? 0); const strobeMax = exposureTime!; @@ -218,7 +220,7 @@ export const SensorControls = () => { )} { From de2a70694a02bcf483336852f726775b6398ba53 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 18:12:42 -0700 Subject: [PATCH 07/15] Add auto sensor config reapply on stream start --- backend_py/src/services/cameras/device.py | 1 - backend_py/src/services/cameras/device_manager.py | 12 +++++++----- backend_py/src/services/cameras/ehd.py | 5 ++--- backend_py/src/services/cameras/shd.py | 12 ++++++++++++ 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 0e5459c2..518e6cdd 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -11,7 +11,6 @@ import logging import struct import threading -import time from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index f9011c8f..ecb7044e 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -25,7 +25,6 @@ from .pwm.serial_pwm_controller import SerialPWMController from .pydantic_schemas import ( DeviceModel, - FrameDropStats, StreamEncodeTypeEnum, StreamInfoModel, StreamTypeEnum, @@ -339,13 +338,16 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: leader_casted = cast(SHDDevice, removed_device) for follower_bus_info in leader_casted.followers: # This can be optimized, but it truly does not matter - follower = self._find_device_with_bus_info(follower_bus_info) - # Remember, follower might not exist now - never inherent - # truth to its existance - if follower: + try: + follower = self._find_device_with_bus_info(follower_bus_info) + # Remember, follower might not exist now - never inherent + # truth to its existence follower_casted = cast(SHDDevice, follower) leader_casted.remove_follower(follower_casted) self.settings_manager.save_device(leader_casted) + except DeviceNotFoundException: + continue + if removed_device.device_type == DeviceType.STELLARHD_FOLLOWER: follower_casted = cast(SHDDevice, removed_device) if follower_casted.is_managed: diff --git a/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py index 6b1679fa..fcb46c95 100644 --- a/backend_py/src/services/cameras/ehd.py +++ b/backend_py/src/services/cameras/ehd.py @@ -9,8 +9,6 @@ from typing import cast -from event_emitter import EventEmitter - from . import xu_controls as xu from .device import BaseOption, ControlTypeEnum, Device, Option from .enumeration import DeviceInfo @@ -48,7 +46,8 @@ def _get_options(self) -> dict[str, BaseOption]: lambda bitrate: int( round(bitrate * 1000000) ), # convert to bps from mpbs (round for float imprecision) - lambda bitrate: cast(int, bitrate) / 1000000.0, # convert to mpbs from bps + # convert to mpbs from bps + lambda bitrate: cast(int, bitrate) / 1000000.0, ) # UVC xu gop control diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index f6c6e343..ee7f80a6 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -622,5 +622,17 @@ def start_stream(self) -> None: super().start_stream() + self.reapply_sensor_config() + for follower in self.follower_devices: + follower.reapply_sensor_config() + + def reapply_sensor_config(self) -> None: + self.logger.info("Reapplying options after starting stream.") + + # Reapply options after starting stream + for option_name in self._options: + option = self._options[option_name] + option.set_value(option.get_value()) + def unconfigure_stream(self) -> None: super().unconfigure_stream() From c4a21e73ed9813be11c9b91a8de192ecd7722719 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 18:13:10 -0700 Subject: [PATCH 08/15] Fix formatting --- update_versioning.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/update_versioning.py b/update_versioning.py index 38b48440..2fbfd3c0 100644 --- a/update_versioning.py +++ b/update_versioning.py @@ -7,37 +7,40 @@ GITHUB_API_URL = "https://api.github.com/repos/DeepWaterExploration/DWE_OS_2/tags" VERSION_FILE_PATH = f"{SCRIPT_DIR}/frontend/package.json" + def get_latest_tag(): # Fetch the latest tags from GitHub API response = requests.get(GITHUB_API_URL) - + if response.status_code == 200: tags = response.json() if tags: - return tags[0]['name'] # The latest tag is the first one + return tags[0]["name"] # The latest tag is the first one return None + def get_new_tag(): try: - new_tag = input('Enter a new tag name: ') + new_tag = input("Enter a new tag name: ") if new_tag == "": return None return new_tag except EOFError: return None + def update_version_json(new_version): # Load the current package.json file - with open(VERSION_FILE_PATH, 'r') as f: + with open(VERSION_FILE_PATH, "r") as f: data = json.load(f) # Update the version string - data['version'] = new_version + data["version"] = new_version # Write the new version back to the json file - with open(VERSION_FILE_PATH, 'w') as f: + with open(VERSION_FILE_PATH, "w") as f: # need newline at end of file - data_str = json.dumps(data, indent=4) + '\n' + data_str = json.dumps(data, indent=4) + "\n" f.write(data_str) From 21558df17fda450c0bb082142e15d062c0d89c79 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 6 May 2026 18:13:19 -0700 Subject: [PATCH 09/15] Remove link to dwe homepage from dwe os --- frontend/src/components/nav/sidebar-left.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/nav/sidebar-left.tsx b/frontend/src/components/nav/sidebar-left.tsx index 4e8e76d9..05a5792f 100644 --- a/frontend/src/components/nav/sidebar-left.tsx +++ b/frontend/src/components/nav/sidebar-left.tsx @@ -80,7 +80,7 @@ export function SidebarLeft({
- + Date: Thu, 7 May 2026 12:33:01 -0700 Subject: [PATCH 10/15] Bugfix: Crash due to floating point serialization --- backend_py/src/services/cameras/device.py | 6 +++--- backend_py/src/services/cameras/pydantic_schemas.py | 10 +++++----- backend_py/src/services/cameras/shd.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 518e6cdd..970a6999 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -498,9 +498,9 @@ def add_control_from_option( option_name: str, default_value: Any, control_type: ControlTypeEnum, - max_value: float = 0, - min_value: float = 0, - step: float = 0, + max_value: float | int = 0, + min_value: float | int = 0, + step: float | int = 0, ) -> None: try: option = self._options[option_name] diff --git a/backend_py/src/services/cameras/pydantic_schemas.py b/backend_py/src/services/cameras/pydantic_schemas.py index 12d6638f..eeb7fb2a 100644 --- a/backend_py/src/services/cameras/pydantic_schemas.py +++ b/backend_py/src/services/cameras/pydantic_schemas.py @@ -102,10 +102,10 @@ class Config: class ControlFlagsModel(BaseModel): - default_value: float - max_value: float - min_value: float - step: float + default_value: float | int + max_value: float | int + min_value: float | int + step: float | int control_type: ControlTypeEnum = Field(...) menu: list[MenuItemModel] = Field(default_factory=list) @@ -117,7 +117,7 @@ class ControlModel(BaseModel): flags: ControlFlagsModel control_id: int name: str - value: float + value: float | int class Config: from_attributes = True diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index ee7f80a6..7d6175ec 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -29,12 +29,12 @@ def get_val(addr: Enum | int) -> int: class StorageOption(BaseOption, EventEmitter): - def __init__(self, name: str, value) -> None: + def __init__(self, name: str, value: int | float) -> None: BaseOption.__init__(self, name) EventEmitter.__init__(self) self.value: int | float = value - def set_value(self, value) -> None: + def set_value(self, value: int | float) -> None: self.value = value self.emit("value_changed") From 983281583f6d9abe9f512f3d2192e2cfebf6536d Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 7 May 2026 16:07:33 -0700 Subject: [PATCH 11/15] Move network to own UI --- frontend/src/App.tsx | 2 ++ .../components/dwe/app/command-palette.tsx | 5 ++++ .../src/components/dwe/network/network.tsx | 28 +++++++++++++++++++ frontend/src/router.tsx | 2 ++ 4 files changed, 37 insertions(+) create mode 100644 frontend/src/components/dwe/network/network.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ea54e28..3e3be916 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -60,6 +60,8 @@ function AppContent() { return "Cameras"; case "/recordings": return "Onboard Recordings"; + case "/network": + return "Network"; case "/preferences": return "Preferences"; case "/log-viewer": diff --git a/frontend/src/components/dwe/app/command-palette.tsx b/frontend/src/components/dwe/app/command-palette.tsx index 4b5f87b7..2799a83c 100644 --- a/frontend/src/components/dwe/app/command-palette.tsx +++ b/frontend/src/components/dwe/app/command-palette.tsx @@ -62,6 +62,11 @@ export function CommandPalette() { > Recordings + runCommand(() => navigate("/network"))} + > + Network + runCommand(() => navigate("/log-viewer"))} > diff --git a/frontend/src/components/dwe/network/network.tsx b/frontend/src/components/dwe/network/network.tsx new file mode 100644 index 00000000..3fc432c9 --- /dev/null +++ b/frontend/src/components/dwe/network/network.tsx @@ -0,0 +1,28 @@ +import { useContext } from "react"; +import WebsocketContext from "@/contexts/WebsocketContext"; +import NotConnected from "../not-connected"; +import WiredConfig from "./wired/wired-config"; +import WirelessConfig from "./wireless/wireless-config"; + +const NetworkLayout = () => { + const { connected } = useContext(WebsocketContext)!; + + if (!connected) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + +
+
+ ); +}; + +export default NetworkLayout; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index e2c022ca..1e061600 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -3,6 +3,7 @@ import App from "./App"; import OverviewLayout from "./components/dwe/overview"; import DeviceListLayout from "./components/dwe/cameras/device-list"; import PreferencesLayout from "./components/dwe/preferences/preferences"; +import NetworkLayout from "./components/dwe/network/network"; import { LogViewer } from "./components/dwe/log-page/log-viewer"; import Terminal from "./components/dwe/terminal/terminal"; import Recordings from "./components/dwe/recordings/recordings"; @@ -14,6 +15,7 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "/cameras", element: }, + { path: "/network", element: }, { path: "/preferences", element: }, { path: "/log-viewer", element: }, { path: "/terminal", element: }, From 98f15b389b86cc9f4c83b0221be0e3ea46fcf5aa Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 7 May 2026 16:09:55 -0700 Subject: [PATCH 12/15] Improve wireless config menu --- .../dwe/network/wireless/wireless-config.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/dwe/network/wireless/wireless-config.tsx b/frontend/src/components/dwe/network/wireless/wireless-config.tsx index cf0b3cb2..8d2e958e 100644 --- a/frontend/src/components/dwe/network/wireless/wireless-config.tsx +++ b/frontend/src/components/dwe/network/wireless/wireless-config.tsx @@ -1,29 +1,29 @@ import { Card, CardContent, - CardFooter, + CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; +import { WifiOff } from "lucide-react"; export default function WirelessConfig() { return ( -
- - - Wireless Configuration - - -
-

- No supported wireless device found. -

-
-
- - For more detailed documentation, refer to our docs. - -
-
+ + + Wireless Network + + Manage Wi-Fi interfaces and connection profiles. + + + +
+ +

+ Wi-Fi is not currently supported +

+
+
+
); } From c9bf5e894ecdc5e4b90e31c8a0d3557d3f638b3c Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 7 May 2026 16:22:09 -0700 Subject: [PATCH 13/15] Add network to sidebar and improve NotConnected card --- .../components/dwe/cameras/device-list.tsx | 5 +- .../dwe/preferences/preferences.tsx | 150 ++++++++---------- frontend/src/components/nav/sidebar-left.tsx | 6 + 3 files changed, 79 insertions(+), 82 deletions(-) diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index be08a3b5..4515019a 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -245,6 +245,8 @@ const DeviceListLayout = () => { const displayDevices = isActive && devices.length === 0 ? [demoDeviceProxy] : devices; + if (!connected) return ; + return (
@@ -275,8 +277,7 @@ const DeviceListLayout = () => {
))} - {displayDevices.length === 0 && - (connected ? : )} + {displayDevices.length === 0 && }
diff --git a/frontend/src/components/dwe/preferences/preferences.tsx b/frontend/src/components/dwe/preferences/preferences.tsx index b77457af..2403437d 100644 --- a/frontend/src/components/dwe/preferences/preferences.tsx +++ b/frontend/src/components/dwe/preferences/preferences.tsx @@ -13,8 +13,6 @@ import { TOUR_STEP_IDS } from "@/lib/tour-constants"; import { Button } from "@/components/ui/button"; import { useTour } from "@/components/tour/tour"; import { SettingsCard } from "./settings-card"; -import WiredConfig from "../network/wired/wired-config"; -import WirelessConfig from "../network/wireless/wireless-config"; import FeaturesContext from "@/contexts/FeaturesContext"; import { RangeControl } from "@/components/ui/range-control"; @@ -91,6 +89,8 @@ const PreferencesLayout = () => { } }, [recommendHost, host, port, frequencyOffset, connected]); + if (!connected) return ; + return (
{ className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(350px,1fr))]" id={TOUR_STEP_IDS.DEFAULT_STREAM_PREFS} > - {connected ? ( - -
-
- - setHost(e.target.value)} - placeholder="Enter host IP" - className={cn( - !IP_REGEX.test(host) && "border-red-500", - "bg-background", - )} - /> -
-
- - setPort(parseInt(e.target.value))} - placeholder="Enter port" - min={1024} - max={65535} - className={cn( - (port < 1024 || port > 65535) && "border-red-500", - "bg-background", - )} - /> -
+ +
+
+ + setHost(e.target.value)} + placeholder="Enter host IP" + className={cn( + !IP_REGEX.test(host) && "border-red-500", + "bg-background", + )} + /> +
+
+ + setPort(parseInt(e.target.value))} + placeholder="Enter port" + min={1024} + max={65535} + className={cn( + (port < 1024 || port > 65535) && "border-red-500", + "bg-background", + )} + /> +
-
- setRecommendHost(!!checked)} - /> - -
+
+ setRecommendHost(!!checked)} + /> + +
- - - {/* Frequency Offset Slider */} - {features?.serial && ( -
-
- -
- - setFrequencyOffset(val)} - className="py-2" - /> - -

- Adjust the fine-tuning offset for camera clock frequency. - Use if you are experiencing flickering or synchronization - issues. -

+ + + {/* Frequency Offset Slider */} + {features?.serial && ( +
+
+
- )} -
- - ) : ( - - )} + + setFrequencyOffset(val)} + className="py-2" + /> + +

+ Adjust the fine-tuning offset for camera clock frequency. Use + if you are experiencing flickering or synchronization issues. +

+
+ )} +
+
@@ -192,11 +187,6 @@ const PreferencesLayout = () => {
- -
-
{connected && }
-
{connected && }
-
); }; diff --git a/frontend/src/components/nav/sidebar-left.tsx b/frontend/src/components/nav/sidebar-left.tsx index 05a5792f..d55fca7f 100644 --- a/frontend/src/components/nav/sidebar-left.tsx +++ b/frontend/src/components/nav/sidebar-left.tsx @@ -9,6 +9,7 @@ import { TerminalIcon, VideoIcon, Unplug, + NetworkIcon, } from "lucide-react"; import DWELogo from "@/assets/dwe-logo.svg"; @@ -45,6 +46,11 @@ const data = { url: "/recordings", icon: VideoIcon, }, + { + title: "Network", + url: "/network", + icon: NetworkIcon, + }, { title: "Preferences", url: "/preferences", From bc6d97c80995abdcdd1f54e7f6b44bb56f3313dc Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 7 May 2026 16:45:19 -0700 Subject: [PATCH 14/15] Fix zip issue --- backend_py/src/routes/recordings.py | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index 3bb29630..92841a85 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -21,6 +21,23 @@ def get_recordings(request: Request) -> list[RecordingInfo]: return recordings_service.get_recordings() +@recordings_router.get("/zip", summary="Download all recordings as a zip file") +def zip_recordings(request: Request) -> FileResponse: + recordings_service: RecordingsService = request.app.state.recordings_service + + zip_file_path = recordings_service.zip_recordings() + if not zip_file_path: + raise HTTPException(status_code=404, detail="No recordings to zip") + + resp = FileResponse( + zip_file_path, + media_type="application/zip", + filename="recordings.zip", + headers={"Content-Disposition": "attachment; filename=recordings.zip"}, + ) + return resp + + @recordings_router.get("/{recording_path}", summary="Get a specific recording") def get_recording(request: Request, recording_path: str) -> FileResponse: @@ -65,20 +82,3 @@ def rename_recording( ) return response - - -@recordings_router.get("/zip", summary="Download all recordings as a zip file") -def zip_recordings(request: Request) -> FileResponse: - recordings_service: RecordingsService = request.app.state.recordings_service - - zip_file_path = recordings_service.zip_recordings() - if not zip_file_path: - raise HTTPException(status_code=404, detail="No recordings to zip") - - resp = FileResponse( - zip_file_path, - media_type="application/zip", - filename="recordings.zip", - headers={"Content-Disposition": "attachment; filename=recordings.zip"}, - ) - return resp From b96a66ff26dce859a41aec4c908a217895a942ba Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 7 May 2026 17:55:18 -0700 Subject: [PATCH 15/15] Fix issues with recording UI and backend --- .../src/services/recordings/__init__.py | 36 +- .../components/dwe/recordings/recordings.tsx | 698 +++++++++++------- 2 files changed, 470 insertions(+), 264 deletions(-) diff --git a/backend_py/src/services/recordings/__init__.py b/backend_py/src/services/recordings/__init__.py index c49d4f5f..d417f0ae 100644 --- a/backend_py/src/services/recordings/__init__.py +++ b/backend_py/src/services/recordings/__init__.py @@ -9,6 +9,7 @@ import logging import os import subprocess +import threading import zipfile from pydantic import BaseModel @@ -29,6 +30,7 @@ def __init__(self) -> None: self.recordings_path = os.path.join(os.getcwd(), "videos") self.recordings: list[RecordingInfo] = [] self.logger = logging.getLogger("dwe_os_2.RecordingsService") + self.recordings_lock = threading.Lock() self.durations = {} @@ -36,22 +38,24 @@ def get_recordings(self) -> list[RecordingInfo]: if not os.path.exists(self.recordings_path): os.makedirs(self.recordings_path) - self.recordings = [] - for filename in os.listdir(self.recordings_path): - if filename.endswith((".mp4", ".avi")): - file_path = os.path.join(self.recordings_path, filename) - file_stat = os.stat(file_path) - recording_info = RecordingInfo( - path=file_path, - name=filename.split(".")[0], - format=filename.split(".")[-1], - duration=self._get_duration(file_path), - created=self._epoch_to_readable(file_stat.st_ctime), - size=f"{file_stat.st_size / (1024 * 1024):.2f} MB", - ) - self.recordings.append(recording_info) - - return self.recordings + with self.recordings_lock: + recordings: list[RecordingInfo] = [] + for filename in os.listdir(self.recordings_path): + if filename.endswith((".mp4", ".avi", ".dwvo")): + file_path = os.path.join(self.recordings_path, filename) + file_stat = os.stat(file_path) + recording_info = RecordingInfo( + path=file_path, + name=filename.split(".")[0], + format=filename.split(".")[-1], + duration=self._get_duration(file_path), + created=self._epoch_to_readable(file_stat.st_ctime), + size=f"{file_stat.st_size / (1024 * 1024):.2f} MB", + ) + recordings.append(recording_info) + + self.recordings = recordings + return recordings def _epoch_to_readable(self, epoch: float) -> str: from datetime import datetime diff --git a/frontend/src/components/dwe/recordings/recordings.tsx b/frontend/src/components/dwe/recordings/recordings.tsx index 6d0f3d25..8163c0a2 100644 --- a/frontend/src/components/dwe/recordings/recordings.tsx +++ b/frontend/src/components/dwe/recordings/recordings.tsx @@ -1,5 +1,7 @@ import { API_CLIENT } from "@/api"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Table, TableBody, @@ -7,16 +9,36 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { components } from "@/schemas/dwe_os_2"; import { Separator } from "@/components/ui/separator"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { + AlertTriangle, Download, FolderArchive, + Pencil, + Play, Trash, Video, VideoOff, - X, } from "lucide-react"; import { useTour } from "@/components/tour/tour"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; @@ -26,6 +48,7 @@ import { TooltipTrigger, TruncatedTooltip, } from "@/components/ui/tooltip"; +import { toast } from "sonner"; type RecordingInfo = components["schemas"]["RecordingInfo"]; @@ -48,6 +71,21 @@ const formatFileSize = (sizeInMB: number): string => { } }; +const formatDate = (value: string | null | undefined): string => { + if (!value) return "—"; + const d = new Date(value); + if (isNaN(d.getTime())) return value; + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const fullName = (rec: RecordingInfo) => `${rec.name}.${rec.format}`; + const Recordings = () => { const hostAddress: string = window.location.hostname; const baseUrl = `http://${ @@ -55,8 +93,6 @@ const Recordings = () => { }`; const [recordings, setRecordings] = useState([]); - const [selectedRecording, setSelectedRecording] = - useState(null); const [sortColumn, setSortColumn] = useState( null, @@ -67,27 +103,21 @@ const Recordings = () => { const { isActive } = useTour(); - const sortRecordings = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let modifier = (x: any) => x; - if (sortColumn === "size") { - modifier = (x: string) => parseFloat(x); - } - if (sortColumn) { - const sorted = [...recordings].sort((a, b) => { - if (sortDirection === "asc") { - return modifier(a[sortColumn]) > modifier(b[sortColumn]) ? 1 : -1; - } else { - return modifier(a[sortColumn]) < modifier(b[sortColumn]) ? 1 : -1; - } - }); - setRecordings(sorted); - } - }; + // Rename dialog state + const [renameTarget, setRenameTarget] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [renameSubmitting, setRenameSubmitting] = useState(false); - useEffect(() => { - sortRecordings(); - }, [sortColumn, sortDirection]); + // Delete dialog state (with confirmation guardrail) + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteConfirmText, setDeleteConfirmText] = useState(""); + const [deleteSubmitting, setDeleteSubmitting] = useState(false); + + // Video player dialog state + const [playTarget, setPlayTarget] = useState(null); + + // "Download All" zip state + const [zipDownloading, setZipDownloading] = useState(false); const handleSort = (column: keyof RecordingInfo) => { if (sortColumn === column) { @@ -107,16 +137,20 @@ const Recordings = () => { useState(null); const menuRef = useRef(null); + const closeContextMenu = () => { + setShowMenu(false); + setRightClickedRecording(null); + }; + useEffect(() => { const handleInterrupt = (event: Event) => { if (event.type == "wheel") { - setShowMenu(false); - setRightClickedRecording(null); + closeContextMenu(); + return; } if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setShowMenu(false); - setRightClickedRecording(null); + closeContextMenu(); } }; @@ -142,17 +176,14 @@ const Recordings = () => { let newX = xPos; let newY = yPos; - // Check right boundary if exceeding flip to left of cursor if (xPos + menuWidth > viewportWidth) { newX = xPos - menuWidth; } - // Check bottom boundary, if exceeding flip to top odf cursor if (yPos + menuHeight > viewportHeight) { newY = yPos - menuHeight; } - // Update state if x/y were changed if (newX !== xPos || newY !== yPos) { setXPos(newX); setYPos(newY); @@ -165,21 +196,158 @@ const Recordings = () => { event: React.MouseEvent, ) => { event.preventDefault(); - - // Set position setXPos(event.clientX); setYPos(event.clientY); setShowMenu(true); setRightClickedRecording(selected); }; + const isPlayable = (rec: RecordingInfo) => rec.format === "mp4"; + + const recordingStreamUrl = (rec: RecordingInfo) => + `${baseUrl}/api/recordings/${encodeURIComponent(fullName(rec))}`; + + const downloadRecording = (rec: RecordingInfo) => { + const link = document.createElement("a"); + link.href = `${recordingStreamUrl(rec)}?download=true`; + link.download = fullName(rec); + document.body.appendChild(link); + link.click(); + link.remove(); + closeContextMenu(); + }; + + const openPlayDialog = (rec: RecordingInfo) => { + if (!isPlayable(rec)) { + toast.info("Format not playable in browser", { + description: `.${rec.format} files must be downloaded to play.`, + }); + closeContextMenu(); + return; + } + setPlayTarget(rec); + closeContextMenu(); + }; + + const downloadAllZip = async () => { + if (zipDownloading) return; + setZipDownloading(true); + try { + const response = await fetch(`${baseUrl}/api/recordings/zip`); + if (!response.ok) { + let description = `Server responded with ${response.status}.`; + try { + const data = await response.json(); + if (data?.detail) description = data.detail; + } catch { + // non-JSON error body; keep default description + } + toast.error("Failed to download recordings", { description }); + return; + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "recordings.zip"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (error) { + console.error("Error downloading recordings zip:", error); + toast.error("Failed to download recordings", { + description: "Please check the logs for more details.", + }); + } finally { + setZipDownloading(false); + } + }; + + const openRenameDialog = (rec: RecordingInfo) => { + setRenameTarget(rec); + setRenameValue(rec.name); + closeContextMenu(); + }; + + const openDeleteDialog = (rec: RecordingInfo) => { + setDeleteTarget(rec); + setDeleteConfirmText(""); + closeContextMenu(); + }; + + const performRename = async () => { + if (!renameTarget) return; + const trimmed = renameValue.trim(); + if (!trimmed || trimmed === renameTarget.name) { + setRenameTarget(null); + return; + } + setRenameSubmitting(true); + try { + const result = await API_CLIENT.PATCH( + "/api/recordings/{old_name}/{new_name}", + { + params: { + path: { + old_name: fullName(renameTarget), + new_name: `${trimmed}.${renameTarget.format}`, + }, + }, + }, + ); + const newRecs = result.data as RecordingInfo[] | undefined; + if (newRecs) { + setRecordings(newRecs); + toast.success("Recording renamed", { + description: `"${renameTarget.name}" → "${trimmed}"`, + }); + } + setRenameTarget(null); + } catch (error) { + console.error("Error renaming recording:", error); + toast.error("Failed to rename recording", { + description: "Please check the logs for more details.", + }); + } finally { + setRenameSubmitting(false); + } + }; + + const performDelete = async () => { + if (!deleteTarget) return; + setDeleteSubmitting(true); + try { + const result = await API_CLIENT.DELETE( + "/api/recordings/{recording_path}", + { + params: { + path: { recording_path: fullName(deleteTarget) }, + }, + }, + ); + const newRecs = result.data as RecordingInfo[] | undefined; + if (newRecs) { + setRecordings(newRecs); + toast.success("Recording deleted", { + description: fullName(deleteTarget), + }); + } + setDeleteTarget(null); + setDeleteConfirmText(""); + } catch (error) { + console.error("Error deleting recording:", error); + toast.error("Failed to delete recording", { + description: "Please check the logs for more details.", + }); + } finally { + setDeleteSubmitting(false); + } + }; + useEffect(() => { - // Fetch recordings data from the backend API_CLIENT.GET("/api/recordings") .then((data) => setRecordings(data.data!)) - .then(() => { - sortRecordings(); - }) .catch((error) => console.error("Error fetching recordings:", error)) .finally(() => setLoading(false)); }, []); @@ -205,82 +373,73 @@ const Recordings = () => { }); }, [recordings, sortColumn, sortDirection, isActive]); + const deleteConfirmMatches = + deleteTarget !== null && deleteConfirmText.trim() === deleteTarget.name; + + const renameDisabled = + !renameValue.trim() || + renameSubmitting || + (renameTarget !== null && renameValue.trim() === renameTarget.name); + return (
-
- {/* handles right click on recordings */} - {showMenu && ( +
+ {showMenu && rightClickedRecording && (
-

- {rightClickedRecording?.name}.{rightClickedRecording?.format} -

- -
{ - const newName = prompt( - `Enter new name for "${rightClickedRecording?.name}":`, - rightClickedRecording?.name, - ); - if (newName && newName.trim() && rightClickedRecording) { - API_CLIENT.PATCH("/api/recordings/{old_name}/{new_name}", { - params: { - path: { - old_name: `${rightClickedRecording.name}.${rightClickedRecording.format}`, - new_name: `${newName.trim()}.${rightClickedRecording.format}`, - }, - }, - }) - .then((newRecs) => { - setRecordings(newRecs.data! as RecordingInfo[]); - setShowMenu(false); - }) - .catch((error) => - console.error("Error renaming recording:", error), - ); - } else { - setShowMenu(false); - } - }} +
+ {fullName(rightClickedRecording)} +
+ + + +
- -
{ - if (rightClickedRecording) { - API_CLIENT.DELETE("/api/recordings/{recording_path}", { - params: { - path: { - recording_path: `${rightClickedRecording.name}.${rightClickedRecording.format}`, - }, - }, - }) - .then((newRecs) => { - setRecordings(newRecs.data! as RecordingInfo[]); - setShowMenu(false); - }) - .catch((error) => - console.error("Error deleting recording:", error), - ); - } - }} + + +
+
)} - {/* handles recording display */} -
- {" "} + +
{loading ? (
Loading... @@ -298,7 +457,7 @@ const Recordings = () => { (sortDirection === "asc" ? "▲" : "▼")} handleSort("created")} > Created   @@ -329,19 +488,9 @@ const Recordings = () => { { - if (selectedRecording === recording) { - setSelectedRecording(null); - } else { - setSelectedRecording(recording); - } - }} onContextMenu={(e) => handleContextMenu(recording, e)} - className={ - selectedRecording === recording - ? "bg-accent cursor-pointer" - : "cursor-pointer bg-background hover:bg-muted rounded-xl" - } + onDoubleClick={() => openPlayDialog(recording)} + className="bg-background hover:bg-muted cursor-pointer select-none" >
@@ -368,8 +517,8 @@ const Recordings = () => { />
- - {recording.created} + + {formatDate(recording.created)} {recording.duration} @@ -385,154 +534,207 @@ const Recordings = () => { )}
- {/* handles recording detailed view */} -
-
setSelectedRecording(null)} - className="flex flex-col justify-center my-8 h-auto cursor-pointer group hover:bg-accent bg-sidebar/50 backdrop-blur border border-r-0 rounded-l-2xl" - > - -
-
- {selectedRecording?.format === "mp4" ? ( - - ) : ( -
-

- .{selectedRecording?.format} is not supported in the browser - video player. -
- Use the download button to download the file and play it in a - compatible player like VLC. -

-
- )} -
-

- {selectedRecording?.name}.{selectedRecording?.format} -

-
-
- -
-

- - Format: - {" "} - {selectedRecording?.format - .toLocaleUpperCase() - .replace("MP4", "MPEG-4")} -

-

- - Created: - {" "} - {selectedRecording?.created} -

-

- - Duration: - {" "} - {selectedRecording?.duration} -

-

- - Size: - {" "} - {formatFileSize( - selectedRecording?.size - ? parseFloat(selectedRecording.size) - : 0, - )} -

-
-
-
-
-
- {/* footer at bottom of viewport */} +
-
- -
- Total Recordings: {recordings.length} +
+ + Total Recordings:{" "} + + {recordings.length} + + Total Size:{" "} - {formatFileSize( - recordings.reduce( - (acc, rec) => acc + (rec.size ? parseFloat(rec.size) : 0), - 0, - ), - )} + + {formatFileSize( + recordings.reduce( + (acc, rec) => acc + (rec.size ? parseFloat(rec.size) : 0), + 0, + ), + )} +
+ + {/* Video Player Dialog */} + { + if (!open) setPlayTarget(null); + }} + > + + + + {playTarget && fullName(playTarget)} + + + {playTarget && + `${formatDate(playTarget.created)} • ${ + playTarget.duration + } • ${formatFileSize( + playTarget.size ? parseFloat(playTarget.size) : 0, + )}`} + + + {playTarget && ( +
+
+ )} + + + + +
+
+ + {/* Rename Dialog */} + { + if (!open && !renameSubmitting) setRenameTarget(null); + }} + > + + + Rename recording + + The file extension{" "} + + .{renameTarget?.format} + {" "} + will be preserved. + + +
{ + e.preventDefault(); + if (!renameDisabled) performRename(); + }} + className="space-y-3" + > +
+ +
+ setRenameValue(e.target.value)} + className="border-0 shadow-none focus-visible:ring-0" + placeholder="Enter a new name" + disabled={renameSubmitting} + /> + + .{renameTarget?.format} + +
+
+
+ + + + +
+
+ + {/* Delete confirmation AlertDialog */} + { + if (!open && !deleteSubmitting) { + setDeleteTarget(null); + setDeleteConfirmText(""); + } + }} + > + + +
+
+ +
+
+ Delete recording? + + This action cannot be undone. + +
+
+
+
+
+ + setDeleteConfirmText(e.target.value)} + placeholder={deleteTarget?.name} + autoComplete="off" + disabled={deleteSubmitting} + /> +
+
+ + + Cancel + + { + e.preventDefault(); + performDelete(); + }} + disabled={!deleteConfirmMatches || deleteSubmitting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteSubmitting ? "Deleting..." : "Delete"} + + +
+
); };