diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 1465655a..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: ty check + run: ty check backend_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/.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/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 diff --git a/backend_py/run.py b/backend_py/run.py index 21cf50cf..5a6f97c0 100644 --- a/backend_py/run.py +++ b/backend_py/run.py @@ -4,16 +4,17 @@ # two is hosted as a uvicorn server, which handles traffic import asyncio +import contextlib import logging +import signal from contextlib import asynccontextmanager import socketio from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from src import FeatureSupport, Server +from backend_py.src import FeatureSupport, Server -# TODO: narrow ORIGINS = ["*"] # Use AsyncServer @@ -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 @@ -54,6 +52,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, @@ -68,7 +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() - asyncio.run(main()) + with contextlib.suppress(KeyboardInterrupt, asyncio.CancelledError): + asyncio.run(main()) diff --git a/backend_py/src/models/__init__.py b/backend_py/src/models/__init__.py new file mode 100644 index 00000000..e4edc6e0 --- /dev/null +++ b/backend_py/src/models/__init__.py @@ -0,0 +1,86 @@ +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 .recordings import RecordingInfo +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", + # Recordings + "RecordingInfo", +] diff --git a/backend_py/src/services/cameras/pydantic_schemas.py b/backend_py/src/models/cameras.py similarity index 96% rename from backend_py/src/services/cameras/pydantic_schemas.py rename to backend_py/src/models/cameras.py index eeb7fb2a..ee724e25 100644 --- a/backend_py/src/services/cameras/pydantic_schemas.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 @@ -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/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 82% rename from backend_py/src/services/preferences/pydantic_schemas.py rename to backend_py/src/models/preferences.py index 7a15198a..717fad44 100644 --- a/backend_py/src/services/preferences/pydantic_schemas.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 @@ -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/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/services/cameras/saved_pydantic_schemas.py b/backend_py/src/models/saved_cameras.py similarity index 89% rename from backend_py/src/services/cameras/saved_pydantic_schemas.py rename to backend_py/src/models/saved_cameras.py index d24006f0..8f94b735 100644 --- a/backend_py/src/services/cameras/saved_pydantic_schemas.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, @@ -8,7 +8,7 @@ from pydantic import BaseModel -from .pydantic_schemas import ( +from .cameras import ( DeviceType, IntervalModel, StreamEncodeTypeEnum, @@ -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 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..d837a081 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -6,23 +6,20 @@ setting UVC controls, and dealing with Leader/Follower for stereo cameras """ -from typing import cast - 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 backend_py.src.models import ( AddFollowerPayload, DeviceDescriptorModel, DeviceModel, DeviceNicknameModel, - DeviceType, StreamInfoModel, UVCControlModel, ) -from ..services.cameras.shd import SHDDevice + +from ..schemas import SimpleRequestStatusModel +from ..services.cameras import DeviceManager +from ..services.cameras.exceptions import DeviceNotFoundException camera_router = APIRouter(tags=["cameras"]) @@ -34,25 +31,22 @@ def get_devices(request: Request) -> list[DeviceModel]: return device_manager.get_devices() +@camera_router.get("/map", summary="Get all devices as a map from bus info to device") +def get_device_map(request: Request) -> dict[str, DeviceModel]: + device_manager: DeviceManager = request.app.state.device_manager + + return device_manager.device_dict + + @camera_router.post("/configure_stream", summary="Configure a stream") async def configure_stream( request: Request, stream_info: StreamInfoModel ) -> SimpleRequestStatusModel: + # FIXME: Maybe switch back to sync 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) 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..d66e0e1c 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 backend_py.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/routes/recordings.py b/backend_py/src/routes/recordings.py index 92841a85..812f3a93 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -9,7 +9,9 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse -from ..services.recordings import RecordingInfo, RecordingsService +from backend_py.src.models import RecordingInfo + +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 fc85f9be..f45bd0a0 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -9,14 +9,15 @@ import asyncio import logging import logging.handlers +import os import socketio +from colorlog import ColoredFormatter from fastapi import FastAPI from .logging import LogHandler from .routes import ( camera_router, - lights_router, logs_router, network_router, preferences_router, @@ -25,10 +26,9 @@ system_router, ) from .schemas import FeatureSupport -from .services.cameras import DeviceManager, SerialPWMController, SettingsManager -from .services.lights import LightManager, create_pwm_controllers +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 @@ -44,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: @@ -57,15 +58,36 @@ 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() 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="%Y-%m-%dT%H:%M:%S", + reset=True, + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, + 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", @@ -92,9 +114,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) @@ -106,7 +125,7 @@ def __init__( self.system_manager = SystemManager() - self.recordings_service = RecordingsService() + self.recordings_service = RecordingsService(self.data_dir) # TTYD if self.feature_support.ttyd: @@ -117,7 +136,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 +149,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 +200,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/__init__.py b/backend_py/src/services/cameras/__init__.py index eae9b8df..4619e0b3 100644 --- a/backend_py/src/services/cameras/__init__.py +++ b/backend_py/src/services/cameras/__init__.py @@ -1,12 +1,10 @@ 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", "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 970a6999..00000000 --- a/backend_py/src/services/cameras/device.py +++ /dev/null @@ -1,628 +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 . 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 ( - ControlFlagsModel, - ControlModel, - ControlTypeEnum, - DeviceType, - FormatSizeModel, - FrameDropStats, - IntervalModel, - MenuItemModel, - StreamEncodeTypeEnum, - StreamEndpointModel, - StreamTypeEnum, - V4LControlTypeEnum, -) -from .saved_pydantic_schemas import SavedDeviceModel -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 ecb7044e..3bb7fd6e 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 @@ -17,20 +17,23 @@ import event_emitter as events import socketio -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 ( +from backend_py.src.models import ( DeviceModel, + DeviceType, StreamEncodeTypeEnum, StreamInfoModel, StreamTypeEnum, ) -from .settings import SettingsManager -from .shd import SHDDevice + +from ..preferences import SettingsManager +from .device_utils import find_device_with_bus_info, list_diff +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 def todict(obj, classkey=None) -> Any: @@ -69,7 +72,10 @@ def __init__( settings_manager: SettingsManager, serial: SerialPWMController, ) -> None: - self.devices: list[Device] = [] + super().__init__() + + self.device_dict: dict[str, Device] = {} + self.sio = sio self.settings_manager = settings_manager self._is_monitoring = False @@ -83,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 @@ -108,31 +118,37 @@ 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", 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)) - if self.serial: - device.on("pwm_frequency", lambda fps: self.serial.apply_from_fps(fps)) + # device.on("frame_stats", lambda: self._schedule_emit_frame_stats(device)) + + # Only followers will update PWM frequency + if self.serial and device.can_follow: + device.on("pwm_frequency", self.serial.apply_from_fps) return device @@ -169,6 +185,8 @@ def configure_device_stream(self, stream_info: StreamInfoModel) -> bool: """ device = self._find_device_with_bus_info(stream_info.bus_info) + self.logger.info("Configuring stream") + stream_format = stream_info.stream_format width: int = stream_format.width height: int = stream_format.height @@ -197,7 +215,7 @@ def set_device_nickname(self, bus_info: str, nickname: str) -> bool: self.logger.info(f"Setting nickname of {bus_info} to {nickname}") - device.nickname = nickname + device.set_nickname(nickname) self.settings_manager.save_device(device) return True @@ -271,12 +289,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() @@ -300,9 +320,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}") @@ -313,14 +333,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: @@ -331,41 +347,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 @@ -408,12 +401,11 @@ 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 - await asyncio.sleep(0.1) - + await asyncio.sleep(1) # get the list of devices and update the internal array devices_info = await self._get_devices(devices_info) diff --git a/backend_py/src/services/cameras/device_utils.py b/backend_py/src/services/cameras/device_utils.py index c72bbdfb..8bef2fb0 100644 --- a/backend_py/src/services/cameras/device_utils.py +++ b/backend_py/src/services/cameras/device_utils.py @@ -5,14 +5,13 @@ 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: - 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/lights/light_types.py b/backend_py/src/services/cameras/drivers/__init__.py similarity index 100% rename from backend_py/src/services/lights/light_types.py rename to backend_py/src/services/cameras/drivers/__init__.py 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..c0ac608a --- /dev/null +++ b/backend_py/src/services/cameras/drivers/device.py @@ -0,0 +1,388 @@ +""" +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 abc import abstractmethod +from typing import Any + +import event_emitter as events +from linuxpy.video import device + +from backend_py.src.models import ( + ControlFlagsModel, + ControlModel, + ControlTypeEnum, + FrameDropStats, + IntervalModel, + MenuItemModel, + SavedDeviceModel, + StreamEncodeTypeEnum, + StreamEndpointModel, + StreamTypeEnum, + V4LControlTypeEnum, +) + +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( + 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 + # 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() + + # Thread safety + self._configuration_lock = threading.Lock() + + # 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() + + @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 + 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 set_nickname(self, nickname: str) -> None: + with self._configuration_lock: + self.nickname = nickname + + 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 = [] + + 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 + + with self._configuration_lock: + 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: + # with self._frame_stats_lock: + # self.frame_stats = FrameDropStats(num_drops=0) + + with self._configuration_lock: + self.stream.enabled = True + self.stream_runner.start() + + # 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: + with self._configuration_lock: + 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("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, + ) + + with self._configuration_lock: + 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 + + # FIXME: + # Take the thread safety ASICControls and implement that logic here instead + # This is NOT thread safe + + 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) + # self.logger.info( + # f"Setting {control.name} to {control.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(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/__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..fab0c8ec --- /dev/null +++ b/backend_py/src/services/cameras/drivers/ehd/ehd.py @@ -0,0 +1,36 @@ +""" +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") + + def remove_device(self) -> None: + pass 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..da15d23c --- /dev/null +++ b/backend_py/src/services/cameras/drivers/shd/asic_interface.py @@ -0,0 +1,241 @@ +""" +asic_interface.py + +Defines low level read/write functions to interact with the SHD ASIC and +sensor registers. +""" + +import logging +import struct +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass + +from ..video4linux import Camera +from ..xu import Selector, StellarRegisterMap, Unit + + +@dataclass +class ASICCommand: + func: Callable + args: list + + +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() + + 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, daemon=True) + self._thread.start() + + def _sync_sync_asic_writes(self) -> None: + while self._is_worker_running: + 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, + write_delay_s: float | None = None, + ) -> None: + self.queue_command( + key, + self.sync_sensor_write_high_low, + [addr_high, addr_low, value, write_delay_s], + ) + + def sync_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.sync_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: + """ + 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.sync_asic_write(addr_low, val_low) + + val_high = value >> 8 & 0xFF + _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 sync_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.sync_asic_write(StellarRegisterMap.REG_ADDR_H, high) + # Set address low + ret |= self.sync_asic_write(StellarRegisterMap.REG_ADDR_L, low) + # Set data + ret |= self.sync_asic_write(StellarRegisterMap.REG_DATA, val) + # Set mode to write ('W' = 0x57) + ret |= self.sync_asic_write(StellarRegisterMap.REG_MODE, 0x57) + # Trigger the command (0x55) + ret |= self.sync_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.sync_asic_write(StellarRegisterMap.REG_ADDR_H, high) + # Set address low + ret |= self.sync_asic_write(StellarRegisterMap.REG_ADDR_L, low) + # Set mode to write ('R' = 0x52) + ret |= self.sync_asic_write(StellarRegisterMap.REG_MODE, 0x52) + # Trigger the command (0x55) + ret |= self.sync_asic_write(StellarRegisterMap.REG_TRIG, 0x55) + + if ret != 0: + return ret, -1 + + ret, val = self.asic_read(StellarRegisterMap.REG_DATA) + + return ret, val + + def sync_sensor_write_high_low( + 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 + """ + 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(write_delay_s) + + # Maybe: add check for success (0xAA in REG_TRIG) + # REG_TRIG actually seems to not work properly, so maybe + # we find another alternative + self.sync_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..756d58e8 --- /dev/null +++ b/backend_py/src/services/cameras/drivers/shd/options.py @@ -0,0 +1,261 @@ +""" +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(self.name, 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.name, 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=60000, + 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 (must be a diff key) + self._interface.asic_write( + self.name + "trig", 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, + write_delay_s: float = 0.05, + ) -> None: + super().__init__( + name, + control_flags, + asic_interface, + ) + + 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.write_delay_s, + ) + + 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, + write_delay_s=0.6, + ) + + +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 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__( + "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__( + "ISO", + ControlFlagsModel( + default_value=400, + max_value=4095, + min_value=0, + step=1, + control_type=ControlTypeEnum.INTEGER, + ), + asic_interface, + StellarSensorMap.ISO_HIGH, + StellarSensorMap.ISO_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..18726640 --- /dev/null +++ b/backend_py/src/services/cameras/drivers/shd/shd.py @@ -0,0 +1,219 @@ +""" +shd.py + +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 +from .options import ( + AutoExposureOption, + GainOption, + HardwareBitrateOption, + HtsOption, + ShutterSpeedOption, + StrobeWidthOption, + VtsOption, +) + + +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: 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]) + + # 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), + "vts": VtsOption(self.asic_interface), + "hts": HtsOption(self.asic_interface), + # "vts": FakeOption("VTS"), + # "hts": FakeOption("HTS"), + } + + 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") + 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: + # 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." + ) + 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 + if device.bus_info not in self.followers: + self.followers.append(device.bus_info) + + # This is the real addition + self.follower_devices[device.bus_info] = device + + # Make the follower managed + device.set_leader(self) + + if self.stream.enabled: + self.start_stream() + + 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 + if persist: + self.followers = [dev for dev in self.followers if dev != device.bus_info] + self.follower_devices.pop(device.bus_info) + + device.remove_leader() + + 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_leader(self, leader: "SHDDevice") -> None: + self.is_managed = True + self.leader_device = leader + + def remove_leader(self) -> None: + self.is_managed = False + self.leader_device = None + + 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.values(): + # 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.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.") + + # 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(): + # if _option_name.lower() not in ["vts", "hts"]: + # 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..0f543683 --- /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 diff --git a/backend_py/src/services/cameras/enumeration.py b/backend_py/src/services/cameras/drivers/video4linux/enumeration.py similarity index 95% rename from backend_py/src/services/cameras/enumeration.py rename to backend_py/src/services/cameras/drivers/video4linux/enumeration.py index 32983877..cdb34aba 100644 --- a/backend_py/src/services/cameras/enumeration.py +++ b/backend_py/src/services/cameras/drivers/video4linux/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() @@ -86,8 +86,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 62% rename from backend_py/src/services/cameras/xu_controls.py rename to backend_py/src/services/cameras/drivers/xu.py index 5d338f3b..a5341016 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,9 +74,17 @@ 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 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/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py deleted file mode 100644 index fcb46c95..00000000 --- a/backend_py/src/services/cameras/ehd.py +++ /dev/null @@ -1,82 +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 . import xu_controls as xu -from .device import BaseOption, ControlTypeEnum, Device, Option -from .enumeration import DeviceInfo -from .pydantic_schemas import H264Mode - - -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..d3b0ffe8 100644 --- a/backend_py/src/services/cameras/pwm/serial_pwm_controller.py +++ b/backend_py/src/services/cameras/pwm/serial_pwm_controller.py @@ -111,12 +111,11 @@ 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()}") 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: diff --git a/backend_py/src/services/cameras/settings.py b/backend_py/src/services/cameras/settings.py deleted file mode 100644 index 805939c5..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 .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 - - -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/cameras/shd.py b/backend_py/src/services/cameras/shd.py deleted file mode 100644 index 7d6175ec..00000000 --- a/backend_py/src/services/cameras/shd.py +++ /dev/null @@ -1,638 +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 . 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: - 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 existance 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 f75c42c2..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,13 @@ import os import signal -import stat import subprocess import threading +from collections.abc import Callable from datetime import datetime -from ..pydantic_schemas import StreamEncodeTypeEnum, StreamTypeEnum +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 @@ -20,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 @@ -90,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: @@ -99,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" ) @@ -118,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 _: @@ -137,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 @@ -149,7 +134,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 b4047798..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 ..pydantic_schemas 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 84550e22..e2f6bf75 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 backend_py.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_runner.py b/backend_py/src/services/cameras/stream_runner.py index e8142554..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 @@ -53,11 +52,10 @@ 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() - time.sleep(1) # We create the engine on start, so the engine can perform initial setup on # constructor diff --git a/backend_py/src/services/cameras/stream_utils.py b/backend_py/src/services/cameras/stream_utils.py index dd52346a..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 .pydantic_schemas import StreamEncodeTypeEnum +from backend_py.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 new file mode 100644 index 00000000..a2591c8a --- /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, + ) -> None: + 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: - self.device = device self.width = width self.height = height @@ -68,6 +67,10 @@ 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 +87,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 +201,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 = 0.1 ) -> CopiedFrame | None: """ Dequeue one buffer, copy its contents into a new bytes object, @@ -180,6 +209,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 @@ -336,7 +369,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/lights/__init__.py b/backend_py/src/services/lights/__init__.py deleted file mode 100644 index 8238e2fc..00000000 --- a/backend_py/src/services/lights/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .light import DisableLightInfo, Light, SetLightInfo -from .light_manager import LightManager -from .utils import create_pwm_controllers - -__all__ = [ - "LightManager", - "create_pwm_controllers", - "DisableLightInfo", - "Light", - "SetLightInfo", -] diff --git a/backend_py/src/services/lights/fake_pwm.py b/backend_py/src/services/lights/fake_pwm.py deleted file mode 100644 index 6dff4108..00000000 --- a/backend_py/src/services/lights/fake_pwm.py +++ /dev/null @@ -1,24 +0,0 @@ -from .pwm_controller import PWMController - - -class FakePWMController(PWMController): - NAME = "Fake PWM Controller" - - def __init__(self) -> 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/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..a70e8544 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 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 03e53a54..c5d6d95c 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 backend_py.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__() @@ -42,7 +27,7 @@ def __init__(self, sio: socketio.AsyncServer) -> None: @self.sio.on("connect") 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/__init__.py b/backend_py/src/services/preferences/__init__.py index 9433f665..f26e2ce2 100644 --- a/backend_py/src/services/preferences/__init__.py +++ b/backend_py/src/services/preferences/__init__.py @@ -1,4 +1,4 @@ from .preferences_manager import PreferencesManager -from .pydantic_schemas import SavedPreferencesModel +from .settings_manager import SettingsManager -__all__ = ["PreferencesManager", "SavedPreferencesModel"] +__all__ = ["PreferencesManager", "SettingsManager"] diff --git a/backend_py/src/services/preferences/preferences_manager.py b/backend_py/src/services/preferences/preferences_manager.py index 4d90deef..90c5bff0 100644 --- a/backend_py/src/services/preferences/preferences_manager.py +++ b/backend_py/src/services/preferences/preferences_manager.py @@ -9,10 +9,11 @@ import json import pathlib +import threading from event_emitter import events -from .pydantic_schemas import SavedPreferencesModel +from backend_py.src.models import SavedPreferencesModel class PreferencesManager(events.EventEmitter): @@ -23,13 +24,15 @@ 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 + with self._lock: + self.settings = preferences self.emit("preferences_updated", preferences) self._save_settings() def get_preferences(self) -> SavedPreferencesModel: - # FIXME: why return self.settings def serialize_preferences(self) -> SavedPreferencesModel: @@ -46,6 +49,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..3ed8db2f --- /dev/null +++ b/backend_py/src/services/preferences/settings_manager.py @@ -0,0 +1,160 @@ +""" +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 backend_py.src.models import ( + SavedDeviceModel, +) + +from ..cameras.device_utils import find_device_with_bus_info +from ..cameras.drivers.device import Device +from ..cameras.drivers.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 + + # 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") + + 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: dict[str, 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) + + 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 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 + """ + + devices = device_dict.values() + + for device in devices: + if not isinstance(device, SHDDevice): + continue + saved_device = self.get_saved_device(device.bus_info) + + if not saved_device: + continue + + 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.debug(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._update_settings() + + def save_device(self, device: Device) -> None: + saved_device = SavedDeviceModel.model_validate(device) + self._save_device(saved_device) 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 6b972fdb..170e0212 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 @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 79fe52ce..81544af2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "v0.6.1", + "version": "5.14.26-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "v0.6.1", + "version": "5.14.26-dev", "dependencies": { "@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -35,6 +35,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "immer": "^11.1.8", "lucide-react": "^0.456.0", "motion": "^12.23.26", "next-themes": "^0.4.6", @@ -47,7 +48,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", - "valtio": "^2.1.4" + "valtio": "^2.1.4", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/js": "^9.13.0", @@ -5114,6 +5116,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8066,6 +8078,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index f01859d0..da061f1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "v0.6.1", + "version": "5.14.26-dev", "type": "module", "scripts": { "dev": "vite --host", @@ -39,6 +39,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "immer": "^11.1.8", "lucide-react": "^0.456.0", "motion": "^12.23.26", "next-themes": "^0.4.6", @@ -51,7 +52,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", - "valtio": "^2.1.4" + "valtio": "^2.1.4", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/js": "^9.13.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e3be916..2263a683 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -125,7 +125,10 @@ function App() { const [connected, setConnected] = useState(false); const connectWebsocket = () => { - if (socket.current) delete socket.current; + if (socket.current) { + socket.current.close(); + delete socket.current; + } socket.current = io( import.meta.env.DEV 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" ] } diff --git a/frontend/src/components/dwe/cameras/camera-card.tsx b/frontend/src/components/dwe/cameras/camera-card.tsx index 3c5c8fc0..4a298f40 100644 --- a/frontend/src/components/dwe/cameras/camera-card.tsx +++ b/frontend/src/components/dwe/cameras/camera-card.tsx @@ -7,7 +7,7 @@ import { } from "@/components/ui/card"; import { CameraNickname } from "./nickname"; -import { CameraStream } from "./stream"; +import { CameraStream } from "./stream/stream"; import { FrameDropIndicator } from "./frame-drop-indicator"; import { proxy, useSnapshot } from "valtio"; import { useContext } from "react"; @@ -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/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(); diff --git a/frontend/src/components/dwe/cameras/device-card.tsx b/frontend/src/components/dwe/cameras/device-card.tsx new file mode 100644 index 00000000..43a5e213 --- /dev/null +++ b/frontend/src/components/dwe/cameras/device-card.tsx @@ -0,0 +1,35 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useDeviceStore } from "@/store/devices"; +import { CameraStream } from "./stream/stream"; + +const DeviceCard = ({ bus_id }: { bus_id: string }) => { + const device = useDeviceStore((state) => state.devices[bus_id]); + + return ( + + +
+
+ {device.name} + + Manufacturer: {device.manufacturer} +
+ USB Port ID: {device.bus_info} +
+
+
+
+ + + +
+ ); +}; + +export default DeviceCard; diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index 4515019a..86343693 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -1,284 +1,47 @@ -import { API_CLIENT } from "@/api"; -import { CameraCard } from "./camera-card"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; -import type { components } from "@/schemas/dwe_os_2"; +import { useContext, useEffect } from "react"; import WebsocketContext from "@/contexts/WebsocketContext"; -import { proxy, subscribe } from "valtio"; -import DeviceContext from "@/contexts/DeviceContext"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import DevicesContext from "@/contexts/DevicesContext"; -import { getDeviceByBusInfo } from "@/lib/utils"; -import NotConnected from "../not-connected"; -import { toast } from "sonner"; -import { useTour } from "@/components/tour/tour"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; - -type DeviceModel = components["schemas"]["DeviceModel"]; - -const DEMO_DEVICE: DeviceModel = { - bus_info: "demo-device", - device_type: 0, - nickname: "Demo Camera", - manufacturer: "DeepWater Exploration", - name: "exploreHD", - - vid: 1234, - pid: 5678, - - is_managed: false, - followers: [], - frame_stats: { num_drops: 0 }, - device_info: { - device_name: "exploreHD Demo", - bus_info: "demo-device", - device_paths: ["/dev/video99"], - vid: 1234, - pid: 5678, - }, - controls: [], - stream: { - device_path: "/dev/video99", - encode_type: "H264", - stream_type: "UDP", - endpoints: [{ host: "192.168.1.100", port: 5600 }], - width: 1920, - height: 1080, - interval: { numerator: 1, denominator: 30 }, - enabled: true, - }, - cameras: [ - { - path: "/dev/video99", - formats: { - H264: [ - { - width: 1920, - height: 1080, - intervals: [{ numerator: 1, denominator: 30 }], - }, - ], - }, - }, - ], -}; - -const NoDevicesConnected = () => { - return ( -
- - - No Devices Connected - - Please make sure your devices are plugged in and accessible by DWE - OS. - - - -
-

Potential Issues

-
    -
  • - Power: If you are using a powered USB Hub, you - must provide power. -
  • -
-
-
- - For more detailed documentation, refer to our docs. - -
-
- ); -}; +import { useDeviceStore } from "@/store/devices"; +import DeviceCard from "./device-card"; +import { useShallow } from "zustand/shallow"; const DeviceListLayout = () => { const { socket, connected } = useContext(WebsocketContext)!; - const { isActive } = useTour(); - - const [devices, setDevices] = useState([] as DeviceModel[]); - - const [matchedExposure, setMatchedExposure] = useState(null); - const [matchedISO, setMatchedISO] = useState(null); - - const [savedPreferences, setSavedPreferences] = useState({ - default_stream: { port: 5600, host: "192.168.2.1" }, - } as components["schemas"]["SavedPreferencesModel"]); - - const [nextPort, setNextPort] = useState(5600); - const [demoDeviceProxy] = useState(() => proxy(DEMO_DEVICE)); - - const deviceMap = useMemo(() => { - const map: { [key: string]: DeviceModel } = {}; - devices.forEach((d) => (map[d.bus_info] = d)); - return map; - }, [devices]); - - const deviceMapRef = useRef(deviceMap); + // Object.keys avoids rerendering everything when one device changes + const deviceIds = useDeviceStore( + useShallow((state) => Object.keys(state.devices)), + ); + const resetDevices = useDeviceStore((state) => state.reset); + const fetchDevices = useDeviceStore((state) => state.fetchDevices); useEffect(() => { - deviceMapRef.current = deviceMap; - }, [deviceMap]); - - const getNextPort = (devs: DeviceModel[]) => { - const allPorts = devs.flatMap((device) => - device.stream.endpoints.map((endpoint) => endpoint.port), - ); - return allPorts.length > 0 - ? Math.max(...allPorts) + 1 - : savedPreferences.default_stream!.port; - }; + if (!connected || !socket) { + resetDevices(); + return; + } - const createDeviceProxy = (device: DeviceModel) => { - const proxyDevice = proxy(device); + fetchDevices(); - subscribe(proxyDevice, () => { - setDevices((prevDevices) => { - const updatedDevices = prevDevices.map((d) => - d.bus_info === proxyDevice.bus_info ? proxyDevice : d, - ); - setNextPort(getNextPort(updatedDevices)); - return updatedDevices; - }); + socket.on("device_added", () => { + fetchDevices(); }); - - return proxyDevice; - }; - - const addDevice = (device: DeviceModel) => { - setDevices((prevDevices) => { - const exists = prevDevices.some((d) => d.bus_info === device.bus_info); - if (exists) { - const updatedDevices = prevDevices.map((d) => - d.bus_info === device.bus_info ? createDeviceProxy(device) : d, - ); - setNextPort(getNextPort(updatedDevices)); - return updatedDevices; - // return prevDevices; - } else { - const newDevices = [...prevDevices, createDeviceProxy(device)]; - setNextPort(getNextPort(newDevices)); - return newDevices; - } + socket.on("device_removed", () => { + fetchDevices(); }); - }; - - const refreshDevices = async () => { - try { - const { data } = await API_CLIENT.GET("/api/devices"); - if (data) { - // Wrap all raw devices in new proxies - const newProxies = data.map(createDeviceProxy); - - setDevices(newProxies); - setNextPort(getNextPort(newProxies)); - } - } catch (e) { - console.error("Failed to refresh devices:", e); - } - }; - - useEffect(() => { - const getDevices = async () => { - const initialDevices = (await API_CLIENT.GET("/api/devices")).data!; - - const newPreferences = (await API_CLIENT.GET("/api/preferences")).data!; - - if (newPreferences.suggest_host) { - newPreferences.default_stream!.host = ( - await API_CLIENT.GET("/api/preferences/get_recommended_host") - ).data!["host"] as string; - } - - setSavedPreferences(newPreferences); - - // Update existing devices instead of replacing them - initialDevices.forEach((device) => { - addDevice(device); - }); - }; - - const handleStreamError = (data: { - errors: string[]; - bus_info: string; - }) => { - console.log("Stream Error:", data.errors, data.bus_info); - setDevices((currentDevices) => { - const device = getDeviceByBusInfo(currentDevices, data.bus_info); - console.log(currentDevices.map((d) => d.bus_info)); - console.log("Device affected by error:", device); - if (device) { - device.stream.enabled = false; - } - return [...currentDevices]; // Return a new array to trigger re-render - }); - toast.error("Stream Error", { - description: `An error occurred with the device ${data.bus_info}. Please check the logs for more details.`, - }); - }; - - if (connected) { - socket?.on("stream_error", handleStreamError); - socket?.on("device_added", refreshDevices); - socket?.on("device_removed", refreshDevices); - - getDevices(); - } else { - setDevices([]); - } return () => { - socket?.off("device_added", refreshDevices); - socket?.off("device_removed", refreshDevices); - socket?.off("stream_error", handleStreamError); + socket.off("device_added"); + socket.off("device_removed"); }; - }, [socket, connected]); - - const displayDevices = - isActive && devices.length === 0 ? [demoDeviceProxy] : devices; - - if (!connected) return ; + }, [connected, socket, fetchDevices, resetDevices]); return (
-
- d.device_type == 2), - matchedExposure, - setMatchedExposure, - matchedISO, - setMatchedISO, - }} - > - {displayDevices.map((device, index) => ( -
- - - -
- ))} - {displayDevices.length === 0 && } -
+
+ {deviceIds.map((id) => ( + + ))}
); diff --git a/frontend/src/components/dwe/cameras/stream-selector.tsx b/frontend/src/components/dwe/cameras/stream-selector.tsx index 707f840c..47c489c9 100644 --- a/frontend/src/components/dwe/cameras/stream-selector.tsx +++ b/frontend/src/components/dwe/cameras/stream-selector.tsx @@ -7,8 +7,6 @@ import { SelectValue, } from "@/components/ui/select"; -type StreamOption = { label: string; value: string }; - export const StreamSelector = ({ options, placeholder, @@ -17,7 +15,7 @@ export const StreamSelector = ({ onChange, disabled = false, }: { - options: StreamOption[]; + options: string[]; placeholder: string; label: string; value?: string; @@ -39,8 +37,8 @@ export const StreamSelector = ({ {options.map((opt) => ( - - {opt.label} + + {opt} ))} diff --git a/frontend/src/components/dwe/cameras/stream.tsx b/frontend/src/components/dwe/cameras/stream.tsx deleted file mode 100644 index c2d565ab..00000000 --- a/frontend/src/components/dwe/cameras/stream.tsx +++ /dev/null @@ -1,856 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { useContext, useEffect, useMemo, useState } from "react"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { - CameraIcon, - Check, - Edit2Icon, - PauseIcon, - PlayIcon, - PlusIcon, - Trash2Icon, -} from "lucide-react"; -import { Input } from "@/components/ui/input"; -import DeviceContext from "@/contexts/DeviceContext"; -import { subscribe, useSnapshot } from "valtio"; -import { components } from "@/schemas/dwe_os_2"; -import DevicesContext from "@/contexts/DevicesContext"; -import { getDeviceByBusInfo, useDidMountEffect } from "@/lib/utils"; -import { API_CLIENT } from "@/api"; -import { CameraControls } from "./camera-controls"; - -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { TOUR_STEP_IDS } from "@/lib/tour-constants"; -import { StreamSelector } from "./stream-selector"; -import { RangeControl } from "@/components/ui/range-control"; -import { Toggle } from "@/components/ui/toggle"; - -type ControlModel = components["schemas"]["ControlModel"]; - -const registerValueMap: Record = { - 60: 1462, - 50: 1756, - 40: 2119, - 30: 2436, - 15: 5884, -} as const; - -export const SensorControls = () => { - const device = useContext(DeviceContext)!; - const { matchedExposure, setMatchedExposure } = useContext(DevicesContext)!; - const { matchedISO, setMatchedISO } = useContext(DevicesContext)!; - - const deviceSnapshot = useSnapshot(device); - - const [matchExposure, setMatchExposure] = useState(false); - - // Registers - - const controlMap = useMemo(() => { - return new Map(deviceSnapshot.controls.map((c) => [c.name, c])); - }, [deviceSnapshot.controls]); - - const exposureControl = controlMap.get("Auto Exposure (ASIC)"); - const isoControl = controlMap.get("ISO"); - const shutterControl = controlMap.get("Shutter Speed"); - const strobeWidthControl = controlMap.get("Strobe Width"); - const hwBitrateControl = controlMap.get("HW Bitrate"); - - console.log(hwBitrateControl); - - const [exposureTime, setExposureTime] = useState(shutterControl?.value || 0); // 0x3501 - const [autoExposure, setAutoExposure] = useState( - exposureControl?.value === 1, - ); - const [gain, setGain] = useState(isoControl?.value || 0); // 0x3508 - const [strobeWidth, setStrobeWidth] = useState( - strobeWidthControl?.value || 0, - ); - const [hwBitrate, setHwBitrate] = useState(hwBitrateControl?.value ?? 0); - - const strobeMax = exposureTime!; - - useEffect(() => { - if (strobeWidth > strobeMax) setStrobeWidth(strobeMax); - }, [strobeMax, strobeWidth]); - - // 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, - }, - }); - }; - - useEffect(() => { - if ( - matchExposure && - exposureTime !== matchedExposure && - matchedExposure != null - ) { - setExposureTime(matchedExposure); - } - - if (matchExposure && gain !== matchedISO && matchedISO != null) { - setGain(matchedISO); - } - }, [matchedExposure, matchExposure, matchedISO]); - - useDidMountEffect(() => { - localStorage.setItem( - device.bus_info, - matchExposure ? "matched" : "unmatched", - ); - }, [matchExposure]); - - useDidMountEffect( - () => setUVCControl(exposureControl as ControlModel, autoExposure ? 1 : 0), - [autoExposure], - ); - - useDidMountEffect(() => { - setUVCControl(shutterControl as ControlModel, exposureTime!); - }, [exposureTime]); - - useDidMountEffect( - () => setUVCControl(isoControl as ControlModel, gain!), - [gain], - ); - - useDidMountEffect( - () => setUVCControl(hwBitrateControl as ControlModel, hwBitrate!), - [hwBitrate], - ); - - // Set both at the same time to fix fw bug - useDidMountEffect( - () => setUVCControl(strobeWidthControl as ControlModel, strobeWidth), - [strobeWidth, exposureTime], - ); - - useEffect(() => { - setMatchExposure(localStorage.getItem(device.bus_info) === "matched"); - }, [device.bus_info]); - - // TODO: replace with is_pro? - if (!exposureControl || !isoControl || !shutterControl || !hwBitrateControl) - return <>; - - return ( - - - - Sensor Controls - - - {/* Top Section: Mode & Options */} -
-
- - setAutoExposure((prev) => !prev)} - > -
Auto Exposure
-
-
- -
- - setMatchExposure((prev) => !prev)} - > -
Match Exposure
-
-
-
- - {/* Manual Controls */} -
-
- {!autoExposure && ( - <> - { - if (matchExposure) { - setMatchedExposure(newValue); - } - setExposureTime(newValue); - }} - /> - { - if (matchExposure) { - setMatchedISO(newValue); - } - setGain(newValue); - }} - /> - - - )} - { - setHwBitrate(newValue); - }} - /> -
-
-
-
-
- ); -}; - -type DeviceModel = components["schemas"]["DeviceModel"]; - -const Endpoint = ({ - endpoint, - deleteEndpoint, - onEdit, -}: { - endpoint: components["schemas"]["StreamEndpointModel"]; - onEdit: () => void; - deleteEndpoint: () => void; -}) => { - const endpointState = useSnapshot(endpoint); - - const [isEditing, setIsEditing] = useState(false); - - const [tempHost, setTempHost] = useState(endpoint.host); - const [tempPort, setTempPort] = useState(endpoint.port); - - return ( -
  • - {/* ListItemIcon */} -
    - -
    - - {/* Content */} - {isEditing ? ( -
    -
    - setTempHost(e.target.value)} - /> - setTempPort(parseInt(e.target.value))} - /> -
    -
    - -
    -
    - ) : ( -
    -
    -

    Address: {endpointState.host}

    -

    - Port: {endpointState.port} -

    -
    -
    - - -
    -
    - )} -
  • - ); -}; - -const EndpointList = ({ - defaultHost, - nextPort, - setShouldPostFlag, -}: { - defaultHost: string; - nextPort: number; - setShouldPostFlag: React.Dispatch>; -}) => { - const device = useContext(DeviceContext); - - // readonly device state - const deviceState = useSnapshot(device!); - - return ( - <> -
    - - - - Endpoints - - - - {/* List */} - {/* If there are no endpoints.... */} - {deviceState.stream.endpoints.length === 0 ? ( -
    -
    -

    No endpoints added

    -

    - Press the plus icon to add an endpoint -

    -
    -
    - ) : ( -
      - {/* If there are endpoints.... */} - {deviceState.stream.endpoints.map((_, index) => ( - setShouldPostFlag(true)} - deleteEndpoint={() => { - device!.stream.endpoints = - device!.stream.endpoints.filter((_, i) => i !== index); - setShouldPostFlag(true); - }} - /> - ))} -
    - )} -
    -
    -
    - {/* Add Button */} - -
    -
    -
    - - ); -}; - -const FollowerList = () => { - const device = useContext(DeviceContext)!; - - const deviceState = useSnapshot(device); - const [followers, setFollowers] = useState(device.followers); - - const { devices } = useContext(DevicesContext)!; - - useEffect(() => { - const unsubscribe = subscribe(device.followers, () => { - setFollowers(device.followers); - }); - - return unsubscribe; - }, [device]); - - const [potentialFollowers, setPotentialFollowers] = useState( - [], - ); - - const updatePotentialFollowers = () => { - setPotentialFollowers([ - ...devices.filter( - (d) => - d.device_type == 2 && - d.bus_info !== device.bus_info && - !followers.find((f) => f == d.bus_info), - ), - ]); - }; - - useEffect(() => { - updatePotentialFollowers(); - }, [devices]); - - useEffect(() => { - followers - .filter((value) => !deviceState.followers.includes(value)) - .forEach((newFollower) => { - device.followers = followers; - API_CLIENT.POST("/api/devices/add_follower", { - body: { - leader_bus_info: deviceState.bus_info, - follower_bus_info: newFollower, - }, - }); - const dev = getDeviceByBusInfo(devices, newFollower); - if (dev) dev.is_managed = true; - updatePotentialFollowers(); - - setSelectedBusInfo("Select a device..."); - }); - - deviceState.followers - .filter((value) => !followers.includes(value)) - .forEach((removedFollower) => { - device.followers = followers; - API_CLIENT.POST("/api/devices/remove_follower", { - body: { - leader_bus_info: deviceState.bus_info, - follower_bus_info: removedFollower, - }, - }); - const dev = getDeviceByBusInfo(devices, removedFollower); - if (dev) dev.is_managed = false; - updatePotentialFollowers(); - - setSelectedBusInfo("Select a device..."); - }); - }, [followers]); - - const [selectedBusInfo, setSelectedBusInfo] = useState("Select a device..."); - - const handleAddFollower = () => { - const selected = devices.find( - (f) => f.bus_info === selectedBusInfo, - )?.bus_info; - if (!selected) return; - - setFollowers([...followers, selected]); - }; - - const handleDeleteFollower = (follower: string) => { - setFollowers((oldFollowers) => oldFollowers.filter((f) => f !== follower)); - }; - - return ( - - - - Followers - - -
    - {/* Add Dropdown */} -
    -
    - ({ - label: `${f.nickname == "" ? f.bus_info : f.nickname}`, - value: f.bus_info, - })), - ]} - placeholder="Select a device..." - label="Add Follower" - value={selectedBusInfo} - onChange={setSelectedBusInfo} - /> -
    - -
    - -
    -
    - - {/* Follower Table */} - {followers.length === 0 ? ( -
    - No followers connected. Devices that mirror this stream will - appear here. -
    - ) : ( -
    - - - - - - - - - {followers.map((follower, index) => ( - - - - - ))} - -
    - Port - - Device Type -
    {follower} -
    - stellarHD - -
    -
    -
    - )} -
    -
    -
    -
    - ); -}; - -const getResolution = (resolution: string) => { - const split = resolution.split("x"); - if (split.length < 2) return [null, null]; - return [parseInt(split[0]), parseInt(split[1])]; -}; - -/* - * Get the list of resolutions available from the device - */ -const getResolutions = ( - device: Readonly, - encodeFormat: components["schemas"]["StreamEncodeTypeEnum"], -) => { - const newResolutions: string[] = []; - - for (const camera of device.cameras!) { - const format = camera.formats[encodeFormat as string]; - if (format) { - for (const resolution of format) { - const resolution_str = `${resolution.width}x${resolution.height}`; - if (newResolutions.includes(resolution_str)) continue; - newResolutions.push(resolution_str); - } - } - } - return newResolutions; -}; - -const ENCODERS = ["H264", "MJPG", "SOFTWARE_H264"]; - -export const CameraStream = ({ - defaultHost, - nextPort, -}: { - defaultHost: string; - nextPort: number; -}) => { - const device = useContext(DeviceContext)!; - - // readonly device state - const deviceState = useSnapshot(device); - - const [streamEnabled, setStreamEnabled] = useState( - deviceState.stream.enabled, - ); - const [resolution, setResolution] = useState( - `${deviceState.stream.width}x${deviceState.stream.height}`, - ); - const [fps, setFps] = useState("" + deviceState.stream.interval.denominator); - const [format, setFormat] = useState(device.stream.encode_type); - const [resolutions, setResolutions] = useState( - getResolutions(device, deviceState.stream.encode_type), - ); - const [intervals, setIntervals] = useState([] as string[]); - const [encoders, setEncoders] = useState( - [] as components["schemas"]["StreamEncodeTypeEnum"][], - ); - - const [shouldPostFlag, setShouldPostFlag] = useState(false); - - const configureStream = () => { - const [width, height] = getResolution(resolution); - if (width === null || height === null) return; - - // Update device state only when sending to server - device.stream.width = width; - device.stream.height = height; - device.stream.interval.denominator = parseInt(fps); - device.stream.encode_type = format; - device.stream.enabled = streamEnabled; - - API_CLIENT.POST("/api/devices/configure_stream", { - body: { - bus_info: device.bus_info, - encode_type: format, - endpoints: device.stream.endpoints, - stream_type: device.stream.stream_type, - stream_format: { - width, - height, - interval: { numerator: 1, denominator: Number(fps) }, - }, - enabled: streamEnabled, - }, - }); - }; - - useEffect(() => { - if (shouldPostFlag) { - configureStream(); - setShouldPostFlag(false); - } - }, [shouldPostFlag]); - - useEffect(() => { - const unsubscribe = subscribe(device.stream, () => { - setResolutions(getResolutions(device, device.stream.encode_type)); - setStreamEnabled(device.stream.enabled); - setResolution(`${device.stream.width}x${device.stream.height}`); - setFps("" + device.stream.interval.denominator); - setFormat(device.stream.encode_type); - }); - - return unsubscribe; - }, [device]); - - useEffect(() => { - const cameraFormat = device.stream.encode_type; - const newIntervals: string[] = []; - for (const camera of device.cameras!) { - const format = camera.formats[cameraFormat]; - if (format) { - for (const resolution of format) { - for (const interval of resolution.intervals) { - if (!newIntervals.includes(interval.denominator.toString())) - newIntervals.push(interval.denominator.toString()); - } - } - } - } - setIntervals(newIntervals); - }, [resolutions, device]); - - useEffect(() => { - const unsubscribe = subscribe(device.stream.endpoints, () => { - setShouldPostFlag(true); - }); - - return unsubscribe; - }, [device]); - - useEffect(() => { - const newEncoders = []; - for (const camera of device.cameras!) { - for (const format in camera.formats) { - if (ENCODERS.includes(format)) { - newEncoders.push(format); - } - } - } - setEncoders(newEncoders as components["schemas"]["StreamEncodeTypeEnum"][]); - }, [device]); - - return ( -
    - - - - - Stream Configuration - - -
    -
    - ({ label: r, value: r }))} - placeholder="Resolution" - label="Resolution" - value={resolution} - // disabled={deviceState.is_managed} - onChange={(newResolution) => { - setResolution(newResolution); - setShouldPostFlag(true); - }} - /> -
    - -
    - ({ label: i, value: i }))} - placeholder="FPS" - label="Frame Rate" - value={fps} - // disabled={deviceState.is_managed} - onChange={(newFps) => { - setFps(newFps); - setShouldPostFlag(true); - }} - /> -
    - -
    - ({ label: e, value: e }))} - placeholder="Format" - label="Format" - value={format} - // disabled={deviceState.is_managed} - onChange={(fmt) => { - setFormat( - fmt as components["schemas"]["StreamEncodeTypeEnum"], - ); - setShouldPostFlag(true); - }} - /> -
    -
    - {!deviceState.is_managed && device.stream.stream_type === "UDP" && ( - - )} - - {/* TODO: Manage this logic in backend too */} - {!deviceState.is_managed && ( - - )} -
    -
    -
    - - {(deviceState.device_type == 1 || - (deviceState.device_type === 2 && !deviceState.is_managed)) && ( - - )} -
    - -
    -
    - - {deviceState.is_managed - ? "Managed" - : streamEnabled - ? "Stop" - : "Start"}{" "} - {device.stream.stream_type === "RECORDING" - ? "Recording" - : "Stream"} - -
    - -
    -
    -
    - ); -}; diff --git a/frontend/src/components/dwe/cameras/stream/endpoint-list.tsx b/frontend/src/components/dwe/cameras/stream/endpoint-list.tsx new file mode 100644 index 00000000..ce4b4f0d --- /dev/null +++ b/frontend/src/components/dwe/cameras/stream/endpoint-list.tsx @@ -0,0 +1,188 @@ +import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + CameraIcon, + Check, + Edit2Icon, + PlusIcon, + Trash2Icon, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { components } from "@/schemas/dwe_os_2"; + +import { TOUR_STEP_IDS } from "@/lib/tour-constants"; +import { useDeviceStore } from "@/store/devices"; +import { useShallow } from "zustand/shallow"; + +const Endpoint = ({ + endpoint, + deleteEndpoint, + onEdit, +}: { + endpoint: components["schemas"]["StreamEndpointModel"]; + onEdit: (endpoint: components["schemas"]["StreamEndpointModel"]) => void; + deleteEndpoint: () => void; +}) => { + const [isEditing, setIsEditing] = useState(false); + + const [tempHost, setTempHost] = useState(endpoint.host); + const [tempPort, setTempPort] = useState(endpoint.port); + + return ( +
  • + {/* ListItemIcon */} +
    + +
    + + {/* Content */} + {isEditing ? ( +
    +
    + setTempHost(e.target.value)} + /> + setTempPort(parseInt(e.target.value))} + /> +
    +
    + +
    +
    + ) : ( +
    +
    +

    Address: {endpoint.host}

    +

    + Port: {endpoint.port} +

    +
    +
    + + +
    +
    + )} +
  • + ); +}; + +export const EndpointList = ({ bus_id }: { bus_id: string }) => { + const configureStream = useDeviceStore((state) => state.configureStream); + const stream = useDeviceStore((state) => state.devices[bus_id].stream); + + const handleDeleteEndpoint = (index: number) => { + const endpoints = stream.endpoints.filter((_, i) => i !== index); + configureStream(bus_id, { endpoints }); + }; + + const handleUpdateEndpoint = ( + updatedEndpoint: components["schemas"]["StreamEndpointModel"], + index: number, + ) => { + const endpoints = stream.endpoints.map((endpoint, i) => + i === index ? updatedEndpoint : endpoint, + ); + configureStream(bus_id, { endpoints }); + }; + + const handleAddEndpoint = ( + endpoint: components["schemas"]["StreamEndpointModel"], + ) => { + const endpoints = [...stream.endpoints, endpoint]; + configureStream(bus_id, { endpoints }); + }; + + return ( + <> +
    + + + + Endpoints + + + + {/* List */} + {stream.endpoints.length === 0 ? ( +
    +
    +

    No endpoints added

    +

    + Press the plus icon to add an endpoint +

    +
    +
    + ) : ( +
      + {/* If there are endpoints.... */} + {stream.endpoints.map((_, index) => ( + { + // Update + handleUpdateEndpoint(endpoint, index); + }} + deleteEndpoint={() => { + handleDeleteEndpoint(index); + }} + /> + ))} +
    + )} +
    +
    +
    + {/* Add Button */} + +
    +
    +
    + + ); +}; diff --git a/frontend/src/components/dwe/cameras/stream/stream.tsx b/frontend/src/components/dwe/cameras/stream/stream.tsx new file mode 100644 index 00000000..d65929ec --- /dev/null +++ b/frontend/src/components/dwe/cameras/stream/stream.tsx @@ -0,0 +1,192 @@ +import { Button } from "@/components/ui/button"; +import { PauseIcon, PlayIcon } from "lucide-react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { TOUR_STEP_IDS } from "@/lib/tour-constants"; +import { useDeviceStore } from "@/store/devices"; +import { EndpointList } from "./endpoint-list"; +import { StreamSelector } from "../stream-selector"; +import { + getAvailableIntervals, + getResolution, + getResolutions, + resolutionToString, +} from "@/lib/util/stream"; +import { useEffect, useState } from "react"; +import { components } from "@/schemas/dwe_os_2"; + +export const CameraStream = ({ bus_id }: { bus_id: string }) => { + const device = useDeviceStore((state) => state.devices[bus_id]); + const configureStream = useDeviceStore((state) => state.configureStream); + + const resolutions = getResolutions(device); + const resolution = resolutionToString( + device.stream.width, + device.stream.height, + ); + + const updateIntervals = (newDevice: components["schemas"]["DeviceModel"]) => + setAvailableIntervals(getAvailableIntervals(newDevice)); + + const [availableIntervals, setAvailableIntervals] = useState>( + new Set(), + ); + + useDeviceStore.subscribe( + (state) => state.devices[bus_id], + (newDevice) => { + if (newDevice && newDevice.stream) updateIntervals(newDevice); + }, + ); + + useEffect(() => { + updateIntervals(device); + const unsubscribe = useDeviceStore.subscribe( + (state) => state.devices[bus_id], + (newDevice) => { + if (newDevice && newDevice.stream) updateIntervals(newDevice); + }, + ); + + return unsubscribe; + }, [bus_id, device]); + + return ( +
    + + + + Stream Configuration + + +
    +
    + { + const [width, height] = getResolution(newResolution); + if (!width || !height) return; + configureStream(device.bus_info, { + stream_format: { + width, + height, + interval: device.stream.interval, + }, + }); + }} + /> +
    + +
    + { + configureStream(bus_id, { + stream_format: { + width: device.stream.width, + height: device.stream.height, + interval: { + numerator: 1, + denominator: parseInt(newFps), + }, + }, + }); + }} + /> +
    + + {/*
    + ({ label: e, value: e }))} + placeholder="Format" + label="Format" + value={format} + // disabled={deviceState.is_managed} + onChange={(fmt) => { + setFormat( + fmt as components["schemas"]["StreamEncodeTypeEnum"], + ); + setShouldPostFlag(true); + }} + /> +
    */} +
    + {!device.is_managed && device.stream.stream_type === "UDP" && ( + + )} + + {/* TODO: Manage this logic in backend too */} + {/*{!deviceState.is_managed && ( + + )}*/} +
    +
    +
    + + {/*{(device.device_type == 1 || + (device.device_type === 2 && !device.is_managed)) && }*/} +
    +
    +
    + + {device.is_managed + ? "Managed" + : device.stream.enabled + ? "Stop" + : "Start"} + +
    + +
    +
    +
    + ); +}; diff --git a/frontend/src/components/dwe/log-page/log-viewer.tsx b/frontend/src/components/dwe/log-page/log-viewer.tsx index 7d427c0d..14325bbe 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 @@ -273,7 +279,7 @@ export function LogViewer() { -
    +
    {log.message}
    diff --git a/frontend/src/lib/util/stream.ts b/frontend/src/lib/util/stream.ts new file mode 100644 index 00000000..22e0de68 --- /dev/null +++ b/frontend/src/lib/util/stream.ts @@ -0,0 +1,47 @@ +import { components } from "@/schemas/dwe_os_2"; + +export const getResolution = (resolution: string) => { + const split = resolution.split("x"); + if (split.length < 2) return [null, null]; + return [parseInt(split[0]), parseInt(split[1])]; +}; + +export const resolutionToString = (width: number, height: number) => { + return `${width}x${height}`; +}; + +export const getAvailableIntervals = ( + device: components["schemas"]["DeviceModel"], +) => { + const cameraFormat = device.stream.encode_type; + + const availableIntervals = new Set( + (device.cameras ?? []) + .flatMap((camera) => camera.formats[cameraFormat] ?? []) + .flatMap((resolution) => resolution.intervals ?? []) + .map((interval) => interval.denominator.toString()), + ); + + return availableIntervals; +}; + +/* + * Get the list of resolutions available from the device + */ +export const getResolutions = ( + device: Readonly, +) => { + const newResolutions: string[] = []; + + for (const camera of device.cameras!) { + const format = camera.formats[device.stream.encode_type]; + if (format) { + for (const resolution of format) { + const resolution_str = `${resolution.width}x${resolution.height}`; + if (newResolutions.includes(resolution_str)) continue; + newResolutions.push(resolution_str); + } + } + } + return newResolutions; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 06ae673c..0a510776 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,9 +3,10 @@ import "./index.css"; import { createRoot } from "react-dom/client"; import { RouterProvider } from "react-router-dom"; import { router } from "./router.tsx"; +import { StrictMode } from "react"; createRoot(document.getElementById("root")!).render( - // - - // + + + , ); diff --git a/frontend/src/schemas/dwe_os_2.d.ts b/frontend/src/schemas/dwe_os_2.d.ts index 2f2d416c..91d9ae1c 100644 --- a/frontend/src/schemas/dwe_os_2.d.ts +++ b/frontend/src/schemas/dwe_os_2.d.ts @@ -21,6 +21,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/devices/map": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all devices as a map from bus info to device */ + get: operations["get_device_map_api_devices_map_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/devices/configure_stream": { parameters: { query?: never; @@ -208,15 +225,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 +242,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 +259,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 +311,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 +463,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 +490,7 @@ export interface components { /** Name */ name: string; /** Value */ - value: number; + value: number | boolean; }; /** * ControlTypeEnum @@ -652,19 +644,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 +701,6 @@ export interface components { */ frequency_offset: number; }; - /** SetLightInfo */ - SetLightInfo: { - /** Index */ - index: number; - /** Intensity */ - intensity: number; - }; /** SimpleRequestStatusModel */ SimpleRequestStatusModel: { /** @@ -797,7 +769,7 @@ export interface components { /** Control Id */ control_id: number; /** Value */ - value: number; + value: number | boolean; }; /** ValidationError */ ValidationError: { @@ -854,6 +826,28 @@ export interface operations { }; }; }; + get_device_map_api_devices_map_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: components["schemas"]["DeviceModel"]; + }; + }; + }; + }; + }; configure_stream_api_devices_configure_stream_post: { parameters: { query?: never; @@ -1167,7 +1161,7 @@ export interface operations { }; }; }; - get_lights_api_lights_get: { + get_logs_api_logs_get: { parameters: { query?: never; header?: never; @@ -1182,45 +1176,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 +1196,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 +1216,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecordingInfo"][]; + "application/json": unknown; }; }; }; @@ -1354,26 +1315,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; diff --git a/frontend/src/store/devices.ts b/frontend/src/store/devices.ts new file mode 100644 index 00000000..1516c383 --- /dev/null +++ b/frontend/src/store/devices.ts @@ -0,0 +1,121 @@ +import { API_CLIENT } from "@/api"; +import { components } from "@/schemas/dwe_os_2"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { subscribeWithSelector } from "zustand/middleware"; + +export interface DeviceState { + devices: Record; + + fetchDevices: () => Promise; + addDevice: (device: components["schemas"]["DeviceModel"]) => void; + removeDevice: (id: string) => void; + reset: () => void; + + configureStream: ( + bus_info: string, + streamInfo: Partial, + ) => void; + restartStream: (bus_info: string) => void; + setNickname: (bus_info: string, nickname: string) => void; + setUVCControl: ( + bus_info: string, + control_id: number, + value: number | boolean, + ) => void; + addFollower: (leader_bus_info: string, follower_bus_info: string) => void; + removeFollower: (leader_bus_info: string, follower_bus_info: string) => void; +} + +export const useDeviceStore = create()( + subscribeWithSelector( + immer((set, get, store) => ({ + devices: {}, + configureStream: ( + bus_info: string, + partialStreamInfo: Partial, + ) => { + const stream = get().devices[bus_info].stream; + + // The stream info we are sending in the API request + const streamInfo: components["schemas"]["StreamInfoModel"] = { + bus_info: bus_info, + enabled: partialStreamInfo.enabled ?? stream.enabled, + encode_type: partialStreamInfo.encode_type ?? stream.encode_type, + endpoints: partialStreamInfo.endpoints ?? stream.endpoints, + // FIXME: Why did I make the API for the sender different from what we receive... + // For now I'll just be doing conversions here + stream_format: partialStreamInfo.stream_format ?? { + width: stream.width, + height: stream.height, + interval: stream.interval, + }, + stream_type: partialStreamInfo.stream_type ?? stream.stream_type, + }; + + API_CLIENT.POST("/api/devices/configure_stream", { + body: streamInfo, + keepalive: false, + }).then((result) => { + const { data, error } = result; + console.log(data); + + // If successful... + set((state) => { + const device = state.devices[bus_info]; + + // TODO: make it base this off of responseData which is not in the API yet + if (device && device.stream) { + device.stream.enabled = streamInfo.enabled; + device.stream.encode_type = streamInfo.encode_type; + device.stream.endpoints = streamInfo.endpoints; + device.stream.stream_type = streamInfo.stream_type; + device.stream.width = streamInfo.stream_format.width; + device.stream.height = streamInfo.stream_format.height; + device.stream.interval = streamInfo.stream_format.interval; + } + }); + }); + }, + addDevice: (device: components["schemas"]["DeviceModel"]) => { + set((state) => { + state.devices[device.bus_info] = device; + }); + }, + removeDevice: (id: string) => { + set((state) => { + delete state.devices[id]; + }); + }, + reset: () => { + set(store.getInitialState()); + }, + fetchDevices: async () => { + try { + const { data } = await API_CLIENT.GET("/api/devices/map"); + if (data) { + set((state) => { + state.devices = data; + }); + } else { + console.error("Failed to load device list!"); + } + } catch (e) { + console.log(e); + } + }, + restartStream: (bus_info: string) => {}, + setNickname: (bus_info: string, nickname: string) => {}, + setUVCControl: ( + bus_info: string, + control_id: number, + value: number | boolean, + ) => {}, + addFollower: (leader_bus_info: string, follower_bus_info: string) => {}, + removeFollower: ( + leader_bus_info: string, + follower_bus_info: string, + ) => {}, + })), + ), +); 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"], } diff --git a/backend_py/pyproject.toml b/pyproject.toml similarity index 80% rename from backend_py/pyproject.toml rename to pyproject.toml index 8ae18b7a..b1f5eaed 100644 --- a/backend_py/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,12 @@ 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"] + [tool.ruff.lint] select = [ # pycodestyle 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():