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
This commit is contained in:
Nicolas Mowen 2023-04-26 05:08:53 -06:00 committed by GitHub
parent 0d16bd0144
commit 43ade86796
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 769 additions and 16 deletions

View File

@ -48,3 +48,21 @@ cameras:
``` ```
For camera model specific settings check the [camera specific](camera_specific.md) infos. 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.

View File

@ -55,6 +55,14 @@ mqtt:
- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast - 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 ```yaml
mqtt: mqtt:
# Optional: Enable mqtt server (default: shown below) # 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) # Optional: Whether or not to show the camera in the Frigate UI (default: shown below)
dashboard: True 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 # Optional
ui: ui:
# Optional: Set the default live mode for cameras in the UI (default: shown below) # Optional: Set the default live mode for cameras in the UI (default: shown below)

View File

@ -291,3 +291,7 @@ Get ffprobe output for camera feed paths.
| param | Type | Description | | param | Type | Description |
| ------- | ------ | ---------------------------------- | | ------- | ------ | ---------------------------------- |
| `paths` | string | `,` separated list of camera paths | | `paths` | string | `,` separated list of camera paths |
### `GET /api/<camera_name>/ptz/info`
Get PTZ info for the camera.

View File

@ -158,3 +158,14 @@ Topic to adjust motion contour area for a camera. Expected value is an integer.
### `frigate/<camera_name>/motion_contour_area/state` ### `frigate/<camera_name>/motion_contour_area/state`
Topic with current motion contour area for a camera. Published value is an integer. Topic with current motion contour area for a camera. Published value is an integer.
### `frigate/<camera_name>/ptz`
Topic to send PTZ commands to camera.
| Command | Description |
| ---------------------- | --------------------------------------------------------------------------------------- |
| `preset-<preset_name>` | send command to move to preset with name `<preset_name>` |
| `MOVE_<dir>` | send command to continuously move in `<dir>`, possible values are [UP, DOWN, LEFT, RIGHT] |
| `ZOOM_<dir>` | send command to continuously zoom `<dir>`, possible values are [IN, OUT] |
| `STOP` | send command to stop moving |

View File

@ -27,6 +27,7 @@ from frigate.models import Event, Recordings, Timeline
from frigate.object_processing import TrackedObjectProcessor from frigate.object_processing import TrackedObjectProcessor
from frigate.output import output_frames from frigate.output import output_frames
from frigate.plus import PlusApi from frigate.plus import PlusApi
from frigate.ptz import OnvifController
from frigate.record import RecordingCleanup, RecordingMaintainer from frigate.record import RecordingCleanup, RecordingMaintainer
from frigate.stats import StatsEmitter, stats_init from frigate.stats import StatsEmitter, stats_init
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
@ -173,9 +174,13 @@ class FrigateApp:
self.stats_tracking, self.stats_tracking,
self.detected_frames_processor, self.detected_frames_processor,
self.storage_maintainer, self.storage_maintainer,
self.onvif_controller,
self.plus_api, self.plus_api,
) )
def init_onvif(self) -> None:
self.onvif_controller = OnvifController(self.config)
def init_dispatcher(self) -> None: def init_dispatcher(self) -> None:
comms: list[Communicator] = [] comms: list[Communicator] = []
@ -183,7 +188,9 @@ class FrigateApp:
comms.append(MqttClient(self.config)) comms.append(MqttClient(self.config))
comms.append(WebSocketClient(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: def start_detectors(self) -> None:
for name in self.config.cameras.keys(): for name in self.config.cameras.keys():
@ -382,6 +389,7 @@ class FrigateApp:
self.set_log_levels() self.set_log_levels()
self.init_queues() self.init_queues()
self.init_database() self.init_database()
self.init_onvif()
self.init_dispatcher() self.init_dispatcher()
except Exception as e: except Exception as e:
print(e) print(e)

View File

@ -7,6 +7,7 @@ from typing import Any, Callable
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.ptz import OnvifController, OnvifCommandEnum
from frigate.types import CameraMetricsTypes from frigate.types import CameraMetricsTypes
from frigate.util import restart_frigate from frigate.util import restart_frigate
@ -39,10 +40,12 @@ class Dispatcher:
def __init__( def __init__(
self, self,
config: FrigateConfig, config: FrigateConfig,
onvif: OnvifController,
camera_metrics: dict[str, CameraMetricsTypes], camera_metrics: dict[str, CameraMetricsTypes],
communicators: list[Communicator], communicators: list[Communicator],
) -> None: ) -> None:
self.config = config self.config = config
self.onvif = onvif
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.comms = communicators self.comms = communicators
@ -63,12 +66,21 @@ class Dispatcher:
"""Handle receiving of payload from communicators.""" """Handle receiving of payload from communicators."""
if topic.endswith("set"): if topic.endswith("set"):
try: try:
# example /cam_name/detect/set payload=ON|OFF
camera_name = topic.split("/")[-3] camera_name = topic.split("/")[-3]
command = topic.split("/")[-2] command = topic.split("/")[-2]
self._camera_settings_handlers[command](camera_name, payload) self._camera_settings_handlers[command](camera_name, payload)
except Exception as e: except IndexError as e:
logger.error(f"Received invalid set command: {topic}") logger.error(f"Received invalid set command: {topic}")
return 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": elif topic == "restart":
restart_frigate() restart_frigate()
@ -204,3 +216,18 @@ class Dispatcher:
snapshots_settings.enabled = False snapshots_settings.enabled = False
self.publish(f"{camera_name}/snapshots/state", payload, retain=True) 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}")

View File

@ -167,6 +167,12 @@ class MqttClient(Communicator): # type: ignore[misc]
self.on_mqtt_command, 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( self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command
) )

View File

@ -125,6 +125,13 @@ class MqttConfig(FrigateBaseModel):
return v 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): class RetainModeEnum(str, Enum):
all = "all" all = "all"
motion = "motion" motion = "motion"
@ -607,6 +614,9 @@ class CameraConfig(FrigateBaseModel):
detect: DetectConfig = Field( detect: DetectConfig = Field(
default_factory=DetectConfig, title="Object detection configuration." default_factory=DetectConfig, title="Object detection configuration."
) )
onvif: OnvifConfig = Field(
default_factory=OnvifConfig, title="Camera Onvif Configuration."
)
ui: CameraUiConfig = Field( ui: CameraUiConfig = Field(
default_factory=CameraUiConfig, title="Camera UI Modifications." default_factory=CameraUiConfig, title="Camera UI Modifications."
) )
@ -939,6 +949,15 @@ class FrigateConfig(FrigateBaseModel):
for input in camera_config.ffmpeg.inputs: for input in camera_config.ffmpeg.inputs:
input.path = input.path.format(**FRIGATE_ENV_VARS) 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 # Add default filters
object_keys = camera_config.objects.track object_keys = camera_config.objects.track
if camera_config.objects.filters is None: if camera_config.objects.filters is None:

View File

@ -36,6 +36,7 @@ from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.models import Event, Recordings, Timeline from frigate.models import Event, Recordings, Timeline
from frigate.object_processing import TrackedObject from frigate.object_processing import TrackedObject
from frigate.plus import PlusApi from frigate.plus import PlusApi
from frigate.ptz import OnvifController
from frigate.stats import stats_snapshot from frigate.stats import stats_snapshot
from frigate.util import ( from frigate.util import (
clean_camera_user_pass, clean_camera_user_pass,
@ -59,6 +60,7 @@ def create_app(
stats_tracking, stats_tracking,
detected_frames_processor, detected_frames_processor,
storage_maintainer: StorageMaintainer, storage_maintainer: StorageMaintainer,
onvif: OnvifController,
plus_api: PlusApi, plus_api: PlusApi,
): ):
app = Flask(__name__) app = Flask(__name__)
@ -77,6 +79,7 @@ def create_app(
app.stats_tracking = stats_tracking app.stats_tracking = stats_tracking
app.detected_frames_processor = detected_frames_processor app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer app.storage_maintainer = storage_maintainer
app.onvif = onvif
app.plus_api = plus_api app.plus_api = plus_api
app.camera_error_image = None app.camera_error_image = None
app.hwaccel_errors = [] app.hwaccel_errors = []
@ -994,6 +997,14 @@ def mjpeg_feed(camera_name):
return "Camera named {} not found".format(camera_name), 404 return "Camera named {} not found".format(camera_name), 404
@bp.route("/<camera_name>/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("/<camera_name>/latest.jpg") @bp.route("/<camera_name>/latest.jpg")
def latest_frame(camera_name): def latest_frame(camera_name):
draw_options = { draw_options = {

View File

@ -19,6 +19,10 @@ from frigate.util import clean_camera_user_pass
def listener_configurer() -> None: def listener_configurer() -> None:
root = logging.getLogger() root = logging.getLogger()
if root.hasHandlers():
root.handlers.clear()
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
formatter = logging.Formatter( formatter = logging.Formatter(
"[%(asctime)s] %(name)-30s %(levelname)-8s: %(message)s", "%Y-%m-%d %H:%M:%S" "[%(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: def root_configurer(queue: Queue) -> None:
h = handlers.QueueHandler(queue) h = handlers.QueueHandler(queue)
root = logging.getLogger() root = logging.getLogger()
if root.hasHandlers():
root.handlers.clear()
root.addHandler(h) root.addHandler(h)
root.setLevel(logging.INFO) root.setLevel(logging.INFO)

219
frigate/ptz.py Normal file
View File

@ -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()),
}

View File

@ -114,7 +114,13 @@ class TestHttp(unittest.TestCase):
def test_get_event_list(self): def test_get_event_list(self):
app = create_app( 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" id = "123456.random"
id2 = "7890.random" id2 = "7890.random"
@ -143,7 +149,13 @@ class TestHttp(unittest.TestCase):
def test_get_good_event(self): def test_get_good_event(self):
app = create_app( 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" id = "123456.random"
@ -157,7 +169,13 @@ class TestHttp(unittest.TestCase):
def test_get_bad_event(self): def test_get_bad_event(self):
app = create_app( 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" id = "123456.random"
bad_id = "654321.other" bad_id = "654321.other"
@ -170,7 +188,13 @@ class TestHttp(unittest.TestCase):
def test_delete_event(self): def test_delete_event(self):
app = create_app( 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" id = "123456.random"
@ -185,7 +209,13 @@ class TestHttp(unittest.TestCase):
def test_event_retention(self): def test_event_retention(self):
app = create_app( 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" id = "123456.random"
@ -204,7 +234,13 @@ class TestHttp(unittest.TestCase):
def test_set_delete_sub_label(self): def test_set_delete_sub_label(self):
app = create_app( 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" id = "123456.random"
sub_label = "sub" sub_label = "sub"
@ -232,7 +268,13 @@ class TestHttp(unittest.TestCase):
def test_sub_label_list(self): def test_sub_label_list(self):
app = create_app( 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" id = "123456.random"
sub_label = "sub" sub_label = "sub"
@ -255,6 +297,7 @@ class TestHttp(unittest.TestCase):
None, None,
None, None,
None, None,
None,
PlusApi(), PlusApi(),
) )
@ -270,6 +313,7 @@ class TestHttp(unittest.TestCase):
None, None,
None, None,
None, None,
None,
PlusApi(), PlusApi(),
) )
id = "123456.random" id = "123456.random"
@ -288,6 +332,7 @@ class TestHttp(unittest.TestCase):
None, None,
None, None,
None, None,
None,
PlusApi(), PlusApi(),
) )
mock_stats.return_value = self.test_stats mock_stats.return_value = self.test_stats

View File

@ -4,6 +4,7 @@ imutils == 0.5.*
matplotlib == 3.6.* matplotlib == 3.6.*
mypy == 0.942 mypy == 0.942
numpy == 1.23.* numpy == 1.23.*
onvif_zeep == 0.2.12
opencv-python-headless == 4.5.5.* opencv-python-headless == 4.5.5.*
paho-mqtt == 1.6.* paho-mqtt == 1.6.*
peewee == 3.15.* peewee == 3.15.*

View File

@ -120,6 +120,15 @@ export function useSnapshotsState(camera) {
return { payload, send, connected }; 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() { export function useRestart() {
const { const {
value: { payload }, value: { payload },

View File

@ -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 <ActivityIndicator />;
}
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 (
<div data-testid="control-panel" className="p-4 text-center sm:flex justify-start">
{ptz.features.includes('pt') && (
<div className="flex justify-center">
<div className="w-44 px-4">
<Heading size="xs" className="my-4">
Pan / Tilt
</Heading>
<div className="w-full flex justify-center">
<button
onMouseDown={(e) => onSetMove(e, 'UP')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'UP');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowUpDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
<div className="w-full flex justify-between">
<button
onMouseDown={(e) => onSetMove(e, 'LEFT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'LEFT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowLeftDouble className="btn h-12 p-2 bg-slate-500" />
</button>
<button
onMouseDown={(e) => onSetMove(e, 'RIGHT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'RIGHT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowRightDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
<div className="flex justify-center">
<button
onMouseDown={(e) => onSetMove(e, 'DOWN')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'DOWN');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowDownDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
</div>
</div>
)}
{ptz.features.includes('zoom') && (
<div className="px-4 sm:w-44">
<Heading size="xs" className="my-4">
Zoom
</Heading>
<div className="w-full flex justify-center">
<button
onMouseDown={(e) => onSetZoom(e, 'IN')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetZoom(e, 'IN');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">+</div>
</button>
</div>
<div className="h-12" />
<div className="flex justify-center">
<button
onMouseDown={(e) => onSetZoom(e, 'OUT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetZoom(e, 'OUT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">-</div>
</button>
</div>
</div>
)}
{(ptz.presets || []).length > 0 && (
<div className="px-4">
<Heading size="xs" className="my-4">
Presets
</Heading>
<div className="py-4">
<select
className="cursor-pointer rounded dark:bg-slate-800"
value={currentPreset}
onChange={(e) => {
setCurrentPreset(e.target.value);
}}
>
<option value="">Select Preset</option>
{ptz.presets.map((item) => (
<option key={item} value={item}>
{item.charAt(0).toUpperCase() + item.slice(1)}
</option>
))}
</select>
</div>
<Button onClick={(e) => onSetPreview(e)}>Move Camera To Preset</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowDownDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M19.5 5.25l-7.5 7.5-7.5-7.5m15 6l-7.5 7.5-7.5-7.5" />
</svg>
);
}
export default memo(ArrowDownDouble);

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowLeftDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
</svg>
);
}
export default memo(ArrowLeftDouble);

View File

@ -3,8 +3,15 @@ import { memo } from 'preact/compat';
export function ArrowRightDouble({ className = '' }) { export function ArrowRightDouble({ className = '' }) {
return ( return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
</svg> </svg>
); );
} }

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowUpDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M4.5 12.75l7.5-7.5 7.5 7.5m-15 6l7.5-7.5 7.5 7.5" />
</svg>
);
}
export default memo(ArrowUpDouble);

View File

@ -6,6 +6,8 @@ import Heading from '../components/Heading';
import WebRtcPlayer from '../components/WebRtcPlayer'; import WebRtcPlayer from '../components/WebRtcPlayer';
import MsePlayer from '../components/MsePlayer'; import MsePlayer from '../components/MsePlayer';
import useSWR from 'swr'; import useSWR from 'swr';
import { useMemo } from 'preact/hooks';
import CameraControlPanel from '../components/CameraControlPanel';
export default function Birdseye() { export default function Birdseye() {
const { data: config } = useSWR('config'); const { data: config } = useSWR('config');
@ -16,6 +18,16 @@ export default function Birdseye() {
); );
const sourceValues = ['mse', 'webrtc', 'jsmpeg']; 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) { if (!config || !sourceIsLoaded) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -25,7 +37,7 @@ export default function Birdseye() {
if ('MediaSource' in window) { if ('MediaSource' in window) {
player = ( player = (
<Fragment> <Fragment>
<div className="max-w-5xl"> <div className="max-w-5xl xl:w-1/2">
<MsePlayer camera="birdseye" /> <MsePlayer camera="birdseye" />
</div> </div>
</Fragment> </Fragment>
@ -42,7 +54,7 @@ export default function Birdseye() {
} else if (viewSource == 'webrtc' && config.birdseye.restream) { } else if (viewSource == 'webrtc' && config.birdseye.restream) {
player = ( player = (
<Fragment> <Fragment>
<div className="max-w-5xl"> <div className="max-w-5xl xl:w-1/2">
<WebRtcPlayer camera="birdseye" /> <WebRtcPlayer camera="birdseye" />
</div> </div>
</Fragment> </Fragment>
@ -50,7 +62,7 @@ export default function Birdseye() {
} else { } else {
player = ( player = (
<Fragment> <Fragment>
<div className="max-w-7xl"> <div className="max-w-7xl xl:w-1/2">
<JSMpegPlayer camera="birdseye" /> <JSMpegPlayer camera="birdseye" />
</div> </div>
</Fragment> </Fragment>
@ -79,7 +91,21 @@ export default function Birdseye() {
)} )}
</div> </div>
<div className="xl:flex justify-between">
{player} {player}
{ptzCameras && (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min xl:h-min xl:w-1/2">
<Heading size="sm">Control Panel</Heading>
{ptzCameras.map((camera) => (
<div className="p-4" key={camera}>
<Heading size="lg">{camera.replaceAll('_', ' ')}</Heading>
<CameraControlPanel camera={camera} />
</div>
))}
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -15,6 +15,7 @@ import { useApiHost } from '../api';
import useSWR from 'swr'; import useSWR from 'swr';
import WebRtcPlayer from '../components/WebRtcPlayer'; import WebRtcPlayer from '../components/WebRtcPlayer';
import MsePlayer from '../components/MsePlayer'; import MsePlayer from '../components/MsePlayer';
import CameraControlPanel from '../components/CameraControlPanel';
const emptyObject = Object.freeze({}); const emptyObject = Object.freeze({});
@ -188,6 +189,13 @@ export default function Camera({ camera }) {
{player} {player}
{cameraConfig?.onvif?.host && (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min">
<Heading size="sm">Control Panel</Heading>
<CameraControlPanel camera={camera} />
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
<Heading size="sm">Tracked objects</Heading> <Heading size="sm">Tracked objects</Heading>
<div className="flex flex-wrap justify-start"> <div className="flex flex-wrap justify-start">