mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-12-23 19:11:14 +01:00
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:
parent
0d16bd0144
commit
43ade86796
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -291,3 +291,7 @@ Get ffprobe output for camera feed paths.
|
||||
| param | Type | Description |
|
||||
| ------- | ------ | ---------------------------------- |
|
||||
| `paths` | string | `,` separated list of camera paths |
|
||||
|
||||
### `GET /api/<camera_name>/ptz/info`
|
||||
|
||||
Get PTZ info for the camera.
|
||||
|
@ -157,4 +157,15 @@ Topic to adjust motion contour area for a camera. Expected value is an integer.
|
||||
|
||||
### `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 |
|
||||
|
@ -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)
|
||||
|
@ -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}")
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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("/<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")
|
||||
def latest_frame(camera_name):
|
||||
draw_options = {
|
||||
|
@ -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)
|
||||
|
||||
|
219
frigate/ptz.py
Normal file
219
frigate/ptz.py
Normal 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()),
|
||||
}
|
@ -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
|
||||
|
@ -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.*
|
||||
|
@ -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 },
|
||||
|
248
web/src/components/CameraControlPanel.jsx
Normal file
248
web/src/components/CameraControlPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
19
web/src/icons/ArrowDownDouble.jsx
Normal file
19
web/src/icons/ArrowDownDouble.jsx
Normal 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);
|
19
web/src/icons/ArrowLeftDouble.jsx
Normal file
19
web/src/icons/ArrowLeftDouble.jsx
Normal 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);
|
@ -3,8 +3,15 @@ import { memo } from 'preact/compat';
|
||||
|
||||
export function ArrowRightDouble({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
<svg
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
19
web/src/icons/ArrowUpDouble.jsx
Normal file
19
web/src/icons/ArrowUpDouble.jsx
Normal 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);
|
@ -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 <ActivityIndicator />;
|
||||
}
|
||||
@ -25,7 +37,7 @@ export default function Birdseye() {
|
||||
if ('MediaSource' in window) {
|
||||
player = (
|
||||
<Fragment>
|
||||
<div className="max-w-5xl">
|
||||
<div className="max-w-5xl xl:w-1/2">
|
||||
<MsePlayer camera="birdseye" />
|
||||
</div>
|
||||
</Fragment>
|
||||
@ -42,7 +54,7 @@ export default function Birdseye() {
|
||||
} else if (viewSource == 'webrtc' && config.birdseye.restream) {
|
||||
player = (
|
||||
<Fragment>
|
||||
<div className="max-w-5xl">
|
||||
<div className="max-w-5xl xl:w-1/2">
|
||||
<WebRtcPlayer camera="birdseye" />
|
||||
</div>
|
||||
</Fragment>
|
||||
@ -50,7 +62,7 @@ export default function Birdseye() {
|
||||
} else {
|
||||
player = (
|
||||
<Fragment>
|
||||
<div className="max-w-7xl">
|
||||
<div className="max-w-7xl xl:w-1/2">
|
||||
<JSMpegPlayer camera="birdseye" />
|
||||
</div>
|
||||
</Fragment>
|
||||
@ -79,7 +91,21 @@ export default function Birdseye() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{player}
|
||||
<div className="xl:flex justify-between">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
@ -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 && (
|
||||
<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">
|
||||
<Heading size="sm">Tracked objects</Heading>
|
||||
<div className="flex flex-wrap justify-start">
|
||||
|
Loading…
Reference in New Issue
Block a user