diff --git a/backend_py/run.py b/backend_py/run.py index 2139f8d5..c27a407f 100644 --- a/backend_py/run.py +++ b/backend_py/run.py @@ -1,3 +1,7 @@ +# run.py runs the backend, creating a async socketio server and a FastAPI web framework, then +# both are passed into a Server instance to handle logic, and a combination of the two is hosted +# as a uvicorn server, which handles traffic + from src import Server, FeatureSupport import socketio from fastapi import FastAPI diff --git a/backend_py/src/routes/cameras.py b/backend_py/src/routes/cameras.py index 94fc4386..1d19b39e 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -1,3 +1,10 @@ +""" +camera.py + +API endpoints for camera device management and streaming config +Handles listing connected devices, updating stream settings (resolution / fps), setting UVC controls, and dealing with Leader/Follower for stereo cameras +""" + from fastapi import APIRouter, Depends, Request from ..services import DeviceManager, StreamInfoModel, DeviceNicknameModel, UVCControlModel, DeviceDescriptorModel, DeviceLeaderModel import logging diff --git a/backend_py/src/routes/lights.py b/backend_py/src/routes/lights.py index f74530c2..5a9cfe91 100644 --- a/backend_py/src/routes/lights.py +++ b/backend_py/src/routes/lights.py @@ -1,3 +1,10 @@ +""" +lights.py + +API endpoints for light device management +Handles listing connected lights, setting intensity, and disabling lights +""" + from fastapi import APIRouter, Depends, Request from typing import List from ..services import LightManager, Light, DisableLightInfo, SetLightInfo diff --git a/backend_py/src/routes/preferences.py b/backend_py/src/routes/preferences.py index f92482f0..b4587a03 100644 --- a/backend_py/src/routes/preferences.py +++ b/backend_py/src/routes/preferences.py @@ -1,3 +1,10 @@ +""" +preferences.py + +API endpoints for server perferences +Handles getting and setting preferences +""" + from fastapi import APIRouter, Depends, Request from typing import Dict from ..services import PreferencesManager, SavedPreferencesModel diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index adda148c..86456fc6 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -1,3 +1,10 @@ +""" +recording.py + +API endpoints for accessing video file library +Handles listing recording metadata, downloading / deleting / renaming recordings, and downloading all recordings as ZIP +""" + from fastapi import APIRouter, Depends, Request, HTTPException from fastapi.responses import FileResponse from typing import List diff --git a/backend_py/src/routes/system.py b/backend_py/src/routes/system.py index 609f09e3..e0c413fe 100644 --- a/backend_py/src/routes/system.py +++ b/backend_py/src/routes/system.py @@ -1,3 +1,10 @@ +""" +system.py + +API endpoints for csystem power control +Handles rebooting / shutting down the system +""" + from fastapi import APIRouter, Request from ..services import SystemManager diff --git a/backend_py/src/routes/wifi.py b/backend_py/src/routes/wifi.py index 71b5a4df..ae33cad3 100644 --- a/backend_py/src/routes/wifi.py +++ b/backend_py/src/routes/wifi.py @@ -1,3 +1,10 @@ +""" +wifi.py + +API endpoints for wifi network management +Handles listing available networks, connecting / disconnecting from networks, managing saved networks, and toggling wifi +""" + from fastapi import APIRouter, Depends, Request from typing import List from ..services import ( diff --git a/backend_py/src/routes/wired.py b/backend_py/src/routes/wired.py index 3aea261e..a53349ba 100644 --- a/backend_py/src/routes/wired.py +++ b/backend_py/src/routes/wired.py @@ -1,3 +1,10 @@ +""" +wired.py + +API endpoints for connected ethernet networks +Handles managing dyanmic / static addresses for ethernet, managing priority (prioritize wifi or ethernet) +""" + from fastapi import APIRouter, Depends, Request from typing import List from ..services import ( diff --git a/backend_py/src/server.py b/backend_py/src/server.py index 93cb38ef..ef5e3cce 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -1,3 +1,10 @@ +""" +server.py + +Handles server logic and initializes all the managers (settings, devices, lights, etc) +Starts device monitoring, wifi scan, and starts ttyd (teletypewriter daemon) to run in the background +""" + from ctypes import * import logging.handlers diff --git a/backend_py/src/services/cameras/__init__.py b/backend_py/src/services/cameras/__init__.py index 547d6cff..d9b69019 100644 --- a/backend_py/src/services/cameras/__init__.py +++ b/backend_py/src/services/cameras/__init__.py @@ -1,7 +1,7 @@ from .device_manager import * from .device_utils import * from .device import * -from .ehd_controls import * +from .xu_controls import * from .ehd import * from .enumeration import * from .pydantic_schemas import * diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index e7731be0..250ea9a4 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -1,3 +1,10 @@ +""" +device.py + +Base class for camera device management +Handles v4l2 device finding, uvc controls, stream configuration, and device settings management +""" + from ctypes import * import struct from dataclasses import dataclass @@ -10,7 +17,7 @@ from enum import Enum from . import v4l2 -from . import ehd_controls as xu +from . import xu_controls as xu from .stream_utils import fourcc2s from .enumeration import * @@ -34,6 +41,16 @@ "PID": 0x6368, "device_type": DeviceType.STELLARHD_FOLLOWER, }, + "stellarHDPro: Leader": { + "VID": 0xC45, + "PID": 0x6369, + "device_type": DeviceType.STELLARHD_LEADER_PRO, + }, + "stellarHDPro: Follower": { + "VID": 0xC45, + "PID": 0x6370, + "device_type": DeviceType.STELLARHD_FOLLOWER_PRO, + }, } @@ -230,7 +247,6 @@ def _get_ctrl(self): def _clear(self): self._data = b"\x00" * self._size - class Device(events.EventEmitter): def __init__(self, device_info: DeviceInfo) -> None: diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index f1d48629..f4bc6160 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -1,3 +1,13 @@ +""" +device_manager.py + +Handles functionality of device and montiors for devices +When it finds a new device, it creates a new device object and updates the device list and that devices settings +When it sees a missing device, it removes that device ojbect from the device list +Manages a devices streaming state as well as changes to device name +Manages the leader follower connections +""" + from typing import * import logging import event_emitter as events diff --git a/backend_py/src/services/cameras/device_utils.py b/backend_py/src/services/cameras/device_utils.py index c634639b..f131a0fd 100644 --- a/backend_py/src/services/cameras/device_utils.py +++ b/backend_py/src/services/cameras/device_utils.py @@ -1,3 +1,9 @@ +""" +device_utils.py + +Utility functions for device_manager.py, specifically for finding added devices / removed devices +""" + from typing import List from .device import Device diff --git a/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py index 64b35189..fde7b389 100644 --- a/backend_py/src/services/cameras/ehd.py +++ b/backend_py/src/services/cameras/ehd.py @@ -1,8 +1,15 @@ +""" +ehd.py + +Adds additional features to exploreHD devices through extension units (xu) as per UVC protocol +Uses options functionality to set defaults, ranges, and specifies registers for where these features store data +""" + from typing import Dict from .enumeration import DeviceInfo from .device import Device, Option, ControlTypeEnum from .pydantic_schemas import H264Mode -from . import ehd_controls as xu +from . import xu_controls as xu class EHDDevice(Device): ''' @@ -31,7 +38,7 @@ def _get_options(self) -> Dict[str, Option]: # Standard integer options options['bitrate'] = Option( self.cameras[2], '>I', xu.Unit.USR_ID, xu.Selector.USR_H264_CTRL, xu.Command.H264_BITRATE_CTRL, 'Bitrate', - lambda bitrate: int(bitrate * 1000000), # convert to bps from mpbs + lambda bitrate: int(round(bitrate * 1000000)), # convert to bps from mpbs (round for float imprecision) lambda bitrate: bitrate / 1000000 # convert to mpbs from bps ) @@ -45,8 +52,8 @@ def _get_options(self) -> Dict[str, Option]: # Maybe rename mode to vbr etc. options['vbr'] = Option( self.cameras[2], 'B', xu.Unit.USR_ID, xu.Selector.USR_H264_CTRL, xu.Command.H264_MODE_CTRL, 'Variable Bitrate', - lambda mode : H264Mode.MODE_VARIABLE_BITRATE.value if mode else H264Mode.MODE_CONSTANT_BITRATE.value, - lambda mode_value : H264Mode(mode_value) == H264Mode.MODE_VARIABLE_BITRATE) + lambda mode : H264Mode.MODE_VARIABLE_BITRATE.value if mode else H264Mode.MODE_CONSTANT_BITRATE.value, + lambda mode_value : H264Mode(mode_value) == H264Mode.MODE_VARIABLE_BITRATE) return options diff --git a/backend_py/src/services/cameras/enumeration.py b/backend_py/src/services/cameras/enumeration.py index e0dfa66d..d858078a 100644 --- a/backend_py/src/services/cameras/enumeration.py +++ b/backend_py/src/services/cameras/enumeration.py @@ -1,3 +1,10 @@ +""" +enumeration.py + +Searches the system for cameras using video4linux, create a DeviceInfo based on the camera, and then maps that to the camera's bus_info, +and return a sorted list of device_infos +""" + from dataclasses import dataclass from . import v4l2 import fcntl diff --git a/backend_py/src/services/cameras/pydantic_schemas.py b/backend_py/src/services/cameras/pydantic_schemas.py index 104fb38c..556dc153 100644 --- a/backend_py/src/services/cameras/pydantic_schemas.py +++ b/backend_py/src/services/cameras/pydantic_schemas.py @@ -1,3 +1,10 @@ +""" +pydantic_schemas.py + +Defines Pydantic models and Enums for camera and device configs +Includes schemas for streams, controls, device info, and API request/response strutures +""" + from pydantic import BaseModel, Field from typing import List, Dict, Optional from enum import Enum, IntEnum @@ -56,6 +63,8 @@ class DeviceType(IntEnum): EXPLOREHD = 0 STELLARHD_LEADER = 1 STELLARHD_FOLLOWER = 2 + STELLARHD_LEADER_PRO = 3 + STELLARHD_FOLLOWER_PRO = 4 class IntervalModel(BaseModel): diff --git a/backend_py/src/services/cameras/saved_pydantic_schemas.py b/backend_py/src/services/cameras/saved_pydantic_schemas.py index ffba3e5a..3739c9ec 100644 --- a/backend_py/src/services/cameras/saved_pydantic_schemas.py +++ b/backend_py/src/services/cameras/saved_pydantic_schemas.py @@ -1,3 +1,10 @@ +""" +saved_pydantic_schemas.py + +Defines Pydantic models and Enums for persisting device settings and configs +Includes schemas for serializing device states (streams, controls, nicknames) to JSON, keeping setting across reboots +""" + from pydantic import BaseModel from typing import List, Optional diff --git a/backend_py/src/services/cameras/settings.py b/backend_py/src/services/cameras/settings.py index 96adcb2d..259293d9 100644 --- a/backend_py/src/services/cameras/settings.py +++ b/backend_py/src/services/cameras/settings.py @@ -1,3 +1,10 @@ +""" +settings.py + +Manages persisting device settings and configs (cameras, lights, etc) +Handles loading and saving device configs to JSON, keeping setting across reboots, and manages background sync of settings +""" + from typing import List, Dict, cast import threading import time diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 1216c04c..5fd329bc 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -1,6 +1,16 @@ +""" +shd.py + +Adds additional features to stellarHD devices +Uses options functionality to set defaults, ranges, and specifies registers for where these features store data +""" + +import logging +import subprocess from .saved_pydantic_schemas import SavedDeviceModel from .enumeration import DeviceInfo from .device import Device, BaseOption, ControlTypeEnum, StreamEncodeTypeEnum +from . import xu_controls as xu from typing import Dict, List from event_emitter import EventEmitter @@ -18,7 +28,90 @@ def set_value(self, value): def get_value(self): return self.value + +""" +Stellar Dual Register Class +""" +class DualRegisterOption(StellarOption): + def __init__( + self, + camera, + high_cmd: xu.Command, + low_cmd: xu.Command, + name:str, + value: int = 0 + ) -> None: + super().__init__(name, value) + + self._camera = camera + self.high_reg = high_cmd + self.low_reg = low_cmd + + self.script_path = "/opt/DWE_Stellar_Control/stellar_control.sh" + self.logger = logging.getLogger("dwe_os.cameras.DualRegisterOption") + + # grab values from 2 registers and combine them to read + def get_value(self): + high_val = self._run_script("read", self._camera.path, self.high_reg) + if high_val is None: + return self.value + low_val = self._run_script("read", self._camera.path, self.low_reg) + if low_val is None: + return self.value + + try: + high_val = int(high_val.strip()) + low_val = int(low_val.strip()) + total_val = (high_val << 8) | low_val + + self.value = total_val + return total_val + except ValueError: + return self.value + + # split value across 2 registers to set + def set_value(self, value): + try: + inputTotal = int(value) + except ValueError: + return + + self.value = inputTotal + + high_val = (inputTotal >> 8) & 0xFF + low_val = inputTotal & 0xFF + + device_path = self._camera.path + self._run_script("write", device_path, self.high_reg, high_val) + self._run_script("write", device_path, self.low_reg, low_val) + + self.emit("value_changed") + + + def _run_script(self, operation, dev_path, register, value=None): + # create arguements + register = f"0x{register.value:04x}" + cmd = [ + self.script_path, + "--dev", dev_path, + operation, + register + ] + if operation == "write" and value is not None: + cmd.append(str(value)) + + # run command + try: + result = subprocess.run(cmd, check=True, capture_output=True, text=True) + if operation == "read": + return result.stdout.strip() + else: + self.logger.info(f"Write output: {result.stdout.strip()}") + return None + except subprocess.CalledProcessError as e: + self.logger.error(f"{operation} Failed on {register}: {e.stderr}") + return None class SHDDevice(Device): """ @@ -26,6 +119,9 @@ class SHDDevice(Device): """ def __init__(self, device_info: DeviceInfo) -> None: + # Specifies if SHD device is Stellar Pro + self.is_pro = True # self.pid == 0x6369 + super().__init__(device_info) # Copy MJPEG over to Software H264, since they are the same thing @@ -46,6 +142,15 @@ def __init__(self, device_info: DeviceInfo) -> None: "bitrate", 5, ControlTypeEnum.INTEGER, 10, 0.1, 0.1 ) + if self.is_pro: + self.add_control_from_option( + 'shutter', 100, ControlTypeEnum.INTEGER, 10000, 1, 1 + ) + + self.add_control_from_option( + 'iso', 400, ControlTypeEnum.INTEGER, 6400, 100, 100 + ) + def add_follower(self, device: 'SHDDevice'): if device.bus_info in self.followers: self.logger.info( @@ -119,6 +224,15 @@ def update_bitrate(): options["bitrate"] = self.bitrate_option + if self.is_pro: + # UVC shutter speed control + options['shutter'] = DualRegisterOption( + self.cameras[0], xu.Command.SHUTTER_COARSE, xu.Command.SHUTTER_FINE, "Shutter Speed") + + # UVC ISO control + options['iso'] = DualRegisterOption( + self.cameras[0], xu.Command.ISO_COARSE, xu.Command.ISO_FINE, "ISO") + return options def load_settings(self, saved_device: SavedDeviceModel): diff --git a/backend_py/src/services/cameras/stream.py b/backend_py/src/services/cameras/stream.py index 0f927282..9d3d835b 100644 --- a/backend_py/src/services/cameras/stream.py +++ b/backend_py/src/services/cameras/stream.py @@ -1,3 +1,9 @@ +""" +stream.py + +Creates the GStreamer pipeline command to begin streaming from a camera as well as the process for ending a stream +""" + from dataclasses import dataclass, field from datetime import datetime import stat diff --git a/backend_py/src/services/cameras/ehd_controls.py b/backend_py/src/services/cameras/xu_controls.py similarity index 76% rename from backend_py/src/services/cameras/ehd_controls.py rename to backend_py/src/services/cameras/xu_controls.py index 43c172fb..c49b72b2 100644 --- a/backend_py/src/services/cameras/ehd_controls.py +++ b/backend_py/src/services/cameras/xu_controls.py @@ -1,3 +1,9 @@ +""" +xu_controls.py + +Specifies the constants of where each extension unit feature's register address is stored +""" + from enum import Enum @@ -8,7 +14,6 @@ class Unit(Enum): SYS_ID = 0x02 USR_ID = 0x04 - class Selector(Enum): SYS_ASIC_RW = 0x01 SYS_FLASH_CTRL = 0x03 @@ -33,3 +38,7 @@ class Command(Enum): H264_BITRATE_CTRL = 0x02 GOP_CTRL = 0x03 H264_MODE_CTRL = 0x06 + SHUTTER_COARSE = 0x3501 + SHUTTER_FINE = 0x3502 + ISO_COARSE = 0x3508 + ISO_FINE = 0x3509 diff --git a/backend_py/src/services/lights/light.py b/backend_py/src/services/lights/light.py index fdaf7239..ac0d2f6d 100644 --- a/backend_py/src/services/lights/light.py +++ b/backend_py/src/services/lights/light.py @@ -1,3 +1,10 @@ +""" +light.py + +Defines Pydantic models for light API request / responses +Includes schemas for Light objects (intensity, pin, controller info) +""" + from pydantic import BaseModel class Light(BaseModel): diff --git a/backend_py/src/services/lights/light_manager.py b/backend_py/src/services/lights/light_manager.py index 7770235b..d3a684fe 100644 --- a/backend_py/src/services/lights/light_manager.py +++ b/backend_py/src/services/lights/light_manager.py @@ -1,3 +1,11 @@ +""" +light_manager.py + +Manages the light system, initiates the proper PWM controllers and creates Light objects for each available pin +Serves as the main interface for setting light intensity or disbaling lights +Calls on PWM controllers to do the actual PWM +""" + from typing import List, Dict from .pwm_controller import PWMController from .light import Light diff --git a/backend_py/src/services/lights/pwm_controller.py b/backend_py/src/services/lights/pwm_controller.py index 96eaf41a..74547347 100644 --- a/backend_py/src/services/lights/pwm_controller.py +++ b/backend_py/src/services/lights/pwm_controller.py @@ -1,3 +1,10 @@ +""" +pwm_controller.py + +Abstract class definition / interface all PWM drivers must follow +Maintains consistency with PWM functionality +""" + from abc import ABC, abstractmethod import logging diff --git a/backend_py/src/services/lights/rpi_pwm_hardware.py b/backend_py/src/services/lights/rpi_pwm_hardware.py index 8bd11e28..4c195123 100644 --- a/backend_py/src/services/lights/rpi_pwm_hardware.py +++ b/backend_py/src/services/lights/rpi_pwm_hardware.py @@ -1,3 +1,11 @@ +""" +rpi_pwm_hardware.py + +Talks to the Raspberry Pi's processor to set light intensity using Pulse Width Modulation +Determines pins the lights are connected to as well as if they support pwm +Raspberry Pi generates a square wave at set intensity (50% = square wave where 50% is on, 50% is off) +""" + from rpi_hardware_pwm import HardwarePWM, HardwarePWMException from .pwm_controller import PWMController from typing import Dict diff --git a/backend_py/src/services/lights/utils.py b/backend_py/src/services/lights/utils.py index f76ff781..4e1d7391 100644 --- a/backend_py/src/services/lights/utils.py +++ b/backend_py/src/services/lights/utils.py @@ -1,3 +1,9 @@ +""" +utils.py + +Determines what kind of system/hardware the application is on, and then tries to enable PWM controllers on them +""" + import logging # from .fake_pwm import FakePWMController diff --git a/backend_py/src/services/preferences/preferences_manager.py b/backend_py/src/services/preferences/preferences_manager.py index 80b13dd1..c0ba5397 100644 --- a/backend_py/src/services/preferences/preferences_manager.py +++ b/backend_py/src/services/preferences/preferences_manager.py @@ -1,3 +1,10 @@ +""" +preference_manager.py + +Manages persistence of server settings by reading from / writing to server_preferences.json +Handles loading saved prefs and updating the json when settings are modified +""" + import json from typing import Dict from .pydantic_schemas import SavedPreferencesModel diff --git a/backend_py/src/services/preferences/pydantic_schemas.py b/backend_py/src/services/preferences/pydantic_schemas.py index c04eb7ba..c9603268 100644 --- a/backend_py/src/services/preferences/pydantic_schemas.py +++ b/backend_py/src/services/preferences/pydantic_schemas.py @@ -1,3 +1,10 @@ +""" +pydantic_schemas.py + +Defines Pydantic models for persistent server settings +Includes schemas for saved preferences, like default stream endpoints +""" + from pydantic import BaseModel, Field from typing import Optional from ..cameras.pydantic_schemas import StreamEndpointModel diff --git a/backend_py/src/services/recordings/__init__.py b/backend_py/src/services/recordings/__init__.py index f180dc57..be8ec9b3 100644 --- a/backend_py/src/services/recordings/__init__.py +++ b/backend_py/src/services/recordings/__init__.py @@ -1,3 +1,10 @@ +""" +recordings + +Handles locating recordings and extracting metadata from the files +Allows for the renaming, deletion, and compression of the found recordings +""" + from functools import lru_cache import json import os diff --git a/backend_py/src/services/ttyd/ttyd.py b/backend_py/src/services/ttyd/ttyd.py index f886ab6e..416d11d3 100644 --- a/backend_py/src/services/ttyd/ttyd.py +++ b/backend_py/src/services/ttyd/ttyd.py @@ -1,8 +1,16 @@ +""" +ttyd.py + +Controls the terminal provided in DWE_OS +""" + import subprocess class TTYDManager: - - TTYD_CMD = ['ttyd', '-p', '7681', 'login'] + + # TTYD_CMD = ['ttyd', '-p', '7681', 'login'] + # For dev mode comment out above, comment in below: + TTYD_CMD = ['ttyd', '-W', '-a', '-p', '7681', 'bash'] def __init__(self) -> None: self._process: subprocess.Popen | None = None diff --git a/backend_py/src/services/wifi/async_network_manager.py b/backend_py/src/services/wifi/async_network_manager.py index 57eb0b5d..8b974301 100644 --- a/backend_py/src/services/wifi/async_network_manager.py +++ b/backend_py/src/services/wifi/async_network_manager.py @@ -1,3 +1,9 @@ +""" +async_network_manager.py + +Acts as an asyncronous wrapper for network_manager.py, preventing blocking in the FastAPI server +Asycronizes the slow DBus calls (scanning / connecting) to separate threads, prevents freezing +""" import asyncio import time from typing import Callable, List diff --git a/backend_py/src/services/wifi/network_manager.py b/backend_py/src/services/wifi/network_manager.py index ee044004..59c2481d 100644 --- a/backend_py/src/services/wifi/network_manager.py +++ b/backend_py/src/services/wifi/network_manager.py @@ -1,3 +1,10 @@ +""" +network_manager.py + +Manages system network connections by communicating with system NetworkManager through DBus +Handles Wifi scanning and connection management (connect / disconnect / forget) and IP Configuration (static / dynamic) for wired/wireless interfaces +""" + import ipaddress from typing import List, Dict, Any # from .wifi_types import Connection, AccessPoint, IPConfiguration, IPType @@ -50,36 +57,40 @@ def get_ip_info(self, interface_name: str | None = None) -> Dict[str, Any] | Non # TODO: get ip of either active ethernet or wireless - ethernet_device, connection = self._get_eth_device_and_connection() - + try: + ethernet_device, connection = self._get_eth_device_and_connection() + - ethernet_device = self._get_ethernet_device( - interface_name) - if ethernet_device is None: - raise Exception("No ethernet device found") + ethernet_device = self._get_ethernet_device( + interface_name) + if ethernet_device is None: + raise Exception("No ethernet device found") - ipv4_config = IPv4Config( - ethernet_device.ip4_config, self.bus - ) + ipv4_config = IPv4Config( + ethernet_device.ip4_config, self.bus + ) + + addresses = ipv4_config.address_data - addresses = ipv4_config.address_data + # method = self.get_connection_method(connection.id) + dns_arr = [i['address'] for i in ipv4_config.nameserver_data or []] - # method = self.get_connection_method(connection.id) - dns_arr = [i['address'] for i in ipv4_config.nameserver_data or []] + if len(addresses) == 0: + return None + method = self.get_connection_method(connection) - if len(addresses) == 0: + return dict( + static_ip=addresses[0]["address"][1], + prefix=addresses[0]["prefix"][1], + gateway=self.get_ip_gateway(connection), + dns=[i[1] for i in dns_arr], + ip_type="STATIC" if method == "manual" else "DYNAMIC", + ) + except Exception: return None - method = self.get_connection_method(connection) - - return dict( - static_ip=addresses[0]["address"][1], - prefix=addresses[0]["prefix"][1], - gateway=self.get_ip_gateway(connection), - dns=[i[1] for i in dns_arr], - ip_type="STATIC" if method == "manual" else "DYNAMIC", - ) + diff --git a/backend_py/src/services/wifi/wifi_types.py b/backend_py/src/services/wifi/wifi_types.py index 936c45cc..5b29283b 100644 --- a/backend_py/src/services/wifi/wifi_types.py +++ b/backend_py/src/services/wifi/wifi_types.py @@ -1,3 +1,11 @@ +""" +wifi_types.py + +Defines Pydantic models and Enums for wifi operations +Includes schemas for wifi networks (ssid, signal stength), security requirements, and connection states used by system NetworkManager +""" + + from pydantic import BaseModel, Field from typing import Optional, List from enum import Enum diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e310110e..0f97dd8d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "v0.5.0-beta", + "version": "temp", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "v0.5.0-beta", + "version": "temp", "dependencies": { "@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-checkbox": "^1.2.3", @@ -120,6 +120,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -2934,6 +2935,7 @@ "integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2949,6 +2951,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2960,6 +2963,7 @@ "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3006,6 +3010,7 @@ "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.31.0", "@typescript-eslint/types": "8.31.0", @@ -3273,7 +3278,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/acorn": { "version": "8.14.1", @@ -3281,6 +3287,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3515,6 +3522,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -4008,6 +4016,7 @@ "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5939,6 +5948,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -6124,6 +6134,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6136,6 +6147,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6785,6 +6797,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6936,6 +6949,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7216,6 +7230,7 @@ "integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/package.json b/frontend/package.json index 0dfc1c70..fbb24182 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "v0.5.0-beta", + "version": "temp", "type": "module", "scripts": { "dev": "vite --host", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c378bb01..63e04e7e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,7 @@ import { SidebarTrigger, } from "@/components/ui/sidebar"; -import { Outlet } from "react-router-dom"; +import { Outlet, useLocation } from "react-router-dom"; import { ThemeProvider } from "@/components/themes/theme-provider"; import { ModeToggle } from "./components/themes/mode-toggle"; import { CommandPalette } from "./components/dwe/app/command-palette"; @@ -23,16 +23,43 @@ import { Toaster } from "@/components/ui/toaster"; import { WifiDropdown } from "./components/dwe/wireless/wifi-dropdown"; import { WiredDropdown } from "./components/dwe/wireless/wired-dropdown"; import { SystemDropdown } from "./components/dwe/system/system-dropdown"; +import { API_CLIENT } from "./api"; function App() { const socket = useRef(undefined); const [connected, setConnected] = useState(false); + const [wifiAvailable, setWifiAvailable] = useState(false); + + const location = useLocation(); + + const getPageTitle = (pathname: string) => { + switch (pathname) { + case "/": + return "Home"; + case "/cameras": + return "Cameras"; + case "/recordings": + return "Onboard Recordings"; + case "/preferences": + return "Preferences"; + case "/log-viewer": + return "Logs"; + case "/terminal": + return "Terminal"; + default: + return ""; + } + }; + + const pageTitle = getPageTitle(location.pathname); const connectWebsocket = () => { if (socket.current) delete socket.current; socket.current = io( - import.meta.env.DEV ? `http://${window.location.hostname}:5000` : undefined, + import.meta.env.DEV + ? `http://${window.location.hostname}:5000` + : undefined, { transports: ["websocket"] } ); @@ -53,6 +80,12 @@ function App() { } }, [connected]); + useEffect(() => { + API_CLIENT.GET("/features").then((data) => + data.data?.wifi ? setWifiAvailable(true) : setWifiAvailable(false) + ); + }, []); + return ( @@ -63,21 +96,24 @@ function App() {
- +

