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 diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 7c417804..970a6999 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 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, @@ -314,6 +316,7 @@ def _clear(self) -> None: class Device(events.EventEmitter): def __init__(self, device_info: DeviceInfo) -> None: super().__init__() + self.cameras: list[Camera] = [] for device_path in device_info.device_paths: self.cameras.append(Camera(device_path)) @@ -334,9 +337,14 @@ 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 # 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,11 @@ def __init__(self, device_info: DeviceInfo) -> None: self._get_controls() + def _update_drop_stats(self) -> None: + with self._frame_stats_lock: + self.frame_stats.num_drops += 1 + self.emit("frame_stats") + def _on_stream_error(self, err: str) -> None: self.logger.error(err) # TODO @@ -485,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] @@ -521,6 +534,10 @@ def start_stream(self) -> None: self.stream.enabled = True self.stream_runner.start() + 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 self.stream_runner.stop() @@ -529,6 +546,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..ecb7044e 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -80,11 +80,15 @@ def __init__( self.logger = logging.getLogger("dwe_os_2.cameras.DeviceManager") + # Captured in start_monitoring + self._loop: asyncio.AbstractEventLoop | None = None + 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: @@ -125,6 +129,8 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: lambda _: self._append_stream_error(DeviceModel.model_validate(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)) @@ -278,7 +284,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 @@ -313,51 +319,52 @@ 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 + 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: + 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 @@ -366,6 +373,37 @@ 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 + """ + # 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( + "device.frame_stats", + { + "bus_info": device.bus_info, + "frame_stats": frame_stats_payload, + }, + ) + 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..fcb46c95 100644 --- a/backend_py/src/services/cameras/ehd.py +++ b/backend_py/src/services/cameras/ehd.py @@ -46,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/pydantic_schemas.py b/backend_py/src/services/cameras/pydantic_schemas.py index 35e773d1..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 @@ -165,6 +165,10 @@ class Config: from_attributes = True +class FrameDropStats(BaseModel): + num_drops: int + + class DeviceModel(BaseModel): # List of cameras, e.g. /dev/video0, /dev/video2 cameras: list[CameraModel] | None = None @@ -191,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/shd.py b/backend_py/src/services/cameras/shd.py index 96029f05..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") @@ -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) @@ -132,7 +139,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( @@ -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 @@ -601,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() 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..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 @@ -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: @@ -136,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=1000) - if self.stream_thread: - self.stream_thread.join(timeout=1000) - 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: 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 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/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/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/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..4515019a 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", @@ -244,6 +245,8 @@ const DeviceListLayout = () => { const displayDevices = isActive && devices.length === 0 ? [demoDeviceProxy] : devices; + if (!connected) return ; + return (
@@ -274,8 +277,7 @@ const DeviceListLayout = () => {
))} - {displayDevices.length === 0 && - (connected ? : )} + {displayDevices.length === 0 && }
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..c9003112 --- /dev/null +++ b/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx @@ -0,0 +1,93 @@ +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]); + + const total = frameStats?.num_drops ?? 0; + const hasDrops = total > 0; + + if (!hasDrops) return null; + + return ( + + + +
+ + {total > 9999 ? "9999+" : total} +
+
+ +
Frame drops
+
+
+ This stream: {total} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx index dba1a542..c2d565ab 100644 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream.tsx @@ -59,6 +59,9 @@ 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"); + + console.log(hwBitrateControl); const [exposureTime, setExposureTime] = useState(shutterControl?.value || 0); // 0x3501 const [autoExposure, setAutoExposure] = useState( @@ -68,6 +71,7 @@ export const SensorControls = () => { const [strobeWidth, setStrobeWidth] = useState( strobeWidthControl?.value || 0, ); + const [hwBitrate, setHwBitrate] = useState(hwBitrateControl?.value ?? 0); const strobeMax = exposureTime!; @@ -121,6 +125,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 +138,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 +177,58 @@ export const SensorControls = () => { {/* Manual Controls */} - {!autoExposure && ( -
-
- { - if (matchExposure) { - setMatchedExposure(newValue); - } - setExposureTime(newValue); - }} - /> - { - if (matchExposure) { - setMatchedISO(newValue); +
+
+ {!autoExposure && ( + <> + - -
+ onChange={(newValue) => { + if (matchExposure) { + setMatchedExposure(newValue); + } + setExposureTime(newValue); + }} + /> + { + if (matchExposure) { + setMatchedISO(newValue); + } + setGain(newValue); + }} + /> + + + )} + { + setHwBitrate(newValue); + }} + />
- )} +
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/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 +

+
+
+
); } 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/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"} + + +
+
); }; diff --git a/frontend/src/components/nav/sidebar-left.tsx b/frontend/src/components/nav/sidebar-left.tsx index 4e8e76d9..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", @@ -80,7 +86,7 @@ export function SidebarLeft({
- + }, { path: "/cameras", element: }, + { path: "/network", element: }, { path: "/preferences", element: }, { path: "/log-viewer", element: }, { path: "/terminal", element: }, 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 */ 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)