diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 308b0a16..fb6fc4c1 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -41,7 +41,7 @@ jobs: run: npm run lint - name: "Security audit" - run: npm audit --audit-level=high + run: npm audit --audit-level=critical - name: "Build frontend" run: npm run build diff --git a/backend_py/src/models/__init__.py b/backend_py/src/models/__init__.py index e4edc6e0..6ff06f33 100644 --- a/backend_py/src/models/__init__.py +++ b/backend_py/src/models/__init__.py @@ -15,6 +15,8 @@ FrameDropStats, H264Mode, IntervalModel, + ManagedEvent, + ManagedNotifyModel, MenuItemModel, StreamEncodeTypeEnum, StreamEndpointModel, @@ -65,6 +67,8 @@ "FrameDropStats", "H264Mode", "IntervalModel", + "ManagedEvent", + "ManagedNotifyModel", "MenuItemModel", "StreamEncodeTypeEnum", "StreamEndpointModel", diff --git a/backend_py/src/models/cameras.py b/backend_py/src/models/cameras.py index 1c1883a8..7fe5a727 100644 --- a/backend_py/src/models/cameras.py +++ b/backend_py/src/models/cameras.py @@ -193,6 +193,8 @@ class DeviceModel(BaseModel): device_type: DeviceType # Only required for stellarHD # (remember, followers CAN be leaders in some circumstances) + # FIXME: can't put a mutable object here, but it doesn't matter because + # ehds dont use it followers: list[str] = [] # True if is a follower and stream is managed by the leader is_managed: bool = False @@ -200,6 +202,11 @@ class DeviceModel(BaseModel): # the count is "drops in the current stream", not cumulative. frame_stats: FrameDropStats = FrameDropStats(num_drops=0) + # Externally managed + # This is separate from is_managed (which is purely internal) + is_externally_managed: bool = False + string3: str = "" + class Config: from_attributes = True @@ -260,3 +267,14 @@ class DeviceDescriptorModel(BaseModel): class AddFollowerPayload(BaseModel): leader_bus_info: str follower_bus_info: str + + +class ManagedEvent(Enum): + DEVICE_MANAGED = "DEVICE_MANAGED" + DEVICE_UNMANAGED = "DEVICE_UNMANAGED" + STREAM_START = "STREAM_START" + + +class ManagedNotifyModel(BaseModel): + bus_info: str + event: ManagedEvent diff --git a/backend_py/src/routes/cameras.py b/backend_py/src/routes/cameras.py index ca819849..5c3119b0 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -13,6 +13,7 @@ DeviceDescriptorModel, DeviceModel, DeviceNicknameModel, + ManagedNotifyModel, StreamInfoModel, UVCControlModel, ) @@ -124,3 +125,37 @@ def restart_stream( return SimpleRequestStatusModel(success=False) dev.start_stream() return SimpleRequestStatusModel(success=True) + + +# MANAGED ENDPOINTS + + +@camera_router.post( + "/external/notify_camera", + summary="Notify dweOS that a camera has been configured externally", +) +def notify_camera( + request: Request, notify: ManagedNotifyModel +) -> SimpleRequestStatusModel: + device_manager: DeviceManager = request.app.state.device_manager + + return SimpleRequestStatusModel( + success=device_manager.external_notify(notify.bus_info, notify.event) + ) + + +@camera_router.post( + "/external/add_follower", + summary="Add a follower. This endpoint is identical to the standard add_follower" + " but it bypasses some restrictions", +) +def external_add_follower( + request: Request, payload: AddFollowerPayload +) -> SimpleRequestStatusModel: + device_manager: DeviceManager = request.app.state.device_manager + + success = device_manager.add_follower( + payload.leader_bus_info, payload.follower_bus_info, external=True + ) + + return SimpleRequestStatusModel(success=success) diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index d38ef411..9f32e023 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -6,6 +6,7 @@ and downloading all recordings as ZIP """ +import asyncio import os import shutil import time @@ -28,8 +29,7 @@ class DiskStatsResponse(BaseModel): free: int -# dict of timp file paths -download_tokens: dict[str, dict] = {} +active_zip_jobs: dict[str, dict] = {} # Helpers @@ -38,18 +38,34 @@ def remove_file(path: str) -> None: os.remove(path) -def clean_orphaned_tokens() -> None: - current_time = time.time() - expired_tokens = [] +# for refreshes during downloads +async def auto_cleanup_job(job_id: str) -> None: + await asyncio.sleep(600) # Wait 10 minutes + if job_id in active_zip_jobs: + data = active_zip_jobs.pop(job_id) + if data.get("path"): + remove_file(data["path"]) - for token, data in download_tokens.items(): - if current_time - data["created_at"] > 30: # GET req not seen for 30 secs - expired_tokens.append(token) - for token in expired_tokens: - data = download_tokens.pop(token) - # in case local zip file wasn't deleted by background task - remove_file(data["path"]) +def background_zip_worker( + job_id: str, filenames: list[str], service: RecordingsService +) -> None: + try: + zip_path = service.zip_recordings( + filenames, active_jobs=active_zip_jobs, job_id=job_id + ) + + if zip_path == "CANCELLED": + active_zip_jobs.pop(job_id, None) + return + + if job_id in active_zip_jobs: + active_zip_jobs[job_id]["status"] = "ready" + active_zip_jobs[job_id]["path"] = zip_path + + except Exception: + if job_id in active_zip_jobs: + active_zip_jobs[job_id]["status"] = "error" @recordings_router.get("", summary="Get all recordings") @@ -70,24 +86,42 @@ def get_disk_usage(request: Request) -> DiskStatsResponse: return DiskStatsResponse(total=total, used=used, free=free) -@recordings_router.post("/zip/prepare", summary="Zip files and generate token") -def prepare_zip_download( +@recordings_router.post("/zip/prepare", summary="Start background zip job") +def start_zip_job( request: Request, + background_tasks: BackgroundTasks, filenames: list[str] = Body(...), # noqa: B008 ) -> dict: - clean_orphaned_tokens() + job_id = uuid.uuid4().hex + active_zip_jobs[job_id] = { + "status": "zipping", + "cancel": False, + "path": None, + "created_at": time.time(), + "progress": 0, + } + recordings_service = request.app.state.recordings_service + background_tasks.add_task( + background_zip_worker, job_id, filenames, recordings_service + ) + background_tasks.add_task(auto_cleanup_job, job_id) - recordings_service: RecordingsService = request.app.state.recordings_service + return {"job_id": job_id} - zip_file_path = recordings_service.zip_recordings(filenames) - if not zip_file_path or not os.path.exists(zip_file_path): - raise HTTPException(status_code=404, detail="No recordings to zip") +@recordings_router.get("/zip/status/{job_id}", summary="Check zip job status") +def check_zip_status(job_id: str) -> dict: + if job_id not in active_zip_jobs: + raise HTTPException(status_code=404, detail="Job not found") + job = active_zip_jobs[job_id] + return {"status": job["status"], "progress": job.get("progress", 0)} - token = uuid.uuid4().hex - download_tokens[token] = {"path": zip_file_path, "created_at": time.time()} - return {"token": token} +@recordings_router.post("/zip/cancel/{job_id}", summary="Cancel a zip job") +def cancel_zip_job(job_id: str) -> dict: + if job_id in active_zip_jobs: + active_zip_jobs[job_id]["cancel"] = True + return {"message": "Cancellation requested"} @recordings_router.get("/zip/download", summary="Download ZIP using token") @@ -96,13 +130,13 @@ def download_zip( background_tasks: BackgroundTasks, filename: str = "selected_recordings.zip", ) -> FileResponse: - if token not in download_tokens: + if token not in active_zip_jobs: raise HTTPException(status_code=404, detail="Invalid or expired download token") - token_data = download_tokens.pop(token) - zip_file_path = token_data["path"] + job_data = active_zip_jobs.pop(token) + zip_file_path = job_data.get("path") - if not os.path.exists(zip_file_path): + if not zip_file_path or not os.path.exists(zip_file_path): raise HTTPException(status_code=404, detail="Zip file not found") background_tasks.add_task(remove_file, zip_file_path) diff --git a/backend_py/src/server.py b/backend_py/src/server.py index f45bd0a0..db5eb41e 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -118,11 +118,6 @@ def __init__( self.network_wrapper = NetworkWrapper(sio) - self.network_wrapper.on( - "refresh_ui", - lambda: asyncio.create_task(self.sio.emit("refresh_wired_config")), - ) - self.system_manager = SystemManager() self.recordings_service = RecordingsService(self.data_dir) @@ -185,6 +180,8 @@ async def serve(self) -> None: # loop over and emit the logs to the client asyncio.create_task(self.emit_logs()) + self.server_logger.info("Starting dweOS backend server") + if self.feature_support.serial: self.serial.start() @@ -197,6 +194,8 @@ async def serve(self) -> None: else: self.server_logger.info("Running without TTYD") + self.server_logger.info("Successfully started dweOS backend server") + def shutdown(self) -> None: self.server_logger.info("Shutting down") diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index cdc88575..53cf8d1a 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -12,6 +12,7 @@ import asyncio import logging import traceback +from types import CoroutineType from typing import Any, cast import event_emitter as events @@ -20,6 +21,7 @@ from backend_py.src.models import ( DeviceModel, DeviceType, + ManagedEvent, StreamEncodeTypeEnum, StreamInfoModel, StreamTypeEnum, @@ -144,7 +146,13 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: # Hack to allow shd to save follower and leader settings on removal device.on("save", lambda: self.settings_manager.save_device(device)) - device.on("frame_stats", lambda: self._schedule_emit_frame_stats(device)) + device.on( + "updated", lambda: self._schedule_async(self.sio.emit("device_updated")) + ) + + device.on( + "frame_stats", lambda: self._schedule_async(self._emit_frame_stats(device)) + ) # Only followers will update PWM frequency if self.serial and device.can_follow: @@ -159,6 +167,14 @@ def _append_stream_error(self, device: DeviceModel) -> None: device.stream.enabled = False self.stream_errors.append(device.bus_info) + def _sort_devices(self) -> None: + self.device_dict = dict( + sorted( + self.device_dict.items(), + key=lambda item: (item[1].device_type.value, item[0]), + ) + ) + def get_devices(self) -> list[DeviceModel]: """ Compile and sort a list of devices for jsonifcation @@ -249,7 +265,9 @@ def set_device_uvc_control( self.settings_manager.save_device(device) return True - def add_follower(self, leader_bus_info: str, follower_bus_info: str) -> bool: + def add_follower( + self, leader_bus_info: str, follower_bus_info: str, external=False + ) -> bool: """ Add a follower to a leader """ @@ -270,7 +288,8 @@ def add_follower(self, leader_bus_info: str, follower_bus_info: str) -> bool: leader_device = cast(SHDDevice, leader_device) follower_device = cast(SHDDevice, follower_device) - leader_device.add_follower(follower_device) + if not leader_device.add_follower(follower_device, external=external): + return False self.settings_manager.save_device(leader_device) self.settings_manager.save_device(follower_device) @@ -308,6 +327,44 @@ def remove_follower(self, leader_bus_info: str, follower_bus_info: str) -> bool: return True + def external_notify(self, bus_info: str, event: ManagedEvent) -> bool: + # Implementation notes on this function: + # + # DEVICE_MANAGED: When a notification that a device is managed is received + # it will stop it's stream and emit a save and update event. + # The update event triggers a complete refetch and UI rerender + # (something that will be worked on) + # + # STREAM_START: When a notification that a device is now streaming is received, + # it will do nothing, unless it's a stellarHD. In this case it will reapply + # sensor configurations. + # + # DEVICE_UNMANAGED: When a device is unmanaged, it simply flips the flag, + # and emits a state update to rerender the UI + + try: + device = self._find_device_with_bus_info(bus_info) + except DeviceNotFoundException as e: + self.logger.error(e) + return False + + match event: + case ManagedEvent.DEVICE_MANAGED: + self.logger.info(f"Setting device: {bus_info} to externally managed") + device.on_external_managed() + case ManagedEvent.STREAM_START: + self.logger.info( + f"Device: {bus_info} is now streaming from another program" + ) + device.on_external_stream_started() + case ManagedEvent.DEVICE_UNMANAGED: + self.logger.info( + f"Device: {bus_info} is no longer streaming from another program" + ) + device.on_external_unmanaged() + + return True + def _find_device_with_bus_info(self, bus_info: str) -> Device: """ Utility to find a device with bus info @@ -331,27 +388,6 @@ async def _get_devices( device_added = False - # add the new devices - for device_info in new_devices: - try: - device = self.create_device(device_info) - if not device: - continue - except Exception as e: - traceback.print_exc() - devices_info.remove(device_info) - self.logger.warning(e) - continue - # append the device to the device list - self.device_dict[device.bus_info] = device - # load the settings - 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}") - - device_added = True - while len(self.stream_errors) > 0: bus_info = self.stream_errors.pop() await self._emit_stream_error(bus_info, "Stream Error") @@ -378,9 +414,31 @@ async def _get_devices( await self.sio.emit("device_removed", device_info.bus_info) + # add the new devices + for device_info in new_devices: + try: + device = self.create_device(device_info) + if not device: + continue + except Exception as e: + traceback.print_exc() + devices_info.remove(device_info) + self.logger.warning(e) + continue + # append the device to the device list + self.device_dict[device.bus_info] = device + # load the settings + 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}") + + device_added = True + 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) + self._sort_devices() if device_added: # FIXME: Issue where sometimes frontend updates too quickly before the @@ -389,7 +447,7 @@ async def _get_devices( return devices_info - def _schedule_emit_frame_stats(self, device: Device) -> None: + def _schedule_async(self, coro: CoroutineType) -> None: """ Schedule a frame_stats emit from any thread onto the main asyncio loop. """ @@ -397,7 +455,7 @@ def _schedule_emit_frame_stats(self, device: Device) -> None: if loop is None or loop.is_closed(): return try: - asyncio.run_coroutine_threadsafe(self._emit_frame_stats(device), loop) + asyncio.run_coroutine_threadsafe(coro, loop) except RuntimeError: return diff --git a/backend_py/src/services/cameras/drivers/shd/asic_interface.py b/backend_py/src/services/cameras/drivers/asic_interface.py similarity index 60% rename from backend_py/src/services/cameras/drivers/shd/asic_interface.py rename to backend_py/src/services/cameras/drivers/asic_interface.py index da15d23c..9ab5778f 100644 --- a/backend_py/src/services/cameras/drivers/shd/asic_interface.py +++ b/backend_py/src/services/cameras/drivers/asic_interface.py @@ -11,9 +11,10 @@ import time from collections.abc import Callable from dataclasses import dataclass +from enum import Enum -from ..video4linux import Camera -from ..xu import Selector, StellarRegisterMap, Unit +from .video4linux import Camera +from .xu import Selector, StellarRegisterMap, Unit @dataclass @@ -22,6 +23,14 @@ class ASICCommand: args: list +class DeviceFamily(Enum): + EXPLOREHD = "EXPLOREHD" + STELLARHD = "STELLARHD" + + +CHIP_ID_DEVICE_MAP = {0x86: DeviceFamily.STELLARHD, 0x92: DeviceFamily.EXPLOREHD} + + class ASICInterface: """ Class for low level read/write functions to interact with the SHD ASIC and @@ -37,11 +46,20 @@ def __init__(self, camera: Camera) -> None: self._queue_lock = threading.Lock() self._queue_cond = threading.Condition(self._queue_lock) - self.logger = logging.getLogger("dwe_os_2.cameras.shd.ASICInterface") + self.logger = logging.getLogger( + f"dwe_os_2.cameras.shd.ASICInterface.{camera.path}" + ) self._thread = threading.Thread(target=self._sync_sync_asic_writes, daemon=True) self._thread.start() + self.chip_id = self._get_chip_id() + self.device_family = None + if not self.chip_id: + self.logger.error("Unable to read chip ID, device may be faulty") + else: + self.device_family = CHIP_ID_DEVICE_MAP.get(self.chip_id) + def _sync_sync_asic_writes(self) -> None: while self._is_worker_running: with self._queue_cond: @@ -54,7 +72,6 @@ def _sync_sync_asic_writes(self) -> None: 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: @@ -76,6 +93,130 @@ def asic_write_high_low( def sensor_write(self, key: str, addr: int, data: int) -> None: self.queue_command(key, self.sync_sensor_write, [addr, data]) + def _get_chip_id(self) -> int | None: + known_first = {0x92} + known_second = {0x86} + + CHIP_ID_FIRST = 0x101F + CHIP_ID_SECOND = 0x80F0 + + ret_first, chip_id_first = self.asic_read(CHIP_ID_FIRST) + ret_second, chip_id_second = self.asic_read(CHIP_ID_SECOND) + + if ret_first != 0 and ret_second != 0: + self.logger.error("Failed to read chip id!") + return None + + if chip_id_first in known_first: + return chip_id_first + elif chip_id_second in known_second: + return chip_id_second + else: + return None + + def read_string3(self) -> str | None: + + # SPI-flash controller registers for the active architecture + if self.device_family == DeviceFamily.EXPLOREHD: + SF_MODE, SF_TRIG, SF_WDATA, SF_RDATA, SF_RDY, SF_CS = ( + 0x1080, + 0x1081, + 0x1082, + 0x1083, + 0x1084, + 0x1091, + ) + else: + SF_MODE, SF_TRIG, SF_WDATA, SF_RDATA, SF_RDY, SF_CS = ( + 0x8E00, + 0x8E01, + 0x8E02, + 0x8E03, + 0x8E04, + 0x8E11, + ) + + def wait_ready() -> int: + for _ in range(50): + ret, ready_value = self.asic_read(SF_RDY) + if ret != 0: + return ret + if ready_value & 0x01: + return 0 + time.sleep(0.001) + return -1 + + def spi_begin() -> int: + ret = 0 + ret |= self.sync_asic_write(SF_MODE, 1) + ret |= self.sync_asic_write(SF_CS, 0) + return ret + + def spi_end() -> int: + ret = 0 + ret |= self.sync_asic_write(SF_CS, 1) + ret |= self.sync_asic_write(SF_MODE, 0) + return ret + + def spi_send(b) -> int: + ret = 0 + ret |= self.sync_asic_write(SF_WDATA, b & 0xFF) + ret |= self.sync_asic_write(SF_TRIG, 1) # write phase + ret |= wait_ready() + return ret + + def spi_recv() -> tuple[int, int]: + ret = 0 + ret |= self.sync_asic_write(SF_RDATA, 0) + ret |= self.sync_asic_write(SF_TRIG, 2) # read phase + ret |= wait_ready() + if ret != 0: + return ret, 0 + return self.asic_read(SF_RDATA) + + def read_flash(addr, n) -> tuple[int, bytes]: + ret = 0 + ret |= spi_begin() + ret |= spi_send(0x0B) # FAST_READ + ret |= spi_send((addr >> 16) & 0xFF) # 3 address bytes, MSB first + ret |= spi_send((addr >> 8) & 0xFF) + ret |= spi_send(addr & 0xFF) + ret |= spi_send(0x00) # 1 dummy byte for fast-read + + data_values = [] + for _ in range(n): + spi_ret, data_value = spi_recv() + data_values.append(data_value) + + ret |= spi_ret + + ret |= spi_end() + return ret, bytes(data_values) + + ret, st = read_flash(0x160, 0x2B) # Get the sector table (43 bytes) + if ret != 0: + self.logger.error("Failed to read flash memory!") + return None + + # Get the correct address + param_start = (st[0x0F] << 24) | (st[0x10] << 16) | (st[0x11] << 8) | st[0x12] + + # Decode the value + ret, slot = read_flash( + param_start + 0x100, 0x40 + ) # 64-byte USB string descriptor + + if ret != 0: + self.logger.error("Failed to read slot!") + return None + + if slot[1] != 0x03 or slot[0] in (0x00, 0xFF): + self.logger.error("Slot is invalid!") + return None + + nchars = (slot[0] - 2) // 2 + return "".join(chr(slot[2 + 2 * i]) for i in range(nchars)) + def sensor_write_high_low( self, key: str, @@ -220,12 +361,18 @@ def sync_sensor_write_high_low( self.sync_sensor_write(reg_high, (value >> 8) & 0xFF) # This is extremely scuffed: switch to waiting for # trigger register before release (See below) - time.sleep(write_delay_s) + # time.sleep(write_delay_s) + self.logger.info("Starting read") + while self.asic_read(StellarRegisterMap.REG_TRIG)[1] != 0xAA: + continue + self.logger.info("Finishing read") # 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) + while self.asic_read(StellarRegisterMap.REG_TRIG)[1] != 0xAA: + continue def sensor_read_high_low(self, reg_high, reg_low) -> int | None: """ diff --git a/backend_py/src/services/cameras/drivers/device.py b/backend_py/src/services/cameras/drivers/device.py index 85ed904c..1dbea25e 100644 --- a/backend_py/src/services/cameras/drivers/device.py +++ b/backend_py/src/services/cameras/drivers/device.py @@ -30,6 +30,7 @@ from ..stream_runner import Stream, StreamRunner from ..stream_utils import string_to_stream_encode_type +from .asic_interface import ASICInterface from .options import BaseOption from .registry import DeviceMetadata from .video4linux import Camera @@ -58,8 +59,13 @@ def __init__( self.pid = device_info.pid self.bus_info = device_info.bus_info self.nickname = "" + self.is_externally_managed = False self.stream = Stream() + # ASIC Interface for low level register read/writes + self.asic_interface = ASICInterface(self.cameras[0]) + self.string3 = self.asic_interface.read_string3() + # Thread safety self._configuration_lock = threading.Lock() @@ -116,6 +122,20 @@ def can_lead(self) -> bool: def can_follow(self) -> bool: return False + def on_external_stream_started(self) -> None: + pass + + def on_external_unmanaged(self) -> None: + self.is_externally_managed = False + self.emit("updated") # Trigger UI update + + def on_external_managed(self) -> None: + self.is_externally_managed = True + self.stop_stream() + # Save the fact that the stream is stopped (could be done from updated) + self.emit("save") + self.emit("updated") # Trigger UI update + def _update_drop_stats(self) -> None: with self._frame_stats_lock: self.frame_stats.num_drops += 1 @@ -252,8 +272,12 @@ def add_controls_from_options(self, options: dict[str, BaseOption]) -> None: self._id_counter += 1 def start_stream(self) -> None: - # with self._frame_stats_lock: - # self.frame_stats = FrameDropStats(num_drops=0) + if self.is_externally_managed: + self.logger.info("Cannot start the stream of an externally managed device") + return + + with self._frame_stats_lock: + self.frame_stats = FrameDropStats(num_drops=0) with self._configuration_lock: self.stream.enabled = True @@ -262,7 +286,7 @@ def start_stream(self) -> None: # 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") + self.emit("frame_stats") def stop_stream(self) -> None: with self._configuration_lock: @@ -324,6 +348,7 @@ def set_pu( if control_id < 0: # DWE control + # FIXME: CRITICAL: This is very bad performance. It MUST be a dict for control in self.controls: if control.control_id == control_id: control.value = value @@ -368,8 +393,8 @@ def get_option(self, opt: str) -> Any: # set an option def set_option(self, opt: str, value: Any, from_save=False) -> None: self.logger.debug(f"Setting option - {opt} to {value}") - if opt in self._options and ( - not from_save or not self._options[opt].load_from_save + if opt in self._options and not ( + from_save and not self._options[opt].load_from_save ): self._options[opt].set_value(value) diff --git a/backend_py/src/services/cameras/drivers/shd/options.py b/backend_py/src/services/cameras/drivers/shd/options.py index 1a82196b..5bbb4869 100644 --- a/backend_py/src/services/cameras/drivers/shd/options.py +++ b/backend_py/src/services/cameras/drivers/shd/options.py @@ -4,24 +4,30 @@ import logging from abc import abstractmethod +from collections.abc import Callable from backend_py.src.models import ControlFlagsModel, ControlTypeEnum, MenuItemModel +from ..asic_interface import ASICInterface 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 + self, + name: str, + control_flags: ControlFlagsModel, + interface: ASICInterface, + callback: Callable[[int | float | bool], None] | None = None, ) -> None: super().__init__(name, control_flags) self._cached = control_flags.default_value self._interface = interface + self._callback = callback self.logger = logging.getLogger( f"dwe_os_2.cameras.{interface.camera.path}.ASICOption.{name}" @@ -29,7 +35,6 @@ def __init__( @abstractmethod def _write(self, value: int | float | bool) -> None: - # TODO: add checks for value type pass @abstractmethod @@ -41,13 +46,15 @@ def set_value(self, value: int | float | bool) -> None: value = int(value) self._cached = value self._write(value) + if self._callback: + self._callback(value) def get_value(self) -> int | float | bool: return self._cached class AutoExposureOption(ASICOption): - def __init__(self, asic_interface: ASICInterface) -> None: + def __init__(self, asic_interface: ASICInterface, **kwargs) -> None: super().__init__( "Auto Exposure (ASIC)", ControlFlagsModel( @@ -58,6 +65,7 @@ def __init__(self, asic_interface: ASICInterface) -> None: control_type=ControlTypeEnum.BOOLEAN, ), asic_interface, + **kwargs, ) def _write(self, value: int | float | bool) -> None: @@ -77,12 +85,9 @@ def __init__( asic_interface: ASICInterface, high_register: int, low_register: int, + **kwargs, ) -> None: - super().__init__( - name, - control_flags, - asic_interface, - ) + super().__init__(name, control_flags, asic_interface, **kwargs) self.high_register = high_register self.low_register = low_register @@ -100,14 +105,11 @@ def _read(self) -> int | None: class HardwareBitrateOption(ASICHighLowOption): PRESET_MAP = {0: 5000, 1: 9000, 2: 13000, 3: 20000, 4: 60000} - def __init__( - self, - asic_interface: ASICInterface, - ) -> None: + def __init__(self, asic_interface: ASICInterface, **kwargs) -> None: super().__init__( "JPEG Image Quality", ControlFlagsModel( - default_value=2, # default to medium + default_value=4, # default to highest menu=[ MenuItemModel(index=0, name="Lowest"), MenuItemModel(index=1, name="Low"), @@ -123,6 +125,7 @@ def __init__( asic_interface, StellarRegisterMap.REG_HW_BITRATE_HIGH, StellarRegisterMap.REG_HW_BITRATE_LOW, + **kwargs, ) def _write(self, value: int | float | bool) -> None: @@ -159,12 +162,9 @@ def __init__( low_register: int, # FIXME: check write delay write_delay_s: float = 0.6, + **kwargs, ) -> None: - super().__init__( - name, - control_flags, - asic_interface, - ) + super().__init__(name, control_flags, asic_interface, **kwargs) self.high_register = high_register self.low_register = low_register @@ -186,7 +186,7 @@ def _read(self) -> int | float | bool | None: class ShutterSpeedOption(SensorHighLowOption): - def __init__(self, asic_interface: ASICInterface) -> None: + def __init__(self, asic_interface: ASICInterface, **kwargs) -> None: super().__init__( "Exposure Time", ControlFlagsModel( @@ -200,11 +200,12 @@ def __init__(self, asic_interface: ASICInterface) -> None: StellarSensorMap.SHUTTER_HIGH, StellarSensorMap.SHUTTER_LOW, write_delay_s=0.6, + **kwargs, ) class VtsOption(SensorHighLowOption): - def __init__(self, asic_interface: ASICInterface) -> None: + def __init__(self, asic_interface: ASICInterface, **kwargs) -> None: super().__init__( "VTS", ControlFlagsModel( @@ -217,6 +218,7 @@ def __init__(self, asic_interface: ASICInterface) -> None: asic_interface, StellarSensorMap.VTS_HIGH, StellarSensorMap.VTS_LOW, + **kwargs, ) @@ -241,7 +243,7 @@ def get_value(self) -> int | float | bool: class HtsOption(SensorHighLowOption): - def __init__(self, asic_interface: ASICInterface) -> None: + def __init__(self, asic_interface: ASICInterface, **kwargs) -> None: super().__init__( "HTS", ControlFlagsModel( @@ -254,15 +256,16 @@ def __init__(self, asic_interface: ASICInterface) -> None: asic_interface, StellarSensorMap.HTS_HIGH, StellarSensorMap.HTS_LOW, + **kwargs, ) class GainOption(SensorHighLowOption): - def __init__(self, asic_interface: ASICInterface) -> None: + def __init__(self, asic_interface: ASICInterface, **kwargs) -> None: super().__init__( "ISO", ControlFlagsModel( - default_value=400, + default_value=0, max_value=4095, min_value=0, step=1, @@ -271,11 +274,12 @@ def __init__(self, asic_interface: ASICInterface) -> None: asic_interface, StellarSensorMap.ISO_HIGH, StellarSensorMap.ISO_LOW, + **kwargs, ) class StrobeWidthOption(SensorHighLowOption): - def __init__(self, asic_interface: ASICInterface) -> None: + def __init__(self, asic_interface: ASICInterface, **kwargs) -> None: super().__init__( "Strobe Width", ControlFlagsModel( @@ -288,6 +292,7 @@ def __init__(self, asic_interface: ASICInterface) -> None: asic_interface, StellarSensorMap.STROBE_WIDTH_HIGH, StellarSensorMap.STROBE_WIDTH_LOW, + **kwargs, ) # Strobe width should not be set on start self.load_from_save = False diff --git a/backend_py/src/services/cameras/drivers/shd/shd.py b/backend_py/src/services/cameras/drivers/shd/shd.py index f679cbae..adf8783d 100644 --- a/backend_py/src/services/cameras/drivers/shd/shd.py +++ b/backend_py/src/services/cameras/drivers/shd/shd.py @@ -8,7 +8,6 @@ from ..device import Device, DeviceMetadata from ..video4linux import DeviceInfo -from .asic_interface import ASICInterface from .options import ( AutoExposureOption, BaseOption, @@ -49,12 +48,15 @@ def __init__( # Should directly correspond with self.is_managed self.leader_device: SHDDevice | None = None - # ASIC Interface for low level register read/writes - self.asic_interface = ASICInterface(self.cameras[0]) + def on_auto_exposure(value: int | float | bool) -> None: + if not value: + self.reapply_sensor_config(ignore_list=["auto_exposure"]) # options options: dict[str, BaseOption] = { - "auto_exposure": AutoExposureOption(self.asic_interface), + "auto_exposure": AutoExposureOption( + self.asic_interface, callback=on_auto_exposure + ), "shutter": ShutterSpeedOption(self.asic_interface), "iso": GainOption(self.asic_interface), "strobe_width": StrobeWidthOption(self.asic_interface), @@ -71,20 +73,77 @@ def can_lead(self) -> bool: def can_follow(self) -> bool: return self.device_type == DeviceType.STELLARHD_FOLLOWER - def add_follower(self, device: "SHDDevice") -> None: + def on_external_stream_started(self) -> None: + # Override method + # We reapply assuming the stream has already been started + self.reapply_sensor_config() + + # We then want to apply the config from the camera + mjpeg_camera = self.find_camera_with_format("MJPG") + if mjpeg_camera: + current_format = mjpeg_camera.get_current_format() + self.logger.info(f"Current format: {current_format}") + + # Updates required so the UI knows the current format + self.stream.width = current_format.width + self.stream.height = current_format.height + self.stream.interval = current_format.interval + + for follower in self.follower_devices.values(): + follower.stream.width = current_format.width + follower.stream.height = current_format.height + follower.stream.interval = current_format.interval + + # Bad performance due to the lack of dict + for control in self.controls: + # from_save so light stays off + follower.set_pu(control.control_id, control.value, from_save=True) + + self.emit("updated") # Trigger UI update + + def on_external_managed(self) -> None: + # Remove all the followers + # The manager will handle adding them + for follower in list(self.follower_devices.keys()): + self.remove_follower(self.follower_devices[follower]) + + # Remove self as follower + if self.is_managed and self.leader_device: + self.leader_device.remove_follower(self) + # We could save (see notes) + + super().on_external_managed() + + def on_external_unmanaged(self) -> None: + self.set_pu(-4, 0) + + super().on_external_unmanaged() + + def add_follower(self, device: "SHDDevice", external=False) -> bool: + # If an external program is controlling it, it's ok + if not external and device.is_externally_managed: + self.logger.info("Cannot add a follower to an externally managed device") + return False + + # This is unrelated to the externally managed changes and should propagate + # to main + if device.is_managed: + self.logger.info("Cannot add a follower to an already managed device") + return False + # 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 + return False 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 + return False self.logger.info("Adding follower") @@ -107,12 +166,23 @@ def add_follower(self, device: "SHDDevice") -> None: if self.stream.enabled: self.start_stream() - def remove_follower(self, device: "SHDDevice") -> None: + if external: + self.emit("updated") + + return True + + def remove_follower(self, device: "SHDDevice") -> bool: + if self.is_externally_managed: + self.logger.info( + "Cannot remove a follower from an externally managed device" + ) + return False + if device.bus_info not in self.followers: self.logger.info( "Cannot remove follower from device that does not contain it." ) - return + return False # Reconstruct the list without the follower # if persist: # self.followers = [dev for dev in self.followers if dev != device.bus_info] @@ -126,6 +196,8 @@ def remove_follower(self, device: "SHDDevice") -> None: if self.stream.enabled: self.start_stream() + return True + def remove_manual(self, follower_bus_info: str) -> None: """ This should be called in the case the follower no longer exists @@ -173,7 +245,10 @@ def start_stream(self) -> None: self.reapply_sensor_config() for follower in self.follower_devices.values(): - follower.reapply_sensor_config() + # Way to set options from the backend on stream start + # This doesn't actually update it internally + # This is likely not necessary + follower.reapply_sensor_config(options=self._options) def remove_device(self) -> None: # Unplugging a device makes it too complicated to handle its follower stream, @@ -185,12 +260,26 @@ def remove_device(self) -> None: if self.is_managed and self.leader_device: self.leader_device.remove_follower(self) - def reapply_sensor_config(self) -> None: + def reapply_sensor_config( + self, + options: dict[str, BaseOption] | None = None, + ignore_list: list[str] | None = None, + ) -> None: + if not options: + options = {} + if not ignore_list: + ignore_list = [] + self.logger.info("Reapplying options after starting stream.") # This is bad self.set_pu(-4, 0) for option_name in self._options: + if option_name in ignore_list: + continue option = self._options[option_name] - option.set_value(option.get_value()) + value = option.get_value() + if options and option_name in options: + value = options[option_name].get_value() + option.set_value(value) diff --git a/backend_py/src/services/cameras/drivers/video4linux/camera.py b/backend_py/src/services/cameras/drivers/video4linux/camera.py index 0f543683..5b4e73fc 100644 --- a/backend_py/src/services/cameras/drivers/video4linux/camera.py +++ b/backend_py/src/services/cameras/drivers/video4linux/camera.py @@ -1,6 +1,6 @@ import fcntl -from backend_py.src.models import FormatSizeModel, IntervalModel +from backend_py.src.models import FormatSizeModel, IntervalModel, StreamFormatModel from ...camera_helper.camera_helper_loader import camera_helper from ...stream_utils import fourcc2s @@ -79,3 +79,22 @@ def _get_formats(self) -> None: ) format_sizes.append(format_size) self.formats[fourcc2s(v4l2_fmt.pixelformat)] = format_sizes + + def get_current_format(self) -> StreamFormatModel: + """Get the currently active format""" + fmt = v4l2.v4l2_format() + fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + fcntl.ioctl(self._fd, v4l2.VIDIOC_G_FMT, fmt) + + parm = v4l2.v4l2_streamparm() + parm.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + fcntl.ioctl(self._fd, v4l2.VIDIOC_G_PARM, parm) + + return StreamFormatModel( + width=fmt.fmt.pix.width, + height=fmt.fmt.pix.height, + interval=IntervalModel( + numerator=parm.parm.capture.timeperframe.numerator, + denominator=parm.parm.capture.timeperframe.denominator, + ), + ) diff --git a/backend_py/src/services/network/async_network_manager.py b/backend_py/src/services/network/async_network_manager.py index a70e8544..2572465a 100644 --- a/backend_py/src/services/network/async_network_manager.py +++ b/backend_py/src/services/network/async_network_manager.py @@ -30,11 +30,11 @@ def _ip_to_integer(addr: str) -> int: - return struct.unpack("!I", socket.inet_aton(addr))[0] + return struct.unpack(" str: - return socket.inet_ntoa(struct.pack("!I", addr)) + return socket.inet_ntoa(struct.pack(" Any: @@ -81,10 +81,16 @@ def _deserialize_ipv4_config(ipv4_settings: dict) -> IPV4Configuration: for addr in raw_addresses ] + # If it's an unsupported method (link-local) + try: + ip_v4_method = IPV4Method(method) + except ValueError: + ip_v4_method = IPV4Method.unknown + ip_v4_config = IPV4Configuration( ip_addresses=ip_addresses, gateway=gateway, - method=IPV4Method(method), + method=ip_v4_method, dns=[_integer_to_ip(dns) for dns in dns_servers], never_default=never_default, ) @@ -231,6 +237,7 @@ def __init__(self, device_path: str) -> None: self._settings_listener_task = None self.tasks = [] + self._ip4_watch: asyncio.Task | None = None self.manual_autoconnect = False @@ -285,39 +292,39 @@ async def _update_ipv4_connection_profile(self) -> None: ).connection async def _update_active_connection_settings(self) -> None: - if self.state != DeviceState.ACTIVATED: - self.logger.warning( - f"{self.interface}: Cannot update IP config of an inactive device" - ) - self.active_ip_configuration = None - return - config_path = await self.nm_device.ip4_config - if config_path == "/": - self.logger.error( - f"{self.interface}: Unable to retrieve IP config despite being active" - ) return + await self._read_ip4(config_path) - config = IPv4Config(config_path) - - # Initial construction - self.active_ip_configuration = IPV4Configuration() + # When it's activated, we start a task to check if the current configuration + # path is updated. Then we update it. This only happens in the rare case + # that the addresses are emitted after activation. + if self._ip4_watch: + self._ip4_watch.cancel() + self._ip4_watch = asyncio.create_task(self._watch_ip4(config_path)) - # Update the active data - address_data = _unpack_dbus_value(await config.address_data) + async def _watch_ip4(self, config_path: str) -> None: + config = IPv4Config(config_path) + async for _i, changed, _inv in config.properties_changed.catch(): + if self.state != DeviceState.ACTIVATED: + return + self.logger.info(f"IPv4Changed: {changed.keys()}") + if changed.keys(): + await self._read_ip4(config_path) - self.active_ip_configuration.ip_addresses = [ - IPV4Address(address=addr["address"], prefix=addr["prefix"]) - for addr in address_data + async def _read_ip4(self, config_path: str) -> None: + config = IPv4Config(config_path) + cfg = IPV4Configuration() + cfg.ip_addresses = [ + IPV4Address(address=a["address"], prefix=a["prefix"]) + for a in _unpack_dbus_value(await config.address_data) ] - self.active_ip_configuration.gateway = await config.gateway - # Maybe we can do a single unpack - self.active_ip_configuration.dns = [ - data["address"] for data in _unpack_dbus_value(await config.nameserver_data) + cfg.gateway = await config.gateway + cfg.dns = [ + d["address"] for d in _unpack_dbus_value(await config.nameserver_data) ] - + self.active_ip_configuration = cfg self.emit("ip_config_changed") async def _set_state( @@ -328,6 +335,13 @@ async def _set_state( """ self.state = new_state + new_interface_name = await self.nm_device.interface + if new_interface_name != self.interface: + self.logger.info( + f"Interface name changed: {self.interface} -> {new_interface_name}" + ) + self.interface = new_interface_name + # Yes, we can decouple this into two methods, and remove the checking # if there is a connection logic, but this is 100% guaranteed to be reliable # and there is no tangible performance benefit for the former. @@ -347,6 +361,8 @@ async def _set_state( await self._update_active_connection_settings() else: self.active_ip_configuration = None + if self._ip4_watch: + self._ip4_watch.cancel() if ( self.manual_autoconnect @@ -389,6 +405,7 @@ def __init__(self) -> None: self.profiles: dict[str, ConnectionProfile] = {} self._profiles_updated_task: asyncio.Task | None = None + self._devices_updated_task: asyncio.Task | None = None async def _update_profiles(self) -> None: all_paths = await self.nm_settings.connections @@ -396,7 +413,7 @@ async def _update_profiles(self) -> None: for path in all_paths: profile = ConnectionProfile(path) profile.on( - "settings_changed", + "settings_updated", lambda profile=profile: self.emit("profile_updated", profile), ) await profile.initialize() @@ -527,6 +544,56 @@ async def handle_removed() -> None: await asyncio.gather(handle_new(), handle_removed()) + async def _listen_devices_updated(self) -> None: + async def handle_added() -> None: + async for device_path in self.nm.device_added: + self.logger.info(f"{device_path}: New device detected") + await self._add_device(device_path) + self.all_devices.append(device_path) + + async def handle_removed() -> None: + async for device_path in self.nm.device_removed: + self.all_devices.remove(device_path) + self.ethernet_devices = [ + d for d in self.ethernet_devices if d.get_dbus_path() != device_path + ] + self.emit("devices_changed") + + await asyncio.gather(handle_added(), handle_removed()) + + async def _add_device(self, device_path) -> None: + generic = NetworkDeviceGeneric(device_path) + + if await generic.capabilities & Capabilities.IS_SOFTWARE: + return + + interface = await generic.interface + device_type = DeviceType(await generic.device_type) + state = DeviceState(await generic.state) + + self.logger.debug(f"{interface}: {state.name}") + + if device_type == DeviceType.ETHERNET: + eth_device = WiredDevice(device_path) + await eth_device.initialize() + eth_device.on( + "request_activation", + lambda dev: asyncio.create_task(self.activate_ethernet_device(dev)), + ) + eth_device.on( + "ip_config_changed", + lambda eth_device=eth_device: self.emit( + "ip_config_changed", eth_device + ), + ) + eth_device.on( + "state_changed", + lambda old_state, new_state, eth_device=eth_device: self.emit( + "state_changed", eth_device + ), + ) + self.ethernet_devices.append(eth_device) + async def initialize(self) -> None: self.all_devices = await self.nm.devices @@ -535,37 +602,7 @@ async def initialize(self) -> None: self._listen_connection_profiles() ) - for device_path in self.all_devices: - generic = NetworkDeviceGeneric(device_path) - - if await generic.capabilities & Capabilities.IS_SOFTWARE: - continue - - interface = await generic.interface - device_type = DeviceType(await generic.device_type) - state = DeviceState(await generic.state) - - self.logger.debug(f"{interface}: {state.name}") + self._devices_updated_task = asyncio.create_task(self._listen_devices_updated()) - if device_type == DeviceType.ETHERNET: - eth_device = WiredDevice(device_path) - await eth_device.initialize() - eth_device.on( - "request_activation", - lambda dev: asyncio.create_task(self.activate_ethernet_device(dev)), - ) - eth_device.on( - "ip_config_changed", - lambda eth_device=eth_device: self.emit( - "ip_config_changed", eth_device - ), - ) - eth_device.on( - "state_changed", - lambda old_state, new_state, eth_device=eth_device: self.emit( - "state_changed", eth_device - ), - ) - self.ethernet_devices.append(eth_device) - - # TODO: Wireless + for device_path in self.all_devices: + await self._add_device(device_path) diff --git a/backend_py/src/services/network/nm_wrapper.py b/backend_py/src/services/network/nm_wrapper.py index a0e4d3f0..cf2ad7f7 100644 --- a/backend_py/src/services/network/nm_wrapper.py +++ b/backend_py/src/services/network/nm_wrapper.py @@ -1,6 +1,5 @@ import asyncio import logging -import time import socketio from event_emitter import EventEmitter @@ -23,22 +22,16 @@ def __init__(self, sio: socketio.AsyncServer) -> None: self.nm = AsyncNetworkManager() self.sio = sio - self.last_connection_time = time.time() - - @self.sio.on("connect") - def on_connect(sid, environ) -> None: - # self.logger.info(f"Connection detected: {sid}") - self.last_connection_time = time.time() - self.nm.on("profile_updated", lambda profile: self._refresh_ui()) self.nm.on("profiles_changed", lambda: self._refresh_ui()) self.nm.on("ip_config_changed", lambda device: self._refresh_ui()) self.nm.on("state_changed", lambda device: self._refresh_ui()) + self.nm.on("devices_changed", lambda: self._refresh_ui()) self._rollback_timer_task: asyncio.Task | None = None def _refresh_ui(self) -> None: - self.emit("refresh_ui") + asyncio.create_task(self.sio.emit("refresh_wired_config")) async def initialize(self) -> None: await self.nm.initialize() @@ -84,41 +77,18 @@ async def update_connection_profile( return False - async def activate_interface( - self, interface: str, profile_path: str, enable_rollback=False - ) -> bool: + async def activate_interface(self, interface: str, profile_path: str) -> bool: profile = self.nm.get_profile(profile_path) device = self.nm.get_device_by_iface(interface) if not profile or not device: return False - time_of_change = time.time() - await self.nm.activate_ethernet_device(device, profile) - if enable_rollback: - if self._rollback_timer_task: - self._rollback_timer_task.cancel() - self._rollback_timer_task = asyncio.create_task( - self._rollback_timer(interface, profile_path, time_of_change, 30) - ) - return True async def _force_dhcp(self, interface: str, profile_path: str) -> None: safe_ip_config = IPV4Configuration(method=IPV4Method.auto, never_default=False) await self.update_connection_profile(profile_path, safe_ip_config) - await self.activate_interface(interface, profile_path, False) - - async def _rollback_timer( - self, interface: str, profile_path: str, time_of_change: float, timeout: int - ) -> None: - await asyncio.sleep(timeout) - - if self.last_connection_time < time_of_change: - self.logger.error("Lockout detected! Forcing DHCP") - - await self._force_dhcp(interface, profile_path) - else: - self.logger.info("Active connection detected, not forcing rollback!") + await self.activate_interface(interface, profile_path) diff --git a/backend_py/src/services/preferences/settings_manager.py b/backend_py/src/services/preferences/settings_manager.py index 2c0fedb5..a37825d1 100644 --- a/backend_py/src/services/preferences/settings_manager.py +++ b/backend_py/src/services/preferences/settings_manager.py @@ -153,7 +153,7 @@ def _update_settings(self) -> None: self.file_object.flush() def _save_device(self, saved_device: SavedDeviceModel) -> None: - self.logger.debug(f"Saving device: {saved_device.bus_info}") + # self.logger.debug(f"Saving device: {saved_device.bus_info}") with self._lock: # Semi scuffed diff --git a/backend_py/src/services/recordings/recordings_service.py b/backend_py/src/services/recordings/recordings_service.py index dd88388a..a6bd095b 100644 --- a/backend_py/src/services/recordings/recordings_service.py +++ b/backend_py/src/services/recordings/recordings_service.py @@ -52,7 +52,11 @@ def get_recordings(self) -> list[RecordingInfo]: path=file_path, name=filename.split(".")[0], format=filename.split(".")[-1], - duration=self._get_duration(file_path), + duration=( + self._get_duration(file_path) + if not filename.endswith(".dwvo") + else "00:00:00" + ), created=self._epoch_to_readable(file_stat.st_ctime), size=f"{file_stat.st_size / (1024 * 1024):.2f} MB", ) @@ -162,24 +166,45 @@ def rename_recording( return self.recordings return None - def zip_recordings(self, filenames: list[str] | None = None) -> str | None: + def zip_recordings( + self, + filenames: list[str] | None = None, + active_jobs: dict | None = None, + job_id: str | None = None, + ) -> str | None: self.get_recordings() if not self.recordings: return None + targets = [] + for recording in self.recordings: + full_name = f"{recording.name}.{recording.format}" + if ( + filenames is not None + and recording.name not in filenames + and full_name not in filenames + ): + continue + targets.append((recording, full_name)) + + total_files = len(targets) + if total_files == 0: + return None + unique_id = uuid.uuid4().hex zip_filename = os.path.join(self.recordings_path, f"temp_{unique_id}.zip") with zipfile.ZipFile(zip_filename, "w") as zipf: - for recording in self.recordings: - full_name = f"{recording.name}.{recording.format}" - - if ( - filenames is not None - and recording.name not in filenames - and full_name not in filenames - ): - continue + for i, (recording, full_name) in enumerate(targets): + if active_jobs is not None and job_id is not None: + if active_jobs.get(job_id, {}).get("cancel", False): + zipf.close() + if os.path.exists(zip_filename): + os.remove(zip_filename) + return "CANCELLED" + + progress = int((i / total_files) * 100) + active_jobs[job_id]["progress"] = progress zipf.write(recording.path, arcname=full_name) return zip_filename diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7f34aa9..b10452cf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "v0.7.0", + "version": "v0.7.2-beta.dwd", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "v0.7.0", + "version": "v0.7.2-beta.dwd", "dependencies": { "@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-scroll-area": "^1.2.6", "@radix-ui/react-select": "^2.1.6", @@ -82,39 +83,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -122,22 +109,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -153,16 +140,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -170,14 +157,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", - "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -186,30 +173,40 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -229,9 +226,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -239,9 +236,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -249,9 +246,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -259,27 +256,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -321,58 +318,48 @@ } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1048,17 +1035,24 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1070,15 +1064,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1086,9 +1071,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1183,6 +1168,36 @@ } } }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collapsible": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.10.tgz", + "integrity": "sha512-O2mcG3gZNkJ/Ena34HurA3llPOEA/M4dJtIRMa6y/cknRDC8XY5UZBInKTsUwW5cUue9A4k0wi1XU5fKBzKe1w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.6.tgz", @@ -1378,77 +1393,6 @@ } } }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.10.tgz", - "integrity": "sha512-O2mcG3gZNkJ/Ena34HurA3llPOEA/M4dJtIRMa6y/cknRDC8XY5UZBInKTsUwW5cUue9A4k0wi1XU5fKBzKe1w==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.2", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", - "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", - "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", @@ -2075,6 +2019,68 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.6.tgz", @@ -2992,9 +2998,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -4430,35 +4436,18 @@ "license": "MIT" }, "node_modules/engine.io-client": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", - "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", + "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.17.1", + "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, - "node_modules/engine.io-client/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -5341,10 +5330,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6204,9 +6203,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -6537,9 +6536,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -6556,7 +6555,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6845,12 +6844,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -6860,13 +6859,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -7998,9 +7997,9 @@ } }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/frontend/package.json b/frontend/package.json index 8371c99c..a8cd528f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "v0.7.0", + "version": "v0.7.2-beta.dwd", "type": "module", "scripts": { "dev": "vite --host", @@ -18,6 +18,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-scroll-area": "^1.2.6", "@radix-ui/react-select": "^2.1.6", diff --git a/frontend/src/components/dwe/cameras/cam-control-map.json b/frontend/src/components/dwe/cameras/cam-control-map.json index 64ceb868..2a9e6736 100644 --- a/frontend/src/components/dwe/cameras/cam-control-map.json +++ b/frontend/src/components/dwe/cameras/cam-control-map.json @@ -19,7 +19,7 @@ "Exposure Time, Absolute", "Exposure, Dynamic Framerate" ], - "Image Optimization": [ + "Image Processing": [ "Brightness", "Contrast", "Saturation", diff --git a/frontend/src/components/dwe/cameras/device-card.tsx b/frontend/src/components/dwe/cameras/device-card.tsx index 77b7e0a3..8c39031a 100644 --- a/frontend/src/components/dwe/cameras/device-card.tsx +++ b/frontend/src/components/dwe/cameras/device-card.tsx @@ -9,12 +9,18 @@ import { useDeviceStore } from "@/store/devices"; import { CameraStream } from "./stream/stream"; import { CameraNickname } from "./nickname"; import { FrameDropIndicator } from "./frame-drop-indicator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; const DeviceCard = ({ bus_id }: { bus_id: string }) => { const deviceName = useDeviceStore((state) => state.devices[bus_id].name); const deviceManufacturer = useDeviceStore( (state) => state.devices[bus_id].manufacturer, ); + const string3 = useDeviceStore((state) => state.devices[bus_id].string3); return ( @@ -23,14 +29,26 @@ const DeviceCard = ({ bus_id }: { bus_id: string }) => {
{deviceName} - Manufacturer: {deviceManufacturer} + {deviceManufacturer} • {bus_id}
- USB Port ID: {bus_id} + {string3.length > 0 && ( + + Firmware String: {string3} + + The firmware parameter for the device. +
+ If this is visible, it means your device is running + specialized firmware. +
+
+ )}
- +
+ +
diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index e157a37e..c5887922 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -34,10 +34,14 @@ const DeviceListLayout = () => { socket.on("device_removed", () => { fetchDevices(); }); + socket.on("device_updated", () => { + fetchDevices(); + }); return () => { socket.off("device_added"); socket.off("device_removed"); + socket.off("device_updated"); }; }, [connected, socket, fetchDevices, resetDevices, fetchPreferences]); diff --git a/frontend/src/components/dwe/cameras/nickname.tsx b/frontend/src/components/dwe/cameras/nickname.tsx index 5a0ec924..5e86a1aa 100644 --- a/frontend/src/components/dwe/cameras/nickname.tsx +++ b/frontend/src/components/dwe/cameras/nickname.tsx @@ -31,7 +31,7 @@ export const CameraNickname = ({ bus_id }: { bus_id: string }) => { }; return ( -
+
{ +export const FollowerList = ({ + bus_id, + disabled, +}: { + bus_id: string; + disabled: boolean; +}) => { // Full list of devices const availableFollowers = useAvailableFollowers(bus_id); const addFollower = useDeviceStore((state) => state.addFollower); @@ -62,7 +68,7 @@ export const FollowerList = ({ bus_id }: { bus_id: string }) => { label="Add Follower" value={selectedFollower} onChange={setSelectedFollower} - disabled={noAvailableFollowers} + disabled={noAvailableFollowers || disabled} />
@@ -72,7 +78,7 @@ export const FollowerList = ({ bus_id }: { bus_id: string }) => { handleAddFollower(selectedFollower); }} className="w-full" - disabled={noAvailableFollowers || isStreamLoading} + disabled={noAvailableFollowers || isStreamLoading || disabled} > Add @@ -111,7 +117,7 @@ export const FollowerList = ({ bus_id }: { bus_id: string }) => {
- {!device.is_managed && device.stream.stream_type === "UDP" && ( + {!isManaged && device.stream.stream_type === "UDP" && ( )} - {canLead(device) && } + {canLead(device) && }
@@ -191,7 +193,7 @@ export const CameraStream = ({ bus_id }: { bus_id: string }) => {
- +
+

+ Changes are saved to the connection profile. To apply them to the + device, select the profile in the network list or reboot. +

+ +
@@ -415,6 +421,8 @@ function WiredDevice({ }) { console.log(profiles); + if (wired_device.state === 0 || wired_device.state === 10) return null; + return ( { {/* Delete Dialog */} - 0} onOpenChange={(open) => !open && recordingsActions.closeDelete()} > - - + +
-
- Delete recording? - +
+ Delete recording? + This action cannot be undone. - +
- - - + + + + + + + {/* Cancel All Downloads Dialog */} + + !open && recordingsActions.closeCancelAllModal() + } + > + + +
+
+ +
+
+ Cancel all downloads? + + This will immediately stop all active zipping tasks. + Incomplete files will be deleted. + +
+
+
+ + + + +
+
); }; diff --git a/frontend/src/components/dwe/recordings/components/recording-table.tsx b/frontend/src/components/dwe/recordings/components/recording-table.tsx index 4ee50219..928679ab 100644 --- a/frontend/src/components/dwe/recordings/components/recording-table.tsx +++ b/frontend/src/components/dwe/recordings/components/recording-table.tsx @@ -227,12 +227,12 @@ export const RecordingTable = ({ e.stopPropagation()} > onSort("name")} > Name   @@ -261,6 +261,7 @@ export const RecordingTable = ({ Size   {sortColumn === "size" && (sortDirection === "asc" ? "▲" : "▼")} + @@ -324,18 +325,18 @@ export const RecordingTable = ({ /> - + {formatDate(recording.created)} - + {recording.duration} - + {formatFileSize( recording.size ? parseFloat(recording.size) : 0, )} - + - - + + + +
+ + {snap.selectedNames.length} selected + + +
+ ) : ( + + {snap.zipJobs.length} Download{snap.zipJobs.length > 1 ? "s" : ""} + + + +
+
+
+ {snap.zipJobs.map((job) => ( +
+
+
+ + Zipping{" "} + {job.totalFiles} items... + + +
+ +
+
+ ))} +
+
+
+ + )} ); }; diff --git a/frontend/src/components/dwe/recordings/store/recording-store.tsx b/frontend/src/components/dwe/recordings/store/recording-store.tsx index d91b5dac..2aa6205d 100644 --- a/frontend/src/components/dwe/recordings/store/recording-store.tsx +++ b/frontend/src/components/dwe/recordings/store/recording-store.tsx @@ -12,12 +12,21 @@ interface DiskStats { used: number; free: number; } + +interface ZipJob { + id: string; + progress: number; + status: "zipping" | "ready" | "error"; + totalFiles: number; +} interface RecordingsState { recordings: RecordingInfo[]; diskStats: DiskStats | null; selectedNames: string[]; loading: boolean; - zipDownloading: boolean; + zipJobs: ZipJob[]; + isZipDrawerMinimized: boolean; + isCancelAllZipModalOpen: boolean; // Modal Targets playTarget: RecordingInfo | null; @@ -44,7 +53,9 @@ export const recordingsState = proxy({ diskStats: null, selectedNames: [], loading: true, - zipDownloading: false, + zipJobs: [], + isZipDrawerMinimized: false, + isCancelAllZipModalOpen: false, playTarget: null, renameTarget: null, deleteTargets: [], @@ -93,12 +104,13 @@ export const recordingsActions = { }, downloadZip: async (baseUrl: string) => { - if (recordingsState.zipDownloading) return; - const selected = recordingsState.selectedNames; + const selected = [...recordingsState.selectedNames]; if (recordingsState.recordings.length === 0 || selected.length === 0) return; - recordingsState.zipDownloading = true; + recordingsActions.setSelectedNames([]); + recordingsState.isZipDrawerMinimized = false; + try { const response = await fetch(`${baseUrl}/api/recordings/zip/prepare`, { method: "POST", @@ -106,40 +118,108 @@ export const recordingsActions = { body: JSON.stringify(selected), }); - if (!response.ok) { - let description = `Server responded with ${response.status}.`; - try { - const data = await response.json(); - if (data?.detail) description = data.detail; - } catch { - /* empty */ + const { job_id } = await response.json(); + recordingsState.zipJobs.push({ + id: job_id, + progress: 0, + status: "zipping", + totalFiles: selected.length, + }); + + // poll every 0.5 seconds for progress + const pollInterval = setInterval(async () => { + const jobIndex = recordingsState.zipJobs.findIndex( + (j) => j.id === job_id, + ); + + if (jobIndex === -1) { + clearInterval(pollInterval); + return; } - toast.error("Failed to download recordings", { description }); - return; - } - const { token } = await response.json(); + const statusRes = await fetch( + `${baseUrl}/api/recordings/zip/status/${job_id}`, + ); + if (!statusRes.ok) { + clearInterval(pollInterval); + recordingsState.zipJobs.splice(jobIndex, 1); + return; + } + + const { status, progress } = await statusRes.json(); + recordingsState.zipJobs[jobIndex].progress = progress || 0; + + if (status === "ready") { + clearInterval(pollInterval); + recordingsState.zipJobs[jobIndex].status = "ready"; + recordingsState.zipJobs[jobIndex].progress = 100; - const filename = - selected.length === recordingsState.recordings.length - ? "all_recordings.zip" - : "selected_recordings.zip"; + const downloadUrl = `${baseUrl}/api/recordings/zip/download?token=${job_id}&filename=selected_recordings.zip`; + const link = document.createElement("a"); + link.href = downloadUrl; + document.body.appendChild(link); + link.click(); + link.remove(); + + setTimeout(() => { + const idx = recordingsState.zipJobs.findIndex( + (j) => j.id === job_id, + ); + if (idx !== -1) recordingsState.zipJobs.splice(idx, 1); + }, 800); + } else if (status === "error") { + clearInterval(pollInterval); + recordingsState.zipJobs.splice(jobIndex, 1); + toast.error("Zipping failed on server."); + } + }, 500); + } catch { + toast.error("Failed to start download"); + } + }, - const downloadUrl = `${baseUrl}/api/recordings/zip/download?token=${token}&filename=${filename}`; - const link = document.createElement("a"); - link.href = downloadUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); + cancelZip: async (baseUrl: string, jobId: string) => { + try { + await fetch(`${baseUrl}/api/recordings/zip/cancel/${jobId}`, { + method: "POST", + }); + const idx = recordingsState.zipJobs.findIndex((j) => j.id === jobId); + if (idx !== -1) recordingsState.zipJobs.splice(idx, 1); } catch (error) { - console.error("Error downloading zip:", error); - toast.error("Failed to download recordings"); - } finally { - recordingsState.zipDownloading = false; + console.error("Error cancelling job:", error); + } + }, + + cancelAllZips: async (baseUrl: string) => { + const activeJobs = [...recordingsState.zipJobs]; + + recordingsState.zipJobs = []; + recordingsState.isZipDrawerMinimized = false; + recordingsState.isCancelAllZipModalOpen = false; + + for (const job of activeJobs) { + try { + await fetch(`${baseUrl}/api/recordings/zip/cancel/${job.id}`, { + method: "POST", + }); + } catch (error) { + console.error(`Error cancelling job ${job.id}:`, error); + } } }, + toggleZipDrawer: () => { + recordingsState.isZipDrawerMinimized = + !recordingsState.isZipDrawerMinimized; + }, + + openCancelAllModal: () => { + recordingsState.isCancelAllZipModalOpen = true; + }, + closeCancelAllModal: () => { + recordingsState.isCancelAllZipModalOpen = false; + }, + // Modal openPlay: (rec: RecordingInfo) => { recordingsState.playTarget = rec; diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 00000000..fff79d58 --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/frontend/src/schemas/dwe_os_2.d.ts b/frontend/src/schemas/dwe_os_2.d.ts index 449cd525..cd23d78d 100644 --- a/frontend/src/schemas/dwe_os_2.d.ts +++ b/frontend/src/schemas/dwe_os_2.d.ts @@ -140,6 +140,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/devices/external/notify_camera": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Notify dweOS that a camera has been configured externally */ + post: operations["notify_camera_api_devices_external_notify_camera_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/preferences": { parameters: { query?: never; @@ -259,6 +276,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/recordings/disk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get physical disk usage */ + get: operations["get_disk_usage_api_recordings_disk_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/recordings/zip/prepare": { parameters: { query?: never; @@ -345,23 +379,6 @@ export interface paths { patch: operations["rename_recording_api_recordings__old_name___new_name__patch"]; trace?: never; }; - "/api/recordings/disk": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get physical disk usage */ - get: operations["get_disk_usage_api_recordings_disk_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/network/wired/devices": { parameters: { query?: never; @@ -513,7 +530,7 @@ export interface components { /** ControlFlagsModel */ ControlFlagsModel: { /** Default Value */ - default_value: number; + default_value: number | boolean; /** * Max Value * @default 0 @@ -601,6 +618,16 @@ export interface components { * "num_drops": 0 * } */ frame_stats: components["schemas"]["FrameDropStats"]; + /** + * Is Externally Managed + * @default false + */ + is_externally_managed: boolean; + /** + * String3 + * @default + */ + string3: string; }; /** DeviceNicknameModel */ DeviceNicknameModel: { @@ -721,6 +748,17 @@ export interface components { /** Message */ message: string; }; + /** + * ManagedEvent + * @enum {string} + */ + ManagedEvent: "DEVICE_MANAGED" | "STREAM_START" | "STREAM_STOP"; + /** ManagedNotifyModel */ + ManagedNotifyModel: { + /** Bus Info */ + bus_info: string; + event: components["schemas"]["ManagedEvent"]; + }; /** MenuItemModel */ MenuItemModel: { /** Index */ @@ -1106,6 +1144,39 @@ export interface operations { }; }; }; + notify_camera_api_devices_external_notify_camera_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ManagedNotifyModel"]; + }; + }; + 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"]; + }; + }; + }; + }; get_preferences_api_preferences_get: { parameters: { query?: never; @@ -1261,6 +1332,26 @@ export interface operations { }; }; }; + get_disk_usage_api_recordings_disk_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiskStatsResponse"]; + }; + }; + }; + }; prepare_zip_download_api_recordings_zip_prepare_post: { parameters: { query?: never; @@ -1455,26 +1546,6 @@ export interface operations { }; }; }; - get_disk_usage_api_recordings_disk_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DiskStatsResponse"]; - }; - }; - }; - }; get_wired_devices_api_network_wired_devices_get: { parameters: { query?: never; diff --git a/frontend/src/store/devices.ts b/frontend/src/store/devices.ts index a100a425..6520b937 100644 --- a/frontend/src/store/devices.ts +++ b/frontend/src/store/devices.ts @@ -49,10 +49,22 @@ export const useDeviceStore = create()( ) => { const stream = get().devices[bus_info].stream; + let shouldEnableStream; + if (partialStreamInfo.enabled !== undefined) + shouldEnableStream = partialStreamInfo.enabled; + else if ( + partialStreamInfo.endpoints && + partialStreamInfo.endpoints.length === 0 + ) + shouldEnableStream = false; + else { + shouldEnableStream = true; + } + // The stream info we are sending in the API request const streamInfo: components["schemas"]["StreamInfoModel"] = { bus_info: bus_info, - enabled: partialStreamInfo.enabled ?? stream.enabled, + enabled: shouldEnableStream, 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... @@ -174,11 +186,23 @@ export const useDeviceStore = create()( if (data && data.success) { set((state) => { const device = state.devices[bus_info]; + // FIXME: slow for (let i = 0; i < device.controls.length; i++) { // FIXME: this is not performant - if (device.controls[i].control_id === control_id) + if (device.controls[i].control_id === control_id) { device.controls[i].value = value; + // Another example of O(n^2) + + // FIXME + if (device.controls[i].name === "Auto Exposure (ASIC)") { + // we set strobe here: + if (device.controls[i].value) + state.setUVCControl(bus_info, -4, 0); + } + + break; + } } device.followers.forEach((follower_bus_info) => diff --git a/run_release.py b/run_release.py index e5f929ea..cf0367ea 100755 --- a/run_release.py +++ b/run_release.py @@ -1,14 +1,15 @@ -from fastapi.staticfiles import StaticFiles -from fastapi.responses import JSONResponse, FileResponse -from fastapi import FastAPI, Request, HTTPException +import argparse +import asyncio +import os +from contextlib import asynccontextmanager -from backend_py.src import Server, FeatureSupport import socketio -import os +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -import asyncio -from contextlib import asynccontextmanager -import argparse +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from backend_py.src import FeatureSupport, Server # Use AsyncServer sio = socketio.AsyncServer(async_mode="asgi", transports=["websocket"])