Skip to content

Commit

Permalink
Support Controlling PTZ Cameras Via WebUI (blakeblackshear#4715)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
NickM-27 committed Apr 26, 2023
1 parent 0d16bd0 commit 43ade86
Show file tree
Hide file tree
Showing 21 changed files with 769 additions and 16 deletions.
18 changes: 18 additions & 0 deletions docs/docs/configuration/cameras.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions docs/docs/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ mqtt:
- path: rtsp:https://{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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/integrations/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 12 additions & 1 deletion docs/docs/integrations/mqtt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
10 changes: 9 additions & 1 deletion frigate/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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
Expand Down Expand Up @@ -173,17 +174,23 @@ def init_web_server(self) -> None:
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] = []

if self.config.mqtt.enabled:
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():
Expand Down Expand Up @@ -382,6 +389,7 @@ def start(self) -> None:
self.set_log_levels()
self.init_queues()
self.init_database()
self.init_onvif()
self.init_dispatcher()
except Exception as e:
print(e)
Expand Down
29 changes: 28 additions & 1 deletion frigate/comms/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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

Expand Down Expand Up @@ -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

Expand All @@ -63,12 +66,21 @@ def _receive(self, topic: str, payload: str) -> None:
"""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()

Expand Down Expand Up @@ -204,3 +216,18 @@ def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
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}")
6 changes: 6 additions & 0 deletions frigate/comms/mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ def _start(self) -> None:
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
)
Expand Down
19 changes: 19 additions & 0 deletions frigate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ def validate_password(cls, v, values):
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"
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -939,6 +949,15 @@ def runtime_config(self) -> FrigateConfig:
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:
Expand Down
11 changes: 11 additions & 0 deletions frigate/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
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,
Expand All @@ -59,6 +60,7 @@ def create_app(
stats_tracking,
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
plus_api: PlusApi,
):
app = Flask(__name__)
Expand All @@ -77,6 +79,7 @@ def _db_close(exc):
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 = []
Expand Down Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions frigate/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

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"
Expand All @@ -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)

Expand Down
Loading

0 comments on commit 43ade86

Please sign in to comment.