Merge branch 'dev' into prometheus-metrics

This commit is contained in:
Mitch Ross 2023-04-26 20:09:27 -04:00
commit dd36c58672
30 changed files with 1540 additions and 704 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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.

View File

@ -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 |

View File

@ -27,14 +27,15 @@ 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.record import RecordingCleanup, RecordingMaintainer
from frigate.ptz import OnvifController
from frigate.record.record import manage_recordings
from frigate.monitoring.stats import StatsEmitter, stats_init
from frigate.storage import StorageMaintainer
from frigate.timeline import TimelineProcessor
from frigate.version import VERSION
from frigate.video import capture_camera, track_camera
from frigate.watchdog import FrigateWatchdog
from frigate.types import CameraMetricsTypes
from frigate.types import CameraMetricsTypes, RecordMetricsTypes
logger = logging.getLogger(__name__)
@ -49,6 +50,7 @@ class FrigateApp:
self.log_queue: Queue = mp.Queue()
self.plus_api = PlusApi()
self.camera_metrics: dict[str, CameraMetricsTypes] = {}
self.record_metrics: dict[str, RecordMetricsTypes] = {}
def set_environment_vars(self) -> None:
for key, value in self.config.environment_vars.items():
@ -108,6 +110,11 @@ class FrigateApp:
"capture_process": None,
"process": None,
}
self.record_metrics[camera_name] = {
"record_enabled": mp.Value(
"i", self.config.cameras[camera_name].record.enabled
)
}
def set_log_levels(self) -> None:
logging.getLogger().setLevel(self.config.logger.default.value.upper())
@ -157,6 +164,20 @@ class FrigateApp:
migrate_db.close()
def init_recording_manager(self) -> None:
recording_process = mp.Process(
target=manage_recordings,
name="recording_manager",
args=(self.config, self.recordings_info_queue, self.record_metrics),
)
recording_process.daemon = True
self.recording_process = recording_process
recording_process.start()
logger.info(f"Recording process started: {recording_process.pid}")
def bind_database(self) -> None:
"""Bind db to the main process."""
# NOTE: all db accessing processes need to be created before the db can be bound to the main process
self.db = SqliteQueueDatabase(self.config.database.path)
models = [Event, Recordings, Timeline]
self.db.bind(models)
@ -173,9 +194,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 +208,13 @@ 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,
self.record_metrics,
comms,
)
def start_detectors(self) -> None:
for name in self.config.cameras.keys():
@ -311,16 +342,6 @@ class FrigateApp:
self.event_cleanup = EventCleanup(self.config, self.stop_event)
self.event_cleanup.start()
def start_recording_maintainer(self) -> None:
self.recording_maintainer = RecordingMaintainer(
self.config, self.recordings_info_queue, self.stop_event
)
self.recording_maintainer.start()
def start_recording_cleanup(self) -> None:
self.recording_cleanup = RecordingCleanup(self.config, self.stop_event)
self.recording_cleanup.start()
def start_storage_maintainer(self) -> None:
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
self.storage_maintainer.start()
@ -382,6 +403,9 @@ class FrigateApp:
self.set_log_levels()
self.init_queues()
self.init_database()
self.init_onvif()
self.init_recording_manager()
self.bind_database()
self.init_dispatcher()
except Exception as e:
print(e)
@ -398,8 +422,6 @@ class FrigateApp:
self.start_timeline_processor()
self.start_event_processor()
self.start_event_cleanup()
self.start_recording_maintainer()
self.start_recording_cleanup()
self.start_stats_emitter()
self.start_watchdog()
self.check_shm()
@ -435,8 +457,6 @@ class FrigateApp:
self.detected_frames_processor.join()
self.event_processor.join()
self.event_cleanup.join()
self.recording_maintainer.join()
self.recording_cleanup.join()
self.stats_emitter.join()
self.frigate_watchdog.join()
self.db.stop()

View File

