From 43ade86796c7ebd354c1173a54da6a18f64deb77 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Apr 2023 05:08:53 -0600 Subject: [PATCH] Support Controlling PTZ Cameras Via WebUI (#4715) * Add support for ptz commands via websocket * Fix startup issues * Fix bugs * Set config manually * Add more commands * Add presets * Add zooming * Fixes * Set name * Cleanup * Add ability to set presets from UI * Add ability to set preset from UI * Cleanup for errors * Ui tweaks * Add visual design for pan / tilt * Add pan/tilt support * Support zooming * Try to set wsdl * Fix duplicate logs * Catch auth errors * Don't init onvif for disabled cameras * Fix layout sizing * Don't comment out * Fix formatting * Add ability to control camera with keyboard shortcuts * Disallow user selection * Fix mobile pressing * Remove logs * Substitute onvif password * Add ptz controls ot birdseye * Put wsdl back * Add padding * Formatting * Catch onvif error * Optimize layout for mobile and web * Place ptz controls next to birdseye view in large layout * Fix pt support * Center text titles * Update tests * Update docs * Write camera docs for PTZ * Add MQTT docs for PTZ * Add ptz info docs for http * Fix test * Make half width when full screen * Fix preset panel logic * Fix parsing * Update mqtt.md * Catch preset error * Add onvif example to docs * Remove template example from main camera docs --- docs/docs/configuration/cameras.md | 18 ++ docs/docs/configuration/index.md | 21 ++ docs/docs/integrations/api.md | 4 + docs/docs/integrations/mqtt.md | 13 +- frigate/app.py | 10 +- frigate/comms/dispatcher.py | 29 ++- frigate/comms/mqtt.py | 6 + frigate/config.py | 19 ++ frigate/http.py | 11 + frigate/log.py | 8 + frigate/ptz.py | 219 +++++++++++++++++++ frigate/test/test_http.py | 59 ++++- requirements-wheels.txt | 1 + web/src/api/ws.jsx | 9 + web/src/components/CameraControlPanel.jsx | 248 ++++++++++++++++++++++ web/src/icons/ArrowDownDouble.jsx | 19 ++ web/src/icons/ArrowLeftDouble.jsx | 19 ++ web/src/icons/ArrowRightDouble.jsx | 11 +- web/src/icons/ArrowUpDouble.jsx | 19 ++ web/src/routes/Birdseye.jsx | 34 ++- web/src/routes/Camera.jsx | 8 + 21 files changed, 769 insertions(+), 16 deletions(-) create mode 100644 frigate/ptz.py create mode 100644 web/src/components/CameraControlPanel.jsx create mode 100644 web/src/icons/ArrowDownDouble.jsx create mode 100644 web/src/icons/ArrowLeftDouble.jsx create mode 100644 web/src/icons/ArrowUpDouble.jsx diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index d8fefed8f..8f907cb3f 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -48,3 +48,21 @@ cameras: ``` For camera model specific settings check the [camera specific](camera_specific.md) infos. + +## Setting up camera PTZ controls + +Add onvif config to camera + +```yaml +cameras: + back: + ffmpeg: + ... + onvif: + host: 10.0.10.10 + port: 8000 + user: admin + password: password +``` + +then PTZ controls will be available in the cameras WebUI. diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md index 113884d6d..93a266b56 100644 --- a/docs/docs/configuration/index.md +++ b/docs/docs/configuration/index.md @@ -55,6 +55,14 @@ mqtt: - path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast ``` +```yaml +onvif: + host: 10.0.10.10 + port: 8000 + user: "{FRIGATE_RTSP_USER}" + password: "{FRIGATE_RTSP_PASSWORD}" +``` + ```yaml mqtt: # Optional: Enable mqtt server (default: shown below) @@ -497,6 +505,19 @@ cameras: # Optional: Whether or not to show the camera in the Frigate UI (default: shown below) dashboard: True + # Optional: connect to ONVIF camera + # to enable PTZ controls. + onvif: + # Required: host of the camera being connected to. + host: 0.0.0.0 + # Optional: ONVIF port for device (default: shown below). + port: 8000 + # Optional: username for login. + # NOTE: Some devices require admin to access ONVIF. + user: admin + # Optional: password for login. + password: admin + # Optional ui: # Optional: Set the default live mode for cameras in the UI (default: shown below) diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index cc5a6576a..9aec392a4 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -291,3 +291,7 @@ Get ffprobe output for camera feed paths. | param | Type | Description | | ------- | ------ | ---------------------------------- | | `paths` | string | `,` separated list of camera paths | + +### `GET /api//ptz/info` + +Get PTZ info for the camera. diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 2a2195abd..814656258 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -157,4 +157,15 @@ Topic to adjust motion contour area for a camera. Expected value is an integer. ### `frigate//motion_contour_area/state` -Topic with current motion contour area for a camera. Published value is an integer. \ No newline at end of file +Topic with current motion contour area for a camera. Published value is an integer. + +### `frigate//ptz` + +Topic to send PTZ commands to camera. + +| Command | Description | +| ---------------------- | --------------------------------------------------------------------------------------- | +| `preset-` | send command to move to preset with name `` | +| `MOVE_` | send command to continuously move in ``, possible values are [UP, DOWN, LEFT, RIGHT] | +| `ZOOM_` | send command to continuously zoom ``, possible values are [IN, OUT] | +| `STOP` | send command to stop moving | diff --git a/frigate/app.py b/frigate/app.py index 8c1cd4433..54d2825c8 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -27,6 +27,7 @@ from frigate.models import Event, Recordings, Timeline from frigate.object_processing import TrackedObjectProcessor from frigate.output import output_frames from frigate.plus import PlusApi +from frigate.ptz import OnvifController from frigate.record import RecordingCleanup, RecordingMaintainer from frigate.stats import StatsEmitter, stats_init from frigate.storage import StorageMaintainer @@ -173,9 +174,13 @@ class FrigateApp: self.stats_tracking, self.detected_frames_processor, self.storage_maintainer, + self.onvif_controller, self.plus_api, ) + def init_onvif(self) -> None: + self.onvif_controller = OnvifController(self.config) + def init_dispatcher(self) -> None: comms: list[Communicator] = [] @@ -183,7 +188,9 @@ class FrigateApp: comms.append(MqttClient(self.config)) comms.append(WebSocketClient(self.config)) - self.dispatcher = Dispatcher(self.config, self.camera_metrics, comms) + self.dispatcher = Dispatcher( + self.config, self.onvif_controller, self.camera_metrics, comms + ) def start_detectors(self) -> None: for name in self.config.cameras.keys(): @@ -382,6 +389,7 @@ class FrigateApp: self.set_log_levels() self.init_queues() self.init_database() + self.init_onvif() self.init_dispatcher() except Exception as e: print(e) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index d304509e4..7a2c98392 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -7,6 +7,7 @@ from typing import Any, Callable from abc import ABC, abstractmethod from frigate.config import FrigateConfig +from frigate.ptz import OnvifController, OnvifCommandEnum from frigate.types import CameraMetricsTypes from frigate.util import restart_frigate @@ -39,10 +40,12 @@ class Dispatcher: def __init__( self, config: FrigateConfig, + onvif: OnvifController, camera_metrics: dict[str, CameraMetricsTypes], communicators: list[Communicator], ) -> None: self.config = config + self.onvif = onvif self.camera_metrics = camera_metrics self.comms = communicators @@ -63,12 +66,21 @@ class Dispatcher: """Handle receiving of payload from communicators.""" if topic.endswith("set"): try: + # example /cam_name/detect/set payload=ON|OFF camera_name = topic.split("/")[-3] command = topic.split("/")[-2] self._camera_settings_handlers[command](camera_name, payload) - except Exception as e: + except IndexError as e: logger.error(f"Received invalid set command: {topic}") return + elif topic.endswith("ptz"): + try: + # example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP... + camera_name = topic.split("/")[-2] + self._on_ptz_command(camera_name, payload) + except IndexError as e: + logger.error(f"Received invalid ptz command: {topic}") + return elif topic == "restart": restart_frigate() @@ -204,3 +216,18 @@ class Dispatcher: snapshots_settings.enabled = False self.publish(f"{camera_name}/snapshots/state", payload, retain=True) + + def _on_ptz_command(self, camera_name: str, payload: str) -> None: + """Callback for ptz topic.""" + try: + if "preset" in payload.lower(): + command = OnvifCommandEnum.preset + param = payload.lower().split("-")[1] + else: + command = OnvifCommandEnum[payload.lower()] + param = "" + + self.onvif.handle_command(camera_name, command, param) + logger.info(f"Setting ptz command to {command} for {camera_name}") + except KeyError as k: + logger.error(f"Invalid PTZ command {payload}: {k}") diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index d106aae71..37fdd44ae 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -167,6 +167,12 @@ class MqttClient(Communicator): # type: ignore[misc] self.on_mqtt_command, ) + if self.config.cameras[name].onvif.host: + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/ptz", + self.on_mqtt_command, + ) + self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command ) diff --git a/frigate/config.py b/frigate/config.py index b62fd29fe..b7d965282 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -125,6 +125,13 @@ class MqttConfig(FrigateBaseModel): return v +class OnvifConfig(FrigateBaseModel): + host: str = Field(default="", title="Onvif Host") + port: int = Field(default=8000, title="Onvif Port") + user: Optional[str] = Field(title="Onvif Username") + password: Optional[str] = Field(title="Onvif Password") + + class RetainModeEnum(str, Enum): all = "all" motion = "motion" @@ -607,6 +614,9 @@ class CameraConfig(FrigateBaseModel): detect: DetectConfig = Field( default_factory=DetectConfig, title="Object detection configuration." ) + onvif: OnvifConfig = Field( + default_factory=OnvifConfig, title="Camera Onvif Configuration." + ) ui: CameraUiConfig = Field( default_factory=CameraUiConfig, title="Camera UI Modifications." ) @@ -939,6 +949,15 @@ class FrigateConfig(FrigateBaseModel): for input in camera_config.ffmpeg.inputs: input.path = input.path.format(**FRIGATE_ENV_VARS) + # ONVIF substitution + if camera_config.onvif.user or camera_config.onvif.password: + camera_config.onvif.user = camera_config.onvif.user.format( + **FRIGATE_ENV_VARS + ) + camera_config.onvif.password = camera_config.onvif.password.format( + **FRIGATE_ENV_VARS + ) + # Add default filters object_keys = camera_config.objects.track if camera_config.objects.filters is None: diff --git a/frigate/http.py b/frigate/http.py index efe99c182..cd2a0c523 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -36,6 +36,7 @@ from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR from frigate.models import Event, Recordings, Timeline from frigate.object_processing import TrackedObject from frigate.plus import PlusApi +from frigate.ptz import OnvifController from frigate.stats import stats_snapshot from frigate.util import ( clean_camera_user_pass, @@ -59,6 +60,7 @@ def create_app( stats_tracking, detected_frames_processor, storage_maintainer: StorageMaintainer, + onvif: OnvifController, plus_api: PlusApi, ): app = Flask(__name__) @@ -77,6 +79,7 @@ def create_app( app.stats_tracking = stats_tracking app.detected_frames_processor = detected_frames_processor app.storage_maintainer = storage_maintainer + app.onvif = onvif app.plus_api = plus_api app.camera_error_image = None app.hwaccel_errors = [] @@ -994,6 +997,14 @@ def mjpeg_feed(camera_name): return "Camera named {} not found".format(camera_name), 404 +@bp.route("//ptz/info") +def camera_ptz_info(camera_name): + if camera_name in current_app.frigate_config.cameras: + return jsonify(current_app.onvif.get_camera_info(camera_name)) + else: + return "Camera named {} not found".format(camera_name), 404 + + @bp.route("//latest.jpg") def latest_frame(camera_name): draw_options = { diff --git a/frigate/log.py b/frigate/log.py index a8041592f..67866942c 100644 --- a/frigate/log.py +++ b/frigate/log.py @@ -19,6 +19,10 @@ from frigate.util import clean_camera_user_pass def listener_configurer() -> None: root = logging.getLogger() + + if root.hasHandlers(): + root.handlers.clear() + console_handler = logging.StreamHandler() formatter = logging.Formatter( "[%(asctime)s] %(name)-30s %(levelname)-8s: %(message)s", "%Y-%m-%d %H:%M:%S" @@ -31,6 +35,10 @@ def listener_configurer() -> None: def root_configurer(queue: Queue) -> None: h = handlers.QueueHandler(queue) root = logging.getLogger() + + if root.hasHandlers(): + root.handlers.clear() + root.addHandler(h) root.setLevel(logging.INFO) diff --git a/frigate/ptz.py b/frigate/ptz.py new file mode 100644 index 000000000..e2c21618e --- /dev/null +++ b/frigate/ptz.py @@ -0,0 +1,219 @@ +"""Configure and control camera via onvif.""" + +import logging +import site + +from enum import Enum +from onvif import ONVIFCamera, ONVIFError + +from frigate.config import FrigateConfig + + +logger = logging.getLogger(__name__) + + +class OnvifCommandEnum(str, Enum): + """Holds all possible move commands""" + + init = "init" + move_down = "move_down" + move_left = "move_left" + move_right = "move_right" + move_up = "move_up" + preset = "preset" + stop = "stop" + zoom_in = "zoom_in" + zoom_out = "zoom_out" + + +class OnvifController: + def __init__(self, config: FrigateConfig) -> None: + self.cams: dict[str, ONVIFCamera] = {} + + for cam_name, cam in config.cameras.items(): + if not cam.enabled: + continue + + if cam.onvif.host: + try: + self.cams[cam_name] = { + "onvif": ONVIFCamera( + cam.onvif.host, + cam.onvif.port, + cam.onvif.user, + cam.onvif.password, + wsdl_dir=site.getsitepackages()[0].replace( + "dist-packages", "site-packages" + ) + + "/wsdl", + ), + "init": False, + "active": False, + "presets": {}, + } + except ONVIFError as e: + logger.error(f"Onvif connection to {cam.name} failed: {e}") + + def _init_onvif(self, camera_name: str) -> bool: + onvif: ONVIFCamera = self.cams[camera_name]["onvif"] + + # create init services + media = onvif.create_media_service() + + try: + profile = media.GetProfiles()[0] + except ONVIFError as e: + logger.error(f"Unable to connect to camera: {camera_name}: {e}") + return False + + ptz = onvif.create_ptz_service() + request = ptz.create_type("GetConfigurationOptions") + request.ConfigurationToken = profile.PTZConfiguration.token + + # setup moving request + move_request = ptz.create_type("ContinuousMove") + move_request.ProfileToken = profile.token + self.cams[camera_name]["move_request"] = move_request + + # setup existing presets + try: + presets: list[dict] = ptz.GetPresets({"ProfileToken": profile.token}) + except ONVIFError as e: + logger.error(f"Unable to get presets from camera: {camera_name}: {e}") + return False + + for preset in presets: + self.cams[camera_name]["presets"][preset["Name"].lower()] = preset["token"] + + # get list of supported features + ptz_config = ptz.GetConfigurationOptions(request) + supported_features = [] + + if ptz_config.Spaces and ptz_config.Spaces.ContinuousPanTiltVelocitySpace: + supported_features.append("pt") + + if ptz_config.Spaces and ptz_config.Spaces.ContinuousZoomVelocitySpace: + supported_features.append("zoom") + + self.cams[camera_name]["features"] = supported_features + + self.cams[camera_name]["init"] = True + return True + + def _stop(self, camera_name: str) -> None: + onvif: ONVIFCamera = self.cams[camera_name]["onvif"] + move_request = self.cams[camera_name]["move_request"] + onvif.get_service("ptz").Stop( + { + "ProfileToken": move_request.ProfileToken, + "PanTilt": True, + "Zoom": True, + } + ) + self.cams[camera_name]["active"] = False + + def _move(self, camera_name: str, command: OnvifCommandEnum) -> None: + if self.cams[camera_name]["active"]: + logger.warning( + f"{camera_name} is already performing an action, stopping..." + ) + self._stop(camera_name) + + self.cams[camera_name]["active"] = True + onvif: ONVIFCamera = self.cams[camera_name]["onvif"] + move_request = self.cams[camera_name]["move_request"] + + if command == OnvifCommandEnum.move_left: + move_request.Velocity = {"PanTilt": {"x": -0.5, "y": 0}} + elif command == OnvifCommandEnum.move_right: + move_request.Velocity = {"PanTilt": {"x": 0.5, "y": 0}} + elif command == OnvifCommandEnum.move_up: + move_request.Velocity = { + "PanTilt": { + "x": 0, + "y": 0.5, + } + } + elif command == OnvifCommandEnum.move_down: + move_request.Velocity = { + "PanTilt": { + "x": 0, + "y": -0.5, + } + } + + onvif.get_service("ptz").ContinuousMove(move_request) + + def _move_to_preset(self, camera_name: str, preset: str) -> None: + if not preset in self.cams[camera_name]["presets"]: + logger.error(f"{preset} is not a valid preset for {camera_name}") + return + + self.cams[camera_name]["active"] = True + move_request = self.cams[camera_name]["move_request"] + onvif: ONVIFCamera = self.cams[camera_name]["onvif"] + preset_token = self.cams[camera_name]["presets"][preset] + onvif.get_service("ptz").GotoPreset( + { + "ProfileToken": move_request.ProfileToken, + "PresetToken": preset_token, + } + ) + self.cams[camera_name]["active"] = False + + def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None: + if self.cams[camera_name]["active"]: + logger.warning( + f"{camera_name} is already performing an action, stopping..." + ) + self._stop(camera_name) + + self.cams[camera_name]["active"] = True + onvif: ONVIFCamera = self.cams[camera_name]["onvif"] + move_request = self.cams[camera_name]["move_request"] + + if command == OnvifCommandEnum.zoom_in: + move_request.Velocity = {"Zoom": {"x": 0.5}} + elif command == OnvifCommandEnum.zoom_out: + move_request.Velocity = {"Zoom": {"x": -0.5}} + + onvif.get_service("ptz").ContinuousMove(move_request) + + def handle_command( + self, camera_name: str, command: OnvifCommandEnum, param: str = "" + ) -> None: + if camera_name not in self.cams.keys(): + logger.error(f"Onvif is not setup for {camera_name}") + return + + if not self.cams[camera_name]["init"]: + if not self._init_onvif(camera_name): + return + + if command == OnvifCommandEnum.init: + # already init + return + elif command == OnvifCommandEnum.stop: + self._stop(camera_name) + elif command == OnvifCommandEnum.preset: + self._move_to_preset(camera_name, param) + elif ( + command == OnvifCommandEnum.zoom_in or command == OnvifCommandEnum.zoom_out + ): + self._zoom(camera_name, command) + else: + self._move(camera_name, command) + + def get_camera_info(self, camera_name: str) -> dict[str, any]: + if camera_name not in self.cams.keys(): + logger.error(f"Onvif is not setup for {camera_name}") + return {} + + if not self.cams[camera_name]["init"]: + self._init_onvif(camera_name) + + return { + "name": camera_name, + "features": self.cams[camera_name]["features"], + "presets": list(self.cams[camera_name]["presets"].keys()), + } diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 12105926e..bc08ec010 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -114,7 +114,13 @@ class TestHttp(unittest.TestCase): def test_get_event_list(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + PlusApi(), ) id = "123456.random" id2 = "7890.random" @@ -143,7 +149,13 @@ class TestHttp(unittest.TestCase): def test_get_good_event(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + PlusApi(), ) id = "123456.random" @@ -157,7 +169,13 @@ class TestHttp(unittest.TestCase): def test_get_bad_event(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + PlusApi(), ) id = "123456.random" bad_id = "654321.other" @@ -170,7 +188,13 @@ class TestHttp(unittest.TestCase): def test_delete_event(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + PlusApi(), ) id = "123456.random" @@ -185,7 +209,13 @@ class TestHttp(unittest.TestCase): def test_event_retention(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + PlusApi(), ) id = "123456.random" @@ -204,7 +234,13 @@ class TestHttp(unittest.TestCase): def test_set_delete_sub_label(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + PlusApi(), ) id = "123456.random" sub_label = "sub" @@ -232,7 +268,13 @@ class TestHttp(unittest.TestCase): def test_sub_label_list(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + PlusApi(), ) id = "123456.random" sub_label = "sub" @@ -255,6 +297,7 @@ class TestHttp(unittest.TestCase): None, None, None, + None, PlusApi(), ) @@ -270,6 +313,7 @@ class TestHttp(unittest.TestCase): None, None, None, + None, PlusApi(), ) id = "123456.random" @@ -288,6 +332,7 @@ class TestHttp(unittest.TestCase): None, None, None, + None, PlusApi(), ) mock_stats.return_value = self.test_stats diff --git a/requirements-wheels.txt b/requirements-wheels.txt index b19c7947f..f20dd4c6d 100644 --- a/requirements-wheels.txt +++ b/requirements-wheels.txt @@ -4,6 +4,7 @@ imutils == 0.5.* matplotlib == 3.6.* mypy == 0.942 numpy == 1.23.* +onvif_zeep == 0.2.12 opencv-python-headless == 4.5.5.* paho-mqtt == 1.6.* peewee == 3.15.* diff --git a/web/src/api/ws.jsx b/web/src/api/ws.jsx index 734200215..8995a065b 100644 --- a/web/src/api/ws.jsx +++ b/web/src/api/ws.jsx @@ -120,6 +120,15 @@ export function useSnapshotsState(camera) { return { payload, send, connected }; } +export function usePtzCommand(camera) { + const { + value: { payload }, + send, + connected, + } = useWs(`${camera}/ptz`, `${camera}/ptz`); + return { payload, send, connected }; +} + export function useRestart() { const { value: { payload }, diff --git a/web/src/components/CameraControlPanel.jsx b/web/src/components/CameraControlPanel.jsx new file mode 100644 index 000000000..90bf3ef27 --- /dev/null +++ b/web/src/components/CameraControlPanel.jsx @@ -0,0 +1,248 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import useSWR from 'swr'; +import { usePtzCommand } from '../api/ws'; +import ActivityIndicator from './ActivityIndicator'; +import ArrowRightDouble from '../icons/ArrowRightDouble'; +import ArrowUpDouble from '../icons/ArrowUpDouble'; +import ArrowDownDouble from '../icons/ArrowDownDouble'; +import ArrowLeftDouble from '../icons/ArrowLeftDouble'; +import Button from './Button'; +import Heading from './Heading'; + +export default function CameraControlPanel({ camera = '' }) { + const { data: ptz } = useSWR(`${camera}/ptz/info`); + const [currentPreset, setCurrentPreset] = useState(''); + + const { payload: _, send: sendPtz } = usePtzCommand(camera); + + const onSetPreview = async (e) => { + e.stopPropagation(); + + if (currentPreset == 'none') { + return; + } + + sendPtz(`preset-${currentPreset}`); + setCurrentPreset(''); + }; + + const onSetMove = async (e, dir) => { + e.stopPropagation(); + sendPtz(`MOVE_${dir}`); + setCurrentPreset(''); + }; + + const onSetZoom = async (e, dir) => { + e.stopPropagation(); + sendPtz(`ZOOM_${dir}`); + setCurrentPreset(''); + }; + + const onSetStop = async (e) => { + e.stopPropagation(); + sendPtz('STOP'); + }; + + if (!ptz) { + return ; + } + + document.addEventListener('keydown', (e) => { + if (!e) { + return; + } + + if (e.repeat) { + e.preventDefault(); + return; + } + + if (ptz.features.includes('pt')) { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + onSetMove(e, 'LEFT'); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + onSetMove(e, 'RIGHT'); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + onSetMove(e, 'UP'); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + onSetMove(e, 'DOWN'); + } + + if (ptz.features.includes('zoom')) { + if (e.key == '+') { + e.preventDefault(); + onSetZoom(e, 'IN'); + } else if (e.key == '-') { + e.preventDefault(); + onSetZoom(e, 'OUT'); + } + } + } + }); + + document.addEventListener('keyup', (e) => { + if (!e || e.repeat) { + return; + } + + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === '+' || + e.key === '-' + ) { + e.preventDefault(); + onSetStop(e); + } + }); + + return ( +
+ {ptz.features.includes('pt') && ( +
+
+ + Pan / Tilt + +
+ +
+
+ + +
+
+ +
+
+
+ )} + + {ptz.features.includes('zoom') && ( +
+ + Zoom + +
+ +
+
+
+ +
+
+ )} + + {(ptz.presets || []).length > 0 && ( +
+ + Presets + +
+ +
+ + +
+ )} +
+ ); +} diff --git a/web/src/icons/ArrowDownDouble.jsx b/web/src/icons/ArrowDownDouble.jsx new file mode 100644 index 000000000..7685542e9 --- /dev/null +++ b/web/src/icons/ArrowDownDouble.jsx @@ -0,0 +1,19 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function ArrowDownDouble({ className = '' }) { + return ( + + + + ); +} + +export default memo(ArrowDownDouble); diff --git a/web/src/icons/ArrowLeftDouble.jsx b/web/src/icons/ArrowLeftDouble.jsx new file mode 100644 index 000000000..eaa25395c --- /dev/null +++ b/web/src/icons/ArrowLeftDouble.jsx @@ -0,0 +1,19 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function ArrowLeftDouble({ className = '' }) { + return ( + + + + ); +} + +export default memo(ArrowLeftDouble); diff --git a/web/src/icons/ArrowRightDouble.jsx b/web/src/icons/ArrowRightDouble.jsx index 7487a4d5c..fc1960836 100644 --- a/web/src/icons/ArrowRightDouble.jsx +++ b/web/src/icons/ArrowRightDouble.jsx @@ -3,8 +3,15 @@ import { memo } from 'preact/compat'; export function ArrowRightDouble({ className = '' }) { return ( - - + + ); } diff --git a/web/src/icons/ArrowUpDouble.jsx b/web/src/icons/ArrowUpDouble.jsx new file mode 100644 index 000000000..7468a2b91 --- /dev/null +++ b/web/src/icons/ArrowUpDouble.jsx @@ -0,0 +1,19 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function ArrowUpDouble({ className = '' }) { + return ( + + + + ); +} + +export default memo(ArrowUpDouble); diff --git a/web/src/routes/Birdseye.jsx b/web/src/routes/Birdseye.jsx index 91c97fcb9..c23713bb9 100644 --- a/web/src/routes/Birdseye.jsx +++ b/web/src/routes/Birdseye.jsx @@ -6,6 +6,8 @@ import Heading from '../components/Heading'; import WebRtcPlayer from '../components/WebRtcPlayer'; import MsePlayer from '../components/MsePlayer'; import useSWR from 'swr'; +import { useMemo } from 'preact/hooks'; +import CameraControlPanel from '../components/CameraControlPanel'; export default function Birdseye() { const { data: config } = useSWR('config'); @@ -16,6 +18,16 @@ export default function Birdseye() { ); const sourceValues = ['mse', 'webrtc', 'jsmpeg']; + const ptzCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.entries(config.cameras) + .filter(([_, conf]) => conf.onvif?.host) + .map(([_, camera]) => camera.name); + }, [config]); + if (!config || !sourceIsLoaded) { return ; } @@ -25,7 +37,7 @@ export default function Birdseye() { if ('MediaSource' in window) { player = ( -
+
@@ -42,7 +54,7 @@ export default function Birdseye() { } else if (viewSource == 'webrtc' && config.birdseye.restream) { player = ( -
+
@@ -50,7 +62,7 @@ export default function Birdseye() { } else { player = ( -
+
@@ -79,7 +91,21 @@ export default function Birdseye() { )}
- {player} +
+ {player} + + {ptzCameras && ( +
+ Control Panel + {ptzCameras.map((camera) => ( +
+ {camera.replaceAll('_', ' ')} + +
+ ))} +
+ )} +
); } diff --git a/web/src/routes/Camera.jsx b/web/src/routes/Camera.jsx index 7a50d530a..4a415e32d 100644 --- a/web/src/routes/Camera.jsx +++ b/web/src/routes/Camera.jsx @@ -15,6 +15,7 @@ import { useApiHost } from '../api'; import useSWR from 'swr'; import WebRtcPlayer from '../components/WebRtcPlayer'; import MsePlayer from '../components/MsePlayer'; +import CameraControlPanel from '../components/CameraControlPanel'; const emptyObject = Object.freeze({}); @@ -188,6 +189,13 @@ export default function Camera({ camera }) { {player} + {cameraConfig?.onvif?.host && ( +
+ Control Panel + +
+ )} +
Tracked objects