diff --git a/synapse_core/src/synapse/core/camera_factory.py b/synapse_core/src/synapse/core/camera_factory.py index dedb96c5..e53f6ef6 100644 --- a/synapse_core/src/synapse/core/camera_factory.py +++ b/synapse_core/src/synapse/core/camera_factory.py @@ -15,7 +15,7 @@ import cv2 import numpy as np from cscore import (CameraServer, CvSink, UsbCamera, VideoCamera, VideoMode, - VideoSource) + VideoProperty, VideoSource) from ntcore import NetworkTable, NetworkTableEntry, NetworkTableInstance from synapse_net.nt_client import NtClient from synapse_net.proto.v1 import CalibrationDataProto @@ -168,8 +168,7 @@ def generateNoSignalFrame(self, size: Resolution = (640, 480)) -> Frame: colors = [ (255, 255, 255), # white (0, 255, 255), # yellow - (255, 255, 0), # cyan - (0, 255, 0), # green + (255, 255, 0), # cyan (0, 255, 0), # green (255, 0, 255), # magenta (0, 0, 255), # red (255, 0, 0), # blue @@ -258,6 +257,9 @@ def setProperty(self, prop: str, value: Union[int, float]) -> None: ... @abstractmethod def getProperty(self, prop: str) -> Union[int, float, None]: ... + def getProperties(self) -> List[VideoProperty]: + return [] + @abstractmethod def setVideoMode(self, fps: int, width: int, height: int) -> None: ... @@ -376,8 +378,8 @@ def __init__(self, name: str) -> None: self.camera: VideoCamera self.sink: CvSink self.propertyMeta: PropertyMetaDict = {} - self._properties: Dict[str, Any] = {} - self._videoModes: List[Any] = [] + self._properties: Dict[str, VideoProperty] = {} + self._videoModes: List[VideoMode] = [] self._validVideoModes: List[VideoMode] = [] # --- FIX: Memory Recycling Implementation --- @@ -396,6 +398,9 @@ def __init__(self, name: str) -> None: self._thread: Optional[threading.Thread] = None self._lock = threading.Lock() + def getProperties(self) -> List: + return list(self._properties.values()) + @classmethod def create( cls, @@ -412,7 +417,6 @@ def create( inst.camera = UsbCamera(f"USB Camera {index}", path) inst.sink = CameraServer.getVideo(inst.camera) - inst.sink.getProperty("auto_exposure").set(0) # Cache properties and metadata props = inst.camera.enumerateProperties() @@ -428,6 +432,7 @@ def create( # Cache video modes and valid resolutions inst._videoModes = inst.camera.enumerateVideoModes() + inst.camera.setExposureManual(1) inst._validVideoModes = [mode for mode in inst._videoModes] # This will call setVideoMode, which now initializes the buffer pool. @@ -536,6 +541,8 @@ def close(self) -> None: ) def setProperty(self, prop: str, value: Union[int, float, str]) -> None: + if prop == "orientation": + return if prop == "resolution" and isinstance(value, str): resolution = value.split("x") width = int(resolution[0]) diff --git a/synapse_core/src/synapse/core/camera_handler.py b/synapse_core/src/synapse/core/camera_handler.py index 9c0908ac..a146058e 100644 --- a/synapse_core/src/synapse/core/camera_handler.py +++ b/synapse_core/src/synapse/core/camera_handler.py @@ -153,7 +153,6 @@ def scanCameras(self) -> None: found.append(info.productId) newIndex = 0 - print(self.requestedCameraUIDs) if len(self.requestedCameraUIDs.keys()) > 0: if id in self.requestedCameraUIDs.keys(): newIndex = self.requestedCameraUIDs.pop(id) @@ -365,7 +364,7 @@ def addCameraData( self.streamOutputs[cameraIndex] = self.createStreamOutput(cameraIndex) - ret, frame = camera.grabFrame() + frame = camera.generateNoSignalFrame() if frame is not None: self.streamOutputs[cameraIndex].putFrame( @@ -381,9 +380,6 @@ def addCameraData( .replace("mjpg:", "") ) - print(cameraConfig.name) - print(stream) - camera.stream = stream self.setRecordingStatus(cameraIndex, False) diff --git a/synapse_core/src/synapse/core/pipeline.py b/synapse_core/src/synapse/core/pipeline.py index aa4049e8..43980d28 100644 --- a/synapse_core/src/synapse/core/pipeline.py +++ b/synapse_core/src/synapse/core/pipeline.py @@ -208,7 +208,7 @@ def setSetting(self, setting: Union[Setting, str], value: SettingsValue) -> None self.settings.setSetting(settingObj, value) self.onSettingChanged(settingObj, self.getSetting(setting)) elif setting in CameraSettings(): - collection = self.getCurrentCameraSettingCollection() + collection = self.getCameraSettings() assert collection is not None collection.setSetting(setting, value) else: @@ -309,11 +309,11 @@ def getCameraSetting(self, setting: Union[str, Setting]) -> Optional[Any]: def setCameraSetting( self, setting: Union[str, Setting], value: SettingsValue ) -> None: - collection = self.getCurrentCameraSettingCollection() + collection = self.getCameraSettings() assert collection is not None collection.setSetting(setting, value) - def getCurrentCameraSettingCollection(self) -> Optional[CameraSettings]: + def getCameraSettings(self) -> CameraSettings: return self.cameraSettings def onSettingChanged(self, setting: Setting, value: SettingsValue) -> None: @@ -332,7 +332,7 @@ def pipelineToProto(inst: Pipeline, index: int, cameraId: CameraID) -> PipelineP for key in api.getSettingsSchema().keys() } - cameraSettings = inst.getCurrentCameraSettingCollection() + cameraSettings = inst.getCameraSettings() if cameraSettings: cameraAPI = cameraSettings.getAPI() settingsValues.update( diff --git a/synapse_core/src/synapse/core/runtime_handler.py b/synapse_core/src/synapse/core/runtime_handler.py index d8edf554..e5382051 100644 --- a/synapse_core/src/synapse/core/runtime_handler.py +++ b/synapse_core/src/synapse/core/runtime_handler.py @@ -298,7 +298,7 @@ def __setupPipelineForCamera( currPipeline.bind(cameraIndex, camera) - cameraSettings = currPipeline.getCurrentCameraSettingCollection() + cameraSettings = currPipeline.getCameraSettings() assert cameraSettings is not None @@ -348,24 +348,26 @@ def updateSetting(self, prop: str, cameraIndex: CameraID, value: Any) -> None: ) assert pipeline is not None - settings = self.pipelineHandler.getPipelineSettings( - self.pipelineBindings[cameraIndex], cameraIndex - ) - setting = settings.getAPI().getSetting(prop) camera = self.cameraHandler.getCamera(cameraIndex) + camSettings = pipeline.getCameraSettings().getAPI().settings.keys() - if prop in CameraSettings().getAPI().settings.keys(): + if prop in camSettings: assert camera is not None camera.setProperty(prop=prop, value=value) pipeline.setCameraSetting(prop, value) - elif setting is not None: - settings.setSetting(prop, value) - pipeline.onSettingChanged(setting, settings.getSetting(prop)) else: - log.warn( - f"Attempted to set setting {prop} on pipeline #{pipeline.pipelineIndex} but it was not found!" + settings = self.pipelineHandler.getPipelineSettings( + self.pipelineBindings[cameraIndex], cameraIndex ) - return + setting = settings.getAPI().getSetting(prop) + if setting is not None: + pipeline.setSetting(prop, value) + pipeline.onSettingChanged(setting, settings.getSetting(prop)) + else: + log.warn( + f"Attempted to set setting {prop} on pipeline #{pipeline.pipelineIndex} but it was not found!" + ) + return self.onSettingChanged.call(prop, value, cameraIndex) @@ -726,20 +728,18 @@ def rotateCameraBySettings(self, settings: CameraSettings, frame: Frame) -> Fram def fixBlackLevelOffset(self, settings: PipelineSettings, frame: Frame) -> Frame: blackLevelOffset = settings.getSetting("black_level_offset") + if blackLevelOffset is None or blackLevelOffset == 0: + return frame - if blackLevelOffset == 0 or blackLevelOffset is None: - return frame # No adjustment needed - - blackLevelOffset = -blackLevelOffset / 100 + # Normalize to [0,1] and convert to float32 + image = frame.astype(np.float32) / 255.0 - # Convert to float32 for better precision - image = frame.astype(np.float32) / 255.0 # Normalize to range [0,1] + # Apply black level offset (scaled) + offset = blackLevelOffset / 100.0 + image = np.clip(image + offset, 0, 1) - # Apply black level offset: lift only the darkest values - image = np.power(image + blackLevelOffset, 1.0) # Apply a soft offset - - # Clip to valid range and convert back to uint8 - return np.clip(image * 255, 0, 255).astype(np.uint8) + # Convert back to uint8 + return (image * 255).astype(np.uint8) def fixtureFrame(self, cameraIndex: CameraID, frame: Frame) -> Frame: if ( @@ -752,9 +752,7 @@ def fixtureFrame(self, cameraIndex: CameraID, frame: Frame) -> Frame: ) if pipeline is None: return frame - settings: Optional[CameraSettings] = ( - pipeline.getCurrentCameraSettingCollection() - ) + settings: Optional[CameraSettings] = pipeline.getCameraSettings() if settings is not None: frame = self.rotateCameraBySettings(settings, frame) diff --git a/synapse_core/src/synapse/core/settings_api.py b/synapse_core/src/synapse/core/settings_api.py index f923c758..737c22e9 100644 --- a/synapse_core/src/synapse/core/settings_api.py +++ b/synapse_core/src/synapse/core/settings_api.py @@ -10,6 +10,7 @@ from typing import Any, Dict, Generic, List, Optional, TypeVar, Union, overload from betterproto import which_one_of +from cscore import VideoProperty from ntcore import NetworkTable, NetworkTableEntry from synapse_net.proto.settings.v1 import (BooleanConstraintProto, ColorConstraintProto, @@ -18,6 +19,7 @@ ConstraintProto, ConstraintTypeProto, EnumeratedConstraintProto, + EnumeratedOptionProto, ListConstraintProto, NumberConstraintProto, SettingMetaProto, SettingValueProto, @@ -134,10 +136,16 @@ def configToProto(self) -> ConstraintConfigProto: TEnumeratedType = TypeVar("TEnumeratedType") +@dataclass +class EnumeratedOption(Generic[TEnumeratedType]): + key: str + value: TEnumeratedType + + class EnumeratedConstraint(Constraint[TEnumeratedType], Generic[TEnumeratedType]): """Constraint for selecting from predefined options""" - def __init__(self, options: List[TEnumeratedType]): + def __init__(self, options: Union[List[EnumeratedOption], List[TEnumeratedType]]): """ Initialize a ListOptionsConstraint instance. @@ -147,16 +155,19 @@ def __init__(self, options: List[TEnumeratedType]): Defaults to False. """ + assert options + if options and not isinstance(options[0], EnumeratedOption): + options = [EnumeratedOption(str(o), o) for o in options] super().__init__(ConstraintTypeProto.ENUMERATED) - self.options = options + self.options: List[EnumeratedOption] = options # pyright: ignore def validate(self, value: SettingsValue) -> ValidationResult: - expectedType = type(self.options[0]) + expectedType = type(self.options[0].value) if not isinstance(value, expectedType): return ValidationResult( False, f"Expected type {expectedType}, got {type(value)}" ) - if value not in self.options: + if value not in map(lambda o: o.value, self.options): return ValidationResult( False, f"Value {value} not in allowed options: {self.options}" ) @@ -165,13 +176,20 @@ def validate(self, value: SettingsValue) -> ValidationResult: def toDict(self) -> Dict[str, Any]: return { "type": self.constraintType.value, - "options": self.options, + "options": {o.key: o.value for o in self.options}, } def configToProto(self) -> ConstraintConfigProto: return ConstraintConfigProto( enumerated=EnumeratedConstraintProto( - options=list(map(lambda op: settingValueToProto(op), self.options)), + options=list( + map( + lambda op: EnumeratedOptionProto( + key=op.key, value=settingValueToProto(op.value) + ), + self.options, + ) + ), ) ) @@ -676,11 +694,79 @@ def __init__(self, settings: Optional[SettingsMap] = None): """ self._settingsApi = SettingsAPI() self._fieldNames = [] - self._initializeSettings() + self._initializeSettings() if settings: self.generateSettingsFromMap(settings) + def generateSetting(self, field: str, value: Any) -> None: + constraint: Optional[Constraint] = None + if isinstance(value, bool): + constraint = BooleanConstraint() + elif isinstance(value, float | int): + constraint = NumberConstraint( + minValue=None, + maxValue=None, + step=None if isinstance(value, float) else 1, + ) + elif isinstance(value, str): + constraint = StringConstraint() + elif isinstance(value, list): + + def getListDepth(value) -> int: + if not isinstance(value, list): + return 0 + if not value: + return 1 + return 1 + max(getListDepth(item) for item in value) + + constraint = ListConstraint(depth=getListDepth(value)) + if constraint is not None: + self._settingsApi.addSetting( + Setting(key=field, constraint=constraint, defaultValue=value) + ) + else: + setting = self._settingsApi.settings[field] + validation = setting.validate(value) + if validation.errorMessage is None: + self._settingsApi.setValue(field, value) + else: + err( + f"Error validating {MarkupColors.bold(field)}" + + f"\n\t\t{validation.errorMessage}" + + f"\n\tSetting {field} as default: {setting.defaultValue}" + ) + + def generateSettingFromProp(self, prop: VideoProperty) -> None: + constraint: Optional[Constraint] = None + if prop.getKind().value == VideoProperty.Kind.kBoolean.value: + constraint = BooleanConstraint() + elif prop.getKind().value == VideoProperty.Kind.kInteger.value: + constraint = NumberConstraint( + minValue=prop.getMin(), + maxValue=prop.getMax(), + step=1, + ) + elif prop.getKind().value == VideoProperty.Kind.kString.value: + constraint = StringConstraint() + elif prop.getKind().value == VideoProperty.Kind.kEnum.value: + options = [] + for i in range(len(prop.getChoices())): + if prop.getChoices()[i]: + options.append(EnumeratedOption(prop.getChoices()[i], i)) + constraint = EnumeratedConstraint(options) + else: + return + if constraint is not None: + self.addSetting( + Setting( + key=prop.getName(), + constraint=constraint, + defaultValue=prop.getDefault(), + ), + prop.getName(), + ) + def generateSettingsFromMap(self, settingsMap: SettingsMap) -> None: """ Populate the settings from a given map, generating constraints dynamically if necessary. @@ -691,42 +777,7 @@ def generateSettingsFromMap(self, settingsMap: SettingsMap) -> None: prexistingKeys = self.getSchema().keys() for field, value in settingsMap.items(): if field not in prexistingKeys: - constraint: Optional[Constraint] = None - if isinstance(value, bool): - constraint = BooleanConstraint() - elif isinstance(value, float | int): - constraint = NumberConstraint( - minValue=None, - maxValue=None, - step=None if isinstance(value, float) else 1, - ) - elif isinstance(value, str): - constraint = StringConstraint() - elif isinstance(value, list): - - def getListDepth(value) -> int: - if not isinstance(value, list): - return 0 - if not value: - return 1 - return 1 + max(getListDepth(item) for item in value) - - constraint = ListConstraint(depth=getListDepth(value)) - if constraint is not None: - self._settingsApi.addSetting( - Setting(key=field, constraint=constraint, defaultValue=value) - ) - else: - setting = self._settingsApi.settings[field] - validation = setting.validate(value) - if validation.errorMessage is None: - self._settingsApi.setValue(field, value) - else: - err( - f"Error validating {MarkupColors.bold(field)}" - + f"\n\t\t{validation.errorMessage}" - + f"\n\tSetting {field} as default: {setting.defaultValue}" - ) + self.generateSetting(field, value) def sendSettings(self, nt_table: NetworkTable): """ @@ -768,8 +819,11 @@ def _initializeSettings(self): if isinstance(attrValue, Setting): if attrValue.key != attrName: attrValue.key = attrName - self._settingsApi.addSetting(attrValue) - self._fieldNames.append(attrName) + self.addSetting(attrValue, attrName) + + def addSetting(self, setting: Setting, name: str): + self._settingsApi.addSetting(setting) + self._fieldNames.append(name) @overload def getSetting(self, setting: str) -> Optional[Any]: ... @@ -949,12 +1003,6 @@ class CameraSettings(SettingsCollection): category=kCameraPropsCategory, description="Adjusts the brightness level of the image.", ) - exposure = settingField( - NumberConstraint(0, 100), - default=50, - category=kCameraPropsCategory, - description="Controls the exposure level.", - ) saturation = settingField( NumberConstraint(0, 100), default=50, @@ -1017,9 +1065,6 @@ def getPropNumberConstraint( self.brightness.constraint = getPropNumberConstraint( propMeta, CameraPropKeys.kBrightness.value ) - self.exposure.constraint = getPropNumberConstraint( - propMeta, CameraPropKeys.kBrightness.value - ) self.saturation.constraint = getPropNumberConstraint( propMeta, CameraPropKeys.kBrightness.value ) @@ -1029,6 +1074,18 @@ def getPropNumberConstraint( self.gain.constraint = getPropNumberConstraint( propMeta, CameraPropKeys.kBrightness.value ) + for prop in camera.getProperties(): + if ( + "raw" in prop.getName() + or "backlight" in prop.getName() + or "connect_verbose" in prop.getName() + or "privacy" in prop.getName() + or "gamma" in prop.getName() + or "exposure_dynamic_framerate" in prop.getName() + ): + continue + self.generateSettingFromProp(prop) + self.resolution.constraint = EnumeratedConstraint( options=list( set(map(lambda s: f"{s[0]}x{s[1]}", camera.getSupportedResolutions())) diff --git a/synapse_core/src/synapse/core/synapse.py b/synapse_core/src/synapse/core/synapse.py index 131b42af..34fd6b5b 100644 --- a/synapse_core/src/synapse/core/synapse.py +++ b/synapse_core/src/synapse/core/synapse.py @@ -73,6 +73,7 @@ def init( runtimeHandler: RuntimeManager, configPath: Path, ) -> bool: + self.__init_cmd_args() """ Initializes the Synapse pipeline by loading configuration settings and setting up NetworkTables and global settings. @@ -96,7 +97,8 @@ def init( else: os.system("clear") - UIHandle.startUI() + if not self.__isHeadless: + UIHandle.startUI() log( MarkupColors.bold( @@ -145,7 +147,6 @@ def init( raise Exception("Global settings setup failed") # Initialize NetworkTables - self.__init_cmd_args() log( f"Network Config:\n Team Number: {config.network.teamNumber}\n Name: {config.network.name}\n Is Server: {self.__isServer}\n Is Sim: {self.__isSim}" @@ -194,6 +195,9 @@ def __init_cmd_args(self) -> None: parser = argparse.ArgumentParser() parser.add_argument("--server", action="store_true", help="Run in server mode") parser.add_argument("--sim", action="store_true", help="Run in sim mode") + parser.add_argument( + "--headless", action="store_true", help="Run in headless mode" + ) args = parser.parse_args() if args.server: @@ -204,6 +208,10 @@ def __init_cmd_args(self) -> None: self.__isSim = True else: self.__isSim = False + if args.headless: + self.__isHeadless = True + else: + self.__isHeadless = False def run(self) -> None: """ @@ -543,7 +551,6 @@ def onMessage(self, ws, msg) -> None: if pipeline is not None: val = protoToSettingValue(setSettingMSG.value) - pipeline.setSetting(setSettingMSG.setting, val) self.runtimeHandler.updateSetting( setSettingMSG.setting, setSettingMSG.cameraid, val ) diff --git a/synapse_net/proto/proto/settings/v1/enumerated.proto b/synapse_net/proto/proto/settings/v1/enumerated.proto index 7d7a4ba9..c26af872 100644 --- a/synapse_net/proto/proto/settings/v1/enumerated.proto +++ b/synapse_net/proto/proto/settings/v1/enumerated.proto @@ -4,8 +4,13 @@ package proto.settings.v1; import "proto/settings/v1/value.proto"; +message EnumeratedOptionProto { + string key = 1; + SettingValueProto value = 2; +} + // Constraint that limits a setting to a predefined list of possible values message EnumeratedConstraintProto { // List of allowed option values for the setting - repeated SettingValueProto options = 1; + repeated EnumeratedOptionProto options = 1; } diff --git a/synapse_net/src/synapse_net/__init__.py b/synapse_net/src/synapse_net/__init__.py index 3a0e1c63..5e934d30 100644 --- a/synapse_net/src/synapse_net/__init__.py +++ b/synapse_net/src/synapse_net/__init__.py @@ -1,4 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Dan Peled # SPDX-FileCopyrightText: 2026 Dan Peled # # SPDX-License-Identifier: GPL-3.0-or-later diff --git a/synapse_net/src/synapse_net/proto/__init__.py b/synapse_net/src/synapse_net/proto/__init__.py index 3a0e1c63..5e934d30 100644 --- a/synapse_net/src/synapse_net/proto/__init__.py +++ b/synapse_net/src/synapse_net/proto/__init__.py @@ -1,4 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Dan Peled # SPDX-FileCopyrightText: 2026 Dan Peled # # SPDX-License-Identifier: GPL-3.0-or-later diff --git a/synapse_net/src/synapse_net/proto/settings/__init__.py b/synapse_net/src/synapse_net/proto/settings/__init__.py index 3a0e1c63..5e934d30 100644 --- a/synapse_net/src/synapse_net/proto/settings/__init__.py +++ b/synapse_net/src/synapse_net/proto/settings/__init__.py @@ -1,4 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Dan Peled # SPDX-FileCopyrightText: 2026 Dan Peled # # SPDX-License-Identifier: GPL-3.0-or-later diff --git a/synapse_net/src/synapse_net/proto/settings/v1/__init__.py b/synapse_net/src/synapse_net/proto/settings/v1/__init__.py index 0378c2fe..226838d8 100644 --- a/synapse_net/src/synapse_net/proto/settings/v1/__init__.py +++ b/synapse_net/src/synapse_net/proto/settings/v1/__init__.py @@ -1,4 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Dan Peled # SPDX-FileCopyrightText: 2026 Dan Peled # # SPDX-License-Identifier: GPL-3.0-or-later @@ -113,13 +112,19 @@ class SettingValueProto(betterproto.Message): """Repeated bytes values (array)""" +@dataclass(eq=False, repr=False) +class EnumeratedOptionProto(betterproto.Message): + key: str = betterproto.string_field(1) + value: "SettingValueProto" = betterproto.message_field(2) + + @dataclass(eq=False, repr=False) class EnumeratedConstraintProto(betterproto.Message): """ Constraint that limits a setting to a predefined list of possible values """ - options: List["SettingValueProto"] = betterproto.message_field(1) + options: List["EnumeratedOptionProto"] = betterproto.message_field(1) """List of allowed option values for the setting""" diff --git a/synapse_net/src/synapse_net/proto/v1/__init__.py b/synapse_net/src/synapse_net/proto/v1/__init__.py index a62005d8..0234d00d 100644 --- a/synapse_net/src/synapse_net/proto/v1/__init__.py +++ b/synapse_net/src/synapse_net/proto/v1/__init__.py @@ -1,4 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Dan Peled # SPDX-FileCopyrightText: 2026 Dan Peled # # SPDX-License-Identifier: GPL-3.0-or-later diff --git a/synapse_ui/package.json b/synapse_ui/package.json index e34b40d6..276231ae 100644 --- a/synapse_ui/package.json +++ b/synapse_ui/package.json @@ -5,7 +5,7 @@ "private": true, "license": "MIT", "scripts": { - "dev": "next dev --turbopack -H 0.0.0.0", + "dev": "next dev --turbopack -H 0.0.0.0 -p 8000", "build": "next build && python move_out_to_subdir.py", "start": "next start", "lint": "next lint", diff --git a/synapse_ui/src/app/camera/camera_config_module.tsx b/synapse_ui/src/app/camera/camera_config_module.tsx index c3ea83b3..e478fc74 100644 --- a/synapse_ui/src/app/camera/camera_config_module.tsx +++ b/synapse_ui/src/app/camera/camera_config_module.tsx @@ -1,3 +1,4 @@ +import { Fragment, JSX, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, @@ -8,14 +9,9 @@ import { import { CameraProto, RenameCameraMessageProto } from "@/proto/v1/camera"; import { MessageProto, MessageTypeProto } from "@/proto/v1/message"; import { CameraID } from "@/services/backend/dataStractures"; -import { - baseCardColor, - borderColor, - hoverBg, - teamColor, -} from "@/services/style"; +import { baseCardColor, borderColor, teamColor } from "@/services/style"; import { WebSocketWrapper } from "@/services/websocket"; -import { Column, Row } from "@/widgets/containers"; +import { Row } from "@/widgets/containers"; import { Dropdown, DropdownOption } from "@/widgets/dropdown"; import TextInput from "@/widgets/textInput"; import { @@ -24,9 +20,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@radix-ui/react-dropdown-menu"; -import assert from "assert"; -import { Ban, Check, Edit, MoreVertical, RotateCcw, Trash } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + Ban, + Check, + Edit, + MoreVertical, + Trash, + Camera, + Hash, + Sliders, + PlayCircle, + Activity, +} from "lucide-react"; +import { useBackendContext } from "@/services/backend/backendContext"; export function CameraConfigModule({ cameras, @@ -39,129 +45,227 @@ export function CameraConfigModule({ setSelectedCamera: (cam?: CameraProto) => void; socket?: WebSocketWrapper; }) { - const [renameCamera, setRenameCamera] = useState(false); - const [newCameraName, setNewCameraName] = useState(selectedCamera?.name); + const [isRenaming, setIsRenaming] = useState(false); + const [newName, setNewName] = useState(selectedCamera?.name ?? ""); + const { pipelines } = useBackendContext(); useEffect(() => { - if (selectedCamera) { - setNewCameraName(selectedCamera?.name); - } + setNewName(selectedCamera?.name ?? ""); }, [selectedCamera]); + /* ---------- derived data ---------- */ + + const cameraOptions: DropdownOption[] = Array.from( + cameras.values(), + ).map((cam) => ({ + label: cam.name, + value: cam, + })); + + const infoFields: [string, string][] = [ + ["Camera Index", `#${selectedCamera?.index ?? "—"}`], + ["Device UID", selectedCamera?.kind ?? "—"], + ["Stream Path", selectedCamera?.streamPath ?? "—"], + [ + "Default Pipeline", + selectedCamera + ? `#${selectedCamera.defaultPipeline} (${ + (selectedCamera && + pipelines + .get(selectedCamera.index) + ?.get(selectedCamera.defaultPipeline)?.name) ?? + "Unknown" + })` + : "N/A", + ], + ["Max FPS", selectedCamera?.maxFps.toString() ?? "—"], + ]; + + /* ---------- actions ---------- */ + + const handleRenameSubmit = () => { + if (!selectedCamera) return; + + const payload = MessageProto.create({ + type: MessageTypeProto.MESSAGE_TYPE_PROTO_RENAME_CAMERA, + renameCamera: RenameCameraMessageProto.create({ + cameraIndex: selectedCamera.index, + newName, + }), + }); + + socket?.sendBinary(MessageProto.encode(payload).finish()); + setIsRenaming(false); + }; + + const handleRenameCancel = () => { + setIsRenaming(false); + setNewName(selectedCamera?.name ?? ""); + }; + + /* ---------- shared styles ---------- */ + + const baseButtonClass = "bg-zinc-900 hover:bg-zinc-800 border rounded-md"; + const buttonStyle = { + borderColor, + color: teamColor, + }; + + /* ---------- render ---------- */ + return ( - -
Camera Configuration
- + +
Camera Configuration
+ + Manage camera settings and properties +
- - - {renameCamera ? ( - - - - - - ) : ( - <> - ({ - label: `${cam?.name}`, - value: cam, - })) - : []) as DropdownOption[] - } - label="Camera" - value={selectedCamera} - onValueChange={(camera) => { - if (camera !== undefined) { - setSelectedCamera(camera); - } - }} - /> - { - setRenameCamera(true); - }} - /> - - )} - -
- Camera Index: - #{selectedCamera?.index ?? "—"} - - Camera Device Name: - {selectedCamera?.kind ?? "—"} - - Stream Path: - - - {selectedCamera?.streamPath ?? "—"} - - - Default Pipeline: - {selectedCamera?.defaultPipeline ?? "—"} + + {isRenaming ? ( + + ) : ( + setIsRenaming(true)} + /> + )} - Max FPS: - {selectedCamera?.maxFps ?? "—"} -
+
); } +function TopControls({ + options, + selectedCamera, + setSelectedCamera, + onRename, +}: { + options: DropdownOption[]; + selectedCamera?: CameraProto; + setSelectedCamera: (cam?: CameraProto) => void; + onRename: () => void; +}) { + return ( + + cam && setSelectedCamera(cam)} + /> + + + + ); +} + +/* ---------- Rename ---------- */ + +function RenameSection({ + newName, + setNewName, + onCancel, + onSubmit, + buttonClass, + buttonStyle, +}: { + newName: string; + setNewName: (v: string) => void; + onCancel: () => void; + onSubmit: () => void; + buttonClass: string; + buttonStyle: React.CSSProperties; +}) { + return ( + + + + + + + + ); +} + +/* ---------- Info Grid ---------- */ + +const fieldIcons: Record = { + "Camera Index": , + "Device UID": , + "Stream Path": , + "Default Pipeline": , + "Max FPS": , +}; + +function InfoGrid({ fields }: { fields: [string, string][] }) { + return ( +
+ {fields.map(([label, value]) => ( + + + {fieldIcons[label]} + {label} + + + {label === "Stream Path" && value !== "—" ? ( + + {value} + + ) : ( + value + )} + + + ))} +
+ ); +} + +/* ---------- Actions ---------- */ + function CameraActions({ selectedCamera, setRenamingCamera, @@ -169,70 +273,51 @@ function CameraActions({ selectedCamera?: CameraProto; setRenamingCamera: () => void; }) { + const actions = [ + { + icon: , + label: "Rename", + onClick: setRenamingCamera, + disabled: !selectedCamera, + }, + { + icon: , + label: "Remove Camera", + onClick: () => {}, + disabled: true, + }, + ]; + return ( + - {[ - { - icon: , - label: "Rename", - action: setRenamingCamera, - disabled: () => selectedCamera === undefined, - }, - { - icon: , - label: "Remove Camera", - action: () => {}, - disabled: () => true, - }, - { - icon: ( - - ), - label: "Rescan Cameras", - action: () => {}, - disabled: () => selectedCamera === undefined, - }, - ].map(({ icon, label, action, disabled }) => ( + {actions.map(({ icon, label, onClick, disabled }) => ( { - if (!disabled()) e.currentTarget.style.backgroundColor = hoverBg; - }} - onMouseLeave={(e) => { - if (!disabled()) - e.currentTarget.style.backgroundColor = "transparent"; - }} + disabled={disabled} + onClick={onClick} + className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm + ${ + disabled + ? "opacity-40 cursor-not-allowed" + : "hover:bg-zinc-700 cursor-pointer" + } + `} + style={{ color: teamColor }} > {icon} {label} diff --git a/synapse_ui/src/proto/settings/v1/enumerated.ts b/synapse_ui/src/proto/settings/v1/enumerated.ts index f3147877..49102e90 100644 --- a/synapse_ui/src/proto/settings/v1/enumerated.ts +++ b/synapse_ui/src/proto/settings/v1/enumerated.ts @@ -10,12 +10,109 @@ import { SettingValueProto } from "./value"; export const protobufPackage = "proto.settings.v1"; +export interface EnumeratedOptionProto { + key: string; + value: SettingValueProto | undefined; +} + /** Constraint that limits a setting to a predefined list of possible values */ export interface EnumeratedConstraintProto { /** List of allowed option values for the setting */ - options: SettingValueProto[]; + options: EnumeratedOptionProto[]; } +function createBaseEnumeratedOptionProto(): EnumeratedOptionProto { + return { key: "", value: undefined }; +} + +export const EnumeratedOptionProto: MessageFns = { + encode( + message: EnumeratedOptionProto, + writer: BinaryWriter = new BinaryWriter(), + ): BinaryWriter { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== undefined) { + SettingValueProto.encode(message.value, writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode( + input: BinaryReader | Uint8Array, + length?: number, + ): EnumeratedOptionProto { + const reader = + input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEnumeratedOptionProto(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = SettingValueProto.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EnumeratedOptionProto { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object.value) + ? SettingValueProto.fromJSON(object.value) + : undefined, + }; + }, + + toJSON(message: EnumeratedOptionProto): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== undefined) { + obj.value = SettingValueProto.toJSON(message.value); + } + return obj; + }, + + create, I>>( + base?: I, + ): EnumeratedOptionProto { + return EnumeratedOptionProto.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I, + ): EnumeratedOptionProto { + const message = createBaseEnumeratedOptionProto(); + message.key = object.key ?? ""; + message.value = + object.value !== undefined && object.value !== null + ? SettingValueProto.fromPartial(object.value) + : undefined; + return message; + }, +}; + function createBaseEnumeratedConstraintProto(): EnumeratedConstraintProto { return { options: [] }; } @@ -27,7 +124,7 @@ export const EnumeratedConstraintProto: MessageFns = writer: BinaryWriter = new BinaryWriter(), ): BinaryWriter { for (const v of message.options) { - SettingValueProto.encode(v!, writer.uint32(10).fork()).join(); + EnumeratedOptionProto.encode(v!, writer.uint32(10).fork()).join(); } return writer; }, @@ -49,7 +146,7 @@ export const EnumeratedConstraintProto: MessageFns = } message.options.push( - SettingValueProto.decode(reader, reader.uint32()), + EnumeratedOptionProto.decode(reader, reader.uint32()), ); continue; } @@ -65,7 +162,7 @@ export const EnumeratedConstraintProto: MessageFns = fromJSON(object: any): EnumeratedConstraintProto { return { options: globalThis.Array.isArray(object?.options) - ? object.options.map((e: any) => SettingValueProto.fromJSON(e)) + ? object.options.map((e: any) => EnumeratedOptionProto.fromJSON(e)) : [], }; }, @@ -73,7 +170,9 @@ export const EnumeratedConstraintProto: MessageFns = toJSON(message: EnumeratedConstraintProto): unknown { const obj: any = {}; if (message.options?.length) { - obj.options = message.options.map((e) => SettingValueProto.toJSON(e)); + obj.options = message.options.map((e) => + EnumeratedOptionProto.toJSON(e), + ); } return obj; }, @@ -88,7 +187,7 @@ export const EnumeratedConstraintProto: MessageFns = ): EnumeratedConstraintProto { const message = createBaseEnumeratedConstraintProto(); message.options = - object.options?.map((e) => SettingValueProto.fromPartial(e)) || []; + object.options?.map((e) => EnumeratedOptionProto.fromPartial(e)) || []; return message; }, }; @@ -119,6 +218,10 @@ export type Exact = P extends Builtin [K in Exclude>]: never; }; +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + export interface MessageFns { encode(message: T, writer?: BinaryWriter): BinaryWriter; decode(input: BinaryReader | Uint8Array, length?: number): T; diff --git a/synapse_ui/src/services/controls_generator.tsx b/synapse_ui/src/services/controls_generator.tsx index e8f24080..0a06309b 100644 --- a/synapse_ui/src/services/controls_generator.tsx +++ b/synapse_ui/src/services/controls_generator.tsx @@ -272,10 +272,12 @@ export function GenerateControl({ ); } case ConstraintTypeProto.CONSTRAINT_TYPE_PROTO_ENUMERATED: { + console.log(setting.constraint.constraint?.enumerated?.options); const options = setting.constraint.constraint?.enumerated?.options.map((op) => { - const val = protoToSettingValue(op); - return { label: String(val), value: val }; + // Extract the actual value from the proto + const val = op.value?.intValue ?? op.value?.stringValue ?? 0; + return { label: op.key, value: val }; }) ?? []; // Patterns for "1920x1080" and single numbers like "60" @@ -310,7 +312,7 @@ export function GenerateControl({ []} + options={sortedOptions as DropdownOption[]} value={val} onValueChange={(val) => setValue(settingValueToProto(val))} disabled={locked} diff --git a/synapse_ui/src/widgets/dropdown.tsx b/synapse_ui/src/widgets/dropdown.tsx index c205a31e..68de38b6 100644 --- a/synapse_ui/src/widgets/dropdown.tsx +++ b/synapse_ui/src/widgets/dropdown.tsx @@ -22,6 +22,7 @@ interface DropdownProps { options: DropdownOption[]; disabled?: boolean; textSize?: string; + className?: string; } export function Dropdown({ @@ -31,6 +32,7 @@ export function Dropdown({ options, disabled = false, textSize = "text-base", + className = "", }: DropdownProps) { const stringToValue = new Map(); const valueToString = new Map(); @@ -45,7 +47,10 @@ export function Dropdown({ return (
({ > {label} -
+