From a8cbb02185005057ace20c0a293d293ca4ca997b Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Thu, 8 Jan 2026 18:55:16 +0700 Subject: [PATCH 001/120] Bump onvif-python version 0.1.9 -> 0.2.9 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 51b8a5ed0..3936908f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,5 +35,5 @@ setproctitle==1.3.3 sqlalchemy==2.0.30 watchdog==4.0.0 python-telegram-bot==21.4 -onvif-python==0.1.9 +onvif-python==0.2.9 ultralytics==8.3.146; platform_machine == "x86_64" or platform_machine == "aarch64" From 6dbd8bccf253ec383e2b50a9bde81b4f1c20ce30 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Thu, 8 Jan 2026 18:57:53 +0700 Subject: [PATCH 002/120] refactor(ptz): Remove obsolete PTZ component files docs --- .../components/ptz/_meta.tsx | 13 -- .../components/ptz/config.json | 112 ------------------ .../components/ptz/index.mdx | 46 ------- 3 files changed, 171 deletions(-) delete mode 100644 docs/src/pages/components-explorer/components/ptz/_meta.tsx delete mode 100644 docs/src/pages/components-explorer/components/ptz/config.json delete mode 100644 docs/src/pages/components-explorer/components/ptz/index.mdx diff --git a/docs/src/pages/components-explorer/components/ptz/_meta.tsx b/docs/src/pages/components-explorer/components/ptz/_meta.tsx deleted file mode 100644 index c88141afc..000000000 --- a/docs/src/pages/components-explorer/components/ptz/_meta.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Component } from "@site/src/types"; - -const ComponentMetadata: Component = { - title: "ONVIF PTZ control", - name: "ptz", - description: - "Controls ONVIF PTZ cameras, currently only supported via Telegram integration.", - image: "/img/logos/onvif.png", - tags: ["protocol"], - category: null, -}; - -export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/ptz/config.json b/docs/src/pages/components-explorer/components/ptz/config.json deleted file mode 100644 index d36325f19..000000000 --- a/docs/src/pages/components-explorer/components/ptz/config.json +++ /dev/null @@ -1,112 +0,0 @@ -[ - { - "type": "map", - "value": [ - { - "type": "map", - "value": [ - { - "type": "map", - "value": [ - { - "type": "string", - "name": "onvif_username", - "description": "ONVIF username for the camera.", - "required": true, - "default": null - }, - { - "type": "string", - "name": "onvif_password", - "description": "ONVIF password for the camera.", - "required": true, - "default": null - }, - { - "type": "integer", - "name": "onvif_port", - "description": "ONVIF port of the camera.", - "optional": true, - "default": 80 - }, - { - "type": "float", - "name": "camera_min_pan", - "description": "Minimum pan value of the camera.", - "optional": true, - "default": null - }, - { - "type": "float", - "name": "camera_max_pan", - "description": "Maximum pan value of the camera.", - "optional": true, - "default": null - }, - { - "type": "list", - "values": [ - [ - { - "type": "string", - "name": "name", - "description": "Name of the PTZ preset.", - "required": true, - "default": null - }, - { - "type": "float", - "name": "pan", - "description": "Pan value of the PTZ preset. Usually a value between -1.0 and 1.0.", - "required": true, - "default": null - }, - { - "type": "float", - "name": "tilt", - "description": "Tilt value of the PTZ preset. Usually a value between -1.0 and 1.0.", - "required": true, - "default": null - }, - { - "type": "float", - "name": "zoom", - "description": "Zoom value of the PTZ preset. Usually a value between -1.0 and 1.0?", - "optional": true, - "default": null - }, - { - "type": "boolean", - "name": "on_startup", - "description": "Move to this (named) preset on startup.", - "optional": true, - "default": false - } - ] - ], - "name": "presets", - "description": "List of PTZ presets.", - "optional": true, - "default": null - } - ], - "name": { - "type": "CAMERA_IDENTIFIER" - }, - "description": "Camera identifier. Valid characters are lowercase a-z, numbers and underscores.", - "cameraidentifier": true, - "default": null - } - ], - "name": "cameras", - "description": "List of ONVIF cameras to make available to the component.", - "required": true, - "default": null - } - ], - "name": "ptz", - "description": "Telegram bot to control pan-tilt-zoom cameras.", - "required": true, - "default": null - } -] \ No newline at end of file diff --git a/docs/src/pages/components-explorer/components/ptz/index.mdx b/docs/src/pages/components-explorer/components/ptz/index.mdx deleted file mode 100644 index d6b1a9248..000000000 --- a/docs/src/pages/components-explorer/components/ptz/index.mdx +++ /dev/null @@ -1,46 +0,0 @@ -import ComponentConfiguration from "@site/src/pages/components-explorer/_components/ComponentConfiguration"; -import ComponentHeader from "@site/src/pages/components-explorer/_components/ComponentHeader"; -import ComponentTroubleshooting from "@site/src/pages/components-explorer/_components/ComponentTroubleshooting"; - -import ComponentMetadata from "./_meta"; -import config from "./config.json"; - - - -The ptz component can be used to control PTZ (Pan-Tilt-Zoom) cameras. -It supports the ONVIF protocol, which is widely supported by most modern cameras. - -## Configuration - -
- Configuration example - -```yaml title="/config/config.yaml" -ptz: - cameras: - camera_1: - onvif_port: 80 # The port the camera listens to for ONVIF connections - onvif_username: # username associated with ONVIF - onvif_password: # password associated with ONVIF - camera_min_x: -0.73 # used in "patrol" mode to limit swings to useful fov - camera_max_x: 0.04 # used in "patrol" mode to limit swings to useful fov - presets: # allows switching between pre-defined (absolute) positions - - name: front # name them - x: 0.0 - y: 0.0 - on_startup: true # have the camera move to this preset when Viseron starts - - name: left - x: -0.5 - y: 0.0 - - name: right - x: 0.5 - y: 0.0 -``` - -
- - - -## Troubleshooting - - From 5983a77923c61859f658af90b94bfeeb59577242 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Thu, 8 Jan 2026 18:58:22 +0700 Subject: [PATCH 003/120] refactor(ptz): Remove obsolete PTZ component files and constants --- viseron/components/ptz/__init__.py | 673 ----------------------------- viseron/components/ptz/const.py | 31 -- 2 files changed, 704 deletions(-) delete mode 100644 viseron/components/ptz/__init__.py delete mode 100644 viseron/components/ptz/const.py diff --git a/viseron/components/ptz/__init__.py b/viseron/components/ptz/__init__.py deleted file mode 100644 index ace8ec7b9..000000000 --- a/viseron/components/ptz/__init__.py +++ /dev/null @@ -1,673 +0,0 @@ -"""PTZ interface.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import TYPE_CHECKING, Any - -import numpy as np -import voluptuous as vol -from onvif import ONVIFClient, ONVIFOperationException - -from viseron.const import EVENT_DOMAIN_REGISTERED, VISERON_SIGNAL_STOPPING -from viseron.domains.camera import AbstractCamera -from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN -from viseron.helpers import escape_string -from viseron.helpers.logs import SensitiveInformationFilter -from viseron.helpers.validators import CameraIdentifier -from viseron.watchdog.thread_watchdog import RestartableThread - -from .const import ( - COMPONENT, - CONFIG_CAMERA_FULL_SWING_MAX_PAN, - CONFIG_CAMERA_FULL_SWING_MIN_PAN, - CONFIG_CAMERA_PASSWORD, - CONFIG_CAMERA_PORT, - CONFIG_CAMERA_USERNAME, - CONFIG_CAMERAS, - CONFIG_HOST, - CONFIG_PRESET_NAME, - CONFIG_PRESET_ON_STARTUP, - CONFIG_PRESET_PAN, - CONFIG_PRESET_TILT, - CONFIG_PRESET_ZOOM, - CONFIG_PTZ_PRESETS, - DESC_CAMERA_FULL_SWING_MAX_PAN, - DESC_CAMERA_FULL_SWING_MIN_PAN, - DESC_CAMERA_PASSWORD, - DESC_CAMERA_PORT, - DESC_CAMERA_USERNAME, - DESC_CAMERAS, - DESC_COMPONENT, - DESC_PRESET_NAME, - DESC_PRESET_ON_STARTUP, - DESC_PRESET_PAN, - DESC_PRESET_TILT, - DESC_PRESET_ZOOM, - DESC_PTZ_PRESETS, -) - -if TYPE_CHECKING: - from viseron import Event, Viseron - -LOGGER = logging.getLogger(__name__) - -PRESET = vol.Schema( - { - vol.Required(CONFIG_PRESET_NAME, description=DESC_PRESET_NAME): str, - vol.Required(CONFIG_PRESET_PAN, description=DESC_PRESET_PAN): float, - vol.Required(CONFIG_PRESET_TILT, description=DESC_PRESET_TILT): float, - vol.Optional(CONFIG_PRESET_ZOOM, description=DESC_PRESET_ZOOM): float, - vol.Optional( - CONFIG_PRESET_ON_STARTUP, description=DESC_PRESET_ON_STARTUP, default=False - ): bool, - } -) - -CAMERA_SCHEMA = vol.Schema( - { - vol.Optional(CONFIG_CAMERA_PORT, description=DESC_CAMERA_PORT, default=80): int, - vol.Required(CONFIG_CAMERA_USERNAME, description=DESC_CAMERA_USERNAME): str, - vol.Required(CONFIG_CAMERA_PASSWORD, description=DESC_CAMERA_PASSWORD): str, - vol.Optional( - CONFIG_CAMERA_FULL_SWING_MIN_PAN, - description=DESC_CAMERA_FULL_SWING_MIN_PAN, - ): float, - vol.Optional( - CONFIG_CAMERA_FULL_SWING_MAX_PAN, - description=DESC_CAMERA_FULL_SWING_MAX_PAN, - ): float, - vol.Optional(CONFIG_PTZ_PRESETS, description=DESC_PTZ_PRESETS): [PRESET], - } -) - -COMPONENT_SCHEMA = vol.Schema( - { - vol.Required(CONFIG_CAMERAS, description=DESC_CAMERAS): { - CameraIdentifier(): CAMERA_SCHEMA - }, - } -) - -CONFIG_SCHEMA = vol.Schema( - {vol.Required(COMPONENT, description=DESC_COMPONENT): COMPONENT_SCHEMA}, - extra=vol.ALLOW_EXTRA, -) - - -def setup(vis: Viseron, config) -> bool: - """Set up the ptz component.""" - ptz = PTZ(vis, config[COMPONENT]) - RestartableThread( - target=ptz.run, - name="ptz", - ).start() - return True - - -class PTZ: - """PTZ class allows control of pan/tilt/zoom (and other stuff) over Telegram.""" - - def __init__(self, vis: Viseron, config) -> None: - self._vis = vis - self._config = config - for cam_name in self._config[CONFIG_CAMERAS]: - camera = self._config[CONFIG_CAMERAS][cam_name] - if camera[CONFIG_CAMERA_PASSWORD]: - SensitiveInformationFilter.add_sensitive_string( - camera[CONFIG_CAMERA_PASSWORD] - ) - SensitiveInformationFilter.add_sensitive_string( - escape_string(camera[CONFIG_CAMERA_PASSWORD]) - ) - self._cameras: dict[str, AbstractCamera] = {} - self._onvif_cameras: dict[str, ONVIFClient] = {} - self._ptz_services: dict[str, Any] = {} - self._ptz_tokens: dict[str, str] = {} - self._stop_patrol_events: dict[str, asyncio.Event] = {} - self._register_lock: asyncio.Lock = asyncio.Lock() - self._stop_event: asyncio.Event = asyncio.Event() - vis.data[COMPONENT] = self - - def initialize(self): - """Initialize PTZ Controller.""" - self._vis.register_signal_handler(VISERON_SIGNAL_STOPPING, self.shutdown) - self._vis.listen_event( - EVENT_DOMAIN_REGISTERED.format(domain=CAMERA_DOMAIN), - self._camera_registered, - ) - - def run(self): - """Run PTZ Controller.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(self._run()) - LOGGER.info("PTZ Controller done") - - async def _run(self): - """Run PTZ Controller.""" - self.initialize() - while not self._stop_event.is_set(): - await asyncio.sleep(0.1) - - def shutdown(self): - """Shutdown PTZ Controller.""" - for event in self._stop_patrol_events.values(): - event.set() - self._stop_event.set() - - def _camera_registered(self, event: Event[AbstractCamera]) -> None: - camera: AbstractCamera = event.data - if camera.identifier in self._config[CONFIG_CAMERAS]: - self._cameras.update({camera.identifier: camera}) - config = self._config[CONFIG_CAMERAS][camera.identifier] - - onvif_camera = ONVIFClient( - camera.config[CONFIG_HOST], - config[CONFIG_CAMERA_PORT], - config[CONFIG_CAMERA_USERNAME], - config[CONFIG_CAMERA_PASSWORD], - ) - self._onvif_cameras.update({camera.identifier: onvif_camera}) - self._ptz_services.update({camera.identifier: onvif_camera.ptz()}) - media_service = onvif_camera.media() - self._ptz_tokens.update( - {camera.identifier: media_service.GetProfiles()[0].token} - ) - self._stop_patrol_events.update({camera.identifier: asyncio.Event()}) - if CONFIG_PTZ_PRESETS in config: - for preset in config[CONFIG_PTZ_PRESETS]: - if preset[CONFIG_PRESET_ON_STARTUP]: - self.move_to_preset( - camera.identifier, preset[CONFIG_PRESET_NAME] - ) - - async def patrol( - self, - camera_identifier: str, - duration: int = 60, - sleep_after_swing: int = 6, - step_size: float = 0.1, - step_sleep_time: float = 0.1, - ) -> None: - """Perform a patrol of the camera.""" - stop_event = self._stop_patrol_events.get(camera_identifier) - if stop_event is not None: - stop_event.clear() - await self._fire_and_forget( - self._do_patrol, - duration, - camera_identifier=camera_identifier, - sleep_after_swing=sleep_after_swing, - step_size=step_size, - step_sleep_time=step_sleep_time, - ) - - async def _fire_and_forget(self, coro, timeout, *args, **kwargs): - """Fire and forget a coroutine with a timeout.""" - coro_task = asyncio.create_task(coro(*args, **kwargs)) - # If a timeout is given, create a task to cancel the coroutine after the timeout - if timeout > 0: - asyncio.create_task(self._timeout_task(coro_task, timeout)) - - async def _timeout_task(self, task, timeout): - """Cancel a task after a set amount of time if given.""" - await asyncio.sleep(timeout) - if not task.done(): - task.cancel() - - async def _do_patrol( - self, - camera_identifier: str, - step_size: float = 0.1, - step_sleep_time: float = 0.1, - sleep_after_swing=6, - ): - """ - Perform a patrol of the camera. - - Swings the camera from left to right and back, etc. within the camera's limits, - either by design or configuration (see minx_x, max_x). - - @param step_size: The size of each move step - @param step_sleep_time: Time to sleep between each move step - @param sleep_after_swing: Time to pause after each swing - """ - try: - - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return - - # Get and store starting position - status = ptz_service.GetStatus( - ProfileToken=self._ptz_tokens.get(camera_identifier) - ) - if status is None: - LOGGER.warning("Cannot determine starting position") - initial_pan = 0.0 - initial_tilt = 0.0 - else: - initial_pan = status.Position.PanTilt.x - initial_tilt = status.Position.PanTilt.y - LOGGER.debug( - f"Camera position at start: x: {initial_pan}, y: {initial_tilt}" - ) - - # Get the camera's FOV limits, if any. - cam = self._cameras.get(camera_identifier) - if cam is None: - LOGGER.error(f"No camera found for {camera_identifier}") - return - - min_pan = cam.config.get(CONFIG_CAMERA_FULL_SWING_MIN_PAN) - max_pan = cam.config.get(CONFIG_CAMERA_FULL_SWING_MAX_PAN) - - # Decide which direction to start swinging based on the distance to the - # camera's FOV limits, left if closer to min_pan, right if closer to max_pan - distance_to_min = initial_pan - min_pan if min_pan else 0 - distance_to_max = max_pan - initial_pan if max_pan else 0 - left = distance_to_min > distance_to_max - - # Swing back and forth until stopped - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - stop_patrol_event = asyncio.Event() - self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) - - while not stop_patrol_event.is_set(): - await self.full_swing( - camera_identifier=camera_identifier, - is_left=left, - step_size=step_size, - step_sleep_time=step_sleep_time, - min_pan=min_pan, - max_pan=max_pan, - ) - if stop_patrol_event.is_set(): - break - await asyncio.sleep(sleep_after_swing) - left = not left - - finally: - # Move back to the initial position - self.absolute_move( - camera_identifier=camera_identifier, pan=initial_pan, tilt=initial_tilt - ) - - def stop_patrol(self, camera_identifier: str) -> None: - """Stop the patrol.""" - event = self._stop_patrol_events.get(camera_identifier) - if event: - event.set() - - async def lissajous_curve_patrol( - self, - camera_identifier: str, - pan_amp: float = 1.0, - pan_freq: float = 0.1, - tilt_amp: float = 1.0, - tilt_freq: float = 0.1, - phase_shift: float = np.pi / 2, - step_sleep_time: float = 0.1, - ): - """Perform a Lissajous curve patrol.""" - - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - LOGGER.error(f"No patrol stop event for camera {camera_identifier}") - return False - - # stop currently running patrol - if not stop_patrol_event.is_set(): - stop_patrol_event.set() - await asyncio.sleep(2.0) - stop_patrol_event.clear() - - # start a new patrol - await self._fire_and_forget( - coro=self._do_lissa_curve_patrol, - timeout=0, - camera_identifier=camera_identifier, - pan_amp=pan_amp, - pan_freq=pan_freq, - tilt_amp=tilt_amp, - tilt_freq=tilt_freq, - phase_shift=phase_shift, - step_sleep_time=step_sleep_time, - ) - - async def _do_lissa_curve_patrol( - self, - camera_identifier: str, - pan_amp: float = 1.0, - pan_freq: float = 0.1, - tilt_amp: float = 1.0, - tilt_freq: float = 0.1, - phase_shift: float = np.pi / 2, - step_sleep_time: float = 0.1, - pan_range: tuple = (-1.0, 1.0), - tilt_range: tuple = (-1.0, 1.0), - ): - """Perform a Lissajous curve patrol.""" - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - stop_patrol_event = asyncio.Event() - self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) - - pan_min, pan_max = pan_range - tilt_min, tilt_max = tilt_range - - t = 0.0 - while not stop_patrol_event.is_set(): - t += 1.0 - x = pan_amp * np.sin(pan_freq * t + phase_shift) - y = tilt_amp * np.sin(tilt_freq * t) - - # Scale x and y to the specified pan and tilt ranges - x = pan_min + (x + 1) * (pan_max - pan_min) / 2 - y = tilt_min + (y + 1) * (tilt_max - tilt_min) / 2 - - await self.absolute_move_wait_complete( - camera_identifier=camera_identifier, pan=x, tilt=y - ) - await asyncio.sleep(step_sleep_time) - - async def full_swing( - self, - camera_identifier: str, - is_left: bool = True, - step_size: float = 0.1, - step_sleep_time: float = 0.1, - min_pan: float | None = None, - max_pan: float | None = None, - ): - """Perform a full swing in the pan direction. - - @param is_left: True if the swing is to the left, False if to the right - @param step_size: The size of each move step - @param sleep_time: Time to sleep between each move step - @param min_pan: Minimum pan value to stop at, meant to be used to avoid - going beyond the camera's limits or field of view - @param max_pan: Maximum pan value to stop at - - """ - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return - - cur_pan, _ = self.get_position(camera_identifier) - # Get and store starting position - LOGGER.debug(f"Fullswing start: pan: {cur_pan}, min: {min_pan}, max: {max_pan}") - - move_step = -abs(step_size) if is_left else abs(step_size) - - # Do not move beyond the camera's FOV bounds - if is_left: - if min_pan is not None and cur_pan + move_step <= min_pan: - return - else: - if max_pan is not None and cur_pan + move_step >= max_pan: - return - - # Move while not stopped or stopped by the camera's FOV or hardware bounds - # Unsure how this will react to 360 (or more?) degree cameras - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - stop_patrol_event = asyncio.Event() - self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) - - while ( - self.relative_move( - camera_identifier=camera_identifier, pan=move_step, tilt=0.0 - ) - and not stop_patrol_event.is_set() - ): - await asyncio.sleep(step_sleep_time) - cur_pan, _ = self.get_position(camera_identifier) - LOGGER.debug( - f"Fullswing moved to: pan: {cur_pan}, min: {min_pan}, max: {max_pan}" - ) - if min_pan is not None and cur_pan <= min_pan: - break - if max_pan is not None and cur_pan >= max_pan: - break - - LOGGER.debug(f"Fullswing end: pan: {cur_pan}, min: {min_pan}, max: {max_pan}") - - def relative_move(self, camera_identifier: str, pan: float, tilt: float) -> bool: - """ - Move the camera relative to its current position. - - @param x: The relative x position to move to - @param y: The relative y position to move to - @return: True if the move was successful, False otherwise - """ - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - - try: - ptz_service.RelativeMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Translation={ - "PanTilt": {"x": pan, "y": tilt}, - "Zoom": {"x": 0.0}, - }, - ) - return True - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in RelativeMove (usually harmless): {e}") - return False - - def zoom(self, camera_identifier: str, zoom: float = 0.1) -> bool: - """Zoom the camera in our out.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - - try: - ptz_service.RelativeMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Translation={ - "PanTilt": {"x": 0.0, "y": 0.0}, - "Zoom": {"x": zoom}, - }, - ) - return True - except ONVIFOperationException as e: - # errors occur when the zoom exceeds the camera's limits?, silence them - # can't check, camera does not support zoom - LOGGER.warning(f"ONVIF error in Zoom (usually harmless): {e}") - return False - - def absolute_move(self, camera_identifier: str, pan: float, tilt: float) -> bool: - """Move the camera to an absolute position.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - try: - ptz_service.AbsoluteMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Position={ - "PanTilt": {"x": pan, "y": tilt}, - }, - ) - return True - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in AbsoluteMove (usually harmless): {e}") - return False - - async def absolute_move_wait_complete( - self, camera_identifier: str, pan: float, tilt: float, timeout: float = 30.0 - ) -> bool: - """Move the camera to an absolute position and wait for the move to complete.""" - if self.absolute_move(camera_identifier=camera_identifier, pan=pan, tilt=tilt): - # get the camera position and wait until it reaches the desired position to - # a tolerance of 0.005, or until the timeout is reached - tolerance = 0.005 - start_time = asyncio.get_event_loop().time() - while ( - abs(self.get_position(camera_identifier)[0] - pan) > tolerance - or abs(self.get_position(camera_identifier)[1] - tilt) > tolerance - ) and (asyncio.get_event_loop().time() - start_time < timeout): - await asyncio.sleep(0.1) - LOGGER.info( - "Position at end of abs move and wait (requested: %s): %s", - (pan, tilt), - self.get_position(camera_identifier), - ) - return True - return False - - async def continuous_move( - self, - camera_identifier: str, - x_velocity: float, - y_velocity: float, - seconds: float, - ): - """Move the camera continuously for a set amount of time.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - try: - ptz_service.ContinuousMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Velocity={ - "PanTilt": {"x": x_velocity, "y": y_velocity}, - "Zoom": {"x": 0.0}, - }, - ) - await asyncio.sleep(seconds) - ptz_service.Stop({"ProfileToken": self._ptz_tokens.get(camera_identifier)}) - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in ContinuousMove (usually harmless): {e}") - - def pan_left(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Pan the camera to the left.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=-step_size, tilt=0.0 - ) - - def pan_right(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Pan the camera to the right.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=step_size, tilt=0.0 - ) - - def tilt_up(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Tilt the camera up.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=0.0, tilt=step_size - ) - - def tilt_down(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Tilt the camera down.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=0.0, tilt=-step_size - ) - - def zoom_out(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Zoom the camera out.""" - return self.zoom(camera_identifier=camera_identifier, zoom=-step_size) - - def zoom_in(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Zoom the camera in.""" - return self.zoom(camera_identifier=camera_identifier, zoom=step_size) - - def get_position(self, camera_identifier: str) -> tuple[float, float]: - """Get the current position of the camera.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return 0.0, 0.0 - try: - status = ptz_service.GetStatus( - ProfileToken=self._ptz_tokens.get(camera_identifier) - ) - return status.Position.PanTilt.x, status.Position.PanTilt.y - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in GetStatus (usually harmless): {e}") - return -255.0, -255.0 - - def get_presets(self, camera_identifier: str) -> list[str]: - """Get the available presets for the camera.""" - if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return [] - presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] - return list({preset[CONFIG_PRESET_NAME] for preset in presets}) - - def move_to_preset(self, camera_identifier: str, preset_name: str) -> bool: - """Move the camera to a preset position.""" - if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return False - - if not any( - preset[CONFIG_PRESET_NAME] == preset_name - for preset in self._config[CONFIG_CAMERAS][camera_identifier][ - CONFIG_PTZ_PRESETS - ] - ): - LOGGER.error( - f"Preset {preset_name} not found for camera {camera_identifier}" - ) - return False - - presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] - for preset in presets: - if preset[CONFIG_PRESET_NAME] == preset_name: - self.absolute_move( - camera_identifier=camera_identifier, - pan=preset[CONFIG_PRESET_PAN], - tilt=preset[CONFIG_PRESET_TILT], - ) - if CONFIG_PRESET_ZOOM in preset: - self.zoom( - camera_identifier=camera_identifier, - zoom=preset[CONFIG_PRESET_ZOOM], - ) - return True - - async def move_to_preset_wait_complete( - self, camera_identifier: str, preset_name: str - ) -> bool: - """Move the camera to a preset position.""" - if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return False - - presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] - - if not presets: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return False - - if not any(preset[CONFIG_PRESET_NAME] == preset_name for preset in presets): - LOGGER.error( - f"Preset {preset_name} not found for camera {camera_identifier}" - ) - return False - - for preset in presets: - if preset[CONFIG_PRESET_NAME] == preset_name: - await self.absolute_move_wait_complete( - camera_identifier=camera_identifier, - pan=preset[CONFIG_PRESET_PAN], - tilt=preset[CONFIG_PRESET_TILT], - ) - if CONFIG_PRESET_ZOOM in preset: - self.zoom( - camera_identifier=camera_identifier, - zoom=preset[CONFIG_PRESET_ZOOM], - ) - return True diff --git a/viseron/components/ptz/const.py b/viseron/components/ptz/const.py deleted file mode 100644 index 49200eba5..000000000 --- a/viseron/components/ptz/const.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Telegram-PTZ component constants.""" - -COMPONENT = "ptz" -DESC_COMPONENT = "Telegram bot to control pan-tilt-zoom cameras." - -CONFIG_CAMERAS = "cameras" -CONFIG_HOST = "host" -CONFIG_CAMERA_PORT = "onvif_port" -CONFIG_CAMERA_USERNAME = "onvif_username" -CONFIG_CAMERA_PASSWORD = "onvif_password" -CONFIG_CAMERA_FULL_SWING_MIN_PAN = "camera_min_pan" -CONFIG_CAMERA_FULL_SWING_MAX_PAN = "camera_max_pan" -CONFIG_PTZ_PRESETS = "presets" -CONFIG_PRESET_NAME = "name" -CONFIG_PRESET_PAN = "pan" -CONFIG_PRESET_TILT = "tilt" -CONFIG_PRESET_ZOOM = "zoom" -CONFIG_PRESET_ON_STARTUP = "on_startup" - -DESC_CAMERAS = "List of ONVIF cameras to make available to the component." -DESC_CAMERA_PORT = "ONVIF port of the camera." -DESC_CAMERA_USERNAME = "ONVIF username for the camera." -DESC_CAMERA_PASSWORD = "ONVIF password for the camera." -DESC_CAMERA_FULL_SWING_MIN_PAN = "Minimum pan value of the camera." -DESC_CAMERA_FULL_SWING_MAX_PAN = "Maximum pan value of the camera." -DESC_PTZ_PRESETS = "List of PTZ presets." -DESC_PRESET_NAME = "Name of the PTZ preset." -DESC_PRESET_PAN = "Pan value of the PTZ preset. Usually a value between -1.0 and 1.0." -DESC_PRESET_TILT = "Tilt value of the PTZ preset. Usually a value between -1.0 and 1.0." -DESC_PRESET_ZOOM = "Zoom value of the PTZ preset. Usually a value between -1.0 and 1.0?" -DESC_PRESET_ON_STARTUP = "Move to this (named) preset on startup." From e8ee6db53eb778f8cce5dba176b1c7dd0d6bdc34 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Thu, 8 Jan 2026 19:25:24 +0700 Subject: [PATCH 004/120] feat(docs): Add source code and github issue links, oldest and newest commit dates to the component header --- docs/package.json | 1 + docs/src/generated/commitDates.json | 91 +++++++++++++++ docs/src/lib/generateCommitDates.mjs | 105 ++++++++++++++++++ docs/src/lib/getCommitsDates.ts | 10 ++ .../_components/ComponentHeader/index.tsx | 67 ++++++++++- .../ComponentHeader/styles.module.css | 4 +- .../background_subtractor/_meta.tsx | 2 + .../components/codeprojectai/_meta.tsx | 2 + .../components/compreface/_meta.tsx | 2 + .../components/darknet/_meta.tsx | 2 + .../components/deepstack/_meta.tsx | 2 + .../components/discord/_meta.tsx | 4 +- .../components/dlib/_meta.tsx | 2 + .../components/edgetpu/_meta.tsx | 2 + .../components/ffmpeg/_meta.tsx | 2 + .../components/go2rtc/_meta.tsx | 2 + .../components/gotify/_meta.tsx | 2 + .../components/gstreamer/_meta.tsx | 2 + .../components/hailo/_meta.tsx | 6 +- .../components/logger/_meta.tsx | 2 + .../components/mog2/_meta.tsx | 2 + .../components/mqtt/_meta.tsx | 2 + .../components/nvr/_meta.tsx | 2 + .../components/storage/_meta.tsx | 2 + .../components/telegram/_meta.tsx | 7 +- .../components/webhook/_meta.tsx | 4 +- .../components/webserver/_meta.tsx | 2 + .../components/yolo/_meta.tsx | 2 + docs/src/types.ts | 2 + docs/static/img/logos/hailo.svg | 21 ++++ docs/static/img/logos/hailo.webp | Bin 640 -> 0 bytes 31 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 docs/src/generated/commitDates.json create mode 100644 docs/src/lib/generateCommitDates.mjs create mode 100644 docs/src/lib/getCommitsDates.ts create mode 100644 docs/static/img/logos/hailo.svg delete mode 100644 docs/static/img/logos/hailo.webp diff --git a/docs/package.json b/docs/package.json index e53404c7c..ac414b982 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "docusaurus": "docusaurus", + "prebuild": "node src/lib/generateCommitDates.mjs", "start": "docusaurus start --host=0.0.0.0", "build": "docusaurus build", "swizzle": "docusaurus swizzle", diff --git a/docs/src/generated/commitDates.json b/docs/src/generated/commitDates.json new file mode 100644 index 000000000..a5911f9d5 --- /dev/null +++ b/docs/src/generated/commitDates.json @@ -0,0 +1,91 @@ +{ + "viseron/components/background_subtractor": { + "created": "2021-11-23T11:41:33Z", + "updated": "2025-03-27T10:01:40Z" + }, + "viseron/components/codeprojectai": { + "created": "2023-04-27T12:09:31Z", + "updated": "2025-03-27T10:01:40Z" + }, + "viseron/components/compreface": { + "created": "2022-11-01T16:12:07Z", + "updated": "2025-04-01T07:46:32Z" + }, + "viseron/components/darknet": { + "created": "2021-12-06T15:10:41Z", + "updated": "2025-03-27T10:01:40Z" + }, + "viseron/components/deepstack": { + "created": "2021-12-07T06:25:47Z", + "updated": "2025-06-30T11:50:37Z" + }, + "viseron/components/discord": { + "created": "2025-03-17T23:00:48Z", + "updated": "2025-10-28T17:19:31Z" + }, + "viseron/components/dlib": { + "created": "2021-12-27T14:26:06Z", + "updated": "2025-03-21T10:05:31Z" + }, + "viseron/components/edgetpu": { + "created": "2021-11-01T10:25:23Z", + "updated": "2025-05-16T05:55:24Z" + }, + "viseron/components/ffmpeg": { + "created": "2021-10-19T20:22:12Z", + "updated": "2025-08-22T07:36:47Z" + }, + "viseron/components/go2rtc": { + "created": "2025-04-29T09:09:42Z", + "updated": "2025-08-11T10:10:18Z" + }, + "viseron/components/gotify": { + "created": "2025-03-18T01:55:20Z", + "updated": "2025-10-18T23:23:23Z" + }, + "viseron/components/gstreamer": { + "created": "2022-03-02T17:36:39Z", + "updated": "2025-10-28T17:19:31Z" + }, + "viseron/components/hailo": { + "created": "2025-09-10T05:21:41Z", + "updated": "2025-12-16T21:09:55Z" + }, + "viseron/components/logger": { + "created": "2021-10-11T12:53:55Z", + "updated": "2023-04-06T13:32:41Z" + }, + "viseron/components/mog2": { + "created": "2021-11-23T11:40:40Z", + "updated": "2025-03-27T10:01:40Z" + }, + "viseron/components/mqtt": { + "created": "2021-12-22T12:52:23Z", + "updated": "2025-07-06T11:47:51Z" + }, + "viseron/components/nvr": { + "created": "2021-10-25T12:29:15Z", + "updated": "2025-05-02T21:05:37Z" + }, + "viseron/components/onvif": null, + "viseron/components/storage": { + "created": "2023-06-12T15:05:18Z", + "updated": "2025-11-04T17:26:14Z" + }, + "viseron/components/telegram": { + "created": "2024-07-29T21:40:23Z", + "updated": "2025-11-18T19:57:12Z" + }, + "viseron/components/webhook": { + "created": "2025-08-15T12:18:50Z", + "updated": "2025-08-18T20:43:38Z" + }, + "viseron/components/webserver": { + "created": "2021-10-12T14:58:37Z", + "updated": "2025-11-18T07:46:49Z" + }, + "viseron/components/yolo": { + "created": "2025-06-22T17:09:46Z", + "updated": "2025-06-30T14:29:38Z" + } +} \ No newline at end of file diff --git a/docs/src/lib/generateCommitDates.mjs b/docs/src/lib/generateCommitDates.mjs new file mode 100644 index 000000000..1b83f46f8 --- /dev/null +++ b/docs/src/lib/generateCommitDates.mjs @@ -0,0 +1,105 @@ +import fs from "fs"; +import path from "path"; + +const ROOT = + "src/pages/components-explorer/components"; + +const OUTPUT = + "src/generated/commitDates.json"; + +const BASE_URL = + "https://api.github.com/repos/roflcoopter/viseron/commits"; + +const headers = { + /** + ...(process.env.GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}), + */ + Accept: "application/vnd.github+json", +}; + +/** + * Recursively find all _meta.tsx files + */ +function findMetaFiles(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const result = []; + + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + result.push(...findMetaFiles(full)); + } else if (e.name === "_meta.tsx") { + result.push(full); + } + } + return result; +} + +/** + * Extract `path: "..."` from _meta.tsx source + */ +function extractPathFromMeta(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + + const match = content.match( + /path\s*:\s*["'`]([^"'`]+)["'`]/ + ); + + return match?.[1] ?? null; +} + +async function fetchCommitDates(repoPath) { + const encoded = encodeURIComponent(repoPath); + + // latest commit + const latestRes = await fetch( + `${BASE_URL}?sha=master&path=${encoded}&per_page=1`, + { headers } + ); + const latest = await latestRes.json(); + if (!Array.isArray(latest) || latest.length === 0) { + return null; + } + + const updated = latest[0].commit.author.date; + + // oldest commit (paginate) + let page = 1; + let created = updated; + + while (true) { + const res = await fetch( + `${BASE_URL}?sha=master&path=${encoded}&per_page=100&page=${page}`, + { headers } + ); + const data = await res.json(); + if (!Array.isArray(data) || data.length === 0) break; + + created = + data[data.length - 1].commit.author.date; + + if (data.length < 100) break; + page++; + } + + return { created, updated }; +} + +async function main() { + const metaFiles = findMetaFiles(ROOT); + const result = {}; + + for (const file of metaFiles) { + const repoPath = extractPathFromMeta(file); + if (!repoPath) continue; + + result[repoPath] = await fetchCommitDates(repoPath); + } + + fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); + fs.writeFileSync(OUTPUT, JSON.stringify(result, null, 2)); +} + +main().catch(console.error); diff --git a/docs/src/lib/getCommitsDates.ts b/docs/src/lib/getCommitsDates.ts new file mode 100644 index 000000000..9c5933125 --- /dev/null +++ b/docs/src/lib/getCommitsDates.ts @@ -0,0 +1,10 @@ +import data from "@site/src/generated/commitDates.json"; + +type CommitDates = { + created?: string; + updated?: string; +}; + +export default function useCommitDates(path: string): CommitDates { + return data[path] ?? {}; +} diff --git a/docs/src/pages/components-explorer/_components/ComponentHeader/index.tsx b/docs/src/pages/components-explorer/_components/ComponentHeader/index.tsx index 9e51ddd90..0c7ae33dd 100644 --- a/docs/src/pages/components-explorer/_components/ComponentHeader/index.tsx +++ b/docs/src/pages/components-explorer/_components/ComponentHeader/index.tsx @@ -1,8 +1,10 @@ import React from "react"; - +import { Debug, LogoGithub } from "@carbon/icons-react"; import Head from "@docusaurus/Head"; +import Link from "@docusaurus/Link"; import Heading from "@theme/Heading"; +import useCommitDates from "@site/src/lib/getCommitsDates"; import { getIconComponent } from "@site/src/lib/iconMap"; import { Component, DomainType, Domains } from "@site/src/types"; @@ -37,6 +39,8 @@ function TagBadge({ tag }: { tag: DomainType }) { } function ComponentHeader({ meta }: { meta: Component }) { + const { created, updated } = useCommitDates(meta.path); + return (
@@ -68,7 +72,66 @@ function ComponentHeader({ meta }: { meta: Component }) {
{meta.title} -
+
+
+
+
+ Created: {created ? new Date(created).toLocaleDateString() : "—"} +
+
+ Updated: {updated ? new Date(updated).toLocaleDateString() : "—"} +
+
+
+ + +  View source on Github + + + +  View all issues + +
+
+
); } diff --git a/docs/src/pages/components-explorer/_components/ComponentHeader/styles.module.css b/docs/src/pages/components-explorer/_components/ComponentHeader/styles.module.css index 418317a71..0c2c534e9 100644 --- a/docs/src/pages/components-explorer/_components/ComponentHeader/styles.module.css +++ b/docs/src/pages/components-explorer/_components/ComponentHeader/styles.module.css @@ -6,8 +6,8 @@ } .header img { - max-height: 67px; - max-width: 100%; + max-height: 90px; + max-width: 200px; } @media only screen and (max-width: 600px) { diff --git a/docs/src/pages/components-explorer/components/background_subtractor/_meta.tsx b/docs/src/pages/components-explorer/components/background_subtractor/_meta.tsx index 293215f32..4bd9881fb 100644 --- a/docs/src/pages/components-explorer/components/background_subtractor/_meta.tsx +++ b/docs/src/pages/components-explorer/components/background_subtractor/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/opencv.svg", tags: ["motion_detector"], category: null, + path: "viseron/components/background_subtractor", + issue: "background%20subtractor%20OR%20motion", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/codeprojectai/_meta.tsx b/docs/src/pages/components-explorer/components/codeprojectai/_meta.tsx index f7556a885..c9709af86 100644 --- a/docs/src/pages/components-explorer/components/codeprojectai/_meta.tsx +++ b/docs/src/pages/components-explorer/components/codeprojectai/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/codeprojectai.png", tags: ["face_recognition", "license_plate_recognition", "object_detector"], category: null, + path: "viseron/components/codeprojectai", + issue: "codeprojectai", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/compreface/_meta.tsx b/docs/src/pages/components-explorer/components/compreface/_meta.tsx index 65a0230fc..5acb7ea59 100644 --- a/docs/src/pages/components-explorer/components/compreface/_meta.tsx +++ b/docs/src/pages/components-explorer/components/compreface/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/compreface.png", tags: ["face_recognition"], category: null, + path: "viseron/components/compreface", + issue: "compreface", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/darknet/_meta.tsx b/docs/src/pages/components-explorer/components/darknet/_meta.tsx index cfa8cc329..bc910abda 100644 --- a/docs/src/pages/components-explorer/components/darknet/_meta.tsx +++ b/docs/src/pages/components-explorer/components/darknet/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/darknet.png", tags: ["object_detector"], category: null, + path: "viseron/components/darknet", + issue: "darknet", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/deepstack/_meta.tsx b/docs/src/pages/components-explorer/components/deepstack/_meta.tsx index 807b3386b..dcb41e273 100644 --- a/docs/src/pages/components-explorer/components/deepstack/_meta.tsx +++ b/docs/src/pages/components-explorer/components/deepstack/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/deepstack.png", tags: ["object_detector", "face_recognition"], category: null, + path: "viseron/components/deepstack", + issue: "deepstack", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/discord/_meta.tsx b/docs/src/pages/components-explorer/components/discord/_meta.tsx index 4ddc29439..f6f82b281 100644 --- a/docs/src/pages/components-explorer/components/discord/_meta.tsx +++ b/docs/src/pages/components-explorer/components/discord/_meta.tsx @@ -1,13 +1,15 @@ import { Component } from "@site/src/types"; const ComponentMetadata: Component = { - title: "Discord Notifications", + title: "Discord", name: "discord", description: "Sends notifications to Discord channels using webhooks integration.", image: "/img/logos/discord.png", tags: ["notification"], category: null, + path: "viseron/components/discord", + issue: "discord", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/dlib/_meta.tsx b/docs/src/pages/components-explorer/components/dlib/_meta.tsx index 9b1025f70..adeb8bd0a 100644 --- a/docs/src/pages/components-explorer/components/dlib/_meta.tsx +++ b/docs/src/pages/components-explorer/components/dlib/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/dlib.png", tags: ["face_recognition"], category: null, + path: "viseron/components/dlib", + issue: "dlib", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/edgetpu/_meta.tsx b/docs/src/pages/components-explorer/components/edgetpu/_meta.tsx index 5b90c761f..dafbfa7f0 100644 --- a/docs/src/pages/components-explorer/components/edgetpu/_meta.tsx +++ b/docs/src/pages/components-explorer/components/edgetpu/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/coraltpu.png", tags: ["image_classification", "object_detector"], category: null, + path: "viseron/components/edgetpu", + issue: "edgetpu", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/ffmpeg/_meta.tsx b/docs/src/pages/components-explorer/components/ffmpeg/_meta.tsx index c5085b717..bd00cad9a 100644 --- a/docs/src/pages/components-explorer/components/ffmpeg/_meta.tsx +++ b/docs/src/pages/components-explorer/components/ffmpeg/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/ffmpeg.svg", tags: ["camera"], category: "choose_one", + path: "viseron/components/ffmpeg", + issue: "ffmpeg%20OR%20camera", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/go2rtc/_meta.tsx b/docs/src/pages/components-explorer/components/go2rtc/_meta.tsx index 7b7cd3b73..6ade1e111 100644 --- a/docs/src/pages/components-explorer/components/go2rtc/_meta.tsx +++ b/docs/src/pages/components-explorer/components/go2rtc/_meta.tsx @@ -7,6 +7,8 @@ const ComponentMetadata: Component = { image: "/img/logos/go2rtc.gif", tags: ["protocol"], category: "featured", + path: "viseron/components/go2rtc", + issue: "go2rtc", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/gotify/_meta.tsx b/docs/src/pages/components-explorer/components/gotify/_meta.tsx index 10f6aecb9..5f4271561 100644 --- a/docs/src/pages/components-explorer/components/gotify/_meta.tsx +++ b/docs/src/pages/components-explorer/components/gotify/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/gotify.png", tags: ["notification"], category: null, + path: "viseron/components/gotify", + issue: "gotify", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/gstreamer/_meta.tsx b/docs/src/pages/components-explorer/components/gstreamer/_meta.tsx index 09868f6ff..a640565af 100644 --- a/docs/src/pages/components-explorer/components/gstreamer/_meta.tsx +++ b/docs/src/pages/components-explorer/components/gstreamer/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/gstreamer.svg", tags: ["camera"], category: "choose_one", + path: "viseron/components/gstreamer", + issue: "gstreamer", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/hailo/_meta.tsx b/docs/src/pages/components-explorer/components/hailo/_meta.tsx index 2ed092dbd..3d0d38b15 100644 --- a/docs/src/pages/components-explorer/components/hailo/_meta.tsx +++ b/docs/src/pages/components-explorer/components/hailo/_meta.tsx @@ -5,9 +5,11 @@ const ComponentMetadata: Component = { name: "hailo", description: "Enables high-performance object detection on edge devices using Hailo AI processors.", - image: "/img/logos/hailo.webp", + image: "/img/logos/hailo.svg", tags: ["object_detector"], - category: "new", + category: null, + path: "viseron/components/hailo", + issue: "hailo", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/logger/_meta.tsx b/docs/src/pages/components-explorer/components/logger/_meta.tsx index 639ca0d1f..8718bdd7e 100644 --- a/docs/src/pages/components-explorer/components/logger/_meta.tsx +++ b/docs/src/pages/components-explorer/components/logger/_meta.tsx @@ -7,6 +7,8 @@ const ComponentMetadata: Component = { image: "/img/undraw_collecting.svg", tags: ["system"], category: null, + path: "viseron/components/logger", + issue: "logger", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/mog2/_meta.tsx b/docs/src/pages/components-explorer/components/mog2/_meta.tsx index 8cc360805..2eaaf2ea6 100644 --- a/docs/src/pages/components-explorer/components/mog2/_meta.tsx +++ b/docs/src/pages/components-explorer/components/mog2/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/opencv.svg", tags: ["motion_detector"], category: null, + path: "viseron/components/mog2", + issue: "mog2%20OR%20motion", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/mqtt/_meta.tsx b/docs/src/pages/components-explorer/components/mqtt/_meta.tsx index f070d3f89..99391cfe0 100644 --- a/docs/src/pages/components-explorer/components/mqtt/_meta.tsx +++ b/docs/src/pages/components-explorer/components/mqtt/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/mqtt.svg", tags: ["protocol"], category: null, + path: "viseron/components/mqtt", + issue: "mqtt", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/nvr/_meta.tsx b/docs/src/pages/components-explorer/components/nvr/_meta.tsx index 25ce07f81..6e641e8db 100644 --- a/docs/src/pages/components-explorer/components/nvr/_meta.tsx +++ b/docs/src/pages/components-explorer/components/nvr/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/undraw_surveillance.svg", tags: ["nvr"], category: "required", + path: "viseron/components/nvr", + issue: "nvr", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/storage/_meta.tsx b/docs/src/pages/components-explorer/components/storage/_meta.tsx index 7ac8ec81f..f4ceb12c9 100644 --- a/docs/src/pages/components-explorer/components/storage/_meta.tsx +++ b/docs/src/pages/components-explorer/components/storage/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/undraw_server.svg", tags: ["system"], category: "featured", + path: "viseron/components/storage", + issue: "storage", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/telegram/_meta.tsx b/docs/src/pages/components-explorer/components/telegram/_meta.tsx index a94313ef2..75158ead1 100644 --- a/docs/src/pages/components-explorer/components/telegram/_meta.tsx +++ b/docs/src/pages/components-explorer/components/telegram/_meta.tsx @@ -1,12 +1,15 @@ import { Component } from "@site/src/types"; const ComponentMetadata: Component = { - title: "Telegram PTZ & Notifications", + title: "Telegram", name: "telegram", - description: "Controls PTZ cameras and sends notifications through Telegram.", + description: + "Controls ONVIF PTZ cameras and sends notifications through Telegram.", image: "/img/logos/telegram.png", tags: ["notification"], category: null, + path: "viseron/components/telegram", + issue: "telegram", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/webhook/_meta.tsx b/docs/src/pages/components-explorer/components/webhook/_meta.tsx index 3386bb023..6dfac08d3 100644 --- a/docs/src/pages/components-explorer/components/webhook/_meta.tsx +++ b/docs/src/pages/components-explorer/components/webhook/_meta.tsx @@ -7,7 +7,9 @@ const ComponentMetadata: Component = { "Sends webhooks to external services when specific events occur.", image: "/img/logos/webhook.svg", tags: ["notification"], - category: "new", + category: null, + path: "viseron/components/webhook", + issue: "webhook", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/webserver/_meta.tsx b/docs/src/pages/components-explorer/components/webserver/_meta.tsx index 9e659b8de..2e339d7f8 100644 --- a/docs/src/pages/components-explorer/components/webserver/_meta.tsx +++ b/docs/src/pages/components-explorer/components/webserver/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/undraw_interface.svg", tags: ["system"], category: null, + path: "viseron/components/webserver", + issue: "webserver", }; export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/yolo/_meta.tsx b/docs/src/pages/components-explorer/components/yolo/_meta.tsx index 90110116a..dbe4c3d11 100644 --- a/docs/src/pages/components-explorer/components/yolo/_meta.tsx +++ b/docs/src/pages/components-explorer/components/yolo/_meta.tsx @@ -8,6 +8,8 @@ const ComponentMetadata: Component = { image: "/img/logos/ultralytics_yolo.svg", tags: ["object_detector"], category: null, + path: "viseron/components/yolo", + issue: "ultralytics%20OR%20yolo", }; export default ComponentMetadata; diff --git a/docs/src/types.ts b/docs/src/types.ts index dba866789..4d9fac4a4 100644 --- a/docs/src/types.ts +++ b/docs/src/types.ts @@ -24,6 +24,8 @@ export type Component = { image: string; tags: DomainType[]; category: string | null; + path: string; + issue: string; }; export const Domains: { [type in DomainType]: Domain } = { diff --git a/docs/static/img/logos/hailo.svg b/docs/static/img/logos/hailo.svg new file mode 100644 index 000000000..18277b791 --- /dev/null +++ b/docs/static/img/logos/hailo.svg @@ -0,0 +1,21 @@ + + + Logo / Main@2x + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/static/img/logos/hailo.webp b/docs/static/img/logos/hailo.webp deleted file mode 100644 index 86e39ce460e0b805f96d1ad000c7a7ec0d3c4288..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 640 zcmV-`0)PEdNk&F^0ssJ4MM6+kP&iC%0ssInr$ILmUmy@j+8<`!K{60Y#y+OWqmdCbf{F?SB+W|ED(wP_^pn8|;2Rz-+#EjZB{) ze~7_VNhA_C-0$}Xk_QNC9wPcb0W|x6_W$hv+5i6sU5G~(E*ALHj$vjl?P$iZ;bMV5 zUF;se7M8!BkH)58R70>^FC4xejZ7kNJOFPCrNk0Z)>t=C0D!G>G{#`5_pN&ez zjZyDjT%C_f+7$F|>FRz|OjFsrm8+j)QgE{h`?hwq7?T8Q;J9xa;r805@H!?L91QG- z;h1a+ixJ5+#0k`sp<-rMVF4UXg%oNc4Z&0}5D;ZhPK1InB1%Ao(;xvwL^*Ig4KgT+ zh&1dcL9QWy6l%ZuYw^=g#TY>a9C?lXPAMo7H3U<@z(F!lPJn_jmcBuS{+9@$Q~}5S zmqAT(u3_(exrRdnWuUyTVvL&-;K=tMVW7_`Yih5m9x z-7hR8LewS!j{PnJMQw9o?{&F`vK1LnURN;&?Y@B{pG$?PT?H!eIRiDd&owi1?QsR; zp#vFE9+wDlmv7+M-;5A;sf2^K Date: Thu, 8 Jan 2026 19:36:06 +0700 Subject: [PATCH 005/120] feat(scripts): Adapt the docs generation script to the latest meta format --- scripts/gen_docs/const.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/gen_docs/const.py b/scripts/gen_docs/const.py index 31cfd0db9..d2013e0df 100644 --- a/scripts/gen_docs/const.py +++ b/scripts/gen_docs/const.py @@ -12,6 +12,9 @@ description: "", image: "", tags: {tags}, + category: "new", + path: "viseron/components/{component}", + issue: "{component}", }}; export default ComponentMetadata; From 4669f19148c3771438bd4f1e9b440e1bf66670b8 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Thu, 8 Jan 2026 19:46:46 +0700 Subject: [PATCH 006/120] feat(docs): Add ONVIF docs and change Telegram docs --- .../components/onvif/_meta.tsx | 15 + .../components/onvif/config.json | 456 ++++++++++++++++++ .../components/onvif/index.mdx | 172 +++++++ .../components/telegram/index.mdx | 38 +- 4 files changed, 677 insertions(+), 4 deletions(-) create mode 100644 docs/src/pages/components-explorer/components/onvif/_meta.tsx create mode 100644 docs/src/pages/components-explorer/components/onvif/config.json create mode 100644 docs/src/pages/components-explorer/components/onvif/index.mdx diff --git a/docs/src/pages/components-explorer/components/onvif/_meta.tsx b/docs/src/pages/components-explorer/components/onvif/_meta.tsx new file mode 100644 index 000000000..28c9b9ee1 --- /dev/null +++ b/docs/src/pages/components-explorer/components/onvif/_meta.tsx @@ -0,0 +1,15 @@ +import { Component } from "@site/src/types"; + +const ComponentMetadata: Component = { + title: "ONVIF", + name: "onvif", + description: + "Control and configure ONVIF compatible cameras directly from Viseron.", + image: "/img/logos/onvif.png", + tags: ["protocol"], + category: "new", + path: "viseron/components/onvif", + issue: "onvif%20OR%20ptz", +}; + +export default ComponentMetadata; diff --git a/docs/src/pages/components-explorer/components/onvif/config.json b/docs/src/pages/components-explorer/components/onvif/config.json new file mode 100644 index 000000000..f21241bf7 --- /dev/null +++ b/docs/src/pages/components-explorer/components/onvif/config.json @@ -0,0 +1,456 @@ +[ + { + "type": "map", + "value": [ + { + "type": "map", + "value": [ + { + "type": "map", + "value": [ + { + "type": "integer", + "name": "port", + "description": "ONVIF port of the camera.", + "required": true, + "default": null + }, + { + "type": "string", + "name": "username", + "description": "ONVIF username for the camera.", + "required": true, + "default": null + }, + { + "type": "string", + "name": "password", + "description": "ONVIF password for the camera.", + "required": true, + "default": null + }, + { + "type": "integer", + "name": "timeout", + "description": "Timeout for ONVIF connections in seconds.", + "optional": true, + "default": 10 + }, + { + "type": "boolean", + "name": "use_https", + "description": "Use HTTPS for ONVIF connections.", + "optional": true, + "default": false + }, + { + "type": "boolean", + "name": "verify_ssl", + "description": "Verify SSL certificates for ONVIF connections.", + "optional": true, + "default": true + }, + { + "type": "string", + "name": "wsdl_dir", + "description": "Path to custom WSDL directory for ONVIF client.", + "optional": true, + "default": null + }, + { + "type": "boolean", + "name": "auto_config", + "description": "Set to true then it will ignore all configuration per each service and use the default service that is already on the ONVIF camera. Don't worry! This ONVIF component will automatically detect the existing configuration in the ONVIF camera precisely.", + "optional": true, + "default": true + }, + { + "type": "map", + "value": [ + { + "type": "string", + "name": "hostname", + "description": "The hostname of the device.", + "optional": true, + "default": null + }, + { + "type": "boolean", + "name": "discoverable", + "description": "Whether the device is discoverable on the network via WS-Discovery.", + "optional": true, + "default": null + }, + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "Manual" + }, + { + "type": "constant", + "value": "NTP" + } + ], + "name": "datetime_type", + "description": "Defines if the date and time is set via NTP or manually.", + "optional": true, + "default": null + }, + { + "type": "boolean", + "name": "daylight_savings", + "description": "Indicates whether Daylight Savings Time is in effect.", + "optional": true, + "default": null + }, + { + "type": "string", + "name": "timezone", + "description": "The time zone in POSIX 1003.1 format. Will be ignored if the datetime_type key is set to NTP.", + "optional": true, + "default": null + }, + { + "type": "boolean", + "name": "ntp_from_dhcp", + "description": "Indicate if NTP address information is to be retrieved using DHCP.", + "optional": true, + "default": null + }, + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "IPv4" + }, + { + "type": "constant", + "value": "DNS" + }, + { + "type": "constant", + "value": "IPv6" + } + ], + "name": "ntp_type", + "description": "Network host type: IPv4, IPv6 or DNS. Will be ignored if the ntp_from_dhcp key is set to true. ", + "optional": true, + "default": null + }, + { + "type": "string", + "name": "ntp_server", + "description": "The NTP server of the device, for example: pool.ntp.org or time.google.com or 192.168.1.1 (must match with ntp_type). Will be ignored if the ntp_from_dhcp key is set to true. ", + "optional": true, + "default": null + } + ], + "name": "device", + "description": "Device service configuration.", + "optional": true, + "default": null + }, + { + "type": "map", + "value": [], + "name": "media", + "description": "Media service configuration.", + "optional": true, + "default": null + }, + { + "type": "map", + "value": [ + { + "type": "boolean", + "name": "force_persistence", + "description": "To determine whether this setting will persist even after a device reboot.", + "optional": true, + "default": true + }, + { + "type": "float", + "name": "brightness", + "description": "Brightness of the image (unit unspecified).", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "color_saturation", + "description": "Color Saturation of the image (unit unspecified).", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "contrast", + "description": "Contrast of the image (unit unspecified).", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "sharpness", + "description": "Sharpness of the Video image (unit unspecified).", + "optional": true, + "default": null + }, + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "OFF" + }, + { + "type": "constant", + "value": "AUTO" + }, + { + "type": "constant", + "value": "ON" + } + ], + "name": "ircut_filter", + "description": "Infrared Cutoff Filter settings.", + "optional": true, + "default": null + }, + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "OFF" + }, + { + "type": "constant", + "value": "ON" + } + ], + "name": "backlight_compensation", + "description": "Enabled/disabled Backlight Compensation mode (on/off).", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "exposure", + "description": "Exposure mode of the device.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "focus", + "description": "Focus configuration.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "wide_dynamic_range", + "description": "Wide dynamic range settings.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "white_balance", + "description": "White balance settings.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "image_stabilization", + "description": "Optional element to configure Image Stabilization feature.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "ircut_filter_auto_adjustment", + "description": "An optional parameter applied to only auto mode to adjust timing of toggling Infrared Cutoff filter.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "tone_compensation", + "description": "Optional element to configure Image Contrast Compensation.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "defogging", + "description": "Optional element to configure Image Defogging.", + "optional": true, + "default": null + }, + { + "type": "custom_validator", + "value": "unable_to_convert", + "name": "noise_reduction", + "description": "Optional element to configure Image Noise Reduction.", + "optional": true, + "default": null + } + ], + "name": "imaging", + "description": "Imaging service configuration.", + "optional": true, + "default": null + }, + { + "type": "map", + "value": [ + { + "type": "boolean", + "name": "home_position", + "description": "Move camera to home position on startup (if supported by camera). Will be ignored if any of the PTZ presets have the on_startup set to true.", + "optional": true, + "default": false + }, + { + "type": "boolean", + "name": "reverse_pan", + "description": "Reverse the pan direction. Will be implemented in backend and frontend, and will not affect the position of user defined PTZ presets.", + "optional": true, + "default": false + }, + { + "type": "boolean", + "name": "reverse_tilt", + "description": "Reverse the tilt direction. Will be implemented in backend and frontend, and will not affect the position of user defined PTZ presets.", + "optional": true, + "default": false + }, + { + "type": "float", + "name": "min_pan", + "description": "Minimum pan value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "max_pan", + "description": "Maximum pan value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "min_tilt", + "description": "Minimum tilt value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "max_tilt", + "description": "Maximum tilt value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "min_zoom", + "description": "Minimum zoom value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "max_zoom", + "description": "Maximum zoom value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + "optional": true, + "default": null + }, + { + "type": "list", + "values": [ + [ + { + "type": "string", + "name": "name", + "description": "Name of the PTZ preset.", + "required": true, + "default": null + }, + { + "type": "float", + "name": "pan", + "description": "Pan value of the PTZ preset. A value between -1.0 and 1.0.", + "required": true, + "default": null + }, + { + "type": "float", + "name": "tilt", + "description": "Tilt value of the PTZ preset. A value between -1.0 and 1.0.", + "required": true, + "default": null + }, + { + "type": "float", + "name": "zoom", + "description": "Zoom value of the PTZ preset. A value between -1.0 and 1.0", + "optional": true, + "default": null + }, + { + "type": "boolean", + "name": "on_startup", + "description": "Move to this (named) preset on startup.", + "optional": true, + "default": false + } + ] + ], + "name": "presets", + "description": "A list of user-defined PTZ presets (using the Absolute Move operation if supported by camera). These presets will not be saved to the ONVIF camera.", + "optional": true, + "default": null + } + ], + "name": "ptz", + "description": "PTZ service configuration.", + "optional": true, + "default": null + } + ], + "name": { + "type": "CAMERA_IDENTIFIER" + }, + "description": "Camera identifier. Valid characters are lowercase a-z, numbers and underscores.", + "cameraidentifier": true, + "default": null + } + ], + "name": "cameras", + "description": "List of ONVIF cameras to make available to the component.", + "required": true, + "default": null + } + ], + "name": "onvif", + "description": "ONVIF cameras integration.", + "required": true, + "default": null + } +] \ No newline at end of file diff --git a/docs/src/pages/components-explorer/components/onvif/index.mdx b/docs/src/pages/components-explorer/components/onvif/index.mdx new file mode 100644 index 000000000..90e51b3e0 --- /dev/null +++ b/docs/src/pages/components-explorer/components/onvif/index.mdx @@ -0,0 +1,172 @@ +import ComponentConfiguration from "@site/src/pages/components-explorer/_components/ComponentConfiguration"; +import ComponentHeader from "@site/src/pages/components-explorer/_components/ComponentHeader"; +import ComponentTroubleshooting from "@site/src/pages/components-explorer/_components/ComponentTroubleshooting/index.mdx"; + +import ComponentMetadata from "./_meta"; +import config from "./config.json"; + + + +The ONVIF component allows you to manage ONVIF cameras directly in Viseron using the [Profile S](https://www.onvif.org/wp-content/uploads/2019/12/ONVIF_Profile_-S_Specification_v1-3.pdf) Client requirements implementation. Currently, the implementation is limited to the **Device**, **Media**, **Imaging**, and **PTZ** (pan-tilt-zoom) services. And not all operations on the service are implemented in this component, if you feel the implementation of the operation is lacking, [please contribute!](/docs/contributing) + +All services can be configured directly via the Viseron Dashboard ([Camera Tuning](/docs/documentation/configuration)), and the PTZ service can also be controlled via the [Telegram component](/components-explorer/components/telegram) or via [Live View](/docs/documentation/configuration/live_view) page. + +Since this component's implementation uses the `onvif-python` library, you can visit the project's [GitHub Repository](https://github.com/nirsimetri/onvif-python) for more details. + +:::warning + +If `auto_config` is set to **false** and one of the configurations under the `device`, `media`, `imaging`, and `ptz` services are filled in, when Viseron starts, these configurations will be set to the ONVIF camera. + +If `auto_config` is set to **true**, all service configurations will be ignored and the existing configuration on the ONVIF camera will be used. + +::: + +:::tip + +It is recommended to set `auto_config` to **true** so you can configure further ONVIF settings directly in the [Camera Tuning](/docs/documentation/configuration). But if you want the ONVIF settings to be persistent every time Viseron is started, then you can configure it directly in each key service. + +::: + +## Configuration + +
+ Configuration example + +```yaml title="/config/config.yaml" +onvif: + cameras: + camera_one: + port: 2020 + username: !secret onvif_username + password: !secret onvif_password + timeout: 15 # set timeout for ONVIF connections + use_https: true # use HTTPS for ONVIF connections + verify_ssl: false # set to false if using self-signed certificated + camera_two: + port: 8000 + username: my_username + password: "@myS3curepassword" + auto_config: false # will use the configuration below + device: + ntp_from_dhcp: false + ntp_type: DNS + ntp_server: pool.ntp.org + imaging: + brightness: 50.0 + color_saturation: 50.0 + contrast: 50.0 + sharpness: 75.5 + ircut_filter: AUTO + backlight_compensation: OFF + exposure: + mode: AUTO + min_gain: 0.0 + max_gain: 100.0 + white_balance: + mode: AUTO + defogging: + mode: ON + level: 0.6 + ptz: + home_position: false + reverse_pan: true + reverse_tilt: false + min_pan: -0.73 # used to limit pan swings to useful fov + max_pan: 0.04 # used to limit pan swings to useful fov + presets: # allows switching between pre-defined (absolute) positions + - name: front # name them + x: 1.0 + y: 0.4 + z: 0.7 + on_startup: true # have the camera move to this preset when Viseron starts + - name: left + x: -0.5 + y: 0.0 + - name: right + x: 0.5 + y: 0.0 +``` + +
+ + + +## Services + +The ONVIF component is structured around several core ONVIF services, each representing a distinct set of capabilities exposed by an ONVIF-compliant device. A service defines what kind of operations can be performed, such as retrieving device information, configuring video streams, adjusting image parameters, or controlling camera movement. + +Not every camera supports all services or all operations within a service, as availability depends on the device’s hardware and firmware. This section describes the supported ONVIF services in Viseron, outlines their purpose, and lists the specific operations that are implemented for each service. + +### Device + +Device service allows you to manage ONVIF devices in the following subcategories: capabilities, system, security, and network. Device service is mandatory for all ONVIF devices, so regardless of your camera brand/model, device service will always be available. + +But it should be noted that **not all operations are supported by all types of cameras**, and this **ONVIF component does not implement all operations**. For a more detailed explanation, you can refer to the [official document](https://developer.onvif.org/pub/specs/branches/development/doc/Core.xml) regarding this service. The operations implemented by this component in the Device service are described as follows: + +| No | Area | Operations | +| --- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Capabilities | `GetCapabilities`, `GetServices` | +| 2 | System | `GetDeviceInformation`, `GetDiscoveryMode`, `SetDiscoveryMode`, `GetScopes`, `AddScopes`, `SetScopes`, `RemoveScopes`, `GetSystemDateAndTime`, `SetSystemDateAndTime`, `SystemReboot` | +| 3 | Security | `GetUsers`, `CreateUsers`, `DeleteUsers`, `SetUser` | +| 4 | Network | `GetHostname`, `SetHostname`, `GetNTP`, `SetNTP`, `GetNetworkDefaultGateway`, `GetNetworkInterfaces`, `GetNetworkProtocols`, `GetDNS` | + +All Device service operations and settings can be configured via the [Camera Tuning](/docs/documentation/configuration) page. + +### Media + +### Imaging + +Imaging service allows you to control and configure the imaging properties of your ONVIF camera video. The Imaging service is mandatory for ONVIF camera devices with a video source, so if your ONVIF device is a camera, this service is definitely present. For a more detailed explanation, you can refer to the [official document](https://developer.onvif.org/pub/specs/branches/development/doc/Imaging.xml) regarding this service. + +Please note that **not all Imaging service operations parameters are supported by your camera**, this component will adjust automatically in the frontend ([Camera Tuning](/docs/documentation/configuration) page) based on your camera capabilities. The operations implemented by this component in the Imaging service are described as follows: + +| No | Area | Operations | +| --- | -------- | -------------------------------------------------------- | +| 1 | Settings | `GetOptions`, `GetImagingSettings`, `SetImagingSettings` | +| 2 | Focus | `GetMoveOptions`, `GetStatus`, `Move`, `Stop` | + +All Imaging service operations and settings can be configured via the [Camera Tuning](/docs/documentation/configuration) page. + +If you decide not to use auto configuration (`auto_config` is set to `false`) the `brightness`, `color_saturation`, `contrast`, and `sharpness` parameters have their units unspecified and you must know their minimum and maximum values beforehand. If you enter the unit incorrectly, an error will appear. + +Then for the parameters `exposure`, `focus`, `wide_dynamic_range`, `white_balance`, `image_stabilization`, `ircut_filter_auto_adjustment`, `tone_compensation`, `defogging`, and `noise_reduction` **must be filled with keys in "snake_case" form**, you can see all the key parameters available in this [WSDL document](https://developer.onvif.org/pub/specs/branches/development/wsdl/ver20/imaging/wsdl/imaging.wsdl). + +For example, based on the ONVIF WSDL document, `WideDynamicRange` has several key parameters like this: + +``` +WideDynamicRange - optional; [WideDynamicRange20] + Mode [WideDynamicMode] + Level - optional; [float] +``` + +Then for `wide_dynamic_range` it should be configured like this: + +```yaml +wide_dynamic_range: + mode: ON + level: 50.0 +``` + +### PTZ + +PTZ service allows you to control and configure the Pan, Tilt, and Zoom movements of your ONVIF PTZ camera. As explained in the ONVIF component description above, this PTZ can be controlled via the [Telegram component](/components-explorer/components/telegram) or via [Live View](/docs/documentation/configuration/live_view) page (as a player menu). + +:::note + +This ONVIF component can still be used on ONVIF-compatible cameras even if the camera itself isn't a PTZ camera. This means you can use/configure services other than PTZ. And if your ONVIF camera does not support PTZ, then the `ptz` key in the configuration or PTZ control will be completely ignored. + +::: + +But it should be noted that **not all operations are supported by all types of cameras**, and this **ONVIF component does not implement all operations**. For a more detailed explanation, you can refer to the [official document](https://developer.onvif.org/pub/specs/branches/development/doc/PTZ.xml) regarding this service. The operations implemented by this component in the PTZ service are described as follows: + +| No | Area | Operations | +| --- | ------------- | ----------------------------------------------------------------------------------------------------------- | +| 1 | Movement | `ContinuousMove`, `RelativeMove`, `AbsoluteMove`, `Stop` | +| 2 | Position | `GotoHomePosition`, `SetHomePosition`, `GetStatus`, `GetPresets`, `GotoPreset`, `SetPreset`, `RemovePreset` | +| 3 | Configuration | `GetNodes`, `GetConfigurations`, `GetConfigurationOptions` | + +All PTZ service settings can be configured via the [Camera Tuning](/docs/documentation/configuration) page and some settings/operations can be configured on the [Live View](/docs/documentation/configuration/live_view) page. + +For transparency, PTZ Control available in [Live View](/docs/documentation/configuration/live_view) uses the `ContinuousMove` operation, which is **mandatory for all ONVIF PTZ cameras**. PTZ Control in the [Telegram component](/components-explorer/components/telegram), however, will use the `RelativeMove` operation if supported by the camera, and will use `ContinuousMove` as a fallback. + + diff --git a/docs/src/pages/components-explorer/components/telegram/index.mdx b/docs/src/pages/components-explorer/components/telegram/index.mdx index e453553e5..5b1120b5f 100644 --- a/docs/src/pages/components-explorer/components/telegram/index.mdx +++ b/docs/src/pages/components-explorer/components/telegram/index.mdx @@ -7,9 +7,10 @@ import config from "./config.json"; -The telegram component can do two things. -First, it can send detection videos (and thumbnails) to a chat when a detection occurs. -Second, it can be used to control the [PTZ component](/components-explorer/components/ptz) from the chat, with commands like `/left`, `/right`, etc. +The telegram component can do two things: + +- First, it can send detection videos (and thumbnails) to a chat when a detection occurs. +- Second, it can be used to control the PTZ (pan-tilt-zoom) for the [ONVIF component](/components-explorer/components/onvif) from the chatbot directly, with commands like `/home`, `/left`, `/right` (see [available commands](#available-commands) below). ## Configuration @@ -62,7 +63,36 @@ https://www.home-assistant.io/integrations/telegram/ ## Available commands -To find the available commands you can use, send `/help` to your bot in Telegram. +To find the available commands you can use, send `/help` to your bot in Telegram. As of the time of writing this documentation, the available help commands are described as follows: + +:::info + +Use `/help` `` to get more information about a command. For commands starting from number **1 to 13**, they only apply to cameras that have [ONVIF component](/components-explorer/components/onvif). + +::: + +| No | Commands | Description | Notes | +| --- | ----------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `/home` or `/h` | Move the camera to its home position. | If supported by the camera. | +| 2 | `/left` or `/l` | Pan the camera to the left. | Using the `RelativeMove` operation. And will use `ContinuousMove` as a fallback if it is not supported. | +| 3 | `/right` or `/r` | Pan the camera to the right. | Using the `RelativeMove` operation. And will use `ContinuousMove` as a fallback if it is not supported. | +| 4 | `/up` or `/u` | Tilt the camera up. | Using the `RelativeMove` operation. And will use `ContinuousMove` as a fallback if it is not supported. | +| 5 | `/down` or `/d` | Tilt the camera down. | Using the `RelativeMove` operation. And will use `ContinuousMove` as a fallback if it is not supported. | +| 6 | `/zo` or `/o` | Zoom the camera out. | Using the `RelativeMove` operation. And will use `ContinuousMove` as a fallback if it is not supported. | +| 7 | `/zi` or `/i` | Zoom the camera in. | Using the `RelativeMove` operation. And will use `ContinuousMove` as a fallback if it is not supported. | +| 8 | `/pos` | Get the current (PTZ) position of the camera. | If supported by the camera. | +| 9 | `/preset` or `/pr` | Change the camera to a preset position. | Will display user-defined presets and presets that are already in the ONVIF camera. | +| 10 | `/repeat` | Presets are paths when names are reused. | - | +| 11 | `/patrol` or `/p` | Swings the camera from left to right and back. | The default duration is 60 seconds. It will not work properly if your camera does not support `GetStatus`, `RelativeMove`, and `AbsoluteMove` operations. | +| 12 | `/lissa` | Perform Lissajous curve swing patrols. | Must be stopped manually with the `/stop` command. It will not work properly if your camera does not support `GetStatus`, `RelativeMove`, and `AbsoluteMove` operations. | +| 13 | `/stop` or `/st` | Stop the patrol. | - | +| 14 | `/record` or `/r` | Record a video with the camera. | - | +| 15 | `/stop_recorder` or `/sr` | Stop an ongoing manual recording. | - | +| 16 | `/list` or `/li` or `/select` | List all available cameras. | - | +| 17 | `/which` or `/w` | Get the currently active camera. | It is important to know which camera to execute. | +| 18 | `/toggle` or `/t` | Toggle the camera on or off. | - | +| 19 | `/snapshot` | Take a snapshot with the camera. | - | +| 20 | `/help` | Display a list of commands and their description. | - | ## Troubleshooting From c2149fcb9dd93443e129debbb9a1bb2e953c37ba Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Fri, 9 Jan 2026 16:26:51 +0700 Subject: [PATCH 007/120] feat(onvif): Create ONVIF components --- viseron/components/onvif/__init__.py | 601 +++++++++++++ viseron/components/onvif/const.py | 217 +++++ viseron/components/onvif/device.py | 280 ++++++ viseron/components/onvif/imaging.py | 525 +++++++++++ viseron/components/onvif/media.py | 156 ++++ viseron/components/onvif/ptz.py | 826 ++++++++++++++++++ viseron/components/onvif/utils.py | 213 +++++ .../webserver/api/v1/actions/onvif/ptz.py | 243 ++++++ 8 files changed, 3061 insertions(+) create mode 100644 viseron/components/onvif/__init__.py create mode 100644 viseron/components/onvif/const.py create mode 100644 viseron/components/onvif/device.py create mode 100644 viseron/components/onvif/imaging.py create mode 100644 viseron/components/onvif/media.py create mode 100644 viseron/components/onvif/ptz.py create mode 100644 viseron/components/onvif/utils.py create mode 100644 viseron/components/webserver/api/v1/actions/onvif/ptz.py diff --git a/viseron/components/onvif/__init__.py b/viseron/components/onvif/__init__.py new file mode 100644 index 000000000..81644d7e1 --- /dev/null +++ b/viseron/components/onvif/__init__.py @@ -0,0 +1,601 @@ +"""ONVIF component.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +import voluptuous as vol +from onvif import ONVIFClient + +from viseron.const import EVENT_DOMAIN_REGISTERED, VISERON_SIGNAL_STOPPING +from viseron.domains.camera import AbstractCamera +from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN +from viseron.helpers import escape_string +from viseron.helpers.logs import SensitiveInformationFilter +from viseron.helpers.validators import CameraIdentifier +from viseron.watchdog.thread_watchdog import RestartableThread + +from .const import ( + COMPONENT, + CONFIG_CAMERAS, + CONFIG_DEVICE, + CONFIG_DEVICE_DATETIME_TYPE, + CONFIG_DEVICE_DAYLIGHT_SAVINGS, + CONFIG_DEVICE_DISCOVERABLE, + CONFIG_DEVICE_HOSTNAME, + CONFIG_DEVICE_NTP_FROM_DHCP, + CONFIG_DEVICE_NTP_SERVER, + CONFIG_DEVICE_NTP_TYPE, + CONFIG_DEVICE_TIMEZONE, + CONFIG_HOST, + CONFIG_IMAGING, + CONFIG_IMAGING_BACKLIGHT_COMPENSATION, + CONFIG_IMAGING_BRIGHTNESS, + CONFIG_IMAGING_COLOR_SATURATION, + CONFIG_IMAGING_CONTRAST, + CONFIG_IMAGING_DEFOGGING, + CONFIG_IMAGING_EXPOSURE, + CONFIG_IMAGING_FOCUS, + CONFIG_IMAGING_FORCE_PERSISTENCE, + CONFIG_IMAGING_IMAGE_STABILIZATION, + CONFIG_IMAGING_IRCUT_FILTER, + CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT, + CONFIG_IMAGING_NOISE_REDUCTION, + CONFIG_IMAGING_SHARPNESS, + CONFIG_IMAGING_TONE_COMPENSATION, + CONFIG_IMAGING_WHITE_BALANCE, + CONFIG_IMAGING_WIDE_DYNAMIC_RANGE, + CONFIG_MEDIA, + CONFIG_ONVIF_AUTO_CONFIG, + CONFIG_ONVIF_PASSWORD, + CONFIG_ONVIF_PORT, + CONFIG_ONVIF_TIMEOUT, + CONFIG_ONVIF_USE_HTTPS, + CONFIG_ONVIF_USERNAME, + CONFIG_ONVIF_VERIFY_SSL, + CONFIG_ONVIF_WSDL_DIR, + CONFIG_PTZ, + CONFIG_PTZ_HOME_POSITION, + CONFIG_PTZ_MAX_PAN, + CONFIG_PTZ_MAX_TILT, + CONFIG_PTZ_MAX_ZOOM, + CONFIG_PTZ_MIN_PAN, + CONFIG_PTZ_MIN_TILT, + CONFIG_PTZ_MIN_ZOOM, + CONFIG_PTZ_PRESET_NAME, + CONFIG_PTZ_PRESET_ON_STARTUP, + CONFIG_PTZ_PRESET_PAN, + CONFIG_PTZ_PRESET_TILT, + CONFIG_PTZ_PRESET_ZOOM, + CONFIG_PTZ_PRESETS, + CONFIG_PTZ_REVERSE_PAN, + CONFIG_PTZ_REVERSE_TILT, + DEFAULT_IMAGING_FORCE_PERSISTENCE, + DEFAULT_ONVIF_AUTO_CONFIG, + DEFAULT_ONVIF_TIMEOUT, + DEFAULT_ONVIF_USE_HTTPS, + DEFAULT_ONVIF_VERIFY_SSL, + DEFAULT_PTZ_HOME_POSITION, + DEFAULT_PTZ_PRESET_ON_STARTUP, + DEFAULT_PTZ_REVERSE_PAN, + DEFAULT_PTZ_REVERSE_TILT, + DESC_CAMERAS, + DESC_COMPONENT, + DESC_DEVICE, + DESC_DEVICE_DATETIME_TYPE, + DESC_DEVICE_DAYLIGHT_SAVINGS, + DESC_DEVICE_DISCOVERABLE, + DESC_DEVICE_HOSTNAME, + DESC_DEVICE_NTP_FROM_DHCP, + DESC_DEVICE_NTP_SERVER, + DESC_DEVICE_NTP_TYPE, + DESC_DEVICE_TIMEZONE, + DESC_IMAGING, + DESC_IMAGING_BACKLIGHT_COMPENSATION, + DESC_IMAGING_BRIGHTNESS, + DESC_IMAGING_COLOR_SATURATION, + DESC_IMAGING_CONTRAST, + DESC_IMAGING_DEFOGGING, + DESC_IMAGING_EXPOSURE, + DESC_IMAGING_FOCUS, + DESC_IMAGING_FORCE_PERSISTENCE, + DESC_IMAGING_IMAGE_STABILIZATION, + DESC_IMAGING_IRCUT_FILTER, + DESC_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT, + DESC_IMAGING_NOISE_REDUCTION, + DESC_IMAGING_SHARPNESS, + DESC_IMAGING_TONE_COMPENSATION, + DESC_IMAGING_WHITE_BALANCE, + DESC_IMAGING_WIDE_DYNAMIC_RANGE, + DESC_MEDIA, + DESC_ONVIF_AUTO_CONFIG, + DESC_ONVIF_PASSWORD, + DESC_ONVIF_PORT, + DESC_ONVIF_TIMEOUT, + DESC_ONVIF_USE_HTTPS, + DESC_ONVIF_USERNAME, + DESC_ONVIF_VERIFY_SSL, + DESC_ONVIF_WSDL_DIR, + DESC_PTZ, + DESC_PTZ_HOME_POSITION, + DESC_PTZ_MAX_PAN, + DESC_PTZ_MAX_TILT, + DESC_PTZ_MAX_ZOOM, + DESC_PTZ_MIN_PAN, + DESC_PTZ_MIN_TILT, + DESC_PTZ_MIN_ZOOM, + DESC_PTZ_PRESET_NAME, + DESC_PTZ_PRESET_ON_STARTUP, + DESC_PTZ_PRESET_PAN, + DESC_PTZ_PRESET_TILT, + DESC_PTZ_PRESET_ZOOM, + DESC_PTZ_PRESETS, + DESC_PTZ_REVERSE_PAN, + DESC_PTZ_REVERSE_TILT, + DEVICE_DATETIME_TYPE_MAP, + DEVICE_NTP_TYPE_MAP, + IMAGING_BACKLIGHT_COMPENSATION_MAP, + IMAGING_IRCUT_FILTER_MAP, +) +from .device import Device +from .imaging import Imaging +from .media import Media +from .ptz import PTZ +from .utils import extract_rtsp_from_go2rtc + +if TYPE_CHECKING: + from viseron import Event, Viseron + +LOGGER = logging.getLogger(__name__) + +# Device Service Schema +DEVICE_SCHEMA = vol.Schema( + { + vol.Optional( + CONFIG_DEVICE_HOSTNAME, + description=DESC_DEVICE_HOSTNAME, + ): str, + vol.Optional( + CONFIG_DEVICE_DISCOVERABLE, + description=DESC_DEVICE_DISCOVERABLE, + ): bool, + vol.Optional( + CONFIG_DEVICE_DATETIME_TYPE, + description=DESC_DEVICE_DATETIME_TYPE, + ): vol.In(DEVICE_DATETIME_TYPE_MAP), + vol.Optional( + CONFIG_DEVICE_DAYLIGHT_SAVINGS, + description=DESC_DEVICE_DAYLIGHT_SAVINGS, + ): bool, + vol.Optional( + CONFIG_DEVICE_TIMEZONE, + description=DESC_DEVICE_TIMEZONE, + ): str, + vol.Optional( + CONFIG_DEVICE_NTP_FROM_DHCP, + description=DESC_DEVICE_NTP_FROM_DHCP, + ): bool, + vol.Optional( + CONFIG_DEVICE_NTP_TYPE, + description=DESC_DEVICE_NTP_TYPE, + ): vol.In(DEVICE_NTP_TYPE_MAP), + vol.Optional( + CONFIG_DEVICE_NTP_SERVER, + description=DESC_DEVICE_NTP_SERVER, + ): str, + } +) + +# Media Service Schema +MEDIA_SCHEMA = vol.Schema({}) + +# Imaging Service Schema +IMAGING_SCHEMA = vol.Schema( + { + vol.Optional( + CONFIG_IMAGING_FORCE_PERSISTENCE, + description=DESC_IMAGING_FORCE_PERSISTENCE, + default=DEFAULT_IMAGING_FORCE_PERSISTENCE, + ): bool, + vol.Optional( + CONFIG_IMAGING_BRIGHTNESS, + description=DESC_IMAGING_BRIGHTNESS, + ): vol.Coerce(float), + vol.Optional( + CONFIG_IMAGING_COLOR_SATURATION, + description=DESC_IMAGING_COLOR_SATURATION, + ): vol.Coerce(float), + vol.Optional( + CONFIG_IMAGING_CONTRAST, + description=DESC_IMAGING_CONTRAST, + ): vol.Coerce(float), + vol.Optional( + CONFIG_IMAGING_SHARPNESS, + description=DESC_IMAGING_SHARPNESS, + ): vol.Coerce(float), + vol.Optional( + CONFIG_IMAGING_IRCUT_FILTER, + description=DESC_IMAGING_IRCUT_FILTER, + ): vol.In(IMAGING_IRCUT_FILTER_MAP), + vol.Optional( + CONFIG_IMAGING_BACKLIGHT_COMPENSATION, + description=DESC_IMAGING_BACKLIGHT_COMPENSATION, + ): vol.In(IMAGING_BACKLIGHT_COMPENSATION_MAP), + vol.Optional( + CONFIG_IMAGING_EXPOSURE, + description=DESC_IMAGING_EXPOSURE, + ): dict, + vol.Optional( + CONFIG_IMAGING_FOCUS, + description=DESC_IMAGING_FOCUS, + ): dict, + vol.Optional( + CONFIG_IMAGING_WIDE_DYNAMIC_RANGE, + description=DESC_IMAGING_WIDE_DYNAMIC_RANGE, + ): dict, + vol.Optional( + CONFIG_IMAGING_WHITE_BALANCE, + description=DESC_IMAGING_WHITE_BALANCE, + ): dict, + vol.Optional( + CONFIG_IMAGING_IMAGE_STABILIZATION, + description=DESC_IMAGING_IMAGE_STABILIZATION, + ): dict, + vol.Optional( + CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT, + description=DESC_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT, + ): dict, + vol.Optional( + CONFIG_IMAGING_TONE_COMPENSATION, + description=DESC_IMAGING_TONE_COMPENSATION, + ): dict, + vol.Optional( + CONFIG_IMAGING_DEFOGGING, + description=DESC_IMAGING_DEFOGGING, + ): dict, + vol.Optional( + CONFIG_IMAGING_NOISE_REDUCTION, + description=DESC_IMAGING_NOISE_REDUCTION, + ): dict, + } +) + +# PTZ Preset Schema +PRESET_SCHEMA = vol.Schema( + { + vol.Required(CONFIG_PTZ_PRESET_NAME, description=DESC_PTZ_PRESET_NAME): str, + vol.Required( + CONFIG_PTZ_PRESET_PAN, description=DESC_PTZ_PRESET_PAN + ): vol.Coerce(float), + vol.Required( + CONFIG_PTZ_PRESET_TILT, description=DESC_PTZ_PRESET_TILT + ): vol.Coerce(float), + vol.Optional( + CONFIG_PTZ_PRESET_ZOOM, description=DESC_PTZ_PRESET_ZOOM + ): vol.Coerce(float), + vol.Optional( + CONFIG_PTZ_PRESET_ON_STARTUP, + description=DESC_PTZ_PRESET_ON_STARTUP, + default=DEFAULT_PTZ_PRESET_ON_STARTUP, + ): bool, + } +) + +# PTZ Service Schema +PTZ_SCHEMA = vol.Schema( + { + vol.Optional( + CONFIG_PTZ_HOME_POSITION, + description=DESC_PTZ_HOME_POSITION, + default=DEFAULT_PTZ_HOME_POSITION, + ): bool, + vol.Optional( + CONFIG_PTZ_REVERSE_PAN, + description=DESC_PTZ_REVERSE_PAN, + default=DEFAULT_PTZ_REVERSE_PAN, + ): bool, + vol.Optional( + CONFIG_PTZ_REVERSE_TILT, + description=DESC_PTZ_REVERSE_TILT, + default=DEFAULT_PTZ_REVERSE_TILT, + ): bool, + vol.Optional( + CONFIG_PTZ_MIN_PAN, + description=DESC_PTZ_MIN_PAN, + ): vol.Coerce(float), + vol.Optional( + CONFIG_PTZ_MAX_PAN, + description=DESC_PTZ_MAX_PAN, + ): vol.Coerce(float), + vol.Optional( + CONFIG_PTZ_MIN_TILT, + description=DESC_PTZ_MIN_TILT, + ): vol.Coerce(float), + vol.Optional( + CONFIG_PTZ_MAX_TILT, + description=DESC_PTZ_MAX_TILT, + ): vol.Coerce(float), + vol.Optional( + CONFIG_PTZ_MIN_ZOOM, + description=DESC_PTZ_MIN_ZOOM, + ): vol.Coerce(float), + vol.Optional( + CONFIG_PTZ_MAX_ZOOM, + description=DESC_PTZ_MAX_ZOOM, + ): vol.Coerce(float), + vol.Optional(CONFIG_PTZ_PRESETS, description=DESC_PTZ_PRESETS): [PRESET_SCHEMA], + } +) + +# Camera Schema with all service configurations +CAMERA_SCHEMA = vol.Schema( + { + vol.Required(CONFIG_ONVIF_PORT, description=DESC_ONVIF_PORT): int, + vol.Required(CONFIG_ONVIF_USERNAME, description=DESC_ONVIF_USERNAME): str, + vol.Required(CONFIG_ONVIF_PASSWORD, description=DESC_ONVIF_PASSWORD): str, + vol.Optional( + CONFIG_ONVIF_TIMEOUT, + description=DESC_ONVIF_TIMEOUT, + default=DEFAULT_ONVIF_TIMEOUT, + ): int, + vol.Optional( + CONFIG_ONVIF_USE_HTTPS, + description=DESC_ONVIF_USE_HTTPS, + default=DEFAULT_ONVIF_USE_HTTPS, + ): bool, + vol.Optional( + CONFIG_ONVIF_VERIFY_SSL, + description=DESC_ONVIF_VERIFY_SSL, + default=DEFAULT_ONVIF_VERIFY_SSL, + ): bool, + vol.Optional( + CONFIG_ONVIF_WSDL_DIR, + description=DESC_ONVIF_WSDL_DIR, + ): str, + vol.Optional( + CONFIG_ONVIF_AUTO_CONFIG, + description=DESC_ONVIF_AUTO_CONFIG, + default=DEFAULT_ONVIF_AUTO_CONFIG, + ): bool, + vol.Optional(CONFIG_DEVICE, description=DESC_DEVICE): DEVICE_SCHEMA, + vol.Optional(CONFIG_MEDIA, description=DESC_MEDIA): MEDIA_SCHEMA, + vol.Optional(CONFIG_IMAGING, description=DESC_IMAGING): IMAGING_SCHEMA, + vol.Optional(CONFIG_PTZ, description=DESC_PTZ): PTZ_SCHEMA, + } +) + +COMPONENT_SCHEMA = vol.Schema( + { + vol.Required(CONFIG_CAMERAS, description=DESC_CAMERAS): { + CameraIdentifier(): CAMERA_SCHEMA + }, + } +) + +CONFIG_SCHEMA = vol.Schema( + {vol.Required(COMPONENT, description=DESC_COMPONENT): COMPONENT_SCHEMA}, + extra=vol.ALLOW_EXTRA, +) + + +def setup(vis: Viseron, config) -> bool: + """Set up the ONVIF component.""" + onvif = ONVIF(vis, config[COMPONENT]) + RestartableThread( + target=onvif.run, + name=config[COMPONENT], + ).start() + return True + + +class ONVIF: + """ONVIF Controller manages ONVIF services for cameras.""" + + def __init__(self, vis: Viseron, config) -> None: + self._vis = vis + self._config = config + for cam_name in self._config[CONFIG_CAMERAS]: + camera = self._config[CONFIG_CAMERAS][cam_name] + if camera.get(CONFIG_ONVIF_PASSWORD): + SensitiveInformationFilter.add_sensitive_string( + camera[CONFIG_ONVIF_PASSWORD] + ) + SensitiveInformationFilter.add_sensitive_string( + escape_string(camera[CONFIG_ONVIF_PASSWORD]) + ) + self._cameras: dict[str, AbstractCamera] = {} + self._onvif_clients: dict[str, ONVIFClient] = {} + self._device_services: dict[str, Device] = {} + self._imaging_services: dict[str, Imaging] = {} + self._media_services: dict[str, Media] = {} + self._ptz_services: dict[str, PTZ] = {} + self._register_lock: asyncio.Lock = asyncio.Lock() + self._stop_event: asyncio.Event = asyncio.Event() + self._loop: asyncio.AbstractEventLoop | None = None + vis.data[COMPONENT] = self + + def initialize(self): + """Initialize ONVIF Controller.""" + self._vis.register_signal_handler(VISERON_SIGNAL_STOPPING, self.shutdown) + self._vis.listen_event( + EVENT_DOMAIN_REGISTERED.format(domain=CAMERA_DOMAIN), + self._camera_registered, + ) + + def run(self): + """Run ONVIF Controller.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self._run()) + LOGGER.info("ONVIF Controller done") + + async def _run(self): + """Async Run ONVIF Controller.""" + self._loop = asyncio.get_event_loop() + self.initialize() + while not self._stop_event.is_set(): + await asyncio.sleep(0.1) + + def shutdown(self): + """Shutdown ONVIF Controller.""" + # For now the implementation only to stop any patrol events + LOGGER.info("Stopping ONVIF Controller") + for ptz_service in self._ptz_services.values(): + ptz_service.stop_patrol() + self._stop_event.set() + + def _camera_registered(self, event: Event[AbstractCamera]) -> None: + """Handle camera registration event.""" + camera: AbstractCamera = event.data + LOGGER.debug(f"Camera registered event received for {camera.identifier}") + + if camera.identifier in self._config[CONFIG_CAMERAS]: + LOGGER.debug(f"Processing ONVIF setup for camera {camera.identifier}") + self._cameras[camera.identifier] = camera + config = self._config[CONFIG_CAMERAS][camera.identifier] + + # Determine the host to use for ONVIF client + onvif_host = camera.config[CONFIG_HOST] + LOGGER.debug(f"Initial ONVIF host for {camera.identifier}: {onvif_host}") + + # Try to extract host from go2rtc RTSP URL if component available + rtsp_url = extract_rtsp_from_go2rtc(camera) + if rtsp_url: + try: + parsed_url = urlparse(rtsp_url) + if parsed_url.hostname: + onvif_host = parsed_url.hostname + except (ValueError, AttributeError) as error: + LOGGER.warning( + f"Could not parse host from go2rtc RTSP URL: {error}. " + f"Using camera config host instead." + ) + else: + LOGGER.debug( + f"No RTSP URL found from go2rtc for {camera.identifier}, " + f"using camera config host: {onvif_host}" + ) + + # Create ONVIF client + onvif_client = ONVIFClient( + onvif_host, + config.get(CONFIG_ONVIF_PORT), + config.get(CONFIG_ONVIF_USERNAME), + config.get(CONFIG_ONVIF_PASSWORD), + timeout=config.get(CONFIG_ONVIF_TIMEOUT), + use_https=config.get(CONFIG_ONVIF_USE_HTTPS), + verify_ssl=config.get(CONFIG_ONVIF_VERIFY_SSL), + wsdl_dir=config.get(CONFIG_ONVIF_WSDL_DIR), + ) + self._onvif_clients[camera.identifier] = onvif_client + + # Then initialize all ONVIF services! + self._initialize_camera_services(camera, onvif_client, config) + + def _initialize_camera_services( + self, camera: AbstractCamera, client: ONVIFClient, config: dict[str, Any] + ): + """ + Initialize ONVIF services for a camera. + + All services share the same ONVIFClient instance to avoid redundant connections. + And will use asyncio to initialize services concurrently. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + auto_config = config.get(CONFIG_ONVIF_AUTO_CONFIG, True) + + async def init_device(): + try: + device_config = config.get(CONFIG_DEVICE, {}) + device_service = Device(camera, client, device_config, auto_config) + await device_service.initialize() + self._device_services[camera.identifier] = device_service + LOGGER.debug(f"Initialized Device service for {camera.identifier}") + except Exception as error: # pylint: disable=broad-exception-caught + LOGGER.error( + f"Failed to initialize Device service for {camera.identifier}" + f": {error}" + ) + + async def init_media(): + try: + media_config = config.get(CONFIG_MEDIA, {}) + media_service = Media(camera, client, media_config, auto_config) + await media_service.initialize() + self._media_services[camera.identifier] = media_service + LOGGER.debug(f"Initialized Media service for {camera.identifier}") + except Exception as error: # pylint: disable=broad-exception-caught + LOGGER.error( + f"Failed to initialize Media service for {camera.identifier}" + f": {error}" + ) + + async def init_imaging(): + try: + imaging_config = config.get(CONFIG_IMAGING, {}) + imaging_service = Imaging( + camera, + client, + imaging_config, + auto_config, + self._media_services.get(camera.identifier), + ) + await imaging_service.initialize() + self._imaging_services[camera.identifier] = imaging_service + LOGGER.debug(f"Initialized Imaging service for {camera.identifier}") + except Exception as error: # pylint: disable=broad-exception-caught + LOGGER.warning( + f"Failed to initialize Imaging service for {camera.identifier}" + f": {error}" + ) + + async def init_ptz(): + try: + ptz_config = config.get(CONFIG_PTZ, {}) + ptz_service = PTZ( + camera, + client, + ptz_config, + auto_config, + self._media_services.get(camera.identifier), + ) + await ptz_service.initialize() + self._ptz_services[camera.identifier] = ptz_service + LOGGER.debug(f"Initialized PTZ service for {camera.identifier}") + except Exception as error: # pylint: disable=broad-exception-caught + LOGGER.error( + f"Failed to initialize PTZ service for {camera.identifier}" + f": {error}" + ) + + loop.run_until_complete( + asyncio.gather(init_device(), init_media(), init_imaging(), init_ptz()) + ) + + # ONVIF Client accessor -> use this to access the ONVIF client in other components + + def get_onvif_client(self, camera_identifier: str) -> ONVIFClient | None: + """Get the ONVIF client for a camera.""" + return self._onvif_clients.get(camera_identifier) + + # Service accessors -> use these to access ONVIF services in other components + + def get_device_service(self, camera_identifier: str) -> Device | None: + """Get the Device service for a camera.""" + return self._device_services.get(camera_identifier) + + def get_media_service(self, camera_identifier: str) -> Media | None: + """Get the Media service for a camera.""" + return self._media_services.get(camera_identifier) + + def get_imaging_service(self, camera_identifier: str) -> Imaging | None: + """Get the Imaging service for a camera.""" + return self._imaging_services.get(camera_identifier) + + def get_ptz_service(self, camera_identifier: str) -> PTZ | None: + """Get the PTZ service for a camera.""" + return self._ptz_services.get(camera_identifier) diff --git a/viseron/components/onvif/const.py b/viseron/components/onvif/const.py new file mode 100644 index 000000000..4d58e4aa4 --- /dev/null +++ b/viseron/components/onvif/const.py @@ -0,0 +1,217 @@ +"""ONVIF component constants.""" + +COMPONENT = "onvif" +DESC_COMPONENT = "ONVIF cameras integration." + +# ONVIF CONFIG +CONFIG_CAMERAS = "cameras" +CONFIG_HOST = "host" +CONFIG_ONVIF_PORT = "port" +CONFIG_ONVIF_USERNAME = "username" +CONFIG_ONVIF_PASSWORD = "password" + +CONFIG_ONVIF_TIMEOUT = "timeout" +CONFIG_ONVIF_USE_HTTPS = "use_https" +CONFIG_ONVIF_VERIFY_SSL = "verify_ssl" +CONFIG_ONVIF_WSDL_DIR = "wsdl_dir" +CONFIG_ONVIF_AUTO_CONFIG = "auto_config" + +DEFAULT_ONVIF_TIMEOUT = 10 +DEFAULT_ONVIF_USE_HTTPS = False +DEFAULT_ONVIF_VERIFY_SSL = True +DEFAULT_ONVIF_AUTO_CONFIG = True + +""" +If all the service configurations below are filled in, then when Viseron starts up all +these configurations will be overridden to the ONVIF device and only if the auto_config +key is set to False. If auto_config is set to True, then all the service configurations +will be ignored and the existing configuration on the ONVIF device will be used. +""" + +# ONVIF DEVICE CONFIG +CONFIG_DEVICE = "device" +CONFIG_DEVICE_HOSTNAME = "hostname" +CONFIG_DEVICE_DISCOVERABLE = "discoverable" +CONFIG_DEVICE_DATETIME_TYPE = "datetime_type" +DEVICE_DATETIME_TYPE_MAP = {"NTP", "Manual"} +CONFIG_DEVICE_DAYLIGHT_SAVINGS = "daylight_savings" +CONFIG_DEVICE_TIMEZONE = "timezone" +CONFIG_DEVICE_NTP_FROM_DHCP = "ntp_from_dhcp" +CONFIG_DEVICE_NTP_TYPE = "ntp_type" +DEVICE_NTP_TYPE_MAP = {"DNS", "IPv4", "IPv6"} +CONFIG_DEVICE_NTP_SERVER = "ntp_server" + +# ONVIF IMAGING CONFIG +CONFIG_IMAGING = "imaging" +CONFIG_IMAGING_FORCE_PERSISTENCE = "force_persistence" +CONFIG_IMAGING_BRIGHTNESS = "brightness" +CONFIG_IMAGING_COLOR_SATURATION = "color_saturation" +CONFIG_IMAGING_CONTRAST = "contrast" +CONFIG_IMAGING_SHARPNESS = "sharpness" +CONFIG_IMAGING_IRCUT_FILTER = "ircut_filter" +IMAGING_IRCUT_FILTER_MAP = {"ON", "OFF", "AUTO"} +CONFIG_IMAGING_BACKLIGHT_COMPENSATION = "backlight_compensation" +IMAGING_BACKLIGHT_COMPENSATION_MAP = {"ON", "OFF"} +CONFIG_IMAGING_EXPOSURE = "exposure" +CONFIG_IMAGING_FOCUS = "focus" +CONFIG_IMAGING_WIDE_DYNAMIC_RANGE = "wide_dynamic_range" +CONFIG_IMAGING_WHITE_BALANCE = "white_balance" +CONFIG_IMAGING_IMAGE_STABILIZATION = "image_stabilization" +CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT = "ircut_filter_auto_adjustment" +CONFIG_IMAGING_TONE_COMPENSATION = "tone_compensation" +CONFIG_IMAGING_DEFOGGING = "defogging" +CONFIG_IMAGING_NOISE_REDUCTION = "noise_reduction" + +DEFAULT_IMAGING_FORCE_PERSISTENCE = True + +# ONVIF MEDIA CONFIG +CONFIG_MEDIA = "media" + +# ONVIF PTZ CONFIG +CONFIG_PTZ = "ptz" +CONFIG_PTZ_HOME_POSITION = "home_position" +CONFIG_PTZ_REVERSE_PAN = "reverse_pan" +CONFIG_PTZ_REVERSE_TILT = "reverse_tilt" +CONFIG_PTZ_MIN_PAN = "min_pan" +CONFIG_PTZ_MAX_PAN = "max_pan" +CONFIG_PTZ_MIN_TILT = "min_tilt" +CONFIG_PTZ_MAX_TILT = "max_tilt" +CONFIG_PTZ_MIN_ZOOM = "min_zoom" +CONFIG_PTZ_MAX_ZOOM = "max_zoom" +CONFIG_PTZ_PRESETS = "presets" +CONFIG_PTZ_PRESET_NAME = "name" +CONFIG_PTZ_PRESET_PAN = "pan" +CONFIG_PTZ_PRESET_TILT = "tilt" +CONFIG_PTZ_PRESET_ZOOM = "zoom" +CONFIG_PTZ_PRESET_ON_STARTUP = "on_startup" + +DEFAULT_PTZ_HOME_POSITION = False +DEFAULT_PTZ_REVERSE_PAN = False +DEFAULT_PTZ_REVERSE_TILT = False +DEFAULT_PTZ_PRESET_ON_STARTUP = False + +# ONVIF CONFIG DESCRIPTIONS +DESC_CAMERAS = "List of ONVIF cameras to make available to the component." +DESC_ONVIF_PORT = "ONVIF port of the camera." +DESC_ONVIF_USERNAME = "ONVIF username for the camera." +DESC_ONVIF_PASSWORD = "ONVIF password for the camera." + +DESC_ONVIF_TIMEOUT = "Timeout for ONVIF connections in seconds." +DESC_ONVIF_USE_HTTPS = "Use HTTPS for ONVIF connections." +DESC_ONVIF_VERIFY_SSL = "Verify SSL certificates for ONVIF connections." +DESC_ONVIF_WSDL_DIR = "Path to custom WSDL directory for ONVIF client." +DESC_ONVIF_AUTO_CONFIG = ( + "Set to true then it will ignore all configuration per each " + "service and use the default service that is already on the ONVIF camera. Don't " + "worry! This ONVIF component will automatically detect the existing " + "configuration in the ONVIF camera precisely." +) + +DESC_DEVICE = "Device service configuration." +DESC_DEVICE_HOSTNAME = "The hostname of the device." +DESC_DEVICE_DISCOVERABLE = ( + "Whether the device is discoverable on the network via WS-Discovery." +) + +DESC_DEVICE_DATETIME_TYPE = "Defines if the date and time is set via NTP or manually." +DESC_DEVICE_DAYLIGHT_SAVINGS = "Indicates whether Daylight Savings Time is in effect." +DESC_DEVICE_TIMEZONE = ( + "The time zone in POSIX 1003.1 format. Will be ignored if the " + "datetime_type key is set to NTP." +) +DESC_DEVICE_NTP_FROM_DHCP = ( + "Indicate if NTP address information is to be retrieved using DHCP." +) +DESC_DEVICE_NTP_TYPE = ( + "Network host type: IPv4, IPv6 or DNS. Will be ignored if the " + "ntp_from_dhcp key is set to true. " +) +DESC_DEVICE_NTP_SERVER = ( + "The NTP server of the device, for example: pool.ntp.org or " + "time.google.com or 192.168.1.1 (must match with " + "ntp_type). Will be ignored if the ntp_from_dhcp " + "key is set to true. " +) + + +DESC_MEDIA = "Media service configuration." + + +DESC_IMAGING = "Imaging service configuration." +DESC_IMAGING_FORCE_PERSISTENCE = ( + "To determine whether this setting will persist even after a device reboot." +) +DESC_IMAGING_BRIGHTNESS = "Brightness of the image (unit unspecified)." +DESC_IMAGING_COLOR_SATURATION = "Color Saturation of the image (unit unspecified)." +DESC_IMAGING_CONTRAST = "Contrast of the image (unit unspecified)." +DESC_IMAGING_SHARPNESS = "Sharpness of the Video image (unit unspecified)." +DESC_IMAGING_IRCUT_FILTER = "Infrared Cutoff Filter settings." +DESC_IMAGING_BACKLIGHT_COMPENSATION = ( + "Enabled/disabled Backlight Compensation mode (on/off)." +) +DESC_IMAGING_EXPOSURE = "Exposure mode of the device." +DESC_IMAGING_FOCUS = "Focus configuration." +DESC_IMAGING_WIDE_DYNAMIC_RANGE = "Wide dynamic range settings." +DESC_IMAGING_WHITE_BALANCE = "White balance settings." +DESC_IMAGING_IMAGE_STABILIZATION = ( + "Optional element to configure Image Stabilization feature." +) +DESC_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT = ( + "An optional parameter applied to only auto mode to adjust timing of toggling " + "Infrared Cutoff filter." +) +DESC_IMAGING_TONE_COMPENSATION = ( + "Optional element to configure Image Contrast Compensation." +) +DESC_IMAGING_DEFOGGING = "Optional element to configure Image Defogging." +DESC_IMAGING_NOISE_REDUCTION = "Optional element to configure Image Noise Reduction." + + +DESC_PTZ = "PTZ service configuration." +DESC_PTZ_HOME_POSITION = ( + "Move camera to home position on startup (if supported by camera). Will " + "be ignored if any of the PTZ presets have the on_startup" + " set to true." +) +DESC_PTZ_REVERSE_PAN = ( + "Reverse the pan direction. Will be implemented in backend and frontend, " + "and will not affect the position of user defined PTZ presets." +) +DESC_PTZ_REVERSE_TILT = ( + "Reverse the tilt direction. Will be implemented in backend and frontend, " + "and will not affect the position of user defined PTZ presets." +) +DESC_PTZ_MIN_PAN = ( + "Minimum pan value of the camera. A value between -1.0 and 1.0 (will be adjusted " + "based on the default ONVIF configuration). Automatically handled by the backend." +) +DESC_PTZ_MAX_PAN = ( + "Maximum pan value of the camera. A value between -1.0 and 1.0 (will be adjusted " + "based on the default ONVIF configuration). Automatically handled by the backend." +) +DESC_PTZ_MIN_TILT = ( + "Minimum tilt value of the camera. A value between -1.0 and 1.0 (will be adjusted " + "based on the default ONVIF configuration). Automatically handled by the backend." +) +DESC_PTZ_MAX_TILT = ( + "Maximum tilt value of the camera. A value between -1.0 and 1.0 (will be adjusted " + "based on the default ONVIF configuration). Automatically handled by the backend." +) +DESC_PTZ_MIN_ZOOM = ( + "Minimum zoom value of the camera. A value between -1.0 and 1.0 (will be adjusted " + "based on the default ONVIF configuration). Automatically handled by the backend." +) +DESC_PTZ_MAX_ZOOM = ( + "Maximum zoom value of the camera. A value between -1.0 and 1.0 (will be adjusted " + "based on the default ONVIF configuration). Automatically handled by the backend." +) +DESC_PTZ_PRESETS = ( + "A list of user-defined PTZ presets (using the Absolute Move operation if" + " supported by camera). These presets will not be saved to the ONVIF " + "camera." +) +DESC_PTZ_PRESET_NAME = "Name of the PTZ preset." +DESC_PTZ_PRESET_PAN = "Pan value of the PTZ preset. A value between -1.0 and 1.0." +DESC_PTZ_PRESET_TILT = "Tilt value of the PTZ preset. A value between -1.0 and 1.0." +DESC_PTZ_PRESET_ZOOM = "Zoom value of the PTZ preset. A value between -1.0 and 1.0" +DESC_PTZ_PRESET_ON_STARTUP = "Move to this (named) preset on startup." diff --git a/viseron/components/onvif/device.py b/viseron/components/onvif/device.py new file mode 100644 index 000000000..df4f2bef6 --- /dev/null +++ b/viseron/components/onvif/device.py @@ -0,0 +1,280 @@ +"""Device (Core) service management for ONVIF component.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from onvif import ONVIFClient + +from .const import ( + CONFIG_DEVICE_DATETIME_TYPE, + CONFIG_DEVICE_DAYLIGHT_SAVINGS, + CONFIG_DEVICE_DISCOVERABLE, + CONFIG_DEVICE_HOSTNAME, + CONFIG_DEVICE_NTP_FROM_DHCP, + CONFIG_DEVICE_NTP_SERVER, + CONFIG_DEVICE_NTP_TYPE, + CONFIG_DEVICE_TIMEZONE, +) +from .utils import operation + +if TYPE_CHECKING: + from viseron.domains.camera import AbstractCamera + +LOGGER = logging.getLogger(__name__) + + +class Device: + """Class for managing Device (Core) operations for an ONVIF camera.""" + + def __init__( + self, + camera: AbstractCamera, + client: ONVIFClient, + config: dict[str, Any], + auto_config: bool = True, + ) -> None: + self._camera = camera + self._client = client + self._config = config + self._auto_config = auto_config + self._device_service: Any = None + + async def initialize(self) -> None: + """Initialize the Device/Core service.""" + self._device_service = self._client.devicemgmt() + + if not self._auto_config and self._config: + await self.apply_config() + + # ## The Real Operations ## # + + # ---- System Operations ---- # + + @operation() + async def get_capabilities(self) -> Any: + """Get device capabilities.""" + return self._device_service.GetCapabilities(Category="All") + + @operation() + async def get_services(self) -> Any: + """Get available services on the device.""" + return self._device_service.GetServices(IncludeCapability=False) + + @operation() + async def get_device_information(self) -> Any: + """Get device information.""" + return self._device_service.GetDeviceInformation() + + @operation() + async def get_discovery_mode(self) -> Any: + """Get discovery mode.""" + return self._device_service.GetDiscoveryMode() + + @operation() + async def set_discovery_mode(self, discoverable: bool | None = None) -> bool: + """Set discovery mode.""" + if not self._auto_config and discoverable is None: + discoverable = self._config.get(CONFIG_DEVICE_DISCOVERABLE, True) + + mode = "Discoverable" if discoverable else "NonDiscoverable" + self._device_service.SetDiscoveryMode(DiscoveryMode=mode) + + return True + + @operation() + async def get_scopes(self) -> Any: + """Get device scopes.""" + return self._device_service.GetScopes() + + @operation() + async def add_scopes(self, scopes: list[str]) -> bool: + """Add device scopes.""" + self._device_service.AddScopes(Scopes=scopes) + return True + + @operation() + async def set_scopes(self, scopes: list[str]) -> bool: + """Set device scopes.""" + self._device_service.SetScopes(Scopes=scopes) + return True + + @operation() + async def remove_scopes(self, scopes: list[str]) -> bool: + """Remove device scopes.""" + self._device_service.RemoveScopes(Scopes=scopes) + return True + + @operation() + async def system_reboot(self) -> bool: + """Reboot the device.""" + LOGGER.warning(f"Rebooting ONVIF camera for {self._camera.identifier}") + self._device_service.SystemReboot() + + return True + + # ---- Date & Time Operations ---- # + + @operation() + async def get_system_date_and_time(self) -> Any: + """Get system date and time from the device.""" + return self._device_service.GetSystemDateAndTime() + + @operation() + async def set_system_date_and_time( + self, + datetime_type: str = "NTP", + daylight_savings: bool | None = None, + timezone: str | None = None, + ) -> bool: + """Set system date and time.""" + daylight_savings = daylight_savings or self._config.get( + CONFIG_DEVICE_DAYLIGHT_SAVINGS + ) + + # Timezone is ignored if datetime_type is NTP + timezone_param = None + if datetime_type != "NTP": + timezone = timezone or self._config.get(CONFIG_DEVICE_TIMEZONE) + timezone_param = {"TZ": timezone} if timezone else None + + self._device_service.SetSystemDateAndTime( + DateTimeType=datetime_type, + DaylightSavings=daylight_savings, + TimeZone=timezone_param, + ) + + return True + + # ---- Security Operations ---- # + + @operation() + async def get_users(self) -> Any: + """Get device users.""" + return self._device_service.GetUsers() + + @operation() + async def create_users(self, user: dict[str, Any]) -> Any: + """Create device users.""" + return self._device_service.CreateUsers(User=user) + + @operation() + async def delete_users(self, usernames: list[str]) -> bool: + """Delete device users.""" + self._device_service.DeleteUsers(Usernames=usernames) + return True + + @operation() + async def set_user(self, user: dict[str, Any]) -> bool: + """Set device user.""" + self._device_service.SetUser(User=user) + return True + + # ---- Network Operations ---- # + + @operation() + async def get_hostname(self) -> Any: + """Get device hostname.""" + return self._device_service.GetHostname() + + @operation() + async def set_hostname(self, hostname: str | None = None) -> Any: + """Set device hostname.""" + self._device_service.SetHostname(Name=hostname) + return True + + @operation() + async def get_ntp(self) -> Any: + """Get NTP configuration.""" + return self._device_service.GetNTP() + + @operation() + async def set_ntp( + self, + ntp_server: str | None = None, + from_dhcp: bool | None = None, + ntp_type: str | None = None, + ) -> bool: + """Set NTP configuration.""" + if not self._auto_config and from_dhcp is None: + from_dhcp = self._config.get(CONFIG_DEVICE_NTP_FROM_DHCP, False) + + ntp_manual = None + if not from_dhcp: + if not self._auto_config and ntp_server is None: + ntp_server = self._config.get(CONFIG_DEVICE_NTP_SERVER) + if ntp_server: + match ntp_type: + case "DNS": + ntp_manual = {"Type": ntp_type, "DNSname": ntp_server} + case "IPv4": + ntp_manual = {"Type": ntp_type, "IPv4Address": ntp_server} + case "IPv6": + ntp_manual = {"Type": ntp_type, "IPv6Address": ntp_server} + case _: + return False + + self._device_service.SetNTP(FromDHCP=from_dhcp, NTPManual=ntp_manual) + + return True + + @operation() + async def get_network_default_gateway(self) -> Any: + """Get network interfaces.""" + return self._device_service.GetNetworkDefaultGateway() + + @operation() + async def get_network_interfaces(self) -> Any: + """Get network interfaces.""" + return self._device_service.GetNetworkInterfaces() + + @operation() + async def get_network_protocols(self) -> Any: + """Get network protocols.""" + return self._device_service.GetNetworkProtocols() + + @operation() + async def get_dns(self) -> Any: + """Get network DNS.""" + return self._device_service.GetDNS() + + # ## Apply Configuration at Startup ## # + + async def apply_config(self) -> bool: + """Apply all configured device settings from config.""" + try: + if CONFIG_DEVICE_DISCOVERABLE in self._config: + await self.set_discovery_mode(self._config[CONFIG_DEVICE_DISCOVERABLE]) + + if CONFIG_DEVICE_HOSTNAME in self._config: + await self.set_hostname(self._config[CONFIG_DEVICE_HOSTNAME]) + + ntp_server = self._config.get(CONFIG_DEVICE_NTP_SERVER) + ntp_from_dhcp = self._config.get(CONFIG_DEVICE_NTP_FROM_DHCP) + ntp_type = self._config.get(CONFIG_DEVICE_NTP_TYPE) + if ntp_server or ntp_from_dhcp is not None: + await self.set_ntp( + ntp_server=ntp_server, from_dhcp=ntp_from_dhcp, ntp_type=ntp_type + ) + + datetime_type = self._config.get(CONFIG_DEVICE_DATETIME_TYPE) + daylight_savings = self._config.get(CONFIG_DEVICE_DAYLIGHT_SAVINGS) + timezone = self._config.get(CONFIG_DEVICE_TIMEZONE) + if datetime_type or timezone or daylight_savings is not None: + await self.set_system_date_and_time( + datetime_type=datetime_type, + daylight_savings=daylight_savings, + timezone=timezone, + ) + + LOGGER.info( + f"Device service configuration for {self._camera.identifier} " + f"has been applied." + ) + except (ValueError, AttributeError) as error: + LOGGER.error( + f"Error applying Device service configuration for " + f"{self._camera.identifier}: {error}" + ) + return False + return True diff --git a/viseron/components/onvif/imaging.py b/viseron/components/onvif/imaging.py new file mode 100644 index 000000000..7df8386a5 --- /dev/null +++ b/viseron/components/onvif/imaging.py @@ -0,0 +1,525 @@ +"""Imaging service management for ONVIF component.""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +from onvif import ONVIFClient + +from .const import ( + CONFIG_IMAGING_BACKLIGHT_COMPENSATION, + CONFIG_IMAGING_BRIGHTNESS, + CONFIG_IMAGING_COLOR_SATURATION, + CONFIG_IMAGING_CONTRAST, + CONFIG_IMAGING_DEFOGGING, + CONFIG_IMAGING_EXPOSURE, + CONFIG_IMAGING_FOCUS, + CONFIG_IMAGING_FORCE_PERSISTENCE, + CONFIG_IMAGING_IMAGE_STABILIZATION, + CONFIG_IMAGING_IRCUT_FILTER, + CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT, + CONFIG_IMAGING_NOISE_REDUCTION, + CONFIG_IMAGING_SHARPNESS, + CONFIG_IMAGING_TONE_COMPENSATION, + CONFIG_IMAGING_WHITE_BALANCE, + CONFIG_IMAGING_WIDE_DYNAMIC_RANGE, + DEFAULT_IMAGING_FORCE_PERSISTENCE, +) +from .utils import find_matching_profile_token, operation + +if TYPE_CHECKING: + from viseron.domains.camera import AbstractCamera + +LOGGER = logging.getLogger(__name__) + + +class Imaging: + """Class for managing Imaging operations for an ONVIF camera.""" + + def __init__( + self, + camera: AbstractCamera, + client: ONVIFClient, + config: dict[str, Any], + auto_config: bool = True, + media_service: Any = None, + ) -> None: + self._camera = camera + self._client = client + self._config = config + self._auto_config = auto_config + self._media_service = ( + media_service # you can't use imaging without media service + ) + self._imaging_service: Any = None + self._video_source_token: str | None = None + + async def initialize(self) -> None: + """Initialize the Imaging service.""" + + self._imaging_service = self._client.imaging() + + profiles = self._media_service.get_cached_profiles() + if profiles: + # Try to find matching profile based on camera's RTSP URL + matching_profile = await find_matching_profile_token( + self._camera, self._media_service, profiles + ) + if matching_profile: + self._video_source_token = ( + matching_profile.VideoSourceConfiguration.SourceToken + ) + LOGGER.debug( + f"Using matching profile {matching_profile.token} for " + f"Imaging service on camera {self._camera.identifier}" + ) + else: + # Fallback to first profile + self._video_source_token = profiles[ + 0 + ].VideoSourceConfiguration.SourceToken + LOGGER.warning( + f"No matching profile found, using first profile for " + f"Imaging service on camera {self._camera.identifier}" + ) + else: + LOGGER.warning( + f"No media profiles found for {self._camera.identifier}, " + "Imaging operations may not work correctly" + ) + + if not self._auto_config and self._config: + await self.apply_config() + + # ## Helper methods ## # + + def _nested_dict(self): + return defaultdict(self._nested_dict) + + def _has_data(self, d): + return any( + isinstance(v, dict) and self._has_data(v) or v is not None + for v in d.values() + ) + + def _to_dict(self, obj): + if isinstance(obj, dict): + return {k: self._to_dict(v) for k, v in obj.items()} + return obj + + def _snake_to_camel(self, s: str) -> str: + if "_" in s: + return "".join(word.capitalize() for word in s.split("_")) + return s[0].upper() + s[1:] if s else s + + def _convert_keys_to_camel(self, obj): + if isinstance(obj, dict): + return { + self._snake_to_camel(k): self._convert_keys_to_camel(v) + for k, v in obj.items() + } + + if isinstance(obj, list): + return [self._convert_keys_to_camel(item) for item in obj] + + return obj + + # ## The Real Operations ## # + + # ---- Settings Operations ---- # + + @operation() + async def get_options(self) -> Any: + """Get available imaging options.""" + return self._imaging_service.GetOptions( + VideoSourceToken=self._video_source_token + ) + + @operation() + async def get_imaging_settings(self) -> Any: + """Get current imaging settings.""" + return self._imaging_service.GetImagingSettings( + VideoSourceToken=self._video_source_token, + ) + + @operation() + async def set_imaging_settings( + self, settings: dict[str, Any], force_persistence: bool = True + ) -> bool: + """Set imaging settings.""" + self._imaging_service.SetImagingSettings( + VideoSourceToken=self._video_source_token, + ImagingSettings=self._convert_keys_to_camel(self._to_dict(settings)), + ForcePersistence=force_persistence, + ) + return True + + # ---- Focus Operations ---- # + + @operation() + async def get_move_options(self) -> Any: + """Get available move options.""" + return self._imaging_service.GetMoveOptions( + VideoSourceToken=self._video_source_token + ) + + @operation() + async def get_status(self) -> bool: + """Get focus movement status.""" + self._imaging_service.GetStatus( + VideoSourceToken=self._video_source_token, + ) + return True + + @operation() + async def move_focus(self, move_config: dict[str, Any]) -> bool: + """Move focus continuously or relatively.""" + self._imaging_service.Move( + VideoSourceToken=self._video_source_token, + Focus=move_config, + ) + return True + + @operation() + async def stop_focus(self) -> bool: + """Stop focus movement.""" + self._imaging_service.Stop( + VideoSourceToken=self._video_source_token, + ) + return True + + # ## Derived operations ## # + + async def set_brightness( + self, force_persistence: bool, brightness: float | None = None + ) -> bool: + """Set brightness level.""" + if not self._auto_config and brightness is None: + brightness = self._config.get(CONFIG_IMAGING_BRIGHTNESS) + + if brightness is not None: + return await self.set_imaging_settings( + {"Brightness": brightness}, force_persistence + ) + + return False + + async def set_color_saturation( + self, force_persistence: bool, saturation: float | None = None + ) -> bool: + """Set color saturation level.""" + if not self._auto_config and saturation is None: + saturation = self._config.get(CONFIG_IMAGING_COLOR_SATURATION) + + if saturation is not None: + return await self.set_imaging_settings( + {"ColorSaturation": saturation}, force_persistence + ) + + return False + + async def set_contrast( + self, force_persistence: bool, contrast: float | None = None + ) -> bool: + """Set contrast level.""" + if not self._auto_config and contrast is None: + contrast = self._config.get(CONFIG_IMAGING_CONTRAST) + + if contrast is not None: + return await self.set_imaging_settings( + {"Contrast": contrast}, force_persistence + ) + + return False + + async def set_sharpness( + self, force_persistence: bool, sharpness: float | None = None + ) -> bool: + """Set sharpness level.""" + if not self._auto_config and sharpness is None: + sharpness = self._config.get(CONFIG_IMAGING_SHARPNESS) + + if sharpness is not None: + return await self.set_imaging_settings( + {"Sharpness": sharpness}, force_persistence + ) + + return False + + async def set_ircut_filter( + self, force_persistence: bool, ircut_filter: str | None = None + ) -> bool: + """Set IR cut filter mode.""" + if not self._auto_config and ircut_filter is None: + ircut_filter = self._config.get(CONFIG_IMAGING_IRCUT_FILTER) + + if ircut_filter is not None: + return await self.set_imaging_settings( + {"IrCutFilter": ircut_filter}, force_persistence + ) + + return False + + async def set_backlight_compensation( + self, force_persistence: bool, blc_mode: str | None = None + ) -> bool: + """Set backlight compensation settings.""" + if not self._auto_config and blc_mode is None: + blc_mode = self._config.get(CONFIG_IMAGING_BACKLIGHT_COMPENSATION) + + if blc_mode is not None: + return await self.set_imaging_settings( + {"BacklightCompensation": {"Mode": blc_mode}}, force_persistence + ) + + return False + + async def set_exposure( + self, force_persistence: bool, exposure_config: dict[str, Any] | None = None + ) -> bool: + """Set exposure settings.""" + if not self._auto_config and exposure_config is None: + exposure_config = self._config.get(CONFIG_IMAGING_EXPOSURE) + + if exposure_config is not None: + return await self.set_imaging_settings( + {"Exposure": exposure_config}, force_persistence + ) + + return False + + async def set_focus( + self, force_persistence: bool, focus_config: dict[str, Any] | None = None + ) -> bool: + """Set focus settings.""" + if not self._auto_config and focus_config is None: + focus_config = self._config.get(CONFIG_IMAGING_FOCUS) + + if focus_config is not None: + return await self.set_imaging_settings( + {"Focus": focus_config}, force_persistence + ) + + return False + + async def set_wide_dynamic_range( + self, force_persistence: bool, wdr_config: dict[str, Any] | None = None + ) -> bool: + """Set wide dynamic range settings.""" + if not self._auto_config and wdr_config is None: + wdr_config = self._config.get(CONFIG_IMAGING_WIDE_DYNAMIC_RANGE) + + if wdr_config is not None: + return await self.set_imaging_settings( + {"WideDynamicRange": wdr_config}, force_persistence + ) + + return False + + async def set_white_balance( + self, force_persistence: bool, wb_config: dict[str, Any] | None = None + ) -> bool: + """Set white balance settings.""" + if not self._auto_config and wb_config is None: + wb_config = self._config.get(CONFIG_IMAGING_WHITE_BALANCE) + + if wb_config is not None: + return await self.set_imaging_settings( + {"WhiteBalance": wb_config}, force_persistence + ) + + return False + + async def set_image_stabilization( + self, force_persistence: bool, is_config: dict[str, Any] | None = None + ) -> bool: + """Set image stabilization settings.""" + if not self._auto_config and is_config is None: + is_config = self._config.get(CONFIG_IMAGING_IMAGE_STABILIZATION) + + if is_config is not None: + return await self.set_imaging_settings( + {"Extension": {"ImageStabilization": is_config}}, force_persistence + ) + + return False + + async def set_ircut_filter_auto_adjustment( + self, force_persistence: bool, ifaa_config: dict[str, Any] | None = None + ) -> bool: + """Set ircut filter auto adjustment settings.""" + if not self._auto_config and ifaa_config is None: + ifaa_config = self._config.get(CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT) + + if ifaa_config is not None: + return await self.set_imaging_settings( + { + "Extension": { + "Extension": {"IrCutFilterAutoAdjustment": ifaa_config} + } + }, + force_persistence, + ) + + return False + + async def set_tone_compensation( + self, force_persistence: bool, tc_config: dict[str, Any] | None = None + ) -> bool: + """Set tone compensation settings.""" + if not self._auto_config and tc_config is None: + tc_config = self._config.get(CONFIG_IMAGING_TONE_COMPENSATION) + + if tc_config is not None: + return await self.set_imaging_settings( + { + "Extension": { + "Extension": {"Extension": {"ToneCompensation": tc_config}} + } + }, + force_persistence, + ) + + return False + + async def set_defogging( + self, force_persistence: bool, d_config: dict[str, Any] | None = None + ) -> bool: + """Set defogging settings.""" + if not self._auto_config and d_config is None: + d_config = self._config.get(CONFIG_IMAGING_DEFOGGING) + + if d_config is not None: + return await self.set_imaging_settings( + {"Extension": {"Extension": {"Extension": {"Defogging": d_config}}}}, + force_persistence, + ) + + return False + + async def set_noise_reduction( + self, force_persistence: bool, nr_config: dict[str, Any] | None = None + ) -> bool: + """Set noise reduction settings.""" + if not self._auto_config and nr_config is None: + nr_config = self._config.get(CONFIG_IMAGING_NOISE_REDUCTION) + + if nr_config is not None: + return await self.set_imaging_settings( + { + "Extension": { + "Extension": {"Extension": {"NoiseReduction": nr_config}} + } + }, + force_persistence, + ) + + return False + + # ## Apply Configuration at Startup ## # + + async def apply_config(self) -> bool: + """Apply all configured imaging settings from config.""" + try: + force_persistence = self._config.get( + CONFIG_IMAGING_FORCE_PERSISTENCE, DEFAULT_IMAGING_FORCE_PERSISTENCE + ) + + settings = {} + + # ---- Flat settings ---- + + if CONFIG_IMAGING_BRIGHTNESS in self._config: + settings["Brightness"] = self._config[CONFIG_IMAGING_BRIGHTNESS] + + if CONFIG_IMAGING_COLOR_SATURATION in self._config: + settings["ColorSaturation"] = self._config[ + CONFIG_IMAGING_COLOR_SATURATION + ] + + if CONFIG_IMAGING_CONTRAST in self._config: + settings["Contrast"] = self._config[CONFIG_IMAGING_CONTRAST] + + if CONFIG_IMAGING_SHARPNESS in self._config: + settings["Sharpness"] = self._config[CONFIG_IMAGING_SHARPNESS] + + if CONFIG_IMAGING_IRCUT_FILTER in self._config: + settings["IrCutFilter"] = self._config[ + CONFIG_IMAGING_IRCUT_FILTER + ].upper() + + if CONFIG_IMAGING_BACKLIGHT_COMPENSATION in self._config: + settings["BacklightCompensation"] = { + "Mode": self._config[CONFIG_IMAGING_BACKLIGHT_COMPENSATION].upper() + } + + if CONFIG_IMAGING_EXPOSURE in self._config: + settings["Exposure"] = self._config[CONFIG_IMAGING_EXPOSURE] + + if CONFIG_IMAGING_FOCUS in self._config: + settings["Focus"] = self._config[CONFIG_IMAGING_FOCUS] + + if CONFIG_IMAGING_WIDE_DYNAMIC_RANGE in self._config: + settings["WideDynamicRange"] = self._config[ + CONFIG_IMAGING_WIDE_DYNAMIC_RANGE + ] + + if CONFIG_IMAGING_WHITE_BALANCE in self._config: + settings["WhiteBalance"] = self._config[CONFIG_IMAGING_WHITE_BALANCE] + + # ---- Extensions (nested safely) ---- + + ext = self._nested_dict() + + if CONFIG_IMAGING_IMAGE_STABILIZATION in self._config: + ext["ImageStabilization"] = self._config[ + CONFIG_IMAGING_IMAGE_STABILIZATION + ] + + if CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT in self._config: + ext["Extension"]["IrCutFilterAutoAdjustment"] = self._config[ + CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT + ] + + if CONFIG_IMAGING_TONE_COMPENSATION in self._config: + ext["Extension"]["Extension"]["ToneCompensation"] = self._config[ + CONFIG_IMAGING_TONE_COMPENSATION + ] + + if CONFIG_IMAGING_DEFOGGING in self._config: + ext["Extension"]["Extension"]["Defogging"] = self._config[ + CONFIG_IMAGING_DEFOGGING + ] + + if CONFIG_IMAGING_NOISE_REDUCTION in self._config: + ext["Extension"]["Extension"]["NoiseReduction"] = self._config[ + CONFIG_IMAGING_NOISE_REDUCTION + ] + + if self._has_data(ext): + settings["Extension"] = ext + + set_imaging_settings = await self.set_imaging_settings( + settings, force_persistence + ) + + if set_imaging_settings: + LOGGER.info( + f"Imaging service configuration for {self._camera.identifier} " + f"has been applied." + ) + return True + + LOGGER.error( + f"Error applying Imaging service configuration for " + f"{self._camera.identifier}!" + ) + return False + except (ValueError, AttributeError) as error: + LOGGER.error( + f"Error applying Imaging service configuration for " + f"{self._camera.identifier}: {error}" + ) + return False diff --git a/viseron/components/onvif/media.py b/viseron/components/onvif/media.py new file mode 100644 index 000000000..7101ce188 --- /dev/null +++ b/viseron/components/onvif/media.py @@ -0,0 +1,156 @@ +"""Media service management for ONVIF component.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from onvif import ONVIFClient + +from .utils import operation + +if TYPE_CHECKING: + from viseron.domains.camera import AbstractCamera + +LOGGER = logging.getLogger(__name__) + + +class Media: + """Class for managing Media operations for an ONVIF camera.""" + + def __init__( + self, + camera: AbstractCamera, + client: ONVIFClient, + config: dict[str, Any], + auto_config: bool = True, + ) -> None: + self._camera = camera + self._client = client + self._config = config + self._auto_config = auto_config + self._media_service: Any = None + self._profiles: list[Any] = [] + + async def initialize(self) -> None: + """Initialize the Media service.""" + self._media_service = self._client.media() + + # Load media profiles + self._profiles = await self.get_profiles() + + if not self._auto_config and self._config: + await self.apply_config() + + # ## The Real Operations ## # + + @operation() + async def get_profiles(self) -> Any: + """Get media profiles.""" + return self._media_service.GetProfiles() + + @operation() + async def get_profile(self, profile_token: str) -> Any: + """Get a specific media profile.""" + return self._media_service.GetProfile(ProfileToken=profile_token) + + @operation() + async def get_stream_uri( + self, profile_token: str | None = None, stream_type: str = "RTP-Unicast" + ) -> Any: + """Get stream URI for a profile.""" + stream_setup = {"Stream": stream_type, "Transport": {"Protocol": "RTSP"}} + return self._media_service.GetStreamUri( + StreamSetup=stream_setup, ProfileToken=profile_token + ) + + @operation() + async def get_snapshot_uri(self, profile_token: str | None = None) -> Any: + """Get snapshot URI for a profile.""" + return self._media_service.GetSnapshotUri(ProfileToken=profile_token) + + @operation() + async def get_video_sources(self) -> Any: + """Get available video sources.""" + return self._media_service.GetVideoSources() + + @operation() + async def get_video_source_configurations(self) -> Any: + """Get video source configurations.""" + return self._media_service.GetVideoSourceConfigurations() + + @operation() + async def get_video_encoder_configurations(self) -> Any: + """Get video encoder configurations.""" + return self._media_service.GetVideoEncoderConfigurations() + + @operation() + async def get_audio_sources(self) -> Any: + """Get available audio sources.""" + return self._media_service.GetAudioSources() + + @operation() + async def get_audio_source_configurations(self) -> Any: + """Get audio source configurations.""" + return self._media_service.GetAudioSourceConfigurations() + + @operation() + async def get_audio_encoder_configurations(self) -> Any: + """Get audio encoder configurations.""" + return self._media_service.GetAudioEncoderConfigurations() + + @operation() + async def set_video_encoder_configuration( + self, configuration: dict[str, Any], force_persistence: bool = True + ) -> bool: + """Set video encoder configuration for a profile.""" + self._media_service.SetVideoEncoderConfiguration( + Configuration=configuration, ForcePersistence=force_persistence + ) + + return True + + @operation() + async def set_video_source_configuration( + self, configuration: dict[str, Any], force_persistence: bool = True + ) -> bool: + """Set video source configuration for a profile.""" + self._media_service.SetVideoSourceConfiguration( + Configuration=configuration, ForcePersistence=force_persistence + ) + + return True + + @operation() + async def create_profile(self, name: str, token: str | None = None) -> Any: + """Create a new media profile.""" + return self._media_service.CreateProfile(Name=name, Token=token) + + @operation() + async def delete_profile(self, profile_token: str) -> bool: + """Delete a media profile.""" + self._media_service.DeleteProfile(ProfileToken=profile_token) + + return True + + # ## Profile Accessors ## # + + def get_cached_profiles(self): + """Get cached media profiles without making ONVIF call.""" + return self._profiles + + def get_primary_profile(self): + """Get the primary (first) media profile.""" + return self._profiles[0] if self._profiles else None + + def get_profile_by_token(self, token: str): + """Get a profile by its token.""" + for profile in self._profiles: + if profile.token == token: + return profile + return None + + # ## Apply Configuration at Startup ## # + + async def apply_config(self) -> bool: + """Apply all configured device settings from config.""" + return True diff --git a/viseron/components/onvif/ptz.py b/viseron/components/onvif/ptz.py new file mode 100644 index 000000000..fb8f74e50 --- /dev/null +++ b/viseron/components/onvif/ptz.py @@ -0,0 +1,826 @@ +"""PTZ service management for ONVIF component.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +import numpy as np +from onvif import ONVIFClient + +from .const import ( + CONFIG_PTZ_HOME_POSITION, + CONFIG_PTZ_MAX_PAN, + CONFIG_PTZ_MAX_TILT, + CONFIG_PTZ_MAX_ZOOM, + CONFIG_PTZ_MIN_PAN, + CONFIG_PTZ_MIN_TILT, + CONFIG_PTZ_MIN_ZOOM, + CONFIG_PTZ_PRESET_NAME, + CONFIG_PTZ_PRESET_ON_STARTUP, + CONFIG_PTZ_PRESET_PAN, + CONFIG_PTZ_PRESET_TILT, + CONFIG_PTZ_PRESET_ZOOM, + CONFIG_PTZ_PRESETS, + CONFIG_PTZ_REVERSE_PAN, + CONFIG_PTZ_REVERSE_TILT, +) +from .utils import find_matching_profile_token, operation + +if TYPE_CHECKING: + from viseron.domains.camera import AbstractCamera + +LOGGER = logging.getLogger(__name__) + + +class PTZ: + """Class for managing PTZ operations for an ONVIF camera.""" + + def __init__( + self, + camera: AbstractCamera, + client: ONVIFClient, + config: dict[str, Any], + auto_config: bool = True, + media_service: Any = None, + ) -> None: + self._camera = camera + self._client = client + self._config = config + self._auto_config = auto_config + self._media_service = media_service # you can't use ptz without media service + self._media_profile: Any = None # selected media profile for any PTZ operations + self._ptz_service: Any = None + self._ptz_config: Any = None # to determine PTZ behaviour + self._ptz_config_options: Any = None # to determine PTZ options + self._stop_patrol_event: asyncio.Event = asyncio.Event() + + async def initialize(self) -> None: + """Initialize the PTZ service.""" + self._ptz_service = self._client.ptz() + + if self._media_service is None: + LOGGER.warning( + f"Media service not available for {self._camera.identifier}, " + "PTZ operations may not work correctly" + ) + return + + profiles = self._media_service.get_cached_profiles() + if profiles: + # Try to find matching profile based on camera's RTSP URL + self._media_profile = await find_matching_profile_token( + self._camera, self._media_service, profiles + ) + + if self._media_profile: + LOGGER.debug( + f"Using matching profile {self._media_profile.token} for " + f"PTZ service on camera {self._camera.identifier}" + ) + else: + # Fallback to first profile + self._media_profile = profiles[0] + LOGGER.warning( + f"No matching profile found, using first profile for " + f"PTZ service on camera {self._camera.identifier}" + ) + + self._ptz_config = await self.get_configurations() + self._ptz_config_options = await self.get_configuration_options() + else: + LOGGER.warning( + f"No media profiles found for {self._camera.identifier}, " + "PTZ operations may not work correctly" + ) + + if not self._auto_config and self._config: + await self.apply_config() + + # ## Helper methods ## # + + def _adjust_pan_tilt(self, pan: float, tilt: float) -> tuple[float, float]: + """Adjust pan/tilt values based on reverse settings.""" + if self._auto_config: + return pan, tilt + + adjusted_pan = -pan if self._config.get(CONFIG_PTZ_REVERSE_PAN) else pan + adjusted_tilt = -tilt if self._config.get(CONFIG_PTZ_REVERSE_TILT) else tilt + return adjusted_pan, adjusted_tilt + + def _clamp_position(self, pan: float, tilt: float) -> tuple[float, float]: + """Clamp pan/tilt values within configured limits.""" + if self._auto_config: + return pan, tilt + + min_pan = self._config.get(CONFIG_PTZ_MIN_PAN) + max_pan = self._config.get(CONFIG_PTZ_MAX_PAN) + min_tilt = self._config.get(CONFIG_PTZ_MIN_TILT) + max_tilt = self._config.get(CONFIG_PTZ_MAX_TILT) + + if min_pan is not None: + pan = max(pan, min_pan) + if max_pan is not None: + pan = min(pan, max_pan) + if min_tilt is not None: + tilt = max(tilt, min_tilt) + if max_tilt is not None: + tilt = min(tilt, max_tilt) + + return pan, tilt + + def _clamp_zoom(self, zoom: float) -> float: + """Clamp zoom value within configured limits.""" + if self._auto_config: + return zoom + + min_zoom = self._config.get(CONFIG_PTZ_MIN_ZOOM) + max_zoom = self._config.get(CONFIG_PTZ_MAX_ZOOM) + + if min_zoom is not None: + zoom = max(zoom, min_zoom) + if max_zoom is not None: + zoom = min(zoom, max_zoom) + + return zoom + + async def _fire_and_forget(self, coro, timeout, *args, **kwargs): + """Fire and forget a coroutine with a timeout.""" + coro_task = asyncio.create_task(coro(*args, **kwargs)) + if timeout > 0: + asyncio.create_task(self._timeout_task(coro_task, timeout)) + + async def _timeout_task(self, task, timeout): + """Cancel a task after a set amount of time if given.""" + await asyncio.sleep(timeout) + if not task.done(): + task.cancel() + + # ## The Real Operations ## # + + # ---- Movement Operations ---- # + + @operation() + async def continuous_move( + self, + x_velocity: float, + y_velocity: float, + zoom_velocity: float = 0.0, + seconds: float = 0.0, + ) -> bool: + """Move the camera continuously for a set amount of time.""" + adjusted_x, adjusted_y = self._adjust_pan_tilt(x_velocity, y_velocity) + clamped_x, clamped_y = self._clamp_position(adjusted_x, adjusted_y) + clamped_zoom = self._clamp_zoom(zoom_velocity) + + velocity = {"PanTilt": {"x": clamped_x, "y": clamped_y}} + + # Only add zoom param if supported + if self._ptz_config_options.Spaces.ContinuousZoomVelocitySpace: + velocity["Zoom"] = {"x": clamped_zoom} + + # Change seconds in ISO 8601 + timeout = f"PT{seconds}S" + + self._ptz_service.ContinuousMove( + ProfileToken=self._media_profile.token, + Velocity=velocity, + Timeout=timeout if seconds > 0 else None, + ) + return True + + @operation() + async def relative_move( + self, + x_translation: float, + y_translation: float, + zoom_translation: float = 0.0, + x_speed: float | None = None, + y_speed: float | None = None, + zoom_speed: float | None = None, + ) -> bool: + """Move the camera relative to its current position.""" + adjusted_x, adjusted_y = self._adjust_pan_tilt(x_translation, y_translation) + clamped_x, clamped_y = self._clamp_position(adjusted_x, adjusted_y) + clamped_zoom = self._clamp_zoom(zoom_translation) + + translation = {"PanTilt": {"x": clamped_x, "y": clamped_y}} + default_speed = self._ptz_config[0].DefaultPTZSpeed + speed: dict | None = None + + if x_speed is not None or y_speed is not None: + speed = { + "PanTilt": { + "x": x_speed if x_speed is not None else default_speed.PanTilt.x, + "y": y_speed if y_speed is not None else default_speed.PanTilt.y, + } + } + + # Only add zoom param if supported + if self._ptz_config_options.Spaces.RelativeZoomTranslationSpace: + translation["Zoom"] = {"x": clamped_zoom} + + if speed is None: + speed = {} + + if zoom_speed is not None: + speed["Zoom"] = {"x": zoom_speed} + elif default_speed.Zoom is not None: + speed["Zoom"] = {"x": default_speed.Zoom.x} + + self._ptz_service.RelativeMove( + ProfileToken=self._media_profile.token, + Translation=translation, + Speed=speed, + ) + return True + + @operation() + async def absolute_move( + self, + x_position: float, + y_position: float, + zoom_position: float = 0.0, + x_speed: float | None = None, + y_speed: float | None = None, + zoom_speed: float | None = None, + is_adjusted: bool = True, # Change to False for user-defined presets + ) -> bool: + """Move the camera to an absolute position.""" + if is_adjusted: + adjusted_x, adjusted_y = self._adjust_pan_tilt(x_position, y_position) + else: + adjusted_x, adjusted_y = x_position, y_position + + clamped_x, clamped_y = self._clamp_position(adjusted_x, adjusted_y) + clamped_zoom = self._clamp_zoom(zoom_position) + + position = {"PanTilt": {"x": clamped_x, "y": clamped_y}} + default_speed = self._ptz_config[0].DefaultPTZSpeed + speed: dict | None = None + + if x_speed is not None or y_speed is not None: + speed = { + "PanTilt": { + "x": x_speed if x_speed is not None else default_speed.PanTilt.x, + "y": y_speed if y_speed is not None else default_speed.PanTilt.y, + } + } + + # Only add zoom param if supported + if self._ptz_config_options.Spaces.AbsoluteZoomPositionSpace: + position["Zoom"] = {"x": clamped_zoom} + + if speed is None: + speed = {} + + if zoom_speed is not None: + speed["Zoom"] = {"x": zoom_speed} + elif default_speed.Zoom is not None: + speed["Zoom"] = {"x": default_speed.Zoom.x} + + self._ptz_service.AbsoluteMove( + ProfileToken=self._media_profile.token, + Position=position, + Speed=speed, + ) + return True + + @operation() + async def stop(self) -> bool: + """Stop any ongoing PTZ movement.""" + self._ptz_service.Stop(ProfileToken=self._media_profile.token) + return True + + # ---- Position Operations ---- # + + @operation() + async def go_home_position(self) -> bool: + """Move the camera to its home position.""" + self._ptz_service.GotoHomePosition(ProfileToken=self._media_profile.token) + return True + + @operation() + async def set_home_position(self) -> bool: + """Set the current position as the home position.""" + self._ptz_service.SetHomePosition(ProfileToken=self._media_profile.token) + return True + + @operation() + async def get_status(self) -> Any: + """Get the PTZ status of the camera.""" + return self._ptz_service.GetStatus(ProfileToken=self._media_profile.token) + + @operation() + async def get_presets(self) -> Any: + """Get the PTZ presets of the camera.""" + return self._ptz_service.GetPresets(ProfileToken=self._media_profile.token) + + @operation() + async def goto_preset(self, preset_token: str) -> bool: + """Move the camera to the specified preset.""" + self._ptz_service.GotoPreset( + ProfileToken=self._media_profile.token, + PresetToken=preset_token, + ) + return True + + @operation() + async def set_preset(self, preset_name: str) -> bool: + """Set a preset at the current position with the given name.""" + return self._ptz_service.SetPreset( + ProfileToken=self._media_profile.token, + PresetName=preset_name, + ) + + @operation() + async def remove_preset(self, preset_token: str) -> bool: + """Remove the specified preset.""" + self._ptz_service.RemovePreset( + ProfileToken=self._media_profile.token, + PresetToken=preset_token, + ) + return True + + # ---- Configuration Operations ---- # + + @operation() + async def get_nodes(self) -> Any: + """Get the PTZ nodes of the camera.""" + return self._ptz_service.GetNodes() + + @operation() + async def get_configurations(self) -> Any: + """Get the PTZ configurations of the camera.""" + return self._ptz_service.GetConfigurations() + + @operation() + async def get_configuration_options(self) -> Any: + """Get the PTZ configuration options of the camera.""" + return self._ptz_service.GetConfigurationOptions( + ConfigurationToken=self._media_profile.PTZConfiguration.token + ) + + # ## Derived operations ## # + + def get_ptz_config(self) -> Any: + """Get PTZ service from user-defined config.""" + if not self._auto_config: + return self._config if self._config else None + return None + + def get_configured_presets(self) -> list[str]: + """Get the available presets for the camera from config.""" + if CONFIG_PTZ_PRESETS not in self._config: + LOGGER.warning(f"No PTZ presets for camera {self._camera.identifier}") + return [] + + presets = self._config[CONFIG_PTZ_PRESETS] + return list({preset[CONFIG_PTZ_PRESET_NAME] for preset in presets}) + + async def get_position(self) -> tuple[float, float, float]: + """Get the current position of the camera.""" + status = await self.get_status() + if status and status.Position is not None: + return ( + status.Position.PanTilt.x, + status.Position.PanTilt.y, + status.Position.Zoom.x if status.Position.Zoom else 0.0, + ) + + LOGGER.warning(f"Could not get PTZ status for camera {self._camera.identifier}") + return 0.0, 0.0, 0.0 + + async def pan_left(self, step_size: float = 0.1) -> bool: + """Pan the camera to the left.""" + do_relative_move = await self.relative_move( + x_translation=-step_size, y_translation=0.0 + ) + + if not do_relative_move: + return await self.continuous_move( + x_velocity=-step_size, y_velocity=0.0, seconds=1 + ) + + return do_relative_move + + async def pan_right(self, step_size: float = 0.1) -> bool: + """Pan the camera to the right.""" + do_relative_move = await self.relative_move( + x_translation=step_size, y_translation=0.0 + ) + + if not do_relative_move: + return await self.continuous_move( + x_velocity=step_size, y_velocity=0.0, seconds=1 + ) + + return do_relative_move + + async def tilt_up(self, step_size: float = 0.1) -> bool: + """Tilt the camera up.""" + do_relative_move = await self.relative_move( + x_translation=0.0, y_translation=step_size + ) + + if not do_relative_move: + return await self.continuous_move( + x_velocity=0.0, y_velocity=step_size, seconds=1 + ) + + return do_relative_move + + async def tilt_down(self, step_size: float = 0.1) -> bool: + """Tilt the camera down.""" + do_relative_move = await self.relative_move( + x_translation=0.0, y_translation=-step_size + ) + + if not do_relative_move: + return await self.continuous_move( + x_velocity=0.0, y_velocity=-step_size, seconds=1 + ) + + return do_relative_move + + async def zoom_in(self, step_size: float = 0.1) -> bool: + """Zoom the camera in.""" + do_relative_move = await self.relative_move( + x_translation=0.0, y_translation=0.0, zoom_translation=step_size + ) + + if not do_relative_move: + return await self.continuous_move( + x_velocity=0.0, y_velocity=0.0, zoom_velocity=step_size, seconds=1 + ) + + return do_relative_move + + async def zoom_out(self, step_size: float = 0.1) -> bool: + """Zoom the camera out.""" + do_relative_move = await self.relative_move( + x_translation=0.0, y_translation=0.0, zoom_translation=-step_size + ) + + if not do_relative_move: + return await self.continuous_move( + x_velocity=0.0, y_velocity=0.0, zoom_velocity=-step_size, seconds=1 + ) + + return do_relative_move + + # This operations moves the camera to a preset defined in the config and built-in + # presets (based on name, and token as fallback) if not found in the config. + async def move_to_preset(self, preset_name: str) -> bool: + """Move the camera to a preset position.""" + if CONFIG_PTZ_PRESETS not in self._config: + LOGGER.error(f"No PTZ presets for camera {self._camera.identifier}") + return False + + if not any( + preset[CONFIG_PTZ_PRESET_NAME] == preset_name + for preset in self._config[CONFIG_PTZ_PRESETS] + ): + # Try to find preset from camera's own presets + cam_presets = await self.get_presets() + for cam_preset in cam_presets: + if cam_preset.Name == preset_name: + return await self.goto_preset(cam_preset.token) + if cam_preset.token == preset_name: + return await self.goto_preset(cam_preset.token) + LOGGER.error( + f"PTZ preset {preset_name} not found for camera " + f"{self._camera.identifier}" + ) + return False + + presets = self._config[CONFIG_PTZ_PRESETS] + for preset in presets: + if preset[CONFIG_PTZ_PRESET_NAME] == preset_name: + return await self.absolute_move( + x_position=preset[CONFIG_PTZ_PRESET_PAN], + y_position=preset[CONFIG_PTZ_PRESET_TILT], + zoom_position=preset[CONFIG_PTZ_PRESET_ZOOM] + if CONFIG_PTZ_PRESET_ZOOM in preset + else 0.0, + is_adjusted=False, + ) + return False + + async def absolute_move_wait_complete( + self, pan: float, tilt: float, timeout: float = 1.0, zoom: float = 0.0 + ) -> bool: + """Move the camera to an absolute position and wait for completion.""" + if not await self.absolute_move( + x_position=pan, y_position=tilt, zoom_position=zoom + ): + return False + + tolerance = 0.005 + start_time = asyncio.get_event_loop().time() + + while asyncio.get_event_loop().time() - start_time < timeout: + status = await self.get_status() + if not status or not status.Position or not status.Position.PanTilt: + await asyncio.sleep(0.1) + continue + + if ( + abs(status.Position.PanTilt.x - pan) <= tolerance + and abs(status.Position.PanTilt.y - tilt) <= tolerance + ): + LOGGER.debug( + "Position at end of abs move and wait (requested: %s): %s", + (pan, tilt), + status, + ) + return True + + await asyncio.sleep(0.1) + + LOGGER.debug( + "Timeout waiting for PTZ move to (%s, %s)", + pan, + tilt, + ) + return False + + async def patrol( + self, + duration: int = 60, + sleep_after_swing: int = 6, + step_size: float = 0.3, + step_sleep_time: float = 0.1, + ) -> None: + """Perform a patrol of the camera. + + Args: + duration: Duration of the patrol in seconds + sleep_after_swing: Time to sleep after each swing + step_size: Size of each movement step + step_sleep_time: Time to sleep between movement steps + """ + if self._stop_patrol_event is not None: + self._stop_patrol_event.clear() + + await self._fire_and_forget( + self._do_patrol, + duration, + sleep_after_swing=sleep_after_swing, + step_size=step_size, + step_sleep_time=step_sleep_time, + ) + + async def _do_patrol( + self, + step_size: float = 0.3, + step_sleep_time: float = 0.1, + sleep_after_swing=6, + ): + """Perform a patrol of the camera.""" + try: + # Get and store starting position + status = await self.get_status() + if status is None or status.Position is None: + LOGGER.warning("Cannot determine starting position") + initial_pan = 0.0 + initial_tilt = 0.0 + else: + initial_pan = status.Position.PanTilt.x + initial_tilt = status.Position.PanTilt.y + LOGGER.debug( + f"Camera position at start: x: {initial_pan}, y: {initial_tilt}" + ) + + # Get the camera's FOV limits, if any + min_pan = self._config.get(CONFIG_PTZ_MIN_PAN) + max_pan = self._config.get(CONFIG_PTZ_MAX_PAN) + min_tilt = self._config.get(CONFIG_PTZ_MIN_TILT) + max_tilt = self._config.get(CONFIG_PTZ_MAX_TILT) + + # Decide which direction to start swinging based on distance to limits + distance_to_min = initial_pan - min_pan if min_pan else 0 + distance_to_max = max_pan - initial_pan if max_pan else 0 + left = distance_to_min > distance_to_max + + # Swing back and forth until stopped + while not self._stop_patrol_event.is_set(): + await self.full_swing( + is_left=left, + step_size=step_size, + step_sleep_time=step_sleep_time, + min_pan=min_pan, + max_pan=max_pan, + min_tilt=min_tilt, + max_tilt=max_tilt, + ) + if self._stop_patrol_event.is_set(): + break + await asyncio.sleep(sleep_after_swing) + left = not left + + finally: + # Move back to the initial position + await self.absolute_move(x_position=initial_pan, y_position=initial_tilt) + + async def full_swing( + self, + is_left: bool = True, + step_size: float = 0.3, + step_sleep_time: float = 0.1, + min_pan: float | None = None, + max_pan: float | None = None, + min_tilt: float | None = None, + max_tilt: float | None = None, + ): + """Perform a full swing in the pan direction. + + Args: + is_left: True if the swing is to the left, False if to the right + step_size: The size of each move step + step_sleep_time: Time to sleep between each move step + min_pan: Minimum pan value to stop at + max_pan: Maximum pan value to stop at + min_tilt: Minimum tilt value (for validation) + max_tilt: Maximum tilt value (for validation) + """ + if not self._ptz_service: + LOGGER.error( + f"PTZ service not initialized for camera {self._camera.identifier}" + ) + return + + status = await self.get_status() + cur_pan = status.Position.PanTilt.x + cur_tilt = status.Position.PanTilt.y + LOGGER.debug( + f"Fullswing start: pan: {cur_pan}, tilt: {cur_tilt}, " + f"limits: pan[{min_pan}, {max_pan}], tilt[{min_tilt}, {max_tilt}]" + ) + + move_step = -abs(step_size) if is_left else abs(step_size) + + # Do not move beyond the camera's FOV bounds + if is_left: + if min_pan is not None and cur_pan + move_step <= min_pan: + return + else: + if max_pan is not None and cur_pan + move_step >= max_pan: + return + + # Move while not stopped or stopped by the camera's FOV or hardware bounds + while ( + await self.relative_move(x_translation=move_step, y_translation=0.0) + and not self._stop_patrol_event.is_set() + ): + await asyncio.sleep(step_sleep_time) + status = await self.get_status() + cur_pan = status.Position.PanTilt.x + cur_tilt = status.Position.PanTilt.y + LOGGER.debug(f"Fullswing moved to: pan: {cur_pan}, tilt: {cur_tilt}") + if min_pan is not None and cur_pan <= min_pan: + break + if max_pan is not None and cur_pan >= max_pan: + break + + LOGGER.debug( + f"Fullswing end: pan: {cur_pan}, tilt: {cur_tilt}, " + f"limits: pan[{min_pan}, {max_pan}], tilt[{min_tilt}, {max_tilt}]" + ) + + async def lissajous_curve_patrol( + self, + pan_amp: float = 1.0, + pan_freq: float = 0.1, + tilt_amp: float = 1.0, + tilt_freq: float = 0.1, + phase_shift: float = np.pi / 2, + step_sleep_time: float = 0.1, + ): + """Perform a Lissajous curve patrol. + + Args: + pan_amp: Pan amplitude + pan_freq: Pan frequency + tilt_amp: Tilt amplitude + tilt_freq: Tilt frequency + phase_shift: Phase shift between pan and tilt + step_sleep_time: Time to sleep between movements + """ + + if self._stop_patrol_event is not None: + self._stop_patrol_event.clear() + + # Start a new patrol + await self._fire_and_forget( + coro=self._do_lissa_curve_patrol, + timeout=0, + pan_amp=pan_amp, + pan_freq=pan_freq, + tilt_amp=tilt_amp, + tilt_freq=tilt_freq, + phase_shift=phase_shift, + step_sleep_time=step_sleep_time, + ) + + async def _do_lissa_curve_patrol( + self, + pan_amp: float = 1.0, + pan_freq: float = 0.1, + tilt_amp: float = 1.0, + tilt_freq: float = 0.1, + phase_shift: float = np.pi / 2, + step_sleep_time: float = 0.1, + pan_range: tuple = (-1.0, 1.0), + tilt_range: tuple = (-1.0, 1.0), + ): + """Perform a Lissajous curve patrol.""" + try: + # Get and store starting position + status = await self.get_status() + if status is None: + LOGGER.warning("Cannot determine starting position") + initial_pan = 0.0 + initial_tilt = 0.0 + else: + initial_pan = status.Position.PanTilt.x + initial_tilt = status.Position.PanTilt.y + LOGGER.debug( + f"Camera position at start: x: {initial_pan}, y: {initial_tilt}" + ) + + pan_min, pan_max = pan_range + tilt_min, tilt_max = tilt_range + + t = 0.0 + while not self._stop_patrol_event.is_set(): + t += 1.0 + x = pan_amp * np.sin(pan_freq * t + phase_shift) + y = tilt_amp * np.sin(tilt_freq * t) + + # Scale x and y to the specified pan and tilt ranges + x = pan_min + (x + 1) * (pan_max - pan_min) / 2 + y = tilt_min + (y + 1) * (tilt_max - tilt_min) / 2 + + if self._stop_patrol_event.is_set(): + break + + await self.absolute_move_wait_complete(pan=x, tilt=y) + await asyncio.sleep(step_sleep_time) + + finally: + # Move back to the initial position + await self.absolute_move(x_position=initial_pan, y_position=initial_tilt) + + def stop_patrol(self) -> bool: + """Stop the patrol event.""" + try: + if self._stop_patrol_event: + self._stop_patrol_event.set() + # await self.stop()# It is required to stop all PTZ movements operations + return True + return False + except RuntimeError as error: + LOGGER.error(f"Error stopping patrol: {error}") + return False + + # ## Apply Configuration at Startup ## # + + async def apply_config(self) -> bool: + """Apply all configured device settings from config.""" + try: + home_position = self._config.get(CONFIG_PTZ_HOME_POSITION, False) + presets = self._config.get(CONFIG_PTZ_PRESETS, []) + has_on_startup = any( + preset.get(CONFIG_PTZ_PRESET_ON_STARTUP, False) for preset in presets + ) + + # Move to home position if configured + if home_position and not has_on_startup: + await self.go_home_position() + LOGGER.debug( + f"PTZ Go Home Position executed for {self._camera.identifier}" + ) + + # Move to startup preset if configured + if presets: + for preset in presets: + if preset.get(CONFIG_PTZ_PRESET_ON_STARTUP, False): + await self.move_to_preset(preset[CONFIG_PTZ_PRESET_NAME]) + LOGGER.debug( + f"PTZ Move to Preset " + f"{preset[CONFIG_PTZ_PRESET_NAME]} executed for " + f"{self._camera.identifier}" + ) + + LOGGER.info( + f"PTZ service configuration for {self._camera.identifier} " + f"has been applied." + ) + except (ValueError, AttributeError) as error: + LOGGER.error( + f"Error applying PTZ service configuration for " + f"{self._camera.identifier}: {error}" + ) + return False + return True diff --git a/viseron/components/onvif/utils.py b/viseron/components/onvif/utils.py new file mode 100644 index 000000000..887ea6b94 --- /dev/null +++ b/viseron/components/onvif/utils.py @@ -0,0 +1,213 @@ +"""Utility functions for ONVIF component.""" + +from __future__ import annotations + +import functools +import json +import logging +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +import zeep.helpers +from onvif import ONVIFOperationException + +from viseron.components.go2rtc.const import COMPONENT as GO2RTC_COMPONENT + +if TYPE_CHECKING: + from viseron.domains.camera import AbstractCamera + +LOGGER = logging.getLogger(__name__) + + +def to_dict(zeep_object: Any) -> dict[str, Any] | list[dict[str, Any]]: + """Convert zeep object(s) to JSON-serializable dictionary.""" + if zeep_object is None: + return {} + + # Serialize using zeep's helper + if isinstance(zeep_object, list): + serialized = [zeep.helpers.serialize_object(obj) for obj in zeep_object] + else: + serialized = zeep.helpers.serialize_object(zeep_object) + + # Convert to JSON and back to ensure full serialization + # This handles any remaining XML elements or non-serializable types + try: + return json.loads(json.dumps(serialized, default=str)) + except (TypeError, ValueError) as error: + LOGGER.warning( + f"Error serializing zeep object, using string conversion: {error}" + ) + return json.loads(json.dumps(serialized, default=str)) + + +def operation(): + """Handle any ONVIF operations.""" + + def decorator(func): + @functools.wraps(func) + async def wrapper(self, *args, **kwargs): + try: + return await func(self, *args, **kwargs) + except ( + ONVIFOperationException, # must exists ! + TypeError, + ValueError, + RuntimeError, + ) as error: + # pylint: disable=protected-access + LOGGER.error( + f"ONVIF error in '{func.__name__}' for camera " + f"{self._camera.identifier}: {error}" + ) + return False + + return wrapper + + return decorator + + +async def find_matching_profile_token( + camera: AbstractCamera, + media_service, + profiles, +) -> Any: + """Find the profile that matches the camera's RTSP URL.""" + camera_rtsp_url = extract_rtsp_from_go2rtc(camera) # prioritize go2rtc config + + if camera_rtsp_url is None: + camera_rtsp_url = build_camera_rtsp_url(camera) + + if not camera_rtsp_url: + return None + + for profile in profiles: + try: + stream_uri_result = await media_service.get_stream_uri( + profile_token=profile.token, + ) + + if stream_uri_result is False: + continue + + if hasattr(stream_uri_result, "Uri"): + profile_rtsp_url = stream_uri_result.Uri + + # Compare URLs (case insensitive, ignoring credentials) + if urls_match(camera_rtsp_url, profile_rtsp_url): + LOGGER.debug( + f"Found matching profile {profile.token} for camera " + f"{camera.identifier}" + ) + return profile + + except (AttributeError, TypeError, ValueError, RuntimeError) as error: + LOGGER.warning(f"Error processing profile {profile.token}: {error}") + continue + + return None + + +def extract_rtsp_from_go2rtc(camera: AbstractCamera) -> str | None: + """Extract RTSP URL from go2rtc configuration.""" + try: + vis = camera._vis # pylint: disable=protected-access + + if GO2RTC_COMPONENT not in vis.data: + return None + + go2rtc_component = vis.data[GO2RTC_COMPONENT] + # pylint: disable=protected-access + if not hasattr(go2rtc_component, "_config"): + return None + + if GO2RTC_COMPONENT not in go2rtc_component._config: + return None + + go2rtc_config = go2rtc_component._config[GO2RTC_COMPONENT] + + if "streams" not in go2rtc_config: + return None + streams = go2rtc_config["streams"] + + camera_id = camera.identifier + if camera_id not in streams: + return None + + stream_sources = streams[camera_id] + + if isinstance(stream_sources, list) and len(stream_sources) > 0: + for idx, source in enumerate(stream_sources): + if isinstance(source, str) and source.startswith("rtsp://"): + LOGGER.debug( + f"Found RTSP URL in go2rtc for {camera_id} at index {idx}" + ) + return source + + if isinstance(stream_sources, str) and stream_sources.startswith("rtsp://"): + return stream_sources + return None + + except (AttributeError, KeyError, TypeError, ValueError) as error: + LOGGER.warning( + f"Error extracting RTSP URL from go2rtc config for camera " + f"{camera.identifier}: {error}" + ) + return None + + +def build_camera_rtsp_url( + camera: AbstractCamera, +) -> str | None: + """Build RTSP URL from camera configuration.""" + try: + if not hasattr(camera, "config"): + return None + + config = camera.config + + host = config.get("host") + if not host: + return None + path = config.get("path", "") + port = config.get("port") + protocol = config.get("protocol") or "rtsp" + + # Build URL + if port and port != 554: + url = f"{protocol}://{host}:{port}{path}" + else: + url = f"{protocol}://{host}{path}" + + return url + + except (AttributeError, KeyError, TypeError, ValueError) as error: + LOGGER.debug(f"Error building camera RTSP URL: {error}") + return None + + +def urls_match(url1: str, url2: str) -> bool: + """Compare two RTSP URLs, ignoring credentials and minor differences.""" + try: + parsed1 = urlparse(url1.lower()) + parsed2 = urlparse(url2.lower()) + + if parsed1.scheme != parsed2.scheme: + return False + + if parsed1.hostname != parsed2.hostname: + return False + + port1 = parsed1.port or (554 if parsed1.scheme == "rtsp" else 80) + port2 = parsed2.port or (554 if parsed2.scheme == "rtsp" else 80) + if port1 != port2: + return False + + if parsed1.path.rstrip("/") != parsed2.path.rstrip("/"): + return False + + return True + + except (AttributeError, KeyError, TypeError, ValueError) as error: + LOGGER.debug(f"Error comparing URLs: {error}") + return False diff --git a/viseron/components/webserver/api/v1/actions/onvif/ptz.py b/viseron/components/webserver/api/v1/actions/onvif/ptz.py new file mode 100644 index 000000000..1ea2b1cac --- /dev/null +++ b/viseron/components/webserver/api/v1/actions/onvif/ptz.py @@ -0,0 +1,243 @@ +"""ONVIF PTZ API handler.""" + +import logging + +import numpy as np + +from viseron.components.onvif.const import CONFIG_PTZ +from viseron.components.webserver.api.v1.actions.onvif.base import ( + ActionsOnvifAPIHandler, + action_handler, +) +from viseron.components.webserver.auth import Role + +LOGGER = logging.getLogger(__name__) + + +class ActionsOnvifPtzAPIHandler(ActionsOnvifAPIHandler): + """ONVIF PTZ action handler.""" + + @property + def _service_name(self): + """Get service name.""" + return CONFIG_PTZ + + ONVIF_PTZ_BASE_PATH = f"/actions/onvif/{CONFIG_PTZ}" + CAMERA_IDENTIFIER_REGEX = r"(?P[A-Za-z0-9_]+)" + ACTION_REGEX = r"(?P[a-z_]+)" + + routes = [ + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_PTZ_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" rf"/{ACTION_REGEX}" + ), + "supported_methods": ["GET"], + "method": "get_onvif_ptz", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_PTZ_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" rf"/{ACTION_REGEX}" + ), + "supported_methods": ["PUT"], + "method": "put_onvif_ptz", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_PTZ_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" rf"/{ACTION_REGEX}" + ), + "supported_methods": ["POST"], + "method": "post_onvif_ptz", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_PTZ_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" rf"/{ACTION_REGEX}" + ), + "supported_methods": ["DELETE"], + "method": "delete_onvif_ptz", + }, + ] + + @action_handler + async def get_onvif_ptz(self, ptz_service, camera_identifier: str, action: str): + """Handle GET requests for ONVIF PTZ actions.""" + + if action == "user_config": + await self.validate_action_response( + ptz_service.get_ptz_config(), action, camera_identifier + ) + return + + if action == "status": + await self.validate_action_response( + await ptz_service.get_status(), action, camera_identifier + ) + return + + if action == "presets": + await self.validate_action_response( + await ptz_service.get_presets(), action, camera_identifier + ) + return + + if action == "nodes": + await self.validate_action_response( + await ptz_service.get_nodes(), action, camera_identifier + ) + return + + if action == "configurations": + await self.validate_action_response( + await ptz_service.get_configurations(), action, camera_identifier + ) + return + + if action == "configuration_options": + await self.validate_action_response( + await ptz_service.get_configuration_options(), action, camera_identifier + ) + return + + self.unknown_action(action) + + @action_handler + async def put_onvif_ptz(self, ptz_service, camera_identifier: str, action: str): + """Handle PUT requests for ONVIF PTZ actions.""" + + request_data = self.get_request_body() + + if action == "set_home": + set_home = await ptz_service.set_home_position() + await self.validate_action_status(set_home, action, camera_identifier) + return + + if action == "set_preset": + preset_name = self.validate_request_data(request_data, "preset_name") + set_preset = await ptz_service.set_preset(preset_name) + await self.validate_action_status(set_preset, action, camera_identifier) + return + + self.unknown_action(action) + + @action_handler + async def post_onvif_ptz(self, ptz_service, camera_identifier: str, action: str): + """Handle POST requests for ONVIF PTZ actions.""" + + request_data = self.get_request_body() + + if action == "continuous_move": + continuous = self.validate_request_data(request_data, "continuous") + continuous_move = await ptz_service.continuous_move( + x_velocity=continuous.get("x_velocity", 0.0), + y_velocity=continuous.get("y_velocity", 0.0), + zoom_velocity=continuous.get("zoom_velocity", 0.0), + seconds=continuous.get("seconds", 0.0), + ) + await self.validate_action_status( + continuous_move, action, camera_identifier + ) + return + + if action == "relative_move": + relative = self.validate_request_data(request_data, "relative") + relative_move = await ptz_service.relative_move( + x_translation=relative.get("x_translation", 0.0), + y_translation=relative.get("y_translation", 0.0), + zoom_translation=relative.get("zoom_translation", 0.0), + x_speed=relative.get("x_speed"), + y_speed=relative.get("y_speed"), + zoom_speed=relative.get("zoom_speed"), + ) + await self.validate_action_status(relative_move, action, camera_identifier) + return + + if action == "absolute_move": + absolute = self.validate_request_data(request_data, "absolute") + absolute_move = await ptz_service.absolute_move( + x_position=absolute.get("x_position", 0.0), + y_position=absolute.get("y_position", 0.0), + zoom_position=absolute.get("zoom_position", 0.0), + x_speed=absolute.get("x_speed"), + y_speed=absolute.get("y_speed"), + zoom_speed=absolute.get("zoom_speed"), + is_adjusted=absolute.get("is_adjusted", False), + ) + await self.validate_action_status(absolute_move, action, camera_identifier) + return + + if action == "stop": + stop = await ptz_service.stop() + if not stop: + # because some cameras do not support stop() operation, + # try sending zero continuous move + stop_with_zero_continuous = await ptz_service.continuous_move( + 0.0, 0.0, 0.0, 1.0 + ) + await self.validate_action_status( + stop_with_zero_continuous, action, camera_identifier + ) + return + await self.validate_action_status(stop, action, camera_identifier) + return + + if action == "home": + home = await ptz_service.go_home_position() + await self.validate_action_status(home, action, camera_identifier) + return + + if action == "goto_preset": + preset_token = self.validate_request_data(request_data, "preset_token") + goto_preset = await ptz_service.goto_preset(preset_token) + await self.validate_action_status(goto_preset, action, camera_identifier) + return + + if action == "patrol": + patrol = self.validate_request_data(request_data, "patrol") + patrol_move = await ptz_service.patrol( + duration=patrol.get("duration", 60), + sleep_after_swing=patrol.get("sleep_after_swing", 6), + step_size=patrol.get("step_size", 0.1), + step_sleep_time=patrol.get("step_sleep_time", 0.1), + ) + await self.validate_action_status(patrol_move, action, camera_identifier) + return + + if action == "lissa_patrol": + lissa_patrol = self.validate_request_data(request_data, "lissa_patrol") + lissa_patrol_move = await ptz_service.lissajous_curve_patrol( + pan_amp=lissa_patrol.get("pan_amp", 1.0), + pan_freq=lissa_patrol.get("pan_freq", 0.1), + tilt_amp=lissa_patrol.get("tilt_amp", 1.0), + tilt_freq=lissa_patrol.get("tilt_freq", 0.1), + phase_shift=lissa_patrol.get("phase_shift", np.pi / 2), + step_sleep_time=lissa_patrol.get("step_sleep_time", 0.1), + ) + await self.validate_action_status( + lissa_patrol_move, action, camera_identifier + ) + return + + if action == "stop_patrol": + stop_patrol = ptz_service.stop_patrol() + await self.validate_action_status(stop_patrol, action, camera_identifier) + return + + self.unknown_action(action) + + @action_handler + async def delete_onvif_ptz(self, ptz_service, camera_identifier: str, action: str): + """Handle DELETE requests for ONVIF PTZ actions.""" + + if action == "remove_preset": + required_query = "preset_token" + preset_token = self.validate_query_parameter( + self.get_query_argument(required_query, None), required_query + ) + remove_preset = await ptz_service.remove_preset(preset_token) + await self.validate_action_status(remove_preset, action, camera_identifier) + return + + self.unknown_action(action) From 7b2b42c39231b61c2f3b7e07fd4fa71b7a8d4690 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Fri, 9 Jan 2026 16:27:45 +0700 Subject: [PATCH 008/120] refactor(telegram): Refactor Telegram component to accommodate the changes of the ONVIF component --- viseron/components/telegram/__init__.py | 62 ++++-- viseron/components/telegram/const.py | 2 +- viseron/components/telegram/ptz_control.py | 224 +++++++++++++++------ 3 files changed, 211 insertions(+), 77 deletions(-) diff --git a/viseron/components/telegram/__init__.py b/viseron/components/telegram/__init__.py index 14bd345f2..57efc27e3 100644 --- a/viseron/components/telegram/__init__.py +++ b/viseron/components/telegram/__init__.py @@ -13,6 +13,7 @@ import cv2 import voluptuous as vol from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.constants import ParseMode from telegram.error import TelegramError from telegram.ext import ( Application, @@ -20,6 +21,7 @@ CallbackQueryHandler, CommandHandler, ) +from telegram.request import HTTPXRequest from viseron.components.nvr import COMPONENT as NVR_COMPONENT from viseron.components.storage.models import TriggerTypes @@ -42,7 +44,7 @@ CONFIG_CAMERAS, CONFIG_DETECTION_LABEL, CONFIG_DETECTION_LABEL_DEFAULT, - CONFIG_PTZ_COMPONENT, + CONFIG_ONVIF_COMPONENT, CONFIG_SEND_MESSAGE, CONFIG_SEND_THUMBNAIL, CONFIG_SEND_VIDEO, @@ -132,13 +134,14 @@ def setup(vis: Viseron, config: dict[str, Any]) -> bool: telegram_notifier = TelegramEventNotifier(vis, component_config) - if not config.get(CONFIG_PTZ_COMPONENT): - LOGGER.info("No PTZ component. Won't start Telegram PTZ Controller.") + # Check if ONVIF component is loaded and ready + if CONFIG_ONVIF_COMPONENT not in vis.data: + LOGGER.info("ONVIF component not loaded. Won't start Telegram PTZ Controller.") telegram_ptz = None else: - if not vis.data.get(CONFIG_PTZ_COMPONENT): + if not vis.data.get(CONFIG_ONVIF_COMPONENT): raise ComponentNotReady( - f"PTZ component '{CONFIG_PTZ_COMPONENT}' not ready yet" + f"PTZ component '{CONFIG_ONVIF_COMPONENT}' not ready yet" ) telegram_ptz = TelegramPTZ(vis, component_config, telegram_notifier) Thread(target=telegram_ptz.run_async).start() @@ -182,6 +185,13 @@ class TelegramEventNotifier: """ def __init__(self, vis: Viseron, config: dict[str, Any]) -> None: + request = HTTPXRequest( + connect_timeout=60, + read_timeout=60, + write_timeout=60, + pool_timeout=60, + ) + self._vis = vis self._config = config SensitiveInformationFilter.add_sensitive_string( @@ -194,7 +204,9 @@ def __init__(self, vis: Viseron, config: dict[str, Any]) -> None: self._chat_ids = self._config[CONFIG_TELEGRAM_CHAT_IDS] self._loop = asyncio.new_event_loop() self._bot = Bot(token=self._bot_token) - self._app = Application.builder().token(self._bot_token).build() + self._app = ( + Application.builder().request(request).token(self._bot_token).build() + ) self._stop_event = asyncio.Event() self._active_camera_identifier: str = ( list(self._config[CONFIG_CAMERAS].keys())[0] or "" @@ -284,9 +296,12 @@ async def _listen(self) -> None: while not self._stop_event.is_set(): await asyncio.sleep(1) finally: - if self._app.updater: + if self._app.updater and self._app.updater.running: await self._app.updater.stop() - await self._app.stop() + + if self._app.running: + await self._app.stop() + await self._app.shutdown() def run_async(self): @@ -359,7 +374,8 @@ async def _which_cam(self, update: Update, _context: CallbackContext) -> None: if update.message: if self.active_camera_identifier: await update.message.reply_text( - f"Active camera: {self.active_camera_identifier}" + f"Active camera: {self.active_camera_identifier}", + parse_mode=ParseMode.HTML, ) else: await update.message.reply_text("No camera selected.") @@ -371,7 +387,11 @@ async def _snapshot(self, update: Update, _context: CallbackContext) -> None: if cam: ret, snapshot = cam.get_snapshot(cam.current_frame) if update.message and ret: - await update.message.reply_photo(photo=snapshot) + await update.message.reply_photo( + photo=snapshot, + caption=f"Snapshot from {cam.name or cam.identifier}", + parse_mode=ParseMode.HTML, + ) else: if update.message: await update.message.reply_text("No active camera.") @@ -384,11 +404,17 @@ async def _toggle_camera(self, update: Update, _context: CallbackContext) -> Non if cam.is_on: cam.stop_camera() if update.message: - await update.message.reply_text("Camera turned off.") + await update.message.reply_text( + f"{cam.name or cam.identifier} is turned off.", + parse_mode=ParseMode.HTML, + ) else: cam.start_camera() if update.message: - await update.message.reply_text("Camera turned on.") + await update.message.reply_text( + f"{cam.name or cam.identifier} is turned on.", + parse_mode=ParseMode.HTML, + ) @limit_user_access async def _record(self, update: Update, context: CallbackContext) -> None: @@ -443,8 +469,9 @@ async def _record(self, update: Update, context: CallbackContext) -> None: ) if update.message: await update.message.reply_text( - f"Started manual recording for camera {cam.identifier} with " + f"Started manual recording for camera {cam.identifier} with " f"{f'duration {duration}s' if duration else 'no duration'}.", + parse_mode=ParseMode.HTML, ) if duration: await asyncio.sleep(duration) @@ -525,8 +552,11 @@ async def _help(self, update: Update, context: CallbackContext) -> None: first_line_doc = next( (line.strip() for line in doc.split("\n") if line.strip()), "" ) - commands.append(f"{command_list} - {first_line_doc}") + commands.append(f"{command_list} — {first_line_doc}") - help_message = "\n".join(commands) - help_message += "\nUse /help to get more information about a command." + help_message = "Viseron Telegram commands:\n\n" + help_message += "\n".join(commands) + help_message += ( + "\n\nUse /help to get more information about a command." + ) await update.message.reply_text(help_message) diff --git a/viseron/components/telegram/const.py b/viseron/components/telegram/const.py index 5b84e29ac..5a59b0cd6 100644 --- a/viseron/components/telegram/const.py +++ b/viseron/components/telegram/const.py @@ -5,7 +5,7 @@ COMPONENT = "telegram" DESC_COMPONENT = "Telegram bot to control cameras." -CONFIG_PTZ_COMPONENT = "ptz" +CONFIG_ONVIF_COMPONENT = "onvif" CONFIG_TELEGRAM_BOT_TOKEN = "telegram_bot_token" CONFIG_TELEGRAM_CHAT_IDS = "telegram_chat_ids" diff --git a/viseron/components/telegram/ptz_control.py b/viseron/components/telegram/ptz_control.py index d42a406ab..50e8fd110 100644 --- a/viseron/components/telegram/ptz_control.py +++ b/viseron/components/telegram/ptz_control.py @@ -10,12 +10,14 @@ import cv2 import numpy as np from telegram import Update +from telegram.constants import ParseMode from telegram.ext import CallbackContext, CommandHandler -from viseron.components.ptz import PTZ +from viseron.components.onvif import ONVIF +from viseron.components.onvif.const import COMPONENT as ONVIF_COMPONENT from viseron.components.telegram.utils import limit_user_access -from .const import COMPONENT, CONFIG_PTZ_COMPONENT +from .const import COMPONENT if TYPE_CHECKING: from viseron import Viseron @@ -38,12 +40,19 @@ def __init__(self, vis: Viseron, config, telegram: TelegramEventNotifier) -> Non self._vis = vis self._config = config self._telegram = telegram - self._ptz: PTZ = self._vis.data[CONFIG_PTZ_COMPONENT] + self._onvif: ONVIF = self._vis.data[ONVIF_COMPONENT] self._stop_event = asyncio.Event() vis.data[COMPONENT] = self + @property + def _ptz_service(self): + """Get PTZ service for active camera.""" + return self._onvif.get_ptz_service(self._telegram.active_camera_identifier) + async def _listen(self) -> None: """Start listening for commands from Telegram.""" + self._telegram.app.add_handler(CommandHandler("home", self._home)) + self._telegram.app.add_handler(CommandHandler("h", self._home)) self._telegram.app.add_handler(CommandHandler("left", self._pan_left)) self._telegram.app.add_handler(CommandHandler("l", self._pan_left)) self._telegram.app.add_handler(CommandHandler("right", self._pan_right)) @@ -52,29 +61,29 @@ async def _listen(self) -> None: self._telegram.app.add_handler(CommandHandler("u", self._tilt_up)) self._telegram.app.add_handler(CommandHandler("down", self._tilt_down)) self._telegram.app.add_handler(CommandHandler("d", self._tilt_down)) - self._telegram.app.add_handler(CommandHandler("patrol", self._patrol)) - self._telegram.app.add_handler(CommandHandler("p", self._patrol)) self._telegram.app.add_handler(CommandHandler("zo", self._zoom_out)) self._telegram.app.add_handler(CommandHandler("o", self._zoom_out)) self._telegram.app.add_handler(CommandHandler("zi", self._zoom_in)) self._telegram.app.add_handler(CommandHandler("i", self._zoom_in)) - self._telegram.app.add_handler(CommandHandler("stop", self._stop_patrol)) - self._telegram.app.add_handler(CommandHandler("st", self._stop_patrol)) self._telegram.app.add_handler(CommandHandler("pos", self._get_position)) self._telegram.app.add_handler(CommandHandler("preset", self._preset)) self._telegram.app.add_handler(CommandHandler("pr", self._preset)) self._telegram.app.add_handler(CommandHandler("repeat", self._repeat_preset)) + self._telegram.app.add_handler(CommandHandler("patrol", self._patrol)) + self._telegram.app.add_handler(CommandHandler("p", self._patrol)) self._telegram.app.add_handler(CommandHandler("lissa", self._lissa)) + self._telegram.app.add_handler(CommandHandler("stop", self._stop_patrol)) + self._telegram.app.add_handler(CommandHandler("st", self._stop_patrol)) while not self._stop_event.is_set(): await asyncio.sleep(1) - LOGGER.info("Telegram PTZ Controller stopped") + LOGGER.info("TelegramPTZ Controller stopped") def stop(self) -> None: """Stop TelegramPTZ Controller.""" self._stop_event.set() - LOGGER.info("Stopping Telegram PTZ Controller") + LOGGER.info("Stopping TelegramPTZ Controller") def run_async(self): """Run TelegramPTZ Controller in a new event loop.""" @@ -83,6 +92,36 @@ def run_async(self): loop.run_until_complete(self._listen()) LOGGER.info("TelegramPTZ Controller done") + async def _inform( + self, update: Update, operation: str, status: bool | None + ) -> None: + """Inform the user with a message.""" + if update.message: + if status: + message_status = "executed" + elif status is None: + message_status = "started" + else: + message_status = "failed" + message = ( + f"{operation.upper().replace('_', ' ')} {message_status} for " + f"{self._telegram.active_camera_identifier}" + ) + await update.message.reply_text( + message, + parse_mode=ParseMode.HTML, + ) + + # pylint: disable=unused-argument + @limit_user_access + async def _home(self, update: Update, context: CallbackContext) -> None: + """Move the camera to its home position.""" + + status = ( + await self._ptz_service.go_home_position() if self._ptz_service else False + ) + await self._inform(update, "home", status) + # pylint: disable=unused-argument @limit_user_access async def _pan_left(self, update: Update, context: CallbackContext) -> None: @@ -94,10 +133,13 @@ async def _pan_left(self, update: Update, context: CallbackContext) -> None: step_size = 0.1 if context.args: step_size = float(context.args[0]) - self._ptz.pan_left( - camera_identifier=self._telegram.active_camera_identifier, - step_size=step_size, + + status = ( + await self._ptz_service.pan_left(step_size=step_size) + if self._ptz_service + else False ) + await self._inform(update, "pan_left", status) # pylint: disable=unused-argument @limit_user_access @@ -110,10 +152,13 @@ async def _pan_right(self, update: Update, context: CallbackContext) -> None: step_size = 0.1 if context.args: step_size = float(context.args[0]) - self._ptz.pan_right( - camera_identifier=self._telegram.active_camera_identifier, - step_size=step_size, + + status = ( + await self._ptz_service.pan_right(step_size=step_size) + if self._ptz_service + else False ) + await self._inform(update, "pan_right", status) # pylint: disable=unused-argument @limit_user_access @@ -126,10 +171,13 @@ async def _tilt_up(self, update: Update, context: CallbackContext) -> None: step_size = 0.1 if context.args: step_size = float(context.args[0]) - self._ptz.tilt_up( - camera_identifier=self._telegram.active_camera_identifier, - step_size=step_size, + + status = ( + await self._ptz_service.tilt_up(step_size=step_size) + if self._ptz_service + else False ) + await self._inform(update, "tilt_up", status) # pylint: disable=unused-argument @limit_user_access @@ -142,10 +190,13 @@ async def _tilt_down(self, update: Update, context: CallbackContext) -> None: step_size = 0.1 if context.args: step_size = float(context.args[0]) - self._ptz.tilt_down( - camera_identifier=self._telegram.active_camera_identifier, - step_size=step_size, + + status = ( + await self._ptz_service.tilt_down(step_size=step_size) + if self._ptz_service + else False ) + await self._inform(update, "tilt_down", status) # pylint: disable=unused-argument @limit_user_access @@ -158,10 +209,13 @@ async def _zoom_out(self, update: Update, context: CallbackContext) -> None: step_size = 0.1 if context.args: step_size = float(context.args[0]) - self._ptz.zoom_out( - camera_identifier=self._telegram.active_camera_identifier, - step_size=step_size, + + status = ( + await self._ptz_service.zoom_out(step_size=step_size) + if self._ptz_service + else False ) + await self._inform(update, "zoom_out", status) # pylint: disable=unused-argument @limit_user_access @@ -174,22 +228,39 @@ async def _zoom_in(self, update: Update, context: CallbackContext) -> None: step_size = 0.1 if context.args: step_size = float(context.args[0]) - self._ptz.zoom_in( - camera_identifier=self._telegram.active_camera_identifier, - step_size=step_size, + + status = ( + await self._ptz_service.zoom_in(step_size=step_size) + if self._ptz_service + else False ) + await self._inform(update, "zoom_in", status) @limit_user_access async def _get_position(self, update: Update, context: CallbackContext) -> None: """Get the current (PTZ) position of the camera.""" - x, y = self._ptz.get_position(self._telegram.active_camera_identifier) + x, y, z = ( + await self._ptz_service.get_position() + if self._ptz_service + else (False, False, False) + ) + if x is False or y is False or z is False: + if update.message: + await update.message.reply_text( + f"Could not get position for " + f"{self._telegram.active_camera_identifier}" + ) + return if update.message: - await update.message.reply_text(f"Position: {x}, {y}") + await update.message.reply_text( + f"{self._telegram.active_camera_identifier} position:\n" + f"PanTilt = x : {x}, y : {y}\nZoom = x : {z}" + ) @limit_user_access async def _patrol(self, update: Update, context: CallbackContext) -> None: """ - Swings the camera from left to right and back, etc. + Swings the camera from left to right and back. @param duration: The duration of the patrol in seconds @param sleep_after_swing: The time to sleep after each swing in seconds @@ -233,19 +304,25 @@ async def _patrol(self, update: Update, context: CallbackContext) -> None: step_size = float(context.args[2]) if context.args and len(context.args) > 3: step_sleep_time = float(context.args[3]) - await self._ptz.patrol( - camera_identifier=self._telegram.active_camera_identifier, - duration=duration, - sleep_after_swing=sleep_after_swing, - step_size=step_size, - step_sleep_time=step_sleep_time, + + status = ( + await self._ptz_service.patrol( + duration=duration, + sleep_after_swing=sleep_after_swing, + step_size=step_size, + step_sleep_time=step_sleep_time, + ) + if self._ptz_service + else False ) + await self._inform(update, "patrol", status) # pylint: disable=unused-argument @limit_user_access async def _stop_patrol(self, update: Update, context: CallbackContext) -> None: """Stop the patrol.""" - self._ptz.stop_patrol(self._telegram.active_camera_identifier) + status = await self._ptz_service.stop_patrol() if self._ptz_service else False + await self._inform(update, "stop_patrol", status) @limit_user_access async def _lissa(self, update: Update, context: CallbackContext) -> None: @@ -360,16 +437,21 @@ async def _lissa(self, update: Update, context: CallbackContext) -> None: io_buf = io.BytesIO(buffer) # type: ignore[arg-type] await update.message.reply_photo(photo=io_buf) - await self._ptz.lissajous_curve_patrol( - camera_identifier=self._telegram.active_camera_identifier, - pan_amp=pan_amp, - pan_freq=pan_freq, - tilt_amp=tilt_amp, - tilt_freq=tilt_freq, - phase_shift=phase_shift, - step_sleep_time=step_sleep_time, + status = ( + await self._ptz_service.lissajous_curve_patrol( + pan_amp=pan_amp, + pan_freq=pan_freq, + tilt_amp=tilt_amp, + tilt_freq=tilt_freq, + phase_shift=phase_shift, + step_sleep_time=step_sleep_time, + ) + if self._ptz_service + else False ) + await self._inform(update, "lissa_patrol", status) + @limit_user_access async def _preset(self, update: Update, context: CallbackContext) -> None: """ @@ -380,20 +462,33 @@ async def _preset(self, update: Update, context: CallbackContext) -> None: """ name = "list" if not context.args else context.args[0] if name == "list": - presets = self._ptz.get_presets(self._telegram.active_camera_identifier) - preset_cmds = "\n".join(f"/preset {preset}" for preset in presets) + if not self._ptz_service: + if update.message: + await update.message.reply_text("No PTZ service available.") + return + config_presets = self._ptz_service.get_configured_presets() + config_preset_cmds = "\n".join( + f"/preset {preset}" for preset in config_presets + ) + presets = await self._ptz_service.get_presets() + preset_cmds = "\n".join( + f"/preset {preset.Name or preset.token}" for preset in presets + ) + all_presets = config_preset_cmds + "\n" + preset_cmds if update.message: - await update.message.reply_text(f"Available presets:\n{preset_cmds}") + await update.message.reply_text(f"Available presets:\n{all_presets}") return - could_complete = await self._ptz.move_to_preset_wait_complete( - camera_identifier=self._telegram.active_camera_identifier, preset_name=name - ) + could_complete = await self._ptz_service.move_to_preset(preset_name=name) if update.message: if could_complete: - await update.message.reply_text(f"Moved to preset '{name}'") + await update.message.reply_text( + f"Moved to preset {name}", parse_mode=ParseMode.HTML + ) else: - await update.message.reply_text(f"Failed to move to preset '{name}'") + await update.message.reply_text( + f"Failed to move to preset {name}", parse_mode=ParseMode.HTML + ) @limit_user_access async def _repeat_preset(self, update: Update, context: CallbackContext) -> None: @@ -409,21 +504,30 @@ async def _repeat_preset(self, update: Update, context: CallbackContext) -> None """ name = "list" if not context.args else context.args[0] if name == "list": - presets = self._ptz.get_presets(self._telegram.active_camera_identifier) - preset_cmds = "\n".join(f"/preset {preset}" for preset in presets) + if not self._ptz_service: + if update.message: + await update.message.reply_text("No PTZ service available.") + return + config_presets = self._ptz_service.get_configured_presets() + config_preset_cmds = "\n".join( + f"/preset {preset}" for preset in config_presets + ) + presets = await self._ptz_service.get_presets() + preset_cmds = "\n".join( + f"/preset {preset.Name or preset.token}" for preset in presets + ) + all_presets = config_preset_cmds + "\n" + preset_cmds if update.message: - await update.message.reply_text(f"Available presets:\n{preset_cmds}") + await update.message.reply_text(f"Available presets:\n{all_presets}") return + repeat_count = 5 if context.args and len(context.args) > 1: repeat_count = int(context.args[1]) async def run_presets_sequentially(): for _ in range(repeat_count): - await self._ptz.move_to_preset_wait_complete( - camera_identifier=self._telegram.active_camera_identifier, - preset_name=name, - ) + await self._ptz_service.move_to_preset(preset_name=name) # Schedule the task to run in the background asyncio.create_task(run_presets_sequentially()) From 952bc9c8120f530f84163413dc5d6777c8181cc9 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Fri, 9 Jan 2026 16:33:27 +0700 Subject: [PATCH 009/120] refactor(webserver): Change how API endpoints are discovered --- viseron/components/webserver/api/__init__.py | 64 +++++++++++++++----- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/viseron/components/webserver/api/__init__.py b/viseron/components/webserver/api/__init__.py index a4c256b85..5cd24d311 100644 --- a/viseron/components/webserver/api/__init__.py +++ b/viseron/components/webserver/api/__init__.py @@ -22,28 +22,57 @@ @cache def get_handler(api_version: str, endpoint: str): - """Get handler for endpoint.""" + """Get handler for endpoint, supporting nested paths.""" version_path = Path(__file__).parent / api_version if not version_path.is_dir(): return APINotFoundHandler + # First try direct file match (e.g., endpoint.py) module_path = version_path / f"{endpoint}.py" - if not module_path.is_file(): - return APINotFoundHandler + if module_path.is_file(): + try: + module = importlib.import_module( + f"viseron.components.webserver.api.{api_version}.{endpoint}" + ) + handler_name = f"{endpoint.title()}APIHandler" + if hasattr(module, handler_name): + return getattr(module, handler_name) + except ImportError as error: + LOGGER.warning( + f"Error importing API handler {endpoint}: {error}", + exc_info=True, + ) - try: - module = importlib.import_module( - f"viseron.components.webserver.api.{api_version}.{endpoint}" - ) - handler_name = f"{endpoint.title()}APIHandler" - if hasattr(module, handler_name): - return getattr(module, handler_name) - except ImportError as error: - LOGGER.warning( - f"Error importing API handler {endpoint}: {error}", - exc_info=True, + # Try nested path (e.g., endpoint/sub/file.py) + # Split endpoint path and look for the deepest matching module + endpoint_parts = endpoint.split("/") + for i in range(len(endpoint_parts), 0, -1): + module_parts = endpoint_parts[:i] + module_path = ( + version_path / "/".join(module_parts[:-1]) / f"{module_parts[-1]}.py" ) + + if module_path.is_file(): + try: + module_import_path = ".".join(module_parts) + module = importlib.import_module( + f"viseron.components.webserver.api.{api_version}." + f"{module_import_path}" + ) + + handler_name = ( + "".join(part.title() for part in module_parts) + "APIHandler" + ) + if hasattr(module, handler_name): + return getattr(module, handler_name) + + except ImportError as error: + LOGGER.debug( + f"Error importing nested API handler {module_import_path}: {error}", + ) + continue + return APINotFoundHandler @@ -61,8 +90,11 @@ def find_handler( ) -> _HandlerDelegate: """Route to correct API handler.""" try: - api_version = request.path.split("/")[2] - endpoint = request.path.split("/")[3] + # Split path: /api/v1/endpoint/sub/path : + # ['', 'api', 'v1', 'endpoint', 'sub', 'path'] + path_parts = request.path.split("/") + api_version = path_parts[2] # 'v1' + endpoint = "/".join(path_parts[3:]) # 'endpoint/sub/path' except IndexError: LOGGER.warning( f"Invalid API request URL: {request.path}", From 61bdafb0141a194cf1dc3d518d14864bfec7f94b Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Fri, 9 Jan 2026 16:34:07 +0700 Subject: [PATCH 010/120] feat(webserver/onvif): Add Actions API for ONVIF components --- .../webserver/api/v1/actions/onvif/base.py | 174 ++++++++++++ .../webserver/api/v1/actions/onvif/device.py | 263 ++++++++++++++++++ .../webserver/api/v1/actions/onvif/imaging.py | 143 ++++++++++ .../webserver/api/v1/actions/onvif/media.py | 1 + 4 files changed, 581 insertions(+) create mode 100644 viseron/components/webserver/api/v1/actions/onvif/base.py create mode 100644 viseron/components/webserver/api/v1/actions/onvif/device.py create mode 100644 viseron/components/webserver/api/v1/actions/onvif/imaging.py create mode 100644 viseron/components/webserver/api/v1/actions/onvif/media.py diff --git a/viseron/components/webserver/api/v1/actions/onvif/base.py b/viseron/components/webserver/api/v1/actions/onvif/base.py new file mode 100644 index 000000000..57703fbe8 --- /dev/null +++ b/viseron/components/webserver/api/v1/actions/onvif/base.py @@ -0,0 +1,174 @@ +"""Base ONVIF API handler.""" + +from __future__ import annotations + +import functools +import json +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, TypeAlias + +from viseron.components.onvif import ONVIF +from viseron.components.onvif.const import ( + COMPONENT as ONVIF_COMPONENT, + CONFIG_DEVICE, + CONFIG_IMAGING, + CONFIG_MEDIA, + CONFIG_PTZ, +) +from viseron.components.onvif.utils import to_dict +from viseron.components.webserver.api.handlers import BaseAPIHandler + +LOGGER = logging.getLogger(__name__) + +if TYPE_CHECKING: + from viseron.components.onvif.device import Device + from viseron.components.onvif.imaging import Imaging + from viseron.components.onvif.media import Media + from viseron.components.onvif.ptz import PTZ + +ServiceType: TypeAlias = "Device | Media | Imaging | PTZ" + + +def action_handler(func): + """Handle operation service retrieval and error handling.""" + + @functools.wraps(func) + async def wrapper(self, camera_identifier: str, action: str) -> None: + # pylint: disable=protected-access + service = self.get_service(self._service_name, camera_identifier) + if service is None: + return + + try: + await func(self, service, camera_identifier, action) + except (AttributeError, ValueError, RuntimeError) as error: + LOGGER.error( + f"Error executing {self._service_name.upper()} action {action} for " + f"{camera_identifier}: {error}" + ) + self.response_error( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + reason=( + f"Error executing {self._service_name.upper()} action {action} for " + f"{camera_identifier}: {str(error)}" + ), + ) + + return wrapper + + +class ActionsOnvifAPIHandler(BaseAPIHandler): + """Base ONVIF action handler.""" + + # Just a placeholder (do not remove it!) + @property + def _service_name(self): + return "base_service" + + def get_service(self, service: str, camera_identifier: str) -> ServiceType | None: + """Get service for camera or send error response.""" + if ONVIF_COMPONENT not in self._vis.data: + self.response_error( + status_code=HTTPStatus.BAD_REQUEST, + reason="ONVIF component not initialized.", + ) + return None + + onvif_component: ONVIF = self._vis.data[ONVIF_COMPONENT] + + service_instance: ServiceType | None = None + + if service == CONFIG_DEVICE: + service_instance = onvif_component.get_device_service(camera_identifier) + elif service == CONFIG_MEDIA: + service_instance = onvif_component.get_media_service(camera_identifier) + elif service == CONFIG_IMAGING: + service_instance = onvif_component.get_imaging_service(camera_identifier) + elif service == CONFIG_PTZ: + service_instance = onvif_component.get_ptz_service(camera_identifier) + + if service_instance is None: + self.response_error( + status_code=HTTPStatus.BAD_REQUEST, + reason=f"No {service.upper()} service for {camera_identifier}", + ) + return None + + return service_instance + + def get_request_body(self) -> dict: + """Parse and return the JSON body of the request.""" + try: + return json.loads(self.request.body) + except json.JSONDecodeError: + self.response_error( + status_code=HTTPStatus.BAD_REQUEST, + reason="Invalid JSON in request body.", + ) + return {} + + def validate_request_data(self, request_data: dict, required_field: str): + """Validate that required fields are present in the request data.""" + if required_field not in request_data: + self.response_error( + status_code=HTTPStatus.BAD_REQUEST, + reason=f"Missing '{required_field}' in request body.", + ) + return + return request_data[required_field] + + def validate_query_parameter(self, parameter_value: Any, parameter_name: str): + """Validate that required query parameters are present.""" + if parameter_value is None: + self.response_error( + status_code=HTTPStatus.BAD_REQUEST, + reason=f"Missing '{parameter_name}' query parameter.", + ) + return + return parameter_value + + async def validate_action_response( + self, response_data: Any, action: str, camera_identifier: str + ): + """Validate the response of an action and send error if failed.""" + if response_data is False or response_data is None: + self.response_error( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + reason=( + f"Failed to get {self._service_name.upper()} {action} " + f"for {camera_identifier}." + ), + ) + return + await self.response_success(response={f"{action}": to_dict(response_data)}) + return + + async def validate_action_status( + self, status: bool, action: str, camera_identifier: str + ): + """Validate the status of an action and send error if failed.""" + if not status: + self.response_error( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + reason=( + f"Failed to send {self._service_name.upper()} {action} " + f"command for {camera_identifier}." + ), + ) + return + await self.response_success( + response={ + "result": f"{self._service_name.upper()} {action} command " + f"sent to {camera_identifier}", + } + ) + return + + def unknown_action(self, action: str): + """Catch unknown action from service.""" + self.response_error( + status_code=HTTPStatus.BAD_REQUEST, + reason=f"Unknown action: {self._service_name.upper()} {action}", + ) + return diff --git a/viseron/components/webserver/api/v1/actions/onvif/device.py b/viseron/components/webserver/api/v1/actions/onvif/device.py new file mode 100644 index 000000000..6e5cd7386 --- /dev/null +++ b/viseron/components/webserver/api/v1/actions/onvif/device.py @@ -0,0 +1,263 @@ +"""ONVIF Device API handler.""" + +import logging + +from viseron.components.onvif.const import CONFIG_DEVICE +from viseron.components.webserver.api.v1.actions.onvif.base import ( + ActionsOnvifAPIHandler, + action_handler, +) +from viseron.components.webserver.auth import Role + +LOGGER = logging.getLogger(__name__) + + +class ActionsOnvifDeviceAPIHandler(ActionsOnvifAPIHandler): + """ONVIF Device action handler.""" + + @property + def _service_name(self): + """Get service name.""" + return CONFIG_DEVICE + + ONVIF_DEVICE_BASE_PATH = f"/actions/onvif/{CONFIG_DEVICE}" + CAMERA_IDENTIFIER_REGEX = r"(?P[A-Za-z0-9_]+)" + ACTION_REGEX = r"(?P[a-z_]+)" + + routes = [ + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_DEVICE_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["GET"], + "method": "get_onvif_device", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_DEVICE_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["PUT"], + "method": "put_onvif_device", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_DEVICE_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["POST"], + "method": "post_onvif_device", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_DEVICE_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["DELETE"], + "method": "delete_onvif_device", + }, + ] + + @action_handler + async def get_onvif_device( + self, + device_service, + camera_identifier: str, + action: str, + ): + """Handle GET requests for ONVIF Device actions.""" + + if action == "information": + await self.validate_action_response( + await device_service.get_device_information(), action, camera_identifier + ) + return + + if action == "scopes": + await self.validate_action_response( + await device_service.get_scopes(), action, camera_identifier + ) + return + + if action == "capabilities": + await self.validate_action_response( + await device_service.get_capabilities(), action, camera_identifier + ) + return + + if action == "services": + await self.validate_action_response( + await device_service.get_services(), action, camera_identifier + ) + return + + if action == "users": + await self.validate_action_response( + await device_service.get_users(), action, camera_identifier + ) + return + + if action == "system_date": + await self.validate_action_response( + await device_service.get_system_date_and_time(), + action, + camera_identifier, + ) + return + + if action == "hostname": + await self.validate_action_response( + await device_service.get_hostname(), action, camera_identifier + ) + return + + if action == "ntp": + await self.validate_action_response( + await device_service.get_ntp(), action, camera_identifier + ) + return + + if action == "discovery_mode": + await self.validate_action_response( + await device_service.get_discovery_mode(), action, camera_identifier + ) + return + + if action == "network_default_gateway": + await self.validate_action_response( + await device_service.get_network_default_gateway(), + action, + camera_identifier, + ) + return + + if action == "network_interface": + await self.validate_action_response( + await device_service.get_network_interfaces(), action, camera_identifier + ) + return + + if action == "network_protocols": + await self.validate_action_response( + await device_service.get_network_protocols(), action, camera_identifier + ) + return + + if action == "dns": + await self.validate_action_response( + await device_service.get_dns(), action, camera_identifier + ) + return + + self.unknown_action(action) + + @action_handler + async def put_onvif_device( + self, + device_service, + camera_identifier: str, + action: str, + ): + """Handle PUT requests for ONVIF Device actions.""" + + request_data = self.get_request_body() + + if action == "set_scopes": + scopes = self.validate_request_data(request_data, "scopes") + set_scopes = await device_service.set_scopes(scopes) + await self.validate_action_status(set_scopes, action, camera_identifier) + return + + if action == "set_system_date": + system_date = self.validate_request_data(request_data, "system_date") + set_system_date_and_time = await device_service.set_system_date_and_time( + datetime_type=system_date.get("datetime_type"), + daylight_savings=system_date.get("daylight_savings"), + timezone=system_date.get("timezone"), + ) + await self.validate_action_status( + set_system_date_and_time, action, camera_identifier + ) + return + + if action == "set_hostname": + hostname = self.validate_request_data(request_data, "hostname") + set_hostname = await device_service.set_hostname(hostname) + await self.validate_action_status(set_hostname, action, camera_identifier) + return + + if action == "set_ntp": + ntp = self.validate_request_data(request_data, "ntp") + set_ntp = await device_service.set_ntp( + ntp_server=ntp.get("ntp_server"), + from_dhcp=ntp.get("from_dhcp"), + ntp_type=ntp.get("ntp_type"), + ) + await self.validate_action_status(set_ntp, action, camera_identifier) + return + + self.unknown_action(action) + + @action_handler + async def post_onvif_device( + self, + device_service, + camera_identifier: str, + action: str, + ): + """Handle POST requests for ONVIF Device actions.""" + + request_data = self.get_request_body() + + if action == "add_scopes": + scopes = self.validate_request_data(request_data, "scopes") + add_scopes = await device_service.add_scopes(scopes) + await self.validate_action_status(add_scopes, action, camera_identifier) + return + + if action == "create_users": + users = self.validate_request_data(request_data, "users") + create_users = await device_service.create_users(users) + await self.validate_action_status(create_users, action, camera_identifier) + return + + if action == "reboot": + reboot = await device_service.system_reboot() + await self.validate_action_status(reboot, action, camera_identifier) + return + + self.unknown_action(action) + + @action_handler + async def delete_onvif_device( + self, + device_service, + camera_identifier: str, + action: str, + ): + """Handle DELETE requests for ONVIF Device actions.""" + + if action == "remove_scopes": + required_query = "scopes" + scopes = self.validate_query_parameter( + self.get_query_argument(required_query, None), required_query + ) + remove_scopes = await device_service.remove_scopes(scopes) + await self.validate_action_status(remove_scopes, action, camera_identifier) + return + + if action == "delete_users": + required_query = "usernames" + usernames = self.validate_query_parameter( + self.get_query_argument(required_query, None), required_query + ) + delete_users = await device_service.delete_users(usernames) + await self.validate_action_status(delete_users, action, camera_identifier) + return + + self.unknown_action(action) diff --git a/viseron/components/webserver/api/v1/actions/onvif/imaging.py b/viseron/components/webserver/api/v1/actions/onvif/imaging.py new file mode 100644 index 000000000..42b3fe1ec --- /dev/null +++ b/viseron/components/webserver/api/v1/actions/onvif/imaging.py @@ -0,0 +1,143 @@ +"""ONVIF Imaging API handler.""" + +import logging + +from viseron.components.onvif.const import CONFIG_IMAGING +from viseron.components.webserver.api.v1.actions.onvif.base import ( + ActionsOnvifAPIHandler, + action_handler, +) +from viseron.components.webserver.auth import Role + +LOGGER = logging.getLogger(__name__) + + +class ActionsOnvifImagingAPIHandler(ActionsOnvifAPIHandler): + """ONVIF Imaging action handler.""" + + @property + def _service_name(self): + """Get service name.""" + return CONFIG_IMAGING + + ONVIF_IMAGING_BASE_PATH = f"/actions/onvif/{CONFIG_IMAGING}" + CAMERA_IDENTIFIER_REGEX = r"(?P[A-Za-z0-9_]+)" + ACTION_REGEX = r"(?P[a-z_]+)" + + routes = [ + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_IMAGING_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["GET"], + "method": "get_onvif_imaging", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_IMAGING_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["PUT"], + "method": "put_onvif_imaging", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_IMAGING_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["POST"], + "method": "post_onvif_imaging", + }, + ] + + @action_handler + async def get_onvif_imaging( + self, + imaging_service, + camera_identifier: str, + action: str, + ): + """Handle GET requests for ONVIF Imaging actions.""" + + if action == "settings": + await self.validate_action_response( + await imaging_service.get_imaging_settings(), action, camera_identifier + ) + return + + if action == "options": + await self.validate_action_response( + await imaging_service.get_options(), action, camera_identifier + ) + return + + if action == "move_options": + await self.validate_action_response( + await imaging_service.get_move_options(), action, camera_identifier + ) + return + + self.unknown_action(action) + + @action_handler + async def put_onvif_imaging( + self, + imaging_service, + camera_identifier: str, + action: str, + ): + """Handle PUT requests for ONVIF Imaging actions.""" + + request_data = self.get_request_body() + + if action == "settings": + settings = self.validate_request_data(request_data, "settings") + force_persistence = self.validate_request_data( + request_data, "force_persistence" + ) + set_settings = await imaging_service.set_imaging_settings( + settings, force_persistence + ) + await self.validate_action_status(set_settings, action, camera_identifier) + return + + if action == "brightness": + brightness = self.validate_request_data(request_data, "brightness") + force_persistence = self.validate_request_data( + request_data, "force_persistence" + ) + set_brightness = await imaging_service.set_brightness( + brightness, force_persistence + ) + await self.validate_action_status(set_brightness, action, camera_identifier) + return + + self.unknown_action(action) + + @action_handler + async def post_onvif_device( + self, + imaging_service, + camera_identifier: str, + action: str, + ): + """Handle POST requests for ONVIF Imaging actions.""" + + request_data = self.get_request_body() + + if action == "move": + focus = self.validate_request_data(request_data, "focus") + move_focus = await imaging_service.move_focus(focus) + await self.validate_action_status(move_focus, action, camera_identifier) + return + + if action == "stop": + stop_focus = await imaging_service.stop_focus() + await self.validate_action_status(stop_focus, action, camera_identifier) + return + + self.unknown_action(action) diff --git a/viseron/components/webserver/api/v1/actions/onvif/media.py b/viseron/components/webserver/api/v1/actions/onvif/media.py new file mode 100644 index 000000000..8c11b9faf --- /dev/null +++ b/viseron/components/webserver/api/v1/actions/onvif/media.py @@ -0,0 +1 @@ +"""ONVIF Media API handler.""" From ea84e0a4ce91fc9df5b934dd34c2c789f5314cdb Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Fri, 9 Jan 2026 16:38:20 +0700 Subject: [PATCH 011/120] refactor: Run gen_docs for ONVIF component --- .../components/onvif/config.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/src/pages/components-explorer/components/onvif/config.json b/docs/src/pages/components-explorer/components/onvif/config.json index f21241bf7..24651a058 100644 --- a/docs/src/pages/components-explorer/components/onvif/config.json +++ b/docs/src/pages/components-explorer/components/onvif/config.json @@ -86,11 +86,11 @@ "options": [ { "type": "constant", - "value": "Manual" + "value": "NTP" }, { "type": "constant", - "value": "NTP" + "value": "Manual" } ], "name": "datetime_type", @@ -124,15 +124,15 @@ "options": [ { "type": "constant", - "value": "IPv4" + "value": "IPv6" }, { "type": "constant", - "value": "DNS" + "value": "IPv4" }, { "type": "constant", - "value": "IPv6" + "value": "DNS" } ], "name": "ntp_type", @@ -204,15 +204,15 @@ "options": [ { "type": "constant", - "value": "OFF" + "value": "AUTO" }, { "type": "constant", - "value": "AUTO" + "value": "ON" }, { "type": "constant", - "value": "ON" + "value": "OFF" } ], "name": "ircut_filter", @@ -225,11 +225,11 @@ "options": [ { "type": "constant", - "value": "OFF" + "value": "ON" }, { "type": "constant", - "value": "ON" + "value": "OFF" } ], "name": "backlight_compensation", From 11e3a7996f0f52e25e4c2fe2464db201b73c45aa Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 05:14:31 +0700 Subject: [PATCH 012/120] feat(onvif): Add Media service implementation and docs --- .../components/onvif/config.json | 205 +++++++++++- .../components/onvif/index.mdx | 18 + viseron/components/onvif/__init__.py | 134 +++++++- viseron/components/onvif/const.py | 219 +++++++++---- viseron/components/onvif/media.py | 308 +++++++++++++++--- 5 files changed, 761 insertions(+), 123 deletions(-) diff --git a/docs/src/pages/components-explorer/components/onvif/config.json b/docs/src/pages/components-explorer/components/onvif/config.json index 24651a058..7d72d50ea 100644 --- a/docs/src/pages/components-explorer/components/onvif/config.json +++ b/docs/src/pages/components-explorer/components/onvif/config.json @@ -124,15 +124,15 @@ "options": [ { "type": "constant", - "value": "IPv6" + "value": "IPv4" }, { "type": "constant", - "value": "IPv4" + "value": "DNS" }, { "type": "constant", - "value": "DNS" + "value": "IPv6" } ], "name": "ntp_type", @@ -155,7 +155,196 @@ }, { "type": "map", - "value": [], + "value": [ + { + "type": "map", + "value": [ + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "MPEG4" + }, + { + "type": "constant", + "value": "H264" + }, + { + "type": "constant", + "value": "JPEG" + } + ], + "name": "encoding", + "description": "Used video codec, either Jpeg, H.264 or Mpeg4", + "required": true, + "default": null + }, + { + "type": "map", + "value": [ + { + "type": "integer", + "name": "width", + "description": "Number of the columns of the Video image. If there is a 90-degree rotation, this represents the number of lines of the Video image.", + "required": true, + "default": null + }, + { + "type": "integer", + "name": "height", + "description": "Number of the lines of the Video image. If there is a 90-degree rotation, this represents the number of columns of the Video image.", + "required": true, + "default": null + } + ], + "name": "resolution", + "description": "Configured video resolution", + "required": true, + "default": null + }, + { + "type": "boolean", + "name": "force_persistence", + "description": "To determine whether this video encoder setting will persist even after a device reboot.", + "optional": true, + "default": true + }, + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "ASP" + }, + { + "type": "constant", + "value": "SP" + } + ], + "name": "mpeg4_profile", + "description": "Only valid if encoding key is set to MPEG4. The Mpeg4 profile, either simple profile (SP) or advanced simple profile (ASP)", + "optional": true, + "default": null + }, + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "High" + }, + { + "type": "constant", + "value": "Baseline" + }, + { + "type": "constant", + "value": "Extended" + }, + { + "type": "constant", + "value": "Main" + } + ], + "name": "h264_profile", + "description": "Only valid if encoding key is set to H264. The H.264 profile, either baseline, main, extended or high", + "optional": true, + "default": null + }, + { + "type": "float", + "name": "quality", + "description": "Relative value for the video quantizers and the quality of the video. A high value within supported quality range means higher quality", + "optional": true, + "default": null + }, + { + "type": "integer", + "name": "frame_rate", + "description": "Maximum output framerate in fps. If an encoding_interval is provided the resulting encoded framerate will be reduced by the given factor.", + "optional": true, + "default": null + }, + { + "type": "integer", + "name": "encoding_interval", + "description": "Interval at which images are encoded and transmitted. (A value of 1 means that every frame is encoded, a value of 2 means that every 2nd frame is encoded ...)", + "optional": true, + "default": null + }, + { + "type": "integer", + "name": "bitrate_limit", + "description": "the maximum output bitrate in kbps", + "optional": true, + "default": null + }, + { + "type": "integer", + "name": "gov_length", + "description": "Determines typically the interval in which the I-Frames will be coded. An entry of 1 indicates I-Frames are continuously generated. An entry of 2 indicates that every 2nd image is an I-Frame, and 3 only every 3rd frame, etc. The frames in between are coded as P or B Frames.", + "optional": true, + "default": null + } + ], + "name": "video_encoder", + "description": "Settings applied to your camera's video encoder. These settings will be applied to the media profile that matches the RTSP url you set in the camera domain (ffmpeg or gstreamer).", + "optional": true, + "default": null + }, + { + "type": "map", + "value": [ + { + "type": "select", + "options": [ + { + "type": "constant", + "value": "AAC" + }, + { + "type": "constant", + "value": "G726" + }, + { + "type": "constant", + "value": "G711" + } + ], + "name": "encoding", + "description": "Audio codec used for encoding the audio input (either G.711, G.726 or AAC)", + "required": true, + "default": null + }, + { + "type": "boolean", + "name": "force_persistence", + "description": "To determine whether this audio encoder setting will persist even after a device reboot.", + "optional": true, + "default": true + }, + { + "type": "integer", + "name": "bitrate", + "description": "The output bitrate in kbps.", + "optional": true, + "default": null + }, + { + "type": "integer", + "name": "sample_rate", + "description": "The output sample rate in kHz.", + "optional": true, + "default": null + } + ], + "name": "audio_encoder", + "description": "Settings applied to your camera's audio encoder. These settings will be applied to the media profile that matches the RTSP url you set in the camera domain (ffmpeg or gstreamer).", + "optional": true, + "default": null + } + ], "name": "media", "description": "Media service configuration.", "optional": true, @@ -208,11 +397,11 @@ }, { "type": "constant", - "value": "ON" + "value": "OFF" }, { "type": "constant", - "value": "OFF" + "value": "ON" } ], "name": "ircut_filter", @@ -225,11 +414,11 @@ "options": [ { "type": "constant", - "value": "ON" + "value": "OFF" }, { "type": "constant", - "value": "OFF" + "value": "ON" } ], "name": "backlight_compensation", diff --git a/docs/src/pages/components-explorer/components/onvif/index.mdx b/docs/src/pages/components-explorer/components/onvif/index.mdx index 90e51b3e0..bb849dd3e 100644 --- a/docs/src/pages/components-explorer/components/onvif/index.mdx +++ b/docs/src/pages/components-explorer/components/onvif/index.mdx @@ -114,6 +114,24 @@ All Device service operations and settings can be configured via the [Camera Tun ### Media +The Media service allows you to manage and configure the video and audio streams transmitted by your ONVIF camera. This service is responsible for stream profiles, video encoder configuration, audio encoder configuration, and managing the camera's built-in OSD (On-Screen Display). + +The Media service is mandatory for ONVIF devices that provide media streaming capabilities, so all ONVIF-compatible cameras with video output will support this service. However, **not all cameras support all Media operations or configuration options**, as availability depends on the camera hardware and firmware. + +This ONVIF component does **not implement all Media service operations** defined in the ONVIF specification. For a more detailed explanation, you can refer to the [official document](https://developer.onvif.org/pub/specs/branches/development/doc/Media.xml) regarding this service. The operations implemented by this component in the Media service are described as follows: + +| No | Area | Operations | +| --- | -------- | ----------------------------------------------------------------------------------------------------- | +| 1 | Profiles | `GetProfiles`, `GetProfile`, `CreateProfile`, `DeleteProfile` | +| 2 | URI | `GetStreamUri`, `GetSnapshotUri` | +| 3 | Video | `GetVideoEncoderConfiguration`, `GetVideoEncoderConfigurationOptions`, `SetVideoEncoderConfiguration` | +| 4 | Audio | `GetAudioEncoderConfiguration`, `GetAudioEncoderConfigurationOptions`, `SetVideoEncoderConfiguration` | +| 5 | OSD | `GetOSD`, `GetOSDs`, `GetOSDOptions`, `CreateOSD`, `DeleteOSD`, `SetOSD` | + +All Media service operations and settings can be configured via the [Camera Tuning](/docs/documentation/configuration) page. + +If you decide not to use auto configuration (`auto_config` is set to `false`), this ONVIF component **assumes that you know the variable options for each key in `video_encoder` and `audio_encoder`**. If you fill them in incorrectly, an error will appear. + ### Imaging Imaging service allows you to control and configure the imaging properties of your ONVIF camera video. The Imaging service is mandatory for ONVIF camera devices with a video source, so if your ONVIF device is a camera, this service is definitely present. For a more detailed explanation, you can refer to the [official document](https://developer.onvif.org/pub/specs/branches/development/doc/Imaging.xml) regarding this service. diff --git a/viseron/components/onvif/__init__.py b/viseron/components/onvif/__init__.py index 81644d7e1..a5de21a22 100644 --- a/viseron/components/onvif/__init__.py +++ b/viseron/components/onvif/__init__.py @@ -18,7 +18,13 @@ from viseron.watchdog.thread_watchdog import RestartableThread from .const import ( + AUDIO_ENCODING_MAP, COMPONENT, + CONFIG_AUDIO_BITRATE, + CONFIG_AUDIO_ENCODER, + CONFIG_AUDIO_ENCODING, + CONFIG_AUDIO_FORCE_PERSISTENCE, + CONFIG_AUDIO_SAMPLE_RATE, CONFIG_CAMERAS, CONFIG_DEVICE, CONFIG_DEVICE_DATETIME_TYPE, @@ -72,6 +78,20 @@ CONFIG_PTZ_PRESETS, CONFIG_PTZ_REVERSE_PAN, CONFIG_PTZ_REVERSE_TILT, + CONFIG_VIDEO_BITRATE, + CONFIG_VIDEO_ENCODER, + CONFIG_VIDEO_ENCODING, + CONFIG_VIDEO_ENCODING_INTERVAL, + CONFIG_VIDEO_FORCE_PERSISTENCE, + CONFIG_VIDEO_FRAME_RATE, + CONFIG_VIDEO_GOV_LENGTH, + CONFIG_VIDEO_H264, + CONFIG_VIDEO_MPEG4, + CONFIG_VIDEO_QUALITY, + CONFIG_VIDEO_RESOLUTION, + CONFIG_VIDEO_RESOLUTION_HEIGHT, + CONFIG_VIDEO_RESOLUTION_WIDTH, + DEFAULT_AUDIO_FORCE_PERSISTENCE, DEFAULT_IMAGING_FORCE_PERSISTENCE, DEFAULT_ONVIF_AUTO_CONFIG, DEFAULT_ONVIF_TIMEOUT, @@ -81,6 +101,12 @@ DEFAULT_PTZ_PRESET_ON_STARTUP, DEFAULT_PTZ_REVERSE_PAN, DEFAULT_PTZ_REVERSE_TILT, + DEFAULT_VIDEO_FORCE_PERSISTENCE, + DESC_AUDIO_BITRATE, + DESC_AUDIO_ENCODER, + DESC_AUDIO_ENCODING, + DESC_AUDIO_FORCE_PERSISTENCE, + DESC_AUDIO_SAMPLE_RATE, DESC_CAMERAS, DESC_COMPONENT, DESC_DEVICE, @@ -134,10 +160,26 @@ DESC_PTZ_PRESETS, DESC_PTZ_REVERSE_PAN, DESC_PTZ_REVERSE_TILT, + DESC_VIDEO_BITRATE, + DESC_VIDEO_ENCODER, + DESC_VIDEO_ENCODING, + DESC_VIDEO_ENCODING_INTERVAL, + DESC_VIDEO_FORCE_PERSISTENCE, + DESC_VIDEO_FRAME_RATE, + DESC_VIDEO_GOV_LENGTH, + DESC_VIDEO_H264, + DESC_VIDEO_MPEG4, + DESC_VIDEO_QUALITY, + DESC_VIDEO_RESOLUTION, + DESC_VIDEO_RESOLUTION_HEIGHT, + DESC_VIDEO_RESOLUTION_WIDTH, DEVICE_DATETIME_TYPE_MAP, DEVICE_NTP_TYPE_MAP, IMAGING_BACKLIGHT_COMPENSATION_MAP, IMAGING_IRCUT_FILTER_MAP, + VIDEO_ENCODING_MAP, + VIDEO_H264_MAP, + VIDEO_MPEG4_MAP, ) from .device import Device from .imaging import Imaging @@ -188,8 +230,98 @@ } ) +# Video Encoder for Media Schema +VIDEO_SCHEMA = vol.Schema( + { + vol.Optional( + CONFIG_VIDEO_FORCE_PERSISTENCE, + description=DESC_VIDEO_FORCE_PERSISTENCE, + default=DEFAULT_VIDEO_FORCE_PERSISTENCE, + ): bool, + vol.Required( + CONFIG_VIDEO_ENCODING, + description=DESC_VIDEO_ENCODING, + ): vol.In(VIDEO_ENCODING_MAP), + vol.Optional( + CONFIG_VIDEO_MPEG4, + description=DESC_VIDEO_MPEG4, + ): vol.In(VIDEO_MPEG4_MAP), + vol.Optional( + CONFIG_VIDEO_H264, + description=DESC_VIDEO_H264, + ): vol.In(VIDEO_H264_MAP), + vol.Required( + CONFIG_VIDEO_RESOLUTION, + description=DESC_VIDEO_RESOLUTION, + ): vol.Schema( + { + vol.Required( + CONFIG_VIDEO_RESOLUTION_WIDTH, + description=DESC_VIDEO_RESOLUTION_WIDTH, + ): int, + vol.Required( + CONFIG_VIDEO_RESOLUTION_HEIGHT, + description=DESC_VIDEO_RESOLUTION_HEIGHT, + ): int, + } + ), + vol.Optional( + CONFIG_VIDEO_QUALITY, + description=DESC_VIDEO_QUALITY, + ): vol.Coerce(float), + vol.Optional( + CONFIG_VIDEO_FRAME_RATE, + description=DESC_VIDEO_FRAME_RATE, + ): int, + vol.Optional( + CONFIG_VIDEO_ENCODING_INTERVAL, + description=DESC_VIDEO_ENCODING_INTERVAL, + ): int, + vol.Optional( + CONFIG_VIDEO_BITRATE, + description=DESC_VIDEO_BITRATE, + ): int, + vol.Optional( + CONFIG_VIDEO_GOV_LENGTH, + description=DESC_VIDEO_GOV_LENGTH, + ): int, + } +) + +# Audio Encoder for Media Schema +AUDIO_SCHEMA = vol.Schema( + { + vol.Optional( + CONFIG_AUDIO_FORCE_PERSISTENCE, + description=DESC_AUDIO_FORCE_PERSISTENCE, + default=DEFAULT_AUDIO_FORCE_PERSISTENCE, + ): bool, + vol.Required( + CONFIG_AUDIO_ENCODING, + description=DESC_AUDIO_ENCODING, + ): vol.In(AUDIO_ENCODING_MAP), + vol.Optional( + CONFIG_AUDIO_BITRATE, + description=DESC_AUDIO_BITRATE, + ): int, + vol.Optional( + CONFIG_AUDIO_SAMPLE_RATE, + description=DESC_AUDIO_SAMPLE_RATE, + ): int, + } +) + # Media Service Schema -MEDIA_SCHEMA = vol.Schema({}) +MEDIA_SCHEMA = vol.Schema( + { + vol.Optional( + CONFIG_VIDEO_ENCODER, description=DESC_VIDEO_ENCODER + ): VIDEO_SCHEMA, + vol.Optional( + CONFIG_AUDIO_ENCODER, description=DESC_AUDIO_ENCODER + ): AUDIO_SCHEMA, + } +) # Imaging Service Schema IMAGING_SCHEMA = vol.Schema( diff --git a/viseron/components/onvif/const.py b/viseron/components/onvif/const.py index 4d58e4aa4..ff5b7b938 100644 --- a/viseron/components/onvif/const.py +++ b/viseron/components/onvif/const.py @@ -21,6 +21,22 @@ DEFAULT_ONVIF_VERIFY_SSL = True DEFAULT_ONVIF_AUTO_CONFIG = True +DESC_CAMERAS = "List of ONVIF cameras to make available to the component." +DESC_ONVIF_PORT = "ONVIF port of the camera." +DESC_ONVIF_USERNAME = "ONVIF username for the camera." +DESC_ONVIF_PASSWORD = "ONVIF password for the camera." + +DESC_ONVIF_TIMEOUT = "Timeout for ONVIF connections in seconds." +DESC_ONVIF_USE_HTTPS = "Use HTTPS for ONVIF connections." +DESC_ONVIF_VERIFY_SSL = "Verify SSL certificates for ONVIF connections." +DESC_ONVIF_WSDL_DIR = "Path to custom WSDL directory for ONVIF client." +DESC_ONVIF_AUTO_CONFIG = ( + "Set to true then it will ignore all configuration per each " + "service and use the default service that is already on the ONVIF camera. Don't " + "worry! This ONVIF component will automatically detect the existing " + "configuration in the ONVIF camera precisely." +) + """ If all the service configurations below are filled in, then when Viseron starts up all these configurations will be overridden to the ONVIF device and only if the auto_config @@ -41,72 +57,6 @@ DEVICE_NTP_TYPE_MAP = {"DNS", "IPv4", "IPv6"} CONFIG_DEVICE_NTP_SERVER = "ntp_server" -# ONVIF IMAGING CONFIG -CONFIG_IMAGING = "imaging" -CONFIG_IMAGING_FORCE_PERSISTENCE = "force_persistence" -CONFIG_IMAGING_BRIGHTNESS = "brightness" -CONFIG_IMAGING_COLOR_SATURATION = "color_saturation" -CONFIG_IMAGING_CONTRAST = "contrast" -CONFIG_IMAGING_SHARPNESS = "sharpness" -CONFIG_IMAGING_IRCUT_FILTER = "ircut_filter" -IMAGING_IRCUT_FILTER_MAP = {"ON", "OFF", "AUTO"} -CONFIG_IMAGING_BACKLIGHT_COMPENSATION = "backlight_compensation" -IMAGING_BACKLIGHT_COMPENSATION_MAP = {"ON", "OFF"} -CONFIG_IMAGING_EXPOSURE = "exposure" -CONFIG_IMAGING_FOCUS = "focus" -CONFIG_IMAGING_WIDE_DYNAMIC_RANGE = "wide_dynamic_range" -CONFIG_IMAGING_WHITE_BALANCE = "white_balance" -CONFIG_IMAGING_IMAGE_STABILIZATION = "image_stabilization" -CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT = "ircut_filter_auto_adjustment" -CONFIG_IMAGING_TONE_COMPENSATION = "tone_compensation" -CONFIG_IMAGING_DEFOGGING = "defogging" -CONFIG_IMAGING_NOISE_REDUCTION = "noise_reduction" - -DEFAULT_IMAGING_FORCE_PERSISTENCE = True - -# ONVIF MEDIA CONFIG -CONFIG_MEDIA = "media" - -# ONVIF PTZ CONFIG -CONFIG_PTZ = "ptz" -CONFIG_PTZ_HOME_POSITION = "home_position" -CONFIG_PTZ_REVERSE_PAN = "reverse_pan" -CONFIG_PTZ_REVERSE_TILT = "reverse_tilt" -CONFIG_PTZ_MIN_PAN = "min_pan" -CONFIG_PTZ_MAX_PAN = "max_pan" -CONFIG_PTZ_MIN_TILT = "min_tilt" -CONFIG_PTZ_MAX_TILT = "max_tilt" -CONFIG_PTZ_MIN_ZOOM = "min_zoom" -CONFIG_PTZ_MAX_ZOOM = "max_zoom" -CONFIG_PTZ_PRESETS = "presets" -CONFIG_PTZ_PRESET_NAME = "name" -CONFIG_PTZ_PRESET_PAN = "pan" -CONFIG_PTZ_PRESET_TILT = "tilt" -CONFIG_PTZ_PRESET_ZOOM = "zoom" -CONFIG_PTZ_PRESET_ON_STARTUP = "on_startup" - -DEFAULT_PTZ_HOME_POSITION = False -DEFAULT_PTZ_REVERSE_PAN = False -DEFAULT_PTZ_REVERSE_TILT = False -DEFAULT_PTZ_PRESET_ON_STARTUP = False - -# ONVIF CONFIG DESCRIPTIONS -DESC_CAMERAS = "List of ONVIF cameras to make available to the component." -DESC_ONVIF_PORT = "ONVIF port of the camera." -DESC_ONVIF_USERNAME = "ONVIF username for the camera." -DESC_ONVIF_PASSWORD = "ONVIF password for the camera." - -DESC_ONVIF_TIMEOUT = "Timeout for ONVIF connections in seconds." -DESC_ONVIF_USE_HTTPS = "Use HTTPS for ONVIF connections." -DESC_ONVIF_VERIFY_SSL = "Verify SSL certificates for ONVIF connections." -DESC_ONVIF_WSDL_DIR = "Path to custom WSDL directory for ONVIF client." -DESC_ONVIF_AUTO_CONFIG = ( - "Set to true then it will ignore all configuration per each " - "service and use the default service that is already on the ONVIF camera. Don't " - "worry! This ONVIF component will automatically detect the existing " - "configuration in the ONVIF camera precisely." -) - DESC_DEVICE = "Device service configuration." DESC_DEVICE_HOSTNAME = "The hostname of the device." DESC_DEVICE_DISCOVERABLE = ( @@ -133,9 +83,28 @@ "key is set to true. " ) +# ONVIF IMAGING CONFIG +CONFIG_IMAGING = "imaging" +CONFIG_IMAGING_FORCE_PERSISTENCE = "force_persistence" +CONFIG_IMAGING_BRIGHTNESS = "brightness" +CONFIG_IMAGING_COLOR_SATURATION = "color_saturation" +CONFIG_IMAGING_CONTRAST = "contrast" +CONFIG_IMAGING_SHARPNESS = "sharpness" +CONFIG_IMAGING_IRCUT_FILTER = "ircut_filter" +IMAGING_IRCUT_FILTER_MAP = {"ON", "OFF", "AUTO"} +CONFIG_IMAGING_BACKLIGHT_COMPENSATION = "backlight_compensation" +IMAGING_BACKLIGHT_COMPENSATION_MAP = {"ON", "OFF"} +CONFIG_IMAGING_EXPOSURE = "exposure" +CONFIG_IMAGING_FOCUS = "focus" +CONFIG_IMAGING_WIDE_DYNAMIC_RANGE = "wide_dynamic_range" +CONFIG_IMAGING_WHITE_BALANCE = "white_balance" +CONFIG_IMAGING_IMAGE_STABILIZATION = "image_stabilization" +CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT = "ircut_filter_auto_adjustment" +CONFIG_IMAGING_TONE_COMPENSATION = "tone_compensation" +CONFIG_IMAGING_DEFOGGING = "defogging" +CONFIG_IMAGING_NOISE_REDUCTION = "noise_reduction" -DESC_MEDIA = "Media service configuration." - +DEFAULT_IMAGING_FORCE_PERSISTENCE = True DESC_IMAGING = "Imaging service configuration." DESC_IMAGING_FORCE_PERSISTENCE = ( @@ -166,6 +135,120 @@ DESC_IMAGING_DEFOGGING = "Optional element to configure Image Defogging." DESC_IMAGING_NOISE_REDUCTION = "Optional element to configure Image Noise Reduction." +# ONVIF MEDIA CONFIG +CONFIG_MEDIA = "media" +CONFIG_VIDEO_ENCODER = "video_encoder" +CONFIG_VIDEO_FORCE_PERSISTENCE = "force_persistence" +CONFIG_VIDEO_ENCODING = "encoding" +VIDEO_ENCODING_MAP = {"JPEG", "MPEG4", "H264"} +CONFIG_VIDEO_MPEG4 = "mpeg4_profile" +VIDEO_MPEG4_MAP = {"SP", "ASP"} +CONFIG_VIDEO_H264 = "h264_profile" +VIDEO_H264_MAP = {"Baseline", "Main", "Extended", "High"} +CONFIG_VIDEO_RESOLUTION = "resolution" +CONFIG_VIDEO_RESOLUTION_WIDTH = "width" +CONFIG_VIDEO_RESOLUTION_HEIGHT = "height" +CONFIG_VIDEO_QUALITY = "quality" +CONFIG_VIDEO_FRAME_RATE = "frame_rate" +CONFIG_VIDEO_ENCODING_INTERVAL = "encoding_interval" +CONFIG_VIDEO_BITRATE = "bitrate_limit" +CONFIG_VIDEO_GOV_LENGTH = "gov_length" +CONFIG_AUDIO_ENCODER = "audio_encoder" +CONFIG_AUDIO_FORCE_PERSISTENCE = "force_persistence" +CONFIG_AUDIO_ENCODING = "encoding" +AUDIO_ENCODING_MAP = {"G711", "G726", "AAC"} +CONFIG_AUDIO_BITRATE = "bitrate" +CONFIG_AUDIO_SAMPLE_RATE = "sample_rate" + +DEFAULT_VIDEO_FORCE_PERSISTENCE = True +DEFAULT_AUDIO_FORCE_PERSISTENCE = True + +DESC_MEDIA = "Media service configuration." +DESC_VIDEO_ENCODER = ( + "Settings applied to your camera's video encoder. These settings will be applied " + "to the media profile that matches the RTSP url you set in the camera domain" + " (ffmpeg or gstreamer)." +) +DESC_VIDEO_FORCE_PERSISTENCE = ( + "To determine whether this video encoder setting will persist even after a " + "device reboot." +) +DESC_VIDEO_ENCODING = "Used video codec, either Jpeg, H.264 or Mpeg4" +DESC_VIDEO_MPEG4 = ( + "Only valid if encoding key is set to MPEG4. The Mpeg4 " + "profile, either simple profile (SP) or advanced simple profile (ASP)" +) +DESC_VIDEO_H264 = ( + "Only valid if encoding key is set to H264. The H.264 " + "profile, either baseline, main, extended or high" +) +DESC_VIDEO_RESOLUTION = "Configured video resolution" +DESC_VIDEO_RESOLUTION_WIDTH = ( + "Number of the columns of the Video image. If there is a 90-degree rotation, this " + "represents the number of lines of the Video image." +) +DESC_VIDEO_RESOLUTION_HEIGHT = ( + "Number of the lines of the Video image. If there is a 90-degree rotation, this " + "represents the number of columns of the Video image." +) +DESC_VIDEO_QUALITY = ( + "Relative value for the video quantizers and the quality of the video. A high " + "value within supported quality range means higher quality" +) +DESC_VIDEO_FRAME_RATE = ( + "Maximum output framerate in fps. If an encoding_interval is provided " + "the resulting encoded framerate will be reduced by the given factor." +) +DESC_VIDEO_ENCODING_INTERVAL = ( + "Interval at which images are encoded and transmitted. (A value of 1 means that " + "every frame is encoded, a value of 2 means that every 2nd frame is encoded ...)" +) +DESC_VIDEO_BITRATE = "the maximum output bitrate in kbps" +DESC_VIDEO_GOV_LENGTH = ( + "Determines typically the interval in which the I-Frames will be coded. An entry " + "of 1 indicates I-Frames are continuously generated. An entry of 2 indicates that " + "every 2nd image is an I-Frame, and 3 only every 3rd frame, etc. The frames in " + "between are coded as P or B Frames." +) +DESC_AUDIO_ENCODER = ( + "Settings applied to your camera's audio encoder. These settings will be applied " + "to the media profile that matches the RTSP url you set in the camera domain" + " (ffmpeg or gstreamer)." +) +DESC_AUDIO_FORCE_PERSISTENCE = ( + "To determine whether this audio encoder setting will persist even after a " + "device reboot." +) +DESC_AUDIO_ENCODING = ( + "Audio codec used for encoding the audio input (either G.711, G.726 or AAC)" +) +DESC_AUDIO_BITRATE = "The output bitrate in kbps." +DESC_AUDIO_SAMPLE_RATE = "The output sample rate in kHz." + +# ONVIF PTZ CONFIG +CONFIG_PTZ = "ptz" +CONFIG_PTZ_HOME_POSITION = "home_position" +CONFIG_PTZ_REVERSE_PAN = "reverse_pan" +CONFIG_PTZ_REVERSE_TILT = "reverse_tilt" +CONFIG_PTZ_MIN_PAN = "min_pan" +CONFIG_PTZ_MAX_PAN = "max_pan" +CONFIG_PTZ_MIN_TILT = "min_tilt" +CONFIG_PTZ_MAX_TILT = "max_tilt" +CONFIG_PTZ_MIN_ZOOM = "min_zoom" +CONFIG_PTZ_MAX_ZOOM = "max_zoom" +CONFIG_PTZ_PRESETS = "presets" +CONFIG_PTZ_PRESET_NAME = "name" +CONFIG_PTZ_PRESET_PAN = "pan" +CONFIG_PTZ_PRESET_TILT = "tilt" +CONFIG_PTZ_PRESET_ZOOM = "zoom" +CONFIG_PTZ_PRESET_ON_STARTUP = "on_startup" + +DEFAULT_PTZ_HOME_POSITION = False +DEFAULT_PTZ_REVERSE_PAN = False +DEFAULT_PTZ_REVERSE_TILT = False +DEFAULT_PTZ_PRESET_ON_STARTUP = False DESC_PTZ = "PTZ service configuration." DESC_PTZ_HOME_POSITION = ( diff --git a/viseron/components/onvif/media.py b/viseron/components/onvif/media.py index 7101ce188..7a90221b1 100644 --- a/viseron/components/onvif/media.py +++ b/viseron/components/onvif/media.py @@ -6,7 +6,29 @@ from onvif import ONVIFClient -from .utils import operation +from .const import ( + CONFIG_AUDIO_BITRATE, + CONFIG_AUDIO_ENCODER, + CONFIG_AUDIO_ENCODING, + CONFIG_AUDIO_FORCE_PERSISTENCE, + CONFIG_AUDIO_SAMPLE_RATE, + CONFIG_VIDEO_BITRATE, + CONFIG_VIDEO_ENCODER, + CONFIG_VIDEO_ENCODING, + CONFIG_VIDEO_ENCODING_INTERVAL, + CONFIG_VIDEO_FORCE_PERSISTENCE, + CONFIG_VIDEO_FRAME_RATE, + CONFIG_VIDEO_GOV_LENGTH, + CONFIG_VIDEO_H264, + CONFIG_VIDEO_MPEG4, + CONFIG_VIDEO_QUALITY, + CONFIG_VIDEO_RESOLUTION, + CONFIG_VIDEO_RESOLUTION_HEIGHT, + CONFIG_VIDEO_RESOLUTION_WIDTH, + DEFAULT_AUDIO_FORCE_PERSISTENCE, + DEFAULT_VIDEO_FORCE_PERSISTENCE, +) +from .utils import find_matching_profile_token, operation if TYPE_CHECKING: from viseron.domains.camera import AbstractCamera @@ -29,6 +51,7 @@ def __init__( self._config = config self._auto_config = auto_config self._media_service: Any = None + self._selected_profile: Any = None self._profiles: list[Any] = [] async def initialize(self) -> None: @@ -37,12 +60,37 @@ async def initialize(self) -> None: # Load media profiles self._profiles = await self.get_profiles() + if self._profiles: + # Try to find matching profile based on camera's RTSP URL + self._selected_profile = await find_matching_profile_token( + self._camera, self._media_service, self._profiles + ) + + if self._selected_profile: + LOGGER.debug( + f"Using matching profile {self._selected_profile.token} for " + f"Media service on camera {self._camera.identifier}" + ) + else: + # Fallback to first profile + self._selected_profile = self._profiles[0] + LOGGER.warning( + f"No matching profile found, using first profile for " + f"Media service on camera {self._camera.identifier}" + ) + else: + LOGGER.warning( + f"No media profiles found for {self._camera.identifier}, " + "Media operations may not work correctly" + ) if not self._auto_config and self._config: await self.apply_config() # ## The Real Operations ## # + # ---- Profiles Operations ---- # + @operation() async def get_profiles(self) -> Any: """Get media profiles.""" @@ -53,85 +101,129 @@ async def get_profile(self, profile_token: str) -> Any: """Get a specific media profile.""" return self._media_service.GetProfile(ProfileToken=profile_token) + @operation() + async def create_profile(self, name: str, token: str | None = None) -> Any: + """Create a new media profile.""" + return self._media_service.CreateProfile(Name=name, Token=token) + + @operation() + async def delete_profile(self, profile_token: str) -> bool: + """Delete a media profile.""" + self._media_service.DeleteProfile(ProfileToken=profile_token) + + return True + + # ---- URI Operations ---- # + @operation() async def get_stream_uri( - self, profile_token: str | None = None, stream_type: str = "RTP-Unicast" + self, + profile_token: str | None = None, + stream_type: str = "RTP-Unicast", + protocol: str = "RTSP", ) -> Any: """Get stream URI for a profile.""" - stream_setup = {"Stream": stream_type, "Transport": {"Protocol": "RTSP"}} + stream_setup = {"Stream": stream_type, "Transport": {"Protocol": protocol}} return self._media_service.GetStreamUri( - StreamSetup=stream_setup, ProfileToken=profile_token + StreamSetup=stream_setup, + ProfileToken=profile_token or self._selected_profile.token, ) @operation() async def get_snapshot_uri(self, profile_token: str | None = None) -> Any: """Get snapshot URI for a profile.""" - return self._media_service.GetSnapshotUri(ProfileToken=profile_token) - - @operation() - async def get_video_sources(self) -> Any: - """Get available video sources.""" - return self._media_service.GetVideoSources() - - @operation() - async def get_video_source_configurations(self) -> Any: - """Get video source configurations.""" - return self._media_service.GetVideoSourceConfigurations() - - @operation() - async def get_video_encoder_configurations(self) -> Any: - """Get video encoder configurations.""" - return self._media_service.GetVideoEncoderConfigurations() + return self._media_service.GetSnapshotUri( + ProfileToken=profile_token or self._selected_profile.token + ) - @operation() - async def get_audio_sources(self) -> Any: - """Get available audio sources.""" - return self._media_service.GetAudioSources() + # ---- Video Operations ---- # @operation() - async def get_audio_source_configurations(self) -> Any: - """Get audio source configurations.""" - return self._media_service.GetAudioSourceConfigurations() + async def get_video_encoder_configuration( + self, config_token: str | None = None + ) -> Any: + """Get video encoder configuration.""" + return self._media_service.GetVideoEncoderConfiguration( + ConfigurationToken=config_token + or self._selected_profile.VideoEncoderConfiguration.token + ) @operation() - async def get_audio_encoder_configurations(self) -> Any: - """Get audio encoder configurations.""" - return self._media_service.GetAudioEncoderConfigurations() + async def get_video_encoder_configuration_options( + self, config_token: str | None = None + ) -> Any: + """Get video encoder configuration options.""" + return self._media_service.GetVideoEncoderConfigurationOptions( + ConfigurationToken=config_token + or self._selected_profile.VideoEncoderConfiguration.token + ) @operation() async def set_video_encoder_configuration( self, configuration: dict[str, Any], force_persistence: bool = True ) -> bool: """Set video encoder configuration for a profile.""" + + if not configuration.get("token"): + configuration[ + "token" + ] = self._selected_profile.VideoEncoderConfiguration.token + + if not configuration.get("Name"): + configuration[ + "Name" + ] = self._selected_profile.VideoEncoderConfiguration.Name + self._media_service.SetVideoEncoderConfiguration( Configuration=configuration, ForcePersistence=force_persistence ) - return True + # ---- Audio Operations ---- # + @operation() - async def set_video_source_configuration( - self, configuration: dict[str, Any], force_persistence: bool = True - ) -> bool: - """Set video source configuration for a profile.""" - self._media_service.SetVideoSourceConfiguration( - Configuration=configuration, ForcePersistence=force_persistence + async def get_audio_encoder_configuration( + self, config_token: str | None = None + ) -> Any: + """Get audio encoder configurations.""" + return self._media_service.GetAudioEncoderConfiguration( + ConfigurationToken=config_token + or self._selected_profile.AudioEncoderConfiguration.token ) - return True - @operation() - async def create_profile(self, name: str, token: str | None = None) -> Any: - """Create a new media profile.""" - return self._media_service.CreateProfile(Name=name, Token=token) + async def get_audio_encoder_configuration_options( + self, config_token: str | None = None + ) -> Any: + """Get audio encoder configuration options.""" + return self._media_service.GetAudioEncoderConfigurationOptions( + ConfigurationToken=config_token + or self._selected_profile.AudioEncoderConfiguration.token + ) @operation() - async def delete_profile(self, profile_token: str) -> bool: - """Delete a media profile.""" - self._media_service.DeleteProfile(ProfileToken=profile_token) + async def set_audio_encoder_configuration( + self, configuration: dict[str, Any], force_persistence: bool = True + ) -> bool: + """Set audio encoder configuration for a profile.""" + + if not configuration.get("token"): + configuration[ + "token" + ] = self._selected_profile.AudioEncoderConfiguration.token + if not configuration.get("Name"): + configuration[ + "Name" + ] = self._selected_profile.AudioEncoderConfiguration.Name + + self._media_service.SetAudioEncoderConfiguration( + Configuration=configuration, ForcePersistence=force_persistence + ) return True + # ---- OSD Operations ---- # + # ## Profile Accessors ## # def get_cached_profiles(self): @@ -153,4 +245,128 @@ def get_profile_by_token(self, token: str): async def apply_config(self) -> bool: """Apply all configured device settings from config.""" - return True + try: + set_video_encoder_config = False + set_audio_encoder_config = False + + # ---- Video Encoder config ---- + + if CONFIG_VIDEO_ENCODER in self._config: + video_force_persistence = self._config[CONFIG_VIDEO_ENCODER].get( + CONFIG_VIDEO_FORCE_PERSISTENCE, DEFAULT_VIDEO_FORCE_PERSISTENCE + ) + + video_config = { + "token": self._selected_profile.VideoEncoderConfiguration.token, + "Name": self._selected_profile.VideoEncoderConfiguration.Name, + "Encoding": self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_ENCODING + ], + "Resolution": { + "Width": self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_RESOLUTION + ][CONFIG_VIDEO_RESOLUTION_WIDTH], + "Height": self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_RESOLUTION + ][CONFIG_VIDEO_RESOLUTION_HEIGHT], + }, + } + + if CONFIG_VIDEO_QUALITY in self._config[CONFIG_VIDEO_ENCODER]: + video_config["Quality"] = self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_QUALITY + ] + + rate_control = {} + + if CONFIG_VIDEO_FRAME_RATE in self._config[CONFIG_VIDEO_ENCODER]: + rate_control["FrameRateLimit"] = self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_FRAME_RATE + ] + + if CONFIG_VIDEO_ENCODING_INTERVAL in self._config[CONFIG_VIDEO_ENCODER]: + rate_control["EncodingInterval"] = self._config[ + CONFIG_VIDEO_ENCODER + ][CONFIG_VIDEO_ENCODING_INTERVAL] + + if CONFIG_VIDEO_BITRATE in self._config[CONFIG_VIDEO_ENCODER]: + rate_control["BitrateLimit"] = self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_BITRATE + ] + + if rate_control: + video_config["RateControl"] = rate_control + + if self._config[CONFIG_VIDEO_ENCODER][CONFIG_VIDEO_ENCODING] == "H264": + h264_config = video_config.setdefault("H264", {}) + if CONFIG_VIDEO_H264 in self._config[CONFIG_VIDEO_ENCODER]: + h264_config["H264Profile"] = self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_H264 + ] + if CONFIG_VIDEO_GOV_LENGTH in self._config[CONFIG_VIDEO_ENCODER]: + h264_config["GovLength"] = self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_GOV_LENGTH + ] + + if self._config[CONFIG_VIDEO_ENCODER][CONFIG_VIDEO_ENCODING] == "MPEG4": + mpeg4_config = video_config.setdefault("MPEG4", {}) + if CONFIG_VIDEO_MPEG4 in self._config[CONFIG_VIDEO_ENCODER]: + mpeg4_config["Mpeg4Profile"] = self._config[ + CONFIG_VIDEO_ENCODER + ][CONFIG_VIDEO_MPEG4] + if CONFIG_VIDEO_GOV_LENGTH in self._config[CONFIG_VIDEO_ENCODER]: + mpeg4_config["GovLength"] = self._config[CONFIG_VIDEO_ENCODER][ + CONFIG_VIDEO_GOV_LENGTH + ] + + set_video_encoder_config = await self.set_video_encoder_configuration( + video_config, video_force_persistence + ) + + # ---- Audio Encoder config ---- + + if CONFIG_AUDIO_ENCODER in self._config: + audio_force_persistence = self._config[CONFIG_AUDIO_ENCODER].get( + CONFIG_AUDIO_FORCE_PERSISTENCE, DEFAULT_AUDIO_FORCE_PERSISTENCE + ) + + audio_config = { + "token": self._selected_profile.AudioEncoderConfiguration.token, + "Name": self._selected_profile.AudioEncoderConfiguration.Name, + "Encoding": self._config[CONFIG_AUDIO_ENCODER][ + CONFIG_AUDIO_ENCODING + ], + } + + if CONFIG_AUDIO_BITRATE in self._config[CONFIG_AUDIO_ENCODER]: + audio_config["Bitrate"] = self._config[CONFIG_AUDIO_ENCODER][ + CONFIG_AUDIO_BITRATE + ] + + if CONFIG_AUDIO_SAMPLE_RATE in self._config[CONFIG_AUDIO_ENCODER]: + audio_config["SampleRate"] = self._config[CONFIG_AUDIO_ENCODER][ + CONFIG_AUDIO_SAMPLE_RATE + ] + + set_audio_encoder_config = await self.set_audio_encoder_configuration( + audio_config, audio_force_persistence + ) + + if set_video_encoder_config or set_audio_encoder_config: + LOGGER.info( + f"Media service configuration for {self._camera.identifier} " + f"has been applied." + ) + return True + + LOGGER.error( + f"Error applying Imaging service configuration for " + f"{self._camera.identifier}!" + ) + return False + except (ValueError, AttributeError) as error: + LOGGER.error( + f"Error applying Media service configuration for " + f"{self._camera.identifier}: {error}" + ) + return False From 063529ac7ced31706534a392cf12c1ed49ae95a8 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 05:44:22 +0700 Subject: [PATCH 013/120] refactor(onvif): Change how selected media profile is used --- viseron/components/onvif/imaging.py | 35 +++++--------------------- viseron/components/onvif/media.py | 8 +++--- viseron/components/onvif/ptz.py | 38 +++-------------------------- 3 files changed, 14 insertions(+), 67 deletions(-) diff --git a/viseron/components/onvif/imaging.py b/viseron/components/onvif/imaging.py index 7df8386a5..f4d5d81be 100644 --- a/viseron/components/onvif/imaging.py +++ b/viseron/components/onvif/imaging.py @@ -27,7 +27,7 @@ CONFIG_IMAGING_WIDE_DYNAMIC_RANGE, DEFAULT_IMAGING_FORCE_PERSISTENCE, ) -from .utils import find_matching_profile_token, operation +from .utils import operation if TYPE_CHECKING: from viseron.domains.camera import AbstractCamera @@ -53,6 +53,7 @@ def __init__( self._media_service = ( media_service # you can't use imaging without media service ) + self._media_profile: Any = None # selected media profile self._imaging_service: Any = None self._video_source_token: str | None = None @@ -61,34 +62,10 @@ async def initialize(self) -> None: self._imaging_service = self._client.imaging() - profiles = self._media_service.get_cached_profiles() - if profiles: - # Try to find matching profile based on camera's RTSP URL - matching_profile = await find_matching_profile_token( - self._camera, self._media_service, profiles - ) - if matching_profile: - self._video_source_token = ( - matching_profile.VideoSourceConfiguration.SourceToken - ) - LOGGER.debug( - f"Using matching profile {matching_profile.token} for " - f"Imaging service on camera {self._camera.identifier}" - ) - else: - # Fallback to first profile - self._video_source_token = profiles[ - 0 - ].VideoSourceConfiguration.SourceToken - LOGGER.warning( - f"No matching profile found, using first profile for " - f"Imaging service on camera {self._camera.identifier}" - ) - else: - LOGGER.warning( - f"No media profiles found for {self._camera.identifier}, " - "Imaging operations may not work correctly" - ) + self._media_profile = self._media_service.get_selected_profile() + self._video_source_token = ( + self._media_profile.VideoSourceConfiguration.SourceToken + ) if not self._auto_config and self._config: await self.apply_config() diff --git a/viseron/components/onvif/media.py b/viseron/components/onvif/media.py index 7a90221b1..04680e007 100644 --- a/viseron/components/onvif/media.py +++ b/viseron/components/onvif/media.py @@ -63,7 +63,7 @@ async def initialize(self) -> None: if self._profiles: # Try to find matching profile based on camera's RTSP URL self._selected_profile = await find_matching_profile_token( - self._camera, self._media_service, self._profiles + self._camera, self, self._profiles ) if self._selected_profile: @@ -226,9 +226,9 @@ async def set_audio_encoder_configuration( # ## Profile Accessors ## # - def get_cached_profiles(self): - """Get cached media profiles without making ONVIF call.""" - return self._profiles + def get_selected_profile(self): + """Get selected media profile without making ONVIF call.""" + return self._selected_profile def get_primary_profile(self): """Get the primary (first) media profile.""" diff --git a/viseron/components/onvif/ptz.py b/viseron/components/onvif/ptz.py index fb8f74e50..b64dacf8a 100644 --- a/viseron/components/onvif/ptz.py +++ b/viseron/components/onvif/ptz.py @@ -25,7 +25,7 @@ CONFIG_PTZ_REVERSE_PAN, CONFIG_PTZ_REVERSE_TILT, ) -from .utils import find_matching_profile_token, operation +from .utils import operation if TYPE_CHECKING: from viseron.domains.camera import AbstractCamera @@ -59,40 +59,10 @@ async def initialize(self) -> None: """Initialize the PTZ service.""" self._ptz_service = self._client.ptz() - if self._media_service is None: - LOGGER.warning( - f"Media service not available for {self._camera.identifier}, " - "PTZ operations may not work correctly" - ) - return - - profiles = self._media_service.get_cached_profiles() - if profiles: - # Try to find matching profile based on camera's RTSP URL - self._media_profile = await find_matching_profile_token( - self._camera, self._media_service, profiles - ) + self._media_profile = self._media_service.get_selected_profile() - if self._media_profile: - LOGGER.debug( - f"Using matching profile {self._media_profile.token} for " - f"PTZ service on camera {self._camera.identifier}" - ) - else: - # Fallback to first profile - self._media_profile = profiles[0] - LOGGER.warning( - f"No matching profile found, using first profile for " - f"PTZ service on camera {self._camera.identifier}" - ) - - self._ptz_config = await self.get_configurations() - self._ptz_config_options = await self.get_configuration_options() - else: - LOGGER.warning( - f"No media profiles found for {self._camera.identifier}, " - "PTZ operations may not work correctly" - ) + self._ptz_config = await self.get_configurations() + self._ptz_config_options = await self.get_configuration_options() if not self._auto_config and self._config: await self.apply_config() From bb8aeb6bcaf6657feecfdf2be3eb9036bd33e0b6 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 06:24:21 +0700 Subject: [PATCH 014/120] feat(docs): Add ONVIF-compatible description for viseron --- docs/docs/documentation.md | 1 + docs/src/pages/index.tsx | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/docs/documentation.md b/docs/docs/documentation.md index df8d086ce..5a1ea5f63 100644 --- a/docs/docs/documentation.md +++ b/docs/docs/documentation.md @@ -14,6 +14,7 @@ Viserons features include, but not limited to the following: - Tiered storage, allowing multiple storage media with different retention policies - A timeline view of events - Built in authentication system +- [ONVIF](https://www.onvif.org/) compatible with Profile S - Object detection via: - YOLOv3, YOLOv4 and YOLOv7 Darknet using OpenCV - Tensorflow via [Google Coral EdgeTPU](https://coral.ai/) diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 86da40d25..d9dbec65c 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -7,7 +7,7 @@ import { Demo, FaceActivated, GroupObjects, - Help, + CheckmarkOutline, ImageReference, Movement, Video, @@ -59,6 +59,15 @@ function HomepageHeader() { + + +
+
ONVIF Compatible
+
+ Control and configure ONVIF cameras +
+
+ - - -
-
Upcoming Features
-
- And more features in the future.. -
-
- From a6861f9f21ea3519328accf09e16e884ab09b674 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 06:28:15 +0700 Subject: [PATCH 015/120] feat(ui/onvif): Add ONVIF PTZ controls and handlers --- .../actions/ptz/OnvifPtzController.tsx | 790 ++++++++++++++++++ .../actions/ptz/useOnvifPtzHandlers.ts | 312 +++++++ frontend/src/components/player/PlayerMenu.tsx | 38 +- frontend/src/lib/api/actions/onvif/ptz.ts | 282 +++++++ frontend/src/lib/api/actions/onvif/types.ts | 88 ++ frontend/src/lib/types.ts | 1 + frontend/src/pages/Live.tsx | 4 +- 7 files changed, 1509 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/actions/ptz/OnvifPtzController.tsx create mode 100644 frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts create mode 100644 frontend/src/lib/api/actions/onvif/ptz.ts create mode 100644 frontend/src/lib/api/actions/onvif/types.ts diff --git a/frontend/src/components/actions/ptz/OnvifPtzController.tsx b/frontend/src/components/actions/ptz/OnvifPtzController.tsx new file mode 100644 index 000000000..dab80789f --- /dev/null +++ b/frontend/src/components/actions/ptz/OnvifPtzController.tsx @@ -0,0 +1,790 @@ +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + Close, + DataCollection, + Flag, + Home, + ImageSearchAlt, + Move, + StopFilledAlt, + TrashCan, + ZAxis, + ZoomIn, + ZoomOut, +} from "@carbon/icons-react"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + FormControl, + FormControlLabel, + IconButton, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Slider, + Switch, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { useEffect, useState } from "react"; + +import { CustomFab } from "components/player/CustomControls"; +import { + useGetPtzConfig, + useGetPtzNodes, + useGetPtzPresets, +} from "lib/api/actions/onvif/ptz"; + +import { useOnvifPtzHandlers } from "./useOnvifPtzHandlers"; + +interface OnvifPtzControllerProps { + cameraIdentifier: string; +} + +export function OnvifPtzController({ + cameraIdentifier, +}: OnvifPtzControllerProps) { + const theme = useTheme(); + const [drawerOpen, setDrawerOpen] = useState(false); + const [presetsDialogOpen, setPresetsDialogOpen] = useState(false); + const [savePresetDialogOpen, setSavePresetDialogOpen] = useState(false); + const [setHomeDialogOpen, setSetHomeDialogOpen] = useState(false); + const [removePresetDialogOpen, setRemovePresetDialogOpen] = useState(false); + const [newPresetName, setNewPresetName] = useState(""); + const [selectedPresetToken, setSelectedPresetToken] = useState(""); + const [selectedPresetName, setSelectedPresetName] = useState(""); + const [moveSpeed, setMoveSpeed] = useState(0.5); + + const { data: nodesData } = useGetPtzNodes(cameraIdentifier); + + // Extract capabilities from nodes + const ptzNode = nodesData?.nodes?.[0]; + const supportsPresets = + ptzNode?.MaximumNumberOfPresets && ptzNode.MaximumNumberOfPresets > 0; + const supportsHome = ptzNode?.HomeSupported === true; + const supportsAbsoluteMove = + !!ptzNode?.SupportedPTZSpaces?.AbsolutePanTiltPositionSpace?.length; // To determine whether Absolute Move is supported by the device for user-defined PTZ presets + const supportsZoom = + !!ptzNode?.SupportedPTZSpaces?.ContinuousZoomVelocitySpace?.length; // To determine whether zoom in Continuous Move is supported + + // Get velocity ranges from nodes (Continuous Move) + const panTiltSpace = + ptzNode?.SupportedPTZSpaces?.ContinuousPanTiltVelocitySpace?.[0]; + const zoomSpace = + ptzNode?.SupportedPTZSpaces?.ContinuousZoomVelocitySpace?.[0]; + + const panTiltMinMax = { + xMin: panTiltSpace?.XRange?.Min ?? -1.0, + xMax: panTiltSpace?.XRange?.Max ?? 1.0, + yMin: panTiltSpace?.YRange?.Min ?? -1.0, + yMax: panTiltSpace?.YRange?.Max ?? 1.0, + }; + + const zoomMinMax = { + min: zoomSpace?.XRange?.Min ?? -1.0, + max: zoomSpace?.XRange?.Max ?? 1.0, + }; + + // Get speed ranges from nodes + const panTiltSpeedSpace = ptzNode?.SupportedPTZSpaces?.PanTiltSpeedSpace?.[0]; + const zoomSpeedSpace = ptzNode?.SupportedPTZSpaces?.ZoomSpeedSpace?.[0]; + + const speedMinMax = { + panTiltMin: panTiltSpeedSpace?.XRange?.Min ?? 0.0, + panTiltMax: panTiltSpeedSpace?.XRange?.Max ?? 1.0, + zoomMin: zoomSpeedSpace?.XRange?.Min ?? 0.0, + zoomMax: zoomSpeedSpace?.XRange?.Max ?? 1.0, + }; + + const { data: presetsData, refetch: refetchPresets } = useGetPtzPresets( + cameraIdentifier, + "onvif", + ); + + const { data: configData } = useGetPtzConfig(cameraIdentifier); + + const [reversePan, setReversePan] = useState( + typeof configData?.user_config?.reverse_pan === "boolean" + ? configData.user_config.reverse_pan + : false, + ); + const [reverseTilt, setReverseTilt] = useState( + typeof configData?.user_config?.reverse_tilt === "boolean" + ? configData.user_config.reverse_tilt + : false, + ); + + useEffect(() => { + const pan = configData?.user_config?.reverse_pan; + if (typeof pan === "boolean" && pan !== reversePan) { + setReversePan(pan); + } + const tilt = configData?.user_config?.reverse_tilt; + if (typeof tilt === "boolean" && tilt !== reverseTilt) { + setReverseTilt(tilt); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + configData?.user_config?.reverse_pan, + configData?.user_config?.reverse_tilt, + ]); + + // Combine user-defined presets with ONVIF presets if auto-config is disabled + const userPresets = supportsAbsoluteMove + ? (configData?.user_config?.presets || []).map((preset) => ({ + Name: preset.name, + type: "user_defined", + token: preset.name, + move_on_startup: preset.on_startup || false, + PTZPosition: { + PanTilt: { x: preset.pan, y: preset.tilt }, + Zoom: + typeof preset.zoom === "number" ? { x: preset.zoom } : undefined, + }, + })) + : []; + const onvifPresets = presetsData?.presets || []; + const allPresets = [...userPresets, ...onvifPresets]; + + const isAutoConfig = !configData; + + // Use ONVIF PTZ handlers hook + const { + handleMoveStart, + handleStop, + handleGoHome, + handleSetHome, + handleGotoPreset, + handleSavePreset, + handleRemovePreset, + handleAbsoluteMove, + mutations, + } = useOnvifPtzHandlers({ + cameraIdentifier, + isAutoConfig, + moveSpeed, + reversePan, + reverseTilt, + ranges: { panTiltMinMax, zoomMinMax, speedMinMax }, + refetchPresets, + setPresetsDialogOpen, + setSetHomeDialogOpen, + setNewPresetName, + setSavePresetDialogOpen, + presetsData, + configData, + }); + + return ( + <> + {/* PTZ FAB Button */} + setDrawerOpen(true)} title="PTZ Controls"> + + + + {/* PTZ Controls Drawer */} + { + setDrawerOpen(false); + handleStop(); // Stop all movement when closing drawer + }} + slotProps={{ + paper: { + sx: { + width: { xs: 310, md: 300 }, + p: 2, + overflowX: "hidden", + overflowY: "auto", + zIndex: 9004, + }, + }, + }} + sx={{ + "& .MuiDrawer-paper": { + borderTop: "none !important", + borderBottom: "none !important", + borderRight: "none !important", + }, + zIndex: 9004, + }} + > + + + + PTZ Controls + + { + setDrawerOpen(false); + handleStop(); + }} + > + + + + + {/* Directional Controls */} + + {/* Top Row */} + + + handleMoveStart(0, 1)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(0, 1)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + > + + + + + handleMoveStart(0, 0, 0.1)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(0, 0, 0.1)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + disabled={!supportsZoom} + > + + + + + {/* Middle Row */} + + handleMoveStart(-1, 0)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(-1, 0)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + > + + + + + + + + + + handleMoveStart(1, 0)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(1, 0)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + > + + + + + {/* Bottom Row */} + + + handleMoveStart(0, -1)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(0, -1)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + > + + + + + handleMoveStart(0, 0, -0.1)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(0, 0, -0.1)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + disabled={!supportsZoom} + > + + + + + + {/* Speed Control Slider */} + + + Speed: {Math.round(moveSpeed * 100)}% + + setMoveSpeed(value as number)} + min={speedMinMax.panTiltMin || 0.0} + max={speedMinMax.panTiltMax || 1.0} + step={0.05} + size="small" + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${Math.round(value * 100)}%`} + /> + + + {/* Reverse Controls */} + + setReversePan(e.target.checked)} + size="small" + /> + } + label="Reverse Pan" + slotProps={{ typography: { variant: "body2" } }} + /> + setReverseTilt(e.target.checked)} + size="small" + /> + } + label="Reverse Tilt" + slotProps={{ typography: { variant: "body2" } }} + /> + + + {/* Action Buttons */} + + {supportsHome && ( + + + + )} + {supportsPresets && ( + <> + + + + + + + + )} + {supportsHome && ( + + + + )} + + + + {/* Presets Dialog */} + setPresetsDialogOpen(false)} + maxWidth="sm" + fullWidth + sx={{ + zIndex: 9005, + }} + > + + + + PTZ Presets + + + + {allPresets.length > 0 ? ( + + {allPresets.map((preset) => ( + + { + e.stopPropagation(); + setSelectedPresetToken(preset.token); + setSelectedPresetName(preset.Name || preset.token); + setRemovePresetDialogOpen(true); + }} + size="small" + color="error" + > + + + + ) : null + } + > + + { + if (preset.type === "user_defined") { + // Absolute move + if (preset.PTZPosition?.PanTilt) { + handleAbsoluteMove( + preset.PTZPosition.PanTilt.x, + preset.PTZPosition.PanTilt.y, + preset.PTZPosition.Zoom?.x ?? undefined, + false, // Not adjusted/reversed for user-defined presets + ); + } + } else { + handleGotoPreset(preset.token); + } + }} + > + + {preset.type === "user_defined" ? ( + + ) : ( + + )} + + + + + + ))} + + ) : ( + + No presets available + + )} + + + + + + + {/* Save Preset Dialog */} + { + setSavePresetDialogOpen(false); + setNewPresetName(""); + }} + maxWidth="sm" + fullWidth + sx={{ + zIndex: 9005, + }} + > + + + + + Save Current Position as Preset + + + + + setNewPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSavePreset(newPresetName); + } + }} + /> + + + + + + + + {/* Set Home Confirmation Dialog */} + setSetHomeDialogOpen(false)} + maxWidth="sm" + fullWidth + sx={{ + zIndex: 9005, + }} + > + + + + Set Home Position + + + + + Are you sure you want to set the current camera position as the home + position? This will override the existing home position. + + + + + + + + + {/* Remove Preset Confirmation Dialog */} + setRemovePresetDialogOpen(false)} + maxWidth="sm" + fullWidth + sx={{ + zIndex: 9005, + }} + > + + + + Remove Preset + + + + + Are you sure you want to remove preset "{selectedPresetName} + "? This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts b/frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts new file mode 100644 index 000000000..f3eb7f35f --- /dev/null +++ b/frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts @@ -0,0 +1,312 @@ +import { useRef } from "react"; + +import { useToast } from "hooks/UseToast"; +import { + usePtzAbsoluteMove, + usePtzContinuousMove, + usePtzGoHome, + usePtzGotoPreset, + usePtzRemovePreset, + usePtzSetHome, + usePtzSetPreset, + usePtzStop, +} from "lib/api/actions/onvif/ptz"; + +interface PtzMinMaxRanges { + panTiltMinMax: { + xMin: number; + xMax: number; + yMin: number; + yMax: number; + }; + zoomMinMax: { + min: number; + max: number; + }; + speedMinMax: { + panTiltMin: number; + panTiltMax: number; + zoomMin: number; + zoomMax: number; + }; +} + +interface UseOnvifPtzHandlersParams { + cameraIdentifier: string; + isAutoConfig: boolean; + moveSpeed: number; + reversePan: boolean; + reverseTilt: boolean; + ranges: PtzMinMaxRanges; + refetchPresets: () => void; + setPresetsDialogOpen: (open: boolean) => void; + setSetHomeDialogOpen: (open: boolean) => void; + setNewPresetName: (name: string) => void; + setSavePresetDialogOpen: (open: boolean) => void; + presetsData?: any; + configData?: any; +} + +export function useOnvifPtzHandlers({ + cameraIdentifier, + isAutoConfig, + moveSpeed, + reversePan, + reverseTilt, + ranges, + refetchPresets, + setPresetsDialogOpen, + setSetHomeDialogOpen, + setNewPresetName, + setSavePresetDialogOpen, + configData, +}: UseOnvifPtzHandlersParams) { + const toast = useToast(); + + const isMoveActiveRef = useRef(false); + const moveParamsRef = useRef<{ + xVelocity: number; + yVelocity: number; + zoomVelocity: number; + }>({ xVelocity: 0, yVelocity: 0, zoomVelocity: 0 }); + + const continuousMoveMutation = usePtzContinuousMove(); + const absoluteMoveMutation = usePtzAbsoluteMove(); + const stopMutation = usePtzStop(); + const goHomeMutation = usePtzGoHome(); + const gotoPresetMutation = usePtzGotoPreset(); + const setHomeMutation = usePtzSetHome(); + const setPresetMutation = usePtzSetPreset(); + const removePresetMutation = usePtzRemovePreset(); + + // Helper function to clamp velocity values to camera's supported range + const clampVelocity = ( + x: number, + y: number, + zoom: number, + ): { x: number; y: number; zoom: number } => ({ + x: Math.max( + ranges.panTiltMinMax.xMin, + Math.min(ranges.panTiltMinMax.xMax, x), + ), + y: Math.max( + ranges.panTiltMinMax.yMin, + Math.min(ranges.panTiltMinMax.yMax, y), + ), + zoom: Math.max( + ranges.zoomMinMax.min, + Math.min(ranges.zoomMinMax.max, zoom), + ), + }); + + const stopContinuousMove = () => { + isMoveActiveRef.current = false; + stopMutation.mutate( + { cameraIdentifier }, + { + onError: (error) => { + toast.error(error.message || "Failed to stop camera movement"); + }, + }, + ); + }; + + const startContinuousMove = ( + xVelocity: number, + yVelocity: number, + zoomVelocity: number = 0, + ) => { + // Prevent multiple intervals + if (isMoveActiveRef.current) return; + + isMoveActiveRef.current = true; + moveParamsRef.current = { xVelocity, yVelocity, zoomVelocity }; + + // Function to send move command + const sendMoveCommand = () => { + const velocities = clampVelocity( + moveParamsRef.current.xVelocity * moveSpeed, + moveParamsRef.current.yVelocity * moveSpeed, + moveParamsRef.current.zoomVelocity * moveSpeed, + ); + continuousMoveMutation.mutate( + { + cameraIdentifier, + params: { + x_velocity: velocities.x, + y_velocity: velocities.y, + zoom_velocity: velocities.zoom, + }, + }, + { + onError: (error) => { + stopContinuousMove(); + toast.error(error.message || "Failed to move camera"); + }, + }, + ); + }; + + // Send first command immediately + sendMoveCommand(); + }; + + const handleMoveStart = ( + xVelocity: number, + yVelocity: number, + zoomVelocity: number = 0, + ) => { + // Helper to reverse only if value is not zero + const reverseIfNeeded = (value: number, reverse: boolean) => + value === 0 ? 0 : reverse ? -value : value; + + let adjustedX: number; + let adjustedY: number; + + if (isAutoConfig) { + // Autoconfig true, use UI toggles directly + adjustedX = reverseIfNeeded(xVelocity, reversePan); + adjustedY = reverseIfNeeded(yVelocity, reverseTilt); + } else { + const isReversePan = configData?.user_config?.reverse_pan; + const isReverseTilt = configData?.user_config?.reverse_tilt; + // Autoconfig false, use camera config toggles inverted + adjustedX = reverseIfNeeded( + xVelocity, + !isReversePan ? reversePan : !reversePan, + ); + adjustedY = reverseIfNeeded( + yVelocity, + !isReverseTilt ? reverseTilt : !reverseTilt, + ); + } + + startContinuousMove(adjustedX, adjustedY, zoomVelocity); + }; + + const handleStop = () => { + stopContinuousMove(); + }; + + const handleGoHome = () => { + goHomeMutation.mutate( + { cameraIdentifier }, + { + onError: (error) => { + toast.error(error.message || "Failed to go to home position"); + }, + }, + ); + }; + + const handleSetHome = () => { + setHomeMutation.mutate( + { cameraIdentifier }, + { + onSuccess: () => { + setSetHomeDialogOpen(false); + toast.success("Home position set successfully"); + }, + onError: (error) => { + toast.error(error.message || "Failed to set home position"); + }, + }, + ); + }; + + const handleGotoPreset = (presetToken: string) => { + gotoPresetMutation.mutate( + { + cameraIdentifier, + presetToken, + }, + { + onError: (error) => { + toast.error(error.message || "Failed to go to preset"); + }, + }, + ); + setPresetsDialogOpen(false); + }; + + const handleAbsoluteMove = ( + x_position: number, + y_position: number, + zoom_position?: number, + is_adjusted?: boolean, + ) => { + absoluteMoveMutation.mutate( + { + cameraIdentifier, + params: { + x_position, + y_position, + zoom_position, + is_adjusted, + }, + }, + { + onError: (error) => { + toast.error(error.message || "Failed to move camera"); + }, + }, + ); + setPresetsDialogOpen(false); + }; + + const handleSavePreset = (presetName: string) => { + if (presetName.trim()) { + setPresetMutation.mutate( + { + cameraIdentifier, + presetName: presetName.trim(), + }, + { + onSuccess: () => { + refetchPresets(); + setNewPresetName(""); + setSavePresetDialogOpen(false); + toast.success(`Preset "${presetName.trim()}" saved successfully`); + }, + onError: (error) => { + toast.error(error.message || "Failed to save preset"); + }, + }, + ); + } + }; + + const handleRemovePreset = (presetToken: string) => { + removePresetMutation.mutate( + { + cameraIdentifier, + presetToken, + }, + { + onSuccess: () => { + refetchPresets(); + toast.success("Preset removed successfully"); + }, + onError: (error) => { + toast.error(error.message || "Failed to remove preset"); + }, + }, + ); + }; + + return { + handleMoveStart, + handleStop, + handleGoHome, + handleSetHome, + handleGotoPreset, + handleSavePreset, + handleRemovePreset, + handleAbsoluteMove, + mutations: { + setHomeMutation, + setPresetMutation, + removePresetMutation, + }, + }; +} diff --git a/frontend/src/components/player/PlayerMenu.tsx b/frontend/src/components/player/PlayerMenu.tsx index 9ad2ab2d6..c3beae411 100644 --- a/frontend/src/components/player/PlayerMenu.tsx +++ b/frontend/src/components/player/PlayerMenu.tsx @@ -6,19 +6,49 @@ import MenuItem from "@mui/material/MenuItem"; import React from "react"; import { useShallow } from "zustand/react/shallow"; +import { OnvifPtzController } from "components/actions/ptz/OnvifPtzController"; import { CustomFab } from "components/player/CustomControls"; import { usePlayerSettingsStore } from "components/player/UsePlayerSettingsStore"; +import { useAuthContext } from "context/AuthContext"; import * as types from "lib/types"; interface PlayerMenuProps { onMenuOpen: (event: React.MouseEvent) => void; + camera: types.Camera | types.FailedCamera; } -export function PlayerMenu({ onMenuOpen }: PlayerMenuProps) { +export function PlayerMenu({ onMenuOpen, camera }: PlayerMenuProps) { + const { auth, user } = useAuthContext(); + + // Determine which PTZ controller to render based on ptz_support + const renderPtzController = () => { + if (auth.enabled && user?.role !== "admin") { + return null; + } + + if (!("ptz_support" in camera) || !camera.ptz_support) { + return null; + } + + // Render PTZ controller based on ptz_support type + switch (camera.ptz_support) { + case "onvif": + return ; + // Future PTZ controller types can be added here + // case "other_ptz_type": + // return ; + default: + return null; + } + }; + return ( - - - + <> + + + + {renderPtzController()} + ); } diff --git a/frontend/src/lib/api/actions/onvif/ptz.ts b/frontend/src/lib/api/actions/onvif/ptz.ts new file mode 100644 index 000000000..8494909e6 --- /dev/null +++ b/frontend/src/lib/api/actions/onvif/ptz.ts @@ -0,0 +1,282 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; + +import * as onvif_types from "lib/api/actions/onvif/types"; +import { viseronAPI } from "lib/api/client"; +import * as types from "lib/types"; + +const ONVIF_PTZ_BASE_PATH = "actions/onvif/ptz"; + +// Get User-Defined PTZ Config +async function getPtzConfig(cameraIdentifier: string) { + const response = await viseronAPI.get( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/user_config`, + ); + return response.data; +} + +export function useGetPtzConfig(cameraIdentifier: string) { + return useQuery({ + queryKey: ["ptz", "user_config", cameraIdentifier], + queryFn: () => getPtzConfig(cameraIdentifier), + enabled: !!cameraIdentifier, + retry: false, // Don't retry on error - camera either supports PTZ or doesn't + staleTime: Infinity, // Cache the result indefinitely - PTZ support doesn't change + }); +} + +// Get PTZ Nodes +async function getPtzNodes(cameraIdentifier: string) { + const response = await viseronAPI.get( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/nodes`, + ); + return response.data; +} + +export function useGetPtzNodes(cameraIdentifier: string) { + return useQuery({ + queryKey: ["ptz", "nodes", cameraIdentifier], + queryFn: () => getPtzNodes(cameraIdentifier), + enabled: !!cameraIdentifier, + }); +} + +// Get PTZ Configurations +async function getPtzConfigurations(cameraIdentifier: string) { + const response = await viseronAPI.get( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/configurations`, + ); + return response.data; +} + +export function useGetPtzConfigurations(cameraIdentifier: string) { + return useQuery< + onvif_types.PtzConfigurationsResponse, + types.APIErrorResponse + >({ + queryKey: ["ptz", "configurations", cameraIdentifier], + queryFn: () => getPtzConfigurations(cameraIdentifier), + enabled: !!cameraIdentifier, + }); +} + +// Get PTZ Status +async function getPtzStatus(cameraIdentifier: string) { + const response = await viseronAPI.get( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/status`, + ); + return response.data; +} + +export function useGetPtzStatus(cameraIdentifier: string) { + return useQuery({ + queryKey: ["ptz", "status", cameraIdentifier], + queryFn: () => getPtzStatus(cameraIdentifier), + enabled: !!cameraIdentifier, + staleTime: 1000 * 5, // 5 seconds + }); +} + +// Get PTZ Presets +async function getPtzPresets(cameraIdentifier: string) { + const response = await viseronAPI.get( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/presets`, + ); + return response.data; +} + +export function useGetPtzPresets( + cameraIdentifier: string, + ptzSupport?: "onvif" | null, +) { + return useQuery({ + queryKey: ["ptz", "presets", cameraIdentifier], + queryFn: () => getPtzPresets(cameraIdentifier), + enabled: !!cameraIdentifier && ptzSupport === "onvif", + retry: false, // Don't retry on error - camera either supports PTZ or doesn't + staleTime: Infinity, // Cache the result indefinitely - PTZ support doesn't change + }); +} + +// PTZ Continuous Move +async function ptzContinuousMove( + cameraIdentifier: string, + params: onvif_types.PtzContinuousMoveParams, +) { + const response = await viseronAPI.post( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/continuous_move`, + { continuous: params }, + ); + return response.data; +} + +export function usePtzContinuousMove() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string; params: onvif_types.PtzContinuousMoveParams } + >({ + mutationFn: ({ cameraIdentifier, params }) => + ptzContinuousMove(cameraIdentifier, params), + }); +} + +// PTZ Relative Move +async function ptzRelativeMove( + cameraIdentifier: string, + params: onvif_types.PtzRelativeMoveParams, +) { + const response = await viseronAPI.post( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/relative_move`, + { relative: params }, + ); + return response.data; +} + +export function usePtzRelativeMove() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string; params: onvif_types.PtzRelativeMoveParams } + >({ + mutationFn: ({ cameraIdentifier, params }) => + ptzRelativeMove(cameraIdentifier, params), + }); +} + +async function ptzAbsoluteMove( + cameraIdentifier: string, + params: onvif_types.PtzAbsoluteMoveParams, +) { + const response = await viseronAPI.post( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/absolute_move`, + { absolute: params }, + ); + return response.data; +} + +export function usePtzAbsoluteMove() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string; params: onvif_types.PtzAbsoluteMoveParams } + >({ + mutationFn: ({ cameraIdentifier, params }) => + ptzAbsoluteMove(cameraIdentifier, params), + }); +} + +// PTZ Stop +async function ptzStop(cameraIdentifier: string) { + const response = await viseronAPI.post( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/stop`, + {}, + ); + return response.data; +} + +export function usePtzStop() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string } + >({ + mutationFn: ({ cameraIdentifier }) => ptzStop(cameraIdentifier), + }); +} + +// PTZ Go Home +async function ptzGoHome(cameraIdentifier: string) { + const response = await viseronAPI.post( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/home`, + {}, + ); + return response.data; +} + +export function usePtzGoHome() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string } + >({ + mutationFn: ({ cameraIdentifier }) => ptzGoHome(cameraIdentifier), + }); +} + +// PTZ Set Home Position +async function ptzSetHome(cameraIdentifier: string) { + const response = await viseronAPI.put( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/set_home`, + {}, + ); + return response.data; +} + +export function usePtzSetHome() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string } + >({ + mutationFn: ({ cameraIdentifier }) => ptzSetHome(cameraIdentifier), + }); +} + +// PTZ Goto Preset +async function ptzGotoPreset(cameraIdentifier: string, presetToken: string) { + const response = await viseronAPI.post( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/goto_preset`, + { preset_token: presetToken }, + ); + return response.data; +} + +export function usePtzGotoPreset() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string; presetToken: string } + >({ + mutationFn: ({ cameraIdentifier, presetToken }) => + ptzGotoPreset(cameraIdentifier, presetToken), + }); +} + +// PTZ Set Preset +async function ptzSetPreset(cameraIdentifier: string, presetName: string) { + const response = await viseronAPI.put( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/set_preset`, + { preset_name: presetName }, + ); + return response.data; +} + +export function usePtzSetPreset() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string; presetName: string } + >({ + mutationFn: ({ cameraIdentifier, presetName }) => + ptzSetPreset(cameraIdentifier, presetName), + }); +} + +// PTZ Remove Preset +async function ptzRemovePreset(cameraIdentifier: string, presetToken: string) { + const response = await viseronAPI.delete( + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/remove_preset?preset_token=${presetToken}`, + ); + return response.data; +} + +export function usePtzRemovePreset() { + return useMutation< + types.APISuccessResponse, + types.APIErrorResponse, + { cameraIdentifier: string; presetToken: string } + >({ + mutationFn: ({ cameraIdentifier, presetToken }) => + ptzRemovePreset(cameraIdentifier, presetToken), + }); +} diff --git a/frontend/src/lib/api/actions/onvif/types.ts b/frontend/src/lib/api/actions/onvif/types.ts new file mode 100644 index 000000000..0f4473a31 --- /dev/null +++ b/frontend/src/lib/api/actions/onvif/types.ts @@ -0,0 +1,88 @@ +// Device Types + +// Media Types + +// Imaging Types + +// PTZ Types +export type PtzConfigResponse = { + user_config: { + home_position: boolean; + reverse_pan: boolean; + reverse_tilt: boolean; + presets?: PtzPresetUserConfig[]; + }; +}; + +export type PtzPosition = { + PanTilt?: { + x: number; + y: number; + }; + Zoom?: { + x: number; + }; +}; + +export type PtzStatusResponse = { + status: { + Position?: PtzPosition; + MoveStatus?: { + PanTilt?: string; + Zoom?: string; + }; + UtcTime?: string; + }; +}; + +export type PtzPresetUserConfig = { + name: string; + pan: number; + tilt: number; + zoom?: number; + on_startup?: boolean; +}; + +export type PtzPreset = { + Name?: string; + type: "onvif"; + token: string; + PTZPosition?: PtzPosition; +}; + +export type PtzPresetsResponse = { + presets: PtzPreset[]; +}; + +export type PtzNodesResponse = { + nodes: any[]; +}; + +export type PtzConfigurationsResponse = { + configurations: any[]; +}; + +export type PtzContinuousMoveParams = { + x_velocity?: number; + y_velocity?: number; + zoom_velocity?: number; +}; + +export type PtzRelativeMoveParams = { + x_translation?: number; + y_translation?: number; + zoom_translation?: number; + x_speed?: number; + y_speed?: number; + zoom_speed?: number; +}; + +export type PtzAbsoluteMoveParams = { + x_position?: number; + y_position?: number; + zoom_position?: number; + x_speed?: number; + y_speed?: number; + zoom_speed?: number; + is_adjusted?: boolean; +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 228ddc27e..b603b759a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -184,6 +184,7 @@ export interface Camera { connected: boolean; live_stream_available: boolean; is_recording: boolean; + ptz_support?: string | null; } export interface Cameras { diff --git a/frontend/src/pages/Live.tsx b/frontend/src/pages/Live.tsx index 91034fbf9..8ab53fb5c 100644 --- a/frontend/src/pages/Live.tsx +++ b/frontend/src/pages/Live.tsx @@ -727,8 +727,8 @@ const CameraPlayer = memo( ); const playerMenuButton = useMemo( - () => , - [handleMenuOpen], + () => , + [handleMenuOpen, camera], ); return mjpegPlayer ? ( From df8914adc0027285cceb61a592dbd5b847b424d7 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 16:06:59 +0700 Subject: [PATCH 016/120] feat(webserver/onvif): Add Actions API for ONVIF Media service --- viseron/components/onvif/media.py | 54 +++- .../webserver/api/v1/actions/onvif/media.py | 231 ++++++++++++++++++ 2 files changed, 283 insertions(+), 2 deletions(-) diff --git a/viseron/components/onvif/media.py b/viseron/components/onvif/media.py index 04680e007..460f094d7 100644 --- a/viseron/components/onvif/media.py +++ b/viseron/components/onvif/media.py @@ -110,7 +110,6 @@ async def create_profile(self, name: str, token: str | None = None) -> Any: async def delete_profile(self, profile_token: str) -> bool: """Delete a media profile.""" self._media_service.DeleteProfile(ProfileToken=profile_token) - return True # ---- URI Operations ---- # @@ -224,6 +223,57 @@ async def set_audio_encoder_configuration( # ---- OSD Operations ---- # + @operation() + async def get_osd(self, token: str) -> Any: + """Get on-screen display configuration.""" + return self._media_service.GetOSD(OSDToken=token) + + @operation() + async def get_osds(self, config_token: str | None = None) -> Any: + """Get all on-screen display configurations.""" + return self._media_service.GetOSDs( + ConfigurationToken=config_token + or self._selected_profile.VideoSourceConfiguration.token + ) + + @operation() + async def get_osd_options(self, config_token: str | None = None) -> Any: + """Get on-screen display configuration options.""" + return self._media_service.GetOSDOptions( + ConfigurationToken=config_token + or self._selected_profile.VideoSourceConfiguration.token + ) + + @operation() + async def create_osd(self, osd_config: dict[str, Any]) -> bool: + """Create new on-screen display configuration.""" + + if not osd_config.get("VideoSourceConfigurationToken"): + osd_config[ + "VideoSourceConfigurationToken" + ] = self._selected_profile.VideoSourceConfiguration.token + + self._media_service.CreateOSD(OSD=osd_config) + return True + + @operation() + async def delete_osd(self, token: str) -> bool: + """Delete on-screen display configuration.""" + self._media_service.DeleteOSD(OSDToken=token) + return True + + @operation() + async def set_osd(self, osd_config: dict[str, Any]) -> bool: + """Set existing on-screen display configuration.""" + + if not osd_config.get("VideoSourceConfigurationToken"): + osd_config[ + "VideoSourceConfigurationToken" + ] = self._selected_profile.VideoSourceConfiguration.token + + self._media_service.SetOSD(OSD=osd_config) + return True + # ## Profile Accessors ## # def get_selected_profile(self): @@ -360,7 +410,7 @@ async def apply_config(self) -> bool: return True LOGGER.error( - f"Error applying Imaging service configuration for " + f"Error applying Media service configuration for " f"{self._camera.identifier}!" ) return False diff --git a/viseron/components/webserver/api/v1/actions/onvif/media.py b/viseron/components/webserver/api/v1/actions/onvif/media.py index 8c11b9faf..7d1161546 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/media.py +++ b/viseron/components/webserver/api/v1/actions/onvif/media.py @@ -1 +1,232 @@ """ONVIF Media API handler.""" + +import logging + +from viseron.components.onvif.const import CONFIG_MEDIA +from viseron.components.webserver.api.v1.actions.onvif.base import ( + ActionsOnvifAPIHandler, + action_handler, +) +from viseron.components.webserver.auth import Role + +LOGGER = logging.getLogger(__name__) + + +class ActionsOnvifMediaAPIHandler(ActionsOnvifAPIHandler): + """ONVIF Media action handler.""" + + @property + def _service_name(self): + """Get service name.""" + return CONFIG_MEDIA + + ONVIF_MEDIA_BASE_PATH = f"/actions/onvif/{CONFIG_MEDIA}" + CAMERA_IDENTIFIER_REGEX = r"(?P[A-Za-z0-9_]+)" + ACTION_REGEX = r"(?P[a-z_]+)" + + routes = [ + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_MEDIA_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["GET"], + "method": "get_onvif_media", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_MEDIA_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["PUT"], + "method": "put_onvif_media", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_MEDIA_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["POST"], + "method": "post_onvif_media", + }, + { + "requires_role": [Role.ADMIN], + "path_pattern": ( + rf"{ONVIF_MEDIA_BASE_PATH}/{CAMERA_IDENTIFIER_REGEX}" + rf"/{ACTION_REGEX}" + ), + "supported_methods": ["DELETE"], + "method": "delete_onvif_media", + }, + ] + + @action_handler + async def get_onvif_media( + self, + media_service, + camera_identifier: str, + action: str, + ): + """Handle GET requests for ONVIF Media actions.""" + + if action == "profiles": + await self.validate_action_response( + await media_service.get_profiles(), action, camera_identifier + ) + return + + if action == "profile": + required_query = "token" + token = self.validate_query_parameter( + self.get_query_argument(required_query, None), required_query + ) + await self.validate_action_response( + await media_service.get_profile(token), action, camera_identifier + ) + return + + if action == "stream_uri": + token = self.get_query_argument("token", None) + stream_type = self.get_query_argument("stream_type", None) + protocol = self.get_query_argument("protocol", None) + await self.validate_action_response( + await media_service.get_stream_uri(token, stream_type, protocol), + action, + camera_identifier, + ) + return + + if action == "snapshot_uri": + token = self.get_query_argument("token", None) + await self.validate_action_response( + await media_service.get_snapshot_uri(token), action, camera_identifier + ) + return + + if action == "video_encoder_configuration": + token = self.get_query_argument("token", None) + await self.validate_action_response( + await media_service.get_video_encoder_configuration(token), + action, + camera_identifier, + ) + return + + if action == "video_encoder_configuration_options": + token = self.get_query_argument("token", None) + await self.validate_action_response( + await media_service.get_video_encoder_configuration_options(token), + action, + camera_identifier, + ) + return + + if action == "audio_encoder_configuration": + token = self.get_query_argument("token", None) + await self.validate_action_response( + await media_service.get_audio_encoder_configuration(token), + action, + camera_identifier, + ) + return + + if action == "audio_encoder_configuration_options": + token = self.get_query_argument("token", None) + await self.validate_action_response( + await media_service.get_audio_encoder_configuration_options(token), + action, + camera_identifier, + ) + return + + self.unknown_action(action) + + @action_handler + async def put_onvif_media(self, media_service, camera_identifier: str, action: str): + """Handle PUT requests for ONVIF Media actions.""" + + request_data = self.get_request_body() + + if action == "set_video_encoder_configuration": + configuration = self.validate_request_data(request_data, "configuration") + set_video_encoder_configuration = ( + await media_service.set_video_encoder_configuration(configuration) + ) + await self.validate_action_status( + set_video_encoder_configuration, action, camera_identifier + ) + return + + if action == "set_audio_encoder_configuration": + configuration = self.validate_request_data(request_data, "configuration") + set_audio_encoder_configuration = ( + await media_service.set_audio_encoder_configuration(configuration) + ) + await self.validate_action_status( + set_audio_encoder_configuration, action, camera_identifier + ) + return + + if action == "set_osd": + osd = self.validate_request_data(request_data, "osd") + set_osd = await media_service.set_osd(osd) + await self.validate_action_status(set_osd, action, camera_identifier) + return + + self.unknown_action(action) + + @action_handler + async def post_onvif_media( + self, media_service, camera_identifier: str, action: str + ): + """Handle POST requests for ONVIF Media actions.""" + + request_data = self.get_request_body() + + if action == "create_profile": + profile = self.validate_request_data(request_data, "profile") + create_profile = await media_service.create_profile( + name=profile.get("name"), + token=profile.get("token", None), + ) + await self.validate_action_status(create_profile, action, camera_identifier) + return + + if action == "create_osd": + osd = self.validate_request_data(request_data, "osd") + create_osd = await media_service.create_osd( + osd_config=osd, + ) + await self.validate_action_status(create_osd, action, camera_identifier) + return + + self.unknown_action(action) + + @action_handler + async def delete_onvif_media( + self, media_service, camera_identifier: str, action: str + ): + """Handle DELETE requests for ONVIF Media actions.""" + + if action == "delete_profile": + required_query = "profile_token" + profile_token = self.validate_query_parameter( + self.get_query_argument(required_query, None), required_query + ) + delete_profile = await media_service.delete_profile(profile_token) + await self.validate_action_status(delete_profile, action, camera_identifier) + return + + if action == "delete_osd": + required_query = "token" + token = self.validate_query_parameter( + self.get_query_argument(required_query, None), required_query + ) + delete_osd = await media_service.delete_osd(token) + await self.validate_action_status(delete_osd, action, camera_identifier) + return + + self.unknown_action(action) From 12a3b954118c0fd8aac62420d59cc4f82b29c09b Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 16:08:08 +0700 Subject: [PATCH 017/120] style(ui): Run formatter for ONVIF PTZ Controls --- .../actions/ptz/OnvifPtzController.tsx | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/actions/ptz/OnvifPtzController.tsx b/frontend/src/components/actions/ptz/OnvifPtzController.tsx index dab80789f..d4e1ce29f 100644 --- a/frontend/src/components/actions/ptz/OnvifPtzController.tsx +++ b/frontend/src/components/actions/ptz/OnvifPtzController.tsx @@ -282,22 +282,22 @@ export function OnvifPtzController({ + handleMoveStart(0, 0, 0.1)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(0, 0, 0.1)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + disabled={!supportsZoom} > - handleMoveStart(0, 0, 0.1)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(0, 0, 0.1)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - disabled={!supportsZoom} - > - - - + + + {/* Middle Row */} + handleMoveStart(0, 0, -0.1)} + onMouseUp={handleStop} + onTouchStart={() => handleMoveStart(0, 0, -0.1)} + onTouchEnd={handleStop} + sx={{ bgcolor: "action.hover" }} + disabled={!supportsZoom} > - handleMoveStart(0, 0, -0.1)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(0, 0, -0.1)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - disabled={!supportsZoom} - > - - - + + + {/* Speed Control Slider */} From d750130a77f878d3e1ac8de1402ceb7a9018a7f1 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 16:09:45 +0700 Subject: [PATCH 018/120] style(ui): Change the view speed dial tooltip SX --- frontend/src/components/player/view/ViewSpeedDial.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/player/view/ViewSpeedDial.tsx b/frontend/src/components/player/view/ViewSpeedDial.tsx index 59d08093e..9433e8bef 100644 --- a/frontend/src/components/player/view/ViewSpeedDial.tsx +++ b/frontend/src/components/player/view/ViewSpeedDial.tsx @@ -128,13 +128,18 @@ export function ViewSpeedDial({ {index + 1} } - tooltipTitle={`Load ${view.name}`} onClick={() => handleLoadView(view.id)} onContextMenu={(e) => handleViewRightClick(e, view.id, view.name)} FabProps={{ size: "small", color: "secondary", }} + slotProps={{ + tooltip: { + title: `Load ${view.name}`, + sx: { zIndex: 10010 }, + }, + }} /> ))} From 8e07a2f0a7b96fa5ff61e5deeda5d1bad95fea3e Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 16:22:49 +0700 Subject: [PATCH 019/120] fix(onvif): Change map constant from dict to list --- .../components/onvif/config.json | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/src/pages/components-explorer/components/onvif/config.json b/docs/src/pages/components-explorer/components/onvif/config.json index 7d72d50ea..a8dd429f5 100644 --- a/docs/src/pages/components-explorer/components/onvif/config.json +++ b/docs/src/pages/components-explorer/components/onvif/config.json @@ -124,11 +124,11 @@ "options": [ { "type": "constant", - "value": "IPv4" + "value": "DNS" }, { "type": "constant", - "value": "DNS" + "value": "IPv4" }, { "type": "constant", @@ -164,15 +164,15 @@ "options": [ { "type": "constant", - "value": "MPEG4" + "value": "JPEG" }, { "type": "constant", - "value": "H264" + "value": "MPEG4" }, { "type": "constant", - "value": "JPEG" + "value": "H264" } ], "name": "encoding", @@ -215,11 +215,11 @@ "options": [ { "type": "constant", - "value": "ASP" + "value": "SP" }, { "type": "constant", - "value": "SP" + "value": "ASP" } ], "name": "mpeg4_profile", @@ -232,11 +232,11 @@ "options": [ { "type": "constant", - "value": "High" + "value": "Baseline" }, { "type": "constant", - "value": "Baseline" + "value": "Main" }, { "type": "constant", @@ -244,7 +244,7 @@ }, { "type": "constant", - "value": "Main" + "value": "High" } ], "name": "h264_profile", @@ -301,7 +301,7 @@ "options": [ { "type": "constant", - "value": "AAC" + "value": "G711" }, { "type": "constant", @@ -309,7 +309,7 @@ }, { "type": "constant", - "value": "G711" + "value": "AAC" } ], "name": "encoding", @@ -393,7 +393,7 @@ "options": [ { "type": "constant", - "value": "AUTO" + "value": "ON" }, { "type": "constant", @@ -401,7 +401,7 @@ }, { "type": "constant", - "value": "ON" + "value": "AUTO" } ], "name": "ircut_filter", @@ -414,11 +414,11 @@ "options": [ { "type": "constant", - "value": "OFF" + "value": "ON" }, { "type": "constant", - "value": "ON" + "value": "OFF" } ], "name": "backlight_compensation", From e77f0483b0c19b65fc6fd71152c367b2a9cffc28 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 11 Jan 2026 16:41:47 +0700 Subject: [PATCH 020/120] fix(onvif): Change map constant from dict to list +2 --- viseron/components/onvif/const.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/viseron/components/onvif/const.py b/viseron/components/onvif/const.py index ff5b7b938..31a95e68c 100644 --- a/viseron/components/onvif/const.py +++ b/viseron/components/onvif/const.py @@ -49,12 +49,12 @@ CONFIG_DEVICE_HOSTNAME = "hostname" CONFIG_DEVICE_DISCOVERABLE = "discoverable" CONFIG_DEVICE_DATETIME_TYPE = "datetime_type" -DEVICE_DATETIME_TYPE_MAP = {"NTP", "Manual"} +DEVICE_DATETIME_TYPE_MAP = ["NTP", "Manual"] CONFIG_DEVICE_DAYLIGHT_SAVINGS = "daylight_savings" CONFIG_DEVICE_TIMEZONE = "timezone" CONFIG_DEVICE_NTP_FROM_DHCP = "ntp_from_dhcp" CONFIG_DEVICE_NTP_TYPE = "ntp_type" -DEVICE_NTP_TYPE_MAP = {"DNS", "IPv4", "IPv6"} +DEVICE_NTP_TYPE_MAP = ["DNS", "IPv4", "IPv6"] CONFIG_DEVICE_NTP_SERVER = "ntp_server" DESC_DEVICE = "Device service configuration." @@ -91,9 +91,9 @@ CONFIG_IMAGING_CONTRAST = "contrast" CONFIG_IMAGING_SHARPNESS = "sharpness" CONFIG_IMAGING_IRCUT_FILTER = "ircut_filter" -IMAGING_IRCUT_FILTER_MAP = {"ON", "OFF", "AUTO"} +IMAGING_IRCUT_FILTER_MAP = ["ON", "OFF", "AUTO"] CONFIG_IMAGING_BACKLIGHT_COMPENSATION = "backlight_compensation" -IMAGING_BACKLIGHT_COMPENSATION_MAP = {"ON", "OFF"} +IMAGING_BACKLIGHT_COMPENSATION_MAP = ["ON", "OFF"] CONFIG_IMAGING_EXPOSURE = "exposure" CONFIG_IMAGING_FOCUS = "focus" CONFIG_IMAGING_WIDE_DYNAMIC_RANGE = "wide_dynamic_range" @@ -140,11 +140,11 @@ CONFIG_VIDEO_ENCODER = "video_encoder" CONFIG_VIDEO_FORCE_PERSISTENCE = "force_persistence" CONFIG_VIDEO_ENCODING = "encoding" -VIDEO_ENCODING_MAP = {"JPEG", "MPEG4", "H264"} +VIDEO_ENCODING_MAP = ["JPEG", "MPEG4", "H264"] CONFIG_VIDEO_MPEG4 = "mpeg4_profile" -VIDEO_MPEG4_MAP = {"SP", "ASP"} +VIDEO_MPEG4_MAP = ["SP", "ASP"] CONFIG_VIDEO_H264 = "h264_profile" -VIDEO_H264_MAP = {"Baseline", "Main", "Extended", "High"} +VIDEO_H264_MAP = ["Baseline", "Main", "Extended", "High"] CONFIG_VIDEO_RESOLUTION = "resolution" CONFIG_VIDEO_RESOLUTION_WIDTH = "width" CONFIG_VIDEO_RESOLUTION_HEIGHT = "height" @@ -156,7 +156,7 @@ CONFIG_AUDIO_ENCODER = "audio_encoder" CONFIG_AUDIO_FORCE_PERSISTENCE = "force_persistence" CONFIG_AUDIO_ENCODING = "encoding" -AUDIO_ENCODING_MAP = {"G711", "G726", "AAC"} +AUDIO_ENCODING_MAP = ["G711", "G726", "AAC"] CONFIG_AUDIO_BITRATE = "bitrate" CONFIG_AUDIO_SAMPLE_RATE = "sample_rate" From c5325131185c6b7adcc4e3ce5aa6210218f8a299 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 04:20:20 +0700 Subject: [PATCH 021/120] style(ui): Change start/stop camera toggle --- frontend/src/components/camera/CameraCard.tsx | 36 +++++++++++++++---- .../src/components/camera/CameraUptime.tsx | 2 +- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/camera/CameraCard.tsx b/frontend/src/components/camera/CameraCard.tsx index ca9e486e3..ba506b0f1 100644 --- a/frontend/src/components/camera/CameraCard.tsx +++ b/frontend/src/components/camera/CameraCard.tsx @@ -5,6 +5,8 @@ import { SettingsAdjust, VideoChat, VideoOff, + ViewFilled, + ViewOffFilled, } from "@carbon/icons-react"; import Image from "@jy95/material-ui-image"; import Box from "@mui/material/Box"; @@ -13,10 +15,10 @@ import CardActionArea from "@mui/material/CardActionArea"; import CardActions from "@mui/material/CardActions"; import CardContent from "@mui/material/CardContent"; import CardMedia from "@mui/material/CardMedia"; +import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; -import Switch from "@mui/material/Switch"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; @@ -254,13 +256,33 @@ function SuccessCameraCard({ > - + ) : ( + + ) + } + label="" disabled={cameraStartStop.isPending} - onChange={() => { - if (cameraStartStop.isPending) { - return; - } + color={camera.is_on ? "error" : "primary"} + size="small" + sx={{ + height: 30, + borderRadius: 1.2, + px: 1.5, + "& .MuiChip-icon": { + margin: 0, + }, + "& .MuiChip-label": { + padding: 0, + width: 0, + }, + justifyContent: "center", + }} + onClick={() => { + if (cameraStartStop.isPending) return; cameraStartStop.mutate({ camera, action: camera.is_on ? "stop" : "start", diff --git a/frontend/src/components/camera/CameraUptime.tsx b/frontend/src/components/camera/CameraUptime.tsx index 03e4b0c46..e3fd2595c 100644 --- a/frontend/src/components/camera/CameraUptime.tsx +++ b/frontend/src/components/camera/CameraUptime.tsx @@ -36,7 +36,7 @@ export function CameraUptime({ sx={{ fontSize: "0.75rem", height: 30, - borderRadius: 0.7, + borderRadius: 1.2, px: 0.5, py: 1, }} From 41e34b42b17bf092380f3a41cbbbba5f7a0210e7 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 04:20:57 +0700 Subject: [PATCH 022/120] style(ui): Change manual recording Fab style --- frontend/src/components/player/CustomControls.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/player/CustomControls.tsx b/frontend/src/components/player/CustomControls.tsx index 2c2672661..c4f5815a2 100644 --- a/frontend/src/components/player/CustomControls.tsx +++ b/frontend/src/components/player/CustomControls.tsx @@ -34,6 +34,7 @@ const ZINDEX = 900; interface CustomFabProps { onClick: (event: React.MouseEvent) => void; size?: "small" | "medium" | "large"; + color?: "primary" | "success" | "error"; children: React.ReactNode; title?: string; isFullscreen?: boolean; @@ -43,6 +44,7 @@ interface CustomFabProps { export function CustomFab({ onClick, size = "small", + color = "primary", children, title, isFullscreen = false, @@ -56,7 +58,7 @@ export function CustomFab({ onClick={onClick} onTouchStart={(e) => e.stopPropagation()} size={size} - color="primary" + color={color} sx={{ margin: 0.25, zIndex: ZINDEX }} disabled={disabled} data-testid={dataTestId} @@ -267,13 +269,14 @@ export function CustomControls({ title={isRecording ? "Stop Recording" : "Start Recording"} disabled={manualRecordingLoading} data-testid="manual-recording-button" + color={isRecording ? "error" : "success"} > {manualRecordingLoading ? ( ) : isRecording ? ( - + ) : ( - + )} )} From 78c10b00475939a3edc9efeeb692f7e94266e5fd Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 04:21:53 +0700 Subject: [PATCH 023/120] style(docs): Change ONVIF icon in main page --- docs/src/pages/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index d9dbec65c..97f101f68 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -7,7 +7,7 @@ import { Demo, FaceActivated, GroupObjects, - CheckmarkOutline, + PartitionAuto, ImageReference, Movement, Video, @@ -60,7 +60,7 @@ function HomepageHeader() { - +
ONVIF Compatible
From 0dad18877a512b49ef05ba9516481761c1e1ee83 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 15:24:40 +0700 Subject: [PATCH 024/120] refactor(webserver/tuning): Change the key usage from component/domain constants --- viseron/components/webserver/api/v1/tune.py | 71 ++++++++++++------ .../webserver/api/v1/tuning/base.py | 30 +++++--- .../webserver/api/v1/tuning/camera.py | 6 +- .../api/v1/tuning/face_recognition.py | 35 +++++---- .../webserver/api/v1/tuning/labels.py | 73 +++++++++++++------ .../v1/tuning/license_plate_recognition.py | 37 ++++++---- .../api/v1/tuning/motion_detector.py | 21 ++++-- .../api/v1/tuning/object_detector.py | 47 +++++++----- 8 files changed, 211 insertions(+), 109 deletions(-) diff --git a/viseron/components/webserver/api/v1/tune.py b/viseron/components/webserver/api/v1/tune.py index a7f6daa18..9970f3860 100644 --- a/viseron/components/webserver/api/v1/tune.py +++ b/viseron/components/webserver/api/v1/tune.py @@ -8,9 +8,23 @@ from ruamel.yaml import YAML, YAMLError from ruamel.yaml.scalarstring import ScalarString +from viseron.components.discord.const import COMPONENT as DISCORD_COMPONENT +from viseron.components.gotify.const import COMPONENT as GOTIFY_COMPONENT +from viseron.components.logger.const import COMPONENT as LOGGER_COMPONENT +from viseron.components.nvr.const import COMPONENT as NVR_COMPONENT +from viseron.components.onvif.const import COMPONENT as ONVIF_COMPONENT +from viseron.components.telegram.const import COMPONENT as TELEGRAM_COMPONENT from viseron.components.webserver.api.handlers import BaseAPIHandler from viseron.components.webserver.auth import Role from viseron.const import CONFIG_PATH +from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN +from viseron.domains.face_recognition.const import DOMAIN as FACE_RECOGNITION_DOMAIN +from viseron.domains.license_plate_recognition import ( + DOMAIN as LICENSE_PLATE_RECOGNITION_DOMAIN, +) +from viseron.domains.motion_detector.const import DOMAIN as MOTION_DETECTOR_DOMAIN +from viseron.domains.object_detector.const import DOMAIN as OBJECT_DETECTOR_DOMAIN +from viseron.domains.post_processor.const import CONFIG_CAMERAS from .tuning import ( CameraTuningHandler, @@ -23,6 +37,20 @@ LOGGER = logging.getLogger(__name__) +# The component has a "cameras" key but the option to do tuning is missing +# or has not been implemented. +SKIPED_COMPONENTS = [ + NVR_COMPONENT, + LOGGER_COMPONENT, + DISCORD_COMPONENT, + GOTIFY_COMPONENT, + TELEGRAM_COMPONENT, +] + +# special case because the "Protocol" domain does not exist. +PROTOCOL_RELATED = "protocol" +PROTOCOL_COMPONENTS = [ONVIF_COMPONENT] + class TuneAPIHandler(BaseAPIHandler): """ @@ -119,7 +147,7 @@ def _process_direct_cameras( camera_identifier: str | None, ) -> None: """Process components with direct cameras key.""" - for cam_id, cam_config in component_config["cameras"].items(): + for cam_id, cam_config in component_config[CONFIG_CAMERAS].items(): if self._should_skip_camera(cam_id, camera_identifier): continue self._ensure_camera_in_settings(tune_settings, cam_id) @@ -156,8 +184,8 @@ def _process_domain_config( return # Handle domains with 'cameras' key (e.g., mog2.motion_detector.cameras) - if "cameras" in domain_config: - cameras = domain_config["cameras"] + if CONFIG_CAMERAS in domain_config: + cameras = domain_config[CONFIG_CAMERAS] if isinstance(cameras, dict): for cam_id, cam_config in cameras.items(): if self._should_skip_camera(cam_id, camera_identifier): @@ -167,7 +195,7 @@ def _process_domain_config( # Add available_labels for object_detector domain config_to_store = cam_config if isinstance(cam_config, dict) else {} - if domain_name == "object_detector": + if domain_name == OBJECT_DETECTOR_DOMAIN: available_labels = get_available_labels( component_name, domain_config ) @@ -176,7 +204,7 @@ def _process_domain_config( dict(config_to_store) if config_to_store else {} ) config_to_store["available_labels"] = available_labels - elif domain_name == "face_recognition": + elif domain_name == FACE_RECOGNITION_DOMAIN: # For face_recognition, available_labels is list of known faces # from filesystem. This will be populated by backend based on # face_recognition_path @@ -228,13 +256,13 @@ def _transform_to_tune_structure( if not isinstance(component_config, dict): continue - # Skip NVR component - if component_name == "nvr": + # Skiped components + if component_name in SKIPED_COMPONENTS: continue # Handle components with direct 'cameras' key - if "cameras" in component_config and isinstance( - component_config["cameras"], dict + if CONFIG_CAMERAS in component_config and isinstance( + component_config[CONFIG_CAMERAS], dict ): self._process_direct_cameras( tune_settings, component_config, component_name, camera_identifier @@ -318,17 +346,18 @@ async def update_camera_tune(self, camera_identifier: str) -> None: return if domain not in [ - "camera", - "object_detector", - "motion_detector", - "face_recognition", - "license_plate_recognition", + CAMERA_DOMAIN, + OBJECT_DETECTOR_DOMAIN, + MOTION_DETECTOR_DOMAIN, + FACE_RECOGNITION_DOMAIN, + LICENSE_PLATE_RECOGNITION_DOMAIN, ]: self.response_error( status_code=HTTPStatus.BAD_REQUEST, reason=f"Domain '{domain}' update not supported. " - "Only 'camera', 'object_detector', 'motion_detector', " - "'face_recognition', and 'license_plate_recognition' are supported.", + f"Only '{CAMERA_DOMAIN}', '{OBJECT_DETECTOR_DOMAIN}', " + f"'{MOTION_DETECTOR_DOMAIN}', '{FACE_RECOGNITION_DOMAIN}', and " + f"'{LICENSE_PLATE_RECOGNITION_DOMAIN}' are supported.", ) return @@ -345,15 +374,15 @@ def _update_config() -> dict[str, Any]: | None ) = None - if domain == "camera": + if domain == CAMERA_DOMAIN: handler = CameraTuningHandler(config) - elif domain == "object_detector": + elif domain == OBJECT_DETECTOR_DOMAIN: handler = ObjectDetectorTuningHandler(config) - elif domain == "motion_detector": + elif domain == MOTION_DETECTOR_DOMAIN: handler = MotionDetectorTuningHandler(config) - elif domain == "face_recognition": + elif domain == FACE_RECOGNITION_DOMAIN: handler = FaceRecognitionTuningHandler(config) - elif domain == "license_plate_recognition": + elif domain == LICENSE_PLATE_RECOGNITION_DOMAIN: handler = LicensePlateRecognitionTuningHandler(config) if handler: diff --git a/viseron/components/webserver/api/v1/tuning/base.py b/viseron/components/webserver/api/v1/tuning/base.py index d991a88e7..77106773a 100644 --- a/viseron/components/webserver/api/v1/tuning/base.py +++ b/viseron/components/webserver/api/v1/tuning/base.py @@ -5,6 +5,14 @@ from ruamel.yaml.comments import CommentedMap +from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN +from viseron.domains.object_detector.const import ( + CONFIG_LABEL_LABEL, + CONFIG_LABELS, + CONFIG_ZONE_NAME, +) +from viseron.domains.post_processor.const import CONFIG_CAMERAS + LOGGER = logging.getLogger(__name__) @@ -110,7 +118,7 @@ def _get_camera_config( domain_config = component_config[domain] # Special case for 'camera' domain which doesn't have 'cameras' key - if domain == "camera": + if domain == CAMERA_DOMAIN: if camera_id not in domain_config: LOGGER.warning( f"Camera '{camera_id}' not found in {component}.{domain}" @@ -119,11 +127,11 @@ def _get_camera_config( return domain_config[camera_id] # Other domains have 'cameras' key - if "cameras" not in domain_config: + if CONFIG_CAMERAS not in domain_config: LOGGER.warning(f"cameras not found in {component}.{domain} config") return None - cameras = domain_config["cameras"] + cameras = domain_config[CONFIG_CAMERAS] if camera_id not in cameras: LOGGER.warning(f"Camera '{camera_id}' not found in {component}.{domain}") return None @@ -137,14 +145,14 @@ def _merge_labels( # Create a dict mapping label name to its config from existing labels existing_label_map = {} for label in existing_labels: - label_name = label.get("label") + label_name = label.get(CONFIG_LABEL_LABEL) if label_name: existing_label_map[label_name] = dict(label) # Build result with only labels from request result_labels = [] for new_label in new_labels: - label_name = new_label.get("label") + label_name = new_label.get(CONFIG_LABEL_LABEL) if not label_name: continue @@ -166,14 +174,14 @@ def _merge_zones( # Create a dict mapping zone name to its config from existing zones existing_zone_map = {} for zone in existing_zones: - zone_name = zone.get("name") + zone_name = zone.get(CONFIG_ZONE_NAME) if zone_name: existing_zone_map[zone_name] = dict(zone) # Build result with only zones from request result_zones = [] for new_zone in new_zones: - zone_name = new_zone.get("name") + zone_name = new_zone.get(CONFIG_ZONE_NAME) if not zone_name: continue @@ -193,13 +201,13 @@ def _merge_zone_data( ) -> None: """Merge new zone data into existing zone.""" # Special handling for nested labels in zones - if "labels" in new_zone and "labels" in existing_zone: - existing_zone["labels"] = self._merge_labels( - existing_zone["labels"], new_zone["labels"] + if CONFIG_LABELS in new_zone and CONFIG_LABELS in existing_zone: + existing_zone[CONFIG_LABELS] = self._merge_labels( + existing_zone[CONFIG_LABELS], new_zone[CONFIG_LABELS] ) # Update other keys except labels for key, value in new_zone.items(): - if key != "labels": + if key != CONFIG_LABELS: existing_zone[key] = value else: existing_zone.update(new_zone) diff --git a/viseron/components/webserver/api/v1/tuning/camera.py b/viseron/components/webserver/api/v1/tuning/camera.py index d77aad3bb..ab39ce5a9 100644 --- a/viseron/components/webserver/api/v1/tuning/camera.py +++ b/viseron/components/webserver/api/v1/tuning/camera.py @@ -3,6 +3,8 @@ import logging from typing import Any +from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN + from .base import BaseTuningHandler LOGGER = logging.getLogger(__name__) @@ -13,12 +15,12 @@ class CameraTuningHandler(BaseTuningHandler): def update(self, camera_id: str, component: str, data: dict[str, Any]) -> bool: """Update camera configuration.""" - camera_config = self._get_camera_config(camera_id, component, "camera") + camera_config = self._get_camera_config(camera_id, component, CAMERA_DOMAIN) if camera_config is None: return False # Get camera_domain dict to update it - camera_domain = self.config[component]["camera"] + camera_domain = self.config[component][CAMERA_DOMAIN] updated_config = self._preserve_yaml_tags(camera_config, data) camera_domain[camera_id] = updated_config diff --git a/viseron/components/webserver/api/v1/tuning/face_recognition.py b/viseron/components/webserver/api/v1/tuning/face_recognition.py index 9148cc5f3..5539e3b41 100644 --- a/viseron/components/webserver/api/v1/tuning/face_recognition.py +++ b/viseron/components/webserver/api/v1/tuning/face_recognition.py @@ -3,6 +3,13 @@ import logging from typing import Any +from viseron.domains.face_recognition.const import DOMAIN as FACE_RECOGNITION_DOMAIN +from viseron.domains.post_processor.const import ( + CONFIG_CAMERAS, + CONFIG_LABELS, + CONFIG_MASK, +) + from .base import BaseTuningHandler LOGGER = logging.getLogger(__name__) @@ -14,43 +21,43 @@ class FaceRecognitionTuningHandler(BaseTuningHandler): def update(self, camera_id: str, component: str, data: dict[str, Any]) -> bool: """Update face recognition configuration.""" camera_config = self._get_camera_config( - camera_id, component, "face_recognition" + camera_id, component, FACE_RECOGNITION_DOMAIN ) if camera_config is None: return False # Get cameras dict to update it later - cameras = self.config[component]["face_recognition"]["cameras"] + cameras = self.config[component][FACE_RECOGNITION_DOMAIN][CONFIG_CAMERAS] # Build ordered config with labels first, then mask, then other fields ordered_config = {} # Update labels (simple list of strings for face_recognition) - if "labels" in data: - if data["labels"]: - ordered_config["labels"] = data["labels"] + if CONFIG_LABELS in data: + if data[CONFIG_LABELS]: + ordered_config[CONFIG_LABELS] = data[CONFIG_LABELS] # If labels is empty/None, don't include it (will be deleted from existing) - elif "labels" in camera_config: + elif CONFIG_LABELS in camera_config: # Preserve existing labels if not in data - ordered_config["labels"] = camera_config["labels"] + ordered_config[CONFIG_LABELS] = camera_config[CONFIG_LABELS] # Update mask (always replace) - if "mask" in data: - if data["mask"]: - ordered_config["mask"] = data["mask"] + if CONFIG_MASK in data: + if data[CONFIG_MASK]: + ordered_config[CONFIG_MASK] = data[CONFIG_MASK] # If mask is empty/None, don't include it (will be deleted from existing) - elif "mask" in camera_config: + elif CONFIG_MASK in camera_config: # Preserve existing mask if not in data - ordered_config["mask"] = camera_config["mask"] + ordered_config[CONFIG_MASK] = camera_config[CONFIG_MASK] # Update all other fields (miscellaneous fields like expire_after, etc.) # Frontend should filter out internal fields before sending for key, value in camera_config.items(): - if key not in {"labels", "mask"}: + if key not in {CONFIG_LABELS, CONFIG_MASK}: ordered_config[key] = value for key, value in data.items(): - if key not in {"labels", "mask"}: + if key not in {CONFIG_LABELS, CONFIG_MASK}: if value is not None: # Preserve YAML tags if existing value has one if key in camera_config: diff --git a/viseron/components/webserver/api/v1/tuning/labels.py b/viseron/components/webserver/api/v1/tuning/labels.py index 9fb07d75c..98094b5ba 100644 --- a/viseron/components/webserver/api/v1/tuning/labels.py +++ b/viseron/components/webserver/api/v1/tuning/labels.py @@ -5,9 +5,34 @@ from ultralytics import YOLO -from viseron.components.darknet.const import DEFAULT_LABEL_PATH as DARKNET_LABEL_PATH -from viseron.components.edgetpu.const import DEFAULT_CLASSIFIER_LABEL_PATH -from viseron.components.hailo.const import DEFAULT_LABEL_PATH as HAILO_LABEL_PATH +from viseron.components.codeprojectai.const import ( + COMPONENT as CODEPROJECTAI_COMPONENT, + CONFIG_CUSTOM_MODEL as CODEPROJECTAI_CONFIG_CUSTOM_MODEL, + DEFAULT_CUSTOM_MODEL as CODEPROJECTAI_DEFAULT_CUSTOM_MODEL, +) +from viseron.components.darknet.const import ( + COMPONENT as DARKNET_COMPONENT, + CONFIG_LABEL_PATH as DARKNET_CONFIG_LABEL_PATH, + DEFAULT_LABEL_PATH as DARKNET_LABEL_PATH, +) +from viseron.components.deepstack.const import ( + COMPONENT as DEEPSTACK_COMPONENT, + CONFIG_CUSTOM_MODEL as DEEPSTACK_CONFIG_CUSTOM_MODEL, +) +from viseron.components.edgetpu.const import ( + COMPONENT as EDGETPU_COMPONENT, + CONFIG_LABEL_PATH as EDGETPU_CONFIG_LABEL_PATH, + DEFAULT_CLASSIFIER_LABEL_PATH, +) +from viseron.components.hailo.const import ( + COMPONENT as HAILO_COMPONENT, + CONFIG_LABEL_PATH as HAILO_CONFIG_LABEL_PATH, + DEFAULT_LABEL_PATH as HAILO_LABEL_PATH, +) +from viseron.components.yolo.const import ( + COMPONENT as YOLO_COMPONENT, + CONFIG_MODEL_PATH as YOLO_CONFIG_MODEL_PATH, +) LOGGER = logging.getLogger(__name__) @@ -95,33 +120,39 @@ def get_available_labels( component_name: str, domain_config: dict | None = None ) -> list[str] | None: """Get available labels for an object detector component.""" - if component_name == "darknet": + if component_name == DARKNET_COMPONENT: label_path = DARKNET_LABEL_PATH - if domain_config and "label_path" in domain_config: - label_path = domain_config["label_path"] + if domain_config and DARKNET_CONFIG_LABEL_PATH in domain_config: + label_path = domain_config[DARKNET_CONFIG_LABEL_PATH] return _load_labels_from_file(label_path) - if component_name == "hailo": + + if component_name == HAILO_COMPONENT: label_path = HAILO_LABEL_PATH - if domain_config and "label_path" in domain_config: - label_path = domain_config["label_path"] + if domain_config and HAILO_CONFIG_LABEL_PATH in domain_config: + label_path = domain_config[HAILO_CONFIG_LABEL_PATH] return _load_labels_from_file(label_path) - if component_name == "edgetpu": + + if component_name == EDGETPU_COMPONENT: label_path = DEFAULT_CLASSIFIER_LABEL_PATH - if domain_config and "label_path" in domain_config: - label_path = domain_config["label_path"] + if domain_config and EDGETPU_CONFIG_LABEL_PATH in domain_config: + label_path = domain_config[EDGETPU_CONFIG_LABEL_PATH] return _load_labels_from_file(label_path) - if component_name == "deepstack": - if domain_config and domain_config.get("custom_model"): + + if component_name == DEEPSTACK_COMPONENT: + if domain_config and domain_config.get(DEEPSTACK_CONFIG_CUSTOM_MODEL): return None # No available labels for custom models return _load_labels_from_file(DARKNET_LABEL_PATH) # Default options - if component_name == "codeprojectai": - selected_model = "ipcam-general" # Default model - if domain_config and "custom_model" in domain_config: - selected_model = domain_config["custom_model"] + + if component_name == CODEPROJECTAI_COMPONENT: + selected_model = CODEPROJECTAI_DEFAULT_CUSTOM_MODEL # Default model + if domain_config and CODEPROJECTAI_CONFIG_CUSTOM_MODEL in domain_config: + selected_model = domain_config[CODEPROJECTAI_CONFIG_CUSTOM_MODEL] return CODEPROJECTAI_MODELS.get(selected_model, []) - if component_name == "yolo": + + if component_name == YOLO_COMPONENT: # YOLO requires model_path to be configured (no default model) - if not domain_config or "model_path" not in domain_config: + if not domain_config or YOLO_CONFIG_MODEL_PATH not in domain_config: return None - return _load_yolo_labels(domain_config["model_path"]) + return _load_yolo_labels(domain_config[YOLO_CONFIG_MODEL_PATH]) + return None diff --git a/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py b/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py index 4efaae33f..807a0612c 100644 --- a/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py +++ b/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py @@ -3,6 +3,15 @@ import logging from typing import Any +from viseron.domains.license_plate_recognition.const import ( + DOMAIN as LICENSE_PLATE_RECOGNITION_DOMAIN, +) +from viseron.domains.post_processor.const import ( + CONFIG_CAMERAS, + CONFIG_LABELS, + CONFIG_MASK, +) + from .base import BaseTuningHandler LOGGER = logging.getLogger(__name__) @@ -14,43 +23,43 @@ class LicensePlateRecognitionTuningHandler(BaseTuningHandler): def update(self, camera_id: str, component: str, data: dict[str, Any]) -> bool: """Update license plate recognition configuration.""" camera_config = self._get_camera_config( - camera_id, component, "license_plate_recognition" + camera_id, component, LICENSE_PLATE_RECOGNITION_DOMAIN ) if camera_config is None: return False # Get cameras dict to update it later - cameras = self.config[component]["license_plate_recognition"]["cameras"] + cameras = self.config[component][LICENSE_PLATE_RECOGNITION_DOMAIN][CONFIG_CAMERAS] # Build ordered config with labels first, then mask, then other fields ordered_config = {} # Update labels (simple list of strings for license_plate_recognition) - if "labels" in data: - if data["labels"]: - ordered_config["labels"] = data["labels"] + if CONFIG_LABELS in data: + if data[CONFIG_LABELS]: + ordered_config[CONFIG_LABELS] = data[CONFIG_LABELS] # If labels is empty/None, don't include it (will be deleted from existing) - elif "labels" in camera_config: + elif CONFIG_LABELS in camera_config: # Preserve existing labels if not in data - ordered_config["labels"] = camera_config["labels"] + ordered_config[CONFIG_LABELS] = camera_config[CONFIG_LABELS] # Update mask (always replace) - if "mask" in data: - if data["mask"]: - ordered_config["mask"] = data["mask"] + if CONFIG_MASK in data: + if data[CONFIG_MASK]: + ordered_config[CONFIG_MASK] = data[CONFIG_MASK] # If mask is empty/None, don't include it (will be deleted from existing) - elif "mask" in camera_config: + elif CONFIG_MASK in camera_config: # Preserve existing mask if not in data - ordered_config["mask"] = camera_config["mask"] + ordered_config[CONFIG_MASK] = camera_config[CONFIG_MASK] # Update all other fields (miscellaneous fields) # Frontend should filter out internal fields before sending for key, value in camera_config.items(): - if key not in {"labels", "mask"}: + if key not in {CONFIG_LABELS, CONFIG_MASK}: ordered_config[key] = value for key, value in data.items(): - if key not in {"labels", "mask"}: + if key not in {CONFIG_LABELS, CONFIG_MASK}: if value is not None: # Preserve YAML tags if existing value has one if key in camera_config: diff --git a/viseron/components/webserver/api/v1/tuning/motion_detector.py b/viseron/components/webserver/api/v1/tuning/motion_detector.py index cd039621e..d8e4c6bf3 100644 --- a/viseron/components/webserver/api/v1/tuning/motion_detector.py +++ b/viseron/components/webserver/api/v1/tuning/motion_detector.py @@ -3,6 +3,11 @@ import logging from typing import Any +from viseron.domains.motion_detector.const import ( + CONFIG_MASK, + DOMAIN as MOTION_DETECTOR_DOMAIN, +) + from .base import BaseTuningHandler LOGGER = logging.getLogger(__name__) @@ -13,21 +18,23 @@ class MotionDetectorTuningHandler(BaseTuningHandler): def update(self, camera_id: str, component: str, data: dict[str, Any]) -> bool: """Update motion detector configuration.""" - camera_config = self._get_camera_config(camera_id, component, "motion_detector") + camera_config = self._get_camera_config( + camera_id, component, MOTION_DETECTOR_DOMAIN + ) if camera_config is None: return False # Update mask (always replace) - if "mask" in data: - if data["mask"]: - camera_config["mask"] = data["mask"] - elif "mask" in camera_config: - del camera_config["mask"] + if CONFIG_MASK in data: + if data[CONFIG_MASK]: + camera_config[CONFIG_MASK] = data[CONFIG_MASK] + elif CONFIG_MASK in camera_config: + del camera_config[CONFIG_MASK] # Update all other fields (miscellaneous fields) # Frontend should filter out internal fields before sending for key, value in data.items(): - if key != "mask": + if key != CONFIG_MASK: if value is not None: # Preserve YAML tags if existing value has one if key in camera_config: diff --git a/viseron/components/webserver/api/v1/tuning/object_detector.py b/viseron/components/webserver/api/v1/tuning/object_detector.py index b74af5345..a1e5ebb2c 100644 --- a/viseron/components/webserver/api/v1/tuning/object_detector.py +++ b/viseron/components/webserver/api/v1/tuning/object_detector.py @@ -3,6 +3,13 @@ import logging from typing import Any +from viseron.domains.object_detector.const import ( + CONFIG_LABELS, + CONFIG_MASK, + CONFIG_ZONES, + DOMAIN as OBJECT_DETECTOR_DOMAIN, +) + from .base import BaseTuningHandler LOGGER = logging.getLogger(__name__) @@ -13,39 +20,41 @@ class ObjectDetectorTuningHandler(BaseTuningHandler): def update(self, camera_id: str, component: str, data: dict[str, Any]) -> bool: """Update object detector configuration.""" - camera_config = self._get_camera_config(camera_id, component, "object_detector") + camera_config = self._get_camera_config( + camera_id, component, OBJECT_DETECTOR_DOMAIN + ) if camera_config is None: return False # Update labels with merge strategy - if "labels" in data: - existing_labels = camera_config.get("labels", []) - merged_labels = self._merge_labels(existing_labels, data["labels"]) + if CONFIG_LABELS in data: + existing_labels = camera_config.get(CONFIG_LABELS, []) + merged_labels = self._merge_labels(existing_labels, data[CONFIG_LABELS]) if merged_labels: - camera_config["labels"] = merged_labels - elif "labels" in camera_config: - del camera_config["labels"] + camera_config[CONFIG_LABELS] = merged_labels + elif CONFIG_LABELS in camera_config: + del camera_config[CONFIG_LABELS] # Update zones with merge strategy - if "zones" in data: - existing_zones = camera_config.get("zones", []) - merged_zones = self._merge_zones(existing_zones, data["zones"]) + if CONFIG_ZONES in data: + existing_zones = camera_config.get(CONFIG_ZONES, []) + merged_zones = self._merge_zones(existing_zones, data[CONFIG_ZONES]) if merged_zones: - camera_config["zones"] = merged_zones - elif "zones" in camera_config: - del camera_config["zones"] + camera_config[CONFIG_ZONES] = merged_zones + elif CONFIG_ZONES in camera_config: + del camera_config[CONFIG_ZONES] # Update mask (always replace) - if "mask" in data: - if data["mask"]: - camera_config["mask"] = data["mask"] - elif "mask" in camera_config: - del camera_config["mask"] + if CONFIG_MASK in data: + if data[CONFIG_MASK]: + camera_config[CONFIG_MASK] = data[CONFIG_MASK] + elif CONFIG_MASK in camera_config: + del camera_config[CONFIG_MASK] # Update all other fields (miscellaneous fields like fps, etc.) # Frontend should filter out internal fields before sending for key, value in data.items(): - if key not in {"labels", "zones", "mask"}: + if key not in {CONFIG_LABELS, CONFIG_ZONES, CONFIG_MASK}: if value is not None: # Preserve YAML tags if existing value has one if key in camera_config: From 0472f16b5a67dd2bf21482fd2cdb3c90336cae03 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 15:27:50 +0700 Subject: [PATCH 025/120] fix: Make sure the camera start/stop feature is for admin role only --- frontend/src/components/camera/CameraCard.tsx | 74 ++++++++++--------- viseron/components/webserver/api/v1/camera.py | 3 + 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/camera/CameraCard.tsx b/frontend/src/components/camera/CameraCard.tsx index ba506b0f1..23a0c0ae6 100644 --- a/frontend/src/components/camera/CameraCard.tsx +++ b/frontend/src/components/camera/CameraCard.tsx @@ -255,41 +255,45 @@ function SuccessCameraCard({ sx={{ width: "100%", alignItems: "center" }} > - - - ) : ( - - ) - } - label="" - disabled={cameraStartStop.isPending} - color={camera.is_on ? "error" : "primary"} - size="small" - sx={{ - height: 30, - borderRadius: 1.2, - px: 1.5, - "& .MuiChip-icon": { - margin: 0, - }, - "& .MuiChip-label": { - padding: 0, - width: 0, - }, - justifyContent: "center", - }} - onClick={() => { - if (cameraStartStop.isPending) return; - cameraStartStop.mutate({ - camera, - action: camera.is_on ? "stop" : "start", - }); - }} - /> - + {(!auth.enabled || user?.role === "admin") && ( + + + ) : ( + + ) + } + label="" + disabled={cameraStartStop.isPending} + color={camera.is_on ? "error" : "primary"} + size="small" + sx={{ + height: 30, + borderRadius: 1.2, + px: 1.5, + "& .MuiChip-icon": { + margin: 0, + }, + "& .MuiChip-label": { + padding: 0, + width: 0, + }, + justifyContent: "center", + }} + onClick={() => { + if (cameraStartStop.isPending) return; + cameraStartStop.mutate({ + camera, + action: camera.is_on ? "stop" : "start", + }); + }} + /> + + )}
[A-Za-z0-9_]+)/start", "supported_methods": ["POST"], "method": "post_start_camera", }, { + "requires_role": [Role.ADMIN], "path_pattern": r"/camera/(?P[A-Za-z0-9_]+)/stop", "supported_methods": ["POST"], "method": "post_stop_camera", From 57513abacb0b75e529533e4f1130d96378620830 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 15:45:43 +0700 Subject: [PATCH 026/120] style: Run pre-commit --- viseron/components/webserver/api/v1/tune.py | 6 +++--- .../webserver/api/v1/tuning/license_plate_recognition.py | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/viseron/components/webserver/api/v1/tune.py b/viseron/components/webserver/api/v1/tune.py index 9970f3860..d88c938b1 100644 --- a/viseron/components/webserver/api/v1/tune.py +++ b/viseron/components/webserver/api/v1/tune.py @@ -39,7 +39,7 @@ # The component has a "cameras" key but the option to do tuning is missing # or has not been implemented. -SKIPED_COMPONENTS = [ +SKIPPED_COMPONENTS = [ NVR_COMPONENT, LOGGER_COMPONENT, DISCORD_COMPONENT, @@ -256,8 +256,8 @@ def _transform_to_tune_structure( if not isinstance(component_config, dict): continue - # Skiped components - if component_name in SKIPED_COMPONENTS: + # Skipped components + if component_name in SKIPPED_COMPONENTS: continue # Handle components with direct 'cameras' key diff --git a/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py b/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py index 807a0612c..5a0462136 100644 --- a/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py +++ b/viseron/components/webserver/api/v1/tuning/license_plate_recognition.py @@ -29,7 +29,9 @@ def update(self, camera_id: str, component: str, data: dict[str, Any]) -> bool: return False # Get cameras dict to update it later - cameras = self.config[component][LICENSE_PLATE_RECOGNITION_DOMAIN][CONFIG_CAMERAS] + cameras = self.config[component][LICENSE_PLATE_RECOGNITION_DOMAIN][ + CONFIG_CAMERAS + ] # Build ordered config with labels first, then mask, then other fields ordered_config = {} From 3b225b1729c3dff53bf082aae2025c72cc9801a1 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 17:37:51 +0700 Subject: [PATCH 027/120] fix: Make sure the manual recording feature is for admin/write role only --- .../src/components/player/CustomControls.tsx | 76 ++++++++++--------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/player/CustomControls.tsx b/frontend/src/components/player/CustomControls.tsx index c4f5815a2..9dacb8c73 100644 --- a/frontend/src/components/player/CustomControls.tsx +++ b/frontend/src/components/player/CustomControls.tsx @@ -26,6 +26,7 @@ import Typography from "@mui/material/Typography"; import React, { useCallback, useEffect, useRef, useState } from "react"; import screenfull from "screenfull"; +import { useAuthContext } from "context/AuthContext"; import { useFullscreen } from "context/FullscreenContext"; import { isTouchDevice } from "lib/helpers"; @@ -134,6 +135,7 @@ export function CustomControls({ const [isDragging, setIsDragging] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const volumeControlRef = useRef(null); + const { user } = useAuthContext(); const handleVolumeControlMouseEnter = useCallback(() => { if (!isDragging) { @@ -246,44 +248,44 @@ export function CustomControls({ alignItems: "center", }} > - {/* LIVE/record button */} - {onLiveClick && ( - - )} - {onManualRecording && ( - - {manualRecordingLoading ? ( - - ) : isRecording ? ( - - ) : ( - + {/* Left-aligned controls */} + + {onLiveClick && ( + + )} + + {(!user || user.role === "admin" || user.role === "write") && + onManualRecording && ( + + {manualRecordingLoading ? ( + + ) : isRecording ? ( + + ) : ( + + )} + )} - - )} - {!onLiveClick && !onManualRecording && ( - // Empty div so that 'space-between' works -
- )} + {/* Right-aligned controls */} From 18be91e15abf7fbe329d1dbc7ff871db2eb05e95 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 17:43:20 +0700 Subject: [PATCH 028/120] fix(webserver): Make sure the manual recording API is for admin/write role only --- viseron/components/webserver/api/v1/camera.py | 1 + 1 file changed, 1 insertion(+) diff --git a/viseron/components/webserver/api/v1/camera.py b/viseron/components/webserver/api/v1/camera.py index aaf43954b..f3994d988 100644 --- a/viseron/components/webserver/api/v1/camera.py +++ b/viseron/components/webserver/api/v1/camera.py @@ -75,6 +75,7 @@ class CameraAPIHandler(BaseAPIHandler): "method": "post_stop_camera", }, { + "requires_role": [Role.ADMIN, Role.WRITE], "path_pattern": ( r"/camera/(?P[A-Za-z0-9_]+)/manual_recording" ), From 6cc53fd837b508641005bc57eefb3d52b38270ad Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 20:10:21 +0700 Subject: [PATCH 029/120] feat(onvif): Add ptz_support key to camera based on available components --- viseron/components/onvif/__init__.py | 2 ++ viseron/domains/camera/__init__.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/viseron/components/onvif/__init__.py b/viseron/components/onvif/__init__.py index a5de21a22..7659c9d33 100644 --- a/viseron/components/onvif/__init__.py +++ b/viseron/components/onvif/__init__.py @@ -697,6 +697,8 @@ async def init_ptz(): ) await ptz_service.initialize() self._ptz_services[camera.identifier] = ptz_service + # Inject PTZ support into camera + camera.get_ptz_support = lambda: COMPONENT LOGGER.debug(f"Initialized PTZ service for {camera.identifier}") except Exception as error: # pylint: disable=broad-exception-caught LOGGER.error( diff --git a/viseron/domains/camera/__init__.py b/viseron/domains/camera/__init__.py index 3a986bf68..d7237f2bf 100644 --- a/viseron/domains/camera/__init__.py +++ b/viseron/domains/camera/__init__.py @@ -211,6 +211,7 @@ def as_dict(self) -> dict[str, Any]: "connected": self.connected, "live_stream_available": self.live_stream_available, "is_recording": self.is_recording, + "ptz_support": self.get_ptz_support(), } def generate_token(self): @@ -428,6 +429,10 @@ def config(self) -> dict[str, Any]: """Return camera config.""" return self._config + def get_ptz_support(self) -> str | None: + """Return PTZ support type.""" + return None + def tier_base_path(self, tier_id: int, tier_category: str, subcategory: str) -> str: """Return storage tier base path.""" return self._storage.camera_tier_handlers[self.identifier][tier_category][ From fa89b4d2715b91207f8224a084fe16c456af0199 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 20:40:40 +0700 Subject: [PATCH 030/120] fix(onvif): Store ptz_support as camera state instead of method override --- viseron/components/onvif/__init__.py | 2 +- viseron/domains/camera/__init__.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/viseron/components/onvif/__init__.py b/viseron/components/onvif/__init__.py index 7659c9d33..d1dbd891f 100644 --- a/viseron/components/onvif/__init__.py +++ b/viseron/components/onvif/__init__.py @@ -698,7 +698,7 @@ async def init_ptz(): await ptz_service.initialize() self._ptz_services[camera.identifier] = ptz_service # Inject PTZ support into camera - camera.get_ptz_support = lambda: COMPONENT + camera._ptz_support = COMPONENT LOGGER.debug(f"Initialized PTZ service for {camera.identifier}") except Exception as error: # pylint: disable=broad-exception-caught LOGGER.error( diff --git a/viseron/domains/camera/__init__.py b/viseron/domains/camera/__init__.py index d7237f2bf..a660da8d0 100644 --- a/viseron/domains/camera/__init__.py +++ b/viseron/domains/camera/__init__.py @@ -185,6 +185,8 @@ def __init__(self, vis: Viseron, component: str, config, identifier: str) -> Non self._logger.debug("Still image is configured, setting availability.") self.still_image_available = True + self._ptz_support: str | None = None + def __post_init__(self, *args, **kwargs): """Post init hook.""" self._vis.register_domain(DOMAIN, self._identifier, self) @@ -211,7 +213,7 @@ def as_dict(self) -> dict[str, Any]: "connected": self.connected, "live_stream_available": self.live_stream_available, "is_recording": self.is_recording, - "ptz_support": self.get_ptz_support(), + "ptz_support": self.ptz_support, } def generate_token(self): @@ -429,9 +431,10 @@ def config(self) -> dict[str, Any]: """Return camera config.""" return self._config - def get_ptz_support(self) -> str | None: + @property + def ptz_support(self) -> str | None: """Return PTZ support type.""" - return None + return self._ptz_support def tier_base_path(self, tier_id: int, tier_category: str, subcategory: str) -> str: """Return storage tier base path.""" From 3686d6ac195e7a43b8f936836dd2e49ea6a01fd6 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 20:42:24 +0700 Subject: [PATCH 031/120] style: Add disable=protected-access for ptz_support camera key in ONVIF --- viseron/components/onvif/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/viseron/components/onvif/__init__.py b/viseron/components/onvif/__init__.py index d1dbd891f..a404cb971 100644 --- a/viseron/components/onvif/__init__.py +++ b/viseron/components/onvif/__init__.py @@ -698,6 +698,7 @@ async def init_ptz(): await ptz_service.initialize() self._ptz_services[camera.identifier] = ptz_service # Inject PTZ support into camera + # pylint: disable=protected-access camera._ptz_support = COMPONENT LOGGER.debug(f"Initialized PTZ service for {camera.identifier}") except Exception as error: # pylint: disable=broad-exception-caught From ad57045e005671fe788fe77c1d61e802f95023ec Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Mon, 12 Jan 2026 21:14:25 +0700 Subject: [PATCH 032/120] fix(webserver): resolve nested API handlers with dynamic path segments --- viseron/components/webserver/api/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/viseron/components/webserver/api/__init__.py b/viseron/components/webserver/api/__init__.py index 5cd24d311..0d3839036 100644 --- a/viseron/components/webserver/api/__init__.py +++ b/viseron/components/webserver/api/__init__.py @@ -49,9 +49,7 @@ def get_handler(api_version: str, endpoint: str): endpoint_parts = endpoint.split("/") for i in range(len(endpoint_parts), 0, -1): module_parts = endpoint_parts[:i] - module_path = ( - version_path / "/".join(module_parts[:-1]) / f"{module_parts[-1]}.py" - ) + module_path = version_path.joinpath(*module_parts).with_suffix(".py") if module_path.is_file(): try: From f2267fd9c2e41febc838beb101dcf58786168d5b Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Tue, 13 Jan 2026 01:44:42 +0700 Subject: [PATCH 033/120] style(ui): Make icons in camera card more responsive --- frontend/src/components/camera/CameraCard.tsx | 49 ++++++++++++++++--- .../src/components/camera/CameraUptime.tsx | 9 +++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/camera/CameraCard.tsx b/frontend/src/components/camera/CameraCard.tsx index 23a0c0ae6..053bd0a5a 100644 --- a/frontend/src/components/camera/CameraCard.tsx +++ b/frontend/src/components/camera/CameraCard.tsx @@ -262,9 +262,19 @@ function SuccessCameraCard({ + ) : ( - + ) } label="" @@ -311,7 +321,12 @@ function SuccessCameraCard({ component={Link} to={`/events?camera=${camera.identifier}&tab=events`} > - + @@ -319,7 +334,12 @@ function SuccessCameraCard({ component={Link} to={`/events?camera=${camera.identifier}&tab=timeline`} > - + @@ -327,7 +347,12 @@ function SuccessCameraCard({ component={Link} to={`/recordings/${camera.identifier}`} > - + @@ -335,7 +360,12 @@ function SuccessCameraCard({ component={Link} to={`/live?camera=${camera.identifier}`} > - + {(!auth.enabled || user?.role === "admin") && ( @@ -344,7 +374,12 @@ function SuccessCameraCard({ component={Link} to={`/cameras/${camera.identifier}`} > - + )} diff --git a/frontend/src/components/camera/CameraUptime.tsx b/frontend/src/components/camera/CameraUptime.tsx index e3fd2595c..9c15ccd37 100644 --- a/frontend/src/components/camera/CameraUptime.tsx +++ b/frontend/src/components/camera/CameraUptime.tsx @@ -29,7 +29,14 @@ export function CameraUptime({ if (compact) { return ( } + icon={ + + } label={displayText} size="small" color={isConnected ? "default" : "error"} From 8b37e2176b5082c519e7c43cffd61d5fcc3f62a2 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Tue, 13 Jan 2026 20:30:59 +0700 Subject: [PATCH 034/120] refactor(onvif): Changed service name for clarity --- viseron/components/onvif/device.py | 53 +++++++++++++++-------------- viseron/components/onvif/imaging.py | 18 +++++----- viseron/components/onvif/media.py | 41 +++++++++++----------- viseron/components/onvif/ptz.py | 37 +++++++++++--------- 4 files changed, 77 insertions(+), 72 deletions(-) diff --git a/viseron/components/onvif/device.py b/viseron/components/onvif/device.py index df4f2bef6..cd97dc69a 100644 --- a/viseron/components/onvif/device.py +++ b/viseron/components/onvif/device.py @@ -1,4 +1,5 @@ """Device (Core) service management for ONVIF component.""" + from __future__ import annotations import logging @@ -38,11 +39,11 @@ def __init__( self._client = client self._config = config self._auto_config = auto_config - self._device_service: Any = None + self._onvif_device_service: Any = None # ONVIF Device service instance async def initialize(self) -> None: """Initialize the Device/Core service.""" - self._device_service = self._client.devicemgmt() + self._onvif_device_service = self._client.devicemgmt() if not self._auto_config and self._config: await self.apply_config() @@ -54,22 +55,22 @@ async def initialize(self) -> None: @operation() async def get_capabilities(self) -> Any: """Get device capabilities.""" - return self._device_service.GetCapabilities(Category="All") + return self._onvif_device_service.GetCapabilities(Category="All") @operation() async def get_services(self) -> Any: """Get available services on the device.""" - return self._device_service.GetServices(IncludeCapability=False) + return self._onvif_device_service.GetServices(IncludeCapability=False) @operation() async def get_device_information(self) -> Any: """Get device information.""" - return self._device_service.GetDeviceInformation() + return self._onvif_device_service.GetDeviceInformation() @operation() async def get_discovery_mode(self) -> Any: """Get discovery mode.""" - return self._device_service.GetDiscoveryMode() + return self._onvif_device_service.GetDiscoveryMode() @operation() async def set_discovery_mode(self, discoverable: bool | None = None) -> bool: @@ -78,38 +79,38 @@ async def set_discovery_mode(self, discoverable: bool | None = None) -> bool: discoverable = self._config.get(CONFIG_DEVICE_DISCOVERABLE, True) mode = "Discoverable" if discoverable else "NonDiscoverable" - self._device_service.SetDiscoveryMode(DiscoveryMode=mode) + self._onvif_device_service.SetDiscoveryMode(DiscoveryMode=mode) return True @operation() async def get_scopes(self) -> Any: """Get device scopes.""" - return self._device_service.GetScopes() + return self._onvif_device_service.GetScopes() @operation() async def add_scopes(self, scopes: list[str]) -> bool: """Add device scopes.""" - self._device_service.AddScopes(Scopes=scopes) + self._onvif_device_service.AddScopes(Scopes=scopes) return True @operation() async def set_scopes(self, scopes: list[str]) -> bool: """Set device scopes.""" - self._device_service.SetScopes(Scopes=scopes) + self._onvif_device_service.SetScopes(Scopes=scopes) return True @operation() async def remove_scopes(self, scopes: list[str]) -> bool: """Remove device scopes.""" - self._device_service.RemoveScopes(Scopes=scopes) + self._onvif_device_service.RemoveScopes(Scopes=scopes) return True @operation() async def system_reboot(self) -> bool: """Reboot the device.""" LOGGER.warning(f"Rebooting ONVIF camera for {self._camera.identifier}") - self._device_service.SystemReboot() + self._onvif_device_service.SystemReboot() return True @@ -118,7 +119,7 @@ async def system_reboot(self) -> bool: @operation() async def get_system_date_and_time(self) -> Any: """Get system date and time from the device.""" - return self._device_service.GetSystemDateAndTime() + return self._onvif_device_service.GetSystemDateAndTime() @operation() async def set_system_date_and_time( @@ -138,7 +139,7 @@ async def set_system_date_and_time( timezone = timezone or self._config.get(CONFIG_DEVICE_TIMEZONE) timezone_param = {"TZ": timezone} if timezone else None - self._device_service.SetSystemDateAndTime( + self._onvif_device_service.SetSystemDateAndTime( DateTimeType=datetime_type, DaylightSavings=daylight_savings, TimeZone=timezone_param, @@ -151,23 +152,23 @@ async def set_system_date_and_time( @operation() async def get_users(self) -> Any: """Get device users.""" - return self._device_service.GetUsers() + return self._onvif_device_service.GetUsers() @operation() async def create_users(self, user: dict[str, Any]) -> Any: """Create device users.""" - return self._device_service.CreateUsers(User=user) + return self._onvif_device_service.CreateUsers(User=user) @operation() async def delete_users(self, usernames: list[str]) -> bool: """Delete device users.""" - self._device_service.DeleteUsers(Usernames=usernames) + self._onvif_device_service.DeleteUsers(Usernames=usernames) return True @operation() async def set_user(self, user: dict[str, Any]) -> bool: """Set device user.""" - self._device_service.SetUser(User=user) + self._onvif_device_service.SetUser(User=user) return True # ---- Network Operations ---- # @@ -175,18 +176,18 @@ async def set_user(self, user: dict[str, Any]) -> bool: @operation() async def get_hostname(self) -> Any: """Get device hostname.""" - return self._device_service.GetHostname() + return self._onvif_device_service.GetHostname() @operation() async def set_hostname(self, hostname: str | None = None) -> Any: """Set device hostname.""" - self._device_service.SetHostname(Name=hostname) + self._onvif_device_service.SetHostname(Name=hostname) return True @operation() async def get_ntp(self) -> Any: """Get NTP configuration.""" - return self._device_service.GetNTP() + return self._onvif_device_service.GetNTP() @operation() async def set_ntp( @@ -214,29 +215,29 @@ async def set_ntp( case _: return False - self._device_service.SetNTP(FromDHCP=from_dhcp, NTPManual=ntp_manual) + self._onvif_device_service.SetNTP(FromDHCP=from_dhcp, NTPManual=ntp_manual) return True @operation() async def get_network_default_gateway(self) -> Any: """Get network interfaces.""" - return self._device_service.GetNetworkDefaultGateway() + return self._onvif_device_service.GetNetworkDefaultGateway() @operation() async def get_network_interfaces(self) -> Any: """Get network interfaces.""" - return self._device_service.GetNetworkInterfaces() + return self._onvif_device_service.GetNetworkInterfaces() @operation() async def get_network_protocols(self) -> Any: """Get network protocols.""" - return self._device_service.GetNetworkProtocols() + return self._onvif_device_service.GetNetworkProtocols() @operation() async def get_dns(self) -> Any: """Get network DNS.""" - return self._device_service.GetDNS() + return self._onvif_device_service.GetDNS() # ## Apply Configuration at Startup ## # diff --git a/viseron/components/onvif/imaging.py b/viseron/components/onvif/imaging.py index f4d5d81be..68aa0399a 100644 --- a/viseron/components/onvif/imaging.py +++ b/viseron/components/onvif/imaging.py @@ -54,13 +54,13 @@ def __init__( media_service # you can't use imaging without media service ) self._media_profile: Any = None # selected media profile - self._imaging_service: Any = None + self._onvif_imaging_service: Any = None # ONVIF Imaging service instance self._video_source_token: str | None = None async def initialize(self) -> None: """Initialize the Imaging service.""" - self._imaging_service = self._client.imaging() + self._onvif_imaging_service = self._client.imaging() self._media_profile = self._media_service.get_selected_profile() self._video_source_token = ( @@ -110,14 +110,14 @@ def _convert_keys_to_camel(self, obj): @operation() async def get_options(self) -> Any: """Get available imaging options.""" - return self._imaging_service.GetOptions( + return self._onvif_imaging_service.GetOptions( VideoSourceToken=self._video_source_token ) @operation() async def get_imaging_settings(self) -> Any: """Get current imaging settings.""" - return self._imaging_service.GetImagingSettings( + return self._onvif_imaging_service.GetImagingSettings( VideoSourceToken=self._video_source_token, ) @@ -126,7 +126,7 @@ async def set_imaging_settings( self, settings: dict[str, Any], force_persistence: bool = True ) -> bool: """Set imaging settings.""" - self._imaging_service.SetImagingSettings( + self._onvif_imaging_service.SetImagingSettings( VideoSourceToken=self._video_source_token, ImagingSettings=self._convert_keys_to_camel(self._to_dict(settings)), ForcePersistence=force_persistence, @@ -138,14 +138,14 @@ async def set_imaging_settings( @operation() async def get_move_options(self) -> Any: """Get available move options.""" - return self._imaging_service.GetMoveOptions( + return self._onvif_imaging_service.GetMoveOptions( VideoSourceToken=self._video_source_token ) @operation() async def get_status(self) -> bool: """Get focus movement status.""" - self._imaging_service.GetStatus( + self._onvif_imaging_service.GetStatus( VideoSourceToken=self._video_source_token, ) return True @@ -153,7 +153,7 @@ async def get_status(self) -> bool: @operation() async def move_focus(self, move_config: dict[str, Any]) -> bool: """Move focus continuously or relatively.""" - self._imaging_service.Move( + self._onvif_imaging_service.Move( VideoSourceToken=self._video_source_token, Focus=move_config, ) @@ -162,7 +162,7 @@ async def move_focus(self, move_config: dict[str, Any]) -> bool: @operation() async def stop_focus(self) -> bool: """Stop focus movement.""" - self._imaging_service.Stop( + self._onvif_imaging_service.Stop( VideoSourceToken=self._video_source_token, ) return True diff --git a/viseron/components/onvif/media.py b/viseron/components/onvif/media.py index 460f094d7..f4fe96900 100644 --- a/viseron/components/onvif/media.py +++ b/viseron/components/onvif/media.py @@ -1,4 +1,5 @@ """Media service management for ONVIF component.""" + from __future__ import annotations import logging @@ -50,13 +51,13 @@ def __init__( self._client = client self._config = config self._auto_config = auto_config - self._media_service: Any = None + self._onvif_media_service: Any = None # ONVIF Media service instance self._selected_profile: Any = None self._profiles: list[Any] = [] async def initialize(self) -> None: """Initialize the Media service.""" - self._media_service = self._client.media() + self._onvif_media_service = self._client.media() # Load media profiles self._profiles = await self.get_profiles() @@ -94,22 +95,22 @@ async def initialize(self) -> None: @operation() async def get_profiles(self) -> Any: """Get media profiles.""" - return self._media_service.GetProfiles() + return self._onvif_media_service.GetProfiles() @operation() async def get_profile(self, profile_token: str) -> Any: """Get a specific media profile.""" - return self._media_service.GetProfile(ProfileToken=profile_token) + return self._onvif_media_service.GetProfile(ProfileToken=profile_token) @operation() async def create_profile(self, name: str, token: str | None = None) -> Any: """Create a new media profile.""" - return self._media_service.CreateProfile(Name=name, Token=token) + return self._onvif_media_service.CreateProfile(Name=name, Token=token) @operation() async def delete_profile(self, profile_token: str) -> bool: """Delete a media profile.""" - self._media_service.DeleteProfile(ProfileToken=profile_token) + self._onvif_media_service.DeleteProfile(ProfileToken=profile_token) return True # ---- URI Operations ---- # @@ -123,7 +124,7 @@ async def get_stream_uri( ) -> Any: """Get stream URI for a profile.""" stream_setup = {"Stream": stream_type, "Transport": {"Protocol": protocol}} - return self._media_service.GetStreamUri( + return self._onvif_media_service.GetStreamUri( StreamSetup=stream_setup, ProfileToken=profile_token or self._selected_profile.token, ) @@ -131,7 +132,7 @@ async def get_stream_uri( @operation() async def get_snapshot_uri(self, profile_token: str | None = None) -> Any: """Get snapshot URI for a profile.""" - return self._media_service.GetSnapshotUri( + return self._onvif_media_service.GetSnapshotUri( ProfileToken=profile_token or self._selected_profile.token ) @@ -142,7 +143,7 @@ async def get_video_encoder_configuration( self, config_token: str | None = None ) -> Any: """Get video encoder configuration.""" - return self._media_service.GetVideoEncoderConfiguration( + return self._onvif_media_service.GetVideoEncoderConfiguration( ConfigurationToken=config_token or self._selected_profile.VideoEncoderConfiguration.token ) @@ -152,7 +153,7 @@ async def get_video_encoder_configuration_options( self, config_token: str | None = None ) -> Any: """Get video encoder configuration options.""" - return self._media_service.GetVideoEncoderConfigurationOptions( + return self._onvif_media_service.GetVideoEncoderConfigurationOptions( ConfigurationToken=config_token or self._selected_profile.VideoEncoderConfiguration.token ) @@ -173,7 +174,7 @@ async def set_video_encoder_configuration( "Name" ] = self._selected_profile.VideoEncoderConfiguration.Name - self._media_service.SetVideoEncoderConfiguration( + self._onvif_media_service.SetVideoEncoderConfiguration( Configuration=configuration, ForcePersistence=force_persistence ) return True @@ -185,7 +186,7 @@ async def get_audio_encoder_configuration( self, config_token: str | None = None ) -> Any: """Get audio encoder configurations.""" - return self._media_service.GetAudioEncoderConfiguration( + return self._onvif_media_service.GetAudioEncoderConfiguration( ConfigurationToken=config_token or self._selected_profile.AudioEncoderConfiguration.token ) @@ -195,7 +196,7 @@ async def get_audio_encoder_configuration_options( self, config_token: str | None = None ) -> Any: """Get audio encoder configuration options.""" - return self._media_service.GetAudioEncoderConfigurationOptions( + return self._onvif_media_service.GetAudioEncoderConfigurationOptions( ConfigurationToken=config_token or self._selected_profile.AudioEncoderConfiguration.token ) @@ -216,7 +217,7 @@ async def set_audio_encoder_configuration( "Name" ] = self._selected_profile.AudioEncoderConfiguration.Name - self._media_service.SetAudioEncoderConfiguration( + self._onvif_media_service.SetAudioEncoderConfiguration( Configuration=configuration, ForcePersistence=force_persistence ) return True @@ -226,12 +227,12 @@ async def set_audio_encoder_configuration( @operation() async def get_osd(self, token: str) -> Any: """Get on-screen display configuration.""" - return self._media_service.GetOSD(OSDToken=token) + return self._onvif_media_service.GetOSD(OSDToken=token) @operation() async def get_osds(self, config_token: str | None = None) -> Any: """Get all on-screen display configurations.""" - return self._media_service.GetOSDs( + return self._onvif_media_service.GetOSDs( ConfigurationToken=config_token or self._selected_profile.VideoSourceConfiguration.token ) @@ -239,7 +240,7 @@ async def get_osds(self, config_token: str | None = None) -> Any: @operation() async def get_osd_options(self, config_token: str | None = None) -> Any: """Get on-screen display configuration options.""" - return self._media_service.GetOSDOptions( + return self._onvif_media_service.GetOSDOptions( ConfigurationToken=config_token or self._selected_profile.VideoSourceConfiguration.token ) @@ -253,13 +254,13 @@ async def create_osd(self, osd_config: dict[str, Any]) -> bool: "VideoSourceConfigurationToken" ] = self._selected_profile.VideoSourceConfiguration.token - self._media_service.CreateOSD(OSD=osd_config) + self._onvif_media_service.CreateOSD(OSD=osd_config) return True @operation() async def delete_osd(self, token: str) -> bool: """Delete on-screen display configuration.""" - self._media_service.DeleteOSD(OSDToken=token) + self._onvif_media_service.DeleteOSD(OSDToken=token) return True @operation() @@ -271,7 +272,7 @@ async def set_osd(self, osd_config: dict[str, Any]) -> bool: "VideoSourceConfigurationToken" ] = self._selected_profile.VideoSourceConfiguration.token - self._media_service.SetOSD(OSD=osd_config) + self._onvif_media_service.SetOSD(OSD=osd_config) return True # ## Profile Accessors ## # diff --git a/viseron/components/onvif/ptz.py b/viseron/components/onvif/ptz.py index b64dacf8a..d64903d25 100644 --- a/viseron/components/onvif/ptz.py +++ b/viseron/components/onvif/ptz.py @@ -1,4 +1,5 @@ """PTZ service management for ONVIF component.""" + from __future__ import annotations import asyncio @@ -50,14 +51,14 @@ def __init__( self._auto_config = auto_config self._media_service = media_service # you can't use ptz without media service self._media_profile: Any = None # selected media profile for any PTZ operations - self._ptz_service: Any = None + self._onvif_ptz_service: Any = None # ONVIF PTZ service instance self._ptz_config: Any = None # to determine PTZ behaviour self._ptz_config_options: Any = None # to determine PTZ options self._stop_patrol_event: asyncio.Event = asyncio.Event() async def initialize(self) -> None: """Initialize the PTZ service.""" - self._ptz_service = self._client.ptz() + self._onvif_ptz_service = self._client.ptz() self._media_profile = self._media_service.get_selected_profile() @@ -152,7 +153,7 @@ async def continuous_move( # Change seconds in ISO 8601 timeout = f"PT{seconds}S" - self._ptz_service.ContinuousMove( + self._onvif_ptz_service.ContinuousMove( ProfileToken=self._media_profile.token, Velocity=velocity, Timeout=timeout if seconds > 0 else None, @@ -198,7 +199,7 @@ async def relative_move( elif default_speed.Zoom is not None: speed["Zoom"] = {"x": default_speed.Zoom.x} - self._ptz_service.RelativeMove( + self._onvif_ptz_service.RelativeMove( ProfileToken=self._media_profile.token, Translation=translation, Speed=speed, @@ -249,7 +250,7 @@ async def absolute_move( elif default_speed.Zoom is not None: speed["Zoom"] = {"x": default_speed.Zoom.x} - self._ptz_service.AbsoluteMove( + self._onvif_ptz_service.AbsoluteMove( ProfileToken=self._media_profile.token, Position=position, Speed=speed, @@ -259,7 +260,7 @@ async def absolute_move( @operation() async def stop(self) -> bool: """Stop any ongoing PTZ movement.""" - self._ptz_service.Stop(ProfileToken=self._media_profile.token) + self._onvif_ptz_service.Stop(ProfileToken=self._media_profile.token) return True # ---- Position Operations ---- # @@ -267,29 +268,31 @@ async def stop(self) -> bool: @operation() async def go_home_position(self) -> bool: """Move the camera to its home position.""" - self._ptz_service.GotoHomePosition(ProfileToken=self._media_profile.token) + self._onvif_ptz_service.GotoHomePosition(ProfileToken=self._media_profile.token) return True @operation() async def set_home_position(self) -> bool: """Set the current position as the home position.""" - self._ptz_service.SetHomePosition(ProfileToken=self._media_profile.token) + self._onvif_ptz_service.SetHomePosition(ProfileToken=self._media_profile.token) return True @operation() async def get_status(self) -> Any: """Get the PTZ status of the camera.""" - return self._ptz_service.GetStatus(ProfileToken=self._media_profile.token) + return self._onvif_ptz_service.GetStatus(ProfileToken=self._media_profile.token) @operation() async def get_presets(self) -> Any: """Get the PTZ presets of the camera.""" - return self._ptz_service.GetPresets(ProfileToken=self._media_profile.token) + return self._onvif_ptz_service.GetPresets( + ProfileToken=self._media_profile.token + ) @operation() async def goto_preset(self, preset_token: str) -> bool: """Move the camera to the specified preset.""" - self._ptz_service.GotoPreset( + self._onvif_ptz_service.GotoPreset( ProfileToken=self._media_profile.token, PresetToken=preset_token, ) @@ -298,7 +301,7 @@ async def goto_preset(self, preset_token: str) -> bool: @operation() async def set_preset(self, preset_name: str) -> bool: """Set a preset at the current position with the given name.""" - return self._ptz_service.SetPreset( + return self._onvif_ptz_service.SetPreset( ProfileToken=self._media_profile.token, PresetName=preset_name, ) @@ -306,7 +309,7 @@ async def set_preset(self, preset_name: str) -> bool: @operation() async def remove_preset(self, preset_token: str) -> bool: """Remove the specified preset.""" - self._ptz_service.RemovePreset( + self._onvif_ptz_service.RemovePreset( ProfileToken=self._media_profile.token, PresetToken=preset_token, ) @@ -317,17 +320,17 @@ async def remove_preset(self, preset_token: str) -> bool: @operation() async def get_nodes(self) -> Any: """Get the PTZ nodes of the camera.""" - return self._ptz_service.GetNodes() + return self._onvif_ptz_service.GetNodes() @operation() async def get_configurations(self) -> Any: """Get the PTZ configurations of the camera.""" - return self._ptz_service.GetConfigurations() + return self._onvif_ptz_service.GetConfigurations() @operation() async def get_configuration_options(self) -> Any: """Get the PTZ configuration options of the camera.""" - return self._ptz_service.GetConfigurationOptions( + return self._onvif_ptz_service.GetConfigurationOptions( ConfigurationToken=self._media_profile.PTZConfiguration.token ) @@ -614,7 +617,7 @@ async def full_swing( min_tilt: Minimum tilt value (for validation) max_tilt: Maximum tilt value (for validation) """ - if not self._ptz_service: + if not self._onvif_ptz_service: LOGGER.error( f"PTZ service not initialized for camera {self._camera.identifier}" ) From ec0a8f41c304bf83d2cc883887e284b55a25f4e5 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Wed, 14 Jan 2026 03:18:33 +0700 Subject: [PATCH 035/120] refactor(ui/ptz): Change PTZ Controls to draggable cards --- .../actions/ptz/OnvifPtzController.tsx | 772 ++++++++++-------- .../components/actions/ptz/useDraggable.ts | 168 ++++ 2 files changed, 611 insertions(+), 329 deletions(-) create mode 100644 frontend/src/components/actions/ptz/useDraggable.ts diff --git a/frontend/src/components/actions/ptz/OnvifPtzController.tsx b/frontend/src/components/actions/ptz/OnvifPtzController.tsx index d4e1ce29f..a3c92ea76 100644 --- a/frontend/src/components/actions/ptz/OnvifPtzController.tsx +++ b/frontend/src/components/actions/ptz/OnvifPtzController.tsx @@ -3,13 +3,17 @@ import { ArrowLeft, ArrowRight, ArrowUp, + ChevronDown, + ChevronUp, Close, DataCollection, + Draggable, + Error, Flag, Home, ImageSearchAlt, Move, - StopFilledAlt, + Settings, TrashCan, ZAxis, ZoomIn, @@ -18,13 +22,13 @@ import { import { Box, Button, + Card, CircularProgress, + Collapse, Dialog, DialogActions, DialogContent, DialogTitle, - Drawer, - FormControl, FormControlLabel, IconButton, List, @@ -32,6 +36,7 @@ import { ListItemButton, ListItemIcon, ListItemText, + Portal, Slider, Switch, TextField, @@ -39,8 +44,8 @@ import { Typography, } from "@mui/material"; import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import { useEffect, useState } from "react"; +import { alpha, useTheme } from "@mui/material/styles"; +import { useCallback, useEffect, useState } from "react"; import { CustomFab } from "components/player/CustomControls"; import { @@ -49,6 +54,7 @@ import { useGetPtzPresets, } from "lib/api/actions/onvif/ptz"; +import { useDraggable } from "./useDraggable"; import { useOnvifPtzHandlers } from "./useOnvifPtzHandlers"; interface OnvifPtzControllerProps { @@ -59,7 +65,7 @@ export function OnvifPtzController({ cameraIdentifier, }: OnvifPtzControllerProps) { const theme = useTheme(); - const [drawerOpen, setDrawerOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); const [presetsDialogOpen, setPresetsDialogOpen] = useState(false); const [savePresetDialogOpen, setSavePresetDialogOpen] = useState(false); const [setHomeDialogOpen, setSetHomeDialogOpen] = useState(false); @@ -68,6 +74,20 @@ export function OnvifPtzController({ const [selectedPresetToken, setSelectedPresetToken] = useState(""); const [selectedPresetName, setSelectedPresetName] = useState(""); const [moveSpeed, setMoveSpeed] = useState(0.5); + const [showAdvanced, setShowAdvanced] = useState(false); + + const PTZ_CONTROL_SIZE = 80; + + const { + position, + isDragging, + isPositionReady, + dragRef, + handleMouseDown, + handleTouchStart, + initializePosition, + resetPosition, + } = useDraggable(); const { data: nodesData } = useGetPtzNodes(cameraIdentifier); @@ -143,6 +163,15 @@ export function OnvifPtzController({ configData?.user_config?.reverse_tilt, ]); + // Initialize position when card opens + useEffect(() => { + if (isOpen) { + requestAnimationFrame(() => { + initializePosition(); + }); + } + }, [isOpen, initializePosition]); + // Combine user-defined presets with ONVIF presets if auto-config is disabled const userPresets = supportsAbsoluteMove ? (configData?.user_config?.presets || []).map((preset) => ({ @@ -189,350 +218,435 @@ export function OnvifPtzController({ configData, }); + const handleClose = useCallback(() => { + setIsOpen(false); + handleStop(); + resetPosition(); + }, [handleStop, resetPosition]); + return ( <> {/* PTZ FAB Button */} - setDrawerOpen(true)} title="PTZ Controls"> + { + if (isOpen) { + handleClose(); + } else { + setIsOpen(true); + } + }} + title="PTZ Controls" + > - {/* PTZ Controls Drawer */} - { - setDrawerOpen(false); - handleStop(); // Stop all movement when closing drawer - }} - slotProps={{ - paper: { - sx: { - width: { xs: 310, md: 300 }, - p: 2, - overflowX: "hidden", - overflowY: "auto", + {/* Draggable PTZ Card - Rendered via Portal to escape player container */} + {isOpen && ( + + - - - - PTZ Controls - - { - setDrawerOpen(false); - handleStop(); - }} - > - - - - - {/* Directional Controls */} - - {/* Top Row */} - - - handleMoveStart(0, 1)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(0, 1)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - > - - - - - handleMoveStart(0, 0, 0.1)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(0, 0, 0.1)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - disabled={!supportsZoom} - > - - - - - {/* Middle Row */} - - handleMoveStart(-1, 0)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(-1, 0)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - > - - - - - - - - - - handleMoveStart(1, 0)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(1, 0)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - > - - - - - {/* Bottom Row */} - - - handleMoveStart(0, -1)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(0, -1)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - > - - - - - handleMoveStart(0, 0, -0.1)} - onMouseUp={handleStop} - onTouchStart={() => handleMoveStart(0, 0, -0.1)} - onTouchEnd={handleStop} - sx={{ bgcolor: "action.hover" }} - disabled={!supportsZoom} - > - - - - - - {/* Speed Control Slider */} - - - Speed: {Math.round(moveSpeed * 100)}% - - setMoveSpeed(value as number)} - min={speedMinMax.panTiltMin || 0.0} - max={speedMinMax.panTiltMax || 1.0} - step={0.05} - size="small" - valueLabelDisplay="auto" - valueLabelFormat={(value) => `${Math.round(value * 100)}%`} - /> - - - {/* Reverse Controls */} - - setReversePan(e.target.checked)} - size="small" - /> - } - label="Reverse Pan" - slotProps={{ typography: { variant: "body2" } }} - /> - setReverseTilt(e.target.checked)} - size="small" - /> - } - label="Reverse Tilt" - slotProps={{ typography: { variant: "body2" } }} - /> - - - {/* Action Buttons */} - - {supportsHome && ( - - - - )} - {supportsPresets && ( - <> - - - - - - - - )} - {supportsHome && ( - - + + )} + {supportsPresets && ( + + + + )} + {supportsPresets && ( + + + + )} + {supportsHome && ( + + + + )} + + + {/* Advanced Section */} + setShowAdvanced(!showAdvanced)} sx={{ - textTransform: "none", - justifyContent: "flex-start", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + py: 0.5, + mt: 1, + borderRadius: 1, + "&:hover": { + bgcolor: "action.hover", + }, }} > - SET HOME - - - )} - - + + + Advanced + + {showAdvanced ? ( + + ) : ( + + )} + + + + setReversePan(e.target.checked)} + size="small" + /> + } + label={Reverse Pan} + /> + setReverseTilt(e.target.checked)} + size="small" + /> + } + label={ + Reverse Tilt + } + sx={{ mr: 0 }} + /> + + + + + + )} {/* Presets Dialog */} ( + initialPosition || { x: 0, y: 0 }, + ); + const [isDragging, setIsDragging] = useState(false); + const [isPositionReady, setIsPositionReady] = useState(!!initialPosition); + + const dragRef = useRef(null); + const dragStartRef = useRef({ x: 0, y: 0 }); + const positionRef = useRef(position); + + // Keep positionRef in sync + useEffect(() => { + positionRef.current = position; + }, [position]); + + // Calculate center position + const getCenterPosition = useCallback(() => { + if (!dragRef.current) return { x: 0, y: 0 }; + + const rect = dragRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + return { + x: (viewportWidth - rect.width) / 2, + y: (viewportHeight - rect.height) / 2, + }; + }, []); + + // Initialize position to center when element mounts + const initializePosition = useCallback(() => { + if (!dragRef.current) return; + + const newPosition = getCenterPosition(); + setPosition(newPosition); + positionRef.current = newPosition; + setIsPositionReady(true); + }, [getCenterPosition]); + + // Reset position to center + const resetPosition = useCallback(() => { + setIsPositionReady(false); + }, []); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + // Only start drag if clicking on the header area + if ((e.target as HTMLElement).closest("[data-drag-handle]")) { + e.preventDefault(); + setIsDragging(true); + dragStartRef.current = { + x: e.clientX - positionRef.current.x, + y: e.clientY - positionRef.current.y, + }; + } + }, []); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if ((e.target as HTMLElement).closest("[data-drag-handle]")) { + const touch = e.touches[0]; + setIsDragging(true); + dragStartRef.current = { + x: touch.clientX - positionRef.current.x, + y: touch.clientY - positionRef.current.y, + }; + } + }, []); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging || !dragRef.current) return; + + const rect = dragRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let newX = e.clientX - dragStartRef.current.x; + let newY = e.clientY - dragStartRef.current.y; + + // Constrain to viewport + newX = Math.max( + boundaryPadding, + Math.min(newX, viewportWidth - rect.width - boundaryPadding), + ); + newY = Math.max( + boundaryPadding, + Math.min(newY, viewportHeight - rect.height - boundaryPadding), + ); + + const newPosition = { x: newX, y: newY }; + setPosition(newPosition); + positionRef.current = newPosition; + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!isDragging || !dragRef.current) return; + + const touch = e.touches[0]; + const rect = dragRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let newX = touch.clientX - dragStartRef.current.x; + let newY = touch.clientY - dragStartRef.current.y; + + // Constrain to viewport + newX = Math.max( + boundaryPadding, + Math.min(newX, viewportWidth - rect.width - boundaryPadding), + ); + newY = Math.max( + boundaryPadding, + Math.min(newY, viewportHeight - rect.height - boundaryPadding), + ); + + const newPosition = { x: newX, y: newY }; + setPosition(newPosition); + positionRef.current = newPosition; + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleTouchEnd = () => { + setIsDragging(false); + }; + + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("touchmove", handleTouchMove); + document.addEventListener("touchend", handleTouchEnd); + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + }; + }, [isDragging, boundaryPadding]); + + return { + position, + isDragging, + isPositionReady, + dragRef, + handleMouseDown, + handleTouchStart, + initializePosition, + resetPosition, + }; +} From f0d4d5e4b358d92b06eaa5a6d889fff0ac0f22e4 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Wed, 14 Jan 2026 03:22:20 +0700 Subject: [PATCH 036/120] style(ui/tuning): Change section items in the box with gap --- .../tuning/config/LabelsSection.tsx | 95 ++++++++++--------- .../components/tuning/config/MasksSection.tsx | 40 ++++---- .../tuning/config/MiscellaneousSection.tsx | 4 +- .../tuning/config/OSDTextsSection.tsx | 87 +++++++++-------- .../tuning/config/VideoTransformsSection.tsx | 79 +++++++-------- .../components/tuning/config/ZonesSection.tsx | 32 ++++--- 6 files changed, 173 insertions(+), 164 deletions(-) diff --git a/frontend/src/components/tuning/config/LabelsSection.tsx b/frontend/src/components/tuning/config/LabelsSection.tsx index 978e8c9f1..0833046c7 100644 --- a/frontend/src/components/tuning/config/LabelsSection.tsx +++ b/frontend/src/components/tuning/config/LabelsSection.tsx @@ -51,61 +51,62 @@ export function LabelsSection({ {labels && Array.isArray(labels) && labels.length > 0 ? ( - labels.map((labelItem: Label | string, index: number) => { - // Check if labelItem is a string (face_recognition) or Label object (object_detector) - const isStringLabel = typeof labelItem === "string"; - const labelText = isStringLabel ? labelItem : labelItem.label; - const confidence = isStringLabel ? undefined : labelItem.confidence; + + {labels.map((labelItem: Label | string, index: number) => { + // Check if labelItem is a string (face_recognition) or Label object (object_detector) + const isStringLabel = typeof labelItem === "string"; + const labelText = isStringLabel ? labelItem : labelItem.label; + const confidence = isStringLabel ? undefined : labelItem.confidence; - // Select icon based on componentType - const IconComponent = - componentType === "face_recognition" - ? User - : componentType === "license_plate_recognition" - ? LabelIcon - : TextCreation; + // Select icon based on componentType + const IconComponent = + componentType === "face_recognition" + ? User + : componentType === "license_plate_recognition" + ? LabelIcon + : TextCreation; - return ( - - ); - }) + {confidence !== undefined && ( + + {Math.round((confidence ?? 0.8) * 100)}% + + )} + + ); + })} + ) : ( + {masks && Array.isArray(masks) && masks.length > 0 ? ( - masks.map((mask: Mask, index: number) => ( - - )) + + {masks.map((mask: Mask, index: number) => ( + + ))} + ) : ( + - Miscellaneous + Configurations {fields.map((field) => renderField(field))} diff --git a/frontend/src/components/tuning/config/OSDTextsSection.tsx b/frontend/src/components/tuning/config/OSDTextsSection.tsx index 936a11c37..0468932fc 100644 --- a/frontend/src/components/tuning/config/OSDTextsSection.tsx +++ b/frontend/src/components/tuning/config/OSDTextsSection.tsx @@ -49,51 +49,54 @@ export function OSDTextsSection({ {osdTexts && Array.isArray(osdTexts) && osdTexts.length > 0 ? ( - osdTexts.map((osdText: OSDText, index: number) => ( - - )) + + {osdText.textType === "timestamp" + ? "Timestamp" + : osdText.customText || "Custom Text"} + + + {osdText.position} • {osdText.fontSize}px + + + ))} + ) : ( + ) : ( - videoTransforms.map((transform, index) => ( - - )) + + {getTransformLabel(transform.transform)} + + + {transform.type === "camera" ? ( + + ) : ( + + + ))} + )} ); diff --git a/frontend/src/components/tuning/config/ZonesSection.tsx b/frontend/src/components/tuning/config/ZonesSection.tsx index 919366eff..b6797135a 100644 --- a/frontend/src/components/tuning/config/ZonesSection.tsx +++ b/frontend/src/components/tuning/config/ZonesSection.tsx @@ -46,21 +46,23 @@ export function ZonesSection({ {zones && Array.isArray(zones) && zones.length > 0 ? ( - zones.map((zone: Zone, index: number) => ( - - )) + + {zones.map((zone: Zone, index: number) => ( + + ))} + ) : ( Date: Wed, 14 Jan 2026 17:25:02 +0700 Subject: [PATCH 037/120] fix(ui/ptz): Prevent event propagation for mouse and touch interactions --- .../src/components/actions/ptz/OnvifPtzController.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/actions/ptz/OnvifPtzController.tsx b/frontend/src/components/actions/ptz/OnvifPtzController.tsx index a3c92ea76..a7a150048 100644 --- a/frontend/src/components/actions/ptz/OnvifPtzController.tsx +++ b/frontend/src/components/actions/ptz/OnvifPtzController.tsx @@ -246,8 +246,15 @@ export function OnvifPtzController({ { + e.stopPropagation(); + handleMouseDown(e); + }} + onTouchStart={(e) => { + e.stopPropagation(); + handleTouchStart(e); + }} + onWheel={(e) => e.stopPropagation()} sx={{ position: "fixed", left: position.x, From 7c3c6699aefeef68b7776dcd7fc1d2ecb0e6e2ca Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Wed, 14 Jan 2026 18:19:42 +0700 Subject: [PATCH 038/120] refactor(ptz): Rename go_home_position to goto_home_position for consistency --- viseron/components/onvif/ptz.py | 4 ++-- viseron/components/telegram/ptz_control.py | 2 +- viseron/components/webserver/api/v1/actions/onvif/ptz.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/viseron/components/onvif/ptz.py b/viseron/components/onvif/ptz.py index d64903d25..6ff1eba67 100644 --- a/viseron/components/onvif/ptz.py +++ b/viseron/components/onvif/ptz.py @@ -266,7 +266,7 @@ async def stop(self) -> bool: # ---- Position Operations ---- # @operation() - async def go_home_position(self) -> bool: + async def goto_home_position(self) -> bool: """Move the camera to its home position.""" self._onvif_ptz_service.GotoHomePosition(ProfileToken=self._media_profile.token) return True @@ -770,7 +770,7 @@ async def apply_config(self) -> bool: # Move to home position if configured if home_position and not has_on_startup: - await self.go_home_position() + await self.goto_home_position() LOGGER.debug( f"PTZ Go Home Position executed for {self._camera.identifier}" ) diff --git a/viseron/components/telegram/ptz_control.py b/viseron/components/telegram/ptz_control.py index 50e8fd110..f0df7b77a 100644 --- a/viseron/components/telegram/ptz_control.py +++ b/viseron/components/telegram/ptz_control.py @@ -118,7 +118,7 @@ async def _home(self, update: Update, context: CallbackContext) -> None: """Move the camera to its home position.""" status = ( - await self._ptz_service.go_home_position() if self._ptz_service else False + await self._ptz_service.goto_home_position() if self._ptz_service else False ) await self._inform(update, "home", status) diff --git a/viseron/components/webserver/api/v1/actions/onvif/ptz.py b/viseron/components/webserver/api/v1/actions/onvif/ptz.py index 1ea2b1cac..ebb927ba7 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/ptz.py +++ b/viseron/components/webserver/api/v1/actions/onvif/ptz.py @@ -183,9 +183,9 @@ async def post_onvif_ptz(self, ptz_service, camera_identifier: str, action: str) await self.validate_action_status(stop, action, camera_identifier) return - if action == "home": - home = await ptz_service.go_home_position() - await self.validate_action_status(home, action, camera_identifier) + if action == "goto_home": + goto_home = await ptz_service.goto_home_position() + await self.validate_action_status(goto_home, action, camera_identifier) return if action == "goto_preset": From 1ff5d76dcc13b29525d8ee8c29231fd967db4f16 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Wed, 14 Jan 2026 18:24:47 +0700 Subject: [PATCH 039/120] refactor(ptz): Rename go_home to goto_home for consistency and update related references --- .../actions/ptz/OnvifPtzController.tsx | 6 +- .../actions/ptz/useOnvifPtzHandlers.ts | 10 +-- frontend/src/lib/api/actions/onvif/ptz.ts | 62 ++++++++++++------- 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/actions/ptz/OnvifPtzController.tsx b/frontend/src/components/actions/ptz/OnvifPtzController.tsx index a7a150048..1127a9710 100644 --- a/frontend/src/components/actions/ptz/OnvifPtzController.tsx +++ b/frontend/src/components/actions/ptz/OnvifPtzController.tsx @@ -195,7 +195,7 @@ export function OnvifPtzController({ const { handleMoveStart, handleStop, - handleGoHome, + handleGotoHome, handleSetHome, handleGotoPreset, handleSavePreset, @@ -511,7 +511,7 @@ export function OnvifPtzController({ > {supportsHome && ( diff --git a/frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts b/frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts index f3eb7f35f..2602d7e1c 100644 --- a/frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts +++ b/frontend/src/components/actions/ptz/useOnvifPtzHandlers.ts @@ -4,7 +4,7 @@ import { useToast } from "hooks/UseToast"; import { usePtzAbsoluteMove, usePtzContinuousMove, - usePtzGoHome, + usePtzGotoHome, usePtzGotoPreset, usePtzRemovePreset, usePtzSetHome, @@ -73,7 +73,7 @@ export function useOnvifPtzHandlers({ const continuousMoveMutation = usePtzContinuousMove(); const absoluteMoveMutation = usePtzAbsoluteMove(); const stopMutation = usePtzStop(); - const goHomeMutation = usePtzGoHome(); + const gotoHomeMutation = usePtzGotoHome(); const gotoPresetMutation = usePtzGotoPreset(); const setHomeMutation = usePtzSetHome(); const setPresetMutation = usePtzSetPreset(); @@ -188,8 +188,8 @@ export function useOnvifPtzHandlers({ stopContinuousMove(); }; - const handleGoHome = () => { - goHomeMutation.mutate( + const handleGotoHome = () => { + gotoHomeMutation.mutate( { cameraIdentifier }, { onError: (error) => { @@ -297,7 +297,7 @@ export function useOnvifPtzHandlers({ return { handleMoveStart, handleStop, - handleGoHome, + handleGotoHome, handleSetHome, handleGotoPreset, handleSavePreset, diff --git a/frontend/src/lib/api/actions/onvif/ptz.ts b/frontend/src/lib/api/actions/onvif/ptz.ts index 8494909e6..5603339f2 100644 --- a/frontend/src/lib/api/actions/onvif/ptz.ts +++ b/frontend/src/lib/api/actions/onvif/ptz.ts @@ -7,16 +7,17 @@ import * as types from "lib/types"; const ONVIF_PTZ_BASE_PATH = "actions/onvif/ptz"; // Get User-Defined PTZ Config +const USER_CONFIG = "user_config"; async function getPtzConfig(cameraIdentifier: string) { const response = await viseronAPI.get( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/user_config`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${USER_CONFIG}`, ); return response.data; } export function useGetPtzConfig(cameraIdentifier: string) { return useQuery({ - queryKey: ["ptz", "user_config", cameraIdentifier], + queryKey: ["ptz", USER_CONFIG, cameraIdentifier], queryFn: () => getPtzConfig(cameraIdentifier), enabled: !!cameraIdentifier, retry: false, // Don't retry on error - camera either supports PTZ or doesn't @@ -25,25 +26,27 @@ export function useGetPtzConfig(cameraIdentifier: string) { } // Get PTZ Nodes +const NODES = "nodes"; async function getPtzNodes(cameraIdentifier: string) { const response = await viseronAPI.get( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/nodes`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${NODES}`, ); return response.data; } export function useGetPtzNodes(cameraIdentifier: string) { return useQuery({ - queryKey: ["ptz", "nodes", cameraIdentifier], + queryKey: ["ptz", NODES, cameraIdentifier], queryFn: () => getPtzNodes(cameraIdentifier), enabled: !!cameraIdentifier, }); } // Get PTZ Configurations +const CONFIGURATIONS = "configurations"; async function getPtzConfigurations(cameraIdentifier: string) { const response = await viseronAPI.get( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/configurations`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${CONFIGURATIONS}`, ); return response.data; } @@ -53,23 +56,24 @@ export function useGetPtzConfigurations(cameraIdentifier: string) { onvif_types.PtzConfigurationsResponse, types.APIErrorResponse >({ - queryKey: ["ptz", "configurations", cameraIdentifier], + queryKey: ["ptz", CONFIGURATIONS, cameraIdentifier], queryFn: () => getPtzConfigurations(cameraIdentifier), enabled: !!cameraIdentifier, }); } // Get PTZ Status +const STATUS = "status"; async function getPtzStatus(cameraIdentifier: string) { const response = await viseronAPI.get( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/status`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${STATUS}`, ); return response.data; } export function useGetPtzStatus(cameraIdentifier: string) { return useQuery({ - queryKey: ["ptz", "status", cameraIdentifier], + queryKey: ["ptz", STATUS, cameraIdentifier], queryFn: () => getPtzStatus(cameraIdentifier), enabled: !!cameraIdentifier, staleTime: 1000 * 5, // 5 seconds @@ -77,9 +81,10 @@ export function useGetPtzStatus(cameraIdentifier: string) { } // Get PTZ Presets +const PRESETS = "presets"; async function getPtzPresets(cameraIdentifier: string) { const response = await viseronAPI.get( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/presets`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${PRESETS}`, ); return response.data; } @@ -89,7 +94,7 @@ export function useGetPtzPresets( ptzSupport?: "onvif" | null, ) { return useQuery({ - queryKey: ["ptz", "presets", cameraIdentifier], + queryKey: ["ptz", PRESETS, cameraIdentifier], queryFn: () => getPtzPresets(cameraIdentifier), enabled: !!cameraIdentifier && ptzSupport === "onvif", retry: false, // Don't retry on error - camera either supports PTZ or doesn't @@ -98,12 +103,13 @@ export function useGetPtzPresets( } // PTZ Continuous Move +const CONTINUOUS_MOVE = "continuous_move"; async function ptzContinuousMove( cameraIdentifier: string, params: onvif_types.PtzContinuousMoveParams, ) { const response = await viseronAPI.post( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/continuous_move`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${CONTINUOUS_MOVE}`, { continuous: params }, ); return response.data; @@ -121,12 +127,13 @@ export function usePtzContinuousMove() { } // PTZ Relative Move +const RELATIVE_MOVE = "relative_move"; async function ptzRelativeMove( cameraIdentifier: string, params: onvif_types.PtzRelativeMoveParams, ) { const response = await viseronAPI.post( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/relative_move`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${RELATIVE_MOVE}`, { relative: params }, ); return response.data; @@ -143,12 +150,14 @@ export function usePtzRelativeMove() { }); } +// PTZ Absolute Move +const ABSOLUTE_MOVE = "absolute_move"; async function ptzAbsoluteMove( cameraIdentifier: string, params: onvif_types.PtzAbsoluteMoveParams, ) { const response = await viseronAPI.post( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/absolute_move`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${ABSOLUTE_MOVE}`, { absolute: params }, ); return response.data; @@ -166,9 +175,10 @@ export function usePtzAbsoluteMove() { } // PTZ Stop +const STOP = "stop"; async function ptzStop(cameraIdentifier: string) { const response = await viseronAPI.post( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/stop`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${STOP}`, {}, ); return response.data; @@ -184,29 +194,31 @@ export function usePtzStop() { }); } -// PTZ Go Home -async function ptzGoHome(cameraIdentifier: string) { +// PTZ Goto Home +const GOTO_HOME = "goto_home"; +async function ptzGotoHome(cameraIdentifier: string) { const response = await viseronAPI.post( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/home`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${GOTO_HOME}`, {}, ); return response.data; } -export function usePtzGoHome() { +export function usePtzGotoHome() { return useMutation< types.APISuccessResponse, types.APIErrorResponse, { cameraIdentifier: string } >({ - mutationFn: ({ cameraIdentifier }) => ptzGoHome(cameraIdentifier), + mutationFn: ({ cameraIdentifier }) => ptzGotoHome(cameraIdentifier), }); } // PTZ Set Home Position +const SET_HOME = "set_home"; async function ptzSetHome(cameraIdentifier: string) { const response = await viseronAPI.put( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/set_home`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${SET_HOME}`, {}, ); return response.data; @@ -223,9 +235,10 @@ export function usePtzSetHome() { } // PTZ Goto Preset +const GOTO_PRESET = "goto_preset"; async function ptzGotoPreset(cameraIdentifier: string, presetToken: string) { const response = await viseronAPI.post( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/goto_preset`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${GOTO_PRESET}`, { preset_token: presetToken }, ); return response.data; @@ -243,9 +256,10 @@ export function usePtzGotoPreset() { } // PTZ Set Preset +const SET_PRESET = "set_preset"; async function ptzSetPreset(cameraIdentifier: string, presetName: string) { const response = await viseronAPI.put( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/set_preset`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${SET_PRESET}`, { preset_name: presetName }, ); return response.data; @@ -263,9 +277,11 @@ export function usePtzSetPreset() { } // PTZ Remove Preset +const REMOVE_PRESET = "remove_preset"; +const PRESET_TOKEN = "preset_token"; async function ptzRemovePreset(cameraIdentifier: string, presetToken: string) { const response = await viseronAPI.delete( - `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/remove_preset?preset_token=${presetToken}`, + `${ONVIF_PTZ_BASE_PATH}/${cameraIdentifier}/${REMOVE_PRESET}?${PRESET_TOKEN}=${presetToken}`, ); return response.data; } From 733242842ebed21a37cf3d24160a66f0008a58aa Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Wed, 14 Jan 2026 22:07:37 +0700 Subject: [PATCH 040/120] fix(api/actions): Fix typo in ONVIF Device Actions API --- viseron/components/webserver/api/v1/actions/onvif/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viseron/components/webserver/api/v1/actions/onvif/device.py b/viseron/components/webserver/api/v1/actions/onvif/device.py index 6e5cd7386..21a2b6dd9 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/device.py +++ b/viseron/components/webserver/api/v1/actions/onvif/device.py @@ -136,7 +136,7 @@ async def get_onvif_device( ) return - if action == "network_interface": + if action == "network_interfaces": await self.validate_action_response( await device_service.get_network_interfaces(), action, camera_identifier ) From f1fa2692a7af5e1a700c2d27637ab8422563a4e9 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Thu, 15 Jan 2026 02:48:59 +0700 Subject: [PATCH 041/120] feat(onvif): Implementing Camera Tuning API for ONVIF component --- viseron/components/onvif/const.py | 3 + viseron/components/webserver/api/v1/tune.py | 24 +- .../webserver/api/v1/tuning/__init__.py | 2 + .../webserver/api/v1/tuning/base.py | 29 ++- .../webserver/api/v1/tuning/onvif.py | 217 ++++++++++++++++++ 5 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 viseron/components/webserver/api/v1/tuning/onvif.py diff --git a/viseron/components/onvif/const.py b/viseron/components/onvif/const.py index 31a95e68c..9346c0b22 100644 --- a/viseron/components/onvif/const.py +++ b/viseron/components/onvif/const.py @@ -3,6 +3,9 @@ COMPONENT = "onvif" DESC_COMPONENT = "ONVIF cameras integration." +# Special config keys, not part of ONVIF spec, used for grouping in tuning API +CONFIG_CLIENT = "client" + # ONVIF CONFIG CONFIG_CAMERAS = "cameras" CONFIG_HOST = "host" diff --git a/viseron/components/webserver/api/v1/tune.py b/viseron/components/webserver/api/v1/tune.py index d88c938b1..332a37585 100644 --- a/viseron/components/webserver/api/v1/tune.py +++ b/viseron/components/webserver/api/v1/tune.py @@ -32,8 +32,10 @@ LicensePlateRecognitionTuningHandler, MotionDetectorTuningHandler, ObjectDetectorTuningHandler, + OnvifTuningHandler, ) from .tuning.labels import get_available_labels +from .tuning.onvif import process_onvif_config LOGGER = logging.getLogger(__name__) @@ -48,7 +50,7 @@ ] # special case because the "Protocol" domain does not exist. -PROTOCOL_RELATED = "protocol" +PROTOCOL_RELATED = "protocol" # not used for now, will be used later if needed PROTOCOL_COMPONENTS = [ONVIF_COMPONENT] @@ -151,7 +153,16 @@ def _process_direct_cameras( if self._should_skip_camera(cam_id, camera_identifier): continue self._ensure_camera_in_settings(tune_settings, cam_id) - tune_settings[cam_id][component_name] = cam_config + + if component_name in PROTOCOL_COMPONENTS: + # Special handling for ONVIF: group base keys under "client" + if component_name == ONVIF_COMPONENT and isinstance(cam_config, dict): + processed_config = process_onvif_config( + self._vis, cam_config, cam_id + ) + tune_settings[cam_id][component_name] = processed_config + else: + tune_settings[cam_id][component_name] = cam_config def _process_camera_domain( self, @@ -351,13 +362,15 @@ async def update_camera_tune(self, camera_identifier: str) -> None: MOTION_DETECTOR_DOMAIN, FACE_RECOGNITION_DOMAIN, LICENSE_PLATE_RECOGNITION_DOMAIN, + ONVIF_COMPONENT, # ONVIF treated as special case ]: self.response_error( status_code=HTTPStatus.BAD_REQUEST, reason=f"Domain '{domain}' update not supported. " f"Only '{CAMERA_DOMAIN}', '{OBJECT_DETECTOR_DOMAIN}', " - f"'{MOTION_DETECTOR_DOMAIN}', '{FACE_RECOGNITION_DOMAIN}', and " - f"'{LICENSE_PLATE_RECOGNITION_DOMAIN}' are supported.", + f"'{MOTION_DETECTOR_DOMAIN}', '{FACE_RECOGNITION_DOMAIN}', " + f"'{LICENSE_PLATE_RECOGNITION_DOMAIN}', and '{ONVIF_COMPONENT}' " + f"are supported.", ) return @@ -371,6 +384,7 @@ def _update_config() -> dict[str, Any]: | FaceRecognitionTuningHandler | LicensePlateRecognitionTuningHandler | CameraTuningHandler + | OnvifTuningHandler | None ) = None @@ -384,6 +398,8 @@ def _update_config() -> dict[str, Any]: handler = FaceRecognitionTuningHandler(config) elif domain == LICENSE_PLATE_RECOGNITION_DOMAIN: handler = LicensePlateRecognitionTuningHandler(config) + elif domain == ONVIF_COMPONENT: + handler = OnvifTuningHandler(config) if handler: success = handler.update(camera_identifier, component, data) diff --git a/viseron/components/webserver/api/v1/tuning/__init__.py b/viseron/components/webserver/api/v1/tuning/__init__.py index 92824e624..d1abe3ca8 100644 --- a/viseron/components/webserver/api/v1/tuning/__init__.py +++ b/viseron/components/webserver/api/v1/tuning/__init__.py @@ -6,6 +6,7 @@ from .license_plate_recognition import LicensePlateRecognitionTuningHandler from .motion_detector import MotionDetectorTuningHandler from .object_detector import ObjectDetectorTuningHandler +from .onvif import OnvifTuningHandler __all__ = [ "BaseTuningHandler", @@ -14,4 +15,5 @@ "LicensePlateRecognitionTuningHandler", "MotionDetectorTuningHandler", "ObjectDetectorTuningHandler", + "OnvifTuningHandler", ] diff --git a/viseron/components/webserver/api/v1/tuning/base.py b/viseron/components/webserver/api/v1/tuning/base.py index 77106773a..db4556a6a 100644 --- a/viseron/components/webserver/api/v1/tuning/base.py +++ b/viseron/components/webserver/api/v1/tuning/base.py @@ -94,17 +94,7 @@ def _preserve_yaml_tags_in_list( def _get_camera_config( self, camera_id: str, component: str, domain: str ) -> dict[str, Any] | None: - """ - Get camera configuration for a specific domain. - - Args: - camera_id: Camera identifier - component: Component name (e.g., 'deepstack', 'edgetpu') - domain: Domain name (e.g., 'object_detector', 'face_recognition') - - Returns: - Camera config dict if found, None otherwise - """ + """Get camera configuration for a specific domain.""" # Find component config if component not in self.config: LOGGER.warning(f"Component '{component}' not found in config") @@ -138,6 +128,23 @@ def _get_camera_config( return cameras[camera_id] + def _get_direct_camera_config( + self, camera_id: str, component: str + ) -> dict[str, Any] | None: + """Get camera configuration for components with direct 'cameras' key.""" + if component not in self.config: + LOGGER.warning(f"Component '{component}' not found in config") + return None + + component_config = self.config[component] + cameras = component_config.get(CONFIG_CAMERAS, {}) + + if camera_id not in cameras: + LOGGER.warning(f"Camera '{camera_id}' not found in {component}.cameras") + return None + + return cameras[camera_id] + def _merge_labels( self, existing_labels: list[dict[str, Any]], new_labels: list[dict[str, Any]] ) -> list[dict[str, Any]]: diff --git a/viseron/components/webserver/api/v1/tuning/onvif.py b/viseron/components/webserver/api/v1/tuning/onvif.py new file mode 100644 index 000000000..fee33789b --- /dev/null +++ b/viseron/components/webserver/api/v1/tuning/onvif.py @@ -0,0 +1,217 @@ +"""ONVIF tuning handler.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from viseron.components.onvif.const import ( + COMPONENT as ONVIF_COMPONENT, + CONFIG_CLIENT, + CONFIG_DEVICE, + CONFIG_HOST, + CONFIG_IMAGING, + CONFIG_MEDIA, + CONFIG_ONVIF_AUTO_CONFIG, + CONFIG_ONVIF_PASSWORD, + CONFIG_ONVIF_PORT, + CONFIG_ONVIF_TIMEOUT, + CONFIG_ONVIF_USE_HTTPS, + CONFIG_ONVIF_USERNAME, + CONFIG_ONVIF_VERIFY_SSL, + CONFIG_ONVIF_WSDL_DIR, + CONFIG_PTZ, + DEFAULT_ONVIF_AUTO_CONFIG, +) + +from .base import BaseTuningHandler + +if TYPE_CHECKING: + from viseron import Viseron + +LOGGER = logging.getLogger(__name__) + +# ONVIF client configuration keys that should be grouped under "client" +ONVIF_CLIENT_KEYS = [ + CONFIG_HOST, + CONFIG_ONVIF_PORT, + CONFIG_ONVIF_USERNAME, + CONFIG_ONVIF_PASSWORD, + CONFIG_ONVIF_TIMEOUT, + CONFIG_ONVIF_USE_HTTPS, + CONFIG_ONVIF_VERIFY_SSL, + CONFIG_ONVIF_WSDL_DIR, + CONFIG_ONVIF_AUTO_CONFIG, +] + +ONVIF_SERVICES = [ + CONFIG_DEVICE, + CONFIG_IMAGING, + CONFIG_MEDIA, + CONFIG_PTZ, +] + +# Mapping from service config key to ONVIF service getter method name +SERVICE_GETTER_MAP = { + CONFIG_DEVICE: "get_device_service", + CONFIG_MEDIA: "get_media_service", + CONFIG_IMAGING: "get_imaging_service", + CONFIG_PTZ: "get_ptz_service", +} + + +def get_available_services(vis: Viseron, camera_identifier: str) -> list[str]: + """Get list of available ONVIF services for a camera.""" + available_services: list[str] = [] + + onvif_component = vis.data.get(ONVIF_COMPONENT) + if onvif_component is None: + return available_services + + for service, getter_name in SERVICE_GETTER_MAP.items(): + getter = getattr(onvif_component, getter_name, None) + if getter and getter(camera_identifier) is not None: + available_services.append(service) + + return available_services + + +def process_onvif_config( + vis: Viseron, cam_config: dict[str, Any], camera_identifier: str +) -> dict[str, Any]: + """Process ONVIF config to group base keys under 'client' key. + + If auto_config is True, all available ONVIF_SERVICES will be empty dicts (ignored). + If auto_config is False, all available ONVIF_SERVICES will be included with their + existing values or as empty dicts if they don't exist. + + Only services that are actually available for the camera will be included. + """ + client_config: dict[str, Any] = {} + other_config: dict[str, Any] = {} + + for key, value in cam_config.items(): + if key in ONVIF_CLIENT_KEYS: + client_config[key] = value + else: + other_config[key] = value + + # Build result with client first + result: dict[str, Any] = {} + + # Ensure auto_config is always present in client config + if CONFIG_ONVIF_AUTO_CONFIG not in client_config: + client_config[CONFIG_ONVIF_AUTO_CONFIG] = DEFAULT_ONVIF_AUTO_CONFIG + + if client_config: + result[CONFIG_CLIENT] = client_config + + # Get available services for this camera + available_services = get_available_services(vis, camera_identifier) + + auto_config = client_config.get(CONFIG_ONVIF_AUTO_CONFIG, DEFAULT_ONVIF_AUTO_CONFIG) + if auto_config: + # If auto_config is True, all available services are empty dicts (ignored) + for service in available_services: + result[service] = {} + else: + # If auto_config is False, include all available services with existing values + # or as empty dicts if they don't exist + for service in available_services: + result[service] = other_config.get(service, {}) + + return result + + +class OnvifTuningHandler(BaseTuningHandler): + """Handler for ONVIF configuration updates.""" + + def _reorder_onvif_config(self, onvif_config: dict[str, Any]) -> None: + """Reorder ONVIF config keys: client keys first, then service keys. + + This ensures the YAML output has client settings (port, username, etc.) + at the top, followed by service configurations (ptz, imaging, etc.). + """ + # Collect all current keys and values + client_items: list[tuple[str, Any]] = [] + service_items: list[tuple[str, Any]] = [] + other_items: list[tuple[str, Any]] = [] + + for key in list(onvif_config.keys()): + value = onvif_config[key] + if key in ONVIF_CLIENT_KEYS: + client_items.append((key, value)) + elif key in ONVIF_SERVICES: + service_items.append((key, value)) + else: + other_items.append((key, value)) + + # Clear and rebuild in correct order + onvif_config.clear() + + # Add client keys first (in defined order) + for key in ONVIF_CLIENT_KEYS: + for item_key, item_value in client_items: + if item_key == key: + onvif_config[key] = item_value + break + + # Add any other non-service keys + for key, value in other_items: + onvif_config[key] = value + + # Add service keys last (in defined order) + for key in ONVIF_SERVICES: + for item_key, item_value in service_items: + if item_key == key: + onvif_config[key] = item_value + break + + def update(self, camera_id: str, component: str, data: dict[str, Any]) -> bool: + """Update ONVIF configuration. + + For 'client' component: updates base ONVIF settings (port, username, etc.) + For service components: only updates if auto_config is False + """ + onvif_config = self._get_direct_camera_config(camera_id, ONVIF_COMPONENT) + if onvif_config is None: + return False + + # 'component' parameter is the section (client, ptz, imaging, etc.) + section = component + + if not section: + LOGGER.warning("Missing 'component' in update data") + return False + + if section == CONFIG_CLIENT: + # Update client config - keys go directly under camera config + # (no 'client' wrapper) + for key, value in data.items(): + if key in ONVIF_CLIENT_KEYS: + onvif_config[key] = value + + # Reorder keys: client keys first, then service keys + self._reorder_onvif_config(onvif_config) + return True + + if section in ONVIF_SERVICES: + # Check if auto_config is False before allowing service updates + auto_config = onvif_config.get( + CONFIG_ONVIF_AUTO_CONFIG, DEFAULT_ONVIF_AUTO_CONFIG + ) + if auto_config: + LOGGER.warning( + f"Cannot update service '{section}' when auto_config is True" + ) + return False + + # Update service config + updated_config = self._preserve_yaml_tags( + onvif_config.get(section, {}), data + ) + onvif_config[section] = updated_config + return True + + LOGGER.warning(f"Unknown component '{section}' for ONVIF update") + return False From b62808feb4e322e8ba7e08a930536c1ac10c1494 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 00:37:15 +0700 Subject: [PATCH 042/120] refactor(ui/ptz): Move draggable function as helper libs --- frontend/src/components/actions/ptz/OnvifPtzController.tsx | 2 +- .../actions/ptz/useDraggable.ts => lib/helpers/draggable.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename frontend/src/{components/actions/ptz/useDraggable.ts => lib/helpers/draggable.ts} (100%) diff --git a/frontend/src/components/actions/ptz/OnvifPtzController.tsx b/frontend/src/components/actions/ptz/OnvifPtzController.tsx index 1127a9710..e01825327 100644 --- a/frontend/src/components/actions/ptz/OnvifPtzController.tsx +++ b/frontend/src/components/actions/ptz/OnvifPtzController.tsx @@ -53,8 +53,8 @@ import { useGetPtzNodes, useGetPtzPresets, } from "lib/api/actions/onvif/ptz"; +import { useDraggable } from "lib/helpers/draggable"; -import { useDraggable } from "./useDraggable"; import { useOnvifPtzHandlers } from "./useOnvifPtzHandlers"; interface OnvifPtzControllerProps { diff --git a/frontend/src/components/actions/ptz/useDraggable.ts b/frontend/src/lib/helpers/draggable.ts similarity index 100% rename from frontend/src/components/actions/ptz/useDraggable.ts rename to frontend/src/lib/helpers/draggable.ts From 741c0faf843d712e3c1ca599bbb0d987bbed79a7 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 06:11:00 +0700 Subject: [PATCH 043/120] feat(onvif): Implement imaging presets and service capabilities --- viseron/components/onvif/imaging.py | 291 +++++++----------- .../webserver/api/v1/actions/onvif/imaging.py | 20 ++ 2 files changed, 130 insertions(+), 181 deletions(-) diff --git a/viseron/components/onvif/imaging.py b/viseron/components/onvif/imaging.py index 68aa0399a..572ba2789 100644 --- a/viseron/components/onvif/imaging.py +++ b/viseron/components/onvif/imaging.py @@ -55,12 +55,14 @@ def __init__( ) self._media_profile: Any = None # selected media profile self._onvif_imaging_service: Any = None # ONVIF Imaging service instance + self._imaging_capabilities: Any = None # to store Imaging capabilities self._video_source_token: str | None = None async def initialize(self) -> None: """Initialize the Imaging service.""" self._onvif_imaging_service = self._client.imaging() + self._imaging_capabilities = await self.get_service_capabilities() self._media_profile = self._media_service.get_selected_profile() self._video_source_token = ( @@ -105,14 +107,14 @@ def _convert_keys_to_camel(self, obj): # ## The Real Operations ## # - # ---- Settings Operations ---- # + # ---- Capabilities Operations ---- # @operation() - async def get_options(self) -> Any: - """Get available imaging options.""" - return self._onvif_imaging_service.GetOptions( - VideoSourceToken=self._video_source_token - ) + async def get_service_capabilities(self) -> Any: + """Get Imaging service capabilities.""" + return self._onvif_imaging_service.GetServiceCapabilities() + + # ---- Settings Operations ---- # @operation() async def get_imaging_settings(self) -> Any: @@ -133,6 +135,38 @@ async def set_imaging_settings( ) return True + @operation() + async def get_options(self) -> Any: + """Get available imaging options.""" + return self._onvif_imaging_service.GetOptions( + VideoSourceToken=self._video_source_token + ) + + # ---- Presets Operations ---- # + + @operation() + async def get_presets(self) -> Any: + """Get available imaging presets.""" + return self._onvif_imaging_service.GetPresets( + VideoSourceToken=self._video_source_token + ) + + @operation() + async def get_current_preset(self) -> Any: + """Get current imaging preset.""" + return self._onvif_imaging_service.GetCurrentPreset( + VideoSourceToken=self._video_source_token + ) + + @operation() + async def set_current_preset(self, preset_token: str) -> bool: + """Set current imaging preset.""" + self._onvif_imaging_service.SetCurrentPreset( + VideoSourceToken=self._video_source_token, + PresetToken=preset_token, + ) + return True + # ---- Focus Operations ---- # @operation() @@ -143,12 +177,11 @@ async def get_move_options(self) -> Any: ) @operation() - async def get_status(self) -> bool: + async def get_status(self) -> Any: """Get focus movement status.""" - self._onvif_imaging_service.GetStatus( + return self._onvif_imaging_service.GetStatus( VideoSourceToken=self._video_source_token, ) - return True @operation() async def move_focus(self, move_config: dict[str, Any]) -> bool: @@ -169,231 +202,127 @@ async def stop_focus(self) -> bool: # ## Derived operations ## # - async def set_brightness( - self, force_persistence: bool, brightness: float | None = None - ) -> bool: + async def set_brightness(self, force_persistence: bool, brightness: float) -> bool: """Set brightness level.""" - if not self._auto_config and brightness is None: - brightness = self._config.get(CONFIG_IMAGING_BRIGHTNESS) - - if brightness is not None: - return await self.set_imaging_settings( - {"Brightness": brightness}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"Brightness": brightness}, force_persistence + ) async def set_color_saturation( - self, force_persistence: bool, saturation: float | None = None + self, force_persistence: bool, saturation: float ) -> bool: """Set color saturation level.""" - if not self._auto_config and saturation is None: - saturation = self._config.get(CONFIG_IMAGING_COLOR_SATURATION) - - if saturation is not None: - return await self.set_imaging_settings( - {"ColorSaturation": saturation}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"ColorSaturation": saturation}, force_persistence + ) - async def set_contrast( - self, force_persistence: bool, contrast: float | None = None - ) -> bool: + async def set_contrast(self, force_persistence: bool, contrast: float) -> bool: """Set contrast level.""" - if not self._auto_config and contrast is None: - contrast = self._config.get(CONFIG_IMAGING_CONTRAST) - - if contrast is not None: - return await self.set_imaging_settings( - {"Contrast": contrast}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"Contrast": contrast}, force_persistence + ) - async def set_sharpness( - self, force_persistence: bool, sharpness: float | None = None - ) -> bool: + async def set_sharpness(self, force_persistence: bool, sharpness: float) -> bool: """Set sharpness level.""" - if not self._auto_config and sharpness is None: - sharpness = self._config.get(CONFIG_IMAGING_SHARPNESS) - - if sharpness is not None: - return await self.set_imaging_settings( - {"Sharpness": sharpness}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"Sharpness": sharpness}, force_persistence + ) async def set_ircut_filter( - self, force_persistence: bool, ircut_filter: str | None = None + self, force_persistence: bool, ircut_filter: str ) -> bool: """Set IR cut filter mode.""" - if not self._auto_config and ircut_filter is None: - ircut_filter = self._config.get(CONFIG_IMAGING_IRCUT_FILTER) - - if ircut_filter is not None: - return await self.set_imaging_settings( - {"IrCutFilter": ircut_filter}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"IrCutFilter": ircut_filter}, force_persistence + ) async def set_backlight_compensation( - self, force_persistence: bool, blc_mode: str | None = None + self, force_persistence: bool, blc_mode: str ) -> bool: """Set backlight compensation settings.""" - if not self._auto_config and blc_mode is None: - blc_mode = self._config.get(CONFIG_IMAGING_BACKLIGHT_COMPENSATION) - - if blc_mode is not None: - return await self.set_imaging_settings( - {"BacklightCompensation": {"Mode": blc_mode}}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"BacklightCompensation": {"Mode": blc_mode}}, force_persistence + ) async def set_exposure( - self, force_persistence: bool, exposure_config: dict[str, Any] | None = None + self, force_persistence: bool, exposure_config: dict[str, Any] ) -> bool: """Set exposure settings.""" - if not self._auto_config and exposure_config is None: - exposure_config = self._config.get(CONFIG_IMAGING_EXPOSURE) - - if exposure_config is not None: - return await self.set_imaging_settings( - {"Exposure": exposure_config}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"Exposure": exposure_config}, force_persistence + ) async def set_focus( - self, force_persistence: bool, focus_config: dict[str, Any] | None = None + self, force_persistence: bool, focus_config: dict[str, Any] ) -> bool: """Set focus settings.""" - if not self._auto_config and focus_config is None: - focus_config = self._config.get(CONFIG_IMAGING_FOCUS) - - if focus_config is not None: - return await self.set_imaging_settings( - {"Focus": focus_config}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"Focus": focus_config}, force_persistence + ) async def set_wide_dynamic_range( - self, force_persistence: bool, wdr_config: dict[str, Any] | None = None + self, force_persistence: bool, wdr_config: dict[str, Any] ) -> bool: """Set wide dynamic range settings.""" - if not self._auto_config and wdr_config is None: - wdr_config = self._config.get(CONFIG_IMAGING_WIDE_DYNAMIC_RANGE) - - if wdr_config is not None: - return await self.set_imaging_settings( - {"WideDynamicRange": wdr_config}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"WideDynamicRange": wdr_config}, force_persistence + ) async def set_white_balance( - self, force_persistence: bool, wb_config: dict[str, Any] | None = None + self, force_persistence: bool, wb_config: dict[str, Any] ) -> bool: """Set white balance settings.""" - if not self._auto_config and wb_config is None: - wb_config = self._config.get(CONFIG_IMAGING_WHITE_BALANCE) - - if wb_config is not None: - return await self.set_imaging_settings( - {"WhiteBalance": wb_config}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"WhiteBalance": wb_config}, force_persistence + ) async def set_image_stabilization( - self, force_persistence: bool, is_config: dict[str, Any] | None = None + self, force_persistence: bool, is_config: dict[str, Any] ) -> bool: """Set image stabilization settings.""" - if not self._auto_config and is_config is None: - is_config = self._config.get(CONFIG_IMAGING_IMAGE_STABILIZATION) - - if is_config is not None: - return await self.set_imaging_settings( - {"Extension": {"ImageStabilization": is_config}}, force_persistence - ) - - return False + return await self.set_imaging_settings( + {"Extension": {"ImageStabilization": is_config}}, force_persistence + ) async def set_ircut_filter_auto_adjustment( - self, force_persistence: bool, ifaa_config: dict[str, Any] | None = None + self, force_persistence: bool, ifaa_config: dict[str, Any] ) -> bool: """Set ircut filter auto adjustment settings.""" - if not self._auto_config and ifaa_config is None: - ifaa_config = self._config.get(CONFIG_IMAGING_IRCUT_FILTER_AUTO_ADJUSTMENT) - - if ifaa_config is not None: - return await self.set_imaging_settings( - { - "Extension": { - "Extension": {"IrCutFilterAutoAdjustment": ifaa_config} - } - }, - force_persistence, - ) - - return False + return await self.set_imaging_settings( + {"Extension": {"Extension": {"IrCutFilterAutoAdjustment": ifaa_config}}}, + force_persistence, + ) async def set_tone_compensation( - self, force_persistence: bool, tc_config: dict[str, Any] | None = None + self, force_persistence: bool, tc_config: dict[str, Any] ) -> bool: """Set tone compensation settings.""" - if not self._auto_config and tc_config is None: - tc_config = self._config.get(CONFIG_IMAGING_TONE_COMPENSATION) - - if tc_config is not None: - return await self.set_imaging_settings( - { - "Extension": { - "Extension": {"Extension": {"ToneCompensation": tc_config}} - } - }, - force_persistence, - ) - - return False + return await self.set_imaging_settings( + { + "Extension": { + "Extension": {"Extension": {"ToneCompensation": tc_config}} + } + }, + force_persistence, + ) async def set_defogging( - self, force_persistence: bool, d_config: dict[str, Any] | None = None + self, force_persistence: bool, d_config: dict[str, Any] ) -> bool: """Set defogging settings.""" - if not self._auto_config and d_config is None: - d_config = self._config.get(CONFIG_IMAGING_DEFOGGING) - - if d_config is not None: - return await self.set_imaging_settings( - {"Extension": {"Extension": {"Extension": {"Defogging": d_config}}}}, - force_persistence, - ) - - return False + return await self.set_imaging_settings( + {"Extension": {"Extension": {"Extension": {"Defogging": d_config}}}}, + force_persistence, + ) async def set_noise_reduction( - self, force_persistence: bool, nr_config: dict[str, Any] | None = None + self, force_persistence: bool, nr_config: dict[str, Any] ) -> bool: """Set noise reduction settings.""" - if not self._auto_config and nr_config is None: - nr_config = self._config.get(CONFIG_IMAGING_NOISE_REDUCTION) - - if nr_config is not None: - return await self.set_imaging_settings( - { - "Extension": { - "Extension": {"Extension": {"NoiseReduction": nr_config}} - } - }, - force_persistence, - ) - - return False + return await self.set_imaging_settings( + {"Extension": {"Extension": {"Extension": {"NoiseReduction": nr_config}}}}, + force_persistence, + ) # ## Apply Configuration at Startup ## # diff --git a/viseron/components/webserver/api/v1/actions/onvif/imaging.py b/viseron/components/webserver/api/v1/actions/onvif/imaging.py index 42b3fe1ec..d8a955b4c 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/imaging.py +++ b/viseron/components/webserver/api/v1/actions/onvif/imaging.py @@ -75,6 +75,18 @@ async def get_onvif_imaging( ) return + if action == "presets": + await self.validate_action_response( + await imaging_service.get_presets(), action, camera_identifier + ) + return + + if action == "current_preset": + await self.validate_action_response( + await imaging_service.get_current_preset(), action, camera_identifier + ) + return + if action == "move_options": await self.validate_action_response( await imaging_service.get_move_options(), action, camera_identifier @@ -105,6 +117,14 @@ async def put_onvif_imaging( await self.validate_action_status(set_settings, action, camera_identifier) return + if action == "set_current_preset": + preset_token = self.validate_request_data(request_data, "preset_token") + set_current_preset = await imaging_service.set_current_preset(preset_token) + await self.validate_action_status( + set_current_preset, action, camera_identifier + ) + return + if action == "brightness": brightness = self.validate_request_data(request_data, "brightness") force_persistence = self.validate_request_data( From c4a3d25ef5581a994fcbdc43f58fc5298347c77d Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 06:12:08 +0700 Subject: [PATCH 044/120] feat(onvif): Implement set configuration and service capabilities --- viseron/components/onvif/ptz.py | 20 +++++++++++++++++++ .../webserver/api/v1/actions/onvif/ptz.py | 13 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/viseron/components/onvif/ptz.py b/viseron/components/onvif/ptz.py index 6ff1eba67..2508ed67b 100644 --- a/viseron/components/onvif/ptz.py +++ b/viseron/components/onvif/ptz.py @@ -52,6 +52,7 @@ def __init__( self._media_service = media_service # you can't use ptz without media service self._media_profile: Any = None # selected media profile for any PTZ operations self._onvif_ptz_service: Any = None # ONVIF PTZ service instance + self._ptz_capabilities: Any = None # to store PTZ capabilities self._ptz_config: Any = None # to determine PTZ behaviour self._ptz_config_options: Any = None # to determine PTZ options self._stop_patrol_event: asyncio.Event = asyncio.Event() @@ -59,6 +60,7 @@ def __init__( async def initialize(self) -> None: """Initialize the PTZ service.""" self._onvif_ptz_service = self._client.ptz() + self._ptz_capabilities = await self.get_service_capabilities() self._media_profile = self._media_service.get_selected_profile() @@ -129,6 +131,13 @@ async def _timeout_task(self, task, timeout): # ## The Real Operations ## # + # ---- Capabilities Operations ---- # + + @operation() + async def get_service_capabilities(self) -> Any: + """Get PTZ service capabilities.""" + return self._onvif_ptz_service.GetServiceCapabilities() + # ---- Movement Operations ---- # @operation() @@ -334,6 +343,17 @@ async def get_configuration_options(self) -> Any: ConfigurationToken=self._media_profile.PTZConfiguration.token ) + @operation() + async def set_configuration( + self, ptz_config: dict[str, Any], force_persistence: bool = True + ) -> bool: + """Set the PTZ configuration of the camera.""" + self._onvif_ptz_service.SetConfiguration( + PTZConfiguration=ptz_config, + ForcePersistence=force_persistence, + ) + return True + # ## Derived operations ## # def get_ptz_config(self) -> Any: diff --git a/viseron/components/webserver/api/v1/actions/onvif/ptz.py b/viseron/components/webserver/api/v1/actions/onvif/ptz.py index ebb927ba7..5cfa52a94 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/ptz.py +++ b/viseron/components/webserver/api/v1/actions/onvif/ptz.py @@ -120,6 +120,19 @@ async def put_onvif_ptz(self, ptz_service, camera_identifier: str, action: str): await self.validate_action_status(set_preset, action, camera_identifier) return + if action == "set_configuration": + configuration = self.validate_request_data(request_data, "configuration") + force_persistence = self.validate_request_data( + request_data, "force_persistence" + ) + set_configuration = await ptz_service.set_configuration( + ptz_config=configuration, force_persistence=force_persistence + ) + await self.validate_action_status( + set_configuration, action, camera_identifier + ) + return + self.unknown_action(action) @action_handler From d853bd4fdcc3d25d84518e23fd02d6dbf69c3e16 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 06:13:58 +0700 Subject: [PATCH 045/120] feat(onvif): Implement service capabilities in media service --- viseron/components/onvif/media.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/viseron/components/onvif/media.py b/viseron/components/onvif/media.py index f4fe96900..8db0d5310 100644 --- a/viseron/components/onvif/media.py +++ b/viseron/components/onvif/media.py @@ -52,12 +52,14 @@ def __init__( self._config = config self._auto_config = auto_config self._onvif_media_service: Any = None # ONVIF Media service instance + self._imaging_capabilities: Any = None # to store Media capabilities self._selected_profile: Any = None self._profiles: list[Any] = [] async def initialize(self) -> None: """Initialize the Media service.""" self._onvif_media_service = self._client.media() + self._imaging_capabilities = await self.get_service_capabilities() # Load media profiles self._profiles = await self.get_profiles() @@ -90,6 +92,13 @@ async def initialize(self) -> None: # ## The Real Operations ## # + # ---- Capabilities Operations ---- # + + @operation() + async def get_service_capabilities(self) -> Any: + """Get Media service capabilities.""" + return self._onvif_media_service.GetServiceCapabilities() + # ---- Profiles Operations ---- # @operation() From 970dad772344e571bc4b4c62ff277b40e7096a2d Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 06:15:06 +0700 Subject: [PATCH 046/120] refactor(onvif): Several fixes and operations additions --- viseron/components/onvif/device.py | 174 +++++++++++------- .../webserver/api/v1/actions/onvif/device.py | 139 +++++++++++--- 2 files changed, 220 insertions(+), 93 deletions(-) diff --git a/viseron/components/onvif/device.py b/viseron/components/onvif/device.py index cd97dc69a..1c457a0fe 100644 --- a/viseron/components/onvif/device.py +++ b/viseron/components/onvif/device.py @@ -40,49 +40,37 @@ def __init__( self._config = config self._auto_config = auto_config self._onvif_device_service: Any = None # ONVIF Device service instance + self._device_capabilities: Any = None # to store Device capabilities async def initialize(self) -> None: """Initialize the Device/Core service.""" self._onvif_device_service = self._client.devicemgmt() + self._device_capabilities = await self.get_service_capabilities() if not self._auto_config and self._config: await self.apply_config() # ## The Real Operations ## # - # ---- System Operations ---- # + # ---- Capabilities Operations ---- # @operation() - async def get_capabilities(self) -> Any: - """Get device capabilities.""" - return self._onvif_device_service.GetCapabilities(Category="All") + async def get_service_capabilities(self) -> Any: + """Get Device service capabilities.""" + return self._onvif_device_service.GetServiceCapabilities() @operation() async def get_services(self) -> Any: """Get available services on the device.""" return self._onvif_device_service.GetServices(IncludeCapability=False) + # ---- System Operations ---- # + @operation() async def get_device_information(self) -> Any: """Get device information.""" return self._onvif_device_service.GetDeviceInformation() - @operation() - async def get_discovery_mode(self) -> Any: - """Get discovery mode.""" - return self._onvif_device_service.GetDiscoveryMode() - - @operation() - async def set_discovery_mode(self, discoverable: bool | None = None) -> bool: - """Set discovery mode.""" - if not self._auto_config and discoverable is None: - discoverable = self._config.get(CONFIG_DEVICE_DISCOVERABLE, True) - - mode = "Discoverable" if discoverable else "NonDiscoverable" - self._onvif_device_service.SetDiscoveryMode(DiscoveryMode=mode) - - return True - @operation() async def get_scopes(self) -> Any: """Get device scopes.""" @@ -91,7 +79,7 @@ async def get_scopes(self) -> Any: @operation() async def add_scopes(self, scopes: list[str]) -> bool: """Add device scopes.""" - self._onvif_device_service.AddScopes(Scopes=scopes) + self._onvif_device_service.AddScopes(ScopeItem=scopes) return True @operation() @@ -103,7 +91,7 @@ async def set_scopes(self, scopes: list[str]) -> bool: @operation() async def remove_scopes(self, scopes: list[str]) -> bool: """Remove device scopes.""" - self._onvif_device_service.RemoveScopes(Scopes=scopes) + self._onvif_device_service.RemoveScopes(ScopeItem=scopes) return True @operation() @@ -111,7 +99,15 @@ async def system_reboot(self) -> bool: """Reboot the device.""" LOGGER.warning(f"Rebooting ONVIF camera for {self._camera.identifier}") self._onvif_device_service.SystemReboot() + return True + @operation() + async def system_factory_default(self, level: str) -> bool: + """Restore the device to factory default settings.""" + LOGGER.warning( + f"Restoring ONVIF camera to factory default for {self._camera.identifier}" + ) + self._onvif_device_service.SetSystemFactoryDefault(FactoryDefault=level) return True # ---- Date & Time Operations ---- # @@ -127,24 +123,16 @@ async def set_system_date_and_time( datetime_type: str = "NTP", daylight_savings: bool | None = None, timezone: str | None = None, + utc_datetime: dict[str, Any] | None = None, ) -> bool: """Set system date and time.""" - daylight_savings = daylight_savings or self._config.get( - CONFIG_DEVICE_DAYLIGHT_SAVINGS - ) - - # Timezone is ignored if datetime_type is NTP - timezone_param = None - if datetime_type != "NTP": - timezone = timezone or self._config.get(CONFIG_DEVICE_TIMEZONE) - timezone_param = {"TZ": timezone} if timezone else None - + timezone_param = {"TZ": timezone} if timezone else None self._onvif_device_service.SetSystemDateAndTime( DateTimeType=datetime_type, DaylightSavings=daylight_savings, TimeZone=timezone_param, + UTCDateTime=utc_datetime, ) - return True # ---- Security Operations ---- # @@ -155,20 +143,21 @@ async def get_users(self) -> Any: return self._onvif_device_service.GetUsers() @operation() - async def create_users(self, user: dict[str, Any]) -> Any: + async def create_users(self, users: list[dict[str, Any]]) -> bool: """Create device users.""" - return self._onvif_device_service.CreateUsers(User=user) + self._onvif_device_service.CreateUsers(User=users) + return True @operation() - async def delete_users(self, usernames: list[str]) -> bool: - """Delete device users.""" - self._onvif_device_service.DeleteUsers(Usernames=usernames) + async def delete_users(self, username: str) -> bool: + """Delete device user.""" + self._onvif_device_service.DeleteUsers(Username=username) return True @operation() - async def set_user(self, user: dict[str, Any]) -> bool: - """Set device user.""" - self._onvif_device_service.SetUser(User=user) + async def set_user(self, users: list[dict[str, Any]]) -> bool: + """Set device users.""" + self._onvif_device_service.SetUser(User=users) return True # ---- Network Operations ---- # @@ -179,11 +168,29 @@ async def get_hostname(self) -> Any: return self._onvif_device_service.GetHostname() @operation() - async def set_hostname(self, hostname: str | None = None) -> Any: + async def set_hostname(self, hostname: str | None = None) -> bool: """Set device hostname.""" self._onvif_device_service.SetHostname(Name=hostname) return True + @operation() + async def set_hostname_from_dhcp(self, from_dhcp: bool) -> bool: + """Set device hostname from DHCP.""" + self._onvif_device_service.SetHostnameFromDHCP(FromDHCP=from_dhcp) + return True + + @operation() + async def get_discovery_mode(self) -> Any: + """Get discovery mode.""" + return self._onvif_device_service.GetDiscoveryMode() + + @operation() + async def set_discovery_mode(self, discoverable: bool) -> bool: + """Set discovery mode.""" + mode = "Discoverable" if discoverable else "NonDiscoverable" + self._onvif_device_service.SetDiscoveryMode(DiscoveryMode=mode) + return True + @operation() async def get_ntp(self) -> Any: """Get NTP configuration.""" @@ -192,53 +199,88 @@ async def get_ntp(self) -> Any: @operation() async def set_ntp( self, - ntp_server: str | None = None, - from_dhcp: bool | None = None, + from_dhcp: bool, ntp_type: str | None = None, + ntp_server: str | None = None, ) -> bool: """Set NTP configuration.""" - if not self._auto_config and from_dhcp is None: - from_dhcp = self._config.get(CONFIG_DEVICE_NTP_FROM_DHCP, False) - ntp_manual = None - if not from_dhcp: - if not self._auto_config and ntp_server is None: - ntp_server = self._config.get(CONFIG_DEVICE_NTP_SERVER) - if ntp_server: - match ntp_type: - case "DNS": - ntp_manual = {"Type": ntp_type, "DNSname": ntp_server} - case "IPv4": - ntp_manual = {"Type": ntp_type, "IPv4Address": ntp_server} - case "IPv6": - ntp_manual = {"Type": ntp_type, "IPv6Address": ntp_server} - case _: - return False - + if ntp_server and ntp_type: + match ntp_type: + case "DNS": + ntp_manual = {"Type": ntp_type, "DNSname": ntp_server} + case "IPv4": + ntp_manual = {"Type": ntp_type, "IPv4Address": ntp_server} + case "IPv6": + ntp_manual = {"Type": ntp_type, "IPv6Address": ntp_server} + case _: + return False self._onvif_device_service.SetNTP(FromDHCP=from_dhcp, NTPManual=ntp_manual) - return True @operation() async def get_network_default_gateway(self) -> Any: - """Get network interfaces.""" + """Get network default gateway.""" return self._onvif_device_service.GetNetworkDefaultGateway() @operation() - async def get_network_interfaces(self) -> Any: - """Get network interfaces.""" - return self._onvif_device_service.GetNetworkInterfaces() + async def set_network_default_gateway( + self, ipv4_address: str | None = None, ipv6_address: str | None = None + ) -> bool: + """Set network default gateway.""" + self._onvif_device_service.SetNetworkDefaultGateway( + IPv4Address=ipv4_address, IPv6Address=ipv6_address + ) + return True @operation() async def get_network_protocols(self) -> Any: """Get network protocols.""" return self._onvif_device_service.GetNetworkProtocols() + @operation() + async def set_network_protocols( + self, network_protocols: list[dict[str, Any]] + ) -> bool: + """Set network protocols.""" + self._onvif_device_service.SetNetworkProtocols( + NetworkProtocols=network_protocols + ) + return True + + @operation() + async def get_network_interfaces(self) -> Any: + """Get network interfaces.""" + return self._onvif_device_service.GetNetworkInterfaces() + + @operation() + async def set_network_interfaces( + self, interface_token: str, network_interface: dict[str, Any] + ) -> bool: + """Set network interfaces.""" + self._onvif_device_service.SetNetworkInterfaces( + InterfaceToken=interface_token, NetworkInterface=network_interface + ) + return True + @operation() async def get_dns(self) -> Any: """Get network DNS.""" return self._onvif_device_service.GetDNS() + @operation() + async def set_dns( + self, + from_dhcp: bool, + search_domain: str | None = None, + dns_manual: list[dict[str, Any]] | None = None, + ) -> bool: + """Set network DNS.""" + self._onvif_device_service.SetDNS( + FromDHCP=from_dhcp, SearchDomain=search_domain, DNSManual=dns_manual + ) + return True + # ## Apply Configuration at Startup ## # async def apply_config(self) -> bool: diff --git a/viseron/components/webserver/api/v1/actions/onvif/device.py b/viseron/components/webserver/api/v1/actions/onvif/device.py index 21a2b6dd9..60e25d07c 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/device.py +++ b/viseron/components/webserver/api/v1/actions/onvif/device.py @@ -72,33 +72,29 @@ async def get_onvif_device( ): """Handle GET requests for ONVIF Device actions.""" - if action == "information": - await self.validate_action_response( - await device_service.get_device_information(), action, camera_identifier - ) - return - - if action == "scopes": + if action == "capabilities": await self.validate_action_response( - await device_service.get_scopes(), action, camera_identifier + await device_service.get_service_capabilities(), + action, + camera_identifier, ) return - if action == "capabilities": + if action == "services": await self.validate_action_response( - await device_service.get_capabilities(), action, camera_identifier + await device_service.get_services(), action, camera_identifier ) return - if action == "services": + if action == "information": await self.validate_action_response( - await device_service.get_services(), action, camera_identifier + await device_service.get_device_information(), action, camera_identifier ) return - if action == "users": + if action == "scopes": await self.validate_action_response( - await device_service.get_users(), action, camera_identifier + await device_service.get_scopes(), action, camera_identifier ) return @@ -110,15 +106,15 @@ async def get_onvif_device( ) return - if action == "hostname": + if action == "users": await self.validate_action_response( - await device_service.get_hostname(), action, camera_identifier + await device_service.get_users(), action, camera_identifier ) return - if action == "ntp": + if action == "hostname": await self.validate_action_response( - await device_service.get_ntp(), action, camera_identifier + await device_service.get_hostname(), action, camera_identifier ) return @@ -128,6 +124,12 @@ async def get_onvif_device( ) return + if action == "ntp": + await self.validate_action_response( + await device_service.get_ntp(), action, camera_identifier + ) + return + if action == "network_default_gateway": await self.validate_action_response( await device_service.get_network_default_gateway(), @@ -179,28 +181,105 @@ async def put_onvif_device( datetime_type=system_date.get("datetime_type"), daylight_savings=system_date.get("daylight_savings"), timezone=system_date.get("timezone"), + utc_datetime=system_date.get("utc_datetime"), ) await self.validate_action_status( set_system_date_and_time, action, camera_identifier ) return + if action == "set_user": + users = self.validate_request_data(request_data, "users") + set_user = await device_service.set_user(users) + await self.validate_action_status(set_user, action, camera_identifier) + return + if action == "set_hostname": hostname = self.validate_request_data(request_data, "hostname") set_hostname = await device_service.set_hostname(hostname) await self.validate_action_status(set_hostname, action, camera_identifier) return + if action == "set_hostname_from_dhcp": + from_dhcp = self.validate_request_data(request_data, "from_dhcp") + set_hostname_from_dhcp = await device_service.set_hostname_from_dhcp( + from_dhcp + ) + await self.validate_action_status( + set_hostname_from_dhcp, action, camera_identifier + ) + return + + if action == "set_discovery_mode": + discoverable = self.validate_request_data(request_data, "discoverable") + set_discovery_mode = await device_service.set_discovery_mode( + discoverable=discoverable + ) + await self.validate_action_status( + set_discovery_mode, action, camera_identifier + ) + return + if action == "set_ntp": ntp = self.validate_request_data(request_data, "ntp") set_ntp = await device_service.set_ntp( - ntp_server=ntp.get("ntp_server"), from_dhcp=ntp.get("from_dhcp"), ntp_type=ntp.get("ntp_type"), + ntp_server=ntp.get("ntp_server"), ) await self.validate_action_status(set_ntp, action, camera_identifier) return + if action == "set_network_default_gateway": + network_default_gateway = self.validate_request_data( + request_data, "network_default_gateway" + ) + set_network_default_gateway = ( + await device_service.set_network_default_gateway( + ipv4_address=network_default_gateway.get("ipv4_address"), + ipv6_address=network_default_gateway.get("ipv6_address"), + ) + ) + await self.validate_action_status( + set_network_default_gateway, action, camera_identifier + ) + return + + if action == "set_network_protocols": + network_protocols = self.validate_request_data( + request_data, "network_protocols" + ) + set_network_protocols = await device_service.set_network_protocols( + network_protocols=network_protocols.get("network_protocols"), + ) + await self.validate_action_status( + set_network_protocols, action, camera_identifier + ) + return + + if action == "set_network_interfaces": + network_interfaces = self.validate_request_data( + request_data, "network_interfaces" + ) + set_network_interfaces = await device_service.set_network_interfaces( + interface_token=network_interfaces.get("interface_token"), + network_interface=network_interfaces.get("network_interface"), + ) + await self.validate_action_status( + set_network_interfaces, action, camera_identifier + ) + return + + if action == "set_dns": + dns = self.validate_request_data(request_data, "dns") + set_dns = await device_service.set_dns( + from_dhcp=dns.get("from_dhcp"), + search_domain=dns.get("search_domain"), + dns_manual=dns.get("dns_manual"), + ) + await self.validate_action_status(set_dns, action, camera_identifier) + return + self.unknown_action(action) @action_handler @@ -220,17 +299,23 @@ async def post_onvif_device( await self.validate_action_status(add_scopes, action, camera_identifier) return + if action == "reboot": + reboot = await device_service.system_reboot() + await self.validate_action_status(reboot, action, camera_identifier) + return + + if action == "factory_reset": + level = self.validate_request_data(request_data, "level") + factory_reset = await device_service.system_factory_default(level=level) + await self.validate_action_status(factory_reset, action, camera_identifier) + return + if action == "create_users": users = self.validate_request_data(request_data, "users") create_users = await device_service.create_users(users) await self.validate_action_status(create_users, action, camera_identifier) return - if action == "reboot": - reboot = await device_service.system_reboot() - await self.validate_action_status(reboot, action, camera_identifier) - return - self.unknown_action(action) @action_handler @@ -252,11 +337,11 @@ async def delete_onvif_device( return if action == "delete_users": - required_query = "usernames" - usernames = self.validate_query_parameter( + required_query = "username" + username = self.validate_query_parameter( self.get_query_argument(required_query, None), required_query ) - delete_users = await device_service.delete_users(usernames) + delete_users = await device_service.delete_users(username) await self.validate_action_status(delete_users, action, camera_identifier) return From 4d04775121f674d6b82f072d70e7528fab250773 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 06:24:24 +0700 Subject: [PATCH 047/120] feat(onvif): Add a dedicated workflow for ONVIF in Camera Tuning --- .../src/components/tuning/TuneConfigPanel.tsx | 85 ++++-- .../tuning/config/ConfigPanelHeader.tsx | 33 ++- .../tuning/config/miscellaneousConfig.ts | 2 + .../tuning/onvif/miscellaneousConfig.ts | 267 ++++++++++++++++++ .../tuning/shared/useTuneHandlers.ts | 3 + frontend/src/pages/Tuning.tsx | 14 +- 6 files changed, 377 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/tuning/onvif/miscellaneousConfig.ts diff --git a/frontend/src/components/tuning/TuneConfigPanel.tsx b/frontend/src/components/tuning/TuneConfigPanel.tsx index 0be1fbc32..f7bd37b84 100644 --- a/frontend/src/components/tuning/TuneConfigPanel.tsx +++ b/frontend/src/components/tuning/TuneConfigPanel.tsx @@ -17,10 +17,15 @@ import { } from "./config"; import { getMiscellaneousFields } from "./config/miscellaneousConfig"; import { Label, Zone } from "./object_detector/types"; +import { DeviceServiceSection } from "./onvif/DeviceServiceSection"; +import { ImagingServiceSection } from "./onvif/ImagingServiceSection"; +import { MediaServiceSection } from "./onvif/MediaServiceSection"; +import { PTZServiceSection } from "./onvif/PTZServiceSection"; import { Mask } from "./shared/types"; interface ComponentData { componentType: string; + componentName?: string; labels?: Label[]; zones?: Zone[]; mask?: Mask[]; @@ -64,6 +69,8 @@ interface TuneConfigPanelProps { selectedOSDTextIndex: number | null; selectedVideoTransformIndex: number | null; currentDomainName: string; + cameraIdentifier?: string; + isOnvifAutoConfig?: boolean; } export function TuneConfigPanel({ @@ -102,6 +109,8 @@ export function TuneConfigPanel({ selectedOSDTextIndex, selectedVideoTransformIndex, currentDomainName, + cameraIdentifier, + isOnvifAutoConfig, }: TuneConfigPanelProps) { const [contextMenu, setContextMenu] = useState<{ mouseX: number; @@ -182,6 +191,9 @@ export function TuneConfigPanel({ isDrawingMode={isDrawingMode} isSaving={isSaving} onRevertConfig={onRevertConfig} + currentDomainName={currentDomainName} + componentName={selectedComponentData.componentName} + isOnvifAutoConfig={isOnvifAutoConfig} /> } sx={{ @@ -202,6 +214,34 @@ export function TuneConfigPanel({ /> )} + {/* ONVIF Device Service Section */} + {currentDomainName === "onvif" && + selectedComponentData.componentType === "device" && + cameraIdentifier && ( + + )} + + {/* ONVIF Media Service Section */} + {currentDomainName === "onvif" && + selectedComponentData.componentType === "media" && + cameraIdentifier && ( + + )} + + {/* ONVIF Imaging Service Section */} + {currentDomainName === "onvif" && + selectedComponentData.componentType === "imaging" && + cameraIdentifier && ( + + )} + + {/* ONVIF PTZ Service Section */} + {currentDomainName === "onvif" && + selectedComponentData.componentType === "ptz" && + cameraIdentifier && ( + + )} + {/* Labels section - for object_detector, face_recognition, and license_plate_recognition */} {(selectedComponentData.componentType === "object_detector" || selectedComponentData.componentType === "face_recognition" || @@ -275,23 +315,36 @@ export function TuneConfigPanel({ )} {/* Miscellaneous section - domain-agnostic configurable fields */} - + {/* Hidden for ONVIF components (except client) when auto_config is true */} + {!( + currentDomainName === "onvif" && + selectedComponentData.componentName !== "client" && + isOnvifAutoConfig + ) && ( + + )} - {/* Save Config Button */} - + {/* Save Config Button - Hidden for ONVIF components (except client) when auto_config is true */} + {!( + currentDomainName === "onvif" && + selectedComponentData.componentName !== "client" && + isOnvifAutoConfig + ) && ( + + )} diff --git a/frontend/src/components/tuning/config/ConfigPanelHeader.tsx b/frontend/src/components/tuning/config/ConfigPanelHeader.tsx index 823509d6d..056955d72 100644 --- a/frontend/src/components/tuning/config/ConfigPanelHeader.tsx +++ b/frontend/src/components/tuning/config/ConfigPanelHeader.tsx @@ -6,6 +6,9 @@ interface ConfigPanelHeaderProps { isDrawingMode: boolean; isSaving: boolean; onRevertConfig: () => void; + currentDomainName: string; + componentName?: string; + isOnvifAutoConfig?: boolean; } export function ConfigPanelHeader({ @@ -13,9 +16,19 @@ export function ConfigPanelHeader({ isDrawingMode, isSaving, onRevertConfig, + currentDomainName, + componentName, + isOnvifAutoConfig, }: ConfigPanelHeaderProps) { const theme = useTheme(); + // Hide reset button for ONVIF components (except client) when auto_config is true + const shouldShowResetButton = !( + currentDomainName === "onvif" && + componentName !== "client" && + isOnvifAutoConfig + ); + return ( Tuning Config - + {shouldShowResetButton && ( + + )} ); } diff --git a/frontend/src/components/tuning/config/miscellaneousConfig.ts b/frontend/src/components/tuning/config/miscellaneousConfig.ts index 8fdcd3504..d04fce8c6 100644 --- a/frontend/src/components/tuning/config/miscellaneousConfig.ts +++ b/frontend/src/components/tuning/config/miscellaneousConfig.ts @@ -1,6 +1,7 @@ import { CAMERA_MISCELLANEOUS_CONFIG } from "../camera/miscellaneousConfig"; import { MOTION_DETECTOR_MISCELLANEOUS_CONFIG } from "../motion_detector/miscellaneousConfig"; import { OBJECT_DETECTOR_MISCELLANEOUS_CONFIG } from "../object_detector/miscellaneousConfig"; +import { ONVIF_MISCELLANEOUS_CONFIG } from "../onvif/miscellaneousConfig"; import { MiscellaneousField } from "./MiscellaneousSection"; /** @@ -47,6 +48,7 @@ export const MISCELLANEOUS_CONFIG: { camera: CAMERA_MISCELLANEOUS_CONFIG, object_detector: OBJECT_DETECTOR_MISCELLANEOUS_CONFIG, motion_detector: MOTION_DETECTOR_MISCELLANEOUS_CONFIG, + onvif: ONVIF_MISCELLANEOUS_CONFIG, }; /** diff --git a/frontend/src/components/tuning/onvif/miscellaneousConfig.ts b/frontend/src/components/tuning/onvif/miscellaneousConfig.ts new file mode 100644 index 000000000..41cd91cff --- /dev/null +++ b/frontend/src/components/tuning/onvif/miscellaneousConfig.ts @@ -0,0 +1,267 @@ +/** + * Miscellaneous field configuration for onvif component + * + * ONVIF is treated as a domain with the following components: + * - client: Connection settings (port, username, password, etc.) + * - device: Device service configuration + * - imaging: Imaging service configuration + * - media: Media service configuration + * - ptz: PTZ service configuration + * + * Define editable fields that will appear in the Miscellaneous section. + * These fields are component-specific within the onvif domain. + */ + +export interface MiscellaneousFieldConfig { + key: string; + label: string; + description?: string; + type: "string" | "integer" | "float" | "boolean" | "enum"; + default?: any; + lowest?: number; + highest?: number; + options?: string[]; +} + +/** + * Configuration for onvif domain + * Use component name as key (client, device, imaging, media, ptz) + */ +export const ONVIF_MISCELLANEOUS_CONFIG: { + [componentType: string]: MiscellaneousFieldConfig[]; +} = { + // Client component - connection/client settings + client: [ + { + key: "port", + label: "Port", + description: "ONVIF port of the camera.", + type: "integer", + }, + { + key: "username", + label: "Username", + description: "ONVIF username for the camera.", + type: "string", + }, + { + key: "password", + label: "Password", + description: "ONVIF password for the camera.", + type: "string", + }, + { + key: "timeout", + label: "Timeout", + description: "Timeout for ONVIF connections in seconds.", + type: "integer", + default: 10, + }, + { + key: "use_https", + label: "Use HTTPS", + description: "Use HTTPS for ONVIF connections.", + type: "boolean", + default: false, + }, + { + key: "verify_ssl", + label: "Verify SSL", + description: "Verify SSL certificates for ONVIF connections.", + type: "boolean", + default: true, + }, + { + key: "wsdl_dir", + label: "WSDL Directory", + description: "Path to custom WSDL directory for ONVIF client.", + type: "string", + }, + { + key: "auto_config", + label: "Auto Config", + description: + "Set to true then it will ignore all configuration per each service and use the default service that is already on the ONVIF camera. Don't worry! This ONVIF component will automatically detect the existing configuration in the ONVIF camera precisely.", + type: "boolean", + default: true, + }, + ], + // Device component - only appears if auto_config is false + device: [ + { + key: "hostname", + label: "Hostname", + description: "The hostname of the device.", + type: "string", + }, + { + key: "discoverable", + label: "Discoverable", + description: + "Whether the device is discoverable on the network via WS-Discovery.", + type: "boolean", + }, + { + key: "datetime_type", + label: "DateTime Type", + description: "Defines if the date and time is set via NTP or manually.", + type: "enum", + options: ["NTP", "Manual"], + }, + { + key: "daylight_savings", + label: "Daylight Savings", + description: "Indicates whether Daylight Savings Time is in effect.", + type: "boolean", + }, + { + key: "timezone", + label: "Timezone", + description: + "The time zone in POSIX 1003.1 format. Will be ignored if the datetime_type key is set to NTP.", + type: "string", + }, + { + key: "ntp_from_dhcp", + label: "NTP from DHCP", + description: + "Indicate if NTP address information is to be retrieved using DHCP.", + type: "boolean", + }, + { + key: "ntp_type", + label: "NTP Type", + description: + "Network host type: IPv4, IPv6 or DNS. Will be ignored if the ntp_from_dhcp key is set to true.", + type: "enum", + options: ["DNS", "IPv4", "IPv6"], + }, + { + key: "ntp_server", + label: "NTP Server", + description: + "The NTP server of the device, for example: pool.ntp.org or time.google.com or 192.168.1.1 (must match with ntp_type). Will be ignored if the ntp_from_dhcp key is set to true.", + type: "string", + }, + ], + // Media component - only appears if auto_config is false + media: [], + // Imaging component - only appears if auto_config is false + imaging: [ + { + key: "force_persistence", + label: "Force Persistence", + description: + "To determine whether this setting will persist even after a device reboot.", + default: true, + type: "boolean", + }, + { + key: "brightness", + label: "Brightness", + description: "Brightness of the image (unit unspecified).", + type: "float", + }, + { + key: "color_saturation", + label: "Color Saturation", + description: "Color saturation of the image (unit unspecified).", + type: "float", + }, + { + key: "contrast", + label: "Contrast", + description: "Contrast of the image (unit unspecified).", + type: "float", + }, + { + key: "sharpness", + label: "Sharpness", + description: "Sharpness of the image (unit unspecified).", + type: "float", + }, + { + key: "ircut_filter", + label: "Infrared Cut Filter", + description: "Infrared Cutoff Filter settings.", + type: "enum", + options: ["ON", "OFF", "AUTO"], + }, + { + key: "backlight_compensation", + label: "Backlight Compensation", + description: "Enabled/disabled Backlight Compensation mode (on/off).", + type: "enum", + options: ["ON", "OFF"], + }, + ], + // PTZ component - only appears if auto_config is false + ptz: [ + { + key: "home_position", + label: "Home Position", + description: + "Move camera to home position on startup (if supported by camera). Will be ignored if any of the PTZ presets have the on_startup set to true.", + default: false, + type: "boolean", + }, + { + key: "reverse_pan", + label: "Reverse Pan", + description: + "Reverse the pan direction. Will be implemented in backend and frontend, and will not affect the position of user defined PTZ presets.", + default: false, + type: "boolean", + }, + { + key: "reverse_tilt", + label: "Reverse Tilt", + description: + "Reverse the tilt direction. Will be implemented in backend and frontend, and will not affect the position of user defined PTZ presets.", + default: false, + type: "boolean", + }, + { + key: "min_pan", + label: "Minimum Pan", + description: + "Minimum pan value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + type: "float", + }, + { + key: "max_pan", + label: "Maximum Pan", + description: + "Maximum pan value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + type: "float", + }, + { + key: "min_tilt", + label: "Minimum Tilt", + description: + "Minimum tilt value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + type: "float", + }, + { + key: "max_tilt", + label: "Maximum Tilt", + description: + "Maximum tilt value of the camera. A value between -1.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + type: "float", + }, + { + key: "min_zoom", + label: "Minimum Zoom", + description: + "Minimum zoom value of the camera. A value between 0.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + type: "float", + }, + { + key: "max_zoom", + label: "Maximum Zoom", + description: + "Maximum zoom value of the camera. A value between 0.0 and 1.0 (will be adjusted based on the default ONVIF configuration). Automatically handled by the backend.", + type: "float", + }, + ], +}; diff --git a/frontend/src/components/tuning/shared/useTuneHandlers.ts b/frontend/src/components/tuning/shared/useTuneHandlers.ts index 08b06ae97..2d1a5cd45 100644 --- a/frontend/src/components/tuning/shared/useTuneHandlers.ts +++ b/frontend/src/components/tuning/shared/useTuneHandlers.ts @@ -184,6 +184,9 @@ export function useTuneHandlers() { componentType = "face_recognition"; } else if (domainName === "license_plate_recognition") { componentType = "license_plate_recognition"; + } else if (domainName === "onvif") { + // For ONVIF domain, use component name as type (device, imaging, media, ptz, client) + componentType = componentName; } // Parse OSD texts and video transforms if camera component diff --git a/frontend/src/pages/Tuning.tsx b/frontend/src/pages/Tuning.tsx index e1cbcfa66..de61009b4 100644 --- a/frontend/src/pages/Tuning.tsx +++ b/frontend/src/pages/Tuning.tsx @@ -124,14 +124,16 @@ function Tunes() { tuneHandlers.setSelectedComponentData({ ...componentDataWithParsed, - componentType: domainName, + // For ONVIF domain, use componentName as componentType + // For other domains, use domainName as componentType + componentType: domainName === "onvif" ? componentName : domainName, componentName, }); // Update original data as well after successful save tuneHandlers.setOriginalComponentData({ ...componentDataWithParsed, - componentType: domainName, + componentType: domainName === "onvif" ? componentName : domainName, componentName, }); } @@ -578,6 +580,7 @@ function Tunes() { } isConfigModified={tuneHandlers.isConfigModified} isSaving={updateTuneConfig.isPending} + cameraIdentifier={camera_identifier} onLabelClick={(index) => { if ( tuneHandlers.selectedComponentData?.componentType === @@ -643,6 +646,13 @@ function Tunes() { onDeleteVideoTransform={tuneHandlers.handleDeleteVideoTransform} onMiscellaneousFieldChange={handleMiscellaneousFieldChange} currentDomainName={getCurrentDomainName()} + isOnvifAutoConfig={ + // Use selectedComponentData if viewing client component (for live updates), + // otherwise use tuneConfig.data + expandedComponent === "onvif-client" + ? tuneHandlers.selectedComponentData?.auto_config === true + : tuneConfig.data?.onvif?.client?.auto_config === true + } /> From e02317ff891ad0fb119d8b27adb6eddf3520ec1d Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 06:36:52 +0700 Subject: [PATCH 048/120] style(ui): Several style improvements for camera tuning page --- .../tuning/config/ConfigPanelContextMenu.tsx | 4 +-- .../tuning/config/MiscellaneousSection.tsx | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/tuning/config/ConfigPanelContextMenu.tsx b/frontend/src/components/tuning/config/ConfigPanelContextMenu.tsx index acb18ba23..dca60d451 100644 --- a/frontend/src/components/tuning/config/ConfigPanelContextMenu.tsx +++ b/frontend/src/components/tuning/config/ConfigPanelContextMenu.tsx @@ -98,8 +98,8 @@ export function ConfigPanelContextMenu({ Edit Transform )} - - + + Delete diff --git a/frontend/src/components/tuning/config/MiscellaneousSection.tsx b/frontend/src/components/tuning/config/MiscellaneousSection.tsx index b5fda776b..4a49c17f1 100644 --- a/frontend/src/components/tuning/config/MiscellaneousSection.tsx +++ b/frontend/src/components/tuning/config/MiscellaneousSection.tsx @@ -1,4 +1,4 @@ -import { InfoOutlined } from "@mui/icons-material"; +import { Help, Information } from "@carbon/icons-react"; import { Box, MenuItem, @@ -56,7 +56,7 @@ export function MiscellaneousSection({ {field.description && ( - + )} @@ -118,7 +118,7 @@ export function MiscellaneousSection({ onChange={(e) => onFieldChange(field.key, e.target.value)} disabled={isDrawingMode || isSaving} size="small" - sx={{ width: "90px" }} + sx={{ width: "120px" }} > {field.options?.map((option) => ( @@ -147,13 +147,21 @@ export function MiscellaneousSection({ return ( - Configurations + + Configurations + + + + {fields.map((field) => renderField(field))} From 565f882846d2de726c2e73b2bc231bca428391c7 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 06:39:00 +0700 Subject: [PATCH 049/120] feat(ui/tuning): Add QueryWrapper component for standardized query state handling --- .../components/tuning/config/QueryWrapper.tsx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 frontend/src/components/tuning/config/QueryWrapper.tsx diff --git a/frontend/src/components/tuning/config/QueryWrapper.tsx b/frontend/src/components/tuning/config/QueryWrapper.tsx new file mode 100644 index 000000000..2b12234a1 --- /dev/null +++ b/frontend/src/components/tuning/config/QueryWrapper.tsx @@ -0,0 +1,105 @@ +import { Alert, Box, LinearProgress, Typography } from "@mui/material"; +import { ReactNode } from "react"; + +interface QueryWrapperProps { + isLoading: boolean; + isError: boolean; + errorMessage?: string | null; + isEmpty?: boolean; + emptyMessage?: string; + showLoadingIndicator?: boolean; + showErrorAlert?: boolean; + showEmptyAlert?: boolean; + loadingProgress?: number; + title?: string; + children: ReactNode; +} + +/** + * A wrapper component for handling query states (loading, error, empty). + * Use this to standardize loading/error/empty handling across components. + * + * @param isLoading - Whether the query is loading + * @param isError - Whether the query has an error + * @param errorMessage - Custom error message to display + * @param isEmpty - Whether the data is empty + * @param emptyMessage - Custom empty message to display + * @param showLoadingIndicator - Show loading progress bar (default: true) + * @param showErrorAlert - Show error alert (default: true) + * @param showEmptyAlert - Show empty alert (default: false, returns null instead) + * @param loadingProgress - Progress value (0-100) for determinate mode (optional) + * @param title - Section title to always display (optional) + * @param children - Content to render when data is available + */ +export function QueryWrapper({ + isLoading, + isError, + errorMessage, + isEmpty = false, + emptyMessage = "No data available", + showLoadingIndicator = true, + showErrorAlert = true, + showEmptyAlert = false, + loadingProgress, + title, + children, +}: QueryWrapperProps) { + const titleElement = title ? ( + + {title} + + ) : null; + + if (isLoading) { + if (!showLoadingIndicator) { + return null; + } + return ( + + {titleElement} + + + ); + } + + if (isError) { + if (!showErrorAlert) { + return null; + } + return ( + + {titleElement} + + {errorMessage || "Failed to load data"} + + + ); + } + + if (isEmpty) { + if (!showEmptyAlert) { + return null; + } + return ( + + {titleElement} + + {emptyMessage} + + + ); + } + + return children; +} From 8c395af1574cfb0bdf9152212cc446a89d943780 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 19:54:19 +0700 Subject: [PATCH 050/120] fix(ui): Resolve git conflict in CustomControls --- .../src/components/player/CustomControls.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/player/CustomControls.tsx b/frontend/src/components/player/CustomControls.tsx index 4d0aa1523..9d5e35ea8 100644 --- a/frontend/src/components/player/CustomControls.tsx +++ b/frontend/src/components/player/CustomControls.tsx @@ -26,8 +26,8 @@ import Typography from "@mui/material/Typography"; import React, { useCallback, useEffect, useRef, useState } from "react"; import screenfull from "screenfull"; -import { useAuthContext } from "context/AuthContext"; import { ProgressBar } from "components/player/ProgressBar"; +import { useAuthContext } from "context/AuthContext"; import { useFullscreen } from "context/FullscreenContext"; import { isTouchDevice } from "lib/helpers"; @@ -286,22 +286,24 @@ export function CustomControls({ LIVE )} - {onManualRecording && ( - - {manualRecordingLoading ? ( - - ) : isRecording ? ( - - ) : ( - - )} - - )} + {(!user || user.role === "admin" || user.role === "write") && + onManualRecording && ( + + {manualRecordingLoading ? ( + + ) : isRecording ? ( + + ) : ( + + )} + + )} {/* Progress bar */} From 4778201010a506d1e96b231977aef37de8cedb42 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 19:56:37 +0700 Subject: [PATCH 051/120] fix(api/actions): Remove unused return in unknown_action --- viseron/components/webserver/api/v1/actions/onvif/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/viseron/components/webserver/api/v1/actions/onvif/base.py b/viseron/components/webserver/api/v1/actions/onvif/base.py index 57703fbe8..b922f0c68 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/base.py +++ b/viseron/components/webserver/api/v1/actions/onvif/base.py @@ -171,4 +171,3 @@ def unknown_action(self, action: str): status_code=HTTPStatus.BAD_REQUEST, reason=f"Unknown action: {self._service_name.upper()} {action}", ) - return From e1290e59797ac1206e71735df00a04db68ddcf2c Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 19:59:40 +0700 Subject: [PATCH 052/120] style(docs): Change icons for object_detector and image_classification domain --- docs/src/lib/iconMap.tsx | 8 ++++---- docs/src/pages/index.tsx | 8 ++++---- docs/src/types.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/src/lib/iconMap.tsx b/docs/src/lib/iconMap.tsx index 447a244ba..27639fa6b 100644 --- a/docs/src/lib/iconMap.tsx +++ b/docs/src/lib/iconMap.tsx @@ -4,8 +4,8 @@ import { Chip, ConnectionSignal, FaceActivated, - GroupObjects, - ImageReference, + WatsonHealth3DMprToggle, + VisualRecognition, Movement, Notification, Video, @@ -18,8 +18,8 @@ export const iconMap: Record = { CarFront, ConnectionSignal, FaceActivated, - GroupObjects, - ImageReference, + WatsonHealth3DMprToggle, + VisualRecognition, Movement, Notification, Chip, diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 97f101f68..ff3723469 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -6,9 +6,9 @@ import { Chip, Demo, FaceActivated, - GroupObjects, + WatsonHealth3DMprToggle, PartitionAuto, - ImageReference, + VisualRecognition, Movement, Video, } from "@carbon/icons-react"; @@ -72,7 +72,7 @@ function HomepageHeader() { to="/components-explorer?tags=object_detector" className={styles.featureItem} > - +
Object Detection
@@ -108,7 +108,7 @@ function HomepageHeader() { to="/components-explorer?tags=image_classification" className={styles.featureItem} > - +
Image Classification diff --git a/docs/src/types.ts b/docs/src/types.ts index 4d9fac4a4..fe32db27f 100644 --- a/docs/src/types.ts +++ b/docs/src/types.ts @@ -62,7 +62,7 @@ export const Domains: { [type in DomainType]: Domain } = { object_detector: { label: "Object Detector", color: "#942f5c", - icon: "GroupObjects", + icon: "WatsonHealth3DMprToggle", }, motion_detector: { @@ -74,7 +74,7 @@ export const Domains: { [type in DomainType]: Domain } = { image_classification: { label: "Image Classification", color: "#993313", - icon: "ImageReference", + icon: "VisualRecognition", }, face_recognition: { From 0a169a3099ae3976e1ccc0aa7e5c442d4d5411c9 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 20:03:42 +0700 Subject: [PATCH 053/120] style(ui/recording): Change icon for Object Detection type recording --- frontend/src/components/recording/RecordingCard.tsx | 4 ++-- frontend/src/components/recording/RecordingHeaderDaily.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/recording/RecordingCard.tsx b/frontend/src/components/recording/RecordingCard.tsx index 036e16785..c7806583a 100644 --- a/frontend/src/components/recording/RecordingCard.tsx +++ b/frontend/src/components/recording/RecordingCard.tsx @@ -1,8 +1,8 @@ import { - CenterSquare, FaceActivated, Movement, TrashCan, + WatsonHealth3DMprToggle, } from "@carbon/icons-react"; import Card from "@mui/material/Card"; import CardActions from "@mui/material/CardActions"; @@ -67,7 +67,7 @@ export default function RecordingCard({ ) : recording.trigger_type === "object" ? ( - + ) : recording.trigger_type === "face_recognition" ? ( diff --git a/frontend/src/components/recording/RecordingHeaderDaily.tsx b/frontend/src/components/recording/RecordingHeaderDaily.tsx index c1b9e8187..7a1679048 100644 --- a/frontend/src/components/recording/RecordingHeaderDaily.tsx +++ b/frontend/src/components/recording/RecordingHeaderDaily.tsx @@ -1,10 +1,10 @@ import { CarFront, - CenterSquare, DocumentVideo, FaceActivated, Movement, TrashCan, + WatsonHealth3DMprToggle, } from "@carbon/icons-react"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; @@ -178,7 +178,7 @@ export function RecordingHeaderDaily({ aria-label="object" sx={{ paddingX: 2, flex: { xs: 1, lg: "initial" } }} > - + From fcb631d287b8c5d0973a593a0670f869f0860ef3 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 18 Jan 2026 20:04:29 +0700 Subject: [PATCH 054/120] style(ui): Add label to Role select input in AddUserDialog --- frontend/src/components/settings/user/AddUserDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/settings/user/AddUserDialog.tsx b/frontend/src/components/settings/user/AddUserDialog.tsx index e4361026d..04f9d3c4d 100644 --- a/frontend/src/components/settings/user/AddUserDialog.tsx +++ b/frontend/src/components/settings/user/AddUserDialog.tsx @@ -96,6 +96,7 @@ function AddUserDialog({ onClose }: AddUserDialogProps) { Role - - + {transformType === "hflip" && "Flips video horizontally (left-right)"} {transformType === "vflip" && "Flips video vertically (top-bottom)"} {transformType === "rotate180" && "Rotates video 180 degrees"} - + From 814aeb6498ee23adfa9ea59aa0984a41a796ee66 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 1 Feb 2026 07:30:41 +0700 Subject: [PATCH 065/120] Revert ptz so that the conflict can be resolved --- viseron/components/ptz/__init__.py | 673 +++++++++++++++++++++++++++++ 1 file changed, 673 insertions(+) create mode 100644 viseron/components/ptz/__init__.py diff --git a/viseron/components/ptz/__init__.py b/viseron/components/ptz/__init__.py new file mode 100644 index 000000000..ace8ec7b9 --- /dev/null +++ b/viseron/components/ptz/__init__.py @@ -0,0 +1,673 @@ +"""PTZ interface.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +import numpy as np +import voluptuous as vol +from onvif import ONVIFClient, ONVIFOperationException + +from viseron.const import EVENT_DOMAIN_REGISTERED, VISERON_SIGNAL_STOPPING +from viseron.domains.camera import AbstractCamera +from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN +from viseron.helpers import escape_string +from viseron.helpers.logs import SensitiveInformationFilter +from viseron.helpers.validators import CameraIdentifier +from viseron.watchdog.thread_watchdog import RestartableThread + +from .const import ( + COMPONENT, + CONFIG_CAMERA_FULL_SWING_MAX_PAN, + CONFIG_CAMERA_FULL_SWING_MIN_PAN, + CONFIG_CAMERA_PASSWORD, + CONFIG_CAMERA_PORT, + CONFIG_CAMERA_USERNAME, + CONFIG_CAMERAS, + CONFIG_HOST, + CONFIG_PRESET_NAME, + CONFIG_PRESET_ON_STARTUP, + CONFIG_PRESET_PAN, + CONFIG_PRESET_TILT, + CONFIG_PRESET_ZOOM, + CONFIG_PTZ_PRESETS, + DESC_CAMERA_FULL_SWING_MAX_PAN, + DESC_CAMERA_FULL_SWING_MIN_PAN, + DESC_CAMERA_PASSWORD, + DESC_CAMERA_PORT, + DESC_CAMERA_USERNAME, + DESC_CAMERAS, + DESC_COMPONENT, + DESC_PRESET_NAME, + DESC_PRESET_ON_STARTUP, + DESC_PRESET_PAN, + DESC_PRESET_TILT, + DESC_PRESET_ZOOM, + DESC_PTZ_PRESETS, +) + +if TYPE_CHECKING: + from viseron import Event, Viseron + +LOGGER = logging.getLogger(__name__) + +PRESET = vol.Schema( + { + vol.Required(CONFIG_PRESET_NAME, description=DESC_PRESET_NAME): str, + vol.Required(CONFIG_PRESET_PAN, description=DESC_PRESET_PAN): float, + vol.Required(CONFIG_PRESET_TILT, description=DESC_PRESET_TILT): float, + vol.Optional(CONFIG_PRESET_ZOOM, description=DESC_PRESET_ZOOM): float, + vol.Optional( + CONFIG_PRESET_ON_STARTUP, description=DESC_PRESET_ON_STARTUP, default=False + ): bool, + } +) + +CAMERA_SCHEMA = vol.Schema( + { + vol.Optional(CONFIG_CAMERA_PORT, description=DESC_CAMERA_PORT, default=80): int, + vol.Required(CONFIG_CAMERA_USERNAME, description=DESC_CAMERA_USERNAME): str, + vol.Required(CONFIG_CAMERA_PASSWORD, description=DESC_CAMERA_PASSWORD): str, + vol.Optional( + CONFIG_CAMERA_FULL_SWING_MIN_PAN, + description=DESC_CAMERA_FULL_SWING_MIN_PAN, + ): float, + vol.Optional( + CONFIG_CAMERA_FULL_SWING_MAX_PAN, + description=DESC_CAMERA_FULL_SWING_MAX_PAN, + ): float, + vol.Optional(CONFIG_PTZ_PRESETS, description=DESC_PTZ_PRESETS): [PRESET], + } +) + +COMPONENT_SCHEMA = vol.Schema( + { + vol.Required(CONFIG_CAMERAS, description=DESC_CAMERAS): { + CameraIdentifier(): CAMERA_SCHEMA + }, + } +) + +CONFIG_SCHEMA = vol.Schema( + {vol.Required(COMPONENT, description=DESC_COMPONENT): COMPONENT_SCHEMA}, + extra=vol.ALLOW_EXTRA, +) + + +def setup(vis: Viseron, config) -> bool: + """Set up the ptz component.""" + ptz = PTZ(vis, config[COMPONENT]) + RestartableThread( + target=ptz.run, + name="ptz", + ).start() + return True + + +class PTZ: + """PTZ class allows control of pan/tilt/zoom (and other stuff) over Telegram.""" + + def __init__(self, vis: Viseron, config) -> None: + self._vis = vis + self._config = config + for cam_name in self._config[CONFIG_CAMERAS]: + camera = self._config[CONFIG_CAMERAS][cam_name] + if camera[CONFIG_CAMERA_PASSWORD]: + SensitiveInformationFilter.add_sensitive_string( + camera[CONFIG_CAMERA_PASSWORD] + ) + SensitiveInformationFilter.add_sensitive_string( + escape_string(camera[CONFIG_CAMERA_PASSWORD]) + ) + self._cameras: dict[str, AbstractCamera] = {} + self._onvif_cameras: dict[str, ONVIFClient] = {} + self._ptz_services: dict[str, Any] = {} + self._ptz_tokens: dict[str, str] = {} + self._stop_patrol_events: dict[str, asyncio.Event] = {} + self._register_lock: asyncio.Lock = asyncio.Lock() + self._stop_event: asyncio.Event = asyncio.Event() + vis.data[COMPONENT] = self + + def initialize(self): + """Initialize PTZ Controller.""" + self._vis.register_signal_handler(VISERON_SIGNAL_STOPPING, self.shutdown) + self._vis.listen_event( + EVENT_DOMAIN_REGISTERED.format(domain=CAMERA_DOMAIN), + self._camera_registered, + ) + + def run(self): + """Run PTZ Controller.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self._run()) + LOGGER.info("PTZ Controller done") + + async def _run(self): + """Run PTZ Controller.""" + self.initialize() + while not self._stop_event.is_set(): + await asyncio.sleep(0.1) + + def shutdown(self): + """Shutdown PTZ Controller.""" + for event in self._stop_patrol_events.values(): + event.set() + self._stop_event.set() + + def _camera_registered(self, event: Event[AbstractCamera]) -> None: + camera: AbstractCamera = event.data + if camera.identifier in self._config[CONFIG_CAMERAS]: + self._cameras.update({camera.identifier: camera}) + config = self._config[CONFIG_CAMERAS][camera.identifier] + + onvif_camera = ONVIFClient( + camera.config[CONFIG_HOST], + config[CONFIG_CAMERA_PORT], + config[CONFIG_CAMERA_USERNAME], + config[CONFIG_CAMERA_PASSWORD], + ) + self._onvif_cameras.update({camera.identifier: onvif_camera}) + self._ptz_services.update({camera.identifier: onvif_camera.ptz()}) + media_service = onvif_camera.media() + self._ptz_tokens.update( + {camera.identifier: media_service.GetProfiles()[0].token} + ) + self._stop_patrol_events.update({camera.identifier: asyncio.Event()}) + if CONFIG_PTZ_PRESETS in config: + for preset in config[CONFIG_PTZ_PRESETS]: + if preset[CONFIG_PRESET_ON_STARTUP]: + self.move_to_preset( + camera.identifier, preset[CONFIG_PRESET_NAME] + ) + + async def patrol( + self, + camera_identifier: str, + duration: int = 60, + sleep_after_swing: int = 6, + step_size: float = 0.1, + step_sleep_time: float = 0.1, + ) -> None: + """Perform a patrol of the camera.""" + stop_event = self._stop_patrol_events.get(camera_identifier) + if stop_event is not None: + stop_event.clear() + await self._fire_and_forget( + self._do_patrol, + duration, + camera_identifier=camera_identifier, + sleep_after_swing=sleep_after_swing, + step_size=step_size, + step_sleep_time=step_sleep_time, + ) + + async def _fire_and_forget(self, coro, timeout, *args, **kwargs): + """Fire and forget a coroutine with a timeout.""" + coro_task = asyncio.create_task(coro(*args, **kwargs)) + # If a timeout is given, create a task to cancel the coroutine after the timeout + if timeout > 0: + asyncio.create_task(self._timeout_task(coro_task, timeout)) + + async def _timeout_task(self, task, timeout): + """Cancel a task after a set amount of time if given.""" + await asyncio.sleep(timeout) + if not task.done(): + task.cancel() + + async def _do_patrol( + self, + camera_identifier: str, + step_size: float = 0.1, + step_sleep_time: float = 0.1, + sleep_after_swing=6, + ): + """ + Perform a patrol of the camera. + + Swings the camera from left to right and back, etc. within the camera's limits, + either by design or configuration (see minx_x, max_x). + + @param step_size: The size of each move step + @param step_sleep_time: Time to sleep between each move step + @param sleep_after_swing: Time to pause after each swing + """ + try: + + ptz_service = self._ptz_services.get(camera_identifier) + if ptz_service is None: + LOGGER.error(f"No PTZ service for camera {camera_identifier}") + return + + # Get and store starting position + status = ptz_service.GetStatus( + ProfileToken=self._ptz_tokens.get(camera_identifier) + ) + if status is None: + LOGGER.warning("Cannot determine starting position") + initial_pan = 0.0 + initial_tilt = 0.0 + else: + initial_pan = status.Position.PanTilt.x + initial_tilt = status.Position.PanTilt.y + LOGGER.debug( + f"Camera position at start: x: {initial_pan}, y: {initial_tilt}" + ) + + # Get the camera's FOV limits, if any. + cam = self._cameras.get(camera_identifier) + if cam is None: + LOGGER.error(f"No camera found for {camera_identifier}") + return + + min_pan = cam.config.get(CONFIG_CAMERA_FULL_SWING_MIN_PAN) + max_pan = cam.config.get(CONFIG_CAMERA_FULL_SWING_MAX_PAN) + + # Decide which direction to start swinging based on the distance to the + # camera's FOV limits, left if closer to min_pan, right if closer to max_pan + distance_to_min = initial_pan - min_pan if min_pan else 0 + distance_to_max = max_pan - initial_pan if max_pan else 0 + left = distance_to_min > distance_to_max + + # Swing back and forth until stopped + stop_patrol_event = self._stop_patrol_events.get(camera_identifier) + if stop_patrol_event is None: + stop_patrol_event = asyncio.Event() + self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) + + while not stop_patrol_event.is_set(): + await self.full_swing( + camera_identifier=camera_identifier, + is_left=left, + step_size=step_size, + step_sleep_time=step_sleep_time, + min_pan=min_pan, + max_pan=max_pan, + ) + if stop_patrol_event.is_set(): + break + await asyncio.sleep(sleep_after_swing) + left = not left + + finally: + # Move back to the initial position + self.absolute_move( + camera_identifier=camera_identifier, pan=initial_pan, tilt=initial_tilt + ) + + def stop_patrol(self, camera_identifier: str) -> None: + """Stop the patrol.""" + event = self._stop_patrol_events.get(camera_identifier) + if event: + event.set() + + async def lissajous_curve_patrol( + self, + camera_identifier: str, + pan_amp: float = 1.0, + pan_freq: float = 0.1, + tilt_amp: float = 1.0, + tilt_freq: float = 0.1, + phase_shift: float = np.pi / 2, + step_sleep_time: float = 0.1, + ): + """Perform a Lissajous curve patrol.""" + + stop_patrol_event = self._stop_patrol_events.get(camera_identifier) + if stop_patrol_event is None: + LOGGER.error(f"No patrol stop event for camera {camera_identifier}") + return False + + # stop currently running patrol + if not stop_patrol_event.is_set(): + stop_patrol_event.set() + await asyncio.sleep(2.0) + stop_patrol_event.clear() + + # start a new patrol + await self._fire_and_forget( + coro=self._do_lissa_curve_patrol, + timeout=0, + camera_identifier=camera_identifier, + pan_amp=pan_amp, + pan_freq=pan_freq, + tilt_amp=tilt_amp, + tilt_freq=tilt_freq, + phase_shift=phase_shift, + step_sleep_time=step_sleep_time, + ) + + async def _do_lissa_curve_patrol( + self, + camera_identifier: str, + pan_amp: float = 1.0, + pan_freq: float = 0.1, + tilt_amp: float = 1.0, + tilt_freq: float = 0.1, + phase_shift: float = np.pi / 2, + step_sleep_time: float = 0.1, + pan_range: tuple = (-1.0, 1.0), + tilt_range: tuple = (-1.0, 1.0), + ): + """Perform a Lissajous curve patrol.""" + stop_patrol_event = self._stop_patrol_events.get(camera_identifier) + if stop_patrol_event is None: + stop_patrol_event = asyncio.Event() + self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) + + pan_min, pan_max = pan_range + tilt_min, tilt_max = tilt_range + + t = 0.0 + while not stop_patrol_event.is_set(): + t += 1.0 + x = pan_amp * np.sin(pan_freq * t + phase_shift) + y = tilt_amp * np.sin(tilt_freq * t) + + # Scale x and y to the specified pan and tilt ranges + x = pan_min + (x + 1) * (pan_max - pan_min) / 2 + y = tilt_min + (y + 1) * (tilt_max - tilt_min) / 2 + + await self.absolute_move_wait_complete( + camera_identifier=camera_identifier, pan=x, tilt=y + ) + await asyncio.sleep(step_sleep_time) + + async def full_swing( + self, + camera_identifier: str, + is_left: bool = True, + step_size: float = 0.1, + step_sleep_time: float = 0.1, + min_pan: float | None = None, + max_pan: float | None = None, + ): + """Perform a full swing in the pan direction. + + @param is_left: True if the swing is to the left, False if to the right + @param step_size: The size of each move step + @param sleep_time: Time to sleep between each move step + @param min_pan: Minimum pan value to stop at, meant to be used to avoid + going beyond the camera's limits or field of view + @param max_pan: Maximum pan value to stop at + + """ + ptz_service = self._ptz_services.get(camera_identifier) + if ptz_service is None: + LOGGER.error(f"No PTZ service for camera {camera_identifier}") + return + + cur_pan, _ = self.get_position(camera_identifier) + # Get and store starting position + LOGGER.debug(f"Fullswing start: pan: {cur_pan}, min: {min_pan}, max: {max_pan}") + + move_step = -abs(step_size) if is_left else abs(step_size) + + # Do not move beyond the camera's FOV bounds + if is_left: + if min_pan is not None and cur_pan + move_step <= min_pan: + return + else: + if max_pan is not None and cur_pan + move_step >= max_pan: + return + + # Move while not stopped or stopped by the camera's FOV or hardware bounds + # Unsure how this will react to 360 (or more?) degree cameras + stop_patrol_event = self._stop_patrol_events.get(camera_identifier) + if stop_patrol_event is None: + stop_patrol_event = asyncio.Event() + self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) + + while ( + self.relative_move( + camera_identifier=camera_identifier, pan=move_step, tilt=0.0 + ) + and not stop_patrol_event.is_set() + ): + await asyncio.sleep(step_sleep_time) + cur_pan, _ = self.get_position(camera_identifier) + LOGGER.debug( + f"Fullswing moved to: pan: {cur_pan}, min: {min_pan}, max: {max_pan}" + ) + if min_pan is not None and cur_pan <= min_pan: + break + if max_pan is not None and cur_pan >= max_pan: + break + + LOGGER.debug(f"Fullswing end: pan: {cur_pan}, min: {min_pan}, max: {max_pan}") + + def relative_move(self, camera_identifier: str, pan: float, tilt: float) -> bool: + """ + Move the camera relative to its current position. + + @param x: The relative x position to move to + @param y: The relative y position to move to + @return: True if the move was successful, False otherwise + """ + ptz_service = self._ptz_services.get(camera_identifier) + if ptz_service is None: + LOGGER.error(f"No PTZ service for camera {camera_identifier}") + return False + + try: + ptz_service.RelativeMove( + ProfileToken=self._ptz_tokens.get(camera_identifier), + Translation={ + "PanTilt": {"x": pan, "y": tilt}, + "Zoom": {"x": 0.0}, + }, + ) + return True + except ONVIFOperationException as e: + LOGGER.warning(f"ONVIF error in RelativeMove (usually harmless): {e}") + return False + + def zoom(self, camera_identifier: str, zoom: float = 0.1) -> bool: + """Zoom the camera in our out.""" + ptz_service = self._ptz_services.get(camera_identifier) + if ptz_service is None: + LOGGER.error(f"No PTZ service for camera {camera_identifier}") + return False + + try: + ptz_service.RelativeMove( + ProfileToken=self._ptz_tokens.get(camera_identifier), + Translation={ + "PanTilt": {"x": 0.0, "y": 0.0}, + "Zoom": {"x": zoom}, + }, + ) + return True + except ONVIFOperationException as e: + # errors occur when the zoom exceeds the camera's limits?, silence them + # can't check, camera does not support zoom + LOGGER.warning(f"ONVIF error in Zoom (usually harmless): {e}") + return False + + def absolute_move(self, camera_identifier: str, pan: float, tilt: float) -> bool: + """Move the camera to an absolute position.""" + ptz_service = self._ptz_services.get(camera_identifier) + if ptz_service is None: + LOGGER.error(f"No PTZ service for camera {camera_identifier}") + return False + try: + ptz_service.AbsoluteMove( + ProfileToken=self._ptz_tokens.get(camera_identifier), + Position={ + "PanTilt": {"x": pan, "y": tilt}, + }, + ) + return True + except ONVIFOperationException as e: + LOGGER.warning(f"ONVIF error in AbsoluteMove (usually harmless): {e}") + return False + + async def absolute_move_wait_complete( + self, camera_identifier: str, pan: float, tilt: float, timeout: float = 30.0 + ) -> bool: + """Move the camera to an absolute position and wait for the move to complete.""" + if self.absolute_move(camera_identifier=camera_identifier, pan=pan, tilt=tilt): + # get the camera position and wait until it reaches the desired position to + # a tolerance of 0.005, or until the timeout is reached + tolerance = 0.005 + start_time = asyncio.get_event_loop().time() + while ( + abs(self.get_position(camera_identifier)[0] - pan) > tolerance + or abs(self.get_position(camera_identifier)[1] - tilt) > tolerance + ) and (asyncio.get_event_loop().time() - start_time < timeout): + await asyncio.sleep(0.1) + LOGGER.info( + "Position at end of abs move and wait (requested: %s): %s", + (pan, tilt), + self.get_position(camera_identifier), + ) + return True + return False + + async def continuous_move( + self, + camera_identifier: str, + x_velocity: float, + y_velocity: float, + seconds: float, + ): + """Move the camera continuously for a set amount of time.""" + ptz_service = self._ptz_services.get(camera_identifier) + if ptz_service is None: + LOGGER.error(f"No PTZ service for camera {camera_identifier}") + return False + try: + ptz_service.ContinuousMove( + ProfileToken=self._ptz_tokens.get(camera_identifier), + Velocity={ + "PanTilt": {"x": x_velocity, "y": y_velocity}, + "Zoom": {"x": 0.0}, + }, + ) + await asyncio.sleep(seconds) + ptz_service.Stop({"ProfileToken": self._ptz_tokens.get(camera_identifier)}) + except ONVIFOperationException as e: + LOGGER.warning(f"ONVIF error in ContinuousMove (usually harmless): {e}") + + def pan_left(self, camera_identifier: str, step_size: float = 0.1) -> bool: + """Pan the camera to the left.""" + return self.relative_move( + camera_identifier=camera_identifier, pan=-step_size, tilt=0.0 + ) + + def pan_right(self, camera_identifier: str, step_size: float = 0.1) -> bool: + """Pan the camera to the right.""" + return self.relative_move( + camera_identifier=camera_identifier, pan=step_size, tilt=0.0 + ) + + def tilt_up(self, camera_identifier: str, step_size: float = 0.1) -> bool: + """Tilt the camera up.""" + return self.relative_move( + camera_identifier=camera_identifier, pan=0.0, tilt=step_size + ) + + def tilt_down(self, camera_identifier: str, step_size: float = 0.1) -> bool: + """Tilt the camera down.""" + return self.relative_move( + camera_identifier=camera_identifier, pan=0.0, tilt=-step_size + ) + + def zoom_out(self, camera_identifier: str, step_size: float = 0.1) -> bool: + """Zoom the camera out.""" + return self.zoom(camera_identifier=camera_identifier, zoom=-step_size) + + def zoom_in(self, camera_identifier: str, step_size: float = 0.1) -> bool: + """Zoom the camera in.""" + return self.zoom(camera_identifier=camera_identifier, zoom=step_size) + + def get_position(self, camera_identifier: str) -> tuple[float, float]: + """Get the current position of the camera.""" + ptz_service = self._ptz_services.get(camera_identifier) + if ptz_service is None: + LOGGER.error(f"No PTZ service for camera {camera_identifier}") + return 0.0, 0.0 + try: + status = ptz_service.GetStatus( + ProfileToken=self._ptz_tokens.get(camera_identifier) + ) + return status.Position.PanTilt.x, status.Position.PanTilt.y + except ONVIFOperationException as e: + LOGGER.warning(f"ONVIF error in GetStatus (usually harmless): {e}") + return -255.0, -255.0 + + def get_presets(self, camera_identifier: str) -> list[str]: + """Get the available presets for the camera.""" + if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: + LOGGER.error(f"No PTZ presets for camera {camera_identifier}") + return [] + presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] + return list({preset[CONFIG_PRESET_NAME] for preset in presets}) + + def move_to_preset(self, camera_identifier: str, preset_name: str) -> bool: + """Move the camera to a preset position.""" + if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: + LOGGER.error(f"No PTZ presets for camera {camera_identifier}") + return False + + if not any( + preset[CONFIG_PRESET_NAME] == preset_name + for preset in self._config[CONFIG_CAMERAS][camera_identifier][ + CONFIG_PTZ_PRESETS + ] + ): + LOGGER.error( + f"Preset {preset_name} not found for camera {camera_identifier}" + ) + return False + + presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] + for preset in presets: + if preset[CONFIG_PRESET_NAME] == preset_name: + self.absolute_move( + camera_identifier=camera_identifier, + pan=preset[CONFIG_PRESET_PAN], + tilt=preset[CONFIG_PRESET_TILT], + ) + if CONFIG_PRESET_ZOOM in preset: + self.zoom( + camera_identifier=camera_identifier, + zoom=preset[CONFIG_PRESET_ZOOM], + ) + return True + + async def move_to_preset_wait_complete( + self, camera_identifier: str, preset_name: str + ) -> bool: + """Move the camera to a preset position.""" + if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: + LOGGER.error(f"No PTZ presets for camera {camera_identifier}") + return False + + presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] + + if not presets: + LOGGER.error(f"No PTZ presets for camera {camera_identifier}") + return False + + if not any(preset[CONFIG_PRESET_NAME] == preset_name for preset in presets): + LOGGER.error( + f"Preset {preset_name} not found for camera {camera_identifier}" + ) + return False + + for preset in presets: + if preset[CONFIG_PRESET_NAME] == preset_name: + await self.absolute_move_wait_complete( + camera_identifier=camera_identifier, + pan=preset[CONFIG_PRESET_PAN], + tilt=preset[CONFIG_PRESET_TILT], + ) + if CONFIG_PRESET_ZOOM in preset: + self.zoom( + camera_identifier=camera_identifier, + zoom=preset[CONFIG_PRESET_ZOOM], + ) + return True From 7d29f001ffe52202c060160c93d24e6153c3bec9 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 1 Feb 2026 08:45:37 +0700 Subject: [PATCH 066/120] wip: Miscellaneous fix/refactor for ONVIF component and Actions API --- viseron/components/onvif/__init__.py | 38 +- viseron/components/onvif/const.py | 17 +- viseron/components/onvif/device.py | 81 ++- viseron/components/onvif/imaging.py | 15 + viseron/components/onvif/media.py | 4 +- viseron/components/ptz/__init__.py | 676 ------------------ .../webserver/api/v1/actions/onvif/device.py | 7 +- .../webserver/api/v1/actions/onvif/imaging.py | 23 +- .../webserver/api/v1/actions/onvif/media.py | 10 +- .../webserver/api/v1/actions/onvif/ptz.py | 10 +- 10 files changed, 138 insertions(+), 743 deletions(-) delete mode 100644 viseron/components/ptz/__init__.py diff --git a/viseron/components/onvif/__init__.py b/viseron/components/onvif/__init__.py index a404cb971..43b78c8e0 100644 --- a/viseron/components/onvif/__init__.py +++ b/viseron/components/onvif/__init__.py @@ -32,6 +32,7 @@ CONFIG_DEVICE_DISCOVERABLE, CONFIG_DEVICE_HOSTNAME, CONFIG_DEVICE_NTP_FROM_DHCP, + CONFIG_DEVICE_NTP_MANUAL, CONFIG_DEVICE_NTP_SERVER, CONFIG_DEVICE_NTP_TYPE, CONFIG_DEVICE_TIMEZONE, @@ -115,6 +116,7 @@ DESC_DEVICE_DISCOVERABLE, DESC_DEVICE_HOSTNAME, DESC_DEVICE_NTP_FROM_DHCP, + DESC_DEVICE_NTP_MANUAL, DESC_DEVICE_NTP_SERVER, DESC_DEVICE_NTP_TYPE, DESC_DEVICE_TIMEZONE, @@ -189,9 +191,24 @@ if TYPE_CHECKING: from viseron import Event, Viseron + from viseron.domain_registry import EventDomainRegisteredData LOGGER = logging.getLogger(__name__) +# NTP Server Schema +NTP_SERVER_SCHEMA = vol.Schema( + { + vol.Optional( + CONFIG_DEVICE_NTP_TYPE, + description=DESC_DEVICE_NTP_TYPE, + ): vol.In(DEVICE_NTP_TYPE_MAP), + vol.Optional( + CONFIG_DEVICE_NTP_SERVER, + description=DESC_DEVICE_NTP_SERVER, + ): str, + } +) + # Device Service Schema DEVICE_SCHEMA = vol.Schema( { @@ -219,14 +236,9 @@ CONFIG_DEVICE_NTP_FROM_DHCP, description=DESC_DEVICE_NTP_FROM_DHCP, ): bool, - vol.Optional( - CONFIG_DEVICE_NTP_TYPE, - description=DESC_DEVICE_NTP_TYPE, - ): vol.In(DEVICE_NTP_TYPE_MAP), - vol.Optional( - CONFIG_DEVICE_NTP_SERVER, - description=DESC_DEVICE_NTP_SERVER, - ): str, + vol.Optional(CONFIG_DEVICE_NTP_MANUAL, description=DESC_DEVICE_NTP_MANUAL): [ + NTP_SERVER_SCHEMA + ], } ) @@ -578,9 +590,11 @@ def shutdown(self): ptz_service.stop_patrol() self._stop_event.set() - def _camera_registered(self, event: Event[AbstractCamera]) -> None: + def _camera_registered( + self, event: Event[EventDomainRegisteredData[AbstractCamera]] + ) -> None: """Handle camera registration event.""" - camera: AbstractCamera = event.data + camera = event.data.instance LOGGER.debug(f"Camera registered event received for {camera.identifier}") if camera.identifier in self._config[CONFIG_CAMERAS]: @@ -699,7 +713,9 @@ async def init_ptz(): self._ptz_services[camera.identifier] = ptz_service # Inject PTZ support into camera # pylint: disable=protected-access - camera._ptz_support = COMPONENT + camera._ptz_support = ( + COMPONENT + "+auto" if auto_config else COMPONENT + "+manual" + ) LOGGER.debug(f"Initialized PTZ service for {camera.identifier}") except Exception as error: # pylint: disable=broad-exception-caught LOGGER.error( diff --git a/viseron/components/onvif/const.py b/viseron/components/onvif/const.py index 9346c0b22..f0f6d2210 100644 --- a/viseron/components/onvif/const.py +++ b/viseron/components/onvif/const.py @@ -21,7 +21,7 @@ DEFAULT_ONVIF_TIMEOUT = 10 DEFAULT_ONVIF_USE_HTTPS = False -DEFAULT_ONVIF_VERIFY_SSL = True +DEFAULT_ONVIF_VERIFY_SSL = False DEFAULT_ONVIF_AUTO_CONFIG = True DESC_CAMERAS = "List of ONVIF cameras to make available to the component." @@ -56,6 +56,7 @@ CONFIG_DEVICE_DAYLIGHT_SAVINGS = "daylight_savings" CONFIG_DEVICE_TIMEZONE = "timezone" CONFIG_DEVICE_NTP_FROM_DHCP = "ntp_from_dhcp" +CONFIG_DEVICE_NTP_MANUAL = "ntp_manual" CONFIG_DEVICE_NTP_TYPE = "ntp_type" DEVICE_NTP_TYPE_MAP = ["DNS", "IPv4", "IPv6"] CONFIG_DEVICE_NTP_SERVER = "ntp_server" @@ -68,22 +69,16 @@ DESC_DEVICE_DATETIME_TYPE = "Defines if the date and time is set via NTP or manually." DESC_DEVICE_DAYLIGHT_SAVINGS = "Indicates whether Daylight Savings Time is in effect." -DESC_DEVICE_TIMEZONE = ( - "The time zone in POSIX 1003.1 format. Will be ignored if the " - "datetime_type key is set to NTP." -) +DESC_DEVICE_TIMEZONE = "The time zone in POSIX 1003.1 format." DESC_DEVICE_NTP_FROM_DHCP = ( "Indicate if NTP address information is to be retrieved using DHCP." ) -DESC_DEVICE_NTP_TYPE = ( - "Network host type: IPv4, IPv6 or DNS. Will be ignored if the " - "ntp_from_dhcp key is set to true. " -) +DESC_DEVICE_NTP_MANUAL = "List of manual NTP Servers settings." +DESC_DEVICE_NTP_TYPE = "Network host type: IPv4, IPv6 or DNS." DESC_DEVICE_NTP_SERVER = ( "The NTP server of the device, for example: pool.ntp.org or " "time.google.com or 192.168.1.1 (must match with " - "ntp_type). Will be ignored if the ntp_from_dhcp " - "key is set to true. " + "ntp_type)." ) # ONVIF IMAGING CONFIG diff --git a/viseron/components/onvif/device.py b/viseron/components/onvif/device.py index 1c457a0fe..6ba52ab0a 100644 --- a/viseron/components/onvif/device.py +++ b/viseron/components/onvif/device.py @@ -13,6 +13,7 @@ CONFIG_DEVICE_DISCOVERABLE, CONFIG_DEVICE_HOSTNAME, CONFIG_DEVICE_NTP_FROM_DHCP, + CONFIG_DEVICE_NTP_MANUAL, CONFIG_DEVICE_NTP_SERVER, CONFIG_DEVICE_NTP_TYPE, CONFIG_DEVICE_TIMEZONE, @@ -121,7 +122,7 @@ async def get_system_date_and_time(self) -> Any: async def set_system_date_and_time( self, datetime_type: str = "NTP", - daylight_savings: bool | None = None, + daylight_savings: bool = False, timezone: str | None = None, utc_datetime: dict[str, Any] | None = None, ) -> bool: @@ -200,21 +201,9 @@ async def get_ntp(self) -> Any: async def set_ntp( self, from_dhcp: bool, - ntp_type: str | None = None, - ntp_server: str | None = None, + ntp_manual: list[dict[str, Any]] | None = None, ) -> bool: """Set NTP configuration.""" - ntp_manual = None - if ntp_server and ntp_type: - match ntp_type: - case "DNS": - ntp_manual = {"Type": ntp_type, "DNSname": ntp_server} - case "IPv4": - ntp_manual = {"Type": ntp_type, "IPv4Address": ntp_server} - case "IPv6": - ntp_manual = {"Type": ntp_type, "IPv6Address": ntp_server} - case _: - return False self._onvif_device_service.SetNTP(FromDHCP=from_dhcp, NTPManual=ntp_manual) return True @@ -283,6 +272,33 @@ async def set_dns( # ## Apply Configuration at Startup ## # + def _build_ntp_manual( + self, ntp_servers: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Build NTP manual structure according to ONVIF NetworkHost schema.""" + ntp_manual = [] + + for server in ntp_servers: + network_host = {"Type": server.get(CONFIG_DEVICE_NTP_TYPE, "IPv4")} + + ntp_server = server.get(CONFIG_DEVICE_NTP_SERVER) + if not ntp_server: + ntp_manual.append(network_host) + continue + + # Map server address to appropriate field based on Type + host_type = network_host["Type"] + if host_type == "IPv4": + network_host["IPv4Address"] = ntp_server + elif host_type == "IPv6": + network_host["IPv6Address"] = ntp_server + elif host_type == "DNS": + network_host["DNSname"] = ntp_server + + ntp_manual.append(network_host) + + return ntp_manual + async def apply_config(self) -> bool: """Apply all configured device settings from config.""" try: @@ -292,18 +308,35 @@ async def apply_config(self) -> bool: if CONFIG_DEVICE_HOSTNAME in self._config: await self.set_hostname(self._config[CONFIG_DEVICE_HOSTNAME]) - ntp_server = self._config.get(CONFIG_DEVICE_NTP_SERVER) - ntp_from_dhcp = self._config.get(CONFIG_DEVICE_NTP_FROM_DHCP) - ntp_type = self._config.get(CONFIG_DEVICE_NTP_TYPE) - if ntp_server or ntp_from_dhcp is not None: - await self.set_ntp( - ntp_server=ntp_server, from_dhcp=ntp_from_dhcp, ntp_type=ntp_type + if ( + CONFIG_DEVICE_NTP_FROM_DHCP in self._config + or CONFIG_DEVICE_NTP_MANUAL in self._config + ): + from_dhcp = self._config.get(CONFIG_DEVICE_NTP_FROM_DHCP, False) + ntp_manual = None + + if CONFIG_DEVICE_NTP_MANUAL in self._config: + ntp_servers = self._config.get(CONFIG_DEVICE_NTP_MANUAL, []) + if ntp_servers: + ntp_manual = self._build_ntp_manual(ntp_servers) + + await self.set_ntp(from_dhcp=from_dhcp, ntp_manual=ntp_manual) + + if ( + CONFIG_DEVICE_DATETIME_TYPE in self._config + or CONFIG_DEVICE_DAYLIGHT_SAVINGS in self._config + or CONFIG_DEVICE_TIMEZONE in self._config + ): + datetime_type = self._config.get(CONFIG_DEVICE_DATETIME_TYPE, "NTP") + daylight_savings = self._config.get( + CONFIG_DEVICE_DAYLIGHT_SAVINGS, False + ) + timezone = ( + {"TZ": self._config.get(CONFIG_DEVICE_TIMEZONE)} + if self._config.get(CONFIG_DEVICE_TIMEZONE) + else None ) - datetime_type = self._config.get(CONFIG_DEVICE_DATETIME_TYPE) - daylight_savings = self._config.get(CONFIG_DEVICE_DAYLIGHT_SAVINGS) - timezone = self._config.get(CONFIG_DEVICE_TIMEZONE) - if datetime_type or timezone or daylight_savings is not None: await self.set_system_date_and_time( datetime_type=datetime_type, daylight_savings=daylight_savings, diff --git a/viseron/components/onvif/imaging.py b/viseron/components/onvif/imaging.py index 572ba2789..b357a211d 100644 --- a/viseron/components/onvif/imaging.py +++ b/viseron/components/onvif/imaging.py @@ -147,6 +147,8 @@ async def get_options(self) -> Any: @operation() async def get_presets(self) -> Any: """Get available imaging presets.""" + if not self._imaging_capabilities.Presets: + return False return self._onvif_imaging_service.GetPresets( VideoSourceToken=self._video_source_token ) @@ -154,6 +156,8 @@ async def get_presets(self) -> Any: @operation() async def get_current_preset(self) -> Any: """Get current imaging preset.""" + if not self._imaging_capabilities.Presets: + return False return self._onvif_imaging_service.GetCurrentPreset( VideoSourceToken=self._video_source_token ) @@ -161,6 +165,8 @@ async def get_current_preset(self) -> Any: @operation() async def set_current_preset(self, preset_token: str) -> bool: """Set current imaging preset.""" + if not self._imaging_capabilities.AdaptablePreset: + return False self._onvif_imaging_service.SetCurrentPreset( VideoSourceToken=self._video_source_token, PresetToken=preset_token, @@ -202,6 +208,15 @@ async def stop_focus(self) -> bool: # ## Derived operations ## # + # Note: + # The following methods are convenience methods that set specific imaging + # settings by calling the more general set_imaging_settings method with + # appropriate parameters. + + # Not yet used anywhere, implemented here to ease future development so that + # Viseron can directly change imaging settings per parameter without needing + # to combine all parameters. + async def set_brightness(self, force_persistence: bool, brightness: float) -> bool: """Set brightness level.""" return await self.set_imaging_settings( diff --git a/viseron/components/onvif/media.py b/viseron/components/onvif/media.py index 8db0d5310..1c31c973d 100644 --- a/viseron/components/onvif/media.py +++ b/viseron/components/onvif/media.py @@ -52,14 +52,14 @@ def __init__( self._config = config self._auto_config = auto_config self._onvif_media_service: Any = None # ONVIF Media service instance - self._imaging_capabilities: Any = None # to store Media capabilities + self._media_capabilities: Any = None # to store Media capabilities self._selected_profile: Any = None self._profiles: list[Any] = [] async def initialize(self) -> None: """Initialize the Media service.""" self._onvif_media_service = self._client.media() - self._imaging_capabilities = await self.get_service_capabilities() + self._media_capabilities = await self.get_service_capabilities() # Load media profiles self._profiles = await self.get_profiles() diff --git a/viseron/components/ptz/__init__.py b/viseron/components/ptz/__init__.py deleted file mode 100644 index b030c3f09..000000000 --- a/viseron/components/ptz/__init__.py +++ /dev/null @@ -1,676 +0,0 @@ -"""PTZ interface.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import TYPE_CHECKING, Any - -import numpy as np -import voluptuous as vol -from onvif import ONVIFClient, ONVIFOperationException - -from viseron.const import EVENT_DOMAIN_REGISTERED, VISERON_SIGNAL_STOPPING -from viseron.domains.camera import AbstractCamera -from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN -from viseron.helpers import escape_string -from viseron.helpers.logs import SensitiveInformationFilter -from viseron.helpers.validators import CameraIdentifier -from viseron.watchdog.thread_watchdog import RestartableThread - -from .const import ( - COMPONENT, - CONFIG_CAMERA_FULL_SWING_MAX_PAN, - CONFIG_CAMERA_FULL_SWING_MIN_PAN, - CONFIG_CAMERA_PASSWORD, - CONFIG_CAMERA_PORT, - CONFIG_CAMERA_USERNAME, - CONFIG_CAMERAS, - CONFIG_HOST, - CONFIG_PRESET_NAME, - CONFIG_PRESET_ON_STARTUP, - CONFIG_PRESET_PAN, - CONFIG_PRESET_TILT, - CONFIG_PRESET_ZOOM, - CONFIG_PTZ_PRESETS, - DESC_CAMERA_FULL_SWING_MAX_PAN, - DESC_CAMERA_FULL_SWING_MIN_PAN, - DESC_CAMERA_PASSWORD, - DESC_CAMERA_PORT, - DESC_CAMERA_USERNAME, - DESC_CAMERAS, - DESC_COMPONENT, - DESC_PRESET_NAME, - DESC_PRESET_ON_STARTUP, - DESC_PRESET_PAN, - DESC_PRESET_TILT, - DESC_PRESET_ZOOM, - DESC_PTZ_PRESETS, -) - -if TYPE_CHECKING: - from viseron import Event, Viseron - from viseron.domain_registry import EventDomainRegisteredData - -LOGGER = logging.getLogger(__name__) - -PRESET = vol.Schema( - { - vol.Required(CONFIG_PRESET_NAME, description=DESC_PRESET_NAME): str, - vol.Required(CONFIG_PRESET_PAN, description=DESC_PRESET_PAN): float, - vol.Required(CONFIG_PRESET_TILT, description=DESC_PRESET_TILT): float, - vol.Optional(CONFIG_PRESET_ZOOM, description=DESC_PRESET_ZOOM): float, - vol.Optional( - CONFIG_PRESET_ON_STARTUP, description=DESC_PRESET_ON_STARTUP, default=False - ): bool, - } -) - -CAMERA_SCHEMA = vol.Schema( - { - vol.Optional(CONFIG_CAMERA_PORT, description=DESC_CAMERA_PORT, default=80): int, - vol.Required(CONFIG_CAMERA_USERNAME, description=DESC_CAMERA_USERNAME): str, - vol.Required(CONFIG_CAMERA_PASSWORD, description=DESC_CAMERA_PASSWORD): str, - vol.Optional( - CONFIG_CAMERA_FULL_SWING_MIN_PAN, - description=DESC_CAMERA_FULL_SWING_MIN_PAN, - ): float, - vol.Optional( - CONFIG_CAMERA_FULL_SWING_MAX_PAN, - description=DESC_CAMERA_FULL_SWING_MAX_PAN, - ): float, - vol.Optional(CONFIG_PTZ_PRESETS, description=DESC_PTZ_PRESETS): [PRESET], - } -) - -COMPONENT_SCHEMA = vol.Schema( - { - vol.Required(CONFIG_CAMERAS, description=DESC_CAMERAS): { - CameraIdentifier(): CAMERA_SCHEMA - }, - } -) - -CONFIG_SCHEMA = vol.Schema( - {vol.Required(COMPONENT, description=DESC_COMPONENT): COMPONENT_SCHEMA}, - extra=vol.ALLOW_EXTRA, -) - - -def setup(vis: Viseron, config) -> bool: - """Set up the ptz component.""" - ptz = PTZ(vis, config[COMPONENT]) - RestartableThread( - target=ptz.run, - name="ptz", - ).start() - return True - - -class PTZ: - """PTZ class allows control of pan/tilt/zoom (and other stuff) over Telegram.""" - - def __init__(self, vis: Viseron, config) -> None: - self._vis = vis - self._config = config - for cam_name in self._config[CONFIG_CAMERAS]: - camera = self._config[CONFIG_CAMERAS][cam_name] - if camera[CONFIG_CAMERA_PASSWORD]: - SensitiveInformationFilter.add_sensitive_string( - camera[CONFIG_CAMERA_PASSWORD] - ) - SensitiveInformationFilter.add_sensitive_string( - escape_string(camera[CONFIG_CAMERA_PASSWORD]) - ) - self._cameras: dict[str, AbstractCamera] = {} - self._onvif_cameras: dict[str, ONVIFClient] = {} - self._ptz_services: dict[str, Any] = {} - self._ptz_tokens: dict[str, str] = {} - self._stop_patrol_events: dict[str, asyncio.Event] = {} - self._register_lock: asyncio.Lock = asyncio.Lock() - self._stop_event: asyncio.Event = asyncio.Event() - vis.data[COMPONENT] = self - - def initialize(self): - """Initialize PTZ Controller.""" - self._vis.register_signal_handler(VISERON_SIGNAL_STOPPING, self.shutdown) - self._vis.listen_event( - EVENT_DOMAIN_REGISTERED.format(domain=CAMERA_DOMAIN), - self._camera_registered, - ) - - def run(self): - """Run PTZ Controller.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(self._run()) - LOGGER.info("PTZ Controller done") - - async def _run(self): - """Run PTZ Controller.""" - self.initialize() - while not self._stop_event.is_set(): - await asyncio.sleep(0.1) - - def shutdown(self): - """Shutdown PTZ Controller.""" - for event in self._stop_patrol_events.values(): - event.set() - self._stop_event.set() - - def _camera_registered( - self, event_data: Event[EventDomainRegisteredData[AbstractCamera]] - ) -> None: - camera = event_data.data.instance - if camera.identifier in self._config[CONFIG_CAMERAS]: - self._cameras.update({camera.identifier: camera}) - config = self._config[CONFIG_CAMERAS][camera.identifier] - - onvif_camera = ONVIFClient( - camera.config[CONFIG_HOST], - config[CONFIG_CAMERA_PORT], - config[CONFIG_CAMERA_USERNAME], - config[CONFIG_CAMERA_PASSWORD], - ) - self._onvif_cameras.update({camera.identifier: onvif_camera}) - self._ptz_services.update({camera.identifier: onvif_camera.ptz()}) - media_service = onvif_camera.media() - self._ptz_tokens.update( - {camera.identifier: media_service.GetProfiles()[0].token} - ) - self._stop_patrol_events.update({camera.identifier: asyncio.Event()}) - if CONFIG_PTZ_PRESETS in config: - for preset in config[CONFIG_PTZ_PRESETS]: - if preset[CONFIG_PRESET_ON_STARTUP]: - self.move_to_preset( - camera.identifier, preset[CONFIG_PRESET_NAME] - ) - - async def patrol( - self, - camera_identifier: str, - duration: int = 60, - sleep_after_swing: int = 6, - step_size: float = 0.1, - step_sleep_time: float = 0.1, - ) -> None: - """Perform a patrol of the camera.""" - stop_event = self._stop_patrol_events.get(camera_identifier) - if stop_event is not None: - stop_event.clear() - await self._fire_and_forget( - self._do_patrol, - duration, - camera_identifier=camera_identifier, - sleep_after_swing=sleep_after_swing, - step_size=step_size, - step_sleep_time=step_sleep_time, - ) - - async def _fire_and_forget(self, coro, timeout, *args, **kwargs): - """Fire and forget a coroutine with a timeout.""" - coro_task = asyncio.create_task(coro(*args, **kwargs)) - # If a timeout is given, create a task to cancel the coroutine after the timeout - if timeout > 0: - asyncio.create_task(self._timeout_task(coro_task, timeout)) - - async def _timeout_task(self, task, timeout): - """Cancel a task after a set amount of time if given.""" - await asyncio.sleep(timeout) - if not task.done(): - task.cancel() - - async def _do_patrol( - self, - camera_identifier: str, - step_size: float = 0.1, - step_sleep_time: float = 0.1, - sleep_after_swing=6, - ): - """ - Perform a patrol of the camera. - - Swings the camera from left to right and back, etc. within the camera's limits, - either by design or configuration (see minx_x, max_x). - - @param step_size: The size of each move step - @param step_sleep_time: Time to sleep between each move step - @param sleep_after_swing: Time to pause after each swing - """ - try: - - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return - - # Get and store starting position - status = ptz_service.GetStatus( - ProfileToken=self._ptz_tokens.get(camera_identifier) - ) - if status is None: - LOGGER.warning("Cannot determine starting position") - initial_pan = 0.0 - initial_tilt = 0.0 - else: - initial_pan = status.Position.PanTilt.x - initial_tilt = status.Position.PanTilt.y - LOGGER.debug( - f"Camera position at start: x: {initial_pan}, y: {initial_tilt}" - ) - - # Get the camera's FOV limits, if any. - cam = self._cameras.get(camera_identifier) - if cam is None: - LOGGER.error(f"No camera found for {camera_identifier}") - return - - min_pan = cam.config.get(CONFIG_CAMERA_FULL_SWING_MIN_PAN) - max_pan = cam.config.get(CONFIG_CAMERA_FULL_SWING_MAX_PAN) - - # Decide which direction to start swinging based on the distance to the - # camera's FOV limits, left if closer to min_pan, right if closer to max_pan - distance_to_min = initial_pan - min_pan if min_pan else 0 - distance_to_max = max_pan - initial_pan if max_pan else 0 - left = distance_to_min > distance_to_max - - # Swing back and forth until stopped - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - stop_patrol_event = asyncio.Event() - self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) - - while not stop_patrol_event.is_set(): - await self.full_swing( - camera_identifier=camera_identifier, - is_left=left, - step_size=step_size, - step_sleep_time=step_sleep_time, - min_pan=min_pan, - max_pan=max_pan, - ) - if stop_patrol_event.is_set(): - break - await asyncio.sleep(sleep_after_swing) - left = not left - - finally: - # Move back to the initial position - self.absolute_move( - camera_identifier=camera_identifier, pan=initial_pan, tilt=initial_tilt - ) - - def stop_patrol(self, camera_identifier: str) -> None: - """Stop the patrol.""" - event = self._stop_patrol_events.get(camera_identifier) - if event: - event.set() - - async def lissajous_curve_patrol( - self, - camera_identifier: str, - pan_amp: float = 1.0, - pan_freq: float = 0.1, - tilt_amp: float = 1.0, - tilt_freq: float = 0.1, - phase_shift: float = np.pi / 2, - step_sleep_time: float = 0.1, - ): - """Perform a Lissajous curve patrol.""" - - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - LOGGER.error(f"No patrol stop event for camera {camera_identifier}") - return False - - # stop currently running patrol - if not stop_patrol_event.is_set(): - stop_patrol_event.set() - await asyncio.sleep(2.0) - stop_patrol_event.clear() - - # start a new patrol - await self._fire_and_forget( - coro=self._do_lissa_curve_patrol, - timeout=0, - camera_identifier=camera_identifier, - pan_amp=pan_amp, - pan_freq=pan_freq, - tilt_amp=tilt_amp, - tilt_freq=tilt_freq, - phase_shift=phase_shift, - step_sleep_time=step_sleep_time, - ) - - async def _do_lissa_curve_patrol( - self, - camera_identifier: str, - pan_amp: float = 1.0, - pan_freq: float = 0.1, - tilt_amp: float = 1.0, - tilt_freq: float = 0.1, - phase_shift: float = np.pi / 2, - step_sleep_time: float = 0.1, - pan_range: tuple = (-1.0, 1.0), - tilt_range: tuple = (-1.0, 1.0), - ): - """Perform a Lissajous curve patrol.""" - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - stop_patrol_event = asyncio.Event() - self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) - - pan_min, pan_max = pan_range - tilt_min, tilt_max = tilt_range - - t = 0.0 - while not stop_patrol_event.is_set(): - t += 1.0 - x = pan_amp * np.sin(pan_freq * t + phase_shift) - y = tilt_amp * np.sin(tilt_freq * t) - - # Scale x and y to the specified pan and tilt ranges - x = pan_min + (x + 1) * (pan_max - pan_min) / 2 - y = tilt_min + (y + 1) * (tilt_max - tilt_min) / 2 - - await self.absolute_move_wait_complete( - camera_identifier=camera_identifier, pan=x, tilt=y - ) - await asyncio.sleep(step_sleep_time) - - async def full_swing( - self, - camera_identifier: str, - is_left: bool = True, - step_size: float = 0.1, - step_sleep_time: float = 0.1, - min_pan: float | None = None, - max_pan: float | None = None, - ): - """Perform a full swing in the pan direction. - - @param is_left: True if the swing is to the left, False if to the right - @param step_size: The size of each move step - @param sleep_time: Time to sleep between each move step - @param min_pan: Minimum pan value to stop at, meant to be used to avoid - going beyond the camera's limits or field of view - @param max_pan: Maximum pan value to stop at - - """ - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return - - cur_pan, _ = self.get_position(camera_identifier) - # Get and store starting position - LOGGER.debug(f"Fullswing start: pan: {cur_pan}, min: {min_pan}, max: {max_pan}") - - move_step = -abs(step_size) if is_left else abs(step_size) - - # Do not move beyond the camera's FOV bounds - if is_left: - if min_pan is not None and cur_pan + move_step <= min_pan: - return - else: - if max_pan is not None and cur_pan + move_step >= max_pan: - return - - # Move while not stopped or stopped by the camera's FOV or hardware bounds - # Unsure how this will react to 360 (or more?) degree cameras - stop_patrol_event = self._stop_patrol_events.get(camera_identifier) - if stop_patrol_event is None: - stop_patrol_event = asyncio.Event() - self._stop_patrol_events.update({camera_identifier: stop_patrol_event}) - - while ( - self.relative_move( - camera_identifier=camera_identifier, pan=move_step, tilt=0.0 - ) - and not stop_patrol_event.is_set() - ): - await asyncio.sleep(step_sleep_time) - cur_pan, _ = self.get_position(camera_identifier) - LOGGER.debug( - f"Fullswing moved to: pan: {cur_pan}, min: {min_pan}, max: {max_pan}" - ) - if min_pan is not None and cur_pan <= min_pan: - break - if max_pan is not None and cur_pan >= max_pan: - break - - LOGGER.debug(f"Fullswing end: pan: {cur_pan}, min: {min_pan}, max: {max_pan}") - - def relative_move(self, camera_identifier: str, pan: float, tilt: float) -> bool: - """ - Move the camera relative to its current position. - - @param x: The relative x position to move to - @param y: The relative y position to move to - @return: True if the move was successful, False otherwise - """ - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - - try: - ptz_service.RelativeMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Translation={ - "PanTilt": {"x": pan, "y": tilt}, - "Zoom": {"x": 0.0}, - }, - ) - return True - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in RelativeMove (usually harmless): {e}") - return False - - def zoom(self, camera_identifier: str, zoom: float = 0.1) -> bool: - """Zoom the camera in our out.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - - try: - ptz_service.RelativeMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Translation={ - "PanTilt": {"x": 0.0, "y": 0.0}, - "Zoom": {"x": zoom}, - }, - ) - return True - except ONVIFOperationException as e: - # errors occur when the zoom exceeds the camera's limits?, silence them - # can't check, camera does not support zoom - LOGGER.warning(f"ONVIF error in Zoom (usually harmless): {e}") - return False - - def absolute_move(self, camera_identifier: str, pan: float, tilt: float) -> bool: - """Move the camera to an absolute position.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - try: - ptz_service.AbsoluteMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Position={ - "PanTilt": {"x": pan, "y": tilt}, - }, - ) - return True - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in AbsoluteMove (usually harmless): {e}") - return False - - async def absolute_move_wait_complete( - self, camera_identifier: str, pan: float, tilt: float, timeout: float = 30.0 - ) -> bool: - """Move the camera to an absolute position and wait for the move to complete.""" - if self.absolute_move(camera_identifier=camera_identifier, pan=pan, tilt=tilt): - # get the camera position and wait until it reaches the desired position to - # a tolerance of 0.005, or until the timeout is reached - tolerance = 0.005 - start_time = asyncio.get_event_loop().time() - while ( - abs(self.get_position(camera_identifier)[0] - pan) > tolerance - or abs(self.get_position(camera_identifier)[1] - tilt) > tolerance - ) and (asyncio.get_event_loop().time() - start_time < timeout): - await asyncio.sleep(0.1) - LOGGER.info( - "Position at end of abs move and wait (requested: %s): %s", - (pan, tilt), - self.get_position(camera_identifier), - ) - return True - return False - - async def continuous_move( - self, - camera_identifier: str, - x_velocity: float, - y_velocity: float, - seconds: float, - ): - """Move the camera continuously for a set amount of time.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return False - try: - ptz_service.ContinuousMove( - ProfileToken=self._ptz_tokens.get(camera_identifier), - Velocity={ - "PanTilt": {"x": x_velocity, "y": y_velocity}, - "Zoom": {"x": 0.0}, - }, - ) - await asyncio.sleep(seconds) - ptz_service.Stop({"ProfileToken": self._ptz_tokens.get(camera_identifier)}) - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in ContinuousMove (usually harmless): {e}") - - def pan_left(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Pan the camera to the left.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=-step_size, tilt=0.0 - ) - - def pan_right(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Pan the camera to the right.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=step_size, tilt=0.0 - ) - - def tilt_up(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Tilt the camera up.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=0.0, tilt=step_size - ) - - def tilt_down(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Tilt the camera down.""" - return self.relative_move( - camera_identifier=camera_identifier, pan=0.0, tilt=-step_size - ) - - def zoom_out(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Zoom the camera out.""" - return self.zoom(camera_identifier=camera_identifier, zoom=-step_size) - - def zoom_in(self, camera_identifier: str, step_size: float = 0.1) -> bool: - """Zoom the camera in.""" - return self.zoom(camera_identifier=camera_identifier, zoom=step_size) - - def get_position(self, camera_identifier: str) -> tuple[float, float]: - """Get the current position of the camera.""" - ptz_service = self._ptz_services.get(camera_identifier) - if ptz_service is None: - LOGGER.error(f"No PTZ service for camera {camera_identifier}") - return 0.0, 0.0 - try: - status = ptz_service.GetStatus( - ProfileToken=self._ptz_tokens.get(camera_identifier) - ) - return status.Position.PanTilt.x, status.Position.PanTilt.y - except ONVIFOperationException as e: - LOGGER.warning(f"ONVIF error in GetStatus (usually harmless): {e}") - return -255.0, -255.0 - - def get_presets(self, camera_identifier: str) -> list[str]: - """Get the available presets for the camera.""" - if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return [] - presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] - return list({preset[CONFIG_PRESET_NAME] for preset in presets}) - - def move_to_preset(self, camera_identifier: str, preset_name: str) -> bool: - """Move the camera to a preset position.""" - if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return False - - if not any( - preset[CONFIG_PRESET_NAME] == preset_name - for preset in self._config[CONFIG_CAMERAS][camera_identifier][ - CONFIG_PTZ_PRESETS - ] - ): - LOGGER.error( - f"Preset {preset_name} not found for camera {camera_identifier}" - ) - return False - - presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] - for preset in presets: - if preset[CONFIG_PRESET_NAME] == preset_name: - self.absolute_move( - camera_identifier=camera_identifier, - pan=preset[CONFIG_PRESET_PAN], - tilt=preset[CONFIG_PRESET_TILT], - ) - if CONFIG_PRESET_ZOOM in preset: - self.zoom( - camera_identifier=camera_identifier, - zoom=preset[CONFIG_PRESET_ZOOM], - ) - return True - - async def move_to_preset_wait_complete( - self, camera_identifier: str, preset_name: str - ) -> bool: - """Move the camera to a preset position.""" - if CONFIG_PTZ_PRESETS not in self._config[CONFIG_CAMERAS][camera_identifier]: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return False - - presets = self._config[CONFIG_CAMERAS][camera_identifier][CONFIG_PTZ_PRESETS] - - if not presets: - LOGGER.error(f"No PTZ presets for camera {camera_identifier}") - return False - - if not any(preset[CONFIG_PRESET_NAME] == preset_name for preset in presets): - LOGGER.error( - f"Preset {preset_name} not found for camera {camera_identifier}" - ) - return False - - for preset in presets: - if preset[CONFIG_PRESET_NAME] == preset_name: - await self.absolute_move_wait_complete( - camera_identifier=camera_identifier, - pan=preset[CONFIG_PRESET_PAN], - tilt=preset[CONFIG_PRESET_TILT], - ) - if CONFIG_PRESET_ZOOM in preset: - self.zoom( - camera_identifier=camera_identifier, - zoom=preset[CONFIG_PRESET_ZOOM], - ) - return True diff --git a/viseron/components/webserver/api/v1/actions/onvif/device.py b/viseron/components/webserver/api/v1/actions/onvif/device.py index 60e25d07c..a8e52a19c 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/device.py +++ b/viseron/components/webserver/api/v1/actions/onvif/device.py @@ -1,4 +1,4 @@ -"""ONVIF Device API handler.""" +"""ONVIF Device Actions API handler.""" import logging @@ -224,8 +224,7 @@ async def put_onvif_device( ntp = self.validate_request_data(request_data, "ntp") set_ntp = await device_service.set_ntp( from_dhcp=ntp.get("from_dhcp"), - ntp_type=ntp.get("ntp_type"), - ntp_server=ntp.get("ntp_server"), + ntp_manual=ntp.get("ntp_manual"), ) await self.validate_action_status(set_ntp, action, camera_identifier) return @@ -250,7 +249,7 @@ async def put_onvif_device( request_data, "network_protocols" ) set_network_protocols = await device_service.set_network_protocols( - network_protocols=network_protocols.get("network_protocols"), + network_protocols=network_protocols, ) await self.validate_action_status( set_network_protocols, action, camera_identifier diff --git a/viseron/components/webserver/api/v1/actions/onvif/imaging.py b/viseron/components/webserver/api/v1/actions/onvif/imaging.py index d8a955b4c..06b54ae1e 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/imaging.py +++ b/viseron/components/webserver/api/v1/actions/onvif/imaging.py @@ -1,4 +1,4 @@ -"""ONVIF Imaging API handler.""" +"""ONVIF Imaging Actions API handler.""" import logging @@ -63,6 +63,14 @@ async def get_onvif_imaging( ): """Handle GET requests for ONVIF Imaging actions.""" + if action == "capabilities": + await self.validate_action_response( + await imaging_service.get_service_capabilities(), + action, + camera_identifier, + ) + return + if action == "settings": await self.validate_action_response( await imaging_service.get_imaging_settings(), action, camera_identifier @@ -125,21 +133,10 @@ async def put_onvif_imaging( ) return - if action == "brightness": - brightness = self.validate_request_data(request_data, "brightness") - force_persistence = self.validate_request_data( - request_data, "force_persistence" - ) - set_brightness = await imaging_service.set_brightness( - brightness, force_persistence - ) - await self.validate_action_status(set_brightness, action, camera_identifier) - return - self.unknown_action(action) @action_handler - async def post_onvif_device( + async def post_onvif_imaging( self, imaging_service, camera_identifier: str, diff --git a/viseron/components/webserver/api/v1/actions/onvif/media.py b/viseron/components/webserver/api/v1/actions/onvif/media.py index 7d1161546..86fd92043 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/media.py +++ b/viseron/components/webserver/api/v1/actions/onvif/media.py @@ -1,4 +1,4 @@ -"""ONVIF Media API handler.""" +"""ONVIF Media Actions API handler.""" import logging @@ -72,6 +72,14 @@ async def get_onvif_media( ): """Handle GET requests for ONVIF Media actions.""" + if action == "capabilities": + await self.validate_action_response( + await media_service.get_service_capabilities(), + action, + camera_identifier, + ) + return + if action == "profiles": await self.validate_action_response( await media_service.get_profiles(), action, camera_identifier diff --git a/viseron/components/webserver/api/v1/actions/onvif/ptz.py b/viseron/components/webserver/api/v1/actions/onvif/ptz.py index 5cfa52a94..f3ca9631e 100644 --- a/viseron/components/webserver/api/v1/actions/onvif/ptz.py +++ b/viseron/components/webserver/api/v1/actions/onvif/ptz.py @@ -1,4 +1,4 @@ -"""ONVIF PTZ API handler.""" +"""ONVIF PTZ Actions API handler.""" import logging @@ -65,6 +65,14 @@ def _service_name(self): async def get_onvif_ptz(self, ptz_service, camera_identifier: str, action: str): """Handle GET requests for ONVIF PTZ actions.""" + if action == "capabilities": + await self.validate_action_response( + await ptz_service.get_service_capabilities(), + action, + camera_identifier, + ) + return + if action == "user_config": await self.validate_action_response( ptz_service.get_ptz_config(), action, camera_identifier From 881e82dd3c924d2a69bc299cb32018fa7d57e632 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 1 Feb 2026 17:39:34 +0700 Subject: [PATCH 067/120] style(ui/tuning): Update button label to reflect add/edit state --- frontend/src/components/tuning/camera/OSDTextDialog.tsx | 2 +- frontend/src/components/tuning/camera/VideoTransformDialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/tuning/camera/OSDTextDialog.tsx b/frontend/src/components/tuning/camera/OSDTextDialog.tsx index 33938d043..59bb64b9a 100644 --- a/frontend/src/components/tuning/camera/OSDTextDialog.tsx +++ b/frontend/src/components/tuning/camera/OSDTextDialog.tsx @@ -368,7 +368,7 @@ export function TuneOSDTextDialog({
diff --git a/frontend/src/components/tuning/camera/VideoTransformDialog.tsx b/frontend/src/components/tuning/camera/VideoTransformDialog.tsx index b7d17dbd9..b4cb4d612 100644 --- a/frontend/src/components/tuning/camera/VideoTransformDialog.tsx +++ b/frontend/src/components/tuning/camera/VideoTransformDialog.tsx @@ -112,7 +112,7 @@ export function VideoTransformDialog({ From c10b4cce589c6a62b75311f7bd3dee511e65f878 Mon Sep 17 00:00:00 2001 From: Kaburagi Date: Sun, 1 Feb 2026 17:40:43 +0700 Subject: [PATCH 068/120] feat(ptz): Enhance OnvifPtzController integration with ptzSupport prop and update PlayerMenu rendering logic --- .../actions/ptz/OnvifPtzController.tsx | 15 +++++++++----- frontend/src/components/player/PlayerMenu.tsx | 20 +++++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/actions/ptz/OnvifPtzController.tsx b/frontend/src/components/actions/ptz/OnvifPtzController.tsx index 46e8b01ee..5fb1d5e56 100644 --- a/frontend/src/components/actions/ptz/OnvifPtzController.tsx +++ b/frontend/src/components/actions/ptz/OnvifPtzController.tsx @@ -28,6 +28,7 @@ import { Dialog, DialogActions, DialogContent, + DialogContentText, DialogTitle, FormControlLabel, IconButton, @@ -59,10 +60,12 @@ import { useOnvifPtzHandlers } from "./useOnvifPtzHandlers"; interface OnvifPtzControllerProps { cameraIdentifier: string; + ptzSupport: string; } export function OnvifPtzController({ cameraIdentifier, + ptzSupport, }: OnvifPtzControllerProps) { const theme = useTheme(); const [isOpen, setIsOpen] = useState(false); @@ -135,7 +138,9 @@ export function OnvifPtzController({ "onvif", ); - const { data: configData } = useGetPtzConfig(cameraIdentifier); + // Determine if this is manual mode (auto-config is disabled) (requires user_config) + const isManualMode = ptzSupport.includes("manual"); + const { data: configData } = useGetPtzConfig(cameraIdentifier, isManualMode); const [reversePan, setReversePan] = useState( typeof configData?.user_config?.reverse_pan === "boolean" @@ -841,10 +846,10 @@ export function OnvifPtzController({ - + Are you sure you want to set the current camera position as the home position? This will override the existing home position. - + @@ -880,10 +885,10 @@ export function OnvifPtzController({ - + Are you sure you want to remove preset "{selectedPresetName} "? This action cannot be undone. - + + + + + setRebootOpen(false)}> + + + + Confirm Reboot + + + + + Are you sure you want to reboot this device? The camera will be + temporarily unavailable during the reboot process. + + + + + + + + + setFactoryResetOpen(false)} + > + + + + Confirm Factory Reset + + + + + Are you sure you want to perform a factory reset on this device? + Sometimes the camera connection will be lost and it will revert to + factory settings. You need to set it back up afterwards. This action + cannot be undone. + + + + + + + + + + ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceDNS.tsx b/frontend/src/components/tuning/onvif/device/DeviceDNS.tsx new file mode 100644 index 000000000..b2dcce420 --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceDNS.tsx @@ -0,0 +1,572 @@ +import { AddAlt, GlobalFilters, Help, TrashCan } from "@carbon/icons-react"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormHelperText, + IconButton, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + TextField, + Tooltip, + Typography, + tableCellClasses, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useEffect, useState } from "react"; + +import { useFormChanges } from "hooks/UseFormChanges"; +import { useToast } from "hooks/UseToast"; +import { useGetDeviceDNS, useSetDeviceDNS } from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +type DNSType = "IPv4" | "IPv6" | ""; + +interface DNSServerEntry { + id: string; + type: DNSType; + server: string; +} + +interface SearchDomainEntry { + id: string; + domain: string; +} + +let dnsServerIdCounter = 0; +const generateDnsServerId = () => `dns-server-${++dnsServerIdCounter}`; + +let searchDomainIdCounter = 0; +const generateSearchDomainId = () => `search-domain-${++searchDomainIdCounter}`; + +interface DeviceDNSProps { + cameraIdentifier: string; + deviceCapabilities?: any; +} + +export function DeviceDNS({ + cameraIdentifier, + deviceCapabilities, +}: DeviceDNSProps) { + // Check if network configuration is not supported + const isNetworkConfigNotSupported = + deviceCapabilities?.System?.NetworkConfigNotSupported === true; + + const TITLE = "DNS Settings"; + const DESC = + "Manage Domain Name System (DNS) settings for the device. Changes to the 'DNS Servers' and 'Search Domains' lists will be ignored if 'DNS From DHCP' is enabled."; + + const theme = useTheme(); + const toast = useToast(); + + // ONVIF API hooks + const { data, isLoading, isError, error } = useGetDeviceDNS( + cameraIdentifier, + !isNetworkConfigNotSupported, + ); + const setDNSMutation = useSetDeviceDNS(cameraIdentifier); + + const dns = data?.dns; + const infoItems: { label: string; value: string }[] = []; + + // Section state + const [dialogOpen, setDialogOpen] = useState(false); + const [DNSFromDHCP, setDNSFromDHCP] = useState(false); + const [searchDomains, setSearchDomains] = useState([]); + const [dnsServers, setDnsServers] = useState([]); + const [originalValues, setOriginalValues] = useState<{ + DNSFromDHCP: boolean; + searchDomains: SearchDomainEntry[]; + dnsServers: DNSServerEntry[]; + }>({ + DNSFromDHCP: false, + searchDomains: [], + dnsServers: [], + }); + + // Data extraction for display + if (dns) { + if (dns.FromDHCP !== undefined) { + infoItems.push({ + label: "From DHCP", + value: dns.FromDHCP ? "Enabled" : "Disabled", + }); + } + + // Add search domains + if (dns.SearchDomain && dns.SearchDomain.length > 0) { + dns.SearchDomain.forEach((domain: string, index: number) => { + infoItems.push({ + label: `Domain (#${index + 1})`, + value: domain, + }); + }); + } + + // Add DNS servers + const addDnsServers = (servers?: Array>) => { + if (!servers?.length) { + return false; + } + + let hasValidServer = false; + + servers.forEach((server, index) => { + const hasIPv4 = server?.IPv4Address; + const hasIPv6 = server?.IPv6Address; + + // Skip if both addresses are null/empty + if (!hasIPv4 && !hasIPv6) { + return; + } + + hasValidServer = true; + + if (server?.Type) { + infoItems.push({ + label: `DNS Type (#${index + 1})`, + value: server.Type, + }); + } + if (hasIPv4) { + infoItems.push({ + label: `DNS Server (#${index + 1})`, + value: server.IPv4Address, + }); + } else if (hasIPv6) { + infoItems.push({ + label: `DNS Server (#${index + 1})`, + value: server.IPv6Address, + }); + } + }); + + return hasValidServer; + }; + + const hasServers = dns.FromDHCP + ? addDnsServers(dns.DNSFromDHCP) + : addDnsServers(dns.DNSManual); + + if (!hasServers) { + infoItems.push({ + label: "DNS Servers", + value: "Not Configured", + }); + } + } + + useEffect(() => { + if (dns?.FromDHCP !== undefined) { + setDNSFromDHCP(dns.FromDHCP); + } + }, [dns?.FromDHCP]); + + // Handlers + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleOpenDialog = () => { + const fromDhcp = dns?.FromDHCP ?? false; + + // Parse search domains + const domains: SearchDomainEntry[] = + dns?.SearchDomain && dns.SearchDomain.length > 0 + ? dns.SearchDomain.map((domain: string) => ({ + id: generateSearchDomainId(), + domain, + })) + : []; + + // Parse DNS servers + const parseDnsServers = ( + servers?: Array>, + ): DNSServerEntry[] => { + if (!servers?.length) { + return []; + } + + return servers.map((server) => { + const id = generateDnsServerId(); + if (server?.Type === "IPv4" && server?.IPv4Address) { + return { id, type: "IPv4" as const, server: server.IPv4Address }; + } + if (server?.Type === "IPv6" && server?.IPv6Address) { + return { id, type: "IPv6" as const, server: server.IPv6Address }; + } + // Fallback + if (server?.IPv4Address) { + return { id, type: "IPv4" as const, server: server.IPv4Address }; + } + if (server?.IPv6Address) { + return { id, type: "IPv6" as const, server: server.IPv6Address }; + } + return { id, type: "" as const, server: "" }; + }); + }; + + const serverSource = fromDhcp ? dns?.DNSFromDHCP : dns?.DNSManual; + const parsedServers = parseDnsServers(serverSource); + + setDNSFromDHCP(fromDhcp); + setSearchDomains(domains); + setDnsServers(parsedServers); + setOriginalValues({ + DNSFromDHCP: fromDhcp, + searchDomains: [...domains], + dnsServers: parsedServers.map((s) => ({ ...s })), + }); + setDialogOpen(true); + }; + + const handleAddSearchDomain = () => { + setSearchDomains([ + ...searchDomains, + { id: generateSearchDomainId(), domain: "" }, + ]); + }; + + const handleRemoveSearchDomain = (index: number) => { + setSearchDomains(searchDomains.filter((_, i) => i !== index)); + }; + + const handleSearchDomainChange = (index: number, value: string) => { + const updated = searchDomains.map((entry, i) => + i === index ? { ...entry, domain: value } : entry, + ); + setSearchDomains(updated); + }; + + const handleAddServer = () => { + setDnsServers([ + ...dnsServers, + { id: generateDnsServerId(), type: "", server: "" }, + ]); + }; + + const handleRemoveServer = (index: number) => { + setDnsServers(dnsServers.filter((_, i) => i !== index)); + }; + + const handleServerChange = ( + index: number, + field: "type" | "server", + value: string, + ) => { + const updated = dnsServers.map((server, i) => + i === index ? { ...server, [field]: value } : server, + ); + setDnsServers(updated); + }; + + // Check if there are any changes + const hasChanges = useFormChanges( + { DNSFromDHCP, searchDomains, dnsServers }, + originalValues, + { + searchDomains: ( + current: SearchDomainEntry[], + original: SearchDomainEntry[], + ) => { + if (current.length !== original.length) return false; + return current.every( + (entry, i) => entry.domain === original[i]?.domain, + ); + }, + dnsServers: (current: DNSServerEntry[], original: DNSServerEntry[]) => { + if (current.length !== original.length) return false; + return current.every( + (server, i) => + server.type === original[i]?.type && + server.server === original[i]?.server, + ); + }, + }, + ); + + // Validate servers: if type is set, server must be set and vice versa + const isServersValid = dnsServers.every((server) => { + const hasType = !!server.type; + const hasServer = !!server.server.trim(); + // Both empty or both filled is valid + return hasType === hasServer; + }); + + const isValid = DNSFromDHCP || isServersValid; + + const handleUpdateDNS = () => { + // Build the DNS manual configuration array from valid servers + const validServers = dnsServers.filter((s) => s.type && s.server.trim()); + const dnsManualConfig = validServers.map((s) => { + const serverConfig: Record = { Type: s.type }; + if (s.type === "IPv4") { + serverConfig.IPv4Address = s.server.trim(); + serverConfig.IPv6Address = null; + } else if (s.type === "IPv6") { + serverConfig.IPv4Address = null; + serverConfig.IPv6Address = s.server.trim(); + } + return serverConfig; + }); + + // Build search domain list + const searchDomainConfig = searchDomains + .filter((entry) => entry.domain.trim()) + .map((entry) => entry.domain.trim()); + + setDNSMutation.mutate( + { + from_dhcp: DNSFromDHCP, + search_domain: searchDomainConfig, + dns_manual: dnsManualConfig, + }, + { + onSuccess: () => { + toast.success("DNS settings updated successfully"); + setDialogOpen(false); + }, + onError: () => { + toast.error("Failed to update DNS settings"); + }, + }, + ); + }; + + return ( + + + + + {TITLE} + + + + + + + + {/* DNS Table */} + + + + {infoItems.map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + +
+
+ + {/* DNS Configuration Dialog */} + + Configure Domain Name System + + + + DNS From DHCP + + + Set whether the device obtains DNS settings from DHCP. + + + + {/* Search Domains */} + + Search Domains + {searchDomains.length === 0 ? ( + + No search domains configured. + + ) : ( + searchDomains.map((entry, index) => ( + + + handleSearchDomainChange(index, e.target.value) + } + disabled={DNSFromDHCP} + placeholder="example.com" + /> + handleRemoveSearchDomain(index)} + disabled={DNSFromDHCP} + color="error" + > + + + + )) + )} + + + + {/* DNS Servers */} + + DNS Servers + {dnsServers.length === 0 ? ( + + No DNS servers configured. + + ) : ( + dnsServers.map((serverEntry, index) => ( + + + Type + + + + handleServerChange(index, "server", e.target.value) + } + disabled={DNSFromDHCP} + placeholder={ + serverEntry.type === "IPv4" + ? "8.8.8.8" + : serverEntry.type === "IPv6" + ? "2001:4860:4860::8888" + : "Select type first" + } + /> + handleRemoveServer(index)} + disabled={DNSFromDHCP} + color="error" + > + + + + )) + )} + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceInformation.tsx b/frontend/src/components/tuning/onvif/device/DeviceInformation.tsx new file mode 100644 index 000000000..3d106190d --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceInformation.tsx @@ -0,0 +1,110 @@ +import { Help } from "@carbon/icons-react"; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Tooltip, + Typography, + tableCellClasses, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; + +import { useGetDeviceInformation } from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +interface DeviceInformationProps { + cameraIdentifier: string; +} + +export function DeviceInformation({ + cameraIdentifier, +}: DeviceInformationProps) { + const TITLE = "Device Information"; + const DESC = "READ-ONLY: List of ONVIF device information."; + + const theme = useTheme(); + + // ONVIF API hook + const { data, isLoading, isError, error } = + useGetDeviceInformation(cameraIdentifier); + + const info = data?.information; + const infoItems = info + ? [ + { label: "Manufacturer", value: info.Manufacturer }, + { label: "Model", value: info.Model }, + { label: "Firmware", value: info.FirmwareVersion }, + { label: "Serial Number", value: info.SerialNumber }, + { label: "Hardware ID", value: info.HardwareId }, + ].filter((item) => item.value) + : []; + + return ( + + + + + {TITLE} + + + + + + + {/* Information Table */} + + + + + {infoItems.map((item) => ( + + + + {item.label} + + + + {item.value} + + + ))} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceNTP.tsx b/frontend/src/components/tuning/onvif/device/DeviceNTP.tsx new file mode 100644 index 000000000..b6822cf30 --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceNTP.tsx @@ -0,0 +1,471 @@ +import { AddAlt, GlobalFilters, Help, TrashCan } from "@carbon/icons-react"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormHelperText, + IconButton, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + TextField, + Tooltip, + Typography, + tableCellClasses, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useEffect, useState } from "react"; + +import { useToast } from "hooks/UseToast"; +import { useFormChanges } from "hooks/useFormChanges"; +import { useGetDeviceNTP, useSetDeviceNTP } from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +type NTPType = "IPv4" | "IPv6" | "DNS" | ""; + +interface NTPServerEntry { + id: string; + type: NTPType; + server: string; +} + +let ntpServerIdCounter = 0; +const generateNtpServerId = () => `ntp-server-${++ntpServerIdCounter}`; + +interface DeviceNTPProps { + cameraIdentifier: string; + deviceCapabilities?: any; +} + +export function DeviceNTP({ + cameraIdentifier, + deviceCapabilities, +}: DeviceNTPProps) { + // Check if network configuration is not supported + const isNetworkConfigNotSupported = + deviceCapabilities?.System?.NetworkConfigNotSupported === true; + + // Check max NTP servers supported (NTP > 1 means multiple NTP servers can be configured) + const maxNTPServers = deviceCapabilities?.Network?.NTP ?? 1; + const supportsMultipleNTP = maxNTPServers > 1; + + const TITLE = "NTP Settings"; + const DESC = + "Manage Network Time Protocol (NTP) settings for network-based time synchronization. Changes to the 'NTP Servers' list will be ignored if 'NTP From DHCP' is enabled."; + + const theme = useTheme(); + const toast = useToast(); + + // ONVIF API hooks + const { data, isLoading, isError, error } = useGetDeviceNTP( + cameraIdentifier, + !isNetworkConfigNotSupported, + ); + const setNTPMutation = useSetDeviceNTP(cameraIdentifier); + + const ntp = data?.ntp; + const infoItems: { label: string; value: string }[] = []; + + // Section state + const [dialogOpen, setDialogOpen] = useState(false); + const [NTPFromDHCP, setNTPFromDHCP] = useState(false); + const [ntpServers, setNtpServers] = useState([ + { id: generateNtpServerId(), type: "", server: "" }, + ]); + const [originalValues, setOriginalValues] = useState<{ + NTPFromDHCP: boolean; + ntpServers: NTPServerEntry[]; + }>({ + NTPFromDHCP: false, + ntpServers: [{ id: generateNtpServerId(), type: "", server: "" }], + }); + + // Data extraction for display + if (ntp) { + if (ntp.FromDHCP !== undefined) { + infoItems.push({ + label: "From DHCP", + value: ntp.FromDHCP ? "Enabled" : "Disabled", + }); + } + const addNtpServers = (servers?: Array>) => { + if (!servers?.length) { + return false; + } + + const serverFields = ["IPv4Address", "IPv6Address", "DNSname"] as const; + + servers.forEach((server, index) => { + serverFields.forEach((field) => { + if (server?.[field]) { + infoItems.push({ + label: `NTP Type (#${index + 1})`, + value: server.Type || "N/A", + }); + infoItems.push({ + label: `NTP Server (#${index + 1})`, + value: server[field], + }); + } + }); + }); + + return true; + }; + + const hasServers = ntp.FromDHCP + ? addNtpServers(ntp.NTPFromDHCP) + : addNtpServers(ntp.NTPManual); + + if (!hasServers) { + infoItems.push({ + label: "NTP Servers", + value: "Not Configured", + }); + } + } + + useEffect(() => { + if (ntp?.FromDHCP !== undefined) { + setNTPFromDHCP(ntp.FromDHCP); + } + }, [ntp?.FromDHCP]); + + // Handlers + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleOpenDialog = () => { + const fromDhcp = ntp?.FromDHCP ?? false; + + const parseNtpServers = ( + servers?: Array>, + ): NTPServerEntry[] => { + if (!servers?.length) { + return [{ id: generateNtpServerId(), type: "", server: "" }]; + } + + return servers.map((server) => { + const id = generateNtpServerId(); + if (server?.Type === "DNS" && server?.DNSname) { + return { id, type: "DNS" as const, server: server.DNSname }; + } + if (server?.Type === "IPv4" && server?.IPv4Address) { + return { id, type: "IPv4" as const, server: server.IPv4Address }; + } + if (server?.Type === "IPv6" && server?.IPv6Address) { + return { id, type: "IPv6" as const, server: server.IPv6Address }; + } + // Fallback + if (server?.IPv4Address) { + return { id, type: "IPv4" as const, server: server.IPv4Address }; + } + if (server?.IPv6Address) { + return { id, type: "IPv6" as const, server: server.IPv6Address }; + } + if (server?.DNSname) { + return { id, type: "DNS" as const, server: server.DNSname }; + } + return { id, type: "" as const, server: "" }; + }); + }; + + const serverSource = fromDhcp ? ntp?.NTPFromDHCP : ntp?.NTPManual; + const parsedServers = parseNtpServers(serverSource); + + setNTPFromDHCP(fromDhcp); + setNtpServers(parsedServers); + setOriginalValues({ + NTPFromDHCP: fromDhcp, + ntpServers: parsedServers.map((s) => ({ ...s })), + }); + setDialogOpen(true); + }; + + const handleAddServer = () => { + if (ntpServers.length < maxNTPServers) { + setNtpServers([ + ...ntpServers, + { id: generateNtpServerId(), type: "", server: "" }, + ]); + } + }; + + const handleRemoveServer = (index: number) => { + if (ntpServers.length > 1) { + setNtpServers(ntpServers.filter((_, i) => i !== index)); + } + }; + + const handleServerChange = ( + index: number, + field: "type" | "server", + value: string, + ) => { + const updated = ntpServers.map((server, i) => + i === index ? { ...server, [field]: value } : server, + ); + setNtpServers(updated); + }; + + // Check if there are any changes + const hasChanges = useFormChanges( + { NTPFromDHCP, ntpServers }, + originalValues, + { + ntpServers: (current: NTPServerEntry[], original: NTPServerEntry[]) => { + if (current.length !== original.length) return false; + return current.every( + (server, i) => + server.type === original[i]?.type && + server.server === original[i]?.server, + ); + }, + }, + ); + + // Validate servers when not using DHCP + const isServersValid = + NTPFromDHCP || + ntpServers.every((server) => server.type && server.server.trim()); + + const handleUpdateNTP = () => { + // Build the NTP manual configuration array from valid servers + const validServers = ntpServers.filter((s) => s.type && s.server.trim()); + const ntpManualConfig = + validServers.length > 0 + ? validServers.map((s) => { + const serverConfig: Record = { Type: s.type }; + if (s.type === "IPv4") { + serverConfig.IPv4Address = s.server.trim(); + } else if (s.type === "IPv6") { + serverConfig.IPv6Address = s.server.trim(); + } else if (s.type === "DNS") { + serverConfig.DNSname = s.server.trim(); + } + return serverConfig; + }) + : undefined; + + setNTPMutation.mutate( + { + from_dhcp: NTPFromDHCP, + ntp_manual: ntpManualConfig, + }, + { + onSuccess: () => { + toast.success("NTP settings updated successfully"); + setDialogOpen(false); + }, + onError: () => { + toast.error("Failed to update NTP settings"); + }, + }, + ); + }; + + return ( + + + + + {TITLE} + + + + + + + + {/* NTP Table */} + + + + {infoItems.map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + +
+
+ + {/* NTP Configuration Dialog */} + + + + + Configure Network Time Protocol + + + {`Max NTP Servers: ${maxNTPServers}`} + + + + + + + NTP From DHCP + + + Set whether the device obtains NTP settings from DHCP. + + + + {ntpServers.map((serverEntry, index) => ( + + + Type + + + + handleServerChange(index, "server", e.target.value) + } + placeholder="e.g., pool.ntp.org or 192.168.1.1" + error={ + !NTPFromDHCP && + (!serverEntry.type || !serverEntry.server.trim()) + } + /> + {ntpServers.length > 1 && ( + handleRemoveServer(index)} + color="error" + disabled={NTPFromDHCP} + > + + + )} + + ))} + {supportsMultipleNTP && ( + + )} + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceNetworkInterfaces.tsx b/frontend/src/components/tuning/onvif/device/DeviceNetworkInterfaces.tsx new file mode 100644 index 000000000..6ef56c653 --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceNetworkInterfaces.tsx @@ -0,0 +1,1011 @@ +import { AddAlt, GlobalFilters, Help, TrashCan } from "@carbon/icons-react"; +import { + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + TextField, + Tooltip, + Typography, + tableCellClasses, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useToast } from "hooks/UseToast"; +import { useFormChanges } from "hooks/useFormChanges"; +import { + useGetDeviceNetworkInterfaces, + useSetDeviceNetworkInterfaces, +} from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +interface DeviceNetworkInterfacesProps { + cameraIdentifier: string; + deviceCapabilities?: any; +} + +// Helper to format MAC address (a8-29-48-33-fe-3c -> A8:29:48:33:FE:3C) +const formatMacAddress = (mac: string): string => + mac.toUpperCase().replace(/-/g, ":"); + +export function DeviceNetworkInterfaces({ + cameraIdentifier, + deviceCapabilities, +}: DeviceNetworkInterfacesProps) { + // Check if network configuration is not supported + const isNetworkConfigNotSupported = + deviceCapabilities?.System?.NetworkConfigNotSupported === true; + + const TITLE = "Network Interfaces"; + const DESC = + "Manage network interfaces for this device. Some cameras will 'perform' or 'require' a reboot after this configuration is changed."; + + const theme = useTheme(); + const toast = useToast(); + + // ONVIF API hooks + const { data, isLoading, isError, error } = useGetDeviceNetworkInterfaces( + cameraIdentifier, + !isNetworkConfigNotSupported, + ); + const setNetworkInterfacesMutation = + useSetDeviceNetworkInterfaces(cameraIdentifier); + + const interfaces = data?.network_interfaces; + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedInterfaceToken, setSelectedInterfaceToken] = useState(""); + const [interfaceEnabled, setInterfaceEnabled] = useState(true); + const [mtu, setMtu] = useState(""); + const [linkAutoNegotiation, setLinkAutoNegotiation] = useState(true); + const [linkSpeed, setLinkSpeed] = useState(100); + const [linkDuplex, setLinkDuplex] = useState<"Full" | "Half">("Full"); + const [ipv4Enabled, setIpv4Enabled] = useState(true); + const [ipv4DHCP, setIpv4DHCP] = useState(true); + const [ipv4ManualAddresses, setIpv4ManualAddresses] = useState< + { id: string; address: string; prefixLength: number | "" }[] + >([]); + const [ipv6Enabled, setIpv6Enabled] = useState(true); + const [ipv6AcceptRouterAdvert, setIpv6AcceptRouterAdvert] = useState(false); + const [ipv6DHCP, setIpv6DHCP] = useState< + "Auto" | "Stateful" | "Stateless" | "Off" + >("Auto"); + const [ipv6ManualAddresses, setIpv6ManualAddresses] = useState< + { id: string; address: string; prefixLength: number | "" }[] + >([]); + const [originalValues, setOriginalValues] = useState({}); + + const addressIdCounterRef = useRef(0); + const generateAddressId = useCallback( + () => `address-${++addressIdCounterRef.current}`, + [], + ); + + // Get selected interface data + const selectedInterface = interfaces?.find( + (iface: any) => iface.token === selectedInterfaceToken, + ); + + // Handlers + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const loadInterfaceData = useCallback( + (iface: any) => { + const token = iface.token; + const enabled = iface.Enabled ?? true; + const mtuValue = iface.Info?.MTU ?? ""; + + // Link settings (use AdminSettings for configuration) + const linkAutoNeg = iface.Link?.AdminSettings?.AutoNegotiation ?? true; + const linkSpd = iface.Link?.AdminSettings?.Speed ?? 100; + const linkDup = iface.Link?.AdminSettings?.Duplex ?? "Full"; + + // IPv4 settings + const ipv4En = iface.IPv4?.Enabled ?? true; + const ipv4Dhcp = iface.IPv4?.Config?.DHCP ?? true; + const ipv4Manual = + iface.IPv4?.Config?.Manual?.map((addr: any) => ({ + id: generateAddressId(), + address: addr.Address ?? "", + prefixLength: addr.PrefixLength ?? "", + })) ?? []; + + // IPv6 settings + const ipv6En = iface.IPv6?.Enabled ?? true; + const ipv6AcceptRA = iface.IPv6?.Config?.AcceptRouterAdvert ?? false; + const ipv6Dhcp = iface.IPv6?.Config?.DHCP ?? "Auto"; + const ipv6Manual = + iface.IPv6?.Config?.Manual?.map((addr: any) => ({ + id: generateAddressId(), + address: addr.Address ?? "", + prefixLength: addr.PrefixLength ?? "", + })) ?? []; + + setSelectedInterfaceToken(token); + setInterfaceEnabled(enabled); + setMtu(mtuValue); + setLinkAutoNegotiation(linkAutoNeg); + setLinkSpeed(linkSpd); + setLinkDuplex(linkDup); + setIpv4Enabled(ipv4En); + setIpv4DHCP(ipv4Dhcp); + setIpv4ManualAddresses(ipv4Manual); + setIpv6Enabled(ipv6En); + setIpv6AcceptRouterAdvert(ipv6AcceptRA); + setIpv6DHCP(ipv6Dhcp); + setIpv6ManualAddresses(ipv6Manual); + + setOriginalValues({ + selectedInterfaceToken: token, + interfaceEnabled: enabled, + mtu: mtuValue, + linkAutoNegotiation: linkAutoNeg, + linkSpeed: linkSpd, + linkDuplex: linkDup, + ipv4Enabled: ipv4En, + ipv4DHCP: ipv4Dhcp, + ipv4ManualAddresses: ipv4Manual, + ipv6Enabled: ipv6En, + ipv6AcceptRouterAdvert: ipv6AcceptRA, + ipv6DHCP: ipv6Dhcp, + ipv6ManualAddresses: ipv6Manual, + }); + }, + [generateAddressId], // Empty deps since generateAddressId is stable and setters are stable + ); + + const handleOpenDialog = () => { + // Default to first interface + const firstInterface = interfaces?.[0]; + if (!firstInterface) return; + + loadInterfaceData(firstInterface); + setDialogOpen(true); + }; + + // Handle interface selection change + useEffect(() => { + if (selectedInterfaceToken && selectedInterface) { + loadInterfaceData(selectedInterface); + } + }, [selectedInterfaceToken, selectedInterface, loadInterfaceData]); + + // IPv4 manual address handlers + const handleAddIpv4Address = () => { + setIpv4ManualAddresses([ + ...ipv4ManualAddresses, + { id: generateAddressId(), address: "", prefixLength: "" }, + ]); + }; + + const handleRemoveIpv4Address = (index: number) => { + setIpv4ManualAddresses(ipv4ManualAddresses.filter((_, i) => i !== index)); + }; + + const handleIpv4AddressChange = ( + index: number, + field: "address" | "prefixLength", + value: string | number, + ) => { + const updated = ipv4ManualAddresses.map((addr, i) => + i === index ? { ...addr, [field]: value } : addr, + ); + setIpv4ManualAddresses(updated); + }; + + // IPv6 manual address handlers + const handleAddIpv6Address = () => { + setIpv6ManualAddresses([ + ...ipv6ManualAddresses, + { id: generateAddressId(), address: "", prefixLength: "" }, + ]); + }; + + const handleRemoveIpv6Address = (index: number) => { + setIpv6ManualAddresses(ipv6ManualAddresses.filter((_, i) => i !== index)); + }; + + const handleIpv6AddressChange = ( + index: number, + field: "address" | "prefixLength", + value: string | number, + ) => { + const updated = ipv6ManualAddresses.map((addr, i) => + i === index ? { ...addr, [field]: value } : addr, + ); + setIpv6ManualAddresses(updated); + }; + + // Check if there are any changes + const hasChanges = useFormChanges( + { + selectedInterfaceToken, + interfaceEnabled, + mtu, + linkAutoNegotiation, + linkSpeed, + linkDuplex, + ipv4Enabled, + ipv4DHCP, + ipv4ManualAddresses, + ipv6Enabled, + ipv6AcceptRouterAdvert, + ipv6DHCP, + ipv6ManualAddresses, + }, + originalValues, + { + ipv4ManualAddresses: (current: any[], original: any[]) => { + if (current.length !== original.length) return false; + return current.every( + (addr, i) => + addr.address === original[i]?.address && + addr.prefixLength === original[i]?.prefixLength, + ); + }, + ipv6ManualAddresses: (current: any[], original: any[]) => { + if (current.length !== original.length) return false; + return current.every( + (addr, i) => + addr.address === original[i]?.address && + addr.prefixLength === original[i]?.prefixLength, + ); + }, + }, + ); + + const handleUpdateNetworkInterface = () => { + // Build IPv4 configuration (only if device supports IPv4) + const ipv4Config = + selectedInterface?.IPv4 && ipv4Enabled + ? { + Enabled: ipv4Enabled, + DHCP: ipv4DHCP, + Manual: + !ipv4DHCP && ipv4ManualAddresses.length > 0 + ? ipv4ManualAddresses + .filter((addr) => addr.address.trim()) + .map((addr) => ({ + Address: addr.address.trim(), + PrefixLength: Number(addr.prefixLength) || 24, + })) + : undefined, + } + : undefined; + + // Build IPv6 configuration (only if device supports IPv6) + const ipv6Config = + selectedInterface?.IPv6 && ipv6Enabled + ? { + Enabled: ipv6Enabled, + AcceptRouterAdvert: ipv6AcceptRouterAdvert, + DHCP: ipv6DHCP, + Manual: + ipv6DHCP === "Off" && ipv6ManualAddresses.length > 0 + ? ipv6ManualAddresses + .filter((addr) => addr.address.trim()) + .map((addr) => ({ + Address: addr.address.trim(), + PrefixLength: Number(addr.prefixLength) || 64, + })) + : undefined, + } + : undefined; + + // Build network interface configuration + const networkInterface: any = { + Enabled: interfaceEnabled, + }; + + if (mtu !== "") { + networkInterface.MTU = Number(mtu); + } + + if (selectedInterface?.Link?.AdminSettings) { + networkInterface.Link = { + AutoNegotiation: linkAutoNegotiation, + Speed: Number(linkSpeed) || 100, + Duplex: linkDuplex, + }; + } + + if (ipv4Config) { + networkInterface.IPv4 = ipv4Config; + } + + if (ipv6Config) { + networkInterface.IPv6 = ipv6Config; + } + + setNetworkInterfacesMutation.mutate( + { + interface_token: selectedInterfaceToken, + network_interface: networkInterface, + }, + { + onSuccess: () => { + toast.success("Network interface updated successfully"); + setDialogOpen(false); + }, + onError: () => { + toast.error("Failed to update network interface"); + }, + }, + ); + }; + + return ( + + + + + {TITLE} + + + + + + + + {interfaces?.map( + (iface: { + token: string; + Enabled: boolean; + Info?: { Name?: string; HwAddress: string; MTU?: number }; + Link?: { + AdminSettings?: { + AutoNegotiation: boolean; + Speed: number; + Duplex: "Full" | "Half"; + }; + OperSettings?: { + AutoNegotiation: boolean; + Speed: number; + Duplex: "Full" | "Half"; + }; + InterfaceType?: number; + }; + IPv4?: { + Enabled: boolean; + Config: { + DHCP: boolean; + Manual?: { Address?: string; PrefixLength?: number }[]; + LinkLocal?: { Address?: string; PrefixLength?: number }; + FromDHCP?: { Address?: string; PrefixLength?: number }; + }; + }; + IPv6?: { + Enabled: boolean; + Config?: { + AcceptRouterAdvert?: boolean; + DHCP: "Auto" | "Stateful" | "Stateless" | "Off"; + Manual: { Address?: string; PrefixLength?: number }[]; + LinkLocal: { Address?: string; PrefixLength?: number }[]; + FromDHCP: { Address?: string; PrefixLength?: number }[]; + FromRA: { Address?: string; PrefixLength?: number }[]; + }; + }; + }) => { + const infoItems: { label: string; value: string }[] = []; + + // General Information + infoItems.push({ + label: "Status", + value: iface.Enabled ? "Enabled" : "Disabled", + }); + if (iface.Info?.Name) { + infoItems.push({ label: "Interface", value: iface.Info.Name }); + } + if (iface.Info?.HwAddress) { + infoItems.push({ + label: "MAC Address", + value: formatMacAddress(iface.Info.HwAddress), + }); + } + if (iface.Info?.MTU) { + infoItems.push({ label: "MTU", value: String(iface.Info.MTU) }); + } + + // Link Information (show OperSettings - current active settings) + if (iface.Link?.OperSettings) { + if (iface.Link?.OperSettings?.AutoNegotiation) { + infoItems.push({ + label: "Auto-Nego", + value: iface.Link.OperSettings.AutoNegotiation + ? "Enabled" + : "Disabled", + }); + } + if (iface.Link?.OperSettings?.Speed) { + infoItems.push({ + label: "Speed", + value: `${iface.Link.OperSettings.Speed} Mbps`, + }); + } + if (iface.Link?.OperSettings?.Duplex) { + infoItems.push({ + label: "Duplex Mode", + value: `${iface.Link.OperSettings.Duplex} Duplex`, + }); + } + } + + // IPv4 Information + if (iface.IPv4) { + infoItems.push({ + label: "IPv4 Status", + value: iface.IPv4.Enabled ? "Enabled" : "Disabled", + }); + if (iface.IPv4.Config) { + const dhcpEnabled = iface.IPv4.Config.DHCP === true; + infoItems.push({ + label: "IPv4 DHCP", + value: dhcpEnabled ? "Enabled" : "Disabled", + }); + } + if ( + iface.IPv4.Config.Manual && + iface.IPv4.Config.Manual.length > 0 + ) { + infoItems.push({ + label: "IPv4 Address", + value: iface.IPv4.Config.Manual?.map( + (addr) => + `${addr.Address ?? "N/A"}/${ + addr.PrefixLength ?? "N/A" + }`, + ).join(", "), + }); + } + if (iface.IPv4.Config.LinkLocal) { + infoItems.push({ + label: "IPv4 Address", + value: `${iface.IPv4.Config.LinkLocal.Address ?? "N/A"}/${iface.IPv4.Config.LinkLocal.PrefixLength ?? "N/A"}`, + }); + } + if (iface.IPv4.Config.FromDHCP) { + infoItems.push({ + label: "IPv4 Address", + value: `${iface.IPv4.Config.FromDHCP.Address ?? "N/A"}/${iface.IPv4.Config.FromDHCP.PrefixLength ?? "N/A"}`, + }); + } + } + + // IPv6 Information + if (iface.IPv6) { + infoItems.push({ + label: "IPv6 Status", + value: iface.IPv6.Enabled ? "Enabled" : "Disabled", + }); + if (iface.IPv6.Config) { + infoItems.push({ + label: "IPv6 DHCP", + value: iface.IPv6.Config.DHCP, + }); + if (iface.IPv6.Config?.Manual.length > 0) { + infoItems.push({ + label: "IPv6 Address", + value: iface.IPv6.Config.Manual?.map( + (addr) => + `${addr.Address ?? "N/A"}/${ + addr.PrefixLength ?? "N/A" + }`, + ).join(", "), + }); + } + if (iface.IPv6.Config?.LinkLocal.length > 0) { + infoItems.push({ + label: "IPv6 Address", + value: iface.IPv6.Config.LinkLocal?.map( + (addr) => + `${addr.Address ?? "N/A"}/${ + addr.PrefixLength ?? "N/A" + }`, + ).join(", "), + }); + } + if (iface.IPv6.Config?.FromDHCP.length > 0) { + infoItems.push({ + label: "IPv6 Address", + value: iface.IPv6.Config.FromDHCP?.map( + (addr) => + `${addr.Address ?? "N/A"}/${ + addr.PrefixLength ?? "N/A" + }`, + ).join(", "), + }); + } + if (iface.IPv6.Config?.FromRA.length > 0) { + infoItems.push({ + label: "IPv6 Address", + value: iface.IPv6.Config.FromRA?.map( + (addr) => + `${addr.Address ?? "N/A"}/${ + addr.PrefixLength ?? "N/A" + }`, + ).join(", "), + }); + } + } + } + + // Token + infoItems.push({ + label: "Token", + value: iface.token, + }); + + return ( + + + + {infoItems.map((item) => ( + + + + {item.label} + + + + + {item.value} + + + + ))} + +
+
+ ); + }, + )} +
+ + {/* Network Interface Configuration Dialog */} + + + + + Configure Network Interfaces + + + {selectedInterface?.token || "Network Interface"} + + + + + + {/* Interface Selection */} + + + + Selected Interface + + + + Status + + + + + + {/* General Settings */} + + setMtu(e.target.value ? Number(e.target.value) : "") + } + helperText="Leave empty to use default value." + /> + + {/* Link Configuration */} + {selectedInterface?.Link?.AdminSettings && ( + + + Link Configuration{" "} + + (some cameras ignore this!) + + + + + + Auto Negotiation + + + + Duplex Mode + + + + + + setLinkSpeed( + e.target.value ? Number(e.target.value) : "", + ) + } + placeholder="100" + helperText="Common values: 10, 100, 1000" + /> + + + )} + + {/* IPv4 Settings */} + {selectedInterface?.IPv4 && ( + + + IPv4 Configuration + + setIpv4Enabled(e.target.checked)} + /> + } + label="IPv4 Enabled" + /> + {ipv4Enabled && ( + <> + setIpv4DHCP(e.target.checked)} + /> + } + label="Use DHCP" + /> + {!ipv4DHCP && ( + + {ipv4ManualAddresses.map((addr, index) => ( + + + handleIpv4AddressChange( + index, + "address", + e.target.value, + ) + } + placeholder="192.168.1.100" + /> + + handleIpv4AddressChange( + index, + "prefixLength", + e.target.value + ? Number(e.target.value) + : "", + ) + } + sx={{ width: 120 }} + placeholder="24" + /> + handleRemoveIpv4Address(index)} + disabled={ipv4ManualAddresses.length === 1} + color="error" + > + + + + ))} + + + )} + + )} + + )} + + {/* IPv6 Settings */} + {selectedInterface?.IPv6 && ( + + + IPv6 Configuration + + setIpv6Enabled(e.target.checked)} + /> + } + label="IPv6 Enabled" + /> + {ipv6Enabled && ( + <> + + setIpv6AcceptRouterAdvert(e.target.checked) + } + /> + } + label="Accept Router Advertisement" + /> + + DHCP Mode + + + {ipv6DHCP === "Off" && ( + + {ipv6ManualAddresses.map((addr, index) => ( + + + handleIpv6AddressChange( + index, + "address", + e.target.value, + ) + } + placeholder="2001:db8::1" + /> + + handleIpv6AddressChange( + index, + "prefixLength", + e.target.value + ? Number(e.target.value) + : "", + ) + } + sx={{ width: 120 }} + placeholder="64" + /> + handleRemoveIpv6Address(index)} + disabled={ipv6ManualAddresses.length === 1} + color="error" + > + + + + ))} + + + )} + + )} + + )} + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceNetworkProtocols.tsx b/frontend/src/components/tuning/onvif/device/DeviceNetworkProtocols.tsx new file mode 100644 index 000000000..ee9c42d2a --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceNetworkProtocols.tsx @@ -0,0 +1,281 @@ +import { Help, Vlan } from "@carbon/icons-react"; +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useState } from "react"; + +import { useToast } from "hooks/UseToast"; +import { useFormChanges } from "hooks/useFormChanges"; +import { + useGetDeviceNetworkProtocols, + useSetDeviceNetworkProtocols, +} from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +interface DeviceNetworkProtocolsProps { + cameraIdentifier: string; + deviceCapabilities?: any; +} + +export function DeviceNetworkProtocols({ + cameraIdentifier, + deviceCapabilities, +}: DeviceNetworkProtocolsProps) { + // Check if network configuration is not supported + const isNetworkConfigNotSupported = + deviceCapabilities?.System?.NetworkConfigNotSupported === true; + + const TITLE = "Network Protocols"; + const DESC = + "Manage network protocols for this device. The port on each protocol must be unique. Some cameras will 'perform' or 'require' a reboot after this configuration is changed."; + + const toast = useToast(); + + // ONVIF API hooks + const { data, isLoading, isError, error } = useGetDeviceNetworkProtocols( + cameraIdentifier, + !isNetworkConfigNotSupported, + ); + const setProtocolsMutation = useSetDeviceNetworkProtocols(cameraIdentifier); + + const protocols = data?.network_protocols; + + // Section state + const [dialogOpen, setDialogOpen] = useState(false); + const [editingProtocol, setEditingProtocol] = useState<{ + Name: string; + Enabled: boolean; + Port: number[]; + } | null>(null); + const [protocolEnabled, setProtocolEnabled] = useState(false); + const [protocolPorts, setProtocolPorts] = useState(""); + + // Store original values to detect changes + const [originalValues, setOriginalValues] = useState<{ + enabled: boolean; + ports: string; + }>({ + enabled: false, + ports: "", + }); + + // Handlers + const handleEditProtocol = (protocol: { + Name: string; + Enabled: boolean; + Port: number[]; + }) => { + setEditingProtocol(protocol); + setProtocolEnabled(protocol.Enabled); + const portString = protocol.Port[0]?.toString() || ""; + setProtocolPorts(portString); + setOriginalValues({ + enabled: protocol.Enabled, + ports: portString, + }); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + // Check if there are any changes using useFormChanges hook + const currentValues = { + enabled: protocolEnabled, + ports: protocolPorts, + }; + + const hasChanges = useFormChanges(currentValues, originalValues); + + const handleUpdateProtocol = () => { + if (!editingProtocol || !protocols) return; + + // Parse port as single number + const port = parseInt(protocolPorts.trim(), 10); + + if (isNaN(port) || port <= 0 || port > 65535) { + toast.error("Please enter a valid port number (1-65535)"); + return; + } + + // Build updated protocols array + const updatedProtocols = protocols.map( + (p: { Name: string; Enabled: boolean; Port: number[] }) => { + if (p.Name === editingProtocol.Name) { + return { + Name: p.Name, + Enabled: protocolEnabled, + Port: [port], // Wrap single port in array + }; + } + return p; + }, + ); + + setProtocolsMutation.mutate( + { network_protocols: updatedProtocols }, + { + onSuccess: () => { + toast.success( + `Protocol "${editingProtocol.Name}" updated successfully`, + ); + handleDialogClose(); + }, + onError: (err) => { + toast.error(err?.message || "Failed to update protocol"); + }, + }, + ); + }; + + return ( + + + + + {TITLE} + + + + + + + {/* Protocols List */} + + {protocols?.map( + (protocol: { Name: string; Enabled: boolean; Port: number[] }) => ( + + } + color={protocol.Enabled ? "success" : "error"} + variant={protocol.Enabled ? "filled" : "outlined"} + onClick={() => handleEditProtocol(protocol)} + /> + + ), + )} + + + {/* Configure Protocol Dialog */} + + + {editingProtocol + ? `Configure ${editingProtocol.Name} Protocol` + : "Configure Network Protocols"} + + + {editingProtocol ? ( + + + Status + + + Enable or disable this network protocol. + + + setProtocolPorts(e.target.value)} + placeholder="e.g., 80" + helperText="Enter port number (1-65535)." + slotProps={{ + htmlInput: { + min: 1, + max: 65535, + }, + }} + /> + + ) : ( + + Click on a protocol chip above to configure it. + + )} + + + + {editingProtocol && ( + + )} + + + + + ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceNetworkSettings.tsx b/frontend/src/components/tuning/onvif/device/DeviceNetworkSettings.tsx new file mode 100644 index 000000000..394e05886 --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceNetworkSettings.tsx @@ -0,0 +1,537 @@ +import { GlobalFilters, Help } from "@carbon/icons-react"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + TextField, + Tooltip, + Typography, + tableCellClasses, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useEffect, useState } from "react"; + +import { useToast } from "hooks/UseToast"; +import { useFormChanges } from "hooks/useFormChanges"; +import { + useGetDeviceDiscoveryMode, + useGetDeviceHostname, + useGetDeviceNetworkDefaultGateway, + useSetDeviceDiscoveryMode, + useSetDeviceHostname, + useSetDeviceHostnameFromDHCP, + useSetDeviceNetworkDefaultGateway, +} from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +interface DeviceSettingsProps { + cameraIdentifier: string; + deviceCapabilities?: any; +} + +export function DeviceNetworkSettings({ + cameraIdentifier, + deviceCapabilities, +}: DeviceSettingsProps) { + // Check if network configuration is not supported + const isNetworkConfigNotSupported = + deviceCapabilities?.System?.NetworkConfigNotSupported === true; + + // Check if discovery is not supported + const isDiscoveryNotSupported = + deviceCapabilities?.System?.DiscoveryNotSupported === true; + + // Check if hostname from DHCP is supported + const isHostnameFromDHCPSupported = + deviceCapabilities?.Network?.HostnameFromDHCP === true; + + const TITLE = "Network Settings"; + const DESC = + "Manage network settings for this device. Usually this configuration will change to 'default value' if the camera do reboot."; + + const theme = useTheme(); + const toast = useToast(); + + // ONVIF API hooks + const { data, isLoading, isError, error } = useGetDeviceNetworkDefaultGateway( + cameraIdentifier, + !isNetworkConfigNotSupported, + ); + const setNetworkGatewayMutation = + useSetDeviceNetworkDefaultGateway(cameraIdentifier); + + const { data: hostnameData } = useGetDeviceHostname( + cameraIdentifier, + !isNetworkConfigNotSupported, + ); + const setHostnameMutation = useSetDeviceHostname(cameraIdentifier); + const setHostnameFromDHCPMutation = + useSetDeviceHostnameFromDHCP(cameraIdentifier); + + const { data: discoveryData } = useGetDeviceDiscoveryMode( + cameraIdentifier, + !isDiscoveryNotSupported, + ); + const setDiscoveryModeMutation = useSetDeviceDiscoveryMode(cameraIdentifier); + + const networkSettingsItems: { label: string; value: string | undefined }[] = + []; + + const networkGatewayData = data?.network_default_gateway as + | { + IPv4Address?: string[]; + IPv6Address?: string[]; + } + | undefined; + + // Section state + const [dialogOpen, setDialogOpen] = useState(false); + const [ipVersion, setIpVersion] = useState<"ipv4" | "ipv6">("ipv4"); + const [ipv4Address, setIpv4Address] = useState(""); + const [ipv6Address, setIpv6Address] = useState(""); + const [hostnameFromDHCP, setHostnameFromDHCP] = useState(false); + const [hostname, setHostname] = useState(""); + const [discoveryMode, setDiscoveryMode] = useState(""); + + // Store original values to detect changes + const [originalValues, setOriginalValues] = useState<{ + ipv4: string; + ipv6: string; + hostname: string; + hostnameFromDHCP: boolean; + discoveryMode: string; + }>({ + ipv4: "", + ipv6: "", + hostname: "", + hostnameFromDHCP: false, + discoveryMode: "", + }); + + // Sync hostnameFromDHCP state with API data + useEffect(() => { + if (hostnameData?.hostname) { + const hostnameFromData = hostnameData.hostname; + if (typeof hostnameFromData === "object" && hostnameFromData !== null) { + const fromDHCP = (hostnameFromData as { FromDHCP?: boolean }).FromDHCP; + if (fromDHCP !== undefined) { + setHostnameFromDHCP(fromDHCP); + } + } + } + }, [hostnameData]); + + // Data extraction for display + if (networkGatewayData && typeof networkGatewayData === "object") { + if (Array.isArray(networkGatewayData.IPv4Address)) { + networkGatewayData.IPv4Address.forEach((ipv4, idx) => { + if (ipv4) { + networkSettingsItems.push({ + label: + networkGatewayData.IPv4Address!.length > 1 + ? `IPv4 Gateway (#${idx + 1})` + : "IPv4 Gateway", + value: ipv4, + }); + } + }); + } + if (Array.isArray(networkGatewayData.IPv6Address)) { + networkGatewayData.IPv6Address.forEach((ipv6, idx) => { + if (ipv6) { + networkSettingsItems.push({ + label: + networkGatewayData.IPv6Address!.length > 1 + ? `IPv6 Gateway (#${idx + 1})` + : "IPv6 Gateway", + value: ipv6, + }); + } + }); + } + } + + if (hostnameData?.hostname) { + const hostnameFromData = hostnameData.hostname; + if (typeof hostnameFromData === "object" && hostnameFromData !== null) { + const hostnameName = (hostnameFromData as { Name?: string }).Name; + const fromDHCP = (hostnameFromData as { FromDHCP?: boolean }).FromDHCP; + + if (hostnameName) { + networkSettingsItems.push({ + label: "Hostname", + value: hostnameName, + }); + } + + if (fromDHCP !== undefined) { + networkSettingsItems.push({ + label: "Hostname DHCP", + value: fromDHCP ? "Enabled" : "Disabled", + }); + } + } else if (typeof hostnameFromData === "string") { + networkSettingsItems.push({ label: "Hostname", value: hostnameFromData }); + } + } + + if (isDiscoveryNotSupported) { + networkSettingsItems.push({ + label: "Discovery Mode", + value: "Not Supported", + }); + } else if (discoveryData?.discovery_mode) { + networkSettingsItems.push({ + label: "Discovery Mode", + value: discoveryData.discovery_mode, + }); + } + + // Handlers + const handleEditGateway = () => { + let ipv4 = ""; + let ipv6 = ""; + + if (networkGatewayData) { + if ( + networkGatewayData.IPv4Address && + networkGatewayData.IPv4Address.length > 0 + ) { + ipv4 = networkGatewayData.IPv4Address[0]; + setIpv4Address(ipv4); + setIpVersion("ipv4"); + } else if ( + networkGatewayData.IPv6Address && + networkGatewayData.IPv6Address.length > 0 + ) { + ipv6 = networkGatewayData.IPv6Address[0]; + setIpv6Address(ipv6); + setIpVersion("ipv6"); + } else { + setIpv4Address(""); + setIpv6Address(""); + setIpVersion("ipv4"); + } + } + + // Pre-fill hostname + let hostnameValue = ""; + if (hostnameData?.hostname) { + const hostnameObj = hostnameData.hostname; + if (typeof hostnameObj === "object" && hostnameObj !== null) { + hostnameValue = (hostnameObj as { Name?: string }).Name || ""; + } else if (typeof hostnameObj === "string") { + hostnameValue = hostnameObj; + } + } + setHostname(hostnameValue); + + // Pre-fill discovery mode + const discoveryModeValue = discoveryData?.discovery_mode || ""; + setDiscoveryMode(discoveryModeValue); + + // Store original values + setOriginalValues({ + ipv4, + ipv6, + hostname: hostnameValue, + hostnameFromDHCP, + discoveryMode: discoveryModeValue, + }); + + setDialogOpen(true); + }; + + // Check if there are any changes + const currentValues = { + ipv4: ipVersion === "ipv4" ? ipv4Address : "", + ipv6: ipVersion === "ipv6" ? ipv6Address : "", + hostname, + hostnameFromDHCP, + discoveryMode, + }; + + const hasChanges = useFormChanges(currentValues, originalValues); + + // Check if gateway address is valid when gateway settings changed + const currentIpv4 = ipVersion === "ipv4" ? ipv4Address : ""; + const currentIpv6 = ipVersion === "ipv6" ? ipv6Address : ""; + const isGatewayChanged = + currentIpv4 !== originalValues.ipv4 || currentIpv6 !== originalValues.ipv6; + const isGatewayAddressEmpty = + ipVersion === "ipv4" ? !ipv4Address.trim() : !ipv6Address.trim(); + const isGatewayInvalid = isGatewayChanged && isGatewayAddressEmpty; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleSave = async () => { + try { + const promises = []; + + // Check if gateway changed + if (isGatewayChanged) { + promises.push( + setNetworkGatewayMutation.mutateAsync({ + ipv4_address: currentIpv4 || undefined, + ipv6_address: currentIpv6 || undefined, + }), + ); + } + + // Check if hostname changed + if (hostname !== originalValues.hostname && hostname.trim()) { + promises.push(setHostnameMutation.mutateAsync(hostname.trim())); + } + + // Check if hostname from DHCP changed (only if supported) + if ( + isHostnameFromDHCPSupported && + hostnameFromDHCP !== originalValues.hostnameFromDHCP + ) { + promises.push( + setHostnameFromDHCPMutation.mutateAsync(hostnameFromDHCP), + ); + } + + // Check if discovery mode changed (only if discovery is supported) + if ( + !isDiscoveryNotSupported && + discoveryMode !== originalValues.discoveryMode && + discoveryMode.trim() + ) { + const discoverable = discoveryMode === "Discoverable"; + promises.push(setDiscoveryModeMutation.mutateAsync(discoverable)); + } + + if (promises.length > 0) { + await Promise.all(promises); + toast.success("Network settings updated successfully"); + handleDialogClose(); + } else { + toast.info("No changes to save"); + handleDialogClose(); + } + } catch (err) { + toast.error("Failed to update network settings"); + } + }; + + return ( + + + + + {TITLE} + + + + + + + + {/* Network Settings Table */} + + + + {networkSettingsItems + .filter((item) => item.value) + .map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + +
+
+ + {/* Configure Network Settings Dialog */} + + Configure Network Settings + + + + + + IP Version + + + + {ipVersion === "ipv4" ? ( + setIpv4Address(e.target.value)} + placeholder="e.g., 192.168.1.1" + /> + ) : ( + setIpv6Address(e.target.value)} + placeholder="e.g., fe80::1" + /> + )} + + + Set the network default gateway address. + + + + + + From DHCP + + + setHostname(e.target.value)} + placeholder="Enter device hostname" + /> + + + Set the network hostname for this device. + + + + + Discovery Mode + + + {isDiscoveryNotSupported + ? "Discovery mode configuration is not supported by this device." + : "Set the device discoverability on the network via WS-Discovery."} + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceScopes.tsx b/frontend/src/components/tuning/onvif/device/DeviceScopes.tsx new file mode 100644 index 000000000..d441297fb --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceScopes.tsx @@ -0,0 +1,281 @@ +import { AddAlt, Help } from "@carbon/icons-react"; +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useState } from "react"; + +import { useToast } from "hooks/UseToast"; +import { + useAddDeviceScopes, + useGetDeviceScopes, + useRemoveDeviceScopes, + useSetDeviceScopes, +} from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +const prefix = "onvif://www.onvif.org/"; + +interface DeviceScopesProps { + cameraIdentifier: string; + deviceCapabilities?: any; +} + +export function DeviceScopes({ + cameraIdentifier, + deviceCapabilities, +}: DeviceScopesProps) { + // Check if discovery is not supported + const isDiscoveryNotSupported = + deviceCapabilities?.System?.DiscoveryNotSupported === true; + + const TITLE = "Discovery Scopes"; + const DESC = + "Manage ONVIF discovery scopes, which are used for device discovery in WS-Discovery."; + + const toast = useToast(); + + // ONVIF API hooks + const { data, isLoading, isError, error } = useGetDeviceScopes( + cameraIdentifier, + !isDiscoveryNotSupported, + ); + const addScopesMutation = useAddDeviceScopes(cameraIdentifier); + const removeScopesMutation = useRemoveDeviceScopes(cameraIdentifier); + const setScopesMutation = useSetDeviceScopes(cameraIdentifier); + + const scopes = data?.scopes; + + // Section state + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState<"add" | "edit">("add"); + const [newScope, setNewScope] = useState(""); + const [editingScope, setEditingScope] = useState(null); + + // Helper to extract readable name from scope item + const getScopeName = (scopeItem: string): string => { + const name = scopeItem.startsWith(prefix) + ? scopeItem.slice(prefix.length) + : scopeItem; + return decodeURI(name); + }; + + // Handlers + const handleAddScope = () => { + setDialogMode("add"); + setDialogOpen(true); + }; + + const handleEditScope = (scopeItem: string) => { + setDialogMode("edit"); + setNewScope(getScopeName(scopeItem)); + setEditingScope(scopeItem); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setNewScope(""); + }; + + const handleCreateScope = () => { + if (newScope) { + addScopesMutation.mutate([`${prefix}${encodeURI(newScope)}`], { + onSuccess: () => { + toast.success(`Scope "${newScope}" created successfully`); + handleDialogClose(); + }, + onError: (err) => { + toast.error(err?.message || "Failed to create scope"); + }, + }); + } + }; + + const handleUpdateScope = () => { + if (newScope && editingScope && scopes) { + // Get all non-Fixed scopes as array of ScopeItem strings + const configurableScopes = scopes + .filter((scope) => scope.ScopeDef !== "Fixed") + .map((scope) => { + // Replace the edited scope with new value + if (scope.ScopeItem === editingScope) { + return `${prefix}${encodeURI(newScope)}`; + } + return scope.ScopeItem; + }); + + setScopesMutation.mutate(configurableScopes, { + onSuccess: () => { + toast.success("Scope updated successfully"); + handleDialogClose(); + }, + onError: (err) => { + toast.error(err?.message || "Failed to update scope"); + }, + }); + } + }; + + const handleDeleteScope = () => { + if (editingScope) { + removeScopesMutation.mutate(encodeURI(editingScope), { + onSuccess: () => { + toast.success("Scope deleted successfully"); + handleDialogClose(); + }, + onError: (err) => { + toast.error(err?.message || "Failed to delete scope"); + }, + }); + } + }; + + return ( + + + + + {TITLE} + + + + + + + + {/* Scopes List */} + + {scopes?.map((scope) => ( + + handleEditScope(scope.ScopeItem) + : undefined + } + sx={{ + cursor: scope.ScopeDef !== "Fixed" ? "pointer" : "default", + }} + /> + + ))} + + + {/* Add/Edit Scope Dialog */} + + + {dialogMode === "add" ? "Add Scope" : "Edit Scope"} + + + setNewScope(e.target.value)} + placeholder="e.g., location/office room 1" + helperText="Spaces and special characters will be automatically encoded" + /> + + + + {dialogMode === "add" && ( + + )} + {dialogMode === "edit" && ( + <> + + + + )} + + + + + ); +} diff --git a/frontend/src/components/tuning/onvif/device/DeviceServices.tsx b/frontend/src/components/tuning/onvif/device/DeviceServices.tsx new file mode 100644 index 000000000..97852baff --- /dev/null +++ b/frontend/src/components/tuning/onvif/device/DeviceServices.tsx @@ -0,0 +1,135 @@ +import { + Activity, + Api, + Camera, + Chip as ChipIcon, + EventSchedule, + Help, + Image, + Move, + Recording, + Repeat, + Search, + Video, +} from "@carbon/icons-react"; +import { Box, Chip, Stack, Tooltip, Typography } from "@mui/material"; + +import { useGetDeviceServices } from "lib/api/actions/onvif/device"; + +import { QueryWrapper } from "../../config/QueryWrapper"; + +interface DeviceServicesProps { + cameraIdentifier: string; +} + +// Helper to get icon for service +const getServiceIcon = (serviceName: string) => { + const lowerName = serviceName.toLowerCase(); + + if (lowerName.includes("media")) return