From 6d965d77b1ba6c060e0e555bfcc6428cc1cb8e66 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Fri, 8 May 2026 18:34:19 -0700 Subject: [PATCH 01/34] [WIP] Refactor --- backend_py/src/models/__init__.py | 83 ++++++ .../pydantic_schemas.py => models/cameras.py} | 0 backend_py/src/models/network.py | 40 +++ .../preferences.py} | 2 +- .../saved_cameras.py} | 2 +- backend_py/src/services/cameras/shd.py | 2 +- .../cameras/synchronized_camera/dwvo.py | 257 ++++++++++++++++++ backend_py/src/services/lights/__init__.py | 11 - backend_py/src/services/lights/fake_pwm.py | 24 -- backend_py/src/services/lights/light.py | 26 -- .../src/services/lights/light_manager.py | 58 ---- backend_py/src/services/lights/light_types.py | 0 .../src/services/lights/pwm_controller.py | 36 --- backend_py/src/services/lights/pwm_manager.py | 156 ----------- .../src/services/lights/rpi_pwm_hardware.py | 83 ------ backend_py/src/services/lights/utils.py | 71 ----- .../services/network/async_network_manager.py | 22 +- backend_py/src/services/network/nm_wrapper.py | 19 +- .../src/services/preferences/__init__.py | 3 +- .../preferences/preferences_manager.py | 2 +- 20 files changed, 388 insertions(+), 509 deletions(-) create mode 100644 backend_py/src/models/__init__.py rename backend_py/src/{services/cameras/pydantic_schemas.py => models/cameras.py} (100%) create mode 100644 backend_py/src/models/network.py rename backend_py/src/{services/preferences/pydantic_schemas.py => models/preferences.py} (87%) rename backend_py/src/{services/cameras/saved_pydantic_schemas.py => models/saved_cameras.py} (97%) create mode 100644 backend_py/src/services/cameras/synchronized_camera/dwvo.py delete mode 100644 backend_py/src/services/lights/__init__.py delete mode 100644 backend_py/src/services/lights/fake_pwm.py delete mode 100644 backend_py/src/services/lights/light.py delete mode 100644 backend_py/src/services/lights/light_manager.py delete mode 100644 backend_py/src/services/lights/light_types.py delete mode 100644 backend_py/src/services/lights/pwm_controller.py delete mode 100644 backend_py/src/services/lights/pwm_manager.py delete mode 100644 backend_py/src/services/lights/rpi_pwm_hardware.py delete mode 100644 backend_py/src/services/lights/utils.py diff --git a/backend_py/src/models/__init__.py b/backend_py/src/models/__init__.py new file mode 100644 index 00000000..d7de9ef9 --- /dev/null +++ b/backend_py/src/models/__init__.py @@ -0,0 +1,83 @@ +from .cameras import ( + AddFollowerPayload, + CameraModel, + ControlFlagsModel, + ControlModel, + ControlTypeEnum, + DeviceDescriptorModel, + DeviceInfoModel, + DeviceLeaderModel, + DeviceModel, + DeviceNicknameModel, + DeviceOptionsModel, + DeviceType, + FormatSizeModel, + FrameDropStats, + H264Mode, + IntervalModel, + MenuItemModel, + StreamEncodeTypeEnum, + StreamEndpointModel, + StreamFormatModel, + StreamInfoModel, + StreamModel, + StreamTypeEnum, + UVCControlModel, + V4LControlTypeEnum, +) +from .network import ( + ConnectionProfileModel, + IPV4Address, + IPV4Configuration, + IPV4Method, + WiredDeviceModel, +) +from .preferences import SavedPreferencesModel +from .saved_cameras import ( + SavedControlModel, + SavedDeviceModel, + SavedLeaderFollowerPairModel, + SavedStreamModel, +) + +__all__ = [ + # Network + "ConnectionProfileModel", + "IPV4Address", + "IPV4Configuration", + "IPV4Method", + "WiredDeviceModel", + # Cameras + "AddFollowerPayload", + "CameraModel", + "ControlFlagsModel", + "ControlModel", + "ControlTypeEnum", + "DeviceDescriptorModel", + "DeviceInfoModel", + "DeviceLeaderModel", + "DeviceModel", + "DeviceNicknameModel", + "DeviceOptionsModel", + "DeviceType", + "FormatSizeModel", + "FrameDropStats", + "H264Mode", + "IntervalModel", + "MenuItemModel", + "StreamEncodeTypeEnum", + "StreamEndpointModel", + "StreamFormatModel", + "StreamInfoModel", + "StreamModel", + "StreamTypeEnum", + "UVCControlModel", + "V4LControlTypeEnum", + # Preferences + "SavedPreferencesModel", + # Saved Cameras + "SavedControlModel", + "SavedDeviceModel", + "SavedLeaderFollowerPairModel", + "SavedStreamModel", +] diff --git a/backend_py/src/services/cameras/pydantic_schemas.py b/backend_py/src/models/cameras.py similarity index 100% rename from backend_py/src/services/cameras/pydantic_schemas.py rename to backend_py/src/models/cameras.py diff --git a/backend_py/src/models/network.py b/backend_py/src/models/network.py new file mode 100644 index 00000000..90033f8b --- /dev/null +++ b/backend_py/src/models/network.py @@ -0,0 +1,40 @@ +from enum import Enum + +from pydantic import BaseModel +from sdbus_async.networkmanager import ( + DeviceState, +) + + +class IPV4Method(Enum): + manual = "manual" + auto = "auto" + unknown = "unknown" + + +class IPV4Address(BaseModel): + address: str + prefix: int + + +class IPV4Configuration(BaseModel): + ip_addresses: list[IPV4Address] | None = None + gateway: str | None = None + method: IPV4Method = IPV4Method.unknown + dns: list[str] | None = None + never_default: bool | None = None + + +class WiredDeviceModel(BaseModel): + interface: str + state: DeviceState + is_active: bool + active_profile_id: str | None = None + active_ip_configuration: IPV4Configuration | None = None + available_profiles: list[str] + + +class ConnectionProfileModel(BaseModel): + id: str + path: str + ipv4_settings: IPV4Configuration diff --git a/backend_py/src/services/preferences/pydantic_schemas.py b/backend_py/src/models/preferences.py similarity index 87% rename from backend_py/src/services/preferences/pydantic_schemas.py rename to backend_py/src/models/preferences.py index 7a15198a..daae13ea 100644 --- a/backend_py/src/services/preferences/pydantic_schemas.py +++ b/backend_py/src/models/preferences.py @@ -7,7 +7,7 @@ from pydantic import BaseModel -from ..cameras.pydantic_schemas import StreamEndpointModel +from .cameras import StreamEndpointModel class SavedPreferencesModel(BaseModel): diff --git a/backend_py/src/services/cameras/saved_pydantic_schemas.py b/backend_py/src/models/saved_cameras.py similarity index 97% rename from backend_py/src/services/cameras/saved_pydantic_schemas.py rename to backend_py/src/models/saved_cameras.py index d24006f0..64a84991 100644 --- a/backend_py/src/services/cameras/saved_pydantic_schemas.py +++ b/backend_py/src/models/saved_cameras.py @@ -8,7 +8,7 @@ from pydantic import BaseModel -from .pydantic_schemas import ( +from .cameras import ( DeviceType, IntervalModel, StreamEncodeTypeEnum, diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 7d6175ec..8c4f7dd6 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -110,7 +110,7 @@ def __init__(self, device_info: DeviceInfo) -> None: mjpg_camera.formats["SOFTWARE_H264"] = mjpg_camera.formats["MJPG"] # List of followers - # Zero inherent truth to the existance of these devices + # Zero inherent truth to the existence of these devices self.followers: list[str] = [] # These exist diff --git a/backend_py/src/services/cameras/synchronized_camera/dwvo.py b/backend_py/src/services/cameras/synchronized_camera/dwvo.py new file mode 100644 index 00000000..b8c0f976 --- /dev/null +++ b/backend_py/src/services/cameras/synchronized_camera/dwvo.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import struct +from collections.abc import Iterable +from pathlib import Path +from typing import BinaryIO + +MAGIC_BYTES = b"DWE.ai" +MAGIC_LEN = 6 + +# DWVOHeader layout (packed, little-endian) +# struct DWVOVersion { uint8_t major, minor, nightly; } +# uint8_t nCameras; +# struct DWVOFormat { uint32_t width, height, pixelFormat; uint8_t fps; } +# uint32_t extLength; +HEADER_STRUCT = struct.Struct("<3B B I I I B I") # 21 bytes + + +class DWVOHeader: + __slots__ = ( + "version", + "n_cameras", + "width", + "height", + "pixel_format", + "fps", + "ext_length", + ) + + def __init__( + self, + version: tuple[int, int, int], + n_cameras: int, + width: int, + height: int, + pixel_format: int, + fps: int, + ext_length: int, + ): + self.version = version + self.n_cameras = n_cameras + self.width = width + self.height = height + self.pixel_format = pixel_format + self.fps = fps + self.ext_length = ext_length + + @classmethod + def read(cls, f: BinaryIO) -> DWVOHeader: + magic = f.read(MAGIC_LEN) + if len(magic) != MAGIC_LEN or magic != MAGIC_BYTES: + raise ValueError(f"Invalid DWVO magic: {magic!r}") + + data = f.read(HEADER_STRUCT.size) + if len(data) != HEADER_STRUCT.size: + raise ValueError("Truncated DWVO header") + + ( + vmaj, + vmin, + vnight, + n_cameras, + width, + height, + pixel_format, + fps, + ext_length, + ) = HEADER_STRUCT.unpack(data) + + return cls( + version=(vmaj, vmin, vnight), + n_cameras=n_cameras, + width=width, + height=height, + pixel_format=pixel_format, + fps=fps, + ext_length=ext_length, + ) + + def write(self, f: BinaryIO) -> None: + f.write(MAGIC_BYTES) + f.write( + HEADER_STRUCT.pack( + self.version[0], + self.version[1], + self.version[2], + self.n_cameras, + self.width, + self.height, + self.pixel_format, + self.fps, + self.ext_length, + ) + ) + + def is_compatible(self, other: DWVOHeader) -> bool: + return ( + self.n_cameras == other.n_cameras + and self.width == other.width + and self.height == other.height + and self.pixel_format == other.pixel_format + and self.fps == other.fps + and self.ext_length == other.ext_length + ) + + +class DWVOVideoFrame: + def __init__(self, bus_id: str, data: bytes) -> None: + self.bus_id = bus_id + self.data = data + self.length = len(data) + + +class DWVOTimestampBlock: + def __init__(self, timestamp: int, video_frames: list[DWVOVideoFrame]) -> None: + self.timestamp = timestamp # uint32 + self.video_frames = video_frames + + +class DWVOReader: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self.file: BinaryIO | None = None + self.header: DWVOHeader | None = None + self.ext_data: bytes | None = None + + def __enter__(self) -> DWVOReader: + f = self.path.open("rb") + self.file = f + self.header = DWVOHeader.read(f) + + if self.header.ext_length: + ext = f.read(self.header.ext_length) + if len(ext) != self.header.ext_length: + raise ValueError("Truncated DWVO extension data") + self.ext_data = ext + + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.file: + self.file.close() + + def read_body(self, chunk_size=1024 * 1024) -> Iterable[bytes]: + assert self.file is not None + while True: + chunk = self.file.read(chunk_size) + if not chunk: + break + yield chunk + + def _read_exact(self, n: int) -> bytes: + assert self.file is not None + data = self.file.read(n) + if len(data) != n: + raise EOFError("Unexpected EOF while reading DWVO") + return data + + def _read_c_string(self) -> str: + assert self.file is not None + buf = bytearray() + while True: + b = self.file.read(1) + if b == b"": + raise EOFError("EOF before null-terminated string ended") + if b == b"\x00": + break + buf.extend(b) + return buf.decode("utf-8", errors="replace") + + def iter_blocks(self) -> Iterable[DWVOTimestampBlock]: + """ + Iterate timestamp blocks until EOF. + Each block: + uint32 timestamp; + for cam in [0, n_cameras): + char busID[] (null-terminated); + uint32 length; + uint8 data[length]; + """ + assert self.file is not None + assert self.header is not None + + n_cams = self.header.n_cameras + + while True: + # Try to read timestamp (4 bytes). If EOF, we're done. + ts_bytes = self.file.read(4) + if ts_bytes == b"": + break # normal EOF + if len(ts_bytes) != 4: + raise EOFError("Truncated timestamp at end of DWVO file") + + (timestamp,) = struct.unpack(" None: + self.path = Path(path) + self.header = header + self.file: BinaryIO | None = None + + def __enter__(self) -> DWVOWriter: + self.file = self.path.open("wb") + self.header.write(self.file) + # No ext data support for now (ext_length must be set accordingly) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.file: + self.file.close() + + def _pack_data(self, data: bytes) -> None: + assert self.file is not None + self.file.write(data) + + def _pack_value(self, fmt: str, value) -> None: + self._pack_data(struct.pack(fmt, value)) + + def write_block(self, block: DWVOTimestampBlock) -> None: + """ + Write a single timestamp block, mirroring your DWVOWriter::WriteFrames + (except timestamp type: we accept a Python int and truncate to uint32). + """ + assert self.file is not None + + # timestamp: uint32 little-endian, truncated + self._pack_value(" None: - super().__init__() - - def is_pwm_pin(self, pin: int) -> bool: - return True - - def set_intensity(self, pin: int, intensity: float) -> None: - # logging.log(f'{}') - pass - - def cleanup(self) -> None: - pass - - def disable_pin(self, pin: int) -> None: - pass - - def get_pins(self) -> list[int]: - return [1, 2, 3, 4] diff --git a/backend_py/src/services/lights/light.py b/backend_py/src/services/lights/light.py deleted file mode 100644 index 288b3e50..00000000 --- a/backend_py/src/services/lights/light.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -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): - intensity: float - pin: int - nickname: str - controller_index: int - controller_name: str - - -class SetLightInfo(BaseModel): - index: int - intensity: float - - -class DisableLightInfo(BaseModel): - controller_index: int - pin: int diff --git a/backend_py/src/services/lights/light_manager.py b/backend_py/src/services/lights/light_manager.py deleted file mode 100644 index 157491f4..00000000 --- a/backend_py/src/services/lights/light_manager.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -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 -""" - -import logging - -from .light import Light -from .pwm_controller import PWMController - - -class LightManager: - def __init__(self, pwm_controllers: list[PWMController]) -> None: - self.pwm_controllers = pwm_controllers - self.lights: list[Light] = [] - self.logger = logging.getLogger("dwe_os_2.LightManager") - for controller_index in range(len(self.pwm_controllers)): - controller = self.pwm_controllers[controller_index] - for pin in controller.get_pins(): - self.lights.append( - Light( - intensity=0, - pin=pin, - controller_index=controller_index, - controller_name=controller.NAME, - nickname="", - ) - ) - - def set_intensity(self, index: int, intensity: float) -> None: - light = self.lights[index] - light.intensity = intensity - pwm_controller = self.pwm_controllers[light.controller_index] - self.logger.info( - f"Setting light ({pwm_controller.NAME}): {light.pin}, {light.intensity}" - ) - pwm_controller.set_intensity(light.pin, intensity) - - def disable_light(self, controller_index: int, pin: int) -> None: - if controller_index >= len(self.pwm_controllers): - self.logger.error("Invalid index given for pwm controller") - return - - pwm_controller = self.pwm_controllers[controller_index] - self.logger.info(f"Disabling light ({pwm_controller.NAME}): {pin}") - pwm_controller.disable_pin(pin) - - def get_lights(self) -> list[Light]: - return self.lights - - def cleanup(self) -> None: - for pwm_controller in self.pwm_controllers: - pwm_controller.cleanup() diff --git a/backend_py/src/services/lights/light_types.py b/backend_py/src/services/lights/light_types.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend_py/src/services/lights/pwm_controller.py b/backend_py/src/services/lights/pwm_controller.py deleted file mode 100644 index 3621607d..00000000 --- a/backend_py/src/services/lights/pwm_controller.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -pwm_controller.py - -Abstract class definition / interface all PWM drivers must follow -Maintains consistency with PWM functionality -""" - -import logging -from abc import ABC, abstractmethod - - -class PWMController(ABC): - NAME = "Abstract Controller" - - def __init__(self) -> None: - self.logger = logging.getLogger("dwe_os_2.PWMController") - - @abstractmethod - def set_intensity(self, pin: int, intensity: float) -> None: - self.logger.info(f"Setting light intensity: {pin} to {intensity}") - - @abstractmethod - def disable_pin(self, pin: int) -> None: - pass - - @abstractmethod - def is_pwm_pin(self, pin: int) -> bool: - pass - - @abstractmethod - def cleanup(self) -> None: - pass - - @abstractmethod - def get_pins(self) -> list[int]: - return [] diff --git a/backend_py/src/services/lights/pwm_manager.py b/backend_py/src/services/lights/pwm_manager.py deleted file mode 100644 index e61230bd..00000000 --- a/backend_py/src/services/lights/pwm_manager.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging -import os -import re -from dataclasses import dataclass - - -@dataclass -class PWMChannel: - channel: int - frequency: float = 0 - duty_cycle: float = 0 - - -@dataclass -class PWMChip: - chip: int - channels: list[PWMChannel] - - -class PWMManager: - PWM_BASE_PATH = "/sys/class/pwm" - CHIP_REGEX = re.compile(r"pwmchip(\d+)") - CHANNEL_REGEX = re.compile(r"pwm(\d+)") - - def __init__(self) -> None: - self.chips: list[PWMChip] = [] - - self._enumerate() - - self.logger = logging.getLogger("dwe_os_2.services.PWMManager") - - def enable_channel(self, chip_id: int, channel_id: int) -> None: - self._echo( - os.path.join(self._get_channel_path(chip_id, channel_id), "enable"), 1 - ) - - def disable_channel(self, chip_id: int, channel_id: int) -> None: - self._echo( - os.path.join(self._get_channel_path(chip_id, channel_id), "enable"), 0 - ) - - def set_channel_frequency( - self, chip_id: int, channel_id: int, frequency: float - ) -> None: - channel = self._get_channel(chip_id, channel_id) - - if not channel: - self.logger.error(f"Failed to get channel: {chip_id}:{channel_id}") - return - - channel.frequency = frequency - - # Save the current duty cycle and zero it - original_duty_cycle = channel.duty_cycle - if original_duty_cycle: - self._set_duty_cycle(chip_id, channel_id, 0, channel.frequency) - - # Update the frequency - self._set_channel_frequency(chip_id, channel_id, frequency) - - # Restore the original duty cycle - self._set_duty_cycle(chip_id, channel_id, original_duty_cycle, frequency) - - def set_channel_duty_cycle( - self, chip_id: int, channel_id: int, duty_cycle: float - ) -> None: - channel = self._get_channel(chip_id, channel_id) - - if not channel: - self.logger.error(f"Failed to get channel: {chip_id}:{channel_id}") - return - - self._set_duty_cycle(chip_id, channel_id, duty_cycle, channel.frequency) - channel.duty_cycle = duty_cycle - - def _set_channel_frequency( - self, chip_id: int, channel_id: int, frequency: float - ) -> None: - period_ns = int((1 / frequency) * 1_000_000_000) - period_path = os.path.join( - self._get_channel_path(chip_id, channel_id), "period" - ) - - self._echo(period_path, period_ns) - - def _set_duty_cycle( - self, chip_id: int, channel_id: int, duty_cycle: float, frequency: float - ) -> None: - # Compute duty cycle in nanoseconds - period_ns = int((1 / frequency) * 1_000_000_000) - duty_cycle_ns = int((duty_cycle / 100) * period_ns) - duty_cycle_path = os.path.join( - self._get_channel_path(chip_id, channel_id), "duty_cycle" - ) - - # Write the duty cycle value - self._echo(duty_cycle_path, duty_cycle_ns) - - def _get_chip_path(self, chip_id: int) -> str: - return os.path.join(self.PWM_BASE_PATH, f"pwmchip{chip_id}") - - def _get_channel_path(self, chip_id: int, channel_id: int) -> str: - return os.path.join(self._get_chip_path(chip_id), f"pwm{channel_id}") - - def _get_channel(self, chip_id: int, channel_id: int) -> PWMChannel | None: - chip = self._get_chip(chip_id) - - if not chip: - self.logger.error(f"Failed to get chip: {chip_id}:{channel_id}") - return None - - for channel in chip.channels: - if channel.channel == channel_id: - return channel - - return None - - def _get_chip(self, chip_id: int) -> PWMChip | None: - for chip in self.chips: - if chip.chip == chip_id: - return chip - return None - - def _echo(self, file: str, value: int) -> None: - with open(file, "w") as export_file: - export_file.write(str(value)) - - def _enumerate(self) -> None: - for chip_entry in os.listdir(self.PWM_BASE_PATH): - # Get the match of the chip - chip_match = self.CHIP_REGEX.match(chip_entry) - - if chip_match: - chip_number = int(chip_match.group(1)) - chip_path = os.path.join(self.PWM_BASE_PATH, chip_entry) - - npwm_path = os.path.join(chip_path, "npwm") - with open(npwm_path) as f: - npwm = int(f.read().strip()) - - channels: list[PWMChannel] = [] - - # Iterate over every channel - export_path = os.path.join(chip_path, "export") - for channel_number in range(npwm): - pwm_channel_path = os.path.join(chip_path, f"pwm{channel_number}") - if not os.path.exists(pwm_channel_path): - # create the export - try: - self._echo(export_path, channel_number) - except OSError: - continue - - channels.append(PWMChannel(channel_number)) - - self.chips.append(PWMChip(chip=chip_number, channels=channels)) diff --git a/backend_py/src/services/lights/rpi_pwm_hardware.py b/backend_py/src/services/lights/rpi_pwm_hardware.py deleted file mode 100644 index 8487dae2..00000000 --- a/backend_py/src/services/lights/rpi_pwm_hardware.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -rpi_pwm_hardware.py - -Talks to the Raspberry Pi's processor to set light intensity using PWM -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) - -This is from -""" - -from rpi_hardware_pwm import HardwarePWM, HardwarePWMException - -from .pwm_controller import PWMController - - -class RPiHardwarePWMController(PWMController): - NAME = "Hardware PWM Controller" - - # Tested working value for frequency - PWM_FREQUENCY = 7812.5 - - def __init__(self, chip=0, pins=None) -> None: - super().__init__() - - self.pwm_supported = True - - if pins is None: - pins = {18: 0, 19: 1} - self.PWM_PINS = pins - - self.pwm_objects: dict[int, HardwarePWM] = {} - - for pin in self.PWM_PINS: - try: - self.pwm_objects[pin] = HardwarePWM( - pwm_channel=self.PWM_PINS[pin], hz=self.PWM_FREQUENCY, chip=chip - ) - except HardwarePWMException: - self.logger.warning( - "Hardware PWM is not enabled. Please add 'dtoverlay=pwm-2chan' to " - "/boot/firmware/config.txt and reboot." - ) - self.pwm_supported = False - break - except OSError as e: - self.logger.warning(f"An OSError occurred with Hardware PWM: {e}") - self.pwm_supported = False - break - self.pwm_objects[pin].start(0) - - # Make sure the objects are not initialized - if not self.pwm_supported: - self.pwm_objects = {} - - def is_pwm_pin(self, pin: int) -> bool: - """ - Return true if the pin is supported but will always return false - if pwm is not entirely supported - """ - return pin in self.PWM_PINS if self.pwm_supported else False - - def disable_pin(self, pin: int) -> None: - # FIXME: Not implemented - # Planned to be implemented soon - pass - - def set_intensity(self, pin: int, intensity: float) -> None: - if not self.is_pwm_pin(pin): - self.logger.warning( - f"Attempted to use pin: {pin}, which is not supported by this device" - ) - return - - duty_cycle = max(0, min(100, intensity)) - self.pwm_objects[pin].change_duty_cycle(duty_cycle) - - def cleanup(self) -> None: - for pwm in self.pwm_objects.values(): - pwm.stop() - - def get_pins(self) -> list[int]: - return list(self.PWM_PINS.keys()) if self.pwm_supported else [] diff --git a/backend_py/src/services/lights/utils.py b/backend_py/src/services/lights/utils.py deleted file mode 100644 index b0e1513c..00000000 --- a/backend_py/src/services/lights/utils.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -utils.py - -Determines what kind of system/hardware the application is on, and then tries to -enable PWM controllers on them -""" - -import logging -import os -import re - -from .pwm_controller import PWMController -from .rpi_pwm_hardware import RPiHardwarePWMController - - -def is_overlay_loaded() -> bool: - """ - Based on function from rpi_hardware_pwm - """ - chippath = "/sys/class/pwm/pwmchip0" - return os.path.isdir(chippath) - - -def get_rpi_version() -> int | None: - try: - # Read the device model from the file - with open("/sys/firmware/devicetree/base/model") as f: - model = f.read().strip() - - # Check if the device is a Raspberry Pi - if "Raspberry Pi" in model: - # Extract the version number using regex - match = re.search(r"Raspberry Pi\s+(\d+)", model) - if match: - # Return the numeric model version - version = int(match.group(1)) - return version - else: - # Fallback if no version number is found - return None - else: - return None - - except FileNotFoundError: - # In case the file doesn't exist or is not accessible - return None - - -def create_pwm_controllers() -> list[PWMController]: - pwm_controllers: list[PWMController] = [] - version = get_rpi_version() - logger = logging.getLogger("dwe_os_2.pwm") - if version is not None: - logger.info(f"Device is Raspberry Pi {version}") - if not is_overlay_loaded(): - logger.warning( - "PWM Overlay not loaded. Need to add 'dtoverlay=pwm-2chan' to " - "/boot/config.txt and reboot" - ) - return [] - - if version == 5: - pwm_controllers.append( - RPiHardwarePWMController(chip=2, pins={12: 0, 13: 1, 18: 2, 19: 3}) - ) - else: - pwm_controllers.append(RPiHardwarePWMController()) - else: - # pwm_controllers.append(FakePWMController()) - logger.info("No supported PWM Controllers Found.") - return pwm_controllers diff --git a/backend_py/src/services/network/async_network_manager.py b/backend_py/src/services/network/async_network_manager.py index dd1cd7a4..98e2b76a 100644 --- a/backend_py/src/services/network/async_network_manager.py +++ b/backend_py/src/services/network/async_network_manager.py @@ -2,12 +2,10 @@ import logging import socket import struct -from enum import Enum from typing import Any import sdbus from event_emitter import EventEmitter -from pydantic import BaseModel from sdbus.utils.inspect import inspect_dbus_path from sdbus_async.networkmanager import ( ActiveConnection, @@ -26,25 +24,7 @@ DeviceCapabilities as Capabilities, ) - -class IPV4Method(Enum): - manual = "manual" - auto = "auto" - unknown = "unknown" - - -class IPV4Address(BaseModel): - address: str - prefix: int - - -class IPV4Configuration(BaseModel): - ip_addresses: list[IPV4Address] | None = None - gateway: str | None = None - method: IPV4Method = IPV4Method.unknown - dns: list[str] | None = None - never_default: bool | None = None - +from src.models import IPV4Address, IPV4Configuration, IPV4Method # ip to integer and reverse: https://stackoverflow.com/a/13294427 diff --git a/backend_py/src/services/network/nm_wrapper.py b/backend_py/src/services/network/nm_wrapper.py index 03e53a54..d05edf5d 100644 --- a/backend_py/src/services/network/nm_wrapper.py +++ b/backend_py/src/services/network/nm_wrapper.py @@ -4,31 +4,16 @@ import socketio from event_emitter import EventEmitter -from pydantic import BaseModel + +from src.models import ConnectionProfileModel, WiredDeviceModel from .async_network_manager import ( AsyncNetworkManager, - DeviceState, IPV4Configuration, IPV4Method, ) -class WiredDeviceModel(BaseModel): - interface: str - state: DeviceState - is_active: bool - active_profile_id: str | None = None - active_ip_configuration: IPV4Configuration | None = None - available_profiles: list[str] - - -class ConnectionProfileModel(BaseModel): - id: str - path: str - ipv4_settings: IPV4Configuration - - class NetworkWrapper(EventEmitter): def __init__(self, sio: socketio.AsyncServer) -> None: super().__init__() diff --git a/backend_py/src/services/preferences/__init__.py b/backend_py/src/services/preferences/__init__.py index 9433f665..1566fdef 100644 --- a/backend_py/src/services/preferences/__init__.py +++ b/backend_py/src/services/preferences/__init__.py @@ -1,4 +1,3 @@ from .preferences_manager import PreferencesManager -from .pydantic_schemas import SavedPreferencesModel -__all__ = ["PreferencesManager", "SavedPreferencesModel"] +__all__ = ["PreferencesManager"] diff --git a/backend_py/src/services/preferences/preferences_manager.py b/backend_py/src/services/preferences/preferences_manager.py index 4d90deef..8725a9cf 100644 --- a/backend_py/src/services/preferences/preferences_manager.py +++ b/backend_py/src/services/preferences/preferences_manager.py @@ -12,7 +12,7 @@ from event_emitter import events -from .pydantic_schemas import SavedPreferencesModel +from src.models import SavedPreferencesModel class PreferencesManager(events.EventEmitter): From f75ba7eec137f42705d08e1986f15c484cd645f1 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Mon, 11 May 2026 11:36:37 -0700 Subject: [PATCH 02/34] Move schemas to src/models --- backend_py/src/models/cameras.py | 2 +- backend_py/src/models/preferences.py | 2 +- backend_py/src/models/saved_cameras.py | 2 +- backend_py/src/routes/__init__.py | 2 - backend_py/src/routes/cameras.py | 9 ++-- backend_py/src/routes/lights.py | 41 ------------------- backend_py/src/routes/preferences.py | 4 +- backend_py/src/server.py | 8 ---- backend_py/src/services/__init__.py | 3 +- backend_py/src/services/cameras/device.py | 13 +++--- .../src/services/cameras/device_manager.py | 13 +++--- backend_py/src/services/cameras/ehd.py | 3 +- backend_py/src/services/cameras/settings.py | 4 +- backend_py/src/services/cameras/shd.py | 3 +- .../stream_engines/gstreamer_stream_engine.py | 3 +- .../services/cameras/stream_engines/stream.py | 2 +- .../synchronized_stream_engine.py | 3 +- .../src/services/cameras/stream_utils.py | 2 +- .../cameras/synchronized_camera/dwvo.py | 2 +- 19 files changed, 39 insertions(+), 82 deletions(-) delete mode 100644 backend_py/src/routes/lights.py diff --git a/backend_py/src/models/cameras.py b/backend_py/src/models/cameras.py index eeb7fb2a..af143a54 100644 --- a/backend_py/src/models/cameras.py +++ b/backend_py/src/models/cameras.py @@ -1,5 +1,5 @@ """ -pydantic_schemas.py +cameras.py Defines Pydantic models and Enums for camera and device configs Includes schemas for streams, controls, device info, and API request/response strutures diff --git a/backend_py/src/models/preferences.py b/backend_py/src/models/preferences.py index daae13ea..717fad44 100644 --- a/backend_py/src/models/preferences.py +++ b/backend_py/src/models/preferences.py @@ -1,5 +1,5 @@ """ -pydantic_schemas.py +preferences.py Defines Pydantic models for persistent server settings Includes schemas for saved preferences, like default stream endpoints diff --git a/backend_py/src/models/saved_cameras.py b/backend_py/src/models/saved_cameras.py index 64a84991..f0ccdb93 100644 --- a/backend_py/src/models/saved_cameras.py +++ b/backend_py/src/models/saved_cameras.py @@ -1,5 +1,5 @@ """ -saved_pydantic_schemas.py +saved_cameras.py Defines Pydantic models and Enums for persisting device settings and configs Includes schemas for serializing device states (streams, controls, nicknames) to JSON, diff --git a/backend_py/src/routes/__init__.py b/backend_py/src/routes/__init__.py index eac10d76..39cc36ea 100644 --- a/backend_py/src/routes/__init__.py +++ b/backend_py/src/routes/__init__.py @@ -1,5 +1,4 @@ from .cameras import camera_router -from .lights import lights_router from .logs import logs_router from .network import network_router from .preferences import preferences_router @@ -9,7 +8,6 @@ __all__ = [ "camera_router", - "lights_router", "logs_router", "preferences_router", "system_router", diff --git a/backend_py/src/routes/cameras.py b/backend_py/src/routes/cameras.py index 517e6ebf..d0d8f3e5 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -10,10 +10,7 @@ from fastapi import APIRouter, Request -from ..schemas import SimpleRequestStatusModel -from ..services.cameras import DeviceManager -from ..services.cameras.exceptions import DeviceNotFoundException -from ..services.cameras.pydantic_schemas import ( +from src.models import ( AddFollowerPayload, DeviceDescriptorModel, DeviceModel, @@ -22,6 +19,10 @@ StreamInfoModel, UVCControlModel, ) + +from ..schemas import SimpleRequestStatusModel +from ..services.cameras import DeviceManager +from ..services.cameras.exceptions import DeviceNotFoundException from ..services.cameras.shd import SHDDevice camera_router = APIRouter(tags=["cameras"]) diff --git a/backend_py/src/routes/lights.py b/backend_py/src/routes/lights.py deleted file mode 100644 index 335d8607..00000000 --- a/backend_py/src/routes/lights.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -lights.py - -API endpoints for light device management -Handles listing connected lights, setting intensity, and disabling lights -""" - -from fastapi import APIRouter, Request - -from ..schemas import SimpleRequestStatusModel -from ..services.lights import DisableLightInfo, Light, LightManager, SetLightInfo - -lights_router = APIRouter(tags=["lights"]) - - -@lights_router.get("") -def get_lights(request: Request) -> list[Light]: - light_manager: LightManager = request.app.state.light_manager - - return light_manager.get_lights() - - -@lights_router.post("/set_intensity") -def set_intensity( - request: Request, set_light_info: SetLightInfo -) -> SimpleRequestStatusModel: - light_manager: LightManager = request.app.state.light_manager - - light_manager.set_intensity(set_light_info.index, set_light_info.intensity) - return SimpleRequestStatusModel(success=True) - - -@lights_router.route("/disable_pin", methods=["POST"]) -def disable_light( - request: Request, disable_light_info: DisableLightInfo -) -> SimpleRequestStatusModel: - light_manager: LightManager = request.app.state.light_manager - light_manager.disable_light( - disable_light_info.controller_index, disable_light_info.pin - ) - return SimpleRequestStatusModel(success=True) diff --git a/backend_py/src/routes/preferences.py b/backend_py/src/routes/preferences.py index 1aab3d83..44e7a5ea 100644 --- a/backend_py/src/routes/preferences.py +++ b/backend_py/src/routes/preferences.py @@ -7,8 +7,10 @@ from fastapi import APIRouter, Request +from src.models import SavedPreferencesModel + from ..schemas import SimpleRequestStatusModel -from ..services.preferences import PreferencesManager, SavedPreferencesModel +from ..services.preferences import PreferencesManager preferences_router = APIRouter(tags=["preferences"]) diff --git a/backend_py/src/server.py b/backend_py/src/server.py index fc85f9be..a905fa76 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -16,7 +16,6 @@ from .logging import LogHandler from .routes import ( camera_router, - lights_router, logs_router, network_router, preferences_router, @@ -26,7 +25,6 @@ ) from .schemas import FeatureSupport from .services.cameras import DeviceManager, SerialPWMController, SettingsManager -from .services.lights import LightManager, create_pwm_controllers from .services.network import NetworkWrapper from .services.preferences import PreferencesManager from .services.recordings import RecordingsService @@ -92,9 +90,6 @@ def __init__( settings_manager=self.settings_manager, sio=self.sio, serial=self.serial ) - # Lights - self.light_manager = LightManager(create_pwm_controllers()) - self.server_logger = logging.getLogger("dwe_os_2.Server") self.network_wrapper = NetworkWrapper(sio) @@ -117,7 +112,6 @@ def __init__( # FAST API self.app.state.device_manager = self.device_manager self.app.state.log_handler = self.log_handler - self.app.state.light_manager = self.light_manager self.app.state.settings_manager = self.settings_manager self.app.state.preferences_manager = self.preferences_manager self.app.state.system_manager = self.system_manager @@ -131,7 +125,6 @@ def __init__( self.app.include_router(camera_router, prefix="/api/devices") self.app.include_router(preferences_router, prefix="/api/preferences") self.app.include_router(system_router, prefix="/api/system") - self.app.include_router(lights_router, prefix="/api/lights") self.app.include_router(logs_router, prefix="/api/logs") self.app.include_router(recordings_router, prefix="/api/recordings") self.app.include_router(network_router, prefix="/api/network") @@ -183,7 +176,6 @@ async def serve(self) -> None: def shutdown(self) -> None: self.server_logger.info("Shutting down") - self.light_manager.cleanup() self.device_manager.stop_monitoring() self.settings_manager.cleanup() diff --git a/backend_py/src/services/__init__.py b/backend_py/src/services/__init__.py index cd3b7ec5..e070c9fd 100644 --- a/backend_py/src/services/__init__.py +++ b/backend_py/src/services/__init__.py @@ -1,8 +1,7 @@ -from . import cameras, lights, network, preferences, recordings, system, ttyd +from . import cameras, network, preferences, recordings, system, ttyd __all__ = [ "cameras", - "lights", "preferences", "network", "system", diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 970a6999..29ed66ec 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -19,11 +19,7 @@ from linuxpy.video import device from pydantic.v1 import NoneBytes -from . import v4l2 -from . import xu_controls as xu -from .camera_helper.camera_helper_loader import camera_helper -from .enumeration import DeviceInfo -from .pydantic_schemas import ( +from src.models import ( ControlFlagsModel, ControlModel, ControlTypeEnum, @@ -32,12 +28,17 @@ FrameDropStats, IntervalModel, MenuItemModel, + SavedDeviceModel, StreamEncodeTypeEnum, StreamEndpointModel, StreamTypeEnum, V4LControlTypeEnum, ) -from .saved_pydantic_schemas import SavedDeviceModel + +from . import v4l2 +from . import xu_controls as xu +from .camera_helper.camera_helper_loader import camera_helper +from .enumeration import DeviceInfo from .stream_runner import Stream, StreamRunner from .stream_utils import fourcc2s, string_to_stream_encode_type diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index ecb7044e..6f469d1e 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -17,18 +17,19 @@ import event_emitter as events import socketio +from src.models import ( + DeviceModel, + StreamEncodeTypeEnum, + StreamInfoModel, + StreamTypeEnum, +) + from .device import Device, DeviceInfo, DeviceType, lookup_pid_vid from .device_utils import find_device_with_bus_info, list_diff from .ehd import EHDDevice from .enumeration import list_devices from .exceptions import DeviceNotFoundException from .pwm.serial_pwm_controller import SerialPWMController -from .pydantic_schemas import ( - DeviceModel, - StreamEncodeTypeEnum, - StreamInfoModel, - StreamTypeEnum, -) from .settings import SettingsManager from .shd import SHDDevice diff --git a/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py index fcb46c95..2fb061b3 100644 --- a/backend_py/src/services/cameras/ehd.py +++ b/backend_py/src/services/cameras/ehd.py @@ -9,10 +9,11 @@ from typing import cast +from src.models import H264Mode + from . import xu_controls as xu from .device import BaseOption, ControlTypeEnum, Device, Option from .enumeration import DeviceInfo -from .pydantic_schemas import H264Mode class EHDDevice(Device): diff --git a/backend_py/src/services/cameras/settings.py b/backend_py/src/services/cameras/settings.py index 805939c5..12a38ab5 100644 --- a/backend_py/src/services/cameras/settings.py +++ b/backend_py/src/services/cameras/settings.py @@ -12,10 +12,10 @@ import time from typing import cast +from src.models import DeviceType, SavedDeviceModel, SavedLeaderFollowerPairModel + from .device import Device from .device_utils import find_device_with_bus_info -from .pydantic_schemas import DeviceType -from .saved_pydantic_schemas import SavedDeviceModel, SavedLeaderFollowerPairModel from .shd import SHDDevice diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 8c4f7dd6..74b25199 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -16,10 +16,11 @@ from event_emitter import EventEmitter +from src.models import SavedDeviceModel + from . import xu_controls as xu from .device import BaseOption, ControlTypeEnum, Device, StreamEncodeTypeEnum from .enumeration import DeviceInfo -from .saved_pydantic_schemas import SavedDeviceModel def get_val(addr: Enum | int) -> int: diff --git a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py index f75c42c2..607ba58d 100644 --- a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py @@ -5,7 +5,8 @@ import threading from datetime import datetime -from ..pydantic_schemas import StreamEncodeTypeEnum, StreamTypeEnum +from src.models import StreamEncodeTypeEnum, StreamTypeEnum + from .base_stream_engine import BaseStreamEngine from .stream import Stream diff --git a/backend_py/src/services/cameras/stream_engines/stream.py b/backend_py/src/services/cameras/stream_engines/stream.py index b4047798..50ea13c1 100644 --- a/backend_py/src/services/cameras/stream_engines/stream.py +++ b/backend_py/src/services/cameras/stream_engines/stream.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field -from ..pydantic_schemas import ( +from src.models import ( IntervalModel, StreamEncodeTypeEnum, StreamEndpointModel, 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 84550e22..0ce16d2c 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 @@ -4,7 +4,8 @@ import threading import time -from ..pydantic_schemas import StreamEndpointModel +from src.models import StreamEndpointModel + from ..synchronized_camera import CopiedFrame, SynchronizedCamera, V4L2Camera from .base_stream_engine import BaseStreamEngine diff --git a/backend_py/src/services/cameras/stream_utils.py b/backend_py/src/services/cameras/stream_utils.py index dd52346a..f79e4b58 100644 --- a/backend_py/src/services/cameras/stream_utils.py +++ b/backend_py/src/services/cameras/stream_utils.py @@ -1,4 +1,4 @@ -from .pydantic_schemas import StreamEncodeTypeEnum +from src.models import StreamEncodeTypeEnum def fourcc2s(fourcc: int) -> str: diff --git a/backend_py/src/services/cameras/synchronized_camera/dwvo.py b/backend_py/src/services/cameras/synchronized_camera/dwvo.py index b8c0f976..a2591c8a 100644 --- a/backend_py/src/services/cameras/synchronized_camera/dwvo.py +++ b/backend_py/src/services/cameras/synchronized_camera/dwvo.py @@ -37,7 +37,7 @@ def __init__( pixel_format: int, fps: int, ext_length: int, - ): + ) -> None: self.version = version self.n_cameras = n_cameras self.width = width From 9c06bf833c9d362bc633cec893382218f8589417 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Mon, 11 May 2026 12:38:52 -0700 Subject: [PATCH 03/34] Switch settings and preferences managers to use threading.Lock --- backend_py/pyproject.toml | 3 + backend_py/src/services/cameras/__init__.py | 2 +- .../src/services/cameras/device_manager.py | 6 +- .../src/services/cameras/enumeration.py | 2 +- backend_py/src/services/cameras/settings.py | 195 ------------------ backend_py/src/services/network/nm_wrapper.py | 2 +- .../src/services/preferences/__init__.py | 3 +- .../preferences/preferences_manager.py | 14 +- .../services/preferences/settings_manager.py | 187 +++++++++++++++++ 9 files changed, 208 insertions(+), 206 deletions(-) delete mode 100644 backend_py/src/services/cameras/settings.py create mode 100644 backend_py/src/services/preferences/settings_manager.py diff --git a/backend_py/pyproject.toml b/backend_py/pyproject.toml index 8ae18b7a..6003dbe7 100644 --- a/backend_py/pyproject.toml +++ b/backend_py/pyproject.toml @@ -3,6 +3,9 @@ line-length = 88 indent-width = 4 exclude = ["v4l2.py"] # exclude v4l2.py, since it's included as a lib directly +[pycodestyle] +max_line_length = 88 + [tool.ruff.lint] select = [ # pycodestyle diff --git a/backend_py/src/services/cameras/__init__.py b/backend_py/src/services/cameras/__init__.py index eae9b8df..19d2f3f3 100644 --- a/backend_py/src/services/cameras/__init__.py +++ b/backend_py/src/services/cameras/__init__.py @@ -1,7 +1,7 @@ +from ..preferences.settings_manager import SettingsManager from .device_manager import DeviceManager from .device_utils import find_device_with_bus_info, list_diff from .pwm import SerialPWMController -from .settings import SettingsManager __all__ = [ "DeviceManager", diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index 6f469d1e..0c2bb00e 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -24,13 +24,13 @@ StreamTypeEnum, ) +from ..preferences import SettingsManager from .device import Device, DeviceInfo, DeviceType, lookup_pid_vid from .device_utils import find_device_with_bus_info, list_diff from .ehd import EHDDevice from .enumeration import list_devices from .exceptions import DeviceNotFoundException from .pwm.serial_pwm_controller import SerialPWMController -from .settings import SettingsManager from .shd import SHDDevice @@ -70,6 +70,8 @@ def __init__( settings_manager: SettingsManager, serial: SerialPWMController, ) -> None: + super().__init__() + self.devices: list[Device] = [] self.sio = sio self.settings_manager = settings_manager @@ -133,7 +135,7 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: 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)) + device.on("pwm_frequency", self.serial.apply_from_fps) return device diff --git a/backend_py/src/services/cameras/enumeration.py b/backend_py/src/services/cameras/enumeration.py index 32983877..ffd97e79 100644 --- a/backend_py/src/services/cameras/enumeration.py +++ b/backend_py/src/services/cameras/enumeration.py @@ -25,7 +25,7 @@ class DeviceInfo: def _get_device_attr(device_path, attr) -> str: - with open(device_path + "/" + attr) as file_object: + with open(device_path + "/" + attr, encoding="utf-8") as file_object: return file_object.read().strip() diff --git a/backend_py/src/services/cameras/settings.py b/backend_py/src/services/cameras/settings.py deleted file mode 100644 index 12a38ab5..00000000 --- a/backend_py/src/services/cameras/settings.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -settings.py - -Manages persisting camera settings and configs -Handles loading and saving device configs to JSON, keeping setting across reboots, -and manages background sync of settings -""" - -import json -import logging -import threading -import time -from typing import cast - -from src.models import DeviceType, SavedDeviceModel, SavedLeaderFollowerPairModel - -from .device import Device -from .device_utils import find_device_with_bus_info -from .shd import SHDDevice - - -class SettingsManager: - def __init__(self, settings_path: str = ".") -> None: - path = f"{settings_path}/device_settings.json" - try: - self.file_object = open(path, "r+") # noqa: SIM115 - except FileNotFoundError: - open(path, "w").close() - self.file_object = open(path, "r+") # noqa: SIM115 - self.to_save: list[SavedDeviceModel] = [] - self.is_running = True - - # TODO: Switch to asyncio or lock - self.thread = threading.Thread(target=self._run_settings_sync) - self.thread.start() - - self.leader_follower_pairs: list[SavedLeaderFollowerPairModel] = [] - - self.logger = logging.getLogger("dwe_os_2.SettingsManager") - - try: - settings: list[dict] = json.loads(self.file_object.read()) - self.settings: list[SavedDeviceModel] = [ - SavedDeviceModel.model_validate(saved_device) - for saved_device in settings - ] - - self.saved_by_bus_info: dict[str, SavedDeviceModel] = { - dev.bus_info: dev for dev in self.settings - } - except json.JSONDecodeError: - self.file_object.seek(0) - self.file_object.write("[]") - self.file_object.truncate() - self.saved_by_bus_info = {} - self.settings = [] - self.file_object.flush() - - def cleanup(self) -> None: - if self.file_object: - self.file_object.close() - - self.is_running = False - self.thread.join(timeout=1) - - def load_device(self, device: Device, devices: list[Device]) -> None: - for saved_device in self.settings: - if saved_device.bus_info == device.bus_info: - if device.device_type != saved_device.device_type: - self.logger.info( - f"Device {device.bus_info} with device_type: " - f"{str(device.device_type)} plugged into port of saved " - f"device_type: {str(saved_device.device_type)}. " - "Discarding stored data." - ) - self.settings.remove(saved_device) - return - - device.load_settings(saved_device) - - # We plugged in a new leader - if isinstance(device, SHDDevice) and saved_device.followers: - for follower_bus_info in saved_device.followers: - follower = find_device_with_bus_info(devices, follower_bus_info) - if not follower: - self.logger.warning( - f"Follower device with bus_info {follower_bus_info} " - "not currently connected" - ) - continue - - if follower.device_type != DeviceType.STELLARHD_FOLLOWER: - self.logger.warning( - f"Follower device {follower.bus_info} is not of " - "follower type, skipping" - ) - saved_device.followers.remove(follower_bus_info) - continue - - follower = cast(SHDDevice, follower) - if follower.is_managed: - self.logger.info("Saved follower already has a new leader") - # This is true when the follower has now gotten a new leader - saved_device.followers.remove(follower_bus_info) - continue - device.add_follower(follower) - - # We plugged in a new follower - if device.device_type == DeviceType.STELLARHD_FOLLOWER: - for potential_leader in devices: - # Skip if the potential leader is not an SHDDevice (cannot lead) - if not isinstance(potential_leader, SHDDevice): - continue - - # Don't try to follow yourself - # Though this should also be checked elsewhere, why not :shrug: - if potential_leader.bus_info == device.bus_info: - continue - - saved_leader = self.saved_by_bus_info.get( - potential_leader.bus_info - ) - if not saved_leader or not saved_leader.followers: - continue - - if device.bus_info in saved_leader.followers: - follower = cast(SHDDevice, device) - potential_leader.add_follower(follower) - break # Only follow one leader - - return - - def link_followers(self, devices: list[Device]) -> None: - """ - Run this when we need to check for new devices - """ - for leader in devices: - # Changed: We now allow followers to be leaders (of other followers) - if not isinstance(leader, SHDDevice): - continue - - saved = self.saved_by_bus_info.get(leader.bus_info) - - # This device has not been saved - if not saved or not saved.followers: - continue - - for follower_bus_info in saved.followers: - if follower_bus_info in leader.followers: - # Already loaded - continue - - follower = find_device_with_bus_info(devices, follower_bus_info) - - # If this follower does not exist, that is ok - # There is no inherent truth to the existance of the followers list - if not follower: - continue - - # What is worse than it not existing, however, is it not being a - # follower. So, we delete - if follower.device_type != DeviceType.STELLARHD_FOLLOWER: - self.logger.warning( - f"Follower device {follower.bus_info} is not of follower type, " - "skipping" - ) - saved.followers.remove(follower_bus_info) - continue - - follower = cast(SHDDevice, follower) - leader.add_follower(follower) - - def _save_device(self, saved_device: SavedDeviceModel) -> None: - for dev in self.settings: - if dev.bus_info == saved_device.bus_info: - self.settings.remove(dev) - break - self.settings.append(saved_device) - self.file_object.seek(0) - self.file_object.write( - json.dumps([model.model_dump() for model in self.settings]) - ) - self.file_object.truncate() - self.file_object.flush() - - def _run_settings_sync(self) -> None: - while self.is_running: - for saved_device in self.to_save: - self._save_device(saved_device) - self.to_save = [] - time.sleep(1) - - def save_device(self, device: Device) -> None: - # schedule a save command - self.to_save.append(SavedDeviceModel.model_validate(device)) diff --git a/backend_py/src/services/network/nm_wrapper.py b/backend_py/src/services/network/nm_wrapper.py index d05edf5d..ac9e9863 100644 --- a/backend_py/src/services/network/nm_wrapper.py +++ b/backend_py/src/services/network/nm_wrapper.py @@ -25,7 +25,7 @@ def __init__(self, sio: socketio.AsyncServer) -> None: self.last_connection_time = time.time() - @self.sio.on("connect") + @self.sio.on("connect") # type: ignore def on_connect(sid, environ) -> None: self.logger.info(f"Connection detected: {sid}") self.last_connection_time = time.time() diff --git a/backend_py/src/services/preferences/__init__.py b/backend_py/src/services/preferences/__init__.py index 1566fdef..f26e2ce2 100644 --- a/backend_py/src/services/preferences/__init__.py +++ b/backend_py/src/services/preferences/__init__.py @@ -1,3 +1,4 @@ from .preferences_manager import PreferencesManager +from .settings_manager import SettingsManager -__all__ = ["PreferencesManager"] +__all__ = ["PreferencesManager", "SettingsManager"] diff --git a/backend_py/src/services/preferences/preferences_manager.py b/backend_py/src/services/preferences/preferences_manager.py index 8725a9cf..4f2226dd 100644 --- a/backend_py/src/services/preferences/preferences_manager.py +++ b/backend_py/src/services/preferences/preferences_manager.py @@ -9,6 +9,7 @@ import json import pathlib +import threading from event_emitter import events @@ -23,13 +24,14 @@ def __init__(self, settings_path: str = ".") -> None: self.settings = SavedPreferencesModel() self._load_settings() + self._lock = threading.Lock() + def save(self, preferences: SavedPreferencesModel) -> None: - self.settings = preferences - self.emit("preferences_updated", preferences) + with self._lock: + self.settings = preferences self._save_settings() def get_preferences(self) -> SavedPreferencesModel: - # FIXME: why return self.settings def serialize_preferences(self) -> SavedPreferencesModel: @@ -46,6 +48,8 @@ def _load_settings(self) -> None: self.settings = SavedPreferencesModel() def _save_settings(self) -> None: - # CHECK: is this thread safe? - with self.path.open("w", encoding="utf-8") as f: + # I changed it to use the with statement and open the file every time we save, + # but it might be more performant to keep the file open. This is solves the ruff + # check that has issues with not using with + with self._lock and self.path.open("w", encoding="utf-8") as f: f.write(self.settings.model_dump_json(indent=4)) diff --git a/backend_py/src/services/preferences/settings_manager.py b/backend_py/src/services/preferences/settings_manager.py new file mode 100644 index 00000000..4886ad96 --- /dev/null +++ b/backend_py/src/services/preferences/settings_manager.py @@ -0,0 +1,187 @@ +""" +settings.py + +Manages persisting camera settings and configs +Handles loading and saving device configs to JSON, keeping setting across reboots, +and manages background sync of settings +""" + +import json +import logging +import threading +from typing import cast + +from src.models import DeviceType, SavedDeviceModel, SavedLeaderFollowerPairModel + +from ..cameras.device import Device +from ..cameras.device_utils import find_device_with_bus_info +from ..cameras.shd import SHDDevice + + +class SettingsManager: + def __init__(self, settings_path: str = ".") -> None: + path = f"{settings_path}/device_settings.json" + try: + self.file_object = open(path, "r+") # noqa: SIM115 + except FileNotFoundError: + open(path, "w").close() + self.file_object = open(path, "r+") # noqa: SIM115 + + self._lock = threading.Lock() + + self.leader_follower_pairs: list[SavedLeaderFollowerPairModel] = [] + + self.logger = logging.getLogger("dwe_os_2.SettingsManager") + + try: + settings: list[dict] = json.loads(self.file_object.read()) + self.settings: list[SavedDeviceModel] = [ + SavedDeviceModel.model_validate(saved_device) + for saved_device in settings + ] + + self.saved_by_bus_info: dict[str, SavedDeviceModel] = { + dev.bus_info: dev for dev in self.settings + } + except json.JSONDecodeError: + self.file_object.seek(0) + self.file_object.write("[]") + self.file_object.truncate() + self.saved_by_bus_info = {} + self.settings = [] + self.file_object.flush() + + def cleanup(self) -> None: + if self.file_object: + self.file_object.close() + + def _load_device( + self, + device: Device, + saved_device: SavedDeviceModel, + devices: list[Device]) -> None: + if device.device_type != saved_device.device_type: + self.logger.info( + f"Device {device.bus_info} with device_type: " + f"{str(device.device_type)} plugged into port of saved " + f"device_type: {str(saved_device.device_type)}. " + "Discarding stored data." + ) + self.settings.remove(saved_device) + return + + device.load_settings(saved_device) + + # We plugged in a new leader + if isinstance(device, SHDDevice) and saved_device.followers: + for follower_bus_info in saved_device.followers: + follower = find_device_with_bus_info(devices, follower_bus_info) + if not follower: + self.logger.warning( + f"Follower device with bus_info {follower_bus_info} " + "not currently connected" + ) + continue + + if follower.device_type != DeviceType.STELLARHD_FOLLOWER: + self.logger.warning( + f"Follower device {follower.bus_info} is not of " + "follower type, skipping" + ) + saved_device.followers.remove(follower_bus_info) + continue + + follower = cast(SHDDevice, follower) + if follower.is_managed: + self.logger.info("Saved follower already has a new leader") + # This is true when the follower has now gotten a new leader + saved_device.followers.remove(follower_bus_info) + continue + device.add_follower(follower) + + # We plugged in a new follower + if device.device_type == DeviceType.STELLARHD_FOLLOWER: + for potential_leader in devices: + # Skip if the potential leader is not an SHDDevice (cannot lead) + if not isinstance(potential_leader, SHDDevice): + continue + + # Don't try to follow yourself + # Though this should also be checked elsewhere, why not :shrug: + if potential_leader.bus_info == device.bus_info: + continue + + saved_leader = self.saved_by_bus_info.get( + potential_leader.bus_info + ) + if not saved_leader or not saved_leader.followers: + continue + + if device.bus_info in saved_leader.followers: + follower = cast(SHDDevice, device) + potential_leader.add_follower(follower) + break # Only follow one leader + + + def load_device(self, device: Device, devices: list[Device]) -> None: + with self._lock: + for saved_device in self.settings: + if saved_device.bus_info == device.bus_info: + self._load_device(device, saved_device, devices) + return + + def link_followers(self, devices: list[Device]) -> None: + """ + Run this when we need to check for new devices + """ + for leader in devices: + # Changed: We now allow followers to be leaders (of other followers) + if not isinstance(leader, SHDDevice): + continue + + saved = self.saved_by_bus_info.get(leader.bus_info) + + # This device has not been saved + if not saved or not saved.followers: + continue + + for follower_bus_info in saved.followers: + if follower_bus_info in leader.followers: + # Already loaded + continue + + follower = find_device_with_bus_info(devices, follower_bus_info) + + # If this follower does not exist, that is ok + # There is no inherent truth to the existance of the followers list + if not follower: + continue + + # What is worse than it not existing, however, is it not being a + # follower. So, we delete + if follower.device_type != DeviceType.STELLARHD_FOLLOWER: + self.logger.warning( + f"Follower device {follower.bus_info} is not of follower type, " + "skipping" + ) + saved.followers.remove(follower_bus_info) + continue + + follower = cast(SHDDevice, follower) + leader.add_follower(follower) + + def save_device(self, device: Device) -> None: + saved_device = SavedDeviceModel.model_validate(device) + + with self._lock: + for dev in self.settings: + if dev.bus_info == saved_device.bus_info: + self.settings.remove(dev) + break + self.settings.append(saved_device) + self.file_object.seek(0) + self.file_object.write( + json.dumps([model.model_dump() for model in self.settings]) + ) + self.file_object.truncate() + self.file_object.flush() From 84233b7cca5fd53ba5b74f4bc757ebc60a80abb6 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 15:11:38 -0700 Subject: [PATCH 04/34] Suppress KeyboardInterrupt for cleaner shutdown --- backend_py/run.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend_py/run.py b/backend_py/run.py index 21cf50cf..b0463cb5 100644 --- a/backend_py/run.py +++ b/backend_py/run.py @@ -4,16 +4,15 @@ # two is hosted as a uvicorn server, which handles traffic import asyncio +import contextlib import logging from contextlib import asynccontextmanager import socketio +from backend_py.src import FeatureSupport, Server from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from src import FeatureSupport, Server - -# TODO: narrow ORIGINS = ["*"] # Use AsyncServer @@ -71,4 +70,5 @@ async def main() -> None: server = uvicorn.Server(config) await server.serve() - asyncio.run(main()) + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(main()) From 56741fb72b85937ba59d455bdab383d78ab4dffc Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 15:11:49 -0700 Subject: [PATCH 05/34] Update pylintrc --- backend_py/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend_py/pyproject.toml b/backend_py/pyproject.toml index 6003dbe7..74ca0a11 100644 --- a/backend_py/pyproject.toml +++ b/backend_py/pyproject.toml @@ -3,8 +3,8 @@ line-length = 88 indent-width = 4 exclude = ["v4l2.py"] # exclude v4l2.py, since it's included as a lib directly -[pycodestyle] -max_line_length = 88 +[tool.pylint."messages control"] +disable = ["R0903"] [tool.ruff.lint] select = [ From 9190ee18666e78762f2aab9fa3cc2d4995002658 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 15:12:33 -0700 Subject: [PATCH 06/34] Add colored logging --- backend_py/src/server.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/backend_py/src/server.py b/backend_py/src/server.py index a905fa76..301c49af 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -11,6 +11,7 @@ import logging.handlers import socketio +from colorlog import ColoredFormatter from fastapi import FastAPI from .logging import LogHandler @@ -24,9 +25,9 @@ system_router, ) from .schemas import FeatureSupport -from .services.cameras import DeviceManager, SerialPWMController, SettingsManager +from .services.cameras import DeviceManager, SerialPWMController from .services.network import NetworkWrapper -from .services.preferences import PreferencesManager +from .services.preferences import PreferencesManager, SettingsManager from .services.recordings import RecordingsService from .services.system import SystemManager from .services.ttyd import TTYDManager @@ -60,9 +61,25 @@ def __init__( self.stream_handler = logging.StreamHandler() self.root_logger.addHandler(self.stream_handler) self.log_handler = LogHandler(self.sio) - self.log_formatter = logging.Formatter( - "%(asctime)s - %(levelname)s - [%(name)s] - %(filename)s:%(lineno)d - " - "%(funcName)s() - %(message)s" + self.log_formatter = ColoredFormatter( + ( + "%(log_color)s%(levelname)-8s%(reset)s " # Level (8 chars wide) + "%(blue)s%(asctime)s%(reset)s " + "[%(name)s] " + "%(thin_white)s%(filename)s:%(lineno)d%(reset)s " + "%(white)s%(message)s%(reset)s" + ), + datefmt="%H:%M:%S", + reset=True, + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, + secondary_log_colors={}, + style="%", ) self.stream_handler.setFormatter(self.log_formatter) self.file_handler = logging.handlers.RotatingFileHandler( From 5ca4fd405abad82fc6e80203f1f9924e40b9f12a Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:42:20 -0700 Subject: [PATCH 07/34] Remove requirement to chmod entire repo to create release --- create_release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/create_release.sh b/create_release.sh index 6b972fdb..49e4ca98 100755 --- a/create_release.sh +++ b/create_release.sh @@ -9,7 +9,7 @@ cd backend_py echo "Packaging backend" -./clean.sh +sudo ./clean.sh # Do not run build.sh, so it will be cross platform # Everything needed to run build.sh comes with any linux device, and it builds quite fast From 2a1b0d492c6ff45fd6df6a301ddca2f5ddddde5f Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:42:32 -0700 Subject: [PATCH 08/34] Remove requests from update_versioning script --- update_versioning.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/update_versioning.py b/update_versioning.py index 2fbfd3c0..bf6eac68 100644 --- a/update_versioning.py +++ b/update_versioning.py @@ -1,4 +1,4 @@ -import requests +# import requests import json import pathlib @@ -8,15 +8,15 @@ 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) +# 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 None +# if response.status_code == 200: +# tags = response.json() +# if tags: +# return tags[0]["name"] # The latest tag is the first one +# return None def get_new_tag(): From a9d83ea5482a2d7b9af1783febe1a79477176464 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:44:14 -0700 Subject: [PATCH 09/34] Add support for booleans in models --- backend_py/src/models/cameras.py | 11 ++++++----- backend_py/src/models/saved_cameras.py | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend_py/src/models/cameras.py b/backend_py/src/models/cameras.py index af143a54..ee724e25 100644 --- a/backend_py/src/models/cameras.py +++ b/backend_py/src/models/cameras.py @@ -102,10 +102,11 @@ class Config: class ControlFlagsModel(BaseModel): + # TODO: allow booleans, strings, etc. default_value: float | int - max_value: float | int - min_value: float | int - step: float | int + max_value: float | int = 0 + min_value: float | int = 0 + step: float | int = 0 control_type: ControlTypeEnum = Field(...) menu: list[MenuItemModel] = Field(default_factory=list) @@ -117,7 +118,7 @@ class ControlModel(BaseModel): flags: ControlFlagsModel control_id: int name: str - value: float | int + value: float | int | bool class Config: from_attributes = True @@ -230,7 +231,7 @@ class Config: class UVCControlModel(BaseModel): bus_info: str control_id: int - value: float | int + value: float | int | bool class Config: from_attributes = True diff --git a/backend_py/src/models/saved_cameras.py b/backend_py/src/models/saved_cameras.py index f0ccdb93..8f94b735 100644 --- a/backend_py/src/models/saved_cameras.py +++ b/backend_py/src/models/saved_cameras.py @@ -20,7 +20,8 @@ class SavedControlModel(BaseModel): control_id: int name: str - value: int | float + # TODO: This is not enough to allow booleans and might actually cause issues + value: int | float | bool class Config: from_attributes = True From 79f910878f5f269b24371ba9bad42d9582e2306a Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:47:29 -0700 Subject: [PATCH 10/34] Refactor entire camera stack: Changes include: - Changing directory organization to split files - Standardize options system for both stellarHD and exploreHD - Improve device registry to use O(1) lookup - Finalize refactor of schema location to models/ - Rewrite asic controls to use RLock --- backend_py/src/routes/cameras.py | 7 +- backend_py/src/routes/preferences.py | 3 +- backend_py/src/services/cameras/__init__.py | 2 - backend_py/src/services/cameras/device.py | 629 ----------------- .../src/services/cameras/device_manager.py | 29 +- .../src/services/cameras/device_utils.py | 2 +- .../src/services/cameras/drivers/__init__.py | 0 .../src/services/cameras/drivers/device.py | 354 ++++++++++ .../services/cameras/drivers/ehd/__init__.py | 3 + .../src/services/cameras/drivers/ehd/ehd.py | 33 + .../services/cameras/drivers/ehd/options.py | 116 ++++ .../cameras/drivers/options/__init__.py | 4 + .../cameras/drivers/options/base_option.py | 27 + .../cameras/drivers/options/xu_option.py | 133 ++++ .../src/services/cameras/drivers/registry.py | 59 ++ .../services/cameras/drivers/shd/__init__.py | 3 + .../cameras/drivers/shd/asic_interface.py | 163 +++++ .../services/cameras/drivers/shd/options.py | 198 ++++++ .../src/services/cameras/drivers/shd/shd.py | 170 +++++ .../cameras/drivers/video4linux/__init__.py | 5 + .../cameras/drivers/video4linux/camera.py | 81 +++ .../{ => drivers/video4linux}/enumeration.py | 4 +- .../cameras/{ => drivers/video4linux}/v4l2.py | 0 .../cameras/{xu_controls.py => drivers/xu.py} | 29 +- backend_py/src/services/cameras/ehd.py | 83 --- .../cameras/pwm/serial_pwm_controller.py | 1 - backend_py/src/services/cameras/shd.py | 639 ------------------ .../stream_engines/gstreamer_stream_engine.py | 4 +- .../services/cameras/stream_engines/stream.py | 2 +- .../synchronized_stream_engine.py | 8 +- .../src/services/cameras/stream_runner.py | 2 +- .../src/services/cameras/stream_utils.py | 2 +- .../cameras/synchronized_camera/lib.py | 4 +- .../services/network/async_network_manager.py | 3 +- backend_py/src/services/network/nm_wrapper.py | 5 +- .../preferences/preferences_manager.py | 3 +- .../services/preferences/settings_manager.py | 21 +- 37 files changed, 1424 insertions(+), 1407 deletions(-) delete mode 100644 backend_py/src/services/cameras/device.py create mode 100644 backend_py/src/services/cameras/drivers/__init__.py create mode 100644 backend_py/src/services/cameras/drivers/device.py create mode 100644 backend_py/src/services/cameras/drivers/ehd/__init__.py create mode 100644 backend_py/src/services/cameras/drivers/ehd/ehd.py create mode 100644 backend_py/src/services/cameras/drivers/ehd/options.py create mode 100644 backend_py/src/services/cameras/drivers/options/__init__.py create mode 100644 backend_py/src/services/cameras/drivers/options/base_option.py create mode 100644 backend_py/src/services/cameras/drivers/options/xu_option.py create mode 100644 backend_py/src/services/cameras/drivers/registry.py create mode 100644 backend_py/src/services/cameras/drivers/shd/__init__.py create mode 100644 backend_py/src/services/cameras/drivers/shd/asic_interface.py create mode 100644 backend_py/src/services/cameras/drivers/shd/options.py create mode 100644 backend_py/src/services/cameras/drivers/shd/shd.py create mode 100644 backend_py/src/services/cameras/drivers/video4linux/__init__.py create mode 100644 backend_py/src/services/cameras/drivers/video4linux/camera.py rename backend_py/src/services/cameras/{ => drivers/video4linux}/enumeration.py (97%) rename backend_py/src/services/cameras/{ => drivers/video4linux}/v4l2.py (100%) rename backend_py/src/services/cameras/{xu_controls.py => drivers/xu.py} (65%) delete mode 100644 backend_py/src/services/cameras/ehd.py delete mode 100644 backend_py/src/services/cameras/shd.py diff --git a/backend_py/src/routes/cameras.py b/backend_py/src/routes/cameras.py index d0d8f3e5..5cfd7bd5 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -8,9 +8,7 @@ from typing import cast -from fastapi import APIRouter, Request - -from src.models import ( +from backend_py.src.models import ( AddFollowerPayload, DeviceDescriptorModel, DeviceModel, @@ -19,11 +17,12 @@ StreamInfoModel, UVCControlModel, ) +from fastapi import APIRouter, Request from ..schemas import SimpleRequestStatusModel from ..services.cameras import DeviceManager +from ..services.cameras.drivers.shd import SHDDevice from ..services.cameras.exceptions import DeviceNotFoundException -from ..services.cameras.shd import SHDDevice camera_router = APIRouter(tags=["cameras"]) diff --git a/backend_py/src/routes/preferences.py b/backend_py/src/routes/preferences.py index 44e7a5ea..c8e8568a 100644 --- a/backend_py/src/routes/preferences.py +++ b/backend_py/src/routes/preferences.py @@ -5,10 +5,9 @@ Handles getting and setting preferences """ +from backend_py.src.models import SavedPreferencesModel from fastapi import APIRouter, Request -from src.models import SavedPreferencesModel - from ..schemas import SimpleRequestStatusModel from ..services.preferences import PreferencesManager diff --git a/backend_py/src/services/cameras/__init__.py b/backend_py/src/services/cameras/__init__.py index 19d2f3f3..4619e0b3 100644 --- a/backend_py/src/services/cameras/__init__.py +++ b/backend_py/src/services/cameras/__init__.py @@ -1,4 +1,3 @@ -from ..preferences.settings_manager import SettingsManager from .device_manager import DeviceManager from .device_utils import find_device_with_bus_info, list_diff from .pwm import SerialPWMController @@ -7,6 +6,5 @@ "DeviceManager", "find_device_with_bus_info", "list_diff", - "SettingsManager", "SerialPWMController", ] diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py deleted file mode 100644 index 29ed66ec..00000000 --- a/backend_py/src/services/cameras/device.py +++ /dev/null @@ -1,629 +0,0 @@ -""" -device.py - -Base class for camera device management -Handles v4l2 device finding, uvc controls, stream configuration, -and device settings management -""" - -import contextlib -import fcntl -import logging -import struct -import threading -from abc import ABC, abstractmethod -from collections.abc import Callable -from typing import Any - -import event_emitter as events -from linuxpy.video import device -from pydantic.v1 import NoneBytes - -from src.models import ( - ControlFlagsModel, - ControlModel, - ControlTypeEnum, - DeviceType, - FormatSizeModel, - FrameDropStats, - IntervalModel, - MenuItemModel, - SavedDeviceModel, - StreamEncodeTypeEnum, - StreamEndpointModel, - StreamTypeEnum, - V4LControlTypeEnum, -) - -from . import v4l2 -from . import xu_controls as xu -from .camera_helper.camera_helper_loader import camera_helper -from .enumeration import DeviceInfo -from .stream_runner import Stream, StreamRunner -from .stream_utils import fourcc2s, string_to_stream_encode_type - -PID_VIDS = { - "exploreHD": {"VID": 0xC45, "PID": 0x6366, "device_type": DeviceType.EXPLOREHD}, - "stellarHD: Leader": { - "VID": 0xC45, - "PID": 0x6367, - "device_type": DeviceType.STELLARHD_LEADER, - }, - "stellarHD: Follower": { - "VID": 0xC45, - "PID": 0x6368, - "device_type": DeviceType.STELLARHD_FOLLOWER, - }, - "exploreHD ": {"VID": 0x3961, "PID": 0x2100, "device_type": DeviceType.EXPLOREHD}, - "exploreHD Heavy": { - "VID": 0x3961, - "PID": 0x2200, - "device_type": DeviceType.EXPLOREHD, - }, - "exploreHD Heavy (AQ)": { - "VID": 0x3961, - "PID": 0x2210, - "device_type": DeviceType.EXPLOREHD, - }, - "stellarHD Elite (AQ-L)": { - "VID": 0x3961, - "PID": 0x1211, - "device_type": DeviceType.STELLARHD_LEADER, - }, - "stellarHD Elite (AQ-F)": { - "VID": 0x3961, - "PID": 0x1212, - "device_type": DeviceType.STELLARHD_FOLLOWER, - }, - "stellarHD Elite (L)": { - "VID": 0x3961, - "PID": 0x1201, - "device_type": DeviceType.STELLARHD_LEADER, - }, - "stellarHD Elite (F)": { - "VID": 0x3961, - "PID": 0x1202, - "device_type": DeviceType.STELLARHD_FOLLOWER, - }, - "stellarHD (AQ-L)": { - "VID": 0x3961, - "PID": 0x1111, - "device_type": DeviceType.STELLARHD_LEADER, - }, - "stellarHD (AQ-F)": { - "VID": 0x3961, - "PID": 0x1112, - "device_type": DeviceType.STELLARHD_FOLLOWER, - }, - "stellarHD (L)": { - "VID": 0x3961, - "PID": 0x1101, - "device_type": DeviceType.STELLARHD_LEADER, - }, - "stellarHD (F)": { - "VID": 0x3961, - "PID": 0x1102, - "device_type": DeviceType.STELLARHD_FOLLOWER, - }, - "explore3D (Left)": { - "VID": 0x3961, - "PID": 0x3112, - "device_type": DeviceType.STELLARHD_FOLLOWER, - }, - "explore3D (Right)": { - "VID": 0x3961, - "PID": 0x3111, - "device_type": DeviceType.STELLARHD_LEADER, - }, -} - - -def lookup_pid_vid(vid: int, pid: int) -> tuple[str, DeviceType] | tuple[None, None]: - for name in PID_VIDS: - dev = PID_VIDS[name] - if dev["VID"] == vid and dev["PID"] == pid: - return (name, DeviceType(dev["device_type"])) - return (None, None) - - -class Camera: - """ - Camera base class - """ - - def __init__(self, path: str) -> None: - self.path = path - self._file_object = open(path) # noqa: SIM115 - self._fd = self._file_object.fileno() # get the file descriptor - self._get_formats() - - def close(self) -> None: - self._file_object.close() - - # uvc_set_ctrl function defined in uvc_functions.c - def uvc_set_ctrl(self, unit: int, ctrl: int, data: bytes, size: int) -> int: - return camera_helper.uvc_set_ctrl(self._fd, unit, ctrl, data, size) - - # uvc_get_ctrl function defined in uvc_functions.c - def uvc_get_ctrl(self, unit: int, ctrl: int, data: bytes, size: int) -> int: - return camera_helper.uvc_get_ctrl(self._fd, unit, ctrl, data, size) - - def has_format(self, pixformat: str) -> bool: - return pixformat in self.formats - - def _get_formats(self) -> None: - self.formats: dict[str, list[FormatSizeModel]] = {} - for i in range(1000): - v4l2_fmt = v4l2.v4l2_fmtdesc() - v4l2_fmt.index = i - v4l2_fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE - try: - fcntl.ioctl(self._fd, v4l2.VIDIOC_ENUM_FMT, v4l2_fmt) - except OSError: - break - - format_sizes = [] - for j in range(1000): - frmsize = v4l2.v4l2_frmsizeenum() - frmsize.index = j - frmsize.pixel_format = v4l2_fmt.pixelformat - try: - fcntl.ioctl(self._fd, v4l2.VIDIOC_ENUM_FRAMESIZES, frmsize) - except OSError: - break - if frmsize.type == v4l2.V4L2_FRMSIZE_TYPE_DISCRETE: - format_size = FormatSizeModel( - width=frmsize.discrete.width, - height=frmsize.discrete.height, - intervals=[], - ) - for k in range(1000): - frmival = v4l2.v4l2_frmivalenum() - frmival.index = k - frmival.pixel_format = v4l2_fmt.pixelformat - frmival.width = frmsize.discrete.width - frmival.height = frmsize.discrete.height - try: - fcntl.ioctl( - self._fd, v4l2.VIDIOC_ENUM_FRAMEINTERVALS, frmival - ) - except OSError: # This is expected and/or possible - break - if frmival.type == v4l2.V4L2_FRMIVAL_TYPE_DISCRETE: - format_size.intervals.append( - IntervalModel( - numerator=frmival.discrete.numerator, - denominator=frmival.discrete.denominator, - ) - ) - format_sizes.append(format_size) - self.formats[fourcc2s(v4l2_fmt.pixelformat)] = format_sizes - - -class BaseOption(ABC): - def __init__(self, name: str) -> None: - self.name = name - - @abstractmethod - def get_value(self) -> Any: - pass - - @abstractmethod - def set_value(self, value) -> NoneBytes: - pass - - -# Note: SHD version is asymmetric from this, despite being functionally similar. -# One will need to be moved to match the other for maintainability -# I prefer SHD version, because logic in the option class is not ideal - Brandon -class Option(BaseOption): - """ - EHD Option Class - """ - - def __init__( - self, - camera: Camera, - fmt: str, - unit: xu.Unit, - ctrl: xu.Selector, - command: xu.Command, - name: str, - conversion_func_set: Callable[[Any], list | Any] = lambda val: val, - conversion_func_get: Callable[[list | Any], Any] = lambda val: val, - size=11, - ) -> None: - super().__init__(name) - - self._camera = camera - self._fmt = fmt - self._conversion_func_set = conversion_func_set - self._conversion_func_get = conversion_func_get - - self._unit = unit - self._ctrl = ctrl - self._command = command - self._size = size - self._data = b"\x00" * size - - # get the control value(s) - def get_value_raw(self) -> tuple[Any, ...] | Any: - self._get_ctrl() - values = self._unpack(self._fmt) - self._clear() - # all cases will basically be this, but otherwise this will still work - if len(values) == 1: - return values[0] - return values - - # set the control value - def set_value_raw(self, *arg: list) -> None: - self._pack(self._fmt, *arg) - self._set_ctrl() - self._clear() - - def set_value(self, value) -> None: - converted = self._conversion_func_set(value) - if isinstance(converted, list): - self.set_value_raw(*converted) - else: - self.set_value_raw(converted) - - def get_value(self) -> list | Any: - return self._conversion_func_get(self.get_value_raw()) - - # pack data to internal buffer - def _pack(self, fmt: str, *arg: list) -> None: - data = struct.pack(fmt, *arg) - # make sure the data is of the right length - self._data = data + bytearray(self._size - len(data)) - - # unpack data from internal buffer - def _unpack(self, fmt: str) -> tuple[Any, ...]: - return struct.unpack_from(fmt, self._data) - - def _set_ctrl(self) -> None: - data = bytearray(self._size) - data[0] = xu.DWE_DEVICE_TAG - data[1] = self._command.value - - # Switch command - self._camera.uvc_set_ctrl( - self._unit.value, self._ctrl.value, bytes(data), self._size - ) - - self._camera.uvc_set_ctrl( - self._unit.value, self._ctrl.value, self._data, self._size - ) - - def _get_ctrl(self) -> None: - data = bytearray(self._size) - data[0] = xu.DWE_DEVICE_TAG - data[1] = self._command.value - self._data = bytes(self._size) - # Switch command - self._camera.uvc_set_ctrl( - self._unit.value, self._ctrl.value, bytes(data), self._size - ) - - self._camera.uvc_get_ctrl( - self._unit.value, self._ctrl.value, self._data, self._size - ) - - def _clear(self) -> None: - self._data = b"\x00" * self._size - - -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)) - - self.logger = logging.getLogger("dwe_os_2.cameras.Device") - self.logger.setLevel(logging.DEBUG) - - self.device_info = device_info - self.vid = device_info.vid - self.pid = device_info.pid - (self.name, self.device_type) = lookup_pid_vid(self.vid, self.pid) - if self.name is not None: - self.manufacturer = "DeepWater Exploration Inc." - else: - # Device is not DWE - return - self.bus_info = device_info.bus_info - 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: - encode_type = string_to_stream_encode_type(encoding) - if encode_type != StreamEncodeTypeEnum.NONE: - self.stream.encode_type = encode_type - # The highest resolution is the default - # Most users will use this, however it is available to be changed - # in the frontend - self.stream.width = camera.formats[encoding][0].width - self.stream.height = camera.formats[encoding][0].height - self.stream.interval.denominator = ( - camera.formats[encoding][0].intervals[0].denominator - ) - self.stream.interval.numerator = ( - camera.formats[encoding][0].intervals[0].numerator - ) - break - - self.v4l2_device = device.Device(self.cameras[0].path) # for control purposes - self.v4l2_device.open() - - # This must be configured by the implementing class - self._options: dict[str, BaseOption] = self._get_options() - - # list the controls and store them - self.controls = [] - - self._id_counter = 1 - - 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 - - def _get_options(self) -> dict[str, BaseOption]: - return {} - - def _get_controls(self) -> None: - # fd = self.cameras[0]._fd - self.controls: list[ControlModel] = [] - - if not self.v4l2_device.controls: - # TODO: If this happens, should delete the device, instead of just - # potentially dying (will never happen anyway) - self.logger.error( - "v4l2_device.controls == None. Unable to get controls. " - "This might be fatal." - ) - return - - for ctrl in self.v4l2_device.controls.values(): - internal_enum = V4LControlTypeEnum(ctrl.type) - control_type = ControlTypeEnum(internal_enum.name) - - max_value = 0 - min_value = 0 - step = 0 - - # FIXME: Should not surpress, should instead log this and use it - - with contextlib.suppress(BaseException): - max_value = ctrl.maximum - - with contextlib.suppress(BaseException): - min_value = ctrl.minimum - - with contextlib.suppress(BaseException): - step = ctrl.step - - default_value = ctrl._info.default_value - - menu: list[MenuItemModel] = [] - match control_type: - case ControlTypeEnum.MENU: - for i in ctrl.data: - menu_item = ctrl.data[i] - menu.append(MenuItemModel(index=i, name=menu_item)) - - flags = ControlFlagsModel( - default_value=default_value, - max_value=max_value, - min_value=min_value, - step=step, - control_type=control_type, - menu=menu, - ) - control = ControlModel( - control_id=ctrl.id, name=ctrl.name, value=ctrl.value, flags=flags - ) - - self.controls.append(control) - - def find_camera_with_format(self, fmt: str) -> Camera | None: - for cam in self.cameras: - if cam.has_format(fmt): - return cam - return None - - def configure_stream( - self, - encode_type: StreamEncodeTypeEnum, - width: int, - height: int, - interval: IntervalModel, - stream_type: StreamTypeEnum, - stream_endpoints: list[StreamEndpointModel] | None = None, - ) -> None: - if stream_endpoints is None: - stream_endpoints = [] - - self.logger.info(self._fmt_log("Configuring stream")) - - camera: Camera | None = None - match encode_type: - case StreamEncodeTypeEnum.H264: - camera = self.find_camera_with_format("H264") - case StreamEncodeTypeEnum.MJPG: - camera = self.find_camera_with_format("MJPG") - case StreamEncodeTypeEnum.SOFTWARE_H264: - camera = self.find_camera_with_format("MJPG") - case _: - pass - - if not camera: - self.logger.warning( - "Attempting to select incompatible encoding type. " - "This is undefined behavior." - ) - return - - self.stream.device_path = camera.path - self.stream.width = width - self.stream.height = height - self.stream.interval = interval - self.stream.endpoints = stream_endpoints - self.stream.encode_type = encode_type - self.stream.stream_type = stream_type - - # Update the pwm frequency with the new fps - self.emit("pwm_frequency", self.stream.interval.denominator) - - def add_control_from_option( - self, - option_name: str, - default_value: Any, - control_type: ControlTypeEnum, - max_value: float | int = 0, - min_value: float | int = 0, - step: float | int = 0, - ) -> None: - try: - option = self._options[option_name] - value = int(option.get_value()) - self.controls.insert( - 0, - ControlModel( - control_id=-self._id_counter, - name=option.name, - value=value, - flags=ControlFlagsModel( - default_value=default_value, - max_value=max_value, - min_value=min_value, - step=step, - control_type=control_type, - ), - ), - ) - self._id_counter += 1 - except AttributeError: - import traceback - - traceback.print_exc() - self.logger.error( - f"Unknown attribute: {self.__class__.__name__}._options[{option_name}]" - ) - self.logger.error("Failed to add option to controls list.") - - 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() - - def close(self) -> None: - """ - Cleanup resources of the device - """ - self.stream_runner.stop() - for camera in self.cameras: - camera.close() - self.v4l2_device.close() - - def load_settings(self, saved_device: SavedDeviceModel) -> None: - self.logger.info(self._fmt_log("Loading device settings")) - - for control in saved_device.controls: - # CHECK: There used to be a try catch here.. - self.set_pu(control.control_id, control.value) - - self.configure_stream( - saved_device.stream.encode_type, - saved_device.stream.width, - saved_device.stream.height, - saved_device.stream.interval, - saved_device.stream.stream_type, - saved_device.stream.endpoints, - ) - self.stream.enabled = saved_device.stream.enabled - self.nickname = saved_device.nickname - if self.stream.enabled: - self.start_stream() - - def unconfigure_stream(self) -> None: - self.stream_runner.stop() - self.logger.info(self._fmt_log("Stream stopped")) - - def get_pu(self, control_id: int) -> int | None: - if not self.v4l2_device.controls: - self.logger.error("v4l2_device.controls == None. Unable to get pu") - return None - control = self.v4l2_device.controls[control_id] - return control.value - - def set_pu(self, control_id: int, value: int | float) -> bool | None: - if not self.v4l2_device.controls: - self.logger.critical("v4l2_device.controls is None; unable to run set_pu") - return - - if control_id < 0: - # DWE control - for control in self.controls: - if control.control_id == control_id: - control.value = value - for option_name in self._options: - if self._options[option_name].name == control.name: - self.set_option(option_name, value) - return - return # in case the id does not exist in controls - - control = self.v4l2_device.controls[control_id] - - try: - control.value = value - except (AttributeError, PermissionError) as e: - self.logger.debug(f"Error setting control value: {e}") - return False - for ctrl in self.controls: - if ctrl.control_id == control_id: - ctrl.value = value - break - - return True - - # get an option - def get_option(self, opt: str) -> Any: - if opt in self._options: - return self._options[opt].get_value() - return None - - # set an option - def set_option(self, opt: str, value: Any) -> None: - # self.logger.debug(self._fmt_log(f"Setting option - {opt} to {value}")) - if opt in self._options: - self._options[opt].set_value(value) - - def _fmt_log(self, message: str) -> str: - return f"{self.bus_info} - {message}" diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index 0c2bb00e..c7d249e3 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -16,22 +16,23 @@ import event_emitter as events import socketio - -from src.models import ( +from backend_py.src.models import ( DeviceModel, + DeviceType, StreamEncodeTypeEnum, StreamInfoModel, StreamTypeEnum, ) from ..preferences import SettingsManager -from .device import Device, DeviceInfo, DeviceType, lookup_pid_vid from .device_utils import find_device_with_bus_info, list_diff -from .ehd import EHDDevice -from .enumeration import list_devices +from .drivers.device import Device, DeviceInfo +from .drivers.ehd import EHDDevice +from .drivers.registry import lookup_vid_pid +from .drivers.shd import SHDDevice +from .drivers.video4linux.enumeration import list_devices from .exceptions import DeviceNotFoundException from .pwm.serial_pwm_controller import SerialPWMController -from .shd import SHDDevice def todict(obj, classkey=None) -> Any: @@ -111,21 +112,23 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: """ Create a new device based on enumerated device info """ - (_, device_type) = lookup_pid_vid(device_info.vid, device_info.pid) + device_metadata = lookup_vid_pid(device_info.vid, device_info.pid) + + if not device_metadata: + return None - device = None - match device_type: + match device_metadata.device_type: case DeviceType.EXPLOREHD: - device = EHDDevice(device_info) + device = EHDDevice(device_info, device_metadata) case DeviceType.STELLARHD_LEADER: - device = SHDDevice(device_info) + device = SHDDevice(device_info, device_metadata) case DeviceType.STELLARHD_FOLLOWER: - device = SHDDevice(device_info) + device = SHDDevice(device_info, device_metadata) case _: # Not a DWE device return None - # we need to broadcast that there was a gst error so that the frontend knows + # we need to broadcast that there was a stream error so that the frontend knows # there may be a kernel issue device.stream_runner.on( "stream_error", diff --git a/backend_py/src/services/cameras/device_utils.py b/backend_py/src/services/cameras/device_utils.py index c72bbdfb..a6657baa 100644 --- a/backend_py/src/services/cameras/device_utils.py +++ b/backend_py/src/services/cameras/device_utils.py @@ -5,7 +5,7 @@ removed devices """ -from .device import Device +from .drivers.device import Device def find_device_with_bus_info(devices: list[Device], bus_info: str) -> Device | None: diff --git a/backend_py/src/services/cameras/drivers/__init__.py b/backend_py/src/services/cameras/drivers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend_py/src/services/cameras/drivers/device.py b/backend_py/src/services/cameras/drivers/device.py new file mode 100644 index 00000000..c05dd86a --- /dev/null +++ b/backend_py/src/services/cameras/drivers/device.py @@ -0,0 +1,354 @@ +""" +device.py + +Base class for camera device management +Handles v4l2 device finding, uvc controls, stream configuration, +and device settings management +""" + +import contextlib +import logging +import threading +from typing import Any, Type + +import event_emitter as events +from backend_py.src.models import ( + ControlFlagsModel, + ControlModel, + ControlTypeEnum, + FrameDropStats, + IntervalModel, + MenuItemModel, + SavedDeviceModel, + StreamEncodeTypeEnum, + StreamEndpointModel, + StreamTypeEnum, + V4LControlTypeEnum, +) +from linuxpy.video import device + +from ..stream_runner import Stream, StreamRunner +from ..stream_utils import string_to_stream_encode_type +from .options import BaseOption +from .registry import DeviceMetadata +from .video4linux import Camera +from .video4linux.enumeration import DeviceInfo + + +class Device(events.EventEmitter): + def __init__( + self, device_info: DeviceInfo, device_metadata: DeviceMetadata + ) -> None: + super().__init__() + + self.cameras: list[Camera] = [] + for device_path in device_info.device_paths: + self.cameras.append(Camera(device_path)) + + self.logger = logging.getLogger("dwe_os_2.cameras.Device") + self.logger.setLevel(logging.DEBUG) + + # If this device has been constructed, we can assume the DeviceManager did + # its job of only creating it if it's compatible. + self.device_info = device_info + self.vid = device_info.vid + self.pid = device_info.pid + self.bus_info = device_info.bus_info + self.nickname = "" + self.stream = Stream() + + # Grab the device metadata + self.manufacturer = device_metadata.manufacturer + self.name = device_metadata.name + self.device_type = device_metadata.device_type + + # 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: + encode_type = string_to_stream_encode_type(encoding) + if encode_type != StreamEncodeTypeEnum.NONE: + self.stream.encode_type = encode_type + # The highest resolution is the default + # Most users will use this, however it is available to be changed + # in the frontend + self.stream.width = camera.formats[encoding][0].width + self.stream.height = camera.formats[encoding][0].height + self.stream.interval.denominator = ( + camera.formats[encoding][0].intervals[0].denominator + ) + self.stream.interval.numerator = ( + camera.formats[encoding][0].intervals[0].numerator + ) + break + + self.v4l2_device = device.Device(self.cameras[0].path) # for control purposes + self.v4l2_device.open() + + # This must be configured by the implementing class + self._options: dict[str, BaseOption] = {} + + # list the controls and store them + self.controls = [] + + self._id_counter = 1 + + 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 + + def _get_options(self) -> dict[str, BaseOption]: + return {} + + def _get_controls(self) -> None: + self.controls: list[ControlModel] = [] + + if not self.v4l2_device.controls: + # TODO: If this happens, should delete the device, instead of just + # potentially dying (will never happen anyway) + self.logger.error( + "v4l2_device.controls == None. Unable to get controls. " + "This might be fatal." + ) + return + + for ctrl in self.v4l2_device.controls.values(): + internal_enum = V4LControlTypeEnum(ctrl.type) + control_type = ControlTypeEnum(internal_enum.name) + + # FIXME: Should not surpress, should instead log this and use it + + with contextlib.suppress(BaseException): + max_value = ctrl.maximum + + with contextlib.suppress(BaseException): + min_value = ctrl.minimum + + with contextlib.suppress(BaseException): + step = ctrl.step + + # Can we access the default_value without using the private variable? + default_value = ctrl._info.default_value + + menu: list[MenuItemModel] = [] + match control_type: + case ControlTypeEnum.MENU: + for i in ctrl.data: + menu_item = ctrl.data[i] + menu.append(MenuItemModel(index=i, name=menu_item)) + + flags = ControlFlagsModel( + default_value=default_value, + max_value=max_value, + min_value=min_value, + step=step, + control_type=control_type, + menu=menu, + ) + control = ControlModel( + control_id=ctrl.id, name=ctrl.name, value=ctrl.value, flags=flags + ) + + self.controls.append(control) + + def find_camera_with_format(self, fmt: str) -> Camera | None: + for cam in self.cameras: + if cam.has_format(fmt): + return cam + return None + + def configure_stream( + self, + encode_type: StreamEncodeTypeEnum, + width: int, + height: int, + interval: IntervalModel, + stream_type: StreamTypeEnum, + stream_endpoints: list[StreamEndpointModel] | None = None, + ) -> None: + if stream_endpoints is None: + stream_endpoints = [] + + self.logger.info(self._fmt_log("Configuring stream")) + + camera: Camera | None = None + match encode_type: + # I know this looks scuffed, but we can't use the stream encode type, + # because it does not directly map to the format + # A utility function is not really useful + case StreamEncodeTypeEnum.H264: + camera = self.find_camera_with_format("H264") + case StreamEncodeTypeEnum.MJPG: + camera = self.find_camera_with_format("MJPG") + case StreamEncodeTypeEnum.SOFTWARE_H264: + camera = self.find_camera_with_format("MJPG") + case _: + pass + if not camera: + self.logger.warning( + "Attempting to select incompatible encoding type. " + "This is undefined behavior." + ) + return + + self.stream.device_path = camera.path + self.stream.width = width + self.stream.height = height + self.stream.interval = interval + self.stream.endpoints = stream_endpoints + self.stream.encode_type = encode_type + self.stream.stream_type = stream_type + + # Update the pwm frequency with the new fps + # TODO: This should be on a command bus or something, not emitted from + # the device like this + self.emit("pwm_frequency", self.stream.interval.denominator) + + def add_control_from_option(self, option_name: str) -> None: + try: + option = self._options[option_name] + value = option.get_value() + self.controls.insert( + 0, + ControlModel( + control_id=-self._id_counter, + name=option.name, + value=value, + flags=option.control_flags, + ), + ) + self._id_counter += 1 + except AttributeError: + import traceback + + traceback.print_exc() + self.logger.error( + f"Unknown attribute: {self.__class__.__name__}._options[{option_name}]" + ) + self.logger.error("Failed to add option to controls list.") + + 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) + + # FIXME: What is a better way to do this? An event bus could work, + # especially since we are propagating this 3 levels up + # For example: self.event_bus.emit("stream_started", self) + self.emit("frame_stats") + + def stop_stream(self) -> None: + self.stream.enabled = False + self.stream_runner.stop() + + def close(self) -> None: + """ + Cleanup resources of the device + """ + self.stream_runner.stop() + for camera in self.cameras: + camera.close() + self.v4l2_device.close() + + def load_settings(self, saved_device: SavedDeviceModel) -> None: + self.logger.info(self._fmt_log("Loading device settings")) + + for control in saved_device.controls: + self.set_pu(control.control_id, control.value) + + self.configure_stream( + saved_device.stream.encode_type, + saved_device.stream.width, + saved_device.stream.height, + saved_device.stream.interval, + saved_device.stream.stream_type, + saved_device.stream.endpoints, + ) + self.stream.enabled = saved_device.stream.enabled + self.nickname = saved_device.nickname + if self.stream.enabled: + self.start_stream() + + def unconfigure_stream(self) -> None: + self.stream_runner.stop() + self.logger.info(self._fmt_log("Stream stopped")) + + def get_pu(self, control_id: int) -> int | None: + if not self.v4l2_device.controls: + # This should never happen, because if the controls were not accessible, + # the device would not have been initialized properly, + # but we check just in case + self.logger.error("v4l2_device.controls == None. Unable to get pu") + return None + control = self.v4l2_device.controls[control_id] + return control.value + + def set_pu(self, control_id: int, value: int | float | bool) -> bool | None: + if not self.v4l2_device.controls: + self.logger.critical("v4l2_device.controls is None; unable to run set_pu") + return + + if control_id < 0: + # DWE control + for control in self.controls: + if control.control_id == control_id: + control.value = value + for option_name in self._options: + if self._options[option_name].name == control.name: + try: + self.set_option(option_name, value) + except TypeError as e: + # TODO: return this to caller (API) + self.logger.info( + "Encountered error setting control: " + f"{control.name} - {e}" + ) + return False + return True + return False # in case the id does not exist in controls + + control = self.v4l2_device.controls[control_id] + + try: + control.value = value + except (AttributeError, PermissionError) as e: + # Usually happens when the parameter can no longer be written to (disabled) + self.logger.debug(f"Error setting control value ({control.name}): {e}") + return False + for ctrl in self.controls: + if ctrl.control_id == control_id: + ctrl.value = value + break + + return True + + # get an option + def get_option(self, opt: str) -> Any: + if opt in self._options: + return self._options[opt].get_value() + return None + + # set an option + def set_option(self, opt: str, value: Any) -> None: + # self.logger.debug(self._fmt_log(f"Setting option - {opt} to {value}")) + if opt in self._options: + self._options[opt].set_value(value) + + def _fmt_log(self, message: str) -> str: + return f"{self.bus_info} - {message}" diff --git a/backend_py/src/services/cameras/drivers/ehd/__init__.py b/backend_py/src/services/cameras/drivers/ehd/__init__.py new file mode 100644 index 00000000..2af5859f --- /dev/null +++ b/backend_py/src/services/cameras/drivers/ehd/__init__.py @@ -0,0 +1,3 @@ +from .ehd import EHDDevice + +__all__ = ["EHDDevice"] diff --git a/backend_py/src/services/cameras/drivers/ehd/ehd.py b/backend_py/src/services/cameras/drivers/ehd/ehd.py new file mode 100644 index 00000000..d5e7d6aa --- /dev/null +++ b/backend_py/src/services/cameras/drivers/ehd/ehd.py @@ -0,0 +1,33 @@ +""" +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 ..device import Device, DeviceMetadata +from ..video4linux.enumeration import DeviceInfo +from .options import EHDBitrateOption, EHDGOPOption, EHDH264ModeOption + + +class EHDDevice(Device): + """ + Class for exploreHD devices + """ + + def __init__( + self, device_info: DeviceInfo, device_metadata: DeviceMetadata + ) -> None: + super().__init__(device_info, device_metadata) + + self._options = { + "bitrate": EHDBitrateOption(self.cameras[2]), + "gop": EHDGOPOption(self.cameras[2]), + "vbr": EHDH264ModeOption(self.cameras[2]), + } + + self.add_control_from_option("gop") + self.add_control_from_option("bitrate") + self.add_control_from_option("vbr") diff --git a/backend_py/src/services/cameras/drivers/ehd/options.py b/backend_py/src/services/cameras/drivers/ehd/options.py new file mode 100644 index 00000000..7bc8297f --- /dev/null +++ b/backend_py/src/services/cameras/drivers/ehd/options.py @@ -0,0 +1,116 @@ +from typing import Any + +from backend_py.src.models import ControlFlagsModel, ControlTypeEnum, H264Mode + +from ..options import OptionDescriptor, XUOption +from ..video4linux import Camera +from ..xu import Command, Selector, Unit + + +class EHDBitrateOption(XUOption): + """ + Option for setting the bitrate on exploreHD cameras. + The bitrate is in megabits per second (Mbps) + """ + + def __init__(self, camera: Camera) -> None: + option_descriptor = OptionDescriptor( + unit=Unit.USR_ID, + ctrl=Selector.USR_H264_CTRL, + command=Command.H264_BITRATE_CTRL, + name="Bitrate", + ) + super().__init__( + camera, + ">I", + option_descriptor, + ControlFlagsModel( + default_value=10, + min_value=0.1, + max_value=15, + step=0.1, + control_type=ControlTypeEnum.INTEGER, + ), + ) + + def set_value(self, value: float) -> None: + # convert to bps from mpbs (round for float imprecision) + bitrate_bps = int(round(value * 1000000)) + super()._set_value_raw(bitrate_bps) + + def get_value(self) -> float: + bitrate_bps = super()._get_value_raw() + # convert to mpbs from bps + return bitrate_bps / 1000000.0 + + +class EHDGOPOption(XUOption): + """Option for setting the Group of Pictures (GOP) on exploreHD cameras""" + + def __init__(self, camera) -> None: + option_descriptor = OptionDescriptor( + unit=Unit.USR_ID, + ctrl=Selector.USR_H264_CTRL, + command=Command.GOP_CTRL, + name="Group of Pictures", + ) + super().__init__( + camera, + "H", + option_descriptor, + control_flags=ControlFlagsModel( + default_value=29, + min_value=0, + max_value=29, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + ) + + def set_value(self, value: Any) -> None: + if not isinstance(value, int): + raise TypeError(f"EHDGOPOption requires an int, got {type(value).__name__}") + + super()._set_value_raw(value) + + def get_value(self) -> int: + return super()._get_value_raw() + + +class EHDH264ModeOption(XUOption): + """Option for setting the H264 mode (CBR or VBR) on exploreHD cameras""" + + def __init__(self, camera) -> None: + option_descriptor = OptionDescriptor( + unit=Unit.USR_ID, + ctrl=Selector.USR_H264_CTRL, + command=Command.H264_MODE_CTRL, + name="Variable Bitrate", + ) + # CHECK: Are we really sending boolean values or just 0 and 1? + # Hopefully this does not affect serialization for settings saving/loading + super().__init__( + camera, + "B", + option_descriptor, + ControlFlagsModel( + default_value=True, + control_type=ControlTypeEnum.BOOLEAN, + ), + ) + + def set_value(self, value: Any) -> None: + if not isinstance(value, bool): + raise TypeError( + f"EHDH264ModeOption requires a bool, got {type(value).__name__}" + ) + + super()._set_value_raw( + H264Mode.MODE_VARIABLE_BITRATE.value + if value + else H264Mode.MODE_CONSTANT_BITRATE.value + ) + + def get_value(self) -> bool: + mode_value = super()._get_value_raw() + return H264Mode(mode_value) == H264Mode.MODE_VARIABLE_BITRATE diff --git a/backend_py/src/services/cameras/drivers/options/__init__.py b/backend_py/src/services/cameras/drivers/options/__init__.py new file mode 100644 index 00000000..002dc0d3 --- /dev/null +++ b/backend_py/src/services/cameras/drivers/options/__init__.py @@ -0,0 +1,4 @@ +from .base_option import BaseOption +from .xu_option import OptionDescriptor, XUOption + +__all__ = ["BaseOption", "XUOption", "OptionDescriptor"] diff --git a/backend_py/src/services/cameras/drivers/options/base_option.py b/backend_py/src/services/cameras/drivers/options/base_option.py new file mode 100644 index 00000000..dc4a29d0 --- /dev/null +++ b/backend_py/src/services/cameras/drivers/options/base_option.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import Any + +from backend_py.src.models import ControlFlagsModel + + +class BaseOption(ABC): + """ + Base class for camera options. + Each option corresponds to a control that can be get or set on the camera. + """ + + def __init__(self, name: str, control_flags: ControlFlagsModel) -> None: + self.name = name + self.control_flags = control_flags + + @abstractmethod + def get_value(self) -> Any: + """ + Get the current value of the option from the camera. + """ + + @abstractmethod + def set_value(self, value) -> None: + """ + Set the value of the option on the camera. + """ diff --git a/backend_py/src/services/cameras/drivers/options/xu_option.py b/backend_py/src/services/cameras/drivers/options/xu_option.py new file mode 100644 index 00000000..5cbe06bb --- /dev/null +++ b/backend_py/src/services/cameras/drivers/options/xu_option.py @@ -0,0 +1,133 @@ +""" +xu_option.py + +Class for setting exploreHD extension unit options. +""" + +import struct +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any + +from backend_py.src.models import ControlFlagsModel + +from ..video4linux import Camera +from ..xu import DWE_DEVICE_TAG, Command, Selector, Unit +from .base_option import BaseOption + + +@dataclass +class OptionDescriptor: + """ + Class to hold information about an extension unit option + """ + + unit: Unit + ctrl: Selector + command: Command + name: str + + +class XUOption(BaseOption): + """ + EHD Option Class + """ + + def __init__( + self, + camera: Camera, + fmt: str, + option_descriptor: OptionDescriptor, + control_flags: ControlFlagsModel, + size=11, + ) -> None: + super().__init__(option_descriptor.name, control_flags) + + self._camera = camera + self._fmt = fmt + + self._option_descriptor = option_descriptor + + self._size = size + self._data = b"\x00" * size + + # get the control value(s) + def _get_value_raw(self) -> int: + self._get_ctrl() + values = self._unpack(self._fmt) + self._clear() + + if len(values) == 1: + return int(values[0]) + + raise NotImplementedError( + "Expected only one value from unpacking, got multiple" + ) + + # set the control value + def _set_value_raw(self, value: int) -> None: + self._pack(self._fmt, value) + self._set_ctrl() + self._clear() + + @abstractmethod + def set_value(self, value: int | float | bool) -> None: + pass + + @abstractmethod + def get_value(self) -> int | float | bool: + pass + + # pack data to internal buffer + def _pack(self, fmt: str, *arg: int) -> None: + data = struct.pack(fmt, *arg) + # make sure the data is of the right length + self._data = data + bytearray(self._size - len(data)) + + # unpack data from internal buffer + def _unpack(self, fmt: str) -> tuple[Any, ...]: + return struct.unpack_from(fmt, self._data) + + def _set_ctrl(self) -> None: + data = bytearray(self._size) + data[0] = DWE_DEVICE_TAG + data[1] = self._option_descriptor.command.value + + # Switch command + self._camera.uvc_set_ctrl( + self._option_descriptor.unit.value, + self._option_descriptor.ctrl.value, + bytes(data), + self._size, + ) + + self._camera.uvc_set_ctrl( + self._option_descriptor.unit.value, + self._option_descriptor.ctrl.value, + self._data, + self._size, + ) + + def _get_ctrl(self) -> None: + data = bytearray(self._size) + data[0] = DWE_DEVICE_TAG + data[1] = self._option_descriptor.command.value + self._data = bytes(self._size) + # Switch command + self._camera.uvc_set_ctrl( + self._option_descriptor.unit.value, + self._option_descriptor.ctrl.value, + bytes(data), + self._size, + ) + + # Grab the data + self._camera.uvc_get_ctrl( + self._option_descriptor.unit.value, + self._option_descriptor.ctrl.value, + self._data, + self._size, + ) + + def _clear(self) -> None: + self._data = b"\x00" * self._size diff --git a/backend_py/src/services/cameras/drivers/registry.py b/backend_py/src/services/cameras/drivers/registry.py new file mode 100644 index 00000000..8a20525a --- /dev/null +++ b/backend_py/src/services/cameras/drivers/registry.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass + +from backend_py.src.models import ( + DeviceType, +) + + +@dataclass(frozen=True) +class DeviceMetadata: + """ + Metadata for a Device class + """ + + name: str + device_type: DeviceType + manufacturer: str = "DeepWater Exploration Inc." + + +# DWE Device registry for supported cameras in dweOS +DEVICE_REGISTRY: dict[tuple[int, int], DeviceMetadata] = { + # Legacy VID + (0x0C45, 0x6366): DeviceMetadata("exploreHD", DeviceType.EXPLOREHD), + (0x0C45, 0x6367): DeviceMetadata("stellarHD: Leader", DeviceType.STELLARHD_LEADER), + (0x0C45, 0x6368): DeviceMetadata( + "stellarHD: Follower", DeviceType.STELLARHD_FOLLOWER + ), + # exploreHD + (0x3961, 0x2100): DeviceMetadata("exploreHD", DeviceType.EXPLOREHD), + (0x3961, 0x2200): DeviceMetadata("exploreHD Heavy", DeviceType.EXPLOREHD), + (0x3961, 0x2210): DeviceMetadata("exploreHD Heavy (AQ)", DeviceType.EXPLOREHD), + # stellarHD Elite + (0x3961, 0x1211): DeviceMetadata( + "stellarHD Elite (AQ-L)", DeviceType.STELLARHD_LEADER + ), + (0x3961, 0x1212): DeviceMetadata( + "stellarHD Elite (AQ-F)", DeviceType.STELLARHD_FOLLOWER + ), + (0x3961, 0x1201): DeviceMetadata( + "stellarHD Elite (L)", DeviceType.STELLARHD_LEADER + ), + (0x3961, 0x1202): DeviceMetadata( + "stellarHD Elite (F)", DeviceType.STELLARHD_FOLLOWER + ), + # stellarHD Standard + (0x3961, 0x1111): DeviceMetadata("stellarHD (AQ-L)", DeviceType.STELLARHD_LEADER), + (0x3961, 0x1112): DeviceMetadata("stellarHD (AQ-F)", DeviceType.STELLARHD_FOLLOWER), + (0x3961, 0x1101): DeviceMetadata("stellarHD (L)", DeviceType.STELLARHD_LEADER), + (0x3961, 0x1102): DeviceMetadata("stellarHD (F)", DeviceType.STELLARHD_FOLLOWER), + # explore3D + (0x3961, 0x3112): DeviceMetadata("explore3D (Left)", DeviceType.STELLARHD_FOLLOWER), + (0x3961, 0x3111): DeviceMetadata("explore3D (Right)", DeviceType.STELLARHD_LEADER), +} + + +def lookup_vid_pid(vid: int, pid: int) -> DeviceMetadata | None: + try: + return DEVICE_REGISTRY[(vid, pid)] + except KeyError: + return None diff --git a/backend_py/src/services/cameras/drivers/shd/__init__.py b/backend_py/src/services/cameras/drivers/shd/__init__.py new file mode 100644 index 00000000..9558a388 --- /dev/null +++ b/backend_py/src/services/cameras/drivers/shd/__init__.py @@ -0,0 +1,3 @@ +from .shd import SHDDevice + +__all__ = ["SHDDevice"] diff --git a/backend_py/src/services/cameras/drivers/shd/asic_interface.py b/backend_py/src/services/cameras/drivers/shd/asic_interface.py new file mode 100644 index 00000000..b49fb43c --- /dev/null +++ b/backend_py/src/services/cameras/drivers/shd/asic_interface.py @@ -0,0 +1,163 @@ +""" +asic_interface.py + +Defines low level read/write functions to interact with the SHD ASIC and +sensor registers. +""" + +import struct +import threading + +from ..video4linux import Camera +from ..xu import Selector, StellarRegisterMap, Unit + + +class ASICInterface: + """ + Class for low level read/write functions to interact with the SHD ASIC and + sensor registers. + """ + + def __init__(self, camera: Camera) -> None: + self.camera = camera + self._lock = threading.RLock() + + def asic_write(self, addr: int, data: int, dummy: bool = False) -> int: + """ + Write a value to an ASIC register. + """ + with self._lock: + unit = Unit.SYS_ID + selector = Selector.SYS_ASIC_RW + size = 4 + + # Dummy writes are used for asic reading + write_mode = 0xFF if dummy else 0 + # Little endian unsigned short (asic address), byte (data), + # byte (write mode: 0 = normal, 0xFF = dummy) + ctrl_data = struct.pack(" tuple[int, int]: + """ + Read a value from an ASIC register. + + Returns 0 on success, along with the value read. (ioctl style) + On failure, returns a non-zero error code and -1 as the value. + """ + with self._lock: + # perform a dummy write to select the correct address + ret = self.asic_write(addr, 0, True) + if ret != 0: + return (ret, -1) + + unit = Unit.SYS_ID + selector = Selector.SYS_ASIC_RW + size = 4 + + # address, data, dummy read + ctrl_data = struct.pack(" None: + val_low = value & 0xFF + # TODO: return after first fails... (we dont even use the status anyway) + _ret = self.asic_write(addr_low, val_low) + + val_high = value >> 8 & 0xFF + _ret = self.asic_write(addr_high, val_high) + + def asic_read_high_low( + self, + addr_high: int, + addr_low: int, + ) -> int | None: + ret, val_high = self.asic_read(addr_high) + ret, val_low = self.asic_read(addr_low) + if ret != 0: + return None + return val_high << 8 | val_low + + def sensor_write(self, reg: int, val: int) -> int: + """ + Write a value to a sensor register. + """ + + with self._lock: + high = (reg >> 8) & 0xFF + low = reg & 0xFF + + ret = 0 + + # Set address high + ret |= self.asic_write(StellarRegisterMap.REG_ADDR_H, high) + # Set address low + ret |= self.asic_write(StellarRegisterMap.REG_ADDR_L, low) + # Set data + ret |= self.asic_write(StellarRegisterMap.REG_DATA, val) + # Set mode to write ('W' = 0x57) + ret |= self.asic_write(StellarRegisterMap.REG_MODE, 0x57) + # Trigger the command (0x55) + ret |= self.asic_write(StellarRegisterMap.REG_TRIG, 0x55) + + return ret + + def sensor_read(self, reg: int) -> tuple[int, int]: + """ + Read a value from the sensor. + + Returns 0 on success, along with the value read. (ioctl style) + On failure, returns a non-zero error code and -1 as the value. + """ + high = (reg >> 8) & 0xFF + low = reg & 0xFF + + ret = 0 + + # Set address high + ret |= self.asic_write(StellarRegisterMap.REG_ADDR_H, high) + # Set address low + ret |= self.asic_write(StellarRegisterMap.REG_ADDR_L, low) + # Set mode to write ('R' = 0x52) + ret |= self.asic_write(StellarRegisterMap.REG_MODE, 0x52) + # Trigger the command (0x55) + ret |= self.asic_write(StellarRegisterMap.REG_TRIG, 0x55) + + if ret != 0: + return ret, -1 + + ret, val = self.asic_read(StellarRegisterMap.REG_DATA) + + return ret, val + + def sensor_write_high_low(self, reg_high: int, reg_low: int, value: int) -> None: + """ + Write high byte from value to high register, low byte to low + """ + self.sensor_write(reg_high, (value >> 8) & 0xFF) + # This is extremely scuffed: switch to waiting for + # trigger register before release (See below) + # time.sleep(0.1) + + # Maybe: add check for success (0xAA in REG_TRIG) + # REG_TRIG actually seems to not work properly, so maybe + # we find another alternative + self.sensor_write(reg_low, value & 0xFF) + + def sensor_read_high_low(self, reg_high, reg_low) -> int | None: + """ + Read high byte from high register to value, low byte to low + """ + ret, high = self.sensor_read(reg_high) + if ret != 0: + return None + ret, low = self.sensor_read(reg_low) + if ret != 0: + return None + + return (high << 8) | (low & 0xFF) diff --git a/backend_py/src/services/cameras/drivers/shd/options.py b/backend_py/src/services/cameras/drivers/shd/options.py new file mode 100644 index 00000000..9ad33fbb --- /dev/null +++ b/backend_py/src/services/cameras/drivers/shd/options.py @@ -0,0 +1,198 @@ +""" +options.py +""" + +from abc import abstractmethod + +from backend_py.src.models import ControlFlagsModel, ControlTypeEnum + +from ..options import BaseOption +from ..xu import StellarRegisterMap, StellarSensorMap +from .asic_interface import ASICInterface + + +class ASICOption(BaseOption): + """ """ + + def __init__( + self, name: str, control_flags: ControlFlagsModel, interface: ASICInterface + ) -> None: + super().__init__(name, control_flags) + + self._cached = control_flags.default_value + self._interface = interface + + @abstractmethod + def _write(self, value: int | float | bool) -> None: + # TODO: add checks for value type + pass + + @abstractmethod + def _read(self) -> int | float | bool | None: + # TODO: add checks for value type + pass + + def set_value(self, value: int | float | bool) -> None: + value = int(value) + self._cached = value + self._write(value) + + def get_value(self) -> int | float | bool: + return self._cached + + +class AutoExposureOption(ASICOption): + def __init__(self, asic_interface: ASICInterface) -> None: + super().__init__( + "Auto Exposure (ASIC)", + ControlFlagsModel( + default_value=1, + max_value=1, + min_value=0, + step=1, + control_type=ControlTypeEnum.BOOLEAN, + ), + asic_interface, + ) + + def _write(self, value: int | float | bool) -> None: + self._interface.asic_write(StellarRegisterMap.REG_AE, int(value)) + + def _read(self) -> bool | None: + ae, ret = self._interface.asic_read(StellarRegisterMap.REG_AE) + + return bool(ae) if ret == 0 else None + + +class ASICHighLowOption(ASICOption): + def __init__( + self, + name: str, + control_flags: ControlFlagsModel, + asic_interface: ASICInterface, + high_register: int, + low_register: int, + ) -> None: + super().__init__( + name, + control_flags, + asic_interface, + ) + + self.high_register = high_register + self.low_register = low_register + + def _write(self, value: int | float | bool) -> None: + # TODO: Require int + self._interface.asic_write_high_low( + self.high_register, self.low_register, int(value) + ) + + def _read(self) -> int | None: + return self._interface.asic_read_high_low(self.high_register, self.low_register) + + +class HardwareBitrateOption(ASICHighLowOption): + def __init__( + self, + asic_interface: ASICInterface, + ) -> None: + super().__init__( + "Hardware Bitrate", + ControlFlagsModel( + default_value=13000, + max_value=13000, + min_value=100, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + asic_interface, + StellarRegisterMap.REG_HW_BITRATE_HIGH, + StellarRegisterMap.REG_HW_BITRATE_LOW, + ) + + def _write(self, value: int | float | bool) -> None: + super()._write(value) + + # Trigger the bitrate value + self._interface.asic_write(StellarRegisterMap.REG_HW_BITRATE_TRIG, 1) + + +class SensorHighLowOption(ASICOption): + def __init__( + self, + name: str, + control_flags: ControlFlagsModel, + asic_interface: ASICInterface, + high_register: int, + low_register: int, + ) -> None: + super().__init__( + name, + control_flags, + asic_interface, + ) + + self.high_register = high_register + self.low_register = low_register + + def _write(self, value: int | float | bool) -> None: + self._interface.sensor_write_high_low( + self.high_register, self.low_register, int(value) + ) + + def _read(self) -> int | float | bool | None: + return self._interface.sensor_read_high_low( + self.high_register, self.low_register + ) + + +class ShutterSpeedOption(SensorHighLowOption): + def __init__(self, asic_interface: ASICInterface) -> None: + super().__init__( + "Shutter Speed", + ControlFlagsModel( + default_value=100, + max_value=8000, + min_value=10, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + asic_interface, + StellarSensorMap.SHUTTER_HIGH, + StellarSensorMap.SHUTTER_LOW, + ) + + +class GainOption(SensorHighLowOption): + def __init__(self, asic_interface: ASICInterface) -> None: + super().__init__( + "ISO", + ControlFlagsModel( + default_value=400, + max_value=4095, + min_value=0, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + asic_interface, + StellarSensorMap.SHUTTER_HIGH, + StellarSensorMap.SHUTTER_LOW, + ) + + +class StrobeWidthOption(SensorHighLowOption): + def __init__(self, asic_interface: ASICInterface) -> None: + super().__init__( + "Strobe Width", + ControlFlagsModel( + default_value=0, + max_value=4095, + min_value=0, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + asic_interface, + StellarSensorMap.STROBE_WIDTH_HIGH, + StellarSensorMap.STROBE_WIDTH_LOW, + ) diff --git a/backend_py/src/services/cameras/drivers/shd/shd.py b/backend_py/src/services/cameras/drivers/shd/shd.py new file mode 100644 index 00000000..58fd2a0c --- /dev/null +++ b/backend_py/src/services/cameras/drivers/shd/shd.py @@ -0,0 +1,170 @@ +""" +shd.py + +Adds additional features to stellarHD devices +""" + +from ..device import Device, DeviceMetadata +from ..video4linux import DeviceInfo +from .asic_interface import ASICInterface +from .options import ( + AutoExposureOption, + GainOption, + ShutterSpeedOption, + StrobeWidthOption, + HardwareBitrateOption, +) + + +class SHDDevice(Device): + """ + Class for stellarHD devices + """ + + def __init__( + self, device_info: DeviceInfo, device_metadata: DeviceMetadata + ) -> None: + # Specifies if SHD device is Stellar Pro + # For now, we can just assume this is true. + # Warn user to not use settings on incompatible devices? + self.is_pro = True + + super().__init__(device_info, device_metadata) + + # Copy MJPEG over to Software H264, since they are the same thing + mjpg_camera = self.find_camera_with_format("MJPG") + if not mjpg_camera: + raise RuntimeError( + "Failed to initialize stellarHD: MJPG camera format not found." + ) + mjpg_camera.formats["SOFTWARE_H264"] = mjpg_camera.formats["MJPG"] + + # List of followers + # Zero inherent truth to the existence of these devices + self.followers: list[str] = [] + + # These exist + self.follower_devices: list[SHDDevice] = [] + + # Is true if it is managed, false otherwise + self.is_managed = False + + # ASIC Interface for low level register read/writes + self.asic_interface = ASICInterface(self.cameras[0]) + + # options + + self._options = { + "auto_exposure": AutoExposureOption(self.asic_interface), + "shutter": ShutterSpeedOption(self.asic_interface), + "iso": GainOption(self.asic_interface), + "strobe_width": StrobeWidthOption(self.asic_interface), + "hw_bitrate": HardwareBitrateOption(self.asic_interface), + } + + self.add_control_from_option("auto_exposure") + self.add_control_from_option("shutter") + self.add_control_from_option("iso") + self.add_control_from_option("strobe_width") + self.add_control_from_option("hw_bitrate") + + def add_follower(self, device: "SHDDevice") -> None: + if device.bus_info in self.followers: + self.logger.info( + "Trying to add follower to device that already has this device as a " + "follower. Ignoring request." + ) + return + + if device.bus_info == self.bus_info: + self.logger.info( + "Trying to add follower of same bus id as self. This is not allowed." + ) + return + + self.logger.info("Adding follower") + + # For saving purposes + self.followers.append(device.bus_info) + + # This is the real addition + self.follower_devices.append(device) + + # Make the follower managed + device.set_is_managed(True) + + if self.stream.enabled: + self.start_stream() + + def remove_follower(self, device: "SHDDevice") -> None: + if device.bus_info not in self.followers: + self.logger.info( + "Cannot remove follower from device that does not contain it." + ) + return + # Reconstruct the list without the follower + self.followers = [dev for dev in self.followers if dev != device.bus_info] + self.follower_devices = [ + dev for dev in self.follower_devices if dev.bus_info != device.bus_info + ] + + device.set_is_managed(False) + + self.logger.info("Removing follower") + + if self.stream.enabled: + self.start_stream() + + def remove_manual(self, follower_bus_info: str) -> None: + """ + This should be called in the case the follower no longer exists + """ + self.followers.remove(follower_bus_info) + + def set_is_managed(self, is_managed: bool) -> None: + self.is_managed = is_managed + + # Configure stream if needbe + if not is_managed and self.stream.enabled: + self.start_stream() + + def start_stream(self) -> None: + if self.is_managed: + self.logger.warning( + f"{self.bus_info}: Cannot start stream that is managed." + ) + return + + self.stream_runner.streams = [self.stream] + + for follower_device in self.follower_devices: + # A not so hacky fix (very clever :]) to ensure the stream's device_path is + # set + follower_device.configure_stream( + self.stream.encode_type, + self.stream.width, + self.stream.height, + self.stream.interval, + self.stream.stream_type, + [], + ) + + # Append the new device stream + self.stream_runner.streams.append(follower_device.stream) + + # mbps to kbit/sec + # self.stream.software_h264_bitrate = + # int(self.bitrate_option.get_value() * 1000) + + 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, option in self._options.items(): + option.set_value(option.get_value()) diff --git a/backend_py/src/services/cameras/drivers/video4linux/__init__.py b/backend_py/src/services/cameras/drivers/video4linux/__init__.py new file mode 100644 index 00000000..fd70d50c --- /dev/null +++ b/backend_py/src/services/cameras/drivers/video4linux/__init__.py @@ -0,0 +1,5 @@ +from . import v4l2 +from .camera import Camera +from .enumeration import DeviceInfo, list_devices + +__all__ = ["Camera", "v4l2", "DeviceInfo", "list_devices"] diff --git a/backend_py/src/services/cameras/drivers/video4linux/camera.py b/backend_py/src/services/cameras/drivers/video4linux/camera.py new file mode 100644 index 00000000..517a1e69 --- /dev/null +++ b/backend_py/src/services/cameras/drivers/video4linux/camera.py @@ -0,0 +1,81 @@ +import fcntl + +from backend_py.src.models import FormatSizeModel, IntervalModel + +from ...camera_helper.camera_helper_loader import camera_helper +from ...stream_utils import fourcc2s +from . import v4l2 + + +class Camera: + """ + Camera base class + """ + + def __init__(self, path: str) -> None: + self.path = path + self._file_object = open(path) # noqa: SIM115 + self._fd = self._file_object.fileno() # get the file descriptor + self._get_formats() + + def close(self) -> None: + self._file_object.close() + + # uvc_set_ctrl function defined in uvc_functions.c + def uvc_set_ctrl(self, unit: int, ctrl: int, data: bytes, size: int) -> int: + return camera_helper.uvc_set_ctrl(self._fd, unit, ctrl, data, size) + + # uvc_get_ctrl function defined in uvc_functions.c + def uvc_get_ctrl(self, unit: int, ctrl: int, data: bytes, size: int) -> int: + return camera_helper.uvc_get_ctrl(self._fd, unit, ctrl, data, size) + + def has_format(self, pixformat: str) -> bool: + return pixformat in self.formats + + def _get_formats(self) -> None: + self.formats: dict[str, list[FormatSizeModel]] = {} + for i in range(1000): + v4l2_fmt = v4l2.v4l2_fmtdesc() + v4l2_fmt.index = i + v4l2_fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + try: + fcntl.ioctl(self._fd, v4l2.VIDIOC_ENUM_FMT, v4l2_fmt) + except OSError: + break + + format_sizes = [] + for j in range(1000): + frmsize = v4l2.v4l2_frmsizeenum() + frmsize.index = j + frmsize.pixel_format = v4l2_fmt.pixelformat + try: + fcntl.ioctl(self._fd, v4l2.VIDIOC_ENUM_FRAMESIZES, frmsize) + except OSError: + break + if frmsize.type == v4l2.V4L2_FRMSIZE_TYPE_DISCRETE: + format_size = FormatSizeModel( + width=frmsize.discrete.width, + height=frmsize.discrete.height, + intervals=[], + ) + for k in range(1000): + frmival = v4l2.v4l2_frmivalenum() + frmival.index = k + frmival.pixel_format = v4l2_fmt.pixelformat + frmival.width = frmsize.discrete.width + frmival.height = frmsize.discrete.height + try: + fcntl.ioctl( + self._fd, v4l2.VIDIOC_ENUM_FRAMEINTERVALS, frmival + ) + except OSError: # This is expected and/or possible + break + if frmival.type == v4l2.V4L2_FRMIVAL_TYPE_DISCRETE: + format_size.intervals.append( + IntervalModel( + numerator=frmival.discrete.numerator, + denominator=frmival.discrete.denominator, + ) + ) + format_sizes.append(format_size) + self.formats[fourcc2s(v4l2_fmt.pixelformat)] = format_sizes \ No newline at end of file diff --git a/backend_py/src/services/cameras/enumeration.py b/backend_py/src/services/cameras/drivers/video4linux/enumeration.py similarity index 97% rename from backend_py/src/services/cameras/enumeration.py rename to backend_py/src/services/cameras/drivers/video4linux/enumeration.py index ffd97e79..655c0fed 100644 --- a/backend_py/src/services/cameras/enumeration.py +++ b/backend_py/src/services/cameras/drivers/video4linux/enumeration.py @@ -28,7 +28,6 @@ def _get_device_attr(device_path, attr) -> str: with open(device_path + "/" + attr, encoding="utf-8") as file_object: return file_object.read().strip() - def _get_vid_pid(devname) -> tuple[int, int] | None: cam_name = devname syspath = "/sys/class/video4linux/" + cam_name @@ -86,8 +85,7 @@ def list_devices() -> list[DeviceInfo]: continue # flatten the dict - for bus_info in devices_map: - device_info = devices_map[bus_info] + for _, device_info in devices_map.items(): # sort the device paths in ascending order device_info.device_paths = natsorted(device_info.device_paths) devices_info.append(device_info) diff --git a/backend_py/src/services/cameras/v4l2.py b/backend_py/src/services/cameras/drivers/video4linux/v4l2.py similarity index 100% rename from backend_py/src/services/cameras/v4l2.py rename to backend_py/src/services/cameras/drivers/video4linux/v4l2.py diff --git a/backend_py/src/services/cameras/xu_controls.py b/backend_py/src/services/cameras/drivers/xu.py similarity index 65% rename from backend_py/src/services/cameras/xu_controls.py rename to backend_py/src/services/cameras/drivers/xu.py index 5d338f3b..24181c5f 100644 --- a/backend_py/src/services/cameras/xu_controls.py +++ b/backend_py/src/services/cameras/drivers/xu.py @@ -11,11 +11,21 @@ class Unit(Enum): - SYS_ID = 0x03 # In past was 0x02, but was unused. Was a mistake - USR_ID = 0x04 + """ + SYS_ID: used for camera controls that are asic level + + USR_ID: used for camera controls that are more user facing + """ + + SYS_ID = 0x03 # Internal controls for the camera's ASIC + USR_ID = 0x04 # User controls for the camera class Selector(Enum): + """ + Selectors for the camera extension unit controls + """ + SYS_ASIC_RW = 0x01 SYS_FLASH_CTRL = 0x03 SYS_FRAME_INFO = 0x06 @@ -36,12 +46,21 @@ class Selector(Enum): class Command(Enum): + """ + Commands for the exploreHD extension unit controls + """ + H264_BITRATE_CTRL = 0x02 GOP_CTRL = 0x03 H264_MODE_CTRL = 0x06 -class StellarRegisterMap(Enum): +class StellarRegisterMap: + """ + Map of stellar registers for the ASIC, + which can be accessed through the SYS_ASIC_RW selector + """ + REG_AE = 0x1673 REG_ADDR_H = 0x1674 REG_ADDR_L = 0x1675 @@ -55,6 +74,10 @@ class StellarRegisterMap(Enum): class StellarSensorMap: + """ + Map of sensor registers for stellar devices, which can be accessed through the ASIC + """ + SHUTTER_HIGH = 0x3501 SHUTTER_LOW = 0x3502 ISO_HIGH = 0x3508 diff --git a/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py deleted file mode 100644 index 2fb061b3..00000000 --- a/backend_py/src/services/cameras/ehd.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -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 cast - -from src.models import H264Mode - -from . import xu_controls as xu -from .device import BaseOption, ControlTypeEnum, Device, Option -from .enumeration import DeviceInfo - - -class EHDDevice(Device): - """ - Class for exploreHD devices - """ - - def __init__(self, device_info: DeviceInfo) -> None: - super().__init__(device_info) - - self.add_control_from_option("vbr", False, ControlTypeEnum.BOOLEAN) - - self.add_control_from_option("gop", 29, ControlTypeEnum.INTEGER, 29, 0, 1) - - self.add_control_from_option( - "bitrate", 10, ControlTypeEnum.INTEGER, 15, 0.1, 0.1 - ) - - def _get_options(self) -> dict[str, BaseOption]: - options = {} - - # UVC xu bitrate control - # 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( - round(bitrate * 1000000) - ), # convert to bps from mpbs (round for float imprecision) - # convert to mpbs from bps - lambda bitrate: cast(int, bitrate) / 1000000.0, - ) - - # UVC xu gop control - options["gop"] = Option( - self.cameras[2], - "H", - xu.Unit.USR_ID, - xu.Selector.USR_H264_CTRL, - xu.Command.GOP_CTRL, - "Group of Pictures", - ) - - # UVC xu H264 mode control - # We want the mode option to be true or false - # true indicates variable bitrate and false indicates constant bitrate - # 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, - ) - - return options diff --git a/backend_py/src/services/cameras/pwm/serial_pwm_controller.py b/backend_py/src/services/cameras/pwm/serial_pwm_controller.py index ed6b326f..76e563db 100644 --- a/backend_py/src/services/cameras/pwm/serial_pwm_controller.py +++ b/backend_py/src/services/cameras/pwm/serial_pwm_controller.py @@ -111,7 +111,6 @@ def apply(self, frequency: float, duty_cycle: int) -> None: self.frequency = frequency self.duty_cycle = duty_cycle if not self.found_port: - self.logger.info("No connected USB serial PWM controller") return command = f"{frequency + self.frequency_offset},{duty_cycle}\n" self.logger.info(f"Sending command {command.strip()}") diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py deleted file mode 100644 index 74b25199..00000000 --- a/backend_py/src/services/cameras/shd.py +++ /dev/null @@ -1,639 +0,0 @@ -""" -shd.py - -Adds additional features to stellarHD devices -""" - -import collections -import logging -import queue -import struct -import threading -import time -from collections.abc import Callable -from enum import Enum -from typing import Any - -from event_emitter import EventEmitter - -from src.models import SavedDeviceModel - -from . import xu_controls as xu -from .device import BaseOption, ControlTypeEnum, Device, StreamEncodeTypeEnum -from .enumeration import DeviceInfo - - -def get_val(addr: Enum | int) -> int: - if isinstance(addr, Enum): - return addr.value - return addr - - -class StorageOption(BaseOption, EventEmitter): - 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: int | float) -> None: - self.value = value - self.emit("value_changed") - - def get_value(self) -> int | float: - return self.value - - -class CustomOption(BaseOption): - def __init__( - self, - 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.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: - if self.is_integer_only: - value = int(value) - self.setter(value) - self.value = value - - def get_value(self) -> int | float | None: - return self.value - - -class SHDDevice(Device): - """ - Class for stellarHD devices - """ - - ASIC_COMMAND_DELAY = 0.001 - - def __init__(self, device_info: DeviceInfo) -> None: - # Specifies if SHD device is Stellar Pro - self.is_pro = True # self.pid == 0x6369 - - self._command_queue = collections.deque() - self._queue_lock = threading.Lock() - self._queue_cond = threading.Condition(self._queue_lock) - - self._asic_worker_running = True - self._asic_thread = threading.Thread( - target=self._asic_command_worker, daemon=True - ) - self._asic_thread.start() - - super().__init__(device_info) - - # Copy MJPEG over to Software H264, since they are the same thing - mjpg_camera = self.find_camera_with_format("MJPG") - if not mjpg_camera: - raise RuntimeError( - "Failed to initialize stellarHD: MJPG camera format not found." - ) - mjpg_camera.formats["SOFTWARE_H264"] = mjpg_camera.formats["MJPG"] - - # List of followers - # Zero inherent truth to the existence of these devices - self.followers: list[str] = [] - - # These exist - self.follower_devices: list[SHDDevice] = [] - - # Is true if it is managed, false otherwise - self.is_managed = False - - self.add_control_from_option( - "bitrate", 5, ControlTypeEnum.INTEGER, 10, 0.1, 0.1 - ) - - if self.is_pro: - self.add_control_from_option( - "shutter", 100, ControlTypeEnum.INTEGER, 8000, 10, 1 - ) - - self.add_control_from_option("ae", False, ControlTypeEnum.BOOLEAN) - - self.add_control_from_option( - "iso", 400, ControlTypeEnum.INTEGER, 4095, 0, 1 - ) - - self.add_control_from_option( - "strobe_width", 0, ControlTypeEnum.INTEGER, 4095, 0, 1 - ) - - self.add_control_from_option( - "hw_bitrate", 5000, ControlTypeEnum.INTEGER, 13000, 0, 1 - ) - - # self.add_control_from_option( - # 'strobe_enabled', False, ControlTypeEnum.BOOLEAN) - - def _asic_command_worker(self) -> None: - """ - Background worker that processes ASIC/Sensor commands sequentionally - """ - while self._asic_worker_running: - task = None - - with self._queue_cond: - # Wait for work - while not self._command_queue and self._asic_worker_running: - self._queue_cond.wait() - - if not self._asic_worker_running: - break - - # Get the next task - task = self._command_queue.popleft() - - if task: - key, func, args, result_queue = task - try: - res = func(*args) - if result_queue: - result_queue.put(res) - except Exception as e: - self.logger.error(f"Error executing ASIC command ({key}): {e}") - if result_queue: - result_queue.put(None) - - # Enforce hardware delay - time.sleep(self.ASIC_COMMAND_DELAY) - - def _run_asic_command( - self, key: str | None, func: Callable, args: tuple, wait: bool = True - ) -> Any: - """ - Helper to submit a command to the queue and wait for the result synchronously. - """ - if not self._asic_worker_running: - return None - - result_queue = queue.Queue() if wait else None - - with self._queue_cond: - if key is not None and any(item[0] == key for item in self._command_queue): - # Filter out previous pending commands of the same type - # This implements the "ignore previous requests" logic - # We rebuild the deque without the matching keys - - # Check if we even need to filter to avoid list overhead - # Filter existing items. - # Note: We only drop items that don't have a result_queue waiting - # (though in this design, keyed items are usually fire-and-forget - # writes) - new_queue = collections.deque() - while self._command_queue: - item = self._command_queue.popleft() - existing_key, _, _, existing_result_q = item - - # If keys match, we drop the OLD one. - # Ideally, we only drop if no one is waiting on it (wait=False). - # If wait=True, we probably shouldn't drop it, or we should - # send None to the queue. - if existing_key == key: - if existing_result_q: - # If something was waiting on the old command, - # release it - existing_result_q.put(None) - # Item is dropped - continue - - new_queue.append(item) - self._command_queue = new_queue - - # Add the new command to the end - self._command_queue.append((key, func, args, result_queue)) - self._queue_cond.notify() - - if wait and result_queue: - return result_queue.get() - return None - - def add_follower(self, device: "SHDDevice") -> None: - if device.bus_info in self.followers: - self.logger.info( - "Trying to add follower to device that already has this device as a " - "follower. Ignoring request." - ) - return - - if device.bus_info == self.bus_info: - self.logger.info( - "Trying to add follower of same bus id as self. This is not allowed." - ) - return - - self.logger.info("Adding follower") - - # For saving purposes - self.followers.append(device.bus_info) - - # This is the real addition - self.follower_devices.append(device) - - # Make the follower managed - device.set_is_managed(True) - - if self.stream.enabled: - self.start_stream() - - def remove_follower(self, device: "SHDDevice") -> None: - if device.bus_info not in self.followers: - self.logger.info( - "Cannot remove follower from device that does not contain it." - ) - return - # Reconstruct the list without the follower - self.followers = [dev for dev in self.followers if dev != device.bus_info] - self.follower_devices = [ - dev for dev in self.follower_devices if dev.bus_info != device.bus_info - ] - - device.set_is_managed(False) - - self.logger.info("Removing follower") - - if self.stream.enabled: - self.start_stream() - - # ASIC stuff - # Sensor writes are not supported by all firmwares - # Only recent stellarHD firmware, no exploreHD firmware - - # but explore does support asic writes as well - - def _sensor_write_high_low(self, reg_high: int, reg_low: int, value: int) -> None: - """ - Write high byte from value to high register, low byte to low - """ - self._sensor_write(reg_high, (value >> 8) & 0xFF) - # This is extremely scuffed: switch to waiting for - # trigger register before release (See below) - time.sleep(0.1) - self._sensor_write(reg_low, value & 0xFF) - - # TODO: add check for success (0xAA in REG_TRIG) - - def _sensor_read_high_low(self, reg_high, reg_low) -> int | None: - """ - Read high byte from high register to value, low byte to low - """ - ret, high = self._sensor_read(reg_high) - if ret != 0: - return None - ret, low = self._sensor_read(reg_low) - if ret != 0: - return None - - return (high << 8) | (low & 0xFF) - - def _sensor_write(self, reg: int, val: int) -> int: - high = (reg >> 8) & 0xFF - low = reg & 0xFF - - ret = 0 - - # # Disable auto exposure - # ret |= self._asic_write(xu.StellarRegisterMap.REG_AE, 0x00) - # Set address high - ret |= self._asic_write(xu.StellarRegisterMap.REG_ADDR_H, high) - # Set address low - ret |= self._asic_write(xu.StellarRegisterMap.REG_ADDR_L, low) - # Set data - ret |= self._asic_write(xu.StellarRegisterMap.REG_DATA, val) - # Set mode to write ('W' = 0x57) - ret |= self._asic_write(xu.StellarRegisterMap.REG_MODE, 0x57) - # Trigger the command (0x55) - ret |= self._asic_write(xu.StellarRegisterMap.REG_TRIG, 0x55) - - return ret - - def _sensor_read(self, reg: int) -> tuple[int, int]: - high = (reg >> 8) & 0xFF - low = reg & 0xFF - - ret = 0 - - # # Disable auto exposure - # ret |= self._asic_write(xu.StellarRegisterMap.REG_AE, 0x00) - # Set address high - ret |= self._asic_write(xu.StellarRegisterMap.REG_ADDR_H, high) - # Set address low - ret |= self._asic_write(xu.StellarRegisterMap.REG_ADDR_L, low) - # Set mode to write ('R' = 0x52) - ret |= self._asic_write(xu.StellarRegisterMap.REG_MODE, 0x52) - # Trigger the command (0x55) - ret |= self._asic_write(xu.StellarRegisterMap.REG_TRIG, 0x55) - - if ret != 0: - return ret, -1 - - ret, val = self._asic_read(xu.StellarRegisterMap.REG_DATA) - - return ret, val - - def _asic_write( - self, addr: int | xu.StellarRegisterMap, data: int, dummy: bool = False - ) -> int: - unit = xu.Unit.SYS_ID - selector = xu.Selector.SYS_ASIC_RW - # Accept enum - addr_val = get_val(addr) - size = 4 - - # Dummy writes are used for asic reading - write_mode = 0xFF if dummy else 0 - # Little endian unsigned short (asic address), byte (data), - # byte (write mode: 0 = normal, 0xFF = dummy) - ctrl_data = struct.pack(" tuple[int, int]: - addr_val = get_val(addr) - - # perform a dummy write to select the correct address - ret = self._asic_write(addr_val, 0, True) - if ret != 0: - return (ret, -1) - - unit = xu.Unit.SYS_ID - selector = xu.Selector.SYS_ASIC_RW - size = 4 - - # address, data, dummy read - ctrl_data = struct.pack(" int: - val_low = value & 0xFF - # TODO: return after first fails... (we dont even use the status anyway) - ret = self._asic_write(addr_low, val_low) - - val_high = value >> 8 & 0xFF - ret = self._asic_write(addr_high, val_high) - return ret - - def _asic_read_high_low( - self, - addr_high: int | xu.StellarRegisterMap, - addr_low: int | xu.StellarRegisterMap, - ) -> int | None: - ret, val_high = self._asic_read(addr_high) - ret, val_low = self._asic_read(addr_low) - if ret != 0: - return None - return val_high << 8 | val_low - - def remove_manual(self, follower_bus_info: str) -> None: - """ - This should be called in the case the follower no longer exists - """ - self.followers.remove(follower_bus_info) - - def set_is_managed(self, is_managed: bool) -> None: - self.is_managed = is_managed - - # Configure stream if needbe - if not is_managed and self.stream.enabled: - self.start_stream() - - # This goes against the architecture created in the exploreHD - # When we designed that, it was preferred to not have any functions that could - # control asic values. - # TODO: FIXME - def set_shutter_speed(self, value: int) -> None: - self._run_asic_command( - "shutter", - self._sensor_write_high_low, - ( - xu.StellarSensorMap.SHUTTER_HIGH, - xu.StellarSensorMap.SHUTTER_LOW, - int(value), - ), - wait=False, - ) - - def get_shutter_speed(self) -> int | None: - return self._run_asic_command( - None, - self._sensor_read_high_low, - (xu.StellarSensorMap.SHUTTER_HIGH, xu.StellarSensorMap.SHUTTER_LOW), - wait=True, - ) - - def set_iso(self, value: int) -> None: - self._run_asic_command( - "iso", - self._sensor_write_high_low, - (xu.StellarSensorMap.ISO_HIGH, xu.StellarSensorMap.ISO_LOW, int(value)), - wait=False, - ) - - def get_iso(self) -> int | None: - return self._run_asic_command( - None, - self._sensor_read_high_low, - (xu.StellarSensorMap.ISO_HIGH, xu.StellarSensorMap.ISO_LOW), - wait=True, - ) - - def set_asic_ae(self, enabled: bool) -> None: - self._run_asic_command( - "ae", - self._asic_write, - (xu.StellarRegisterMap.REG_AE, 0x01 if enabled else 0x00), - wait=False, - ) - - def get_asic_ae(self) -> bool | None: - # We can run asic read commands without worrying, since they don't write to the - # camera. - besides dummy writes - ret, val = self._asic_read(xu.StellarRegisterMap.REG_AE) - if ret != 0: - return None - return val == 0x01 - - def set_strobe_width(self, value: int) -> None: - self._run_asic_command( - "strobe", - self._sensor_write_high_low, - ( - xu.StellarSensorMap.STROBE_WIDTH_HIGH, - xu.StellarSensorMap.STROBE_WIDTH_LOW, - int(value), - ), - wait=False, - ) - - def get_strobe_width(self) -> int | None: - return self._run_asic_command( - None, - self._sensor_read_high_low, - ( - xu.StellarSensorMap.STROBE_WIDTH_HIGH, - xu.StellarSensorMap.STROBE_WIDTH_LOW, - ), - wait=True, - ) - - def set_hw_bitrate(self, value: int) -> None: - self._run_asic_command( - "hw_bitrate", - self._asic_write_high_low, - ( - xu.StellarRegisterMap.REG_HW_BITRATE_HIGH, - xu.StellarRegisterMap.REG_HW_BITRATE_LOW, - value, - ), - ) - self._run_asic_command( - "hw_bitrate", - self._asic_write, - (xu.StellarRegisterMap.REG_HW_BITRATE_TRIG, 1), - ) - - def get_hw_bitrate(self) -> int | None: - return self._run_asic_command( - "hw_bitrate", - self._asic_read_high_low, - ( - xu.StellarRegisterMap.REG_HW_BITRATE_HIGH, - xu.StellarRegisterMap.REG_HW_BITRATE_LOW, - ), - wait=True, - ) - - def _get_options(self) -> dict[str, BaseOption]: - options = {} - - self.bitrate_option = StorageOption("Software H.264 Bitrate", 5) # 5 mpbs - - def update_bitrate() -> None: - if ( - self.stream.enabled - and self.stream.encode_type == StreamEncodeTypeEnum.SOFTWARE_H264 - ): - self.start_stream() - - # Only restart if it's being used - self.bitrate_option.on( - "value_changed", - update_bitrate, - ) - - options["bitrate"] = self.bitrate_option - - if self.is_pro: - options["ae"] = CustomOption( - "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, - default_value=10, - ) - - # UVC ISO control - 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, - default_value=0, - ) - - options["hw_bitrate"] = CustomOption( - "HW Bitrate", - self.set_hw_bitrate, - self.get_hw_bitrate, - default_value=13000, - ) - - return options - - def load_settings(self, saved_device: SavedDeviceModel) -> None: - super().load_settings(saved_device) - - def start_stream(self) -> None: - if self.is_managed: - self.logger.warning( - f"{self.bus_info}: Cannot start stream that is managed." - ) - return - - self.stream_runner.streams = [self.stream] - - for follower_device in self.follower_devices: - # A not so hacky fix (very clever :]) to ensure the stream's device_path is - # set - follower_device.configure_stream( - self.stream.encode_type, - self.stream.width, - self.stream.height, - self.stream.interval, - self.stream.stream_type, - [], - ) - - # Append the new device stream - self.stream_runner.streams.append(follower_device.stream) - - # mbps to kbit/sec - self.stream.software_h264_bitrate = int(self.bitrate_option.get_value() * 1000) - - 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/gstreamer_stream_engine.py b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py index 607ba58d..020aebc0 100644 --- a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py @@ -5,7 +5,7 @@ import threading from datetime import datetime -from src.models import StreamEncodeTypeEnum, StreamTypeEnum +from backend_py.src.models import StreamEncodeTypeEnum, StreamTypeEnum from .base_stream_engine import BaseStreamEngine from .stream import Stream @@ -150,7 +150,7 @@ def start(self) -> None: with self._lock: self.logger.info( "Starting stream for devices: " - f"{[stream.device_path for stream in self.streams]}" + f"{', '.join([stream.device_path for stream in self.streams])}" ) if self.started: self.stop() diff --git a/backend_py/src/services/cameras/stream_engines/stream.py b/backend_py/src/services/cameras/stream_engines/stream.py index 50ea13c1..b50784ac 100644 --- a/backend_py/src/services/cameras/stream_engines/stream.py +++ b/backend_py/src/services/cameras/stream_engines/stream.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field -from src.models import ( +from backend_py.src.models import ( IntervalModel, StreamEncodeTypeEnum, StreamEndpointModel, 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 0ce16d2c..8679a4d2 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 @@ -4,9 +4,13 @@ import threading import time -from src.models import StreamEndpointModel +from backend_py.src.models import StreamEndpointModel +from backend_py.src.services.cameras.synchronized_camera import ( + CopiedFrame, + SynchronizedCamera, + V4L2Camera, +) -from ..synchronized_camera import CopiedFrame, SynchronizedCamera, V4L2Camera from .base_stream_engine import BaseStreamEngine diff --git a/backend_py/src/services/cameras/stream_runner.py b/backend_py/src/services/cameras/stream_runner.py index e8142554..e09511dd 100644 --- a/backend_py/src/services/cameras/stream_runner.py +++ b/backend_py/src/services/cameras/stream_runner.py @@ -53,7 +53,7 @@ def _on_engine_error(self, error_data) -> None: def start(self) -> None: with self._lock: self.logger.info( - f"Starting streams: {[s.device_path for s in self.streams]}" + f"Starting streams: {','.join([s.device_path for s in self.streams])}" ) if self.started: self.stop() diff --git a/backend_py/src/services/cameras/stream_utils.py b/backend_py/src/services/cameras/stream_utils.py index f79e4b58..ce4afb2c 100644 --- a/backend_py/src/services/cameras/stream_utils.py +++ b/backend_py/src/services/cameras/stream_utils.py @@ -1,4 +1,4 @@ -from src.models import StreamEncodeTypeEnum +from backend_py.src.models import StreamEncodeTypeEnum def fourcc2s(fourcc: int) -> str: diff --git a/backend_py/src/services/cameras/synchronized_camera/lib.py b/backend_py/src/services/cameras/synchronized_camera/lib.py index 8ea7f486..2525ef58 100644 --- a/backend_py/src/services/cameras/synchronized_camera/lib.py +++ b/backend_py/src/services/cameras/synchronized_camera/lib.py @@ -13,7 +13,7 @@ from event_emitter import EventEmitter -from .. import v4l2 +from ..drivers.video4linux import v4l2 @dataclass @@ -336,7 +336,7 @@ def grab(self) -> list[CopiedFrame] | None: else: # Drop the earliest frame (smallest timestamp) and try again min_index = timestamps.index(min_ts) - self.logger.info(f"Dropping frame of difference: {max_ts - min_ts}") + # self.logger.info(f"Dropping frame of difference: {max_ts - min_ts}") self.queues[min_index].popleft() self.emit("frame_drop") diff --git a/backend_py/src/services/network/async_network_manager.py b/backend_py/src/services/network/async_network_manager.py index 98e2b76a..4d53b09f 100644 --- a/backend_py/src/services/network/async_network_manager.py +++ b/backend_py/src/services/network/async_network_manager.py @@ -5,6 +5,7 @@ from typing import Any import sdbus +from backend_py.src.models import IPV4Address, IPV4Configuration, IPV4Method from event_emitter import EventEmitter from sdbus.utils.inspect import inspect_dbus_path from sdbus_async.networkmanager import ( @@ -24,8 +25,6 @@ DeviceCapabilities as Capabilities, ) -from src.models import IPV4Address, IPV4Configuration, IPV4Method - # ip to integer and reverse: https://stackoverflow.com/a/13294427 diff --git a/backend_py/src/services/network/nm_wrapper.py b/backend_py/src/services/network/nm_wrapper.py index ac9e9863..c12fb7b7 100644 --- a/backend_py/src/services/network/nm_wrapper.py +++ b/backend_py/src/services/network/nm_wrapper.py @@ -3,10 +3,9 @@ import time import socketio +from backend_py.src.models import ConnectionProfileModel, WiredDeviceModel from event_emitter import EventEmitter -from src.models import ConnectionProfileModel, WiredDeviceModel - from .async_network_manager import ( AsyncNetworkManager, IPV4Configuration, @@ -27,7 +26,7 @@ def __init__(self, sio: socketio.AsyncServer) -> None: @self.sio.on("connect") # type: ignore def on_connect(sid, environ) -> None: - self.logger.info(f"Connection detected: {sid}") + # self.logger.info(f"Connection detected: {sid}") self.last_connection_time = time.time() self.nm.on("profile_updated", lambda profile: self._refresh_ui()) diff --git a/backend_py/src/services/preferences/preferences_manager.py b/backend_py/src/services/preferences/preferences_manager.py index 4f2226dd..b88ce2b7 100644 --- a/backend_py/src/services/preferences/preferences_manager.py +++ b/backend_py/src/services/preferences/preferences_manager.py @@ -11,10 +11,9 @@ import pathlib import threading +from backend_py.src.models import SavedPreferencesModel from event_emitter import events -from src.models import SavedPreferencesModel - class PreferencesManager(events.EventEmitter): def __init__(self, settings_path: str = ".") -> None: diff --git a/backend_py/src/services/preferences/settings_manager.py b/backend_py/src/services/preferences/settings_manager.py index 4886ad96..59a4204b 100644 --- a/backend_py/src/services/preferences/settings_manager.py +++ b/backend_py/src/services/preferences/settings_manager.py @@ -11,11 +11,15 @@ import threading from typing import cast -from src.models import DeviceType, SavedDeviceModel, SavedLeaderFollowerPairModel +from backend_py.src.models import ( + DeviceType, + SavedDeviceModel, + SavedLeaderFollowerPairModel, +) -from ..cameras.device import Device from ..cameras.device_utils import find_device_with_bus_info -from ..cameras.shd import SHDDevice +from ..cameras.drivers.device import Device +from ..cameras.drivers.shd import SHDDevice class SettingsManager: @@ -56,10 +60,8 @@ def cleanup(self) -> None: self.file_object.close() def _load_device( - self, - device: Device, - saved_device: SavedDeviceModel, - devices: list[Device]) -> None: + self, device: Device, saved_device: SavedDeviceModel, devices: list[Device] + ) -> None: if device.device_type != saved_device.device_type: self.logger.info( f"Device {device.bus_info} with device_type: " @@ -111,9 +113,7 @@ def _load_device( if potential_leader.bus_info == device.bus_info: continue - saved_leader = self.saved_by_bus_info.get( - potential_leader.bus_info - ) + saved_leader = self.saved_by_bus_info.get(potential_leader.bus_info) if not saved_leader or not saved_leader.followers: continue @@ -122,7 +122,6 @@ def _load_device( potential_leader.add_follower(follower) break # Only follow one leader - def load_device(self, device: Device, devices: list[Device]) -> None: with self._lock: for saved_device in self.settings: From 9abf5f3f396833cad4ae61f572492a9f7db8ecaa Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:47:58 -0700 Subject: [PATCH 11/34] Update frontend to include backend changes --- .../components/dwe/cameras/camera-card.tsx | 2 +- .../dwe/cameras/camera-controls.tsx | 2 +- .../dwe/cameras/controls/boolean-control.tsx | 9 +- .../src/components/dwe/cameras/stream.tsx | 26 ++- frontend/src/schemas/dwe_os_2.d.ts | 156 ++++-------------- 5 files changed, 54 insertions(+), 141 deletions(-) diff --git a/frontend/src/components/dwe/cameras/camera-card.tsx b/frontend/src/components/dwe/cameras/camera-card.tsx index 3c5c8fc0..8a3ed327 100644 --- a/frontend/src/components/dwe/cameras/camera-card.tsx +++ b/frontend/src/components/dwe/cameras/camera-card.tsx @@ -37,7 +37,7 @@ export function CameraCard({
- {deviceState.device_info?.device_name} + {deviceState.name} Manufacturer: {deviceState.manufacturer}
diff --git a/frontend/src/components/dwe/cameras/camera-controls.tsx b/frontend/src/components/dwe/cameras/camera-controls.tsx index c550269e..1c113750 100644 --- a/frontend/src/components/dwe/cameras/camera-controls.tsx +++ b/frontend/src/components/dwe/cameras/camera-controls.tsx @@ -55,7 +55,7 @@ const ControlWrapper = ({ const setUVCControl = ( bus_info: string, - value: number, + value: number | boolean, control_id: number, ) => { API_CLIENT.POST("/api/devices/set_uvc_control", { diff --git a/frontend/src/components/dwe/cameras/controls/boolean-control.tsx b/frontend/src/components/dwe/cameras/controls/boolean-control.tsx index 78a3e1ee..975e54bb 100644 --- a/frontend/src/components/dwe/cameras/controls/boolean-control.tsx +++ b/frontend/src/components/dwe/cameras/controls/boolean-control.tsx @@ -3,15 +3,16 @@ import { components } from "@/schemas/dwe_os_2"; import { useState } from "react"; import { subscribe } from "valtio"; -const numberToBoolean = (val: number, VALUE_TRUE: number) => val === VALUE_TRUE; +const numberToBoolean = (val: number | boolean, VALUE_TRUE: number | boolean) => + val === VALUE_TRUE; const BooleanControl = ({ control, }: { control: components["schemas"]["ControlModel"]; }) => { - let VALUE_TRUE = 1, - VALUE_FALSE = 0; + let VALUE_TRUE: boolean | number = true, + VALUE_FALSE: boolean | number = false; if (control.name == "Auto Exposure") { VALUE_TRUE = 3; @@ -19,7 +20,7 @@ const BooleanControl = ({ } const [value, setValue] = useState( - numberToBoolean(control.value, VALUE_TRUE) + numberToBoolean(control.value, VALUE_TRUE), ); subscribe(control, () => { diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx index c2d565ab..57d4256d 100644 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream.tsx @@ -59,19 +59,29 @@ 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 hwBitrateControl = controlMap.get("Hardware Bitrate"); + + console.log( + exposureControl, + isoControl, + shutterControl, + strobeWidthControl, + hwBitrateControl, + ); - const [exposureTime, setExposureTime] = useState(shutterControl?.value || 0); // 0x3501 + const [exposureTime, setExposureTime] = useState( + (shutterControl?.value as number) || 0, + ); // 0x3501 const [autoExposure, setAutoExposure] = useState( exposureControl?.value === 1, ); - const [gain, setGain] = useState(isoControl?.value || 0); // 0x3508 - const [strobeWidth, setStrobeWidth] = useState( - strobeWidthControl?.value || 0, + const [gain, setGain] = useState((isoControl?.value as number) || 0); // 0x3508 + const [strobeWidth, setStrobeWidth] = useState( + (strobeWidthControl?.value as number) || 0, + ); + const [hwBitrate, setHwBitrate] = useState( + (hwBitrateControl?.value as number) ?? 0, ); - const [hwBitrate, setHwBitrate] = useState(hwBitrateControl?.value ?? 0); const strobeMax = exposureTime!; diff --git a/frontend/src/schemas/dwe_os_2.d.ts b/frontend/src/schemas/dwe_os_2.d.ts index 2f2d416c..0a1b3c1b 100644 --- a/frontend/src/schemas/dwe_os_2.d.ts +++ b/frontend/src/schemas/dwe_os_2.d.ts @@ -208,15 +208,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/lights": { + "/api/logs": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Lights */ - get: operations["get_lights_api_lights_get"]; + /** Get Logs */ + get: operations["get_logs_api_logs_get"]; put?: never; post?: never; delete?: never; @@ -225,32 +225,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/lights/set_intensity": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Set Intensity */ - post: operations["set_intensity_api_lights_set_intensity_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/logs": { + "/api/recordings": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Logs */ - get: operations["get_logs_api_logs_get"]; + /** Get all recordings */ + get: operations["get_recordings_api_recordings_get"]; put?: never; post?: never; delete?: never; @@ -259,15 +242,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/recordings": { + "/api/recordings/zip": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get all recordings */ - get: operations["get_recordings_api_recordings_get"]; + /** Download all recordings as a zip file */ + get: operations["zip_recordings_api_recordings_zip_get"]; put?: never; post?: never; delete?: never; @@ -311,23 +294,6 @@ export interface paths { patch: operations["rename_recording_api_recordings__old_name___new_name__patch"]; trace?: never; }; - "/api/recordings/zip": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Download all recordings as a zip file */ - get: operations["zip_recordings_api_recordings_zip_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/network/wired/devices": { parameters: { query?: never; @@ -480,11 +446,20 @@ export interface components { ControlFlagsModel: { /** Default Value */ default_value: number; - /** Max Value */ + /** + * Max Value + * @default 0 + */ max_value: number; - /** Min Value */ + /** + * Min Value + * @default 0 + */ min_value: number; - /** Step */ + /** + * Step + * @default 0 + */ step: number; control_type: components["schemas"]["ControlTypeEnum"]; /** Menu */ @@ -498,7 +473,7 @@ export interface components { /** Name */ name: string; /** Value */ - value: number; + value: number | boolean; }; /** * ControlTypeEnum @@ -652,19 +627,6 @@ export interface components { /** Denominator */ denominator: number; }; - /** Light */ - Light: { - /** Intensity */ - intensity: number; - /** Pin */ - pin: number; - /** Nickname */ - nickname: string; - /** Controller Index */ - controller_index: number; - /** Controller Name */ - controller_name: string; - }; /** LogSchema */ LogSchema: { /** Timestamp */ @@ -722,13 +684,6 @@ export interface components { */ frequency_offset: number; }; - /** SetLightInfo */ - SetLightInfo: { - /** Index */ - index: number; - /** Intensity */ - intensity: number; - }; /** SimpleRequestStatusModel */ SimpleRequestStatusModel: { /** @@ -797,7 +752,7 @@ export interface components { /** Control Id */ control_id: number; /** Value */ - value: number; + value: number | boolean; }; /** ValidationError */ ValidationError: { @@ -1167,7 +1122,7 @@ export interface operations { }; }; }; - get_lights_api_lights_get: { + get_logs_api_logs_get: { parameters: { query?: never; header?: never; @@ -1182,45 +1137,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Light"][]; - }; - }; - }; - }; - set_intensity_api_lights_set_intensity_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SetLightInfo"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SimpleRequestStatusModel"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; + "application/json": components["schemas"]["LogSchema"][]; }; }; }; }; - get_logs_api_logs_get: { + get_recordings_api_recordings_get: { parameters: { query?: never; header?: never; @@ -1235,12 +1157,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LogSchema"][]; + "application/json": components["schemas"]["RecordingInfo"][]; }; }; }; }; - get_recordings_api_recordings_get: { + zip_recordings_api_recordings_zip_get: { parameters: { query?: never; header?: never; @@ -1255,7 +1177,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecordingInfo"][]; + "application/json": unknown; }; }; }; @@ -1354,26 +1276,6 @@ export interface operations { }; }; }; - zip_recordings_api_recordings_zip_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; get_wired_devices_api_network_wired_devices_get: { parameters: { query?: never; From beaf40fd3c7e060159321ca15838aad1853686d7 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:49:49 -0700 Subject: [PATCH 12/34] Remove console.log statement --- frontend/src/components/dwe/cameras/stream.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx index 57d4256d..b5c6a5f9 100644 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream.tsx @@ -61,14 +61,6 @@ export const SensorControls = () => { const strobeWidthControl = controlMap.get("Strobe Width"); const hwBitrateControl = controlMap.get("Hardware Bitrate"); - console.log( - exposureControl, - isoControl, - shutterControl, - strobeWidthControl, - hwBitrateControl, - ); - const [exposureTime, setExposureTime] = useState( (shutterControl?.value as number) || 0, ); // 0x3501 From 91cb70113653a78b5e4f64f3a13bfd919d69a58a Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:52:29 -0700 Subject: [PATCH 13/34] Add check for control in case it does not exist --- frontend/src/components/dwe/cameras/stream.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx index b5c6a5f9..662fcc64 100644 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream.tsx @@ -83,13 +83,15 @@ export const SensorControls = () => { // TODO: Streamline this more effectively (We should have a global API class) const setUVCControl = (control: ControlModel, value: number) => { - API_CLIENT.POST("/api/devices/set_uvc_control", { - body: { - bus_info: deviceSnapshot.bus_info, - control_id: control.control_id, - value, - }, - }); + if (control) { + API_CLIENT.POST("/api/devices/set_uvc_control", { + body: { + bus_info: deviceSnapshot.bus_info, + control_id: control.control_id, + value, + }, + }); + } }; useEffect(() => { From 917fd3ef63cbd5b6ffb2bf60a0e7f1711d106ca9 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:55:22 -0700 Subject: [PATCH 14/34] Fix ruff diagnostic issues --- backend_py/src/services/cameras/drivers/device.py | 2 +- backend_py/src/services/cameras/drivers/shd/shd.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend_py/src/services/cameras/drivers/device.py b/backend_py/src/services/cameras/drivers/device.py index c05dd86a..48103596 100644 --- a/backend_py/src/services/cameras/drivers/device.py +++ b/backend_py/src/services/cameras/drivers/device.py @@ -9,7 +9,7 @@ import contextlib import logging import threading -from typing import Any, Type +from typing import Any import event_emitter as events from backend_py.src.models import ( diff --git a/backend_py/src/services/cameras/drivers/shd/shd.py b/backend_py/src/services/cameras/drivers/shd/shd.py index 58fd2a0c..f4e97666 100644 --- a/backend_py/src/services/cameras/drivers/shd/shd.py +++ b/backend_py/src/services/cameras/drivers/shd/shd.py @@ -10,9 +10,9 @@ from .options import ( AutoExposureOption, GainOption, + HardwareBitrateOption, ShutterSpeedOption, StrobeWidthOption, - HardwareBitrateOption, ) From 9735b6611b0070a6c6fc242ddd378bd881445107 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:57:48 -0700 Subject: [PATCH 15/34] Fix type errors --- .../dwe/cameras/controls/integer-control.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/dwe/cameras/controls/integer-control.tsx b/frontend/src/components/dwe/cameras/controls/integer-control.tsx index cdd944af..6aba6d45 100644 --- a/frontend/src/components/dwe/cameras/controls/integer-control.tsx +++ b/frontend/src/components/dwe/cameras/controls/integer-control.tsx @@ -20,15 +20,18 @@ const IntegerControl = ({ const controlId = `control-${control.control_id}-${control.name}`; const safeStep = step && step > 0 ? step : 1; + const controlValue = control.value as number; + // FIXME isDisabled = false; const precision = safeStep < 1 ? step.toString().split(".")[1]?.length || 0 : 0; - const [currentValue, setCurrentValue] = useState(control.value); + // TODO: Remove the need to typecast control.value every time it's used + const [currentValue, setCurrentValue] = useState(controlValue); const [inputValue, setInputValue] = useState( - control.value.toFixed(precision).toString(), + controlValue.toFixed(precision).toString(), ); const containerRef = useRef(null); @@ -43,8 +46,8 @@ const IntegerControl = ({ useEffect(() => { const unsubscribe = subscribe(control, () => { if (control.value !== currentValue) { - setCurrentValue(control.value); - setInputValue(control.value.toFixed(precision).toString()); + setCurrentValue(controlValue); + setInputValue(controlValue.toFixed(precision).toString()); } }); return () => unsubscribe(); From c9776bb57e8fb09e3e50824cb7a4e515a46cf35c Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 16:59:13 -0700 Subject: [PATCH 16/34] Fix ruff formatting --- backend_py/src/services/cameras/drivers/video4linux/camera.py | 2 +- .../src/services/cameras/drivers/video4linux/enumeration.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend_py/src/services/cameras/drivers/video4linux/camera.py b/backend_py/src/services/cameras/drivers/video4linux/camera.py index 517a1e69..0f543683 100644 --- a/backend_py/src/services/cameras/drivers/video4linux/camera.py +++ b/backend_py/src/services/cameras/drivers/video4linux/camera.py @@ -78,4 +78,4 @@ def _get_formats(self) -> None: ) ) format_sizes.append(format_size) - self.formats[fourcc2s(v4l2_fmt.pixelformat)] = format_sizes \ No newline at end of file + self.formats[fourcc2s(v4l2_fmt.pixelformat)] = format_sizes diff --git a/backend_py/src/services/cameras/drivers/video4linux/enumeration.py b/backend_py/src/services/cameras/drivers/video4linux/enumeration.py index 655c0fed..cdb34aba 100644 --- a/backend_py/src/services/cameras/drivers/video4linux/enumeration.py +++ b/backend_py/src/services/cameras/drivers/video4linux/enumeration.py @@ -28,6 +28,7 @@ def _get_device_attr(device_path, attr) -> str: with open(device_path + "/" + attr, encoding="utf-8") as file_object: return file_object.read().strip() + def _get_vid_pid(devname) -> tuple[int, int] | None: cam_name = devname syspath = "/sys/class/video4linux/" + cam_name From 539f5ba118f5a997e10571ad0fb75f817a18168c Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 17:01:43 -0700 Subject: [PATCH 17/34] Add colorlog to requirements.txt --- backend_py/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/backend_py/requirements.txt b/backend_py/requirements.txt index 270879f5..1cf8a826 100644 --- a/backend_py/requirements.txt +++ b/backend_py/requirements.txt @@ -12,3 +12,4 @@ sdbus==0.14.0 sdbus-networkmanager==2.0.0 rtp==0.0.4 pyserial +colorlog==6.10.1 From d92b5d4d43eabeead8f389f0bfc291bb4aa84e93 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 17:08:31 -0700 Subject: [PATCH 18/34] Fix issue with type checking action resulting in false import errors --- .github/workflows/backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 1465655a..bd52bb5e 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -48,4 +48,4 @@ jobs: run: bandit -r . -lll - name: Type checking (ty) - run: ty check + run: cd .. && ty check backend_py From 91afd55e0c36e3d93c55e312c867afa4a627f41f Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 13 May 2026 17:55:25 -0700 Subject: [PATCH 19/34] Refactor recordings and add data dir for SVC Pro compatibility --- .gitignore | 2 +- backend_py/run.py | 1 + backend_py/src/models/__init__.py | 3 + backend_py/src/models/recordings.py | 10 ++ backend_py/src/routes/recordings.py | 3 +- backend_py/src/server.py | 10 +- .../stream_engines/gstreamer_stream_engine.py | 32 +--- .../src/services/recordings/__init__.py | 161 +----------------- .../services/recordings/recordings_service.py | 155 +++++++++++++++++ create_release.sh | 2 - 10 files changed, 190 insertions(+), 189 deletions(-) create mode 100644 backend_py/src/models/recordings.py create mode 100644 backend_py/src/services/recordings/recordings_service.py diff --git a/.gitignore b/.gitignore index 1a5c418d..b5daef25 100644 --- a/.gitignore +++ b/.gitignore @@ -333,6 +333,6 @@ release.tar.gz **/server_preferences.json .env pi-gen -videos +/recordings/ !frontend/src/lib/ diff --git a/backend_py/run.py b/backend_py/run.py index b0463cb5..087ca36c 100644 --- a/backend_py/run.py +++ b/backend_py/run.py @@ -53,6 +53,7 @@ async def lifespan(app: FastAPI): # noqa: ANN201 FeatureSupport(ttyd=True, wifi=True, serial=True), sio, app, + data_dir=".", settings_path=".", log_level=logging.DEBUG, is_dev_mode=True, diff --git a/backend_py/src/models/__init__.py b/backend_py/src/models/__init__.py index d7de9ef9..e4edc6e0 100644 --- a/backend_py/src/models/__init__.py +++ b/backend_py/src/models/__init__.py @@ -33,6 +33,7 @@ WiredDeviceModel, ) from .preferences import SavedPreferencesModel +from .recordings import RecordingInfo from .saved_cameras import ( SavedControlModel, SavedDeviceModel, @@ -80,4 +81,6 @@ "SavedDeviceModel", "SavedLeaderFollowerPairModel", "SavedStreamModel", + # Recordings + "RecordingInfo", ] diff --git a/backend_py/src/models/recordings.py b/backend_py/src/models/recordings.py new file mode 100644 index 00000000..bd05d396 --- /dev/null +++ b/backend_py/src/models/recordings.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class RecordingInfo(BaseModel): + path: str + name: str + format: str + duration: str + size: str + created: str diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index 92841a85..0b70645c 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -6,10 +6,11 @@ and downloading all recordings as ZIP """ +from backend_py.src.models import RecordingInfo from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse -from ..services.recordings import RecordingInfo, RecordingsService +from ..services.recordings import RecordingsService recordings_router = APIRouter(tags=["recordings"]) diff --git a/backend_py/src/server.py b/backend_py/src/server.py index 301c49af..0b0c6355 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -9,6 +9,7 @@ import asyncio import logging import logging.handlers +import os import socketio from colorlog import ColoredFormatter @@ -43,7 +44,8 @@ def __init__( feature_support: FeatureSupport, sio: socketio.AsyncServer, app: FastAPI, - settings_path: str = "/", + data_dir: str = "/var/lib/dwe_os", + settings_path: str = ".", log_level=logging.INFO, is_dev_mode=False, ) -> None: @@ -56,6 +58,10 @@ def __init__( # Create the managers self.sio = sio + # /var/lib/dwe_os + self.data_dir = data_dir + os.makedirs(self.data_dir, exist_ok=True, mode=0o755) + # Create the logging handler self.root_logger = logging.getLogger("dwe_os_2") self.stream_handler = logging.StreamHandler() @@ -118,7 +124,7 @@ def __init__( self.system_manager = SystemManager() - self.recordings_service = RecordingsService() + self.recordings_service = RecordingsService(self.data_dir) # TTYD if self.feature_support.ttyd: diff --git a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py index 020aebc0..6b41f8c1 100644 --- a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py @@ -1,11 +1,12 @@ import os import signal -import stat import subprocess import threading +from collections.abc import Callable from datetime import datetime from backend_py.src.models import StreamEncodeTypeEnum, StreamTypeEnum +from backend_py.src.services.recordings import RecordingsService from .base_stream_engine import BaseStreamEngine from .stream import Stream @@ -21,7 +22,7 @@ def build(cls, stream: Stream) -> str: source = cls._build_source(stream) caps = GStreamerPipelineBuilder._construct_caps(stream) payload = GStreamerPipelineBuilder._build_payload(stream) - sink = GStreamerPipelineBuilder._build_sink(stream) + sink = GStreamerPipelineBuilder._build_sink(stream, RecordingsService.BASE_PATH) return f"{source} ! {caps} ! {payload} ! {sink}" @staticmethod @@ -91,7 +92,7 @@ def _build_payload(stream: Stream) -> str: return "" @staticmethod - def _build_sink(stream: Stream) -> str: + def _build_sink(stream: Stream, recording_directory: str) -> str: match stream.stream_type: case StreamTypeEnum.UDP: if len(stream.endpoints) == 0: @@ -100,18 +101,6 @@ def _build_sink(stream: Stream) -> str: sink += ",".join(f"{e.host}:{e.port}" for e in stream.endpoints) return sink case StreamTypeEnum.RECORDING: - home_dir = os.getcwd() - video_dir = os.path.join(home_dir, "videos") - if not os.path.exists(video_dir): - os.makedirs(video_dir) - permissions = ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - os.chmod(video_dir, permissions) extension = ( "avi" if stream.encode_type == StreamEncodeTypeEnum.MJPG else "mp4" ) @@ -119,14 +108,7 @@ def _build_sink(stream: Stream) -> str: unique_filename = ( f"{stream.device_path.split('/')[-1]}_{timestamp}.{extension}" ) - unique_path = os.path.join(video_dir, unique_filename) - if os.path.exists(unique_path): - # TODO: use pathlib - unique_filename = ( - f"{stream.device_path.split('/')[-1]}" - f"_{timestamp}_{os.getpid()}.{extension}" - ) - unique_path = os.path.join(video_dir, unique_filename) + unique_path = os.path.join(recording_directory, unique_filename) stream.file_path = unique_path return f"filesink location={unique_path} sync=true" case _: @@ -138,7 +120,9 @@ class GStreamerProcessEngine(BaseStreamEngine): GStreamer stream Engine """ - def __init__(self, streams, error_callback) -> None: + def __init__( + self, streams: list[Stream], error_callback: Callable[[str], None] + ) -> None: super().__init__(streams, error_callback) self._process: subprocess.Popen | None = None diff --git a/backend_py/src/services/recordings/__init__.py b/backend_py/src/services/recordings/__init__.py index d417f0ae..b74a3c65 100644 --- a/backend_py/src/services/recordings/__init__.py +++ b/backend_py/src/services/recordings/__init__.py @@ -1,160 +1,3 @@ -""" -recordings +from .recordings_service import RecordingsService -Handles locating recordings and extracting metadata from the files -Allows for the renaming, deletion, and compression of the found recordings -""" - -import json -import logging -import os -import subprocess -import threading -import zipfile - -from pydantic import BaseModel - - -class RecordingInfo(BaseModel): - path: str - name: str - format: str - duration: str - size: str - created: str - - -class RecordingsService: - 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 = {} - - def get_recordings(self) -> list[RecordingInfo]: - if not os.path.exists(self.recordings_path): - os.makedirs(self.recordings_path) - - 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 - - return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M:%S") - - def _get_duration(self, file_path: str) -> str: - if file_path in self.durations: - self.logger.info(f"Found cached duration: {file_path}") - return self.durations[file_path] - - # FIXME: We need to change this function to use a better metadata library - try: - result = subprocess.run( - ["exiftool", "-json", file_path], - capture_output=True, - text=True, - check=True, - ) - output = result.stdout.strip() - if output: - data = json.loads(output) - - if file_path.endswith(".mp4"): - duration = data[0].get("Duration", "00:00:00") - if "s" in duration: - duration = ( - f"00:00:{round(float(duration.replace(' s', ''))):02}" - ) - self.durations[file_path] = duration - return duration - - totalFrameCount = data[0].get("TotalFrameCount", 0) - frameRate = data[0].get("FrameRate", 0) - if frameRate > 0: - duration = totalFrameCount / frameRate - hours = int(duration // 3600) - minutes = int((duration % 3600) // 60) - seconds = int(duration % 60) - duration = f"{hours:02}:{minutes:02}:{seconds:02d}" - self.durations[file_path] = duration - return duration - - return "00:00:00" - except FileNotFoundError as e: - self.logger.error(f"exiftool was not found: {e}") - except json.JSONDecodeError as e: - self.logger.error(f"Error decoding output from exiftool: {e}") - except Exception as e: - self.logger.error(f"exiftool had an unknown system error: {e}") - return "Unknown" - - def get_recording(self, filename: str) -> RecordingInfo | None: - self.get_recordings() - recording_path = os.path.join(self.recordings_path, filename) - for recording in self.recordings: - if recording.path == recording_path: - return recording - return None - - def delete_recording(self, filename: str) -> list[RecordingInfo] | None: - if filename in self.durations: - self.durations.pop(filename, None) - - recording_path = os.path.join(self.recordings_path, filename) - if os.path.exists(recording_path): - os.remove(recording_path) - self.recordings = [ - rec for rec in self.recordings if rec.path != recording_path - ] - return self.recordings - return None - - def rename_recording( - self, old_name: str, new_name: str - ) -> list[RecordingInfo] | None: - old_path = os.path.join(self.recordings_path, old_name) - new_path = os.path.join(self.recordings_path, new_name) - - if os.path.exists(old_path): - os.rename(old_path, new_path) - for recording in self.recordings: - if recording.path == old_path: - recording.name = new_name.split(".")[0] - recording.path = new_path - recording.format = new_name.split(".")[-1] - return self.recordings - return None - - def zip_recordings(self) -> str | None: - self.get_recordings() # Refresh the recordings list - if not self.recordings: - return None - - zip_filename = os.path.join(self.recordings_path, "recordings.zip") - - with zipfile.ZipFile(zip_filename, "w") as zipf: - for recording in self.recordings: - zipf.write( - recording.path, arcname=recording.name + "." + recording.format - ) - return zip_filename +__all__ = ["RecordingsService"] diff --git a/backend_py/src/services/recordings/recordings_service.py b/backend_py/src/services/recordings/recordings_service.py new file mode 100644 index 00000000..db71c8a3 --- /dev/null +++ b/backend_py/src/services/recordings/recordings_service.py @@ -0,0 +1,155 @@ +""" +recordings + +Handles locating recordings and extracting metadata from the files +Allows for the renaming, deletion, and compression of the found recordings +""" + +import json +import logging +import os +import subprocess +import threading +import zipfile + +from backend_py.src.models import RecordingInfo + + +class RecordingsService: + BASE_PATH = "" + + def __init__(self, data_dir: str) -> None: + self.recordings_path = os.path.join(data_dir, "recordings") + RecordingsService.BASE_PATH = self.recordings_path + self.recordings: list[RecordingInfo] = [] + self.logger = logging.getLogger("dwe_os_2.RecordingsService") + self.recordings_lock = threading.Lock() + + self.durations = {} + + if not os.path.exists(self.recordings_path): + os.makedirs(self.recordings_path) + + def get_recordings(self) -> list[RecordingInfo]: + 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 + + return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M:%S") + + def _get_duration(self, file_path: str) -> str: + if file_path in self.durations: + self.logger.info(f"Found cached duration: {file_path}") + return self.durations[file_path] + + # FIXME: We need to change this function to use a better metadata library + try: + result = subprocess.run( + ["exiftool", "-json", file_path], + capture_output=True, + text=True, + check=True, + ) + output = result.stdout.strip() + if output: + data = json.loads(output) + + if file_path.endswith(".mp4"): + duration = data[0].get("Duration", "00:00:00") + if "s" in duration: + duration = ( + f"00:00:{round(float(duration.replace(' s', ''))):02}" + ) + self.durations[file_path] = duration + return duration + + totalFrameCount = data[0].get("TotalFrameCount", 0) + frameRate = data[0].get("FrameRate", 0) + if frameRate > 0: + duration = totalFrameCount / frameRate + hours = int(duration // 3600) + minutes = int((duration % 3600) // 60) + seconds = int(duration % 60) + duration = f"{hours:02}:{minutes:02}:{seconds:02d}" + self.durations[file_path] = duration + return duration + + return "00:00:00" + except FileNotFoundError as e: + self.logger.error(f"exiftool was not found: {e}") + except json.JSONDecodeError as e: + self.logger.error(f"Error decoding output from exiftool: {e}") + except Exception as e: + self.logger.error(f"exiftool had an unknown system error: {e}") + return "Unknown" + + def get_recording(self, filename: str) -> RecordingInfo | None: + self.get_recordings() + recording_path = os.path.join(self.recordings_path, filename) + for recording in self.recordings: + if recording.path == recording_path: + return recording + return None + + def delete_recording(self, filename: str) -> list[RecordingInfo] | None: + if filename in self.durations: + self.durations.pop(filename, None) + + recording_path = os.path.join(self.recordings_path, filename) + if os.path.exists(recording_path): + os.remove(recording_path) + self.recordings = [ + rec for rec in self.recordings if rec.path != recording_path + ] + return self.recordings + return None + + def rename_recording( + self, old_name: str, new_name: str + ) -> list[RecordingInfo] | None: + old_path = os.path.join(self.recordings_path, old_name) + new_path = os.path.join(self.recordings_path, new_name) + + if os.path.exists(old_path): + os.rename(old_path, new_path) + for recording in self.recordings: + if recording.path == old_path: + recording.name = new_name.split(".")[0] + recording.path = new_path + recording.format = new_name.split(".")[-1] + return self.recordings + return None + + def zip_recordings(self) -> str | None: + self.get_recordings() # Refresh the recordings list + if not self.recordings: + return None + + zip_filename = os.path.join(self.recordings_path, "recordings.zip") + + with zipfile.ZipFile(zip_filename, "w") as zipf: + for recording in self.recordings: + zipf.write( + recording.path, arcname=recording.name + "." + recording.format + ) + + # FIXME: Delete zip after download completes + return zip_filename diff --git a/create_release.sh b/create_release.sh index 49e4ca98..170e0212 100755 --- a/create_release.sh +++ b/create_release.sh @@ -42,6 +42,4 @@ cp create_venv.sh release cp run_release.sh release cp -r service release -rm -rf release/backend_py/videos - tar -czvf release.tar.gz release From e56c671926adfda2aa7ed503ff01e527c9ac657b Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 14 May 2026 13:35:05 -0700 Subject: [PATCH 20/34] [WIP] Asic control improvements and add VTS HTS --- .pre-commit-config.yaml | 22 ++++++++++++ backend_py/pyproject.toml | 3 ++ .../src/services/cameras/drivers/device.py | 3 ++ .../cameras/drivers/shd/asic_interface.py | 34 +++++++++++++++++++ .../services/cameras/drivers/shd/options.py | 34 +++++++++++++++++++ .../src/services/cameras/drivers/shd/shd.py | 6 ++++ backend_py/src/services/cameras/drivers/xu.py | 4 +++ .../dwe/cameras/cam-control-map.json | 4 ++- 8 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b7595b52 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.15.12 + hooks: + # Run the linter. + - id: ruff-check + args: [--fix] + files: ^backend_py/ + # Run the formatter. + - id: ruff-format + files: ^backend_py/ + # ty + - repo: local + hooks: + - id: ty + name: ty check backend_py + entry: ty check backend_py + language: system + types: [python] + files: ^backend_py/ + pass_filenames: false diff --git a/backend_py/pyproject.toml b/backend_py/pyproject.toml index 74ca0a11..b1f5eaed 100644 --- a/backend_py/pyproject.toml +++ b/backend_py/pyproject.toml @@ -3,6 +3,9 @@ line-length = 88 indent-width = 4 exclude = ["v4l2.py"] # exclude v4l2.py, since it's included as a lib directly +[tool.ty.environment] +python = ".venv" + [tool.pylint."messages control"] disable = ["R0903"] diff --git a/backend_py/src/services/cameras/drivers/device.py b/backend_py/src/services/cameras/drivers/device.py index 48103596..d1e57ce8 100644 --- a/backend_py/src/services/cameras/drivers/device.py +++ b/backend_py/src/services/cameras/drivers/device.py @@ -313,6 +313,9 @@ def set_pu(self, control_id: int, value: int | float | bool) -> bool | None: if self._options[option_name].name == control.name: try: self.set_option(option_name, value) + self.logger.info( + f"Setting {control.name} to {control.value}" + ) except TypeError as e: # TODO: return this to caller (API) self.logger.info( diff --git a/backend_py/src/services/cameras/drivers/shd/asic_interface.py b/backend_py/src/services/cameras/drivers/shd/asic_interface.py index b49fb43c..329f4d11 100644 --- a/backend_py/src/services/cameras/drivers/shd/asic_interface.py +++ b/backend_py/src/services/cameras/drivers/shd/asic_interface.py @@ -7,11 +7,21 @@ import struct import threading +from collections.abc import Callable + +from pydantic.dataclasses import dataclass from ..video4linux import Camera from ..xu import Selector, StellarRegisterMap, Unit +@dataclass +class ASICCommand: + func: Callable + args: list + key: str + + class ASICInterface: """ Class for low level read/write functions to interact with the SHD ASIC and @@ -22,6 +32,30 @@ def __init__(self, camera: Camera) -> None: self.camera = camera self._lock = threading.RLock() + # self.is_worker_running = True + # self.command_queue: dict[str, ASICCommand] = {} + # self._queue_lock = threading.Lock() + # self.queue_cond = threading.Condition(self._queue_lock) + + # self.thread = threading.Thread(target=self._sync_asic_writes) + + # def _sync_asic_writes(self) -> None: + # while self.is_worker_running: + # with self.queue_cond: + # self.queue_cond.wait_for( + # lambda: self.command_queue or not self.is_worker_running + # ) + + # if not self.is_worker_running: + # break + + # task = self.command_queue.popleft() + # task.func(*task.args) + + # def queue_command(self, key: str, func: Callable, args: list) -> None: + # pass + # with self.queue_cond: + def asic_write(self, addr: int, data: int, dummy: bool = False) -> int: """ Write a value to an ASIC register. diff --git a/backend_py/src/services/cameras/drivers/shd/options.py b/backend_py/src/services/cameras/drivers/shd/options.py index 9ad33fbb..9a18638f 100644 --- a/backend_py/src/services/cameras/drivers/shd/options.py +++ b/backend_py/src/services/cameras/drivers/shd/options.py @@ -164,6 +164,40 @@ def __init__(self, asic_interface: ASICInterface) -> None: ) +class VtsOption(SensorHighLowOption): + def __init__(self, asic_interface: ASICInterface) -> None: + super().__init__( + "VTS", + ControlFlagsModel( + default_value=0, + max_value=65535, + min_value=0, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + asic_interface, + StellarSensorMap.VTS_HIGH, + StellarSensorMap.VTS_LOW, + ) + + +class HtsOption(SensorHighLowOption): + def __init__(self, asic_interface: ASICInterface) -> None: + super().__init__( + "HTS", + ControlFlagsModel( + default_value=0, + max_value=65535, + min_value=0, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + asic_interface, + StellarSensorMap.HTS_HIGH, + StellarSensorMap.HTS_LOW, + ) + + class GainOption(SensorHighLowOption): def __init__(self, asic_interface: ASICInterface) -> None: super().__init__( diff --git a/backend_py/src/services/cameras/drivers/shd/shd.py b/backend_py/src/services/cameras/drivers/shd/shd.py index f4e97666..2e35220c 100644 --- a/backend_py/src/services/cameras/drivers/shd/shd.py +++ b/backend_py/src/services/cameras/drivers/shd/shd.py @@ -11,8 +11,10 @@ AutoExposureOption, GainOption, HardwareBitrateOption, + HtsOption, ShutterSpeedOption, StrobeWidthOption, + VtsOption, ) @@ -60,6 +62,8 @@ def __init__( "iso": GainOption(self.asic_interface), "strobe_width": StrobeWidthOption(self.asic_interface), "hw_bitrate": HardwareBitrateOption(self.asic_interface), + "vts": VtsOption(self.asic_interface), + "hts": HtsOption(self.asic_interface), } self.add_control_from_option("auto_exposure") @@ -67,6 +71,8 @@ def __init__( self.add_control_from_option("iso") self.add_control_from_option("strobe_width") self.add_control_from_option("hw_bitrate") + self.add_control_from_option("vts") + self.add_control_from_option("hts") def add_follower(self, device: "SHDDevice") -> None: if device.bus_info in self.followers: diff --git a/backend_py/src/services/cameras/drivers/xu.py b/backend_py/src/services/cameras/drivers/xu.py index 24181c5f..a5341016 100644 --- a/backend_py/src/services/cameras/drivers/xu.py +++ b/backend_py/src/services/cameras/drivers/xu.py @@ -84,3 +84,7 @@ class StellarSensorMap: ISO_LOW = 0x3509 STROBE_WIDTH_HIGH = 0x3927 STROBE_WIDTH_LOW = 0x3928 + VTS_HIGH = 0x380E + VTS_LOW = 0x380F + HTS_HIGH = 0x380C + HTS_LOW = 0x380D diff --git a/frontend/src/components/dwe/cameras/cam-control-map.json b/frontend/src/components/dwe/cameras/cam-control-map.json index 20fbbaa7..cbd718d3 100644 --- a/frontend/src/components/dwe/cameras/cam-control-map.json +++ b/frontend/src/components/dwe/cameras/cam-control-map.json @@ -21,6 +21,8 @@ "Power Line Frequency", "Bitrate", "Group of Pictures", - "Variable Bitrate" + "Variable Bitrate", + "VTS", + "HTS" ] } From 6065a06546700aab4af7fb4557891653fc74f914 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 14 May 2026 19:04:26 -0700 Subject: [PATCH 21/34] Move pyproject.toml for linting fixes --- backend_py/pyproject.toml => pyproject.toml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend_py/pyproject.toml => pyproject.toml (100%) diff --git a/backend_py/pyproject.toml b/pyproject.toml similarity index 100% rename from backend_py/pyproject.toml rename to pyproject.toml From 2e4437402f5e4a0ccd38160001fd4b5d1b49db38 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 14 May 2026 19:07:10 -0700 Subject: [PATCH 22/34] Fix formatting issues from changes with pyproject.toml --- backend_py/run.py | 3 ++- backend_py/src/routes/cameras.py | 3 ++- backend_py/src/routes/preferences.py | 3 ++- backend_py/src/routes/recordings.py | 3 ++- backend_py/src/services/cameras/device_manager.py | 1 + backend_py/src/services/cameras/drivers/device.py | 9 +++++---- backend_py/src/services/network/async_network_manager.py | 3 ++- backend_py/src/services/network/nm_wrapper.py | 3 ++- .../src/services/preferences/preferences_manager.py | 4 +++- 9 files changed, 21 insertions(+), 11 deletions(-) diff --git a/backend_py/run.py b/backend_py/run.py index 087ca36c..c5d247ed 100644 --- a/backend_py/run.py +++ b/backend_py/run.py @@ -9,10 +9,11 @@ from contextlib import asynccontextmanager import socketio -from backend_py.src import FeatureSupport, Server from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from backend_py.src import FeatureSupport, Server + ORIGINS = ["*"] # Use AsyncServer diff --git a/backend_py/src/routes/cameras.py b/backend_py/src/routes/cameras.py index 5cfd7bd5..e264a861 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -8,6 +8,8 @@ from typing import cast +from fastapi import APIRouter, Request + from backend_py.src.models import ( AddFollowerPayload, DeviceDescriptorModel, @@ -17,7 +19,6 @@ StreamInfoModel, UVCControlModel, ) -from fastapi import APIRouter, Request from ..schemas import SimpleRequestStatusModel from ..services.cameras import DeviceManager diff --git a/backend_py/src/routes/preferences.py b/backend_py/src/routes/preferences.py index c8e8568a..d66e0e1c 100644 --- a/backend_py/src/routes/preferences.py +++ b/backend_py/src/routes/preferences.py @@ -5,9 +5,10 @@ Handles getting and setting preferences """ -from backend_py.src.models import SavedPreferencesModel from fastapi import APIRouter, Request +from backend_py.src.models import SavedPreferencesModel + from ..schemas import SimpleRequestStatusModel from ..services.preferences import PreferencesManager diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index 0b70645c..812f3a93 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -6,10 +6,11 @@ and downloading all recordings as ZIP """ -from backend_py.src.models import RecordingInfo from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse +from backend_py.src.models import RecordingInfo + from ..services.recordings import RecordingsService recordings_router = APIRouter(tags=["recordings"]) diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index c7d249e3..5dc70bc4 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -16,6 +16,7 @@ import event_emitter as events import socketio + from backend_py.src.models import ( DeviceModel, DeviceType, diff --git a/backend_py/src/services/cameras/drivers/device.py b/backend_py/src/services/cameras/drivers/device.py index d1e57ce8..90329353 100644 --- a/backend_py/src/services/cameras/drivers/device.py +++ b/backend_py/src/services/cameras/drivers/device.py @@ -12,6 +12,8 @@ from typing import Any import event_emitter as events +from linuxpy.video import device + from backend_py.src.models import ( ControlFlagsModel, ControlModel, @@ -25,7 +27,6 @@ StreamTypeEnum, V4LControlTypeEnum, ) -from linuxpy.video import device from ..stream_runner import Stream, StreamRunner from ..stream_utils import string_to_stream_encode_type @@ -313,9 +314,9 @@ def set_pu(self, control_id: int, value: int | float | bool) -> bool | None: if self._options[option_name].name == control.name: try: self.set_option(option_name, value) - self.logger.info( - f"Setting {control.name} to {control.value}" - ) + # self.logger.info( + # f"Setting {control.name} to {control.value}" + # ) except TypeError as e: # TODO: return this to caller (API) self.logger.info( diff --git a/backend_py/src/services/network/async_network_manager.py b/backend_py/src/services/network/async_network_manager.py index 4d53b09f..a70e8544 100644 --- a/backend_py/src/services/network/async_network_manager.py +++ b/backend_py/src/services/network/async_network_manager.py @@ -5,7 +5,6 @@ from typing import Any import sdbus -from backend_py.src.models import IPV4Address, IPV4Configuration, IPV4Method from event_emitter import EventEmitter from sdbus.utils.inspect import inspect_dbus_path from sdbus_async.networkmanager import ( @@ -25,6 +24,8 @@ DeviceCapabilities as Capabilities, ) +from backend_py.src.models import IPV4Address, IPV4Configuration, IPV4Method + # ip to integer and reverse: https://stackoverflow.com/a/13294427 diff --git a/backend_py/src/services/network/nm_wrapper.py b/backend_py/src/services/network/nm_wrapper.py index c12fb7b7..5089d21a 100644 --- a/backend_py/src/services/network/nm_wrapper.py +++ b/backend_py/src/services/network/nm_wrapper.py @@ -3,9 +3,10 @@ import time import socketio -from backend_py.src.models import ConnectionProfileModel, WiredDeviceModel from event_emitter import EventEmitter +from backend_py.src.models import ConnectionProfileModel, WiredDeviceModel + from .async_network_manager import ( AsyncNetworkManager, IPV4Configuration, diff --git a/backend_py/src/services/preferences/preferences_manager.py b/backend_py/src/services/preferences/preferences_manager.py index b88ce2b7..90c5bff0 100644 --- a/backend_py/src/services/preferences/preferences_manager.py +++ b/backend_py/src/services/preferences/preferences_manager.py @@ -11,9 +11,10 @@ import pathlib import threading -from backend_py.src.models import SavedPreferencesModel from event_emitter import events +from backend_py.src.models import SavedPreferencesModel + class PreferencesManager(events.EventEmitter): def __init__(self, settings_path: str = ".") -> None: @@ -28,6 +29,7 @@ def __init__(self, settings_path: str = ".") -> None: def save(self, preferences: SavedPreferencesModel) -> None: with self._lock: self.settings = preferences + self.emit("preferences_updated", preferences) self._save_settings() def get_preferences(self) -> SavedPreferencesModel: From 1effbb6f9bb348f427105ef0f6d4878d140cfaf6 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 14 May 2026 19:08:17 -0700 Subject: [PATCH 23/34] Add debounce for ASIC interface --- .../cameras/drivers/shd/asic_interface.py | 133 ++++++++++++------ .../services/cameras/drivers/shd/options.py | 38 +++-- .../src/services/cameras/drivers/shd/shd.py | 10 +- 3 files changed, 125 insertions(+), 56 deletions(-) diff --git a/backend_py/src/services/cameras/drivers/shd/asic_interface.py b/backend_py/src/services/cameras/drivers/shd/asic_interface.py index 329f4d11..f4d3fc11 100644 --- a/backend_py/src/services/cameras/drivers/shd/asic_interface.py +++ b/backend_py/src/services/cameras/drivers/shd/asic_interface.py @@ -5,11 +5,12 @@ sensor registers. """ +import logging import struct import threading +import time from collections.abc import Callable - -from pydantic.dataclasses import dataclass +from dataclasses import dataclass from ..video4linux import Camera from ..xu import Selector, StellarRegisterMap, Unit @@ -19,7 +20,6 @@ class ASICCommand: func: Callable args: list - key: str class ASICInterface: @@ -32,31 +32,62 @@ def __init__(self, camera: Camera) -> None: self.camera = camera self._lock = threading.RLock() - # self.is_worker_running = True - # self.command_queue: dict[str, ASICCommand] = {} - # self._queue_lock = threading.Lock() - # self.queue_cond = threading.Condition(self._queue_lock) - - # self.thread = threading.Thread(target=self._sync_asic_writes) - - # def _sync_asic_writes(self) -> None: - # while self.is_worker_running: - # with self.queue_cond: - # self.queue_cond.wait_for( - # lambda: self.command_queue or not self.is_worker_running - # ) - - # if not self.is_worker_running: - # break - - # task = self.command_queue.popleft() - # task.func(*task.args) - - # def queue_command(self, key: str, func: Callable, args: list) -> None: - # pass - # with self.queue_cond: - - def asic_write(self, addr: int, data: int, dummy: bool = False) -> int: + self._is_worker_running = True + self._commands: dict[str, ASICCommand] = {} + self._queue_lock = threading.Lock() + self._queue_cond = threading.Condition(self._queue_lock) + + self.logger = logging.getLogger("dwe_os_2.cameras.shd.ASICInterface") + + self._thread = threading.Thread(target=self._sync_sync_asic_writes) + self._thread.start() + + def _sync_sync_asic_writes(self) -> None: + while self._is_worker_running: + tasks_to_run = {} + + with self._queue_cond: + while not self._commands and self._is_worker_running: + self._queue_cond.wait() + + tasks_to_run = self._commands + self._commands = {} + + for key, task in tasks_to_run.items(): + self.logger.info(f"Running task {key} with {task.args}") + task.func(*task.args) + time.sleep(0.5) + + def queue_command(self, key: str, func: Callable, args: list) -> None: + with self._queue_cond: + self._commands[key] = ASICCommand(func, args) + self._queue_cond.notify() + + def asic_write(self, key: str, addr: int, data: int) -> None: + self.queue_command(key, self.sync_asic_write, [addr, data]) + + def asic_write_high_low( + self, key: str, addr_high: int, addr_low: int, value: int + ) -> None: + self.queue_command( + key, + self.sync_asic_write_high_low, + [addr_high, addr_low, value], + ) + + def sensor_write(self, key: str, addr: int, data: int) -> None: + self.queue_command(key, self.sync_sensor_write, [addr, data]) + + def sensor_write_high_low( + self, key: str, addr_high: int, addr_low: int, value: int + ) -> None: + self.queue_command( + key, + self.sync_sensor_write_high_low, + [addr_high, addr_low, value], + ) + + def sync_asic_write(self, addr: int, data: int, dummy: bool = False) -> int: """ Write a value to an ASIC register. """ @@ -82,7 +113,7 @@ def asic_read(self, addr: int) -> tuple[int, int]: """ with self._lock: # perform a dummy write to select the correct address - ret = self.asic_write(addr, 0, True) + ret = self.sync_asic_write(addr, 0, True) if ret != 0: return (ret, -1) @@ -98,26 +129,34 @@ def asic_read(self, addr: int) -> tuple[int, int]: val = ctrl_data[2] return (ret, val) - def asic_write_high_low(self, addr_high: int, addr_low: int, value: int) -> None: + def sync_asic_write_high_low( + self, addr_high: int, addr_low: int, value: int + ) -> None: + """ + Write asic with high and low registers + """ val_low = value & 0xFF # TODO: return after first fails... (we dont even use the status anyway) - _ret = self.asic_write(addr_low, val_low) + _ret = self.sync_asic_write(addr_low, val_low) val_high = value >> 8 & 0xFF - _ret = self.asic_write(addr_high, val_high) + _ret = self.sync_asic_write(addr_high, val_high) def asic_read_high_low( self, addr_high: int, addr_low: int, ) -> int | None: + """ + Read asic with high and low registers + """ ret, val_high = self.asic_read(addr_high) ret, val_low = self.asic_read(addr_low) if ret != 0: return None return val_high << 8 | val_low - def sensor_write(self, reg: int, val: int) -> int: + def sync_sensor_write(self, reg: int, val: int) -> int: """ Write a value to a sensor register. """ @@ -129,15 +168,15 @@ def sensor_write(self, reg: int, val: int) -> int: ret = 0 # Set address high - ret |= self.asic_write(StellarRegisterMap.REG_ADDR_H, high) + ret |= self.sync_asic_write(StellarRegisterMap.REG_ADDR_H, high) # Set address low - ret |= self.asic_write(StellarRegisterMap.REG_ADDR_L, low) + ret |= self.sync_asic_write(StellarRegisterMap.REG_ADDR_L, low) # Set data - ret |= self.asic_write(StellarRegisterMap.REG_DATA, val) + ret |= self.sync_asic_write(StellarRegisterMap.REG_DATA, val) # Set mode to write ('W' = 0x57) - ret |= self.asic_write(StellarRegisterMap.REG_MODE, 0x57) + ret |= self.sync_asic_write(StellarRegisterMap.REG_MODE, 0x57) # Trigger the command (0x55) - ret |= self.asic_write(StellarRegisterMap.REG_TRIG, 0x55) + ret |= self.sync_asic_write(StellarRegisterMap.REG_TRIG, 0x55) return ret @@ -154,13 +193,13 @@ def sensor_read(self, reg: int) -> tuple[int, int]: ret = 0 # Set address high - ret |= self.asic_write(StellarRegisterMap.REG_ADDR_H, high) + ret |= self.sync_asic_write(StellarRegisterMap.REG_ADDR_H, high) # Set address low - ret |= self.asic_write(StellarRegisterMap.REG_ADDR_L, low) + ret |= self.sync_asic_write(StellarRegisterMap.REG_ADDR_L, low) # Set mode to write ('R' = 0x52) - ret |= self.asic_write(StellarRegisterMap.REG_MODE, 0x52) + ret |= self.sync_asic_write(StellarRegisterMap.REG_MODE, 0x52) # Trigger the command (0x55) - ret |= self.asic_write(StellarRegisterMap.REG_TRIG, 0x55) + ret |= self.sync_asic_write(StellarRegisterMap.REG_TRIG, 0x55) if ret != 0: return ret, -1 @@ -169,19 +208,21 @@ def sensor_read(self, reg: int) -> tuple[int, int]: return ret, val - def sensor_write_high_low(self, reg_high: int, reg_low: int, value: int) -> None: + def sync_sensor_write_high_low( + self, reg_high: int, reg_low: int, value: int + ) -> None: """ Write high byte from value to high register, low byte to low """ - self.sensor_write(reg_high, (value >> 8) & 0xFF) + self.sync_sensor_write(reg_high, (value >> 8) & 0xFF) # This is extremely scuffed: switch to waiting for # trigger register before release (See below) - # time.sleep(0.1) + time.sleep(0.6) # Maybe: add check for success (0xAA in REG_TRIG) # REG_TRIG actually seems to not work properly, so maybe # we find another alternative - self.sensor_write(reg_low, value & 0xFF) + self.sync_sensor_write(reg_low, value & 0xFF) def sensor_read_high_low(self, reg_high, reg_low) -> int | None: """ diff --git a/backend_py/src/services/cameras/drivers/shd/options.py b/backend_py/src/services/cameras/drivers/shd/options.py index 9a18638f..4b193220 100644 --- a/backend_py/src/services/cameras/drivers/shd/options.py +++ b/backend_py/src/services/cameras/drivers/shd/options.py @@ -56,7 +56,7 @@ def __init__(self, asic_interface: ASICInterface) -> None: ) def _write(self, value: int | float | bool) -> None: - self._interface.asic_write(StellarRegisterMap.REG_AE, int(value)) + self._interface.asic_write(self.name, StellarRegisterMap.REG_AE, int(value)) def _read(self) -> bool | None: ae, ret = self._interface.asic_read(StellarRegisterMap.REG_AE) @@ -85,7 +85,7 @@ def __init__( def _write(self, value: int | float | bool) -> None: # TODO: Require int self._interface.asic_write_high_low( - self.high_register, self.low_register, int(value) + self.name, self.high_register, self.low_register, int(value) ) def _read(self) -> int | None: @@ -101,7 +101,7 @@ def __init__( "Hardware Bitrate", ControlFlagsModel( default_value=13000, - max_value=13000, + max_value=60000, min_value=100, step=1, control_type=ControlTypeEnum.INTEGER, @@ -114,8 +114,10 @@ def __init__( def _write(self, value: int | float | bool) -> None: super()._write(value) - # Trigger the bitrate value - self._interface.asic_write(StellarRegisterMap.REG_HW_BITRATE_TRIG, 1) + # Trigger the bitrate value (must be a diff key) + self._interface.asic_write( + self.name + "trig", StellarRegisterMap.REG_HW_BITRATE_TRIG, 1 + ) class SensorHighLowOption(ASICOption): @@ -138,7 +140,7 @@ def __init__( def _write(self, value: int | float | bool) -> None: self._interface.sensor_write_high_low( - self.high_register, self.low_register, int(value) + self.name, self.high_register, self.low_register, int(value) ) def _read(self) -> int | float | bool | None: @@ -181,6 +183,26 @@ def __init__(self, asic_interface: ASICInterface) -> None: ) +class FakeOption(BaseOption): + def __init__(self, name: str) -> None: + super().__init__( + name, + ControlFlagsModel(default_value=0, control_type=ControlTypeEnum.INTEGER), + ) + + def _write(self, value: int | float | bool) -> None: + pass + + def _read(self) -> int | float | bool | None: + pass + + def set_value(self, value: int | float | bool) -> None: + pass + + def get_value(self) -> int | float | bool: + return 0 + + class HtsOption(SensorHighLowOption): def __init__(self, asic_interface: ASICInterface) -> None: super().__init__( @@ -210,8 +232,8 @@ def __init__(self, asic_interface: ASICInterface) -> None: control_type=ControlTypeEnum.INTEGER, ), asic_interface, - StellarSensorMap.SHUTTER_HIGH, - StellarSensorMap.SHUTTER_LOW, + StellarSensorMap.ISO_HIGH, + StellarSensorMap.ISO_LOW, ) diff --git a/backend_py/src/services/cameras/drivers/shd/shd.py b/backend_py/src/services/cameras/drivers/shd/shd.py index 2e35220c..34b1e5ab 100644 --- a/backend_py/src/services/cameras/drivers/shd/shd.py +++ b/backend_py/src/services/cameras/drivers/shd/shd.py @@ -64,6 +64,8 @@ def __init__( "hw_bitrate": HardwareBitrateOption(self.asic_interface), "vts": VtsOption(self.asic_interface), "hts": HtsOption(self.asic_interface), + # "vts": FakeOption("VTS"), + # "hts": FakeOption("HTS"), } self.add_control_from_option("auto_exposure") @@ -171,6 +173,10 @@ def start_stream(self) -> None: def reapply_sensor_config(self) -> None: self.logger.info("Reapplying options after starting stream.") + # FIXME: We only set bitrate rn + bitrate_option = self._options["hw_bitrate"] + bitrate_option.set_value(bitrate_option.get_value()) # Reapply options after starting stream - for _option_name, option in self._options.items(): - option.set_value(option.get_value()) + # for _option_name, option in self._options.items(): + # if _option_name.lower() not in ["vts", "hts"]: + # option.set_value(option.get_value()) From da3a2eb47f89ed4fd8510f3b6ec817b1ffa3b721 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 14 May 2026 19:08:55 -0700 Subject: [PATCH 24/34] Change SerialPWMController to only log a command when it's connected --- backend_py/src/services/cameras/pwm/serial_pwm_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend_py/src/services/cameras/pwm/serial_pwm_controller.py b/backend_py/src/services/cameras/pwm/serial_pwm_controller.py index 76e563db..d3b0ffe8 100644 --- a/backend_py/src/services/cameras/pwm/serial_pwm_controller.py +++ b/backend_py/src/services/cameras/pwm/serial_pwm_controller.py @@ -113,9 +113,9 @@ def apply(self, frequency: float, duty_cycle: int) -> None: if not self.found_port: return command = f"{frequency + self.frequency_offset},{duty_cycle}\n" - self.logger.info(f"Sending command {command.strip()}") if self.serial: + self.logger.info(f"Sending command {command.strip()}") self.serial.write(command.encode("utf-8")) def apply_from_fps(self, fps: int) -> None: From 0ca13ddd8e1e8450b59919849272c432b6b7cb5c Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 14 May 2026 19:09:35 -0700 Subject: [PATCH 25/34] [WIP] Minor updates to SynchronizedStreamEngine to improve reliability --- .../synchronized_stream_engine.py | 7 ++-- .../cameras/synchronized_camera/lib.py | 38 +++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) 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 8679a4d2..0a620e3b 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 @@ -31,7 +31,7 @@ def __init__(self, streams, error_callback) -> None: self.capture_thread: threading.Thread | None = None self._running = False - self.synchronized_camera = None + self.synchronized_camera: SynchronizedCamera | None = None # Always MJPEG try: @@ -142,12 +142,13 @@ def start(self) -> None: self.stream_thread.start() def stop(self) -> None: + self.logger.info("Stopping stream engine") self._running = False if self.capture_thread: - self.capture_thread.join(timeout=1) + self.capture_thread.join(timeout=5) if self.stream_thread: - self.stream_thread.join(timeout=1) + self.stream_thread.join(timeout=5) def capture_loop_(self) -> None: if not self.synchronized_camera: diff --git a/backend_py/src/services/cameras/synchronized_camera/lib.py b/backend_py/src/services/cameras/synchronized_camera/lib.py index 2525ef58..216705fd 100644 --- a/backend_py/src/services/cameras/synchronized_camera/lib.py +++ b/backend_py/src/services/cameras/synchronized_camera/lib.py @@ -49,7 +49,6 @@ def __init__( pixel_format: int = v4l2.V4L2_PIX_FMT_MJPEG, buffer_count: int = 4, ) -> None: - self.device = device self.width = width self.height = height @@ -59,7 +58,7 @@ def __init__( self.critical_error = False try: - self.fd = os.open(device, os.O_RDWR | os.O_NONBLOCK) + self.fd = os.open(device, os.O_RDWR) except OSError as e: self.critical_error = True raise e @@ -68,6 +67,9 @@ def __init__( self.logger = logging.getLogger("dwe_os_2.cameras.V4L2Camera") + # This doesn't seem to work + # self._reset_usb_device() + self._set_format() self._set_fps() self._request_and_map_buffers() @@ -84,6 +86,32 @@ def _ioctl(self, req, arg) -> int: self.logger.error(f"IOCTL error: {e.strerror}") return -1 + def _reset_usb_device(self) -> None: + """ + From SDK ResetUSBDevice + """ + self.logger.info(f"Resetting USB Device: {self.device}") + + self._set_format() + self._set_fps() + + buf_type = ctypes.c_int(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE) + try: + self._ioctl(v4l2.VIDIOC_STREAMOFF, buf_type) + self._ioctl(v4l2.VIDIOC_STREAMOFF, buf_type) + except Exception: + self.logger.warning("Failed to run streamoff") + pass + + req = v4l2.v4l2_requestbuffers() + req.count = 0 + req.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + req.memory = v4l2.V4L2_MEMORY_MMAP + with contextlib.suppress(OSError): + self._ioctl(v4l2.VIDIOC_REQBUFS, req) + + self.logger.info("USB Device reset complete.") + def _set_format(self) -> None: fmt = v4l2.v4l2_format() fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE @@ -172,7 +200,7 @@ def _stop_stream(self) -> None: # Public API def grab_copied_frame( - self, blocking: bool = True, timeout_s: float = 1.0 + self, blocking: bool = True, timeout_s: float = 5 ) -> CopiedFrame | None: """ Dequeue one buffer, copy its contents into a new bytes object, @@ -180,6 +208,10 @@ def grab_copied_frame( If blocking=False, returns None immediately if no frame is ready. """ + if not self.fd: + self.logger.error("FD is none!") + return None + if blocking: import select From 96e2278c3a3a7e373b654c374f2651badd738d11 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Thu, 14 May 2026 19:31:50 -0700 Subject: [PATCH 26/34] Update backend action to use caching and virtual environments --- .github/workflows/backend.yml | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index bd52bb5e..cda83111 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -19,10 +19,6 @@ jobs: build: runs-on: ubuntu-22.04 - defaults: - run: - working-directory: ./backend_py - steps: - uses: actions/checkout@v4 @@ -31,21 +27,37 @@ jobs: with: python-version: "3.11" + - name: Restore cached virtualenv + id: cache-venv-restore + uses: actions/cache/restore@v5 + with: + key: venv-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('requirements.txt') }} + path: .venv + - name: Install dependencies + if: steps.cache-venv-restore.outputs.cache-hit != 'true' run: | # TODO: switch to using proper dev dependencies sudo apt-get install build-essential libdbus-glib-1-dev libdbus-1-dev libpython3-dev -y # For dbus-python - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install ruff bandit ty # dev dependencies + ./create_venv.sh + source .venv/bin/activate + echo "$VIRTUAL_ENV/bin" >> $GITHUB_PATH + echo "VIRTUAL_ENV=$VIRTUAL_ENV" >> $GITHUB_ENV + pip install ruff bandit ty + + - name: Saved cached virtualenv + uses: actions/cache/save@v4 + with: + key: ${{ steps.cache-primes-restore.outputs.cache-primary-key }} + path: .venv - name: Ruff linting - run: ruff check --output-format=github . + run: ruff check --output-format=github backend_py - name: Ruff formatting - run: ruff format --check . + run: ruff format --check backend_py - name: Bandit security check (high severity) - run: bandit -r . -lll + run: bandit -r backend_py -lll - name: Type checking (ty) - run: cd .. && ty check backend_py + run: ty check backend_py From 6cddd2e45372709d490c573724b14cc69aa60b93 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Fri, 15 May 2026 23:16:59 -0700 Subject: [PATCH 27/34] Significant improvements to streaming system, re-add saved leader-follower, refactor settings and improve lookup time by switching to a dictionary for device lists, and fix issues in log viewer --- backend_py/src/server.py | 3 +- .../src/services/cameras/device_manager.py | 71 +++----- .../src/services/cameras/device_utils.py | 9 +- .../src/services/cameras/drivers/device.py | 23 ++- .../src/services/cameras/drivers/ehd/ehd.py | 3 + .../src/services/cameras/drivers/shd/shd.py | 73 ++++++-- .../synchronized_stream_engine.py | 9 +- .../cameras/synchronized_camera/lib.py | 5 +- .../services/preferences/settings_manager.py | 164 ++++++++---------- .../components/dwe/log-page/log-viewer.tsx | 22 ++- frontend/tsconfig.app.json | 12 +- 11 files changed, 209 insertions(+), 185 deletions(-) diff --git a/backend_py/src/server.py b/backend_py/src/server.py index 0b0c6355..f45bd0a0 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -75,7 +75,7 @@ def __init__( "%(thin_white)s%(filename)s:%(lineno)d%(reset)s " "%(white)s%(message)s%(reset)s" ), - datefmt="%H:%M:%S", + # datefmt="%Y-%m-%dT%H:%M:%S", reset=True, log_colors={ "DEBUG": "cyan", @@ -87,6 +87,7 @@ def __init__( secondary_log_colors={}, style="%", ) + self.log_formatter.default_msec_format = "%s.%03d" self.stream_handler.setFormatter(self.log_formatter) self.file_handler = logging.handlers.RotatingFileHandler( "dwe_os_2.log", diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index 5dc70bc4..937e69c0 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -1,7 +1,7 @@ """ device_manager.py -Handles functionality of device and montiors for devices +Handles functionality of device and monitors 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 @@ -74,7 +74,8 @@ def __init__( ) -> None: super().__init__() - self.devices: list[Device] = [] + self.device_dict: dict[str, Device] = {} + self.sio = sio self.settings_manager = settings_manager self._is_monitoring = False @@ -88,6 +89,10 @@ def __init__( # Captured in start_monitoring self._loop: asyncio.AbstractEventLoop | None = None + @property + def devices(self) -> list[Device]: + return list(self.device_dict.values()) + def start_monitoring(self) -> None: """ Begin monitoring for devices in the background @@ -136,7 +141,10 @@ 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)) + # Hack to allow shd to save follower and leader settings on removal + device.on("save", lambda: self.settings_manager.save_device(device)) + + # device.on("frame_stats", lambda: self._schedule_emit_frame_stats(device)) if self.serial: device.on("pwm_frequency", self.serial.apply_from_fps) @@ -278,12 +286,14 @@ def _find_device_with_bus_info(self, bus_info: str) -> Device: """ Utility to find a device with bus info """ - device = find_device_with_bus_info(self.devices, bus_info) + device = find_device_with_bus_info(self.device_dict, bus_info) if not device: raise DeviceNotFoundException(bus_info) return device - async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: + async def _get_devices( + self, old_devices: list[DeviceInfo], is_initial: bool = False + ) -> list[DeviceInfo]: # enumerate the devices devices_info = list_devices() @@ -307,9 +317,9 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: self.logger.warning(e) continue # append the device to the device list - self.devices.append(device) + self.device_dict[device.bus_info] = device # load the settings - self.settings_manager.load_device(device, self.devices) + self.settings_manager.load_device(device, self.device_dict) # Output device to log (after loading settings) self.logger.info(f"Device Added: {device_info.bus_info}") @@ -320,14 +330,10 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: bus_info = self.stream_errors.pop() await self._emit_stream_error(bus_info, "Stream Error") - if len(removed_devices) > 0 or len(new_devices) > 0: - # make sure to load the leader followers in case there are new ones to check - self.settings_manager.link_followers(self.devices) - # remove the old devices for device_info in removed_devices: removed_device = find_device_with_bus_info( - self.devices, device_info.bus_info + self.device_dict, device_info.bus_info ) if not removed_device: @@ -338,41 +344,18 @@ async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: # 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) - - self.devices.remove(removed_device) + + removed_device.remove_device() + + self.device_dict.pop(removed_device.bus_info) self.logger.info(f"Device Removed: {device_info.bus_info}") await self.sio.emit("device_removed", device_info.bus_info) + if len(removed_devices) > 0 or len(new_devices) > 0: + # make sure to load the leader followers in case there are new ones to check + self.settings_manager.link_followers(self.device_dict) + if device_added: # FIXME: Issue where sometimes frontend updates too quickly before the # changes have been made @@ -415,7 +398,7 @@ async def _monitor(self) -> None: """ Internal code to monitor devices for changes """ - devices_info = await self._get_devices([]) + devices_info = await self._get_devices([], True) while self._is_monitoring: # do not overload the bus diff --git a/backend_py/src/services/cameras/device_utils.py b/backend_py/src/services/cameras/device_utils.py index a6657baa..8bef2fb0 100644 --- a/backend_py/src/services/cameras/device_utils.py +++ b/backend_py/src/services/cameras/device_utils.py @@ -8,11 +8,10 @@ from .drivers.device import Device -def find_device_with_bus_info(devices: list[Device], bus_info: str) -> Device | None: - for device in devices: - if device.bus_info == bus_info: - return device - return None +def find_device_with_bus_info( + devices: dict[str, Device], bus_info: str +) -> Device | None: + return devices.get(bus_info) def list_diff(listA: list, listB: list) -> list: diff --git a/backend_py/src/services/cameras/drivers/device.py b/backend_py/src/services/cameras/drivers/device.py index 90329353..4f018986 100644 --- a/backend_py/src/services/cameras/drivers/device.py +++ b/backend_py/src/services/cameras/drivers/device.py @@ -9,6 +9,7 @@ import contextlib import logging import threading +from abc import abstractmethod from typing import Any import event_emitter as events @@ -46,7 +47,9 @@ def __init__( for device_path in device_info.device_paths: self.cameras.append(Camera(device_path)) - self.logger = logging.getLogger("dwe_os_2.cameras.Device") + self.logger = logging.getLogger( + f"dwe_os_2.cameras.Device.{device_info.bus_info}" + ) self.logger.setLevel(logging.DEBUG) # If this device has been constructed, we can assume the DeviceManager did @@ -103,6 +106,14 @@ def __init__( self._get_controls() + @property + def can_lead(self) -> bool: + return False + + @property + def can_follow(self) -> bool: + return False + def _update_drop_stats(self) -> None: with self._frame_stats_lock: self.frame_stats.num_drops += 1 @@ -184,7 +195,7 @@ def configure_stream( if stream_endpoints is None: stream_endpoints = [] - self.logger.info(self._fmt_log("Configuring stream")) + self.logger.info("Configuring stream") camera: Camera | None = None match encode_type: @@ -268,7 +279,7 @@ def close(self) -> None: self.v4l2_device.close() def load_settings(self, saved_device: SavedDeviceModel) -> None: - self.logger.info(self._fmt_log("Loading device settings")) + self.logger.info("Loading device settings") for control in saved_device.controls: self.set_pu(control.control_id, control.value) @@ -350,9 +361,13 @@ def get_option(self, opt: str) -> Any: # set an option def set_option(self, opt: str, value: Any) -> None: - # self.logger.debug(self._fmt_log(f"Setting option - {opt} to {value}")) + self.logger.debug(f"Setting option - {opt} to {value}") if opt in self._options: self._options[opt].set_value(value) + @abstractmethod + def remove_device(self) -> None: + pass + def _fmt_log(self, message: str) -> str: return f"{self.bus_info} - {message}" diff --git a/backend_py/src/services/cameras/drivers/ehd/ehd.py b/backend_py/src/services/cameras/drivers/ehd/ehd.py index d5e7d6aa..fab0c8ec 100644 --- a/backend_py/src/services/cameras/drivers/ehd/ehd.py +++ b/backend_py/src/services/cameras/drivers/ehd/ehd.py @@ -31,3 +31,6 @@ def __init__( self.add_control_from_option("gop") self.add_control_from_option("bitrate") self.add_control_from_option("vbr") + + def remove_device(self) -> None: + pass diff --git a/backend_py/src/services/cameras/drivers/shd/shd.py b/backend_py/src/services/cameras/drivers/shd/shd.py index 34b1e5ab..18726640 100644 --- a/backend_py/src/services/cameras/drivers/shd/shd.py +++ b/backend_py/src/services/cameras/drivers/shd/shd.py @@ -4,6 +4,8 @@ Adds additional features to stellarHD devices """ +from backend_py.src.models import DeviceType + from ..device import Device, DeviceMetadata from ..video4linux import DeviceInfo from .asic_interface import ASICInterface @@ -46,11 +48,16 @@ def __init__( self.followers: list[str] = [] # These exist - self.follower_devices: list[SHDDevice] = [] + self.follower_devices: dict[str, SHDDevice] = {} # Is true if it is managed, false otherwise self.is_managed = False + # Should directly correspond with self.is_managed + # I've been hesitant to add this out of fear that it will incorrectly represent + # state, but if we keep inline with that requirement, we should be ok + self.leader_device: SHDDevice | None = None + # ASIC Interface for low level register read/writes self.asic_interface = ASICInterface(self.cameras[0]) @@ -76,8 +83,17 @@ def __init__( self.add_control_from_option("vts") self.add_control_from_option("hts") + @property + def can_lead(self) -> bool: + return True + + @property + def can_follow(self) -> bool: + return self.device_type == DeviceType.STELLARHD_FOLLOWER + def add_follower(self, device: "SHDDevice") -> None: - if device.bus_info in self.followers: + # CHANGED: only check if it's in follower devices not the follower list + if device.bus_info in self.follower_devices: self.logger.info( "Trying to add follower to device that already has this device as a " "follower. Ignoring request." @@ -93,30 +109,30 @@ def add_follower(self, device: "SHDDevice") -> None: self.logger.info("Adding follower") # For saving purposes - self.followers.append(device.bus_info) + if device.bus_info not in self.followers: + self.followers.append(device.bus_info) # This is the real addition - self.follower_devices.append(device) + self.follower_devices[device.bus_info] = device # Make the follower managed - device.set_is_managed(True) + device.set_leader(self) if self.stream.enabled: self.start_stream() - def remove_follower(self, device: "SHDDevice") -> None: + def remove_follower(self, device: "SHDDevice", persist=True) -> None: if device.bus_info not in self.followers: self.logger.info( "Cannot remove follower from device that does not contain it." ) return # Reconstruct the list without the follower - self.followers = [dev for dev in self.followers if dev != device.bus_info] - self.follower_devices = [ - dev for dev in self.follower_devices if dev.bus_info != device.bus_info - ] + if persist: + self.followers = [dev for dev in self.followers if dev != device.bus_info] + self.follower_devices.pop(device.bus_info) - device.set_is_managed(False) + device.remove_leader() self.logger.info("Removing follower") @@ -129,12 +145,13 @@ def remove_manual(self, follower_bus_info: str) -> None: """ self.followers.remove(follower_bus_info) - def set_is_managed(self, is_managed: bool) -> None: - self.is_managed = is_managed + def set_leader(self, leader: "SHDDevice") -> None: + self.is_managed = True + self.leader_device = leader - # Configure stream if needbe - if not is_managed and self.stream.enabled: - self.start_stream() + def remove_leader(self) -> None: + self.is_managed = False + self.leader_device = None def start_stream(self) -> None: if self.is_managed: @@ -145,7 +162,7 @@ def start_stream(self) -> None: self.stream_runner.streams = [self.stream] - for follower_device in self.follower_devices: + for follower_device in self.follower_devices.values(): # A not so hacky fix (very clever :]) to ensure the stream's device_path is # set follower_device.configure_stream( @@ -167,9 +184,29 @@ def start_stream(self) -> None: super().start_stream() self.reapply_sensor_config() - for follower in self.follower_devices: + for follower in self.follower_devices.values(): follower.reapply_sensor_config() + def remove_device(self) -> None: + # Unplugging a device makes it too complicated to handle its follower stream, + # so we just remove the follower and revert it to a normal stream + for follower in self.follower_devices.values(): + follower.remove_leader() + # self.remove_manual(follower.bus_info) + # needs to emit a device change perhaps (frontend can handle + # this separately too) + # self.emit("save") + + # This requires settings manager and it works without this logic + # FIXME: Let's decide whether or not to save followers and leaders when unplug. + # I mean, whats the difference between unplugging a device while dwe os is + # running and while it's not for example, we need to handle it anyway, so might + # as well make it easier to test the bugs + # If it's a follower device, we need to remove ourselves from the leader + if self.is_managed and self.leader_device: + self.leader_device.remove_follower(self, False) + # self.leader_device.emit("save") + def reapply_sensor_config(self) -> None: self.logger.info("Reapplying options after starting stream.") 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 0a620e3b..b2199898 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 @@ -143,6 +143,10 @@ def start(self) -> None: def stop(self) -> None: self.logger.info("Stopping stream engine") + + # NOTE: This causes errors to report because it's still trying to grab + if self.synchronized_camera: + self.synchronized_camera.stop() self._running = False if self.capture_thread: @@ -150,6 +154,8 @@ def stop(self) -> None: if self.stream_thread: self.stream_thread.join(timeout=5) + self.logger.info("Stopped stream engine") + def capture_loop_(self) -> None: if not self.synchronized_camera: self.logger.error( @@ -165,13 +171,14 @@ def capture_loop_(self) -> None: continue self.frame_queue.append((frames[0], frames[1])) - self.synchronized_camera.stop() def stream_loop_(self) -> None: while self._running: try: endpoint = self.streams[0].endpoints[0] except IndexError: + # FIXME + time.sleep(1 / self.streams[0].interval.denominator) continue # TODO: do not assume two try: diff --git a/backend_py/src/services/cameras/synchronized_camera/lib.py b/backend_py/src/services/cameras/synchronized_camera/lib.py index 216705fd..48341ca6 100644 --- a/backend_py/src/services/cameras/synchronized_camera/lib.py +++ b/backend_py/src/services/cameras/synchronized_camera/lib.py @@ -58,7 +58,7 @@ def __init__( self.critical_error = False try: - self.fd = os.open(device, os.O_RDWR) + self.fd = os.open(device, os.O_RDWR | os.O_NONBLOCK) except OSError as e: self.critical_error = True raise e @@ -69,6 +69,7 @@ def __init__( # This doesn't seem to work # self._reset_usb_device() + # self._set_format() self._set_fps() @@ -200,7 +201,7 @@ def _stop_stream(self) -> None: # Public API def grab_copied_frame( - self, blocking: bool = True, timeout_s: float = 5 + self, blocking: bool = True, timeout_s: float = 0.1 ) -> CopiedFrame | None: """ Dequeue one buffer, copy its contents into a new bytes object, diff --git a/backend_py/src/services/preferences/settings_manager.py b/backend_py/src/services/preferences/settings_manager.py index 59a4204b..0ab094e9 100644 --- a/backend_py/src/services/preferences/settings_manager.py +++ b/backend_py/src/services/preferences/settings_manager.py @@ -12,9 +12,7 @@ from typing import cast from backend_py.src.models import ( - DeviceType, SavedDeviceModel, - SavedLeaderFollowerPairModel, ) from ..cameras.device_utils import find_device_with_bus_info @@ -31,9 +29,9 @@ def __init__(self, settings_path: str = ".") -> None: open(path, "w").close() self.file_object = open(path, "r+") # noqa: SIM115 - self._lock = threading.Lock() - - self.leader_follower_pairs: list[SavedLeaderFollowerPairModel] = [] + # NOTE: not sure if RLock is the correct change to make, + # Lock might work fine here + self._lock = threading.RLock() self.logger = logging.getLogger("dwe_os_2.SettingsManager") @@ -60,7 +58,7 @@ def cleanup(self) -> None: self.file_object.close() def _load_device( - self, device: Device, saved_device: SavedDeviceModel, devices: list[Device] + self, device: Device, saved_device: SavedDeviceModel, devices: dict[str, Device] ) -> None: if device.device_type != saved_device.device_type: self.logger.info( @@ -74,113 +72,89 @@ def _load_device( device.load_settings(saved_device) - # We plugged in a new leader - if isinstance(device, SHDDevice) and saved_device.followers: - for follower_bus_info in saved_device.followers: - follower = find_device_with_bus_info(devices, follower_bus_info) - if not follower: - self.logger.warning( - f"Follower device with bus_info {follower_bus_info} " - "not currently connected" - ) - continue - - if follower.device_type != DeviceType.STELLARHD_FOLLOWER: - self.logger.warning( - f"Follower device {follower.bus_info} is not of " - "follower type, skipping" - ) - saved_device.followers.remove(follower_bus_info) - continue - - follower = cast(SHDDevice, follower) - if follower.is_managed: - self.logger.info("Saved follower already has a new leader") - # This is true when the follower has now gotten a new leader - saved_device.followers.remove(follower_bus_info) - continue - device.add_follower(follower) - - # We plugged in a new follower - if device.device_type == DeviceType.STELLARHD_FOLLOWER: - for potential_leader in devices: - # Skip if the potential leader is not an SHDDevice (cannot lead) - if not isinstance(potential_leader, SHDDevice): - continue - - # Don't try to follow yourself - # Though this should also be checked elsewhere, why not :shrug: - if potential_leader.bus_info == device.bus_info: - continue - - saved_leader = self.saved_by_bus_info.get(potential_leader.bus_info) - if not saved_leader or not saved_leader.followers: - continue - - if device.bus_info in saved_leader.followers: - follower = cast(SHDDevice, device) - potential_leader.add_follower(follower) - break # Only follow one leader - - def load_device(self, device: Device, devices: list[Device]) -> None: + def load_device(self, device: Device, devices: dict[str, Device]) -> None: with self._lock: for saved_device in self.settings: if saved_device.bus_info == device.bus_info: self._load_device(device, saved_device, devices) return - def link_followers(self, devices: list[Device]) -> None: + def get_saved_device(self, bus_info: str) -> SavedDeviceModel | None: + for saved_device in self.settings: + if saved_device.bus_info == bus_info: + return saved_device + return None + + def link_followers(self, device_dict: dict[str, Device]) -> None: """ Run this when we need to check for new devices """ - for leader in devices: - # Changed: We now allow followers to be leaders (of other followers) - if not isinstance(leader, SHDDevice): - continue - saved = self.saved_by_bus_info.get(leader.bus_info) + devices = device_dict.values() - # This device has not been saved - if not saved or not saved.followers: + for device in devices: + if not isinstance(device, SHDDevice): continue + saved_device = self.get_saved_device(device.bus_info) - for follower_bus_info in saved.followers: - if follower_bus_info in leader.followers: - # Already loaded - continue - - follower = find_device_with_bus_info(devices, follower_bus_info) - - # If this follower does not exist, that is ok - # There is no inherent truth to the existance of the followers list - if not follower: - continue - - # What is worse than it not existing, however, is it not being a - # follower. So, we delete - if follower.device_type != DeviceType.STELLARHD_FOLLOWER: - self.logger.warning( - f"Follower device {follower.bus_info} is not of follower type, " - "skipping" - ) - saved.followers.remove(follower_bus_info) - continue + if not saved_device: + continue - follower = cast(SHDDevice, follower) - leader.add_follower(follower) - - def save_device(self, device: Device) -> None: - saved_device = SavedDeviceModel.model_validate(device) + if device.can_lead and saved_device.followers: + self.logger.info("Adding followers") + new_followers = [] + for follower_bus_info in saved_device.followers: + follower = find_device_with_bus_info(device_dict, follower_bus_info) + + # If this follower does not exist, that is ok + # There is no inherent truth to the existance of the followers list + if not follower: + self.logger.warning( + f"Follower device {follower_bus_info} corresponding to " + f"device {device.bus_info} not yet found." + ) + new_followers.append(follower_bus_info) + continue + + # What is worse than it not existing, however, is it not being a + # follower. So, we delete + if not follower.can_follow: + self.logger.warning( + f"Follower device {follower.bus_info} is not of follower" + " type, skipping" + ) + continue + + follower = cast(SHDDevice, follower) + device.add_follower(follower) + new_followers.append(follower_bus_info) + + saved_device.followers = new_followers + + self._update_settings() + + def _update_settings(self) -> None: + self.file_object.seek(0) + # FIXME: remove indent when we are done testing settings + # (switch to dev mode only) + self.file_object.write( + json.dumps([model.model_dump() for model in self.settings], indent=4) + ) + self.file_object.truncate() + self.file_object.flush() + + def _save_device(self, saved_device: SavedDeviceModel) -> None: + self.logger.info(f"Saving device: {saved_device.bus_info}") with self._lock: + # Semi scuffed for dev in self.settings: if dev.bus_info == saved_device.bus_info: self.settings.remove(dev) break self.settings.append(saved_device) - self.file_object.seek(0) - self.file_object.write( - json.dumps([model.model_dump() for model in self.settings]) - ) - self.file_object.truncate() - self.file_object.flush() + self._update_settings() + + def save_device(self, device: Device) -> None: + saved_device = SavedDeviceModel.model_validate(device) + self._save_device(saved_device) diff --git a/frontend/src/components/dwe/log-page/log-viewer.tsx b/frontend/src/components/dwe/log-page/log-viewer.tsx index 7d427c0d..386412ba 100644 --- a/frontend/src/components/dwe/log-page/log-viewer.tsx +++ b/frontend/src/components/dwe/log-page/log-viewer.tsx @@ -96,7 +96,15 @@ export function LogViewer() { const formatTimestamp = (timestamp: string) => { try { const date = new Date(timestamp.replace(",", ".")); - return date.toLocaleString(); + return date.toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { return timestamp; // Return original if parsing fails @@ -109,9 +117,7 @@ export function LogViewer() { // Sort newest first filtered = filtered.sort((a, b) => { - const dateA = new Date(a.timestamp.replace(",", ".")).getTime(); - const dateB = new Date(b.timestamp.replace(",", ".")).getTime(); - return dateB - dateA; // newest first + return b.timestamp.localeCompare(a.timestamp); }); // Filter by level @@ -223,10 +229,10 @@ export function LogViewer() { - Timestamp - Level - Logger - Source + Timestamp + Level + Logger + Source Message diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index fa6b32e1..4db0191c 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -1,9 +1,9 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", + "target": "ES2021", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, @@ -24,10 +24,8 @@ "baseUrl": ".", "paths": { - "@/*": [ - "./src/*" - ] - } + "@/*": ["./src/*"], + }, }, - "include": ["src"] + "include": ["src"], } From 0945ead84bfb66518a670c4a3689c8188b0eaad9 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Mon, 18 May 2026 12:45:14 -0700 Subject: [PATCH 28/34] Fix cameras route by removing unnecessary async and legacy shd code --- backend_py/src/routes/cameras.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/backend_py/src/routes/cameras.py b/backend_py/src/routes/cameras.py index e264a861..7b51ac1e 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -6,8 +6,6 @@ setting UVC controls, and dealing with Leader/Follower for stereo cameras """ -from typing import cast - from fastapi import APIRouter, Request from backend_py.src.models import ( @@ -15,14 +13,12 @@ DeviceDescriptorModel, DeviceModel, DeviceNicknameModel, - DeviceType, StreamInfoModel, UVCControlModel, ) from ..schemas import SimpleRequestStatusModel from ..services.cameras import DeviceManager -from ..services.cameras.drivers.shd import SHDDevice from ..services.cameras.exceptions import DeviceNotFoundException camera_router = APIRouter(tags=["cameras"]) @@ -36,24 +32,13 @@ def get_devices(request: Request) -> list[DeviceModel]: @camera_router.post("/configure_stream", summary="Configure a stream") -async def configure_stream( +def configure_stream( request: Request, stream_info: StreamInfoModel ) -> SimpleRequestStatusModel: device_manager: DeviceManager = request.app.state.device_manager device_manager.configure_device_stream(stream_info) - for device in device_manager.devices: - if device.bus_info == stream_info.bus_info: - if device.device_type != DeviceType.STELLARHD_FOLLOWER: - return SimpleRequestStatusModel(success=False) - break - for device in device_manager.devices: - if device.device_type == DeviceType.STELLARHD_LEADER: - stellarhd_device = cast(SHDDevice, device) - if stream_info.bus_info in stellarhd_device.followers: - stellarhd_device.start_stream() - return SimpleRequestStatusModel(success=True) From ab9d27a4f2a96e0ca6afd34dcd51a3a49fcec1a7 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Mon, 18 May 2026 16:27:24 -0700 Subject: [PATCH 29/34] Improve shutdown routine to avoid threading issues and add multiprocessing to SynchronizedStreamEngine --- backend_py/run.py | 19 +-- .../synchronized_stream_engine.py | 135 +++++++++--------- .../src/services/cameras/stream_runner.py | 2 - 3 files changed, 82 insertions(+), 74 deletions(-) diff --git a/backend_py/run.py b/backend_py/run.py index c5d247ed..5a6f97c0 100644 --- a/backend_py/run.py +++ b/backend_py/run.py @@ -6,6 +6,7 @@ import asyncio import contextlib import logging +import signal from contextlib import asynccontextmanager import socketio @@ -27,11 +28,8 @@ async def lifespan(app: FastAPI): # noqa: ANN201 await server.serve() yield - print("Shutting down server...") - try: - server.shutdown() - except Exception as e: - print(f"Error during shutdown: {e}") + + # Shutdown should go here, but it isn't always reached # FastAPI application @@ -69,8 +67,13 @@ async def lifespan(app: FastAPI): # noqa: ANN201 async def main() -> None: config = uvicorn.Config(app, host="0.0.0.0", port=5000, log_level="warning") - server = uvicorn.Server(config) - await server.serve() + uvicorn_server = uvicorn.Server(config) + signal.signal(signal.SIGINT, signal.SIG_IGN) + try: + await uvicorn_server.serve() + finally: + signal.signal(signal.SIGINT, signal.SIG_IGN) + server.shutdown() - with contextlib.suppress(KeyboardInterrupt): + with contextlib.suppress(KeyboardInterrupt, asyncio.CancelledError): asyncio.run(main()) 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 b2199898..5663645e 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 @@ -1,4 +1,8 @@ import collections +import logging +import logging.handlers +import multiprocessing +import multiprocessing.synchronize import socket import struct import threading @@ -11,27 +15,38 @@ V4L2Camera, ) -from .base_stream_engine import BaseStreamEngine +from .base_stream_engine import BaseStreamEngine, Stream -class SynchronizedStreamEngine(BaseStreamEngine): - def __init__(self, streams, error_callback) -> None: - super().__init__(streams, error_callback) +class StreamProcess: + def __init__( + self, + streams: list[Stream], + exit: multiprocessing.synchronize.Event, + log_queue: multiprocessing.Queue, + ) -> None: + self.MTU = 1400 + self.SSRC = 0x445745 # "DWE" + self.exit = exit + + self.streams = streams self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.frame_queue: collections.deque[tuple[CopiedFrame, CopiedFrame]] = ( collections.deque(maxlen=2) ) - self.MTU = 1400 - self.SSRC = 0x445745 # "DWE" - self.sequence_number = 1 + self.synchronized_camera: SynchronizedCamera | None = None - self.stream_thread: threading.Thread | None = None - self.capture_thread: threading.Thread | None = None - self._running = False + root_logger = logging.getLogger("dwe_os_2") - self.synchronized_camera: SynchronizedCamera | None = None + self.logger = logging.getLogger("dwe_os_2.cameras.SynchronizedStreamEngine") + + # remove handlers so that logs don't print + root_logger.handlers = [] + + ipc_handler = logging.handlers.QueueHandler(log_queue) + root_logger.addHandler(ipc_handler) # Always MJPEG try: @@ -46,11 +61,14 @@ def __init__(self, streams, error_callback) -> None: ] self.synchronized_camera = SynchronizedCamera(self.cameras) - self.synchronized_camera.on("frame_drop", lambda: self.emit("frame_drop")) + + # TODO: Add this back as IPC + # 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: - self.emit_error(e.strerror) + # if e.strerror: + # self.emit_error(e.strerror) + pass # Custom RTP improves performance compared to RTP class def _send_frame( @@ -58,11 +76,7 @@ def _send_frame( ) -> None: # TODO: change protocol to handle more than two cameras if len(frames) > 2: - self.logger.warning( - "Only 2 cameras are supported for synchronized streaming. " - "This is an in progress feature- please contact us if you " - "require it sooner." - ) + pass left_frame = frames[0] right_frame = frames[1] @@ -114,57 +128,15 @@ def _send_frame( offset += chunk_size sequence_number = (sequence_number + 1) & 0xFFFF - def start(self) -> None: - self.logger.info( - "Starting synchronized stream with: " - f"{(', '.join([stream.device_path for stream in self.streams]))}" - ) - # self.logger.warning("SynchronizedStreamEngine is not yet implemented") - if len(self.streams) != 2: - self.logger.error( - "SynchronizedStreamEngine cannot support more than 2 streams yet!" - ) - return - + def run(self) -> None: if not self.synchronized_camera: - self.logger.error( - "Synchronized camera does not exist. An error occurred previously in " - "construction!" - ) return - self.capture_thread = threading.Thread(target=self.capture_loop_) - self._running = True - self.capture_thread.start() - - # We cannot handle more than 2 synchronized streams yet in the protocol self.stream_thread = threading.Thread(target=self.stream_loop_) self.stream_thread.start() - def stop(self) -> None: - self.logger.info("Stopping stream engine") - - # NOTE: This causes errors to report because it's still trying to grab - if self.synchronized_camera: - self.synchronized_camera.stop() - self._running = False - - if self.capture_thread: - self.capture_thread.join(timeout=5) - if self.stream_thread: - self.stream_thread.join(timeout=5) - - self.logger.info("Stopped stream engine") - - def capture_loop_(self) -> None: - if not self.synchronized_camera: - self.logger.error( - "Cannot run capture loop when synchronized camera is not defined!" - ) - return - # We need to be careful about the blocking aspect of grab - while self._running: + while not self.exit.is_set(): frames = self.synchronized_camera.grab() if frames is None: time.sleep(1 / self.streams[0].interval.denominator) @@ -173,7 +145,7 @@ def capture_loop_(self) -> None: self.frame_queue.append((frames[0], frames[1])) def stream_loop_(self) -> None: - while self._running: + while not self.exit.is_set(): try: endpoint = self.streams[0].endpoints[0] except IndexError: @@ -188,3 +160,38 @@ def stream_loop_(self) -> None: except IndexError: time.sleep(1 / self.streams[0].interval.denominator) continue + + +class SynchronizedStreamEngine(BaseStreamEngine): + def __init__(self, streams, error_callback) -> None: + super().__init__(streams, error_callback) + + self.log_queue = multiprocessing.Queue() + root_logger = logging.getLogger("dwe_os_2") + self.log_listener = logging.handlers.QueueListener( + self.log_queue, *root_logger.handlers, respect_handler_level=True + ) + self.log_listener.start() + + self.exit = multiprocessing.Event() + self.process = multiprocessing.Process( + target=self._create_process, args=(streams, self.exit, self.log_queue) + ) + + def start(self) -> None: + self.process.start() + + def stop(self) -> None: + self.exit.set() + if self.process.is_alive(): + self.process.join() + self.logger.info("Successfully stopped SynchronizedStreamEngine process") + + def _create_process( + self, + streams: list[Stream], + exit: multiprocessing.synchronize.Event, + log_queue: multiprocessing.Queue, + ) -> None: + process = StreamProcess(streams, exit, log_queue) + process.run() diff --git a/backend_py/src/services/cameras/stream_runner.py b/backend_py/src/services/cameras/stream_runner.py index e09511dd..beb92683 100644 --- a/backend_py/src/services/cameras/stream_runner.py +++ b/backend_py/src/services/cameras/stream_runner.py @@ -4,7 +4,6 @@ import logging import threading -import time import event_emitter as events @@ -57,7 +56,6 @@ def start(self) -> None: ) if self.started: self.stop() - time.sleep(1) # We create the engine on start, so the engine can perform initial setup on # constructor From 440e82de2e448495457b9a894b7d05f682fab58e Mon Sep 17 00:00:00 2001 From: brandonhs Date: Mon, 18 May 2026 16:28:02 -0700 Subject: [PATCH 30/34] Add configurable delay to ASICInterface to remove delay for non shutter related options --- .../cameras/drivers/shd/asic_interface.py | 21 +++++++++++-------- .../services/cameras/drivers/shd/options.py | 9 +++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/backend_py/src/services/cameras/drivers/shd/asic_interface.py b/backend_py/src/services/cameras/drivers/shd/asic_interface.py index f4d3fc11..da15d23c 100644 --- a/backend_py/src/services/cameras/drivers/shd/asic_interface.py +++ b/backend_py/src/services/cameras/drivers/shd/asic_interface.py @@ -39,13 +39,11 @@ def __init__(self, camera: Camera) -> None: self.logger = logging.getLogger("dwe_os_2.cameras.shd.ASICInterface") - self._thread = threading.Thread(target=self._sync_sync_asic_writes) + self._thread = threading.Thread(target=self._sync_sync_asic_writes, daemon=True) self._thread.start() def _sync_sync_asic_writes(self) -> None: while self._is_worker_running: - tasks_to_run = {} - with self._queue_cond: while not self._commands and self._is_worker_running: self._queue_cond.wait() @@ -53,8 +51,8 @@ def _sync_sync_asic_writes(self) -> None: tasks_to_run = self._commands self._commands = {} - for key, task in tasks_to_run.items(): - self.logger.info(f"Running task {key} with {task.args}") + for _key, task in tasks_to_run.items(): + # self.logger.info(f"Running task {key} with {task.args}") task.func(*task.args) time.sleep(0.5) @@ -79,12 +77,17 @@ def sensor_write(self, key: str, addr: int, data: int) -> None: self.queue_command(key, self.sync_sensor_write, [addr, data]) def sensor_write_high_low( - self, key: str, addr_high: int, addr_low: int, value: int + self, + key: str, + addr_high: int, + addr_low: int, + value: int, + write_delay_s: float | None = None, ) -> None: self.queue_command( key, self.sync_sensor_write_high_low, - [addr_high, addr_low, value], + [addr_high, addr_low, value, write_delay_s], ) def sync_asic_write(self, addr: int, data: int, dummy: bool = False) -> int: @@ -209,7 +212,7 @@ def sensor_read(self, reg: int) -> tuple[int, int]: return ret, val def sync_sensor_write_high_low( - self, reg_high: int, reg_low: int, value: int + self, reg_high: int, reg_low: int, value: int, write_delay_s=0.05 ) -> None: """ Write high byte from value to high register, low byte to low @@ -217,7 +220,7 @@ def sync_sensor_write_high_low( self.sync_sensor_write(reg_high, (value >> 8) & 0xFF) # This is extremely scuffed: switch to waiting for # trigger register before release (See below) - time.sleep(0.6) + time.sleep(write_delay_s) # Maybe: add check for success (0xAA in REG_TRIG) # REG_TRIG actually seems to not work properly, so maybe diff --git a/backend_py/src/services/cameras/drivers/shd/options.py b/backend_py/src/services/cameras/drivers/shd/options.py index 4b193220..e13c868d 100644 --- a/backend_py/src/services/cameras/drivers/shd/options.py +++ b/backend_py/src/services/cameras/drivers/shd/options.py @@ -128,6 +128,7 @@ def __init__( asic_interface: ASICInterface, high_register: int, low_register: int, + write_delay_s: float | None = None, ) -> None: super().__init__( name, @@ -137,10 +138,15 @@ def __init__( self.high_register = high_register self.low_register = low_register + self.write_delay_s = write_delay_s def _write(self, value: int | float | bool) -> None: self._interface.sensor_write_high_low( - self.name, self.high_register, self.low_register, int(value) + self.name, + self.high_register, + self.low_register, + int(value), + self.write_delay_s, ) def _read(self) -> int | float | bool | None: @@ -163,6 +169,7 @@ def __init__(self, asic_interface: ASICInterface) -> None: asic_interface, StellarSensorMap.SHUTTER_HIGH, StellarSensorMap.SHUTTER_LOW, + write_delay_s=0.6, ) From c5211d2535f95482afa3a359722c88b852fb2527 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Tue, 19 May 2026 16:12:51 -0700 Subject: [PATCH 31/34] additional features for recordings tab --- backend_py/src/routes/recordings.py | 94 ++- .../services/recordings/recordings_service.py | 44 +- frontend/package-lock.json | 19 +- frontend/package.json | 2 +- .../components/recording-context-menu.tsx | 158 ++++ .../components/recording-modals.tsx | 237 ++++++ .../recordings/components/recording-table.tsx | 367 +++++++++ .../components/dwe/recordings/recordings.tsx | 765 +++--------------- .../dwe/recordings/store/recording-store.tsx | 233 ++++++ .../dwe/recordings/utils/recording-utils.tsx | 42 + frontend/src/components/ui/button-group.tsx | 83 ++ frontend/src/components/ui/button.tsx | 11 +- frontend/src/components/ui/input.tsx | 6 +- frontend/src/components/ui/spinner.tsx | 16 + frontend/src/components/ui/tooltip.tsx | 8 +- frontend/src/schemas/dwe_os_2.d.ts | 86 +- 16 files changed, 1480 insertions(+), 691 deletions(-) create mode 100644 frontend/src/components/dwe/recordings/components/recording-context-menu.tsx create mode 100644 frontend/src/components/dwe/recordings/components/recording-modals.tsx create mode 100644 frontend/src/components/dwe/recordings/components/recording-table.tsx create mode 100644 frontend/src/components/dwe/recordings/store/recording-store.tsx create mode 100644 frontend/src/components/dwe/recordings/utils/recording-utils.tsx create mode 100644 frontend/src/components/ui/button-group.tsx create mode 100644 frontend/src/components/ui/spinner.tsx diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index 812f3a93..f0b06574 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -6,7 +6,11 @@ and downloading all recordings as ZIP """ -from fastapi import APIRouter, HTTPException, Request +import os +import time +import uuid + +from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request from fastapi.responses import FileResponse from backend_py.src.models import RecordingInfo @@ -15,6 +19,26 @@ recordings_router = APIRouter(tags=["recordings"]) +# dict of timp file paths +download_tokens: dict[str, dict] = {} + +# Helpers +def remove_file(path: str) -> None: + if os.path.exists(path): + os.remove(path) + +def clean_orphaned_tokens() -> None: + current_time = time.time() + expired_tokens = [] + + for token, data in download_tokens.items(): + if current_time - data["created_at"] > 30: # GET req not seen for 30 secs + expired_tokens.append(token) + + for token in expired_tokens: + data = download_tokens.pop(token) + # in case local zip file wasn't deleted by background task + remove_file(data["path"]) @recordings_router.get("", summary="Get all recordings") def get_recordings(request: Request) -> list[RecordingInfo]: @@ -22,23 +46,50 @@ 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_router.post("/zip/prepare", summary="Zip files and generate token") +def prepare_zip_download( + request: Request, + filenames: list[str] = Body(...) # noqa: B008 +) -> dict: + clean_orphaned_tokens() + recordings_service: RecordingsService = request.app.state.recordings_service - zip_file_path = recordings_service.zip_recordings() - if not zip_file_path: + zip_file_path = recordings_service.zip_recordings(filenames) + + if not zip_file_path or not os.path.exists(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 - + token = uuid.uuid4().hex + download_tokens[token] = { + "path": zip_file_path, + "created_at": time.time() + } + + return {"token": token} + +@recordings_router.get("/zip/download", summary="Download ZIP using token") +def download_zip( + token: str, + background_tasks:BackgroundTasks, + filename: str = "selected_recordings.zip" +) -> FileResponse: + if token not in download_tokens: + raise HTTPException(status_code=404, detail="Invalid or expired download token") + + token_data = download_tokens.pop(token) + zip_file_path = token_data["path"] + + if not os.path.exists(zip_file_path): + raise HTTPException(status_code=404, detail="Zip file not found") + + background_tasks.add_task(remove_file, zip_file_path) + + return FileResponse( + zip_file_path, + media_type="application/zip", + filename=filename, + headers={"Content-Disposition": f"attachment; filename={filename}"},) @recordings_router.get("/{recording_path}", summary="Get a specific recording") def get_recording(request: Request, recording_path: str) -> FileResponse: @@ -70,6 +121,21 @@ def delete_recording(request: Request, recording_path: str) -> list[RecordingInf return response +@recordings_router.post("/bulk-delete", summary="Delete multiple recordings") +def bulk_delete_recording( + request: Request, + filenames: list[str] = Body(...) # noqa: B008 +) -> list[RecordingInfo]: + recordings_service: RecordingsService = request.app.state.recordings_service + + response = recordings_service.bulk_delete_recordings(filenames) + if not response: + raise HTTPException( + status_code=404, detail="Recordings not found or could not be deleted" + ) + + return response + @recordings_router.patch("/{old_name}/{new_name}", summary="Rename a recording") def rename_recording( diff --git a/backend_py/src/services/recordings/recordings_service.py b/backend_py/src/services/recordings/recordings_service.py index db71c8a3..dd88388a 100644 --- a/backend_py/src/services/recordings/recordings_service.py +++ b/backend_py/src/services/recordings/recordings_service.py @@ -10,6 +10,7 @@ import os import subprocess import threading +import uuid import zipfile from backend_py.src.models import RecordingInfo @@ -27,11 +28,21 @@ def __init__(self, data_dir: str) -> None: self.durations = {} + self._last_dir_mtime = 0.0 + if not os.path.exists(self.recordings_path): os.makedirs(self.recordings_path) def get_recordings(self) -> list[RecordingInfo]: with self.recordings_lock: + # check last modified + current_mtime = os.stat(self.recordings_path).st_mtime + + # if no modifications, just return cache + if self.recordings and current_mtime == self._last_dir_mtime: + return self.recordings + + # otherwise run scan recordings: list[RecordingInfo] = [] for filename in os.listdir(self.recordings_path): if filename.endswith((".mp4", ".avi", ".dwvo")): @@ -48,6 +59,7 @@ def get_recordings(self) -> list[RecordingInfo]: recordings.append(recording_info) self.recordings = recordings + self._last_dir_mtime = current_mtime return recordings def _epoch_to_readable(self, epoch: float) -> str: @@ -122,6 +134,18 @@ def delete_recording(self, filename: str) -> list[RecordingInfo] | None: return self.recordings return None + def bulk_delete_recordings(self, filenames: list[str]) -> list[RecordingInfo]: + with self.recordings_lock: + for filename in filenames: + if filename in self.durations: + self.durations.pop(filename, None) + + recording_path = os.path.join(self.recordings_path, filename) + if os.path.exists(recording_path): + os.remove(recording_path) + + return self.get_recordings() + def rename_recording( self, old_name: str, new_name: str ) -> list[RecordingInfo] | None: @@ -138,18 +162,24 @@ def rename_recording( return self.recordings return None - def zip_recordings(self) -> str | None: - self.get_recordings() # Refresh the recordings list + def zip_recordings(self, filenames: list[str] | None = None) -> str | None: + self.get_recordings() if not self.recordings: return None - zip_filename = os.path.join(self.recordings_path, "recordings.zip") + unique_id = uuid.uuid4().hex + zip_filename = os.path.join(self.recordings_path, f"temp_{unique_id}.zip") with zipfile.ZipFile(zip_filename, "w") as zipf: for recording in self.recordings: - zipf.write( - recording.path, arcname=recording.name + "." + recording.format - ) + full_name = f"{recording.name}.{recording.format}" + + if ( + filenames is not None + and recording.name not in filenames + and full_name not in filenames + ): + continue - # FIXME: Delete zip after download completes + zipf.write(recording.path, arcname=full_name) return zip_filename diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 79fe52ce..ef440cb2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,7 +32,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.456.0", @@ -125,6 +125,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", @@ -3460,6 +3461,7 @@ "integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3475,6 +3477,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" @@ -3486,6 +3489,7 @@ "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3532,6 +3536,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", @@ -3799,7 +3804,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.15.0", @@ -3807,6 +3813,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4041,6 +4048,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -4534,6 +4542,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6543,6 +6552,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -6728,6 +6738,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" }, @@ -6740,6 +6751,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" @@ -7387,6 +7399,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", @@ -7538,6 +7551,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7818,6 +7832,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "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 f01859d0..86aea9e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.456.0", diff --git a/frontend/src/components/dwe/recordings/components/recording-context-menu.tsx b/frontend/src/components/dwe/recordings/components/recording-context-menu.tsx new file mode 100644 index 00000000..99416030 --- /dev/null +++ b/frontend/src/components/dwe/recordings/components/recording-context-menu.tsx @@ -0,0 +1,158 @@ +import { + recordingsActions, + recordingsState, +} from "@/components/dwe/recordings/store/recording-store"; +import { + fullName, + isPlayable, +} from "@/components/dwe/recordings/utils/recording-utils"; +import { Separator } from "@/components/ui/separator"; +import { Download, Pencil, Play, Trash } from "lucide-react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useSnapshot } from "valtio"; + +export const RecordingContextMenu = ({ baseUrl }: { baseUrl: string }) => { + const snap = useSnapshot(recordingsState); + const menuRef = useRef(null); + + // Local state strictly for the edge-detection math + const [adjustedX, setAdjustedX] = useState(0); + const [adjustedY, setAdjustedY] = useState(0); + + // Handle global click-away / scroll-away + useEffect(() => { + const handleInterrupt = (event: Event) => { + if (event.type === "wheel") { + recordingsActions.closeContextMenu(); + return; + } + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + recordingsActions.closeContextMenu(); + } + }; + + if (snap.contextMenu.isOpen) { + document.addEventListener("mousedown", handleInterrupt); + document.addEventListener("wheel", handleInterrupt); + document.addEventListener("keydown", handleInterrupt); + } + return () => { + document.removeEventListener("mousedown", handleInterrupt); + document.removeEventListener("wheel", handleInterrupt); + document.removeEventListener("keydown", handleInterrupt); + }; + }, [snap.contextMenu.isOpen]); + + // Handle screen edge collisions + useLayoutEffect(() => { + if (snap.contextMenu.isOpen && menuRef.current) { + const { offsetWidth: menuWidth, offsetHeight: menuHeight } = + menuRef.current; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let newX = snap.contextMenu.x; + let newY = snap.contextMenu.y; + + if (newX + menuWidth > viewportWidth) newX = newX - menuWidth; + if (newY + menuHeight > viewportHeight) newY = newY - menuHeight; + + setAdjustedX(newX); + setAdjustedY(newY); + } + }, [snap.contextMenu.isOpen, snap.contextMenu.x, snap.contextMenu.y]); + + if (!snap.contextMenu.isOpen || !snap.contextMenu.target) return null; + + const target = snap.contextMenu.target; + const playable = isPlayable(target); + const isBulk = snap.selectedNames.length > 1; + const selectedRecs = snap.recordings.filter((r) => + snap.selectedNames.includes(r.name), + ); + + const handlePlay = () => { + recordingsActions.closeContextMenu(); + if (!playable) { + toast.info("Format not playable in browser", { + description: `.${target.format} files must be downloaded to play.`, + }); + return; + } + recordingsActions.openPlay(target); + }; + + const handleDownload = () => { + recordingsActions.closeContextMenu(); + if (isBulk) { + recordingsActions.downloadZip(baseUrl); + } else { + recordingsActions.downloadRecording(target, baseUrl); + } + }; + + const handleRename = () => { + recordingsActions.closeContextMenu(); + recordingsActions.openRename(target); + }; + + const handleDelete = () => { + recordingsActions.closeContextMenu(); + recordingsActions.openDelete(isBulk ? selectedRecs : [target]); + }; + + return ( +
e.preventDefault()} // Prevent native menu if right-clicking *inside* our menu + > +
+ {fullName(target)} +
+ + + + + + + + + + + +
+ ); +}; diff --git a/frontend/src/components/dwe/recordings/components/recording-modals.tsx b/frontend/src/components/dwe/recordings/components/recording-modals.tsx new file mode 100644 index 00000000..2480b38c --- /dev/null +++ b/frontend/src/components/dwe/recordings/components/recording-modals.tsx @@ -0,0 +1,237 @@ +import { + recordingsActions, + recordingsState, +} from "@/components/dwe/recordings/store/recording-store"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { AlertTriangle, Download } from "lucide-react"; +import { useSnapshot } from "valtio"; +import { + formatDate, + formatFileSize, + fullName, + getRecordingStreamUrl, +} from "../utils/recording-utils"; + +export const RecordingModals = ({ baseUrl }: { baseUrl: string }) => { + const snap = useSnapshot(recordingsState); + + const isBulkDelete = snap.deleteTargets.length > 1; + const deleteConfirmMatches = isBulkDelete + ? snap.deleteConfirmText.trim() === + "Delete_" + snap.deleteTargets.length.toString() + "_Videos" + : snap.deleteTargets.length === 1 && + snap.deleteConfirmText.trim() === snap.deleteTargets[0].name; + + const renameDisabled = + !snap.renameValue.trim() || + snap.renameSubmitting || + (snap.renameTarget !== null && + snap.renameValue.trim() === snap.renameTarget.name); + + return ( + <> + {/* Video Player Dialog */} + !open && recordingsActions.closePlay()} + > + + + + {snap.playTarget && fullName(snap.playTarget)} + + + {snap.playTarget && + `${formatDate(snap.playTarget.created)} • ${snap.playTarget.duration} • ${formatFileSize(snap.playTarget.size ? parseFloat(snap.playTarget.size) : 0)}`} + + + {snap.playTarget && ( +
+
+ )} + + + + +
+
+ + {/* Rename Dialog */} + !open && recordingsActions.closeRename()} + > + + + Rename recording + + The file extension{" "} + + .{snap.renameTarget?.format} + {" "} + will be preserved. + + +
{ + e.preventDefault(); + if (!renameDisabled) recordingsActions.performRename(); + }} + className="space-y-3" + > +
+ +
+ + recordingsActions.setRenameValue(e.target.value) + } + className="border-0 shadow-none focus-visible:ring-0" + placeholder="Enter a new name" + disabled={snap.renameSubmitting} + /> + + .{snap.renameTarget?.format} + +
+
+ + + + + +
+
+ + {/* Delete Dialog */} + 0} + onOpenChange={(open) => !open && recordingsActions.closeDelete()} + > + + +
+
+ +
+
+ Delete recording? + + This action cannot be undone. + +
+
+
+
+
+ + + recordingsActions.setDeleteConfirmText(e.target.value) + } + placeholder={ + isBulkDelete + ? "Delete_" + + snap.deleteTargets.length.toString() + + "_Videos" + : snap.deleteTargets[0]?.name + } + autoComplete="off" + disabled={snap.deleteSubmitting} + /> +
+
+ + + Cancel + + { + e.preventDefault(); + recordingsActions.performDelete(); + }} + disabled={!deleteConfirmMatches || snap.deleteSubmitting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {snap.deleteSubmitting ? "Deleting..." : "Delete"} + + +
+
+ + ); +}; + +export default RecordingModals; diff --git a/frontend/src/components/dwe/recordings/components/recording-table.tsx b/frontend/src/components/dwe/recordings/components/recording-table.tsx new file mode 100644 index 00000000..4ee50219 --- /dev/null +++ b/frontend/src/components/dwe/recordings/components/recording-table.tsx @@ -0,0 +1,367 @@ +import { + recordingsActions, + recordingsState, +} from "@/components/dwe/recordings/store/recording-store"; +import { + formatDate, + formatFileSize, + isPlayable, + RecordingInfo, +} from "@/components/dwe/recordings/utils/recording-utils"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TruncatedTooltip, +} from "@/components/ui/tooltip"; +import { TOUR_STEP_IDS } from "@/lib/tour-constants"; +import { cn } from "@/lib/utils"; +import { Eye, EyeOff, MoreVertical } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useSnapshot } from "valtio"; + +interface TableProps { + recordings: readonly RecordingInfo[]; + loading: boolean; + sortColumn: keyof RecordingInfo | null; + sortDirection: "asc" | "desc" | null; + onSort: (column: keyof RecordingInfo) => void; +} + +export const RecordingTable = ({ + recordings, + loading, + sortColumn, + sortDirection, + onSort, +}: TableProps) => { + const snap = useSnapshot(recordingsState); + + const [selectionBox, setSelectionBox] = useState<{ + startX: number; + startY: number; + currX: number; + currY: number; + } | null>(null); + + const tableContainerRef = useRef(null); + const initialSelectionRef = useRef([]); + const isModifierRef = useRef(false); + const [lastIndex, setLastIndex] = useState(null); + + const handlePlay = (rec: RecordingInfo) => { + if (!isPlayable(rec)) { + toast.info("Format not playable in browser", { + description: `.${rec.format} files must be downloaded to play.`, + }); + return; + } + recordingsActions.openPlay(rec); + }; + + const handleRowMouseDown = ( + e: React.MouseEvent, + index: number, + rec: RecordingInfo, + ) => { + if (e.button !== 0) return; + + const isCtrl = e.ctrlKey; + const isShift = e.shiftKey; + const isSelected = snap.selectedNames.includes(rec.name); + let newSelection = [...snap.selectedNames]; + + if (isShift && lastIndex !== null) { + // range definition + const start = Math.min(lastIndex, index); + const end = Math.max(lastIndex, index); + const rangeNames = recordings.slice(start, end + 1).map((r) => r.name); + + if (isCtrl) { + newSelection = Array.from(new Set([...newSelection, ...rangeNames])); + } else { + newSelection = rangeNames; + } + } else if (isCtrl) { + if (isSelected) { + newSelection = newSelection.filter((n) => n !== rec.name); + } else { + newSelection.push(rec.name); + } + setLastIndex(index); + } else { + newSelection = [rec.name]; + setLastIndex(index); + } + + recordingsActions.setSelectedNames(newSelection); + }; + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + + const target = e.target as HTMLElement; + + if (target.closest("button, .group, input, [role='dialog']")) return; + + setSelectionBox({ + startX: e.clientX, + startY: e.clientY, + currX: e.clientX, + currY: e.clientY, + }); + + isModifierRef.current = e.ctrlKey || e.metaKey || e.shiftKey; + + // clear selections if modifier key not held + if (!target.closest("tr[data-row-name]") && !isModifierRef.current) { + recordingsActions.setSelectedNames([]); + initialSelectionRef.current = []; + } else { + initialSelectionRef.current = [...recordingsState.selectedNames]; + } + }; + + // Drag rect + useEffect(() => { + if (!selectionBox) return; + + const handlePointerMove = (e: PointerEvent) => { + setSelectionBox((prev) => + prev ? { ...prev, currX: e.clientX, currY: e.clientY } : null, + ); + + if (!tableContainerRef.current) return; + + const dragDist = Math.max( + Math.abs(e.clientX - selectionBox.startX), + Math.abs(e.clientY - selectionBox.startY), + ); + + // cancel tiny drags + if (dragDist < 5) return; + + const boxRect = { + left: Math.min(selectionBox.startX, e.clientX), + right: Math.max(selectionBox.startX, e.clientX), + top: Math.min(selectionBox.startY, e.clientY), + bottom: Math.max(selectionBox.startY, e.clientY), + }; + + const rows = + tableContainerRef.current.querySelectorAll("[data-row-name]"); + const boxSelection: string[] = []; + + rows.forEach((row) => { + const rect = row.getBoundingClientRect(); + // intersection + if ( + rect.left < boxRect.right && + rect.right > boxRect.left && + rect.top < boxRect.bottom && + rect.bottom > boxRect.top + ) { + const name = row.getAttribute("data-row-name"); + if (name) boxSelection.push(name); + } + }); + + if (isModifierRef.current) { + recordingsActions.setSelectedNames( + Array.from( + new Set([...initialSelectionRef.current, ...boxSelection]), + ), + ); + } else { + recordingsActions.setSelectedNames(boxSelection); + } + }; + + const handlePointerUp = () => { + setSelectionBox(null); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [selectionBox]); + + if (loading) + return ( +
+ +
+ ); + + return ( +
+ {selectionBox && ( +
+ )} + +
+ e.stopPropagation()} + > + + onSort("name")} + > + Name   + {sortColumn === "name" && (sortDirection === "asc" ? "▲" : "▼")} + + onSort("created")} + > + Created   + {sortColumn === "created" && + (sortDirection === "asc" ? "▲" : "▼")} + + onSort("duration")} + > + Duration   + {sortColumn === "duration" && + (sortDirection === "asc" ? "▲" : "▼")} + + onSort("size")} + > + Size   + {sortColumn === "size" && (sortDirection === "asc" ? "▲" : "▼")} + + + + + {recordings.map((recording, index) => { + const isSelected = snap.selectedNames.includes(recording.name); + return ( + handleRowMouseDown(e, index, recording)} + onDoubleClick={() => handlePlay(recording)} + onContextMenu={(e) => { + e.preventDefault(); + if (!isSelected) { + recordingsActions.setSelectedNames([recording.name]); + setLastIndex(index); + } + recordingsActions.openContextMenu( + recording, + e.clientX, + e.clientY, + ); + }} + className={cn( + "bg-background hover:bg-muted cursor-pointer select-none transition-colors", + isSelected ? "bg-accent hover:bg-accent/80" : "", + )} + > + +
+ + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + handlePlay(recording); + }} + onDoubleClick={(e) => e.stopPropagation()} + className="group" + > + {recording?.format === "mp4" ? ( + + ) : ( + + )} + + + +

+ {recording?.format === "mp4" + ? "Playable in browser" + : "Download required to play"} +

+
+
+ +
+
+ + {formatDate(recording.created)} + + + {recording.duration} + + + {formatFileSize( + recording.size ? parseFloat(recording.size) : 0, + )} + + + + +
+ ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/components/dwe/recordings/recordings.tsx b/frontend/src/components/dwe/recordings/recordings.tsx index 8163c0a2..30cec646 100644 --- a/frontend/src/components/dwe/recordings/recordings.tsx +++ b/frontend/src/components/dwe/recordings/recordings.tsx @@ -1,99 +1,36 @@ -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, - TableCell, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { RecordingContextMenu } from "@/components/dwe/recordings/components/recording-context-menu"; +import RecordingModals from "@/components/dwe/recordings/components/recording-modals"; +import { RecordingTable } from "@/components/dwe/recordings/components/recording-table"; 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"; + recordingsActions, + recordingsState, +} from "@/components/dwe/recordings/store/recording-store"; import { - AlertTriangle, - Download, - FolderArchive, - Pencil, - Play, - Trash, - Video, - VideoOff, -} from "lucide-react"; + DEMO_RECORDING, + formatFileSize, +} from "@/components/dwe/recordings/utils/recording-utils"; import { useTour } from "@/components/tour/tour"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, - TruncatedTooltip, -} from "@/components/ui/tooltip"; -import { toast } from "sonner"; +import { components } from "@/schemas/dwe_os_2"; +import { Download, Search, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useSnapshot } from "valtio"; type RecordingInfo = components["schemas"]["RecordingInfo"]; -const DEMO_RECORDING: RecordingInfo = { - path: "", - name: "Demo Recording", - format: "mp4", - duration: "00:00:00", - size: "0", - created: new Date().toISOString(), -}; - -const formatFileSize = (sizeInMB: number): string => { - if (sizeInMB >= 1024 * 1024) { - return `${(sizeInMB / (1024 * 1024)).toFixed(2)} TB`; - } else if (sizeInMB >= 1024) { - return `${(sizeInMB / 1024).toFixed(2)} GB`; - } else { - return `${sizeInMB.toFixed(2)} MB`; - } -}; - -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://${ import.meta.env.DEV ? hostAddress + ":5000" : window.location.host }`; + const snap = useSnapshot(recordingsState); + const { isActive } = useTour(); - const [recordings, setRecordings] = useState([]); - + const [searchQuery, setSearchQuery] = useState(""); const [sortColumn, setSortColumn] = useState( null, ); @@ -101,23 +38,10 @@ const Recordings = () => { null, ); - const { isActive } = useTour(); - - // Rename dialog state - const [renameTarget, setRenameTarget] = useState(null); - const [renameValue, setRenameValue] = useState(""); - const [renameSubmitting, setRenameSubmitting] = useState(false); - - // 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); + // initial data fetch + useEffect(() => { + recordingsActions.fetchRecordings(); + }, []); const handleSort = (column: keyof RecordingInfo) => { if (sortColumn === column) { @@ -128,232 +52,20 @@ const Recordings = () => { } }; - const [loading, setLoading] = useState(true); - - const [showMenu, setShowMenu] = useState(false); - const [xPos, setXPos] = useState(0); - const [yPos, setYPos] = useState(0); - const [rightClickedRecording, setRightClickedRecording] = - useState(null); - const menuRef = useRef(null); - - const closeContextMenu = () => { - setShowMenu(false); - setRightClickedRecording(null); - }; - - useEffect(() => { - const handleInterrupt = (event: Event) => { - if (event.type == "wheel") { - closeContextMenu(); - return; - } - - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - closeContextMenu(); - } - }; - - if (showMenu) { - document.addEventListener("mousedown", handleInterrupt); - document.addEventListener("wheel", handleInterrupt); - document.addEventListener("keydown", handleInterrupt); - } - return () => { - document.removeEventListener("mousedown", handleInterrupt); - document.removeEventListener("wheel", handleInterrupt); - document.removeEventListener("keydown", handleInterrupt); - }; - }, [showMenu]); - - useLayoutEffect(() => { - if (showMenu && menuRef.current) { - const { offsetWidth: menuWidth, offsetHeight: menuHeight } = - menuRef.current; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let newX = xPos; - let newY = yPos; - - if (xPos + menuWidth > viewportWidth) { - newX = xPos - menuWidth; - } - - if (yPos + menuHeight > viewportHeight) { - newY = yPos - menuHeight; - } - - if (newX !== xPos || newY !== yPos) { - setXPos(newX); - setYPos(newY); - } - } - }, [showMenu, xPos, yPos]); - - const handleContextMenu = ( - selected: RecordingInfo, - event: React.MouseEvent, - ) => { - event.preventDefault(); - 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 displayRecordings = useMemo(() => { + let data = isActive ? [DEMO_RECORDING] : snap.recordings; + + // searching + if (searchQuery.trim()) { + const lowerQuery = searchQuery.toLowerCase(); + data = data.filter( + (rec) => + rec.name.toLowerCase().includes(lowerQuery) || + rec.format.toLowerCase().includes(lowerQuery), ); - 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(() => { - API_CLIENT.GET("/api/recordings") - .then((data) => setRecordings(data.data!)) - .catch((error) => console.error("Error fetching recordings:", error)) - .finally(() => setLoading(false)); - }, []); - - const displayRecordings = useMemo(() => { - const data = isActive ? [DEMO_RECORDING] : recordings; + // sorting if (!sortColumn || !sortDirection) return data; return [...data].sort((a, b) => { @@ -371,168 +83,33 @@ const Recordings = () => { if (valA > valB) return sortDirection === "asc" ? 1 : -1; return 0; }); - }, [recordings, sortColumn, sortDirection, isActive]); - - const deleteConfirmMatches = - deleteTarget !== null && deleteConfirmText.trim() === deleteTarget.name; - - const renameDisabled = - !renameValue.trim() || - renameSubmitting || - (renameTarget !== null && renameValue.trim() === renameTarget.name); + }, [snap.recordings, sortColumn, sortDirection, isActive, searchQuery]); return (
-
- {showMenu && rightClickedRecording && ( -
-
- {fullName(rightClickedRecording)} -
- - - - - - -
- )} +
+ + setSearchQuery(e.target.value)} + /> +
+
- {loading ? ( -
- Loading... -
- ) : ( - - - - handleSort("name")} - > - Name   - {sortColumn === "name" && - (sortDirection === "asc" ? "▲" : "▼")} - - handleSort("created")} - > - Created   - {sortColumn === "created" && - (sortDirection === "asc" ? "▲" : "▼")} - - handleSort("duration")} - > - Duration   - {sortColumn === "duration" && - (sortDirection === "asc" ? "▲" : "▼")} - - handleSort("size")} - > - Size   - {sortColumn === "size" && - (sortDirection === "asc" ? "▲" : "▼")} - - - - - - {displayRecordings.map((recording) => ( - handleContextMenu(recording, e)} - onDoubleClick={() => openPlayDialog(recording)} - className="bg-background hover:bg-muted cursor-pointer select-none" - > - -
- - - - {recording?.format === "mp4" ? ( - - - -

- {recording?.format === "mp4" - ? "Playable in browser" - : "Download required to play"} -

-
-
- -
-
- - {formatDate(recording.created)} - - - {recording.duration} - - - {formatFileSize( - recording.size ? parseFloat(recording.size) : 0, - )} - -
- ))} -
-
- )} +
@@ -541,27 +118,69 @@ const Recordings = () => { id={TOUR_STEP_IDS.RECORDING_FOOTER} >
- -
+ {snap.selectedNames.length > 0 ? ( + + + + + ) : ( + + )} +
Total Recordings:{" "} - {recordings.length} + {snap.recordings.length} Total Size:{" "} {formatFileSize( - recordings.reduce( + snap.recordings.reduce( (acc, rec) => acc + (rec.size ? parseFloat(rec.size) : 0), 0, ), @@ -571,170 +190,8 @@ const Recordings = () => {
- - {/* 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/dwe/recordings/store/recording-store.tsx b/frontend/src/components/dwe/recordings/store/recording-store.tsx new file mode 100644 index 00000000..b96a9c73 --- /dev/null +++ b/frontend/src/components/dwe/recordings/store/recording-store.tsx @@ -0,0 +1,233 @@ +import { API_CLIENT } from "@/api"; +import { + fullName, + getRecordingStreamUrl, + RecordingInfo, +} from "@/components/dwe/recordings/utils/recording-utils"; +import { toast } from "sonner"; +import { proxy } from "valtio"; + +interface RecordingsState { + recordings: RecordingInfo[]; + selectedNames: string[]; + loading: boolean; + zipDownloading: boolean; + + // Modal Targets + playTarget: RecordingInfo | null; + renameTarget: RecordingInfo | null; + deleteTargets: RecordingInfo[]; + + // Form States + renameValue: string; + renameSubmitting: boolean; + deleteConfirmText: string; + deleteSubmitting: boolean; + + // Context Menu + contextMenu: { + isOpen: boolean; + x: number; + y: number; + target: RecordingInfo | null; + }; +} + +export const recordingsState = proxy({ + recordings: [], + selectedNames: [], + loading: true, + zipDownloading: false, + playTarget: null, + renameTarget: null, + deleteTargets: [], + renameValue: "", + renameSubmitting: false, + deleteConfirmText: "", + deleteSubmitting: false, + contextMenu: { isOpen: false, x: 0, y: 0, target: null }, +}); + +export const recordingsActions = { + fetchRecordings: async () => { + recordingsState.loading = true; + try { + const { data } = await API_CLIENT.GET("/api/recordings"); + if (data) recordingsState.recordings = data; + } catch (error) { + console.error("Error fetching recordings:", error); + } finally { + recordingsState.loading = false; + } + }, + + setSelectedNames: (names: string[]) => { + recordingsState.selectedNames = names; + }, + + downloadRecording: (rec: RecordingInfo, baseUrl: string) => { + const link = document.createElement("a"); + link.href = `${getRecordingStreamUrl(rec, baseUrl)}?download=true`; + link.download = fullName(rec); + document.body.appendChild(link); + link.click(); + link.remove(); + }, + + downloadZip: async (baseUrl: string) => { + if (recordingsState.zipDownloading) return; + const selected = recordingsState.selectedNames; + if (recordingsState.recordings.length === 0 || selected.length === 0) + return; + + recordingsState.zipDownloading = true; + try { + const response = await fetch(`${baseUrl}/api/recordings/zip/prepare`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(selected), + }); + + if (!response.ok) { + let description = `Server responded with ${response.status}.`; + try { + const data = await response.json(); + if (data?.detail) description = data.detail; + } catch { + /* empty */ + } + toast.error("Failed to download recordings", { description }); + return; + } + + const { token } = await response.json(); + + const filename = + selected.length === recordingsState.recordings.length + ? "all_recordings.zip" + : "selected_recordings.zip"; + + const downloadUrl = `${baseUrl}/api/recordings/zip/download?token=${token}&filename=${filename}`; + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (error) { + console.error("Error downloading zip:", error); + toast.error("Failed to download recordings"); + } finally { + recordingsState.zipDownloading = false; + } + }, + + // Modal + openPlay: (rec: RecordingInfo) => { + recordingsState.playTarget = rec; + }, + closePlay: () => { + recordingsState.playTarget = null; + }, + + openRename: (rec: RecordingInfo) => { + recordingsState.renameTarget = rec; + recordingsState.renameValue = rec.name; + }, + closeRename: () => { + if (recordingsState.renameSubmitting) return; + recordingsState.renameTarget = null; + }, + setRenameValue: (val: string) => { + recordingsState.renameValue = val; + }, + + openDelete: (targets: RecordingInfo[]) => { + recordingsState.deleteTargets = targets; + recordingsState.deleteConfirmText = ""; + }, + closeDelete: () => { + if (recordingsState.deleteSubmitting) return; + recordingsState.deleteTargets = []; + recordingsState.deleteConfirmText = ""; + }, + setDeleteConfirmText: (val: string) => { + recordingsState.deleteConfirmText = val; + }, + + // Perform actions + performRename: async () => { + const { renameTarget, renameValue } = recordingsState; + if (!renameTarget) return; + const trimmed = renameValue.trim(); + if (!trimmed || trimmed === renameTarget.name) { + recordingsActions.closeRename(); + return; + } + + recordingsState.renameSubmitting = 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}`, + }, + }, + }, + ); + if (result.data) { + recordingsState.recordings = result.data; + toast.success("Recording renamed", { + description: `"${renameTarget.name}" → "${trimmed}"`, + }); + } + } catch (error) { + console.error(error); + toast.error("Failed to rename recording"); + } finally { + recordingsState.renameSubmitting = false; + recordingsActions.closeRename(); + } + }, + + performDelete: async () => { + const { deleteTargets } = recordingsState; + if (!deleteTargets || deleteTargets.length === 0) return; + + recordingsState.deleteSubmitting = true; + try { + const targetNames = deleteTargets.map((t) => fullName(t)); + + const result = await API_CLIENT.POST("/api/recordings/bulk-delete", { + body: targetNames, + }); + + if (result.data) { + recordingsState.recordings = result.data; + recordingsActions.setSelectedNames([]); + + toast.success( + deleteTargets.length > 1 + ? `Deleted ${deleteTargets.length} recordings` + : `Recording deleted: ${deleteTargets[0].name}`, + ); + } + } catch (error) { + console.error(error); + toast.error("Failed to delete recording"); + } finally { + recordingsState.deleteSubmitting = false; + recordingsActions.closeDelete(); + } + }, + + openContextMenu: (target: RecordingInfo, x: number, y: number) => { + recordingsState.contextMenu = { isOpen: true, x, y, target }; + }, + closeContextMenu: () => { + recordingsState.contextMenu.isOpen = false; + }, +}; diff --git a/frontend/src/components/dwe/recordings/utils/recording-utils.tsx b/frontend/src/components/dwe/recordings/utils/recording-utils.tsx new file mode 100644 index 00000000..388b36d9 --- /dev/null +++ b/frontend/src/components/dwe/recordings/utils/recording-utils.tsx @@ -0,0 +1,42 @@ +import { components } from "@/schemas/dwe_os_2"; + +export type RecordingInfo = components["schemas"]["RecordingInfo"]; + +export const DEMO_RECORDING: RecordingInfo = { + path: "", + name: "Demo Recording", + format: "mp4", + duration: "00:00:00", + size: "0", + created: new Date().toISOString(), +}; + +export const formatFileSize = (sizeInMB: number): string => { + if (sizeInMB >= 1024 * 1024) { + return `${(sizeInMB / (1024 * 1024)).toFixed(2)} TB`; + } else if (sizeInMB >= 1024) { + return `${(sizeInMB / 1024).toFixed(2)} GB`; + } else { + return `${sizeInMB.toFixed(2)} MB`; + } +}; + +export 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", + }); +}; + +export const fullName = (rec: RecordingInfo) => `${rec.name}.${rec.format}`; + +export const isPlayable = (rec: RecordingInfo) => rec.format === "mp4"; + +export const getRecordingStreamUrl = (rec: RecordingInfo, baseUrl: string) => + `${baseUrl}/api/recordings/${encodeURIComponent(fullName(rec))}`; diff --git a/frontend/src/components/ui/button-group.tsx b/frontend/src/components/ui/button-group.tsx new file mode 100644 index 00000000..3ccbbbab --- /dev/null +++ b/frontend/src/components/ui/button-group.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +); + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot : "div"; + + return ( + + ); +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +}; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 9f066749..35a8ef78 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,11 +1,11 @@ -import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "select-none cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { @@ -32,11 +32,12 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } + }, ); export interface ButtonProps - extends React.ButtonHTMLAttributes, + extends + React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } @@ -52,7 +53,7 @@ const Button = React.forwardRef( {...props} /> ); - } + }, ); Button.displayName = "Button"; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index e4e43a98..a42b851a 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -8,14 +8,14 @@ const Input = React.forwardRef>( ); - } + }, ); Input.displayName = "Input"; diff --git a/frontend/src/components/ui/spinner.tsx b/frontend/src/components/ui/spinner.tsx new file mode 100644 index 00000000..055ac767 --- /dev/null +++ b/frontend/src/components/ui/spinner.tsx @@ -0,0 +1,16 @@ +import { Loader2Icon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + + ); +} + +export { Spinner }; diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index aeeee09e..6478e2f0 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import * as React from "react"; import { cn } from "@/lib/utils"; @@ -18,8 +18,8 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-background/50 backdrop-blur border px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className + "z-50 select-none overflow-hidden rounded-md bg-background/50 backdrop-blur border px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className, )} {...props} /> @@ -80,8 +80,8 @@ const TruncatedTooltip = ({ export { Tooltip, - TooltipTrigger, TooltipContent, TooltipProvider, + TooltipTrigger, TruncatedTooltip, }; diff --git a/frontend/src/schemas/dwe_os_2.d.ts b/frontend/src/schemas/dwe_os_2.d.ts index 0a1b3c1b..a71a0a5d 100644 --- a/frontend/src/schemas/dwe_os_2.d.ts +++ b/frontend/src/schemas/dwe_os_2.d.ts @@ -252,7 +252,8 @@ export interface paths { /** Download all recordings as a zip file */ get: operations["zip_recordings_api_recordings_zip_get"]; put?: never; - post?: never; + /** Download selected recordings as a zip file */ + post: operations["zip_selected_recordings_api_recordings_zip_post"]; delete?: never; options?: never; head?: never; @@ -277,6 +278,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/recordings/bulk-delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete multiple recordings */ + post: operations["bulk_delete_recording_api_recordings_bulk_delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/recordings/{old_name}/{new_name}": { parameters: { query?: never; @@ -1182,6 +1200,39 @@ export interface operations { }; }; }; + zip_selected_recordings_api_recordings_zip_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": string[]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_recording_api_recordings__recording_path__get: { parameters: { query?: never; @@ -1244,6 +1295,39 @@ export interface operations { }; }; }; + bulk_delete_recording_api_recordings_bulk_delete_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": string[]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RecordingInfo"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; rename_recording_api_recordings__old_name___new_name__patch: { parameters: { query?: never; From d9889275b3b0485b81433854d37bd6e80f93f588 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Tue, 19 May 2026 16:19:08 -0700 Subject: [PATCH 32/34] ruff, i missed one --- backend_py/src/routes/recordings.py | 45 ++++++++++++++++------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index f0b06574..cf257ac6 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -22,23 +22,26 @@ # dict of timp file paths download_tokens: dict[str, dict] = {} + # Helpers def remove_file(path: str) -> None: if os.path.exists(path): os.remove(path) + def clean_orphaned_tokens() -> None: current_time = time.time() expired_tokens = [] - + for token, data in download_tokens.items(): - if current_time - data["created_at"] > 30: # GET req not seen for 30 secs + if current_time - data["created_at"] > 30: # GET req not seen for 30 secs expired_tokens.append(token) - + for token in expired_tokens: data = download_tokens.pop(token) # in case local zip file wasn't deleted by background task - remove_file(data["path"]) + remove_file(data["path"]) + @recordings_router.get("", summary="Get all recordings") def get_recordings(request: Request) -> list[RecordingInfo]: @@ -46,50 +49,51 @@ def get_recordings(request: Request) -> list[RecordingInfo]: return recordings_service.get_recordings() + @recordings_router.post("/zip/prepare", summary="Zip files and generate token") def prepare_zip_download( request: Request, - filenames: list[str] = Body(...) # noqa: B008 + filenames: list[str] = Body(...), # noqa: B008 ) -> dict: clean_orphaned_tokens() - + recordings_service: RecordingsService = request.app.state.recordings_service zip_file_path = recordings_service.zip_recordings(filenames) - + if not zip_file_path or not os.path.exists(zip_file_path): raise HTTPException(status_code=404, detail="No recordings to zip") token = uuid.uuid4().hex - download_tokens[token] = { - "path": zip_file_path, - "created_at": time.time() - } - + download_tokens[token] = {"path": zip_file_path, "created_at": time.time()} + return {"token": token} + @recordings_router.get("/zip/download", summary="Download ZIP using token") def download_zip( token: str, - background_tasks:BackgroundTasks, - filename: str = "selected_recordings.zip" + background_tasks: BackgroundTasks, + filename: str = "selected_recordings.zip", ) -> FileResponse: if token not in download_tokens: raise HTTPException(status_code=404, detail="Invalid or expired download token") - + token_data = download_tokens.pop(token) zip_file_path = token_data["path"] if not os.path.exists(zip_file_path): raise HTTPException(status_code=404, detail="Zip file not found") - + background_tasks.add_task(remove_file, zip_file_path) return FileResponse( - zip_file_path, - media_type="application/zip", + zip_file_path, + media_type="application/zip", filename=filename, - headers={"Content-Disposition": f"attachment; filename={filename}"},) + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + @recordings_router.get("/{recording_path}", summary="Get a specific recording") def get_recording(request: Request, recording_path: str) -> FileResponse: @@ -121,10 +125,11 @@ def delete_recording(request: Request, recording_path: str) -> list[RecordingInf return response + @recordings_router.post("/bulk-delete", summary="Delete multiple recordings") def bulk_delete_recording( request: Request, - filenames: list[str] = Body(...) # noqa: B008 + filenames: list[str] = Body(...), # noqa: B008 ) -> list[RecordingInfo]: recordings_service: RecordingsService = request.app.state.recordings_service From 8029ae40b6d63b09716a6f5af1437094a08ea9f1 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Wed, 20 May 2026 11:00:06 -0700 Subject: [PATCH 33/34] disk usage implementation --- backend_py/src/routes/recordings.py | 21 ++ .../components/recording-context-menu.tsx | 8 +- .../components/dwe/recordings/recordings.tsx | 193 ++++++++++++------ .../dwe/recordings/store/recording-store.tsx | 18 ++ frontend/src/schemas/dwe_os_2.d.ts | 106 ++++++++-- 5 files changed, 260 insertions(+), 86 deletions(-) diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index cf257ac6..64ee06e5 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -9,6 +9,7 @@ import os import time import uuid +import shutil from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request from fastapi.responses import FileResponse @@ -19,6 +20,14 @@ recordings_router = APIRouter(tags=["recordings"]) +from pydantic import BaseModel + +class DiskStatsResponse(BaseModel): + total: int + used: int + free: int + + # dict of timp file paths download_tokens: dict[str, dict] = {} @@ -49,6 +58,18 @@ def get_recordings(request: Request) -> list[RecordingInfo]: return recordings_service.get_recordings() +@recordings_router.get("/disk", summary="Get physical disk usage", response_model=DiskStatsResponse) +def get_disk_usage(request: Request): + recordings_service: RecordingsService = request.app.state.recordings_service + + total, used, free = shutil.disk_usage(recordings_service.recordings_path) + + return { + "total": total, + "used": used, + "free": free + } + @recordings_router.post("/zip/prepare", summary="Zip files and generate token") def prepare_zip_download( diff --git a/frontend/src/components/dwe/recordings/components/recording-context-menu.tsx b/frontend/src/components/dwe/recordings/components/recording-context-menu.tsx index 99416030..69342472 100644 --- a/frontend/src/components/dwe/recordings/components/recording-context-menu.tsx +++ b/frontend/src/components/dwe/recordings/components/recording-context-menu.tsx @@ -16,11 +16,11 @@ export const RecordingContextMenu = ({ baseUrl }: { baseUrl: string }) => { const snap = useSnapshot(recordingsState); const menuRef = useRef(null); - // Local state strictly for the edge-detection math + // Edge collision states const [adjustedX, setAdjustedX] = useState(0); const [adjustedY, setAdjustedY] = useState(0); - // Handle global click-away / scroll-away + // menu open/close useEffect(() => { const handleInterrupt = (event: Event) => { if (event.type === "wheel") { @@ -44,7 +44,7 @@ export const RecordingContextMenu = ({ baseUrl }: { baseUrl: string }) => { }; }, [snap.contextMenu.isOpen]); - // Handle screen edge collisions + // Handle edge collision useLayoutEffect(() => { if (snap.contextMenu.isOpen && menuRef.current) { const { offsetWidth: menuWidth, offsetHeight: menuHeight } = @@ -107,7 +107,7 @@ export const RecordingContextMenu = ({ baseUrl }: { baseUrl: string }) => { ref={menuRef} style={{ left: adjustedX, top: adjustedY }} className="fixed min-w-56 max-w-80 bg-popover/30 backdrop-blur border rounded-lg shadow-lg z-50 text-sm p-1 overflow-hidden" - onContextMenu={(e) => e.preventDefault()} // Prevent native menu if right-clicking *inside* our menu + onContextMenu={(e) => e.preventDefault()} >
{fullName(target)} diff --git a/frontend/src/components/dwe/recordings/recordings.tsx b/frontend/src/components/dwe/recordings/recordings.tsx index 30cec646..7b8651b9 100644 --- a/frontend/src/components/dwe/recordings/recordings.tsx +++ b/frontend/src/components/dwe/recordings/recordings.tsx @@ -5,10 +5,7 @@ import { recordingsActions, recordingsState, } from "@/components/dwe/recordings/store/recording-store"; -import { - DEMO_RECORDING, - formatFileSize, -} from "@/components/dwe/recordings/utils/recording-utils"; +import { DEMO_RECORDING } from "@/components/dwe/recordings/utils/recording-utils"; import { useTour } from "@/components/tour/tour"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; @@ -16,7 +13,7 @@ import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; import { components } from "@/schemas/dwe_os_2"; -import { Download, Search, Trash2 } from "lucide-react"; +import { Circle, Download, Search, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useSnapshot } from "valtio"; @@ -85,6 +82,32 @@ const Recordings = () => { }); }, [snap.recordings, sortColumn, sortDirection, isActive, searchQuery]); + const disk = snap.diskStats; + + // MB to Bytes + const recordingsSizeBytes = snap.recordings.reduce( + (acc, rec) => acc + (rec.size ? parseFloat(rec.size) * 1024 * 1024 : 0), + 0, + ); + + // disk stats fallback + const totalBytes = disk?.total || 1; + const freeBytes = disk?.free || 0; + const otherUsedBytes = Math.max((disk?.used || 0) - recordingsSizeBytes, 0); + + // calculate percentages of each + const recordingsPct = (recordingsSizeBytes / totalBytes) * 100; + const otherPct = (otherUsedBytes / totalBytes) * 100; + const freePct = (freeBytes / totalBytes) * 100; + + const formatBytes = (bytes: number) => { + if (bytes === 0) return "0 GB"; + const gb = bytes / (1024 * 1024 * 1024); + return gb >= 1 + ? `${gb.toFixed(1)} GB` + : `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + }; + return (
{ className="bg-background p-4 mt-auto" id={TOUR_STEP_IDS.RECORDING_FOOTER} > -
- {snap.selectedNames.length > 0 ? ( - - +
+ {/* BUTTONS */} +
+ {snap.selectedNames.length > 0 ? ( + + + + + ) : ( - - ) : ( - - )} -
- - Total Recordings:{" "} - - {snap.recordings.length} - - - - Total Size:{" "} - - {formatFileSize( - snap.recordings.reduce( - (acc, rec) => acc + (rec.size ? parseFloat(rec.size) : 0), - 0, - ), + )} +
+ + {/* STORAGE BAR */} +
+
+ {/* Recordings */} +
+ {/* Other Files */} +
+ {/* Free Space */} +
+
+ + {/* Legend */} +
+
+ +
+ + Recordings +
+ ({formatBytes(recordingsSizeBytes)}) +
+ {disk && ( + +
+ + Other +
+ ({formatBytes(otherUsedBytes)}) +
)} +
+ + {disk ? `${formatBytes(freeBytes)} Free` : "Calculating..."} - +
+
diff --git a/frontend/src/components/dwe/recordings/store/recording-store.tsx b/frontend/src/components/dwe/recordings/store/recording-store.tsx index b96a9c73..d91b5dac 100644 --- a/frontend/src/components/dwe/recordings/store/recording-store.tsx +++ b/frontend/src/components/dwe/recordings/store/recording-store.tsx @@ -7,8 +7,14 @@ import { import { toast } from "sonner"; import { proxy } from "valtio"; +interface DiskStats { + total: number; + used: number; + free: number; +} interface RecordingsState { recordings: RecordingInfo[]; + diskStats: DiskStats | null; selectedNames: string[]; loading: boolean; zipDownloading: boolean; @@ -35,6 +41,7 @@ interface RecordingsState { export const recordingsState = proxy({ recordings: [], + diskStats: null, selectedNames: [], loading: true, zipDownloading: false, @@ -49,9 +56,20 @@ export const recordingsState = proxy({ }); export const recordingsActions = { + fetchDiskStats: async () => { + try { + const { data } = await API_CLIENT.GET("/api/recordings/disk"); + if (data) recordingsState.diskStats = data as DiskStats; + } catch (error) { + console.error("Error fetching disk stats:", error); + } + }, + fetchRecordings: async () => { recordingsState.loading = true; try { + recordingsActions.fetchDiskStats(); + const { data } = await API_CLIENT.GET("/api/recordings"); if (data) recordingsState.recordings = data; } catch (error) { diff --git a/frontend/src/schemas/dwe_os_2.d.ts b/frontend/src/schemas/dwe_os_2.d.ts index a71a0a5d..0896e0a0 100644 --- a/frontend/src/schemas/dwe_os_2.d.ts +++ b/frontend/src/schemas/dwe_os_2.d.ts @@ -242,18 +242,34 @@ export interface paths { patch?: never; trace?: never; }; - "/api/recordings/zip": { + "/api/recordings/zip/prepare": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Download all recordings as a zip file */ - get: operations["zip_recordings_api_recordings_zip_get"]; + get?: never; put?: never; - /** Download selected recordings as a zip file */ - post: operations["zip_selected_recordings_api_recordings_zip_post"]; + /** Zip files and generate token */ + post: operations["prepare_zip_download_api_recordings_zip_prepare_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/recordings/zip/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download ZIP using token */ + get: operations["download_zip_api_recordings_zip_download_get"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; @@ -312,6 +328,23 @@ export interface paths { patch: operations["rename_recording_api_recordings__old_name___new_name__patch"]; trace?: never; }; + "/api/recordings/disk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get physical disk usage */ + get: operations["get_disk_usage_api_recordings_disk_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/network/wired/devices": { parameters: { query?: never; @@ -585,6 +618,15 @@ export interface components { * @enum {integer} */ DeviceType: 0 | 1 | 2 | 3 | 4; + /** DiskStatsResponse */ + DiskStatsResponse: { + /** Total */ + total: number; + /** Used */ + used: number; + /** Free */ + free: number; + }; /** FeatureSupport */ FeatureSupport: { /** Ttyd */ @@ -1180,14 +1222,18 @@ export interface operations { }; }; }; - zip_recordings_api_recordings_zip_get: { + prepare_zip_download_api_recordings_zip_prepare_post: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": string[]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -1195,23 +1241,33 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; }; }; }; }; - zip_selected_recordings_api_recordings_zip_post: { + download_zip_api_recordings_zip_download_get: { parameters: { - query?: never; + query: { + token: string; + filename?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": string[]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -1360,6 +1416,26 @@ export interface operations { }; }; }; + get_disk_usage_api_recordings_disk_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiskStatsResponse"]; + }; + }; + }; + }; get_wired_devices_api_network_wired_devices_get: { parameters: { query?: never; From b8b0a2dc7b9cc63652eabf03afe3cdb0ea664a6a Mon Sep 17 00:00:00 2001 From: John Zhou Date: Wed, 20 May 2026 11:09:41 -0700 Subject: [PATCH 34/34] ruff fixes --- backend_py/src/routes/recordings.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index 64ee06e5..d38ef411 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -7,12 +7,13 @@ """ import os +import shutil import time import uuid -import shutil from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request from fastapi.responses import FileResponse +from pydantic import BaseModel from backend_py.src.models import RecordingInfo @@ -20,7 +21,6 @@ recordings_router = APIRouter(tags=["recordings"]) -from pydantic import BaseModel class DiskStatsResponse(BaseModel): total: int @@ -58,17 +58,16 @@ def get_recordings(request: Request) -> list[RecordingInfo]: return recordings_service.get_recordings() -@recordings_router.get("/disk", summary="Get physical disk usage", response_model=DiskStatsResponse) -def get_disk_usage(request: Request): + +@recordings_router.get( + "/disk", summary="Get physical disk usage", response_model=DiskStatsResponse +) +def get_disk_usage(request: Request) -> DiskStatsResponse: recordings_service: RecordingsService = request.app.state.recordings_service - + total, used, free = shutil.disk_usage(recordings_service.recordings_path) - - return { - "total": total, - "used": used, - "free": free - } + + return DiskStatsResponse(total=total, used=used, free=free) @recordings_router.post("/zip/prepare", summary="Zip files and generate token")