+ DWE OS +

+ - - DWE OS + + {pageTitle} - + - + {wifiAvailable ? : <>}
diff --git a/frontend/src/components/dwe/app/command-palette.tsx b/frontend/src/components/dwe/app/command-palette.tsx index f3c9e54c..2c47da03 100644 --- a/frontend/src/components/dwe/app/command-palette.tsx +++ b/frontend/src/components/dwe/app/command-palette.tsx @@ -56,11 +56,13 @@ export function CommandPalette() { Cameras runCommand(() => navigate("/videos"))} + onSelect={() => runCommand(() => navigate("/recordings"))} > Recordings - runCommand(() => navigate("/log-viewer"))}> + runCommand(() => navigate("/log-viewer"))} + > Logs @@ -35,9 +33,9 @@ export function CameraCard({
USB Port ID: {deviceState.bus_info} +
- diff --git a/frontend/src/components/dwe/cameras/camera-controls.tsx b/frontend/src/components/dwe/cameras/camera-controls.tsx index a93ad18a..80127323 100644 --- a/frontend/src/components/dwe/cameras/camera-controls.tsx +++ b/frontend/src/components/dwe/cameras/camera-controls.tsx @@ -2,7 +2,6 @@ import { useContext, useEffect, useState } from "react"; // Added useState import DeviceContext from "@/contexts/DeviceContext"; -import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; // Import Dialog components import { @@ -14,8 +13,8 @@ import { DialogTrigger, // DialogFooter, // Optional: if you want a dedicated footer } from "@/components/ui/dialog"; -import { subscribe } from "valtio"; -import { RotateCcwIcon } from "lucide-react"; // Added SettingsIcon +import { subscribe, useSnapshot } from "valtio"; +import { RotateCcwIcon, SlidersHorizontal } from "lucide-react"; import IntegerControl from "./controls/integer-control"; import BooleanControl from "./controls/boolean-control"; @@ -23,12 +22,23 @@ import MenuControl from "./controls/menu-control"; import { components } from "@/schemas/dwe_os_2"; import { API_CLIENT } from "@/api"; import { useToast } from "@/hooks/use-toast"; +import CameraControlMap from "./cam-control-map.json"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; + +type ControlModel = components["schemas"]["ControlModel"]; + +type GroupedControls = { [key: string]: ControlModel[] }; const ControlWrapper = ({ control, index, }: { - control: components["schemas"]["ControlModel"]; + control: ControlModel; index: number; }) => { const key = control.control_id ?? `control-${index}`; @@ -53,6 +63,33 @@ const ControlWrapper = ({ }); }; + // handles disabling associated controls based on another (ie Auto Exposure turns off Exposure Time, Absolute) + // add any dependency / disabling pairings here + const dependencyName = control.name.includes("Exposure Time, Absolute") + ? "Auto Exposure" + : control.name.includes("White Balance Temperature") + ? "White Balance, Auto" + : control.name.includes("Bitrate") + ? "Variable Bitrate" + : null; + + const dependencyControl = dependencyName + ? device.controls.find((c) => c.name.includes(dependencyName)) + : null; + + const dependencySnap = dependencyControl + ? useSnapshot(dependencyControl) + : null; + + let isDisabled = false; + if (dependencySnap) { + if (control.name.includes("Exposure Time, Absolute")) { + isDisabled = dependencySnap.value !== 1; + } else { + isDisabled = !!dependencySnap.value; + } + } + useEffect(() => { const unsub = subscribe(control, () => { setUVCControl(bus_info, control.value, control.control_id); @@ -69,7 +106,9 @@ const ControlWrapper = ({ switch (control.flags.control_type) { case "INTEGER": - return ; + return ( + + ); case "BOOLEAN": return ; case "MENU": @@ -99,40 +138,113 @@ export const CameraControls = () => { ["INTEGER", "BOOLEAN", "MENU"].includes(c.flags.control_type || "") ); + const getGroupName = (controlName: string) => { + for (const [groupName, ids] of Object.entries(CameraControlMap)) { + if (ids.includes(controlName)) { + return groupName; + } + } + return "Miscellaneous"; + }; + + const getTypeRank = (a: ControlModel, b: ControlModel, order: string[]) => { + const typeRankA = order.indexOf(a.flags.control_type); + const typeRankB = order.indexOf(b.flags.control_type); + + if (typeRankA !== typeRankB) { + return typeRankA - typeRankB; + } + + return a.name.localeCompare(b.name); + }; + + const groupedControls = supportedControls.reduce( + (acc, control) => { + const groupName = getGroupName(control.name); + + if (!acc[groupName]) { + acc[groupName] = []; + } + + acc[groupName].push(control); + return acc; + }, + {} + ); + const typeOrder = ["INTEGER", "MENU"]; + + const groupOrder = [ + "Exposure Controls", + "Image Controls", + "System Controls", + "Miscellaneous", + ]; + return ( - - - + + Camera Controls Adjust settings for the selected camera. Changes are applied immediately. -
+
{supportedControls.length > 0 ? (
- {supportedControls.map((control, index) => ( - - ))} - - + {Object.keys(groupedControls) + .sort((a, b) => groupOrder.indexOf(a) - groupOrder.indexOf(b)) + .map((groupName) => { + const booleans = groupedControls[groupName].filter( + (c) => c.flags.control_type === "BOOLEAN" + ); + const others = groupedControls[groupName].filter( + (c) => c.flags.control_type !== "BOOLEAN" + ); + return ( + + + + {groupName} + + + {others && ( +
+ {others + .sort((a, b) => { + return getTypeRank(a, b, typeOrder); + }) + .map((control, index) => ( + + ))} +
+ )} + {booleans && ( +
+ {booleans.map((control, index) => ( + + ))} +
+ )} +
+
+
+ ); + })}
) : (

@@ -140,6 +252,14 @@ export const CameraControls = () => {

)}
+
); diff --git a/frontend/src/components/dwe/cameras/controls/boolean-control.tsx b/frontend/src/components/dwe/cameras/controls/boolean-control.tsx index aeb4afdf..aa1ced8b 100644 --- a/frontend/src/components/dwe/cameras/controls/boolean-control.tsx +++ b/frontend/src/components/dwe/cameras/controls/boolean-control.tsx @@ -1,4 +1,4 @@ -import { Switch } from "@/components/ui/switch"; +import { Toggle } from "@/components/ui/toggle"; import { components } from "@/schemas/dwe_os_2"; import { useState } from "react"; import { subscribe } from "valtio"; @@ -32,8 +32,10 @@ const BooleanControl = ({ return (
- {control.name} - + + +
{control.name}
+
); }; diff --git a/frontend/src/components/dwe/cameras/controls/integer-control.tsx b/frontend/src/components/dwe/cameras/controls/integer-control.tsx index 325b64f6..e94d47b5 100644 --- a/frontend/src/components/dwe/cameras/controls/integer-control.tsx +++ b/frontend/src/components/dwe/cameras/controls/integer-control.tsx @@ -1,150 +1,253 @@ -// src/components/integer-control.tsx (Updated) +// src/components/integer-control.tsx import { Slider } from "@/components/ui/slider"; -import { Input } from "@/components/ui/input"; // Import Input +import { Input } from "@/components/ui/input"; import { components } from "@/schemas/dwe_os_2"; -import { useState, useEffect, useCallback } from "react"; // Import useEffect, useCallback +import { useState, useEffect, useCallback, useRef } from "react"; import { subscribe } from "valtio"; -import { Label } from "@/components/ui/label"; // Import Label for better accessibility +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { CirclePlus, CircleMinus } from "lucide-react"; const IntegerControl = ({ control, + isDisabled = false, }: { control: components["schemas"]["ControlModel"]; + isDisabled?: boolean; }) => { - // Local state for both slider and input interaction + const { min_value, max_value, step } = control.flags; + const controlId = `control-${control.control_id}-${control.name}`; + const safeStep = step && step > 0 ? step : 1; + + const precision = + safeStep < 1 ? step.toString().split(".")[1]?.length || 0 : 0; + const [currentValue, setCurrentValue] = useState(control.value); - // State to hold the potentially invalid input string - const [inputValue, setInputValue] = useState(control.value.toString()); + const [inputValue, setInputValue] = useState( + control.value.toFixed(precision).toString() + ); - const { min_value, max_value, step } = control.flags; - const controlId = `control-${control.control_id}-${control.name}`; // Unique ID for label association + const containerRef = useRef(null); + const inputRef = useRef(null); + + const dragState = useRef<{ + startX: number; + startValue: number; + containerWidth: number; + } | null>(null); - // Update local state when the global control value changes useEffect(() => { const unsubscribe = subscribe(control, () => { - // Only update if the external value differs from the current committed value if (control.value !== currentValue) { setCurrentValue(control.value); - setInputValue(control.value.toString()); + setInputValue(control.value.toFixed(precision).toString()); } }); - return () => unsubscribe(); // Cleanup subscription - }, [control, currentValue]); // Re-subscribe if control or currentValue changes + return () => unsubscribe(); + }, [control, currentValue]); - // Clamp value within min/max bounds const clamp = (val: number): number => { return Math.min(max_value, Math.max(min_value, val)); }; - // Snap value to the nearest valid step - const snapToStep = (val: number): number => { - if (step <= 0) return val; // Avoid division by zero or infinite loops - return Math.round((val - min_value) / step) * step + min_value; - }; + // Logic to snap to the specific step defined in flags + const snapToStep = useCallback( + (val: number): number => { + if (!step || step <= 0) return val; + return Math.round((val - min_value) / safeStep) * safeStep + min_value; + }, + [min_value, step] + ); - // Validate and commit the value from input or slider end const commitValue = useCallback( (newValue: number) => { let validatedValue = clamp(newValue); + + // We enforce the strict 'step' here, at the end of the interaction validatedValue = snapToStep(validatedValue); - // Update local state immediately for responsiveness setCurrentValue(validatedValue); - setInputValue(validatedValue.toString()); + setInputValue(validatedValue.toFixed(precision).toString()); - // Update the global state only if the value actually changed if (control.value !== validatedValue) { - control.value = validatedValue; // This triggers the API call via subscription in CameraControls + control.value = validatedValue; } }, - [control, min_value, max_value, step, clamp, snapToStep] // Dependencies for useCallback + [control, min_value, max_value, clamp, snapToStep] ); - // Handle changes from the text input - const handleInputChange = (event: React.ChangeEvent) => { - const rawValue = event.target.value; - setInputValue(rawValue); // Update input display immediately + const handlePointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + + if (!containerRef.current) return; - // Attempt to parse and validate on change for quick feedback (optional) - // Or wait for blur/enter for final validation + (e.target as HTMLElement).setPointerCapture(e.pointerId); + + dragState.current = { + startX: e.clientX, + startValue: currentValue, + containerWidth: containerRef.current.offsetWidth, + }; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragState.current) return; + + const { startX, startValue, containerWidth } = dragState.current; + + const deltaX = e.clientX - startX; + + const range = max_value - min_value; + const valueDelta = (deltaX / containerWidth) * range; + + let newValue = clamp(startValue + valueDelta); + + setCurrentValue(newValue); + setInputValue(newValue.toFixed(precision).toString()); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!dragState.current) return; + + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + dragState.current = null; + + commitValue(currentValue); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); }; - // Handle blur event for the text input (commit value) const handleInputBlur = () => { - const parsedValue = parseInt(inputValue, 10); + const parsedValue = parseFloat(inputValue); if (!isNaN(parsedValue)) { commitValue(parsedValue); } else { - // Reset input to the last valid committed value if input is invalid setInputValue(currentValue.toString()); } }; - // Handle Enter key press in the text input (commit value) const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { - const parsedValue = parseInt(inputValue, 10); + const parsedValue = parseFloat(inputValue); if (!isNaN(parsedValue)) { commitValue(parsedValue); - // Optionally blur the input after commit event.currentTarget.blur(); } else { setInputValue(currentValue.toString()); } } else if (event.key === "Escape") { - // Reset input to current committed value on Escape setInputValue(currentValue.toString()); event.currentTarget.blur(); } }; - // Handle slider value changes (live update) - const handleSliderChange = (value: number[]) => { - const val = value[0]; - // Update visually immediately - setCurrentValue(val); - setInputValue(val.toString()); - }; + const handleInputStep = (step: string) => { + if (isDisabled || !inputRef.current) return; + + try { + if (step === "up") { + inputRef.current.stepUp(); + } else { + inputRef.current.stepDown(); + } + } catch (e) { + return; + } + + // stepUp and stepDown don't call on change like the arrow keys do, so we need to update react from the dom + const newValue = inputRef.current.value; + setInputValue(newValue); + setCurrentValue(parseFloat(newValue)); - // Handle slider commit (end of drag) - const handleSliderCommit = (value: number[]) => { - commitValue(value[0]); + inputRef.current.focus(); }; + // // Handle slider live updates + // const handleSliderChange = (value: number[]) => { + // // We allow the "raw" value (step 1) to flow through here for smooth UI + // const val = value[0]; + // setCurrentValue(val); + // setInputValue(val.toString()); + // }; + + // // Handle slider release (commit) + // const handleSliderCommit = (value: number[]) => { + // // When user lets go, we snap the raw value to the nearest valid step + // commitValue(value[0]); + // }; return ( -
-
- - {/* Display current committed value */} - {/* {currentValue} */} -
+
- - (e.target as HTMLInputElement).blur()} - /> +
+ + + {control.name} + + +
+
+
+ (e.target as HTMLInputElement).blur()} + disabled={isDisabled} + ref={inputRef} + /> +
+ + +
+
); diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index 2add1432..b5c2c321 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -105,7 +105,6 @@ const DeviceListLayout = () => { }); }; - const removeDevice = (bus_info: string) => { setDevices((prevDevices) => { const filteredDevices = prevDevices.filter( @@ -148,7 +147,7 @@ const DeviceListLayout = () => { console.log("GStreamer Error:", data.errors, data.bus_info); setDevices((currentDevices) => { const device = getDeviceByBusInfo(currentDevices, data.bus_info); - console.log(currentDevices.map(d => d.bus_info)); + console.log(currentDevices.map((d) => d.bus_info)); console.log("Device affected by error:", device); if (device) { device.stream.enabled = false; @@ -160,9 +159,9 @@ const DeviceListLayout = () => { description: `An error occurred with the device ${data.bus_info}. Please check the logs for more details.`, variant: "destructive", }); - } + }; - const getSavedPreferences = async () => { }; + const getSavedPreferences = async () => {}; if (connected) { socket?.on("gst_error", handleGstError); @@ -188,7 +187,7 @@ const DeviceListLayout = () => { }; return ( -
+
{ const [nickname, setNickname] = useState(deviceState.nickname); const [tempNickname, setTempNickname] = useState(deviceState.nickname); + const inputRef = useRef(null); + const startEditing = () => { setTempNickname(nickname); setIsEditing(true); + setTimeout(() => inputRef.current?.focus(), 0); }; const cancelEditing = () => { setTempNickname(nickname); setIsEditing(false); + inputRef.current?.blur(); }; const saveNickname = () => { const trimmedNickname = tempNickname.trim(); setNickname(trimmedNickname); setIsEditing(false); + inputRef.current?.blur(); API_CLIENT.POST("/devices/set_nickname", { body: { bus_info: device.bus_info, nickname: trimmedNickname }, @@ -50,67 +55,56 @@ export const CameraNickname = () => { return (
-
-

Camera Nickname

-
- {" "} - {/* Maintain a constant spacing to ensure the page does not move up */} - {!isEditing && ( - - )} -
-
- - {isEditing ? ( -
+
+
setTempNickname(e.target.value)} + onFocus={(e) => { + setIsEditing(true); + // sets typing cursor to the end of the nickname + const val = e.target.value; + e.target.setSelectionRange(val.length, val.length); + }} onKeyDown={handleKeyDown} placeholder="Enter a nickname" - className="h-9" - autoFocus + className={`h-9 bg-background ${isEditing && "border-accent"}`} /> -
- + {isEditing ? ( +
+ + +
+ ) : ( -
-
- ) : ( -
- {nickname ? ( -

{nickname}

- ) : ( -

- No nickname set (click edit to add one) -

)}
- )} +
); }; diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx index 3c8aecdf..226623c2 100644 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream.tsx @@ -58,7 +58,7 @@ const StreamSelector = ({ {label} - ) + ); } -) -Input.displayName = "Input" +); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/frontend/src/components/ui/radio-group.tsx b/frontend/src/components/ui/radio-group.tsx index e8e552ec..96593dd6 100644 --- a/frontend/src/components/ui/radio-group.tsx +++ b/frontend/src/components/ui/radio-group.tsx @@ -32,7 +32,7 @@ const RadioGroupItem = React.forwardRef< {...props} > - +
); diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index 2ca1df8d..3e68d5cb 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -1,14 +1,14 @@ -import * as React from "react" -import * as SelectPrimitive from "@radix-ui/react-select" -import { Check, ChevronDown, ChevronUp } from "lucide-react" +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Select = SelectPrimitive.Root +const Select = SelectPrimitive.Root; -const SelectGroup = SelectPrimitive.Group +const SelectGroup = SelectPrimitive.Group; -const SelectValue = SelectPrimitive.Value +const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< React.ElementRef, @@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-9 w-full items-center justify-between whitespace-nowrap hover:bg-accent rounded-md border border-muted bg-background/30 px-3 py-2 text-sm shadow-md ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...props} @@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef< -)) -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; const SelectScrollUpButton = React.forwardRef< React.ElementRef, @@ -44,8 +44,8 @@ const SelectScrollUpButton = React.forwardRef< > -)) -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; const SelectScrollDownButton = React.forwardRef< React.ElementRef, @@ -61,9 +61,9 @@ const SelectScrollDownButton = React.forwardRef< > -)) +)); SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName + SelectPrimitive.ScrollDownButton.displayName; const SelectContent = React.forwardRef< React.ElementRef, @@ -73,7 +73,7 @@ const SelectContent = React.forwardRef< -)) -SelectContent.displayName = SelectPrimitive.Content.displayName +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< React.ElementRef, @@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef< className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} /> -)) -SelectLabel.displayName = SelectPrimitive.Label.displayName +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< React.ElementRef, @@ -128,8 +128,8 @@ const SelectItem = React.forwardRef< {children} -)) -SelectItem.displayName = SelectPrimitive.Item.displayName +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< React.ElementRef, @@ -140,8 +140,8 @@ const SelectSeparator = React.forwardRef< className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} /> -)) -SelectSeparator.displayName = SelectPrimitive.Separator.displayName +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; export { Select, @@ -154,4 +154,4 @@ export { SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, -} +}; diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx index 6d7f1226..849bec82 100644 --- a/frontend/src/components/ui/separator.tsx +++ b/frontend/src/components/ui/separator.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Separator = React.forwardRef< React.ElementRef, @@ -16,14 +16,14 @@ const Separator = React.forwardRef< decorative={decorative} orientation={orientation} className={cn( - "shrink-0 bg-border", + "shrink-0 bg-accent", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className )} {...props} /> ) -) -Separator.displayName = SeparatorPrimitive.Root.displayName +); +Separator.displayName = SeparatorPrimitive.Root.displayName; -export { Separator } +export { Separator }; diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index eeb2d7ae..e3f24502 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -1,58 +1,58 @@ -"use client" - -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { VariantProps, cva } from "class-variance-authority" -import { PanelLeft } from "lucide-react" - -import { useIsMobile } from "@/hooks/use-mobile" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { Sheet, SheetContent } from "@/components/ui/sheet" -import { Skeleton } from "@/components/ui/skeleton" +"use client"; + +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { VariantProps, cva } from "class-variance-authority"; +import { PanelLeft } from "lucide-react"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from "@/components/ui/tooltip" +} from "@/components/ui/tooltip"; -const SIDEBAR_COOKIE_NAME = "sidebar:state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" -const SIDEBAR_WIDTH_ICON = "3rem" -const SIDEBAR_KEYBOARD_SHORTCUT = "b" +const SIDEBAR_COOKIE_NAME = "sidebar:state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; type SidebarContext = { - state: "expanded" | "collapsed" - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; -const SidebarContext = React.createContext(null) +const SidebarContext = React.createContext(null); function useSidebar() { - const context = React.useContext(SidebarContext) + const context = React.useContext(SidebarContext); if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider.") + throw new Error("useSidebar must be used within a SidebarProvider."); } - return context + return context; } const SidebarProvider = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; } >( ( @@ -67,34 +67,34 @@ const SidebarProvider = React.forwardRef< }, ref ) => { - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = React.useState(false) + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen) - const open = openProp ?? _open + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value + const openState = typeof value === "function" ? value(open) : value; if (setOpenProp) { - setOpenProp(openState) + setOpenProp(openState); } else { - _setOpen(openState) + _setOpen(openState); } // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open] - ) + ); // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) - : setOpen((open) => !open) - }, [isMobile, setOpen, setOpenMobile]) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { @@ -103,18 +103,18 @@ const SidebarProvider = React.forwardRef< event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { - event.preventDefault() - toggleSidebar() + event.preventDefault(); + toggleSidebar(); } - } + }; - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [toggleSidebar]) + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed" + const state = open ? "expanded" : "collapsed"; const contextValue = React.useMemo( () => ({ @@ -127,7 +127,7 @@ const SidebarProvider = React.forwardRef< toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ) + ); return ( @@ -151,17 +151,17 @@ const SidebarProvider = React.forwardRef<
- ) + ); } -) -SidebarProvider.displayName = "SidebarProvider" +); +SidebarProvider.displayName = "SidebarProvider"; const Sidebar = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; } >( ( @@ -175,7 +175,7 @@ const Sidebar = React.forwardRef< }, ref ) => { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); if (collapsible === "none") { return ( @@ -189,7 +189,7 @@ const Sidebar = React.forwardRef< > {children}
- ) + ); } if (isMobile) { @@ -209,7 +209,7 @@ const Sidebar = React.forwardRef<
{children}
- ) + ); } return ( @@ -224,7 +224,7 @@ const Sidebar = React.forwardRef< {/* This is what handles the sidebar gap on desktop */}
- ) + ); } -) -Sidebar.displayName = "Sidebar" +); +Sidebar.displayName = "Sidebar"; const SidebarTrigger = React.forwardRef< React.ElementRef, React.ComponentProps >(({ className, onClick, ...props }, ref) => { - const { toggleSidebar } = useSidebar() + const { toggleSidebar } = useSidebar(); return ( - ) -}) -SidebarTrigger.displayName = "SidebarTrigger" + ); +}); +SidebarTrigger.displayName = "SidebarTrigger"; const SidebarRail = React.forwardRef< HTMLButtonElement, React.ComponentProps<"button"> >(({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar() + const { toggleSidebar } = useSidebar(); return (