@ -7,7 +7,8 @@ from typing import Any, Callable
from abc import ABC, abstractmethod
from frigate.config import FrigateConfig
from frigate.types import CameraMetricsTypes
from frigate.ptz import OnvifController, OnvifCommandEnum
from frigate.types import CameraMetricsTypes, RecordMetricsTypes
from frigate.util import restart_frigate
@ -39,11 +40,15 @@ class Dispatcher:
def __init__(
self,
config: FrigateConfig,
onvif: OnvifController,
camera_metrics: dict[str, CameraMetricsTypes],
record_metrics: dict[str, RecordMetricsTypes],
communicators: list[Communicator],
) -> None:
self.config = config
self.onvif = onvif
self.camera_metrics = camera_metrics
self.record_metrics = record_metrics
self.comms = communicators
for comm in self.comms:
@ -63,12 +68,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()
@ -180,13 +194,15 @@ class Dispatcher:
record_settings = self.config.cameras[camera_name].record
if payload == "ON":
if not record_settings.enabled:
if not self.record_metrics[camera_name]["record_enabled"].value:
logger.info(f"Turning on recordings for {camera_name}")
record_settings.enabled = True
self.record_metrics[camera_name]["record_enabled"].value = True
elif payload == "OFF":
if record_settings.enabled:
if self.record_metrics[camera_name]["record_enabled"].value:
logger.info(f"Turning off recordings for {camera_name}")
record_settings.enabled = False
self.record_metrics[camera_name]["record_enabled"].value = False
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
@ -204,3 +220,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}")

View File

@ -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
)

View File

@ -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:

View File

@ -8,7 +8,6 @@ CACHE_DIR = "/tmp/cache"
YAML_EXT = (".yaml", ".yml")
PLUS_ENV_VAR = "PLUS_API_KEY"
PLUS_API_HOST = "https://api.frigate.video"
MAX_SEGMENT_DURATION = 600
BTBN_PATH = "/usr/lib/btbn-ffmpeg"
# Regex Consts
@ -23,3 +22,8 @@ DRIVER_ENV_VAR = "LIBVA_DRIVER_NAME"
DRIVER_AMD = "radeonsi"
DRIVER_INTEL_i965 = "i965"
DRIVER_INTEL_iHD = "iHD"
# Record Values
MAX_SEGMENT_DURATION = 600
SECONDS_IN_DAY = 60 * 60 * 24

View File

@ -41,6 +41,7 @@ from frigate.monitoring.prometheus import setupRegistry
from frigate.monitoring.stats import stats_snapshot
from frigate.plus import PlusApi
from frigate.ptz import OnvifController
from frigate.util import (
clean_camera_user_pass,
ffprobe_stream,
@ -63,6 +64,7 @@ def create_app(
stats_tracking,
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
plus_api: PlusApi,
):
app = Flask(__name__)
@ -81,6 +83,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 = []
@ -1002,6 +1005,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 = {

View File

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

248
frigate/record/cleanup.py Normal file
View File

@ -0,0 +1,248 @@
"""Cleanup recordings that are expired based on retention config."""
import datetime
import itertools
import logging
import subprocess as sp
import threading
from pathlib import Path
from peewee import DoesNotExist
from multiprocessing.synchronize import Event as MpEvent
from frigate.config import RetainModeEnum, FrigateConfig
from frigate.const import RECORD_DIR, SECONDS_IN_DAY
from frigate.models import Event, Recordings
from frigate.record.util import remove_empty_directories
logger = logging.getLogger(__name__)
class RecordingCleanup(threading.Thread):
"""Cleanup existing recordings based on retention config."""
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
threading.Thread.__init__(self)
self.name = "recording_cleanup"
self.config = config
self.stop_event = stop_event
def clean_tmp_clips(self) -> None:
# delete any clips more than 5 minutes old
for p in Path("/tmp/cache").rglob("clip_*.mp4"):
logger.debug(f"Checking tmp clip {p}.")
if p.stat().st_mtime < (datetime.datetime.now().timestamp() - 60 * 1):
logger.debug("Deleting tmp clip.")
# empty contents of file before unlinking https://github.com/blakeblackshear/frigate/issues/4769
with open(p, "w"):
pass
p.unlink(missing_ok=True)
def expire_recordings(self) -> None:
logger.debug("Start expire recordings (new).")
logger.debug("Start deleted cameras.")
# Handle deleted cameras
expire_days = self.config.record.retain.days
expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
no_camera_recordings: Recordings = Recordings.select().where(
Recordings.camera.not_in(list(self.config.cameras.keys())),
Recordings.end_time < expire_before,
)
deleted_recordings = set()
for recording in no_camera_recordings:
Path(recording.path).unlink(missing_ok=True)
deleted_recordings.add(recording.id)
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
Recordings.delete().where(Recordings.id << deleted_recordings).execute()
logger.debug("End deleted cameras.")
logger.debug("Start all cameras.")
for camera, config in self.config.cameras.items():
logger.debug(f"Start camera: {camera}.")
# Get the timestamp for cutoff of retained days
expire_days = config.record.retain.days
expire_date = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# Get recordings to check for expiration
recordings: Recordings = (
Recordings.select()
.where(
Recordings.camera == camera,
Recordings.end_time < expire_date,
)
.order_by(Recordings.start_time)
)
# Get all the events to check against
events: Event = (
Event.select()
.where(
Event.camera == camera,
# need to ensure segments for all events starting
# before the expire date are included
Event.start_time < expire_date,
Event.has_clip,
)
.order_by(Event.start_time)
.objects()
)
# loop over recordings and see if they overlap with any non-expired events
# TODO: expire segments based on segment stats according to config
event_start = 0
deleted_recordings = set()
for recording in recordings.objects().iterator():
keep = False
# Now look for a reason to keep this recording segment
for idx in range(event_start, len(events)):
event = events[idx]
# if the event starts in the future, stop checking events
# and let this recording segment expire
if event.start_time > recording.end_time:
keep = False
break
# if the event is in progress or ends after the recording starts, keep it
# and stop looking at events
if event.end_time is None or event.end_time >= recording.start_time:
keep = True
break
# if the event ends before this recording segment starts, skip
# this event and check the next event for an overlap.
# since the events and recordings are sorted, we can skip events
# that end before the previous recording segment started on future segments
if event.end_time < recording.start_time:
event_start = idx
# Delete recordings outside of the retention window or based on the retention mode
if (
not keep
or (
config.record.events.retain.mode == RetainModeEnum.motion
and recording.motion == 0
)
or (
config.record.events.retain.mode
== RetainModeEnum.active_objects
and recording.objects == 0
)
):
Path(recording.path).unlink(missing_ok=True)
deleted_recordings.add(recording.id)
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time
max_deletes = 100000
deleted_recordings_list = list(deleted_recordings)
for i in range(0, len(deleted_recordings_list), max_deletes):
Recordings.delete().where(
Recordings.id << deleted_recordings_list[i : i + max_deletes]
).execute()
logger.debug(f"End camera: {camera}.")
logger.debug("End all cameras.")
logger.debug("End expire recordings (new).")
def expire_files(self) -> None:
logger.debug("Start expire files (legacy).")
default_expire = (
datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * self.config.record.retain.days
)
delete_before = {}
for name, camera in self.config.cameras.items():
delete_before[name] = (
datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * camera.record.retain.days
)
# find all the recordings older than the oldest recording in the db
try:
oldest_recording = Recordings.select().order_by(Recordings.start_time).get()
p = Path(oldest_recording.path)
oldest_timestamp = p.stat().st_mtime - 1
except DoesNotExist:
oldest_timestamp = datetime.datetime.now().timestamp()
except FileNotFoundError:
logger.warning(f"Unable to find file from recordings database: {p}")
Recordings.delete().where(Recordings.id == oldest_recording.id).execute()
return
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
process = sp.run(
["find", RECORD_DIR, "-type", "f", "!", "-newermt", f"@{oldest_timestamp}"],
capture_output=True,
text=True,
)
files_to_check = process.stdout.splitlines()
for f in files_to_check:
p = Path(f)
try:
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
p.unlink(missing_ok=True)
except FileNotFoundError:
logger.warning(f"Attempted to expire missing file: {f}")
logger.debug("End expire files (legacy).")
def sync_recordings(self) -> None:
logger.debug("Start sync recordings.")
# get all recordings in the db
recordings: Recordings = Recordings.select()
# get all recordings files on disk
process = sp.run(
["find", RECORD_DIR, "-type", "f"],
capture_output=True,
text=True,
)
files_on_disk = process.stdout.splitlines()
recordings_to_delete = []
for recording in recordings.objects().iterator():
if not recording.path in files_on_disk:
recordings_to_delete.append(recording.id)
logger.debug(
f"Deleting {len(recordings_to_delete)} recordings with missing files"
)
# delete up to 100,000 at a time
max_deletes = 100000
for i in range(0, len(recordings_to_delete), max_deletes):
Recordings.delete().where(
Recordings.id << recordings_to_delete[i : i + max_deletes]
).execute()
logger.debug("End sync recordings.")
def run(self) -> None:
# on startup sync recordings with disk (disabled due to too much CPU usage)
# self.sync_recordings()
# Expire tmp clips every minute, recordings and clean directories every hour.
for counter in itertools.cycle(range(self.config.record.expire_interval)):
if self.stop_event.wait(60):
logger.info(f"Exiting recording cleanup...")
break
self.clean_tmp_clips()
if counter == 0:
self.expire_recordings()
self.expire_files()
remove_empty_directories(RECORD_DIR)

View File

@ -1,5 +1,6 @@
"""Maintain recording segments in cache."""
import datetime
import itertools
import logging
import multiprocessing as mp
import os
@ -8,51 +9,40 @@ import random
import string
import subprocess as sp
import threading
from collections import defaultdict
from pathlib import Path
import psutil
from peewee import JOIN, DoesNotExist
from collections import defaultdict
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Any, Tuple
from frigate.config import RetainModeEnum, FrigateConfig
from frigate.const import CACHE_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.models import Event, Recordings
from frigate.types import RecordMetricsTypes
from frigate.util import area
logger = logging.getLogger(__name__)
SECONDS_IN_DAY = 60 * 60 * 24
def remove_empty_directories(directory):
# list all directories recursively and sort them by path,
# longest first
paths = sorted(
[x[0] for x in os.walk(RECORD_DIR)],
key=lambda p: len(str(p)),
reverse=True,
)
for path in paths:
# don't delete the parent
if path == RECORD_DIR:
continue
if len(os.listdir(path)) == 0:
os.rmdir(path)
class RecordingMaintainer(threading.Thread):
def __init__(
self, config: FrigateConfig, recordings_info_queue: mp.Queue, stop_event
self,
config: FrigateConfig,
recordings_info_queue: mp.Queue,
process_info: dict[str, RecordMetricsTypes],
stop_event: MpEvent,
):
threading.Thread.__init__(self)
self.name = "recording_maint"
self.name = "recording_maintainer"
self.config = config
self.recordings_info_queue = recordings_info_queue
self.process_info = process_info
self.stop_event = stop_event
self.recordings_info = defaultdict(list)
self.end_time_cache = {}
self.recordings_info: dict[str, Any] = defaultdict(list)
self.end_time_cache: dict[str, Tuple[datetime.datetime, float]] = {}
def move_files(self):
def move_files(self) -> None:
cache_files = sorted(
[
d
@ -77,14 +67,14 @@ class RecordingMaintainer(threading.Thread):
continue
# group recordings by camera
grouped_recordings = defaultdict(list)
for f in cache_files:
grouped_recordings: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
for cache in cache_files:
# Skip files currently in use
if f in files_in_use:
if cache in files_in_use:
continue
cache_path = os.path.join(CACHE_DIR, f)
basename = os.path.splitext(f)[0]
cache_path = os.path.join(CACHE_DIR, cache)
basename = os.path.splitext(cache)[0]
camera, date = basename.rsplit("-", maxsplit=1)
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
@ -104,8 +94,8 @@ class RecordingMaintainer(threading.Thread):
f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {segment_count} and discarding the rest..."
)
to_remove = grouped_recordings[camera][:-keep_count]
for f in to_remove:
cache_path = f["cache_path"]
for rec in to_remove:
cache_path = rec["cache_path"]
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
@ -138,7 +128,7 @@ class RecordingMaintainer(threading.Thread):
# Just delete files if recordings are turned off
if (
not camera in self.config.cameras
or not self.config.cameras[camera].record.enabled
or not self.process_info[camera]["record_enabled"].value
):
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
@ -170,7 +160,7 @@ class RecordingMaintainer(threading.Thread):
else:
if duration == -1:
logger.warning(
f"Failed to probe corrupt segment {cache_path}: {p.returncode} - {p.stderr}"
f"Failed to probe corrupt segment {cache_path} : {p.returncode} - {str(p.stderr)}"
)
logger.warning(
@ -241,7 +231,9 @@ class RecordingMaintainer(threading.Thread):
camera, start_time, end_time, duration, cache_path, record_mode
)
def segment_stats(self, camera, start_time, end_time):
def segment_stats(
self, camera: str, start_time: datetime.datetime, end_time: datetime.datetime
) -> Tuple[int, int]:
active_count = 0
motion_count = 0
for frame in self.recordings_info[camera]:
@ -266,13 +258,13 @@ class RecordingMaintainer(threading.Thread):
def store_segment(
self,
camera,
camera: str,
start_time: datetime.datetime,
end_time: datetime.datetime,
duration,
cache_path,
duration: float,
cache_path: str,
store_mode: RetainModeEnum,
):
) -> None:
motion_count, active_count = self.segment_stats(camera, start_time, end_time)
# check if the segment shouldn't be stored
@ -363,9 +355,9 @@ class RecordingMaintainer(threading.Thread):
# clear end_time cache
self.end_time_cache.pop(cache_path, None)
def run(self):
def run(self) -> None:
# Check for new files every 5 seconds
wait_time = 5
wait_time = 5.0
while not self.stop_event.wait(wait_time):
run_start = datetime.datetime.now().timestamp()
@ -380,7 +372,7 @@ class RecordingMaintainer(threading.Thread):
regions,
) = self.recordings_info_queue.get(False)
if self.config.cameras[camera].record.enabled:
if self.process_info[camera]["record_enabled"].value:
self.recordings_info[camera].append(
(
frame_time,
@ -403,231 +395,3 @@ class RecordingMaintainer(threading.Thread):
wait_time = max(0, 5 - duration)
logger.info(f"Exiting recording maintenance...")
class RecordingCleanup(threading.Thread):
def __init__(self, config: FrigateConfig, stop_event):
threading.Thread.__init__(self)
self.name = "recording_cleanup"
self.config = config
self.stop_event = stop_event
def clean_tmp_clips(self):
# delete any clips more than 5 minutes old
for p in Path("/tmp/cache").rglob("clip_*.mp4"):
logger.debug(f"Checking tmp clip {p}.")
if p.stat().st_mtime < (datetime.datetime.now().timestamp() - 60 * 1):
logger.debug("Deleting tmp clip.")
# empty contents of file before unlinking https://github.com/blakeblackshear/frigate/issues/4769
with open(p, "w"):
pass
p.unlink(missing_ok=True)
def expire_recordings(self):
logger.debug("Start expire recordings (new).")
logger.debug("Start deleted cameras.")
# Handle deleted cameras
expire_days = self.config.record.retain.days
expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
no_camera_recordings: Recordings = Recordings.select().where(
Recordings.camera.not_in(list(self.config.cameras.keys())),
Recordings.end_time < expire_before,
)
deleted_recordings = set()
for recording in no_camera_recordings:
Path(recording.path).unlink(missing_ok=True)
deleted_recordings.add(recording.id)
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
Recordings.delete().where(Recordings.id << deleted_recordings).execute()
logger.debug("End deleted cameras.")
logger.debug("Start all cameras.")
for camera, config in self.config.cameras.items():
logger.debug(f"Start camera: {camera}.")
# Get the timestamp for cutoff of retained days
expire_days = config.record.retain.days
expire_date = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# Get recordings to check for expiration
recordings: Recordings = (
Recordings.select()
.where(
Recordings.camera == camera,
Recordings.end_time < expire_date,
)
.order_by(Recordings.start_time)
)
# Get all the events to check against
events: Event = (
Event.select()
.where(
Event.camera == camera,
# need to ensure segments for all events starting
# before the expire date are included
Event.start_time < expire_date,
Event.has_clip,
)
.order_by(Event.start_time)
.objects()
)
# loop over recordings and see if they overlap with any non-expired events
# TODO: expire segments based on segment stats according to config
event_start = 0
deleted_recordings = set()
for recording in recordings.objects().iterator():
keep = False
# Now look for a reason to keep this recording segment
for idx in range(event_start, len(events)):
event = events[idx]
# if the event starts in the future, stop checking events
# and let this recording segment expire
if event.start_time > recording.end_time:
keep = False
break
# if the event is in progress or ends after the recording starts, keep it
# and stop looking at events
if event.end_time is None or event.end_time >= recording.start_time:
keep = True
break
# if the event ends before this recording segment starts, skip
# this event and check the next event for an overlap.
# since the events and recordings are sorted, we can skip events
# that end before the previous recording segment started on future segments
if event.end_time < recording.start_time:
event_start = idx
# Delete recordings outside of the retention window or based on the retention mode
if (
not keep
or (
config.record.events.retain.mode == RetainModeEnum.motion
and recording.motion == 0
)
or (
config.record.events.retain.mode
== RetainModeEnum.active_objects
and recording.objects == 0
)
):
Path(recording.path).unlink(missing_ok=True)
deleted_recordings.add(recording.id)
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time
max_deletes = 100000
deleted_recordings_list = list(deleted_recordings)
for i in range(0, len(deleted_recordings_list), max_deletes):
Recordings.delete().where(
Recordings.id << deleted_recordings_list[i : i + max_deletes]
).execute()
logger.debug(f"End camera: {camera}.")
logger.debug("End all cameras.")
logger.debug("End expire recordings (new).")
def expire_files(self):
logger.debug("Start expire files (legacy).")
default_expire = (
datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * self.config.record.retain.days
)
delete_before = {}
for name, camera in self.config.cameras.items():
delete_before[name] = (
datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * camera.record.retain.days
)
# find all the recordings older than the oldest recording in the db
try:
oldest_recording = Recordings.select().order_by(Recordings.start_time).get()
p = Path(oldest_recording.path)
oldest_timestamp = p.stat().st_mtime - 1
except DoesNotExist:
oldest_timestamp = datetime.datetime.now().timestamp()
except FileNotFoundError:
logger.warning(f"Unable to find file from recordings database: {p}")
Recordings.delete().where(Recordings.id == oldest_recording.id).execute()
return
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
process = sp.run(
["find", RECORD_DIR, "-type", "f", "!", "-newermt", f"@{oldest_timestamp}"],
capture_output=True,
text=True,
)
files_to_check = process.stdout.splitlines()
for f in files_to_check:
p = Path(f)
try:
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
p.unlink(missing_ok=True)
except FileNotFoundError:
logger.warning(f"Attempted to expire missing file: {f}")
logger.debug("End expire files (legacy).")
def sync_recordings(self):
logger.debug("Start sync recordings.")
# get all recordings in the db
recordings: Recordings = Recordings.select()
# get all recordings files on disk
process = sp.run(
["find", RECORD_DIR, "-type", "f"],
capture_output=True,
text=True,
)
files_on_disk = process.stdout.splitlines()
recordings_to_delete = []
for recording in recordings.objects().iterator():
if not recording.path in files_on_disk:
recordings_to_delete.append(recording.id)
logger.debug(
f"Deleting {len(recordings_to_delete)} recordings with missing files"
)
# delete up to 100,000 at a time
max_deletes = 100000
for i in range(0, len(recordings_to_delete), max_deletes):
Recordings.delete().where(
Recordings.id << recordings_to_delete[i : i + max_deletes]
).execute()
logger.debug("End sync recordings.")
def run(self):
# on startup sync recordings with disk (disabled due to too much CPU usage)
# self.sync_recordings()
# Expire tmp clips every minute, recordings and clean directories every hour.
for counter in itertools.cycle(range(self.config.record.expire_interval)):
if self.stop_event.wait(60):
logger.info(f"Exiting recording cleanup...")
break
self.clean_tmp_clips()
if counter == 0:
self.expire_recordings()
self.expire_files()
remove_empty_directories(RECORD_DIR)

53
frigate/record/record.py Normal file
View File

@ -0,0 +1,53 @@
"""Run recording maintainer and cleanup."""
import logging
import multiprocessing as mp
import signal
import threading
from setproctitle import setproctitle
from types import FrameType
from typing import Optional
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.config import FrigateConfig
from frigate.models import Event, Recordings, Timeline
from frigate.record.cleanup import RecordingCleanup
from frigate.record.maintainer import RecordingMaintainer
from frigate.types import RecordMetricsTypes
from frigate.util import listen
logger = logging.getLogger(__name__)
def manage_recordings(
config: FrigateConfig,
recordings_info_queue: mp.Queue,
process_info: dict[str, RecordMetricsTypes],
) -> None:
stop_event = mp.Event()
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
stop_event.set()
signal.signal(signal.SIGTERM, receiveSignal)
signal.signal(signal.SIGINT, receiveSignal)
threading.current_thread().name = "process:recording_manager"
setproctitle("frigate.recording_manager")
listen()
db = SqliteQueueDatabase(config.database.path)
models = [Event, Recordings, Timeline]
db.bind(models)
maintainer = RecordingMaintainer(
config, recordings_info_queue, process_info, stop_event
)
maintainer.start()
cleanup = RecordingCleanup(config, stop_event)
cleanup.start()
logger.info("recording_manager: exiting subprocess")

19
frigate/record/util.py Normal file
View File

@ -0,0 +1,19 @@
"""Recordings Utilities."""
import os
def remove_empty_directories(directory: str) -> None:
# list all directories recursively and sort them by path,
# longest first
paths = sorted(
[x[0] for x in os.walk(directory)],
key=lambda p: len(str(p)),
reverse=True,
)
for path in paths:
# don't delete the parent
if path == directory:
continue
if len(os.listdir(path)) == 0:
os.rmdir(path)

View File

@ -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

View File

@ -24,6 +24,10 @@ class CameraMetricsTypes(TypedDict):
skipped_fps: Synchronized
class RecordMetricsTypes(TypedDict):
record_enabled: Synchronized
class StatsTrackingTypes(TypedDict):
camera_metrics: dict[str, CameraMetricsTypes]
detectors: dict[str, ObjectDetectProcess]

View File

@ -1,18 +1,19 @@
click == 8.1.*
Flask == 2.2.*
imutils == 0.5.*
matplotlib == 3.6.*
matplotlib == 3.7.*
mypy == 0.942
numpy == 1.23.*
onvif_zeep == 0.2.12
opencv-python-headless == 4.5.5.*
paho-mqtt == 1.6.*
peewee == 3.15.*
peewee_migrate == 1.6.*
peewee_migrate == 1.7.*
psutil == 5.9.*
pydantic == 1.10.*
PyYAML == 6.0
pytz == 2023.3
tzlocal == 4.2
tzlocal == 4.3
types-PyYAML == 6.0.*
requests == 2.28.*
types-requests == 2.28.*

View File

@ -1,2 +1,2 @@
scikit-build == 0.17.1
scikit-build == 0.17.*
nvidia-pyindex

741
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5",
"axios": "^1.3.5",
"axios": "^1.3.6",
"copy-to-clipboard": "3.3.3",
"date-fns": "^2.29.3",
"idb-keyval": "^6.2.0",
@ -36,23 +36,23 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/preact": "^3.2.3",
"@testing-library/user-event": "^14.4.3",
"@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/parser": "^5.58.0",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"@vitest/coverage-c8": "^0.30.1",
"@vitest/ui": "^0.30.1",
"autoprefixer": "^10.4.14",
"eslint": "^8.38.0",
"eslint": "^8.39.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-vitest-globals": "^1.3.1",
"fake-indexeddb": "^4.0.1",
"jsdom": "^21.1.1",
"msw": "^1.2.1",
"postcss": "^8.4.19",
"prettier": "^2.8.7",
"tailwindcss": "^3.3.1",
"postcss": "^8.4.23",
"prettier": "^2.8.8",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.4",
"vite": "^4.2.1",
"vite": "^4.3.2",
"vitest": "^0.30.1"
}
}

View File

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

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 = '' }) {
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>
);
}

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 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>
);
}

View File

@ -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">