mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-05 02:35:22 +03:00
Merge branch 'dev' into prometheus-metrics
This commit is contained in:
commit
dd36c58672
@ -48,3 +48,21 @@ cameras:
|
|||||||
```
|
```
|
||||||
|
|
||||||
For camera model specific settings check the [camera specific](camera_specific.md) infos.
|
For camera model specific settings check the [camera specific](camera_specific.md) infos.
|
||||||
|
|
||||||
|
## Setting up camera PTZ controls
|
||||||
|
|
||||||
|
Add onvif config to camera
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
back:
|
||||||
|
ffmpeg:
|
||||||
|
...
|
||||||
|
onvif:
|
||||||
|
host: 10.0.10.10
|
||||||
|
port: 8000
|
||||||
|
user: admin
|
||||||
|
password: password
|
||||||
|
```
|
||||||
|
|
||||||
|
then PTZ controls will be available in the cameras WebUI.
|
||||||
|
|||||||
@ -55,6 +55,14 @@ mqtt:
|
|||||||
- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast
|
- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
onvif:
|
||||||
|
host: 10.0.10.10
|
||||||
|
port: 8000
|
||||||
|
user: "{FRIGATE_RTSP_USER}"
|
||||||
|
password: "{FRIGATE_RTSP_PASSWORD}"
|
||||||
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
mqtt:
|
mqtt:
|
||||||
# Optional: Enable mqtt server (default: shown below)
|
# Optional: Enable mqtt server (default: shown below)
|
||||||
@ -497,6 +505,19 @@ cameras:
|
|||||||
# Optional: Whether or not to show the camera in the Frigate UI (default: shown below)
|
# Optional: Whether or not to show the camera in the Frigate UI (default: shown below)
|
||||||
dashboard: True
|
dashboard: True
|
||||||
|
|
||||||
|
# Optional: connect to ONVIF camera
|
||||||
|
# to enable PTZ controls.
|
||||||
|
onvif:
|
||||||
|
# Required: host of the camera being connected to.
|
||||||
|
host: 0.0.0.0
|
||||||
|
# Optional: ONVIF port for device (default: shown below).
|
||||||
|
port: 8000
|
||||||
|
# Optional: username for login.
|
||||||
|
# NOTE: Some devices require admin to access ONVIF.
|
||||||
|
user: admin
|
||||||
|
# Optional: password for login.
|
||||||
|
password: admin
|
||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
ui:
|
ui:
|
||||||
# Optional: Set the default live mode for cameras in the UI (default: shown below)
|
# Optional: Set the default live mode for cameras in the UI (default: shown below)
|
||||||
|
|||||||
@ -291,3 +291,7 @@ Get ffprobe output for camera feed paths.
|
|||||||
| param | Type | Description |
|
| param | Type | Description |
|
||||||
| ------- | ------ | ---------------------------------- |
|
| ------- | ------ | ---------------------------------- |
|
||||||
| `paths` | string | `,` separated list of camera paths |
|
| `paths` | string | `,` separated list of camera paths |
|
||||||
|
|
||||||
|
### `GET /api/<camera_name>/ptz/info`
|
||||||
|
|
||||||
|
Get PTZ info for the camera.
|
||||||
|
|||||||
@ -158,3 +158,14 @@ Topic to adjust motion contour area for a camera. Expected value is an integer.
|
|||||||
### `frigate/<camera_name>/motion_contour_area/state`
|
### `frigate/<camera_name>/motion_contour_area/state`
|
||||||
|
|
||||||
Topic with current motion contour area for a camera. Published value is an integer.
|
Topic with current motion contour area for a camera. Published value is an integer.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/ptz`
|
||||||
|
|
||||||
|
Topic to send PTZ commands to camera.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ---------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| `preset-<preset_name>` | send command to move to preset with name `<preset_name>` |
|
||||||
|
| `MOVE_<dir>` | send command to continuously move in `<dir>`, possible values are [UP, DOWN, LEFT, RIGHT] |
|
||||||
|
| `ZOOM_<dir>` | send command to continuously zoom `<dir>`, possible values are [IN, OUT] |
|
||||||
|
| `STOP` | send command to stop moving |
|
||||||
|
|||||||
@ -27,14 +27,15 @@ from frigate.models import Event, Recordings, Timeline
|
|||||||
from frigate.object_processing import TrackedObjectProcessor
|
from frigate.object_processing import TrackedObjectProcessor
|
||||||
from frigate.output import output_frames
|
from frigate.output import output_frames
|
||||||
from frigate.plus import PlusApi
|
from frigate.plus import PlusApi
|
||||||
from frigate.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.monitoring.stats import StatsEmitter, stats_init
|
||||||
from frigate.storage import StorageMaintainer
|
from frigate.storage import StorageMaintainer
|
||||||
from frigate.timeline import TimelineProcessor
|
from frigate.timeline import TimelineProcessor
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
from frigate.video import capture_camera, track_camera
|
from frigate.video import capture_camera, track_camera
|
||||||
from frigate.watchdog import FrigateWatchdog
|
from frigate.watchdog import FrigateWatchdog
|
||||||
from frigate.types import CameraMetricsTypes
|
from frigate.types import CameraMetricsTypes, RecordMetricsTypes
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ class FrigateApp:
|
|||||||
self.log_queue: Queue = mp.Queue()
|
self.log_queue: Queue = mp.Queue()
|
||||||
self.plus_api = PlusApi()
|
self.plus_api = PlusApi()
|
||||||
self.camera_metrics: dict[str, CameraMetricsTypes] = {}
|
self.camera_metrics: dict[str, CameraMetricsTypes] = {}
|
||||||
|
self.record_metrics: dict[str, RecordMetricsTypes] = {}
|
||||||
|
|
||||||
def set_environment_vars(self) -> None:
|
def set_environment_vars(self) -> None:
|
||||||
for key, value in self.config.environment_vars.items():
|
for key, value in self.config.environment_vars.items():
|
||||||
@ -108,6 +110,11 @@ class FrigateApp:
|
|||||||
"capture_process": None,
|
"capture_process": None,
|
||||||
"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:
|
def set_log_levels(self) -> None:
|
||||||
logging.getLogger().setLevel(self.config.logger.default.value.upper())
|
logging.getLogger().setLevel(self.config.logger.default.value.upper())
|
||||||
@ -157,6 +164,20 @@ class FrigateApp:
|
|||||||
|
|
||||||
migrate_db.close()
|
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)
|
self.db = SqliteQueueDatabase(self.config.database.path)
|
||||||
models = [Event, Recordings, Timeline]
|
models = [Event, Recordings, Timeline]
|
||||||
self.db.bind(models)
|
self.db.bind(models)
|
||||||
@ -173,9 +194,13 @@ class FrigateApp:
|
|||||||
self.stats_tracking,
|
self.stats_tracking,
|
||||||
self.detected_frames_processor,
|
self.detected_frames_processor,
|
||||||
self.storage_maintainer,
|
self.storage_maintainer,
|
||||||
|
self.onvif_controller,
|
||||||
self.plus_api,
|
self.plus_api,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def init_onvif(self) -> None:
|
||||||
|
self.onvif_controller = OnvifController(self.config)
|
||||||
|
|
||||||
def init_dispatcher(self) -> None:
|
def init_dispatcher(self) -> None:
|
||||||
comms: list[Communicator] = []
|
comms: list[Communicator] = []
|
||||||
|
|
||||||
@ -183,7 +208,13 @@ class FrigateApp:
|
|||||||
comms.append(MqttClient(self.config))
|
comms.append(MqttClient(self.config))
|
||||||
|
|
||||||
comms.append(WebSocketClient(self.config))
|
comms.append(WebSocketClient(self.config))
|
||||||
self.dispatcher = Dispatcher(self.config, self.camera_metrics, comms)
|
self.dispatcher = Dispatcher(
|
||||||
|
self.config,
|
||||||
|
self.onvif_controller,
|
||||||
|
self.camera_metrics,
|
||||||
|
self.record_metrics,
|
||||||
|
comms,
|
||||||
|
)
|
||||||
|
|
||||||
def start_detectors(self) -> None:
|
def start_detectors(self) -> None:
|
||||||
for name in self.config.cameras.keys():
|
for name in self.config.cameras.keys():
|
||||||
@ -311,16 +342,6 @@ class FrigateApp:
|
|||||||
self.event_cleanup = EventCleanup(self.config, self.stop_event)
|
self.event_cleanup = EventCleanup(self.config, self.stop_event)
|
||||||
self.event_cleanup.start()
|
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:
|
def start_storage_maintainer(self) -> None:
|
||||||
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
|
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
|
||||||
self.storage_maintainer.start()
|
self.storage_maintainer.start()
|
||||||
@ -382,6 +403,9 @@ class FrigateApp:
|
|||||||
self.set_log_levels()
|
self.set_log_levels()
|
||||||
self.init_queues()
|
self.init_queues()
|
||||||
self.init_database()
|
self.init_database()
|
||||||
|
self.init_onvif()
|
||||||
|
self.init_recording_manager()
|
||||||
|
self.bind_database()
|
||||||
self.init_dispatcher()
|
self.init_dispatcher()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
@ -398,8 +422,6 @@ class FrigateApp:
|
|||||||
self.start_timeline_processor()
|
self.start_timeline_processor()
|
||||||
self.start_event_processor()
|
self.start_event_processor()
|
||||||
self.start_event_cleanup()
|
self.start_event_cleanup()
|
||||||
self.start_recording_maintainer()
|
|
||||||
self.start_recording_cleanup()
|
|
||||||
self.start_stats_emitter()
|
self.start_stats_emitter()
|
||||||
self.start_watchdog()
|
self.start_watchdog()
|
||||||
self.check_shm()
|
self.check_shm()
|
||||||
@ -435,8 +457,6 @@ class FrigateApp:
|
|||||||
self.detected_frames_processor.join()
|
self.detected_frames_processor.join()
|
||||||
self.event_processor.join()
|
self.event_processor.join()
|
||||||
self.event_cleanup.join()
|
self.event_cleanup.join()
|
||||||
self.recording_maintainer.join()
|
|
||||||
self.recording_cleanup.join()
|
|
||||||
self.stats_emitter.join()
|
self.stats_emitter.join()
|
||||||
self.frigate_watchdog.join()
|
self.frigate_watchdog.join()
|
||||||
self.db.stop()
|
self.db.stop()
|
||||||
|
|||||||
@ -7,7 +7,8 @@ from typing import Any, Callable
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.types import CameraMetricsTypes
|
from frigate.ptz import OnvifController, OnvifCommandEnum
|
||||||
|
from frigate.types import CameraMetricsTypes, RecordMetricsTypes
|
||||||
from frigate.util import restart_frigate
|
from frigate.util import restart_frigate
|
||||||
|
|
||||||
|
|
||||||
@ -39,11 +40,15 @@ class Dispatcher:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
|
onvif: OnvifController,
|
||||||
camera_metrics: dict[str, CameraMetricsTypes],
|
camera_metrics: dict[str, CameraMetricsTypes],
|
||||||
|
record_metrics: dict[str, RecordMetricsTypes],
|
||||||
communicators: list[Communicator],
|
communicators: list[Communicator],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.onvif = onvif
|
||||||
self.camera_metrics = camera_metrics
|
self.camera_metrics = camera_metrics
|
||||||
|
self.record_metrics = record_metrics
|
||||||
self.comms = communicators
|
self.comms = communicators
|
||||||
|
|
||||||
for comm in self.comms:
|
for comm in self.comms:
|
||||||
@ -63,12 +68,21 @@ class Dispatcher:
|
|||||||
"""Handle receiving of payload from communicators."""
|
"""Handle receiving of payload from communicators."""
|
||||||
if topic.endswith("set"):
|
if topic.endswith("set"):
|
||||||
try:
|
try:
|
||||||
|
# example /cam_name/detect/set payload=ON|OFF
|
||||||
camera_name = topic.split("/")[-3]
|
camera_name = topic.split("/")[-3]
|
||||||
command = topic.split("/")[-2]
|
command = topic.split("/")[-2]
|
||||||
self._camera_settings_handlers[command](camera_name, payload)
|
self._camera_settings_handlers[command](camera_name, payload)
|
||||||
except Exception as e:
|
except IndexError as e:
|
||||||
logger.error(f"Received invalid set command: {topic}")
|
logger.error(f"Received invalid set command: {topic}")
|
||||||
return
|
return
|
||||||
|
elif topic.endswith("ptz"):
|
||||||
|
try:
|
||||||
|
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
|
||||||
|
camera_name = topic.split("/")[-2]
|
||||||
|
self._on_ptz_command(camera_name, payload)
|
||||||
|
except IndexError as e:
|
||||||
|
logger.error(f"Received invalid ptz command: {topic}")
|
||||||
|
return
|
||||||
elif topic == "restart":
|
elif topic == "restart":
|
||||||
restart_frigate()
|
restart_frigate()
|
||||||
|
|
||||||
@ -180,13 +194,15 @@ class Dispatcher:
|
|||||||
record_settings = self.config.cameras[camera_name].record
|
record_settings = self.config.cameras[camera_name].record
|
||||||
|
|
||||||
if payload == "ON":
|
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}")
|
logger.info(f"Turning on recordings for {camera_name}")
|
||||||
record_settings.enabled = True
|
record_settings.enabled = True
|
||||||
|
self.record_metrics[camera_name]["record_enabled"].value = True
|
||||||
elif payload == "OFF":
|
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}")
|
logger.info(f"Turning off recordings for {camera_name}")
|
||||||
record_settings.enabled = False
|
record_settings.enabled = False
|
||||||
|
self.record_metrics[camera_name]["record_enabled"].value = False
|
||||||
|
|
||||||
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
|
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
|
||||||
|
|
||||||
@ -204,3 +220,18 @@ class Dispatcher:
|
|||||||
snapshots_settings.enabled = False
|
snapshots_settings.enabled = False
|
||||||
|
|
||||||
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
|
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
|
||||||
|
|
||||||
|
def _on_ptz_command(self, camera_name: str, payload: str) -> None:
|
||||||
|
"""Callback for ptz topic."""
|
||||||
|
try:
|
||||||
|
if "preset" in payload.lower():
|
||||||
|
command = OnvifCommandEnum.preset
|
||||||
|
param = payload.lower().split("-")[1]
|
||||||
|
else:
|
||||||
|
command = OnvifCommandEnum[payload.lower()]
|
||||||
|
param = ""
|
||||||
|
|
||||||
|
self.onvif.handle_command(camera_name, command, param)
|
||||||
|
logger.info(f"Setting ptz command to {command} for {camera_name}")
|
||||||
|
except KeyError as k:
|
||||||
|
logger.error(f"Invalid PTZ command {payload}: {k}")
|
||||||
|
|||||||
@ -167,6 +167,12 @@ class MqttClient(Communicator): # type: ignore[misc]
|
|||||||
self.on_mqtt_command,
|
self.on_mqtt_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.config.cameras[name].onvif.host:
|
||||||
|
self.client.message_callback_add(
|
||||||
|
f"{self.mqtt_config.topic_prefix}/{name}/ptz",
|
||||||
|
self.on_mqtt_command,
|
||||||
|
)
|
||||||
|
|
||||||
self.client.message_callback_add(
|
self.client.message_callback_add(
|
||||||
f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command
|
f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command
|
||||||
)
|
)
|
||||||
|
|||||||
@ -125,6 +125,13 @@ class MqttConfig(FrigateBaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class OnvifConfig(FrigateBaseModel):
|
||||||
|
host: str = Field(default="", title="Onvif Host")
|
||||||
|
port: int = Field(default=8000, title="Onvif Port")
|
||||||
|
user: Optional[str] = Field(title="Onvif Username")
|
||||||
|
password: Optional[str] = Field(title="Onvif Password")
|
||||||
|
|
||||||
|
|
||||||
class RetainModeEnum(str, Enum):
|
class RetainModeEnum(str, Enum):
|
||||||
all = "all"
|
all = "all"
|
||||||
motion = "motion"
|
motion = "motion"
|
||||||
@ -607,6 +614,9 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
detect: DetectConfig = Field(
|
detect: DetectConfig = Field(
|
||||||
default_factory=DetectConfig, title="Object detection configuration."
|
default_factory=DetectConfig, title="Object detection configuration."
|
||||||
)
|
)
|
||||||
|
onvif: OnvifConfig = Field(
|
||||||
|
default_factory=OnvifConfig, title="Camera Onvif Configuration."
|
||||||
|
)
|
||||||
ui: CameraUiConfig = Field(
|
ui: CameraUiConfig = Field(
|
||||||
default_factory=CameraUiConfig, title="Camera UI Modifications."
|
default_factory=CameraUiConfig, title="Camera UI Modifications."
|
||||||
)
|
)
|
||||||
@ -939,6 +949,15 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
for input in camera_config.ffmpeg.inputs:
|
for input in camera_config.ffmpeg.inputs:
|
||||||
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
||||||
|
|
||||||
|
# ONVIF substitution
|
||||||
|
if camera_config.onvif.user or camera_config.onvif.password:
|
||||||
|
camera_config.onvif.user = camera_config.onvif.user.format(
|
||||||
|
**FRIGATE_ENV_VARS
|
||||||
|
)
|
||||||
|
camera_config.onvif.password = camera_config.onvif.password.format(
|
||||||
|
**FRIGATE_ENV_VARS
|
||||||
|
)
|
||||||
|
|
||||||
# Add default filters
|
# Add default filters
|
||||||
object_keys = camera_config.objects.track
|
object_keys = camera_config.objects.track
|
||||||
if camera_config.objects.filters is None:
|
if camera_config.objects.filters is None:
|
||||||
|
|||||||
@ -8,7 +8,6 @@ CACHE_DIR = "/tmp/cache"
|
|||||||
YAML_EXT = (".yaml", ".yml")
|
YAML_EXT = (".yaml", ".yml")
|
||||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||||
PLUS_API_HOST = "https://api.frigate.video"
|
PLUS_API_HOST = "https://api.frigate.video"
|
||||||
MAX_SEGMENT_DURATION = 600
|
|
||||||
BTBN_PATH = "/usr/lib/btbn-ffmpeg"
|
BTBN_PATH = "/usr/lib/btbn-ffmpeg"
|
||||||
|
|
||||||
# Regex Consts
|
# Regex Consts
|
||||||
@ -23,3 +22,8 @@ DRIVER_ENV_VAR = "LIBVA_DRIVER_NAME"
|
|||||||
DRIVER_AMD = "radeonsi"
|
DRIVER_AMD = "radeonsi"
|
||||||
DRIVER_INTEL_i965 = "i965"
|
DRIVER_INTEL_i965 = "i965"
|
||||||
DRIVER_INTEL_iHD = "iHD"
|
DRIVER_INTEL_iHD = "iHD"
|
||||||
|
|
||||||
|
# Record Values
|
||||||
|
|
||||||
|
MAX_SEGMENT_DURATION = 600
|
||||||
|
SECONDS_IN_DAY = 60 * 60 * 24
|
||||||
|
|||||||
@ -41,6 +41,7 @@ from frigate.monitoring.prometheus import setupRegistry
|
|||||||
|
|
||||||
from frigate.monitoring.stats import stats_snapshot
|
from frigate.monitoring.stats import stats_snapshot
|
||||||
from frigate.plus import PlusApi
|
from frigate.plus import PlusApi
|
||||||
|
from frigate.ptz import OnvifController
|
||||||
from frigate.util import (
|
from frigate.util import (
|
||||||
clean_camera_user_pass,
|
clean_camera_user_pass,
|
||||||
ffprobe_stream,
|
ffprobe_stream,
|
||||||
@ -63,6 +64,7 @@ def create_app(
|
|||||||
stats_tracking,
|
stats_tracking,
|
||||||
detected_frames_processor,
|
detected_frames_processor,
|
||||||
storage_maintainer: StorageMaintainer,
|
storage_maintainer: StorageMaintainer,
|
||||||
|
onvif: OnvifController,
|
||||||
plus_api: PlusApi,
|
plus_api: PlusApi,
|
||||||
):
|
):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@ -81,6 +83,7 @@ def create_app(
|
|||||||
app.stats_tracking = stats_tracking
|
app.stats_tracking = stats_tracking
|
||||||
app.detected_frames_processor = detected_frames_processor
|
app.detected_frames_processor = detected_frames_processor
|
||||||
app.storage_maintainer = storage_maintainer
|
app.storage_maintainer = storage_maintainer
|
||||||
|
app.onvif = onvif
|
||||||
app.plus_api = plus_api
|
app.plus_api = plus_api
|
||||||
app.camera_error_image = None
|
app.camera_error_image = None
|
||||||
app.hwaccel_errors = []
|
app.hwaccel_errors = []
|
||||||
@ -1002,6 +1005,14 @@ def mjpeg_feed(camera_name):
|
|||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<camera_name>/ptz/info")
|
||||||
|
def camera_ptz_info(camera_name):
|
||||||
|
if camera_name in current_app.frigate_config.cameras:
|
||||||
|
return jsonify(current_app.onvif.get_camera_info(camera_name))
|
||||||
|
else:
|
||||||
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<camera_name>/latest.jpg")
|
@bp.route("/<camera_name>/latest.jpg")
|
||||||
def latest_frame(camera_name):
|
def latest_frame(camera_name):
|
||||||
draw_options = {
|
draw_options = {
|
||||||
|
|||||||
@ -19,6 +19,10 @@ from frigate.util import clean_camera_user_pass
|
|||||||
|
|
||||||
def listener_configurer() -> None:
|
def listener_configurer() -> None:
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
|
|
||||||
|
if root.hasHandlers():
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
"[%(asctime)s] %(name)-30s %(levelname)-8s: %(message)s", "%Y-%m-%d %H:%M:%S"
|
"[%(asctime)s] %(name)-30s %(levelname)-8s: %(message)s", "%Y-%m-%d %H:%M:%S"
|
||||||
@ -31,6 +35,10 @@ def listener_configurer() -> None:
|
|||||||
def root_configurer(queue: Queue) -> None:
|
def root_configurer(queue: Queue) -> None:
|
||||||
h = handlers.QueueHandler(queue)
|
h = handlers.QueueHandler(queue)
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
|
|
||||||
|
if root.hasHandlers():
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
root.addHandler(h)
|
root.addHandler(h)
|
||||||
root.setLevel(logging.INFO)
|
root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|||||||
219
frigate/ptz.py
Normal file
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()),
|
||||||
|
}
|
||||||
248
frigate/record/cleanup.py
Normal file
248
frigate/record/cleanup.py
Normal 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)
|
||||||
@ -1,5 +1,6 @@
|
|||||||
|
"""Maintain recording segments in cache."""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import os
|
import os
|
||||||
@ -8,51 +9,40 @@ import random
|
|||||||
import string
|
import string
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import psutil
|
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.config import RetainModeEnum, FrigateConfig
|
||||||
from frigate.const import CACHE_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
|
from frigate.const import CACHE_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
|
from frigate.types import RecordMetricsTypes
|
||||||
from frigate.util import area
|
from frigate.util import area
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class RecordingMaintainer(threading.Thread):
|
||||||
def __init__(
|
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)
|
threading.Thread.__init__(self)
|
||||||
self.name = "recording_maint"
|
self.name = "recording_maintainer"
|
||||||
self.config = config
|
self.config = config
|
||||||
self.recordings_info_queue = recordings_info_queue
|
self.recordings_info_queue = recordings_info_queue
|
||||||
|
self.process_info = process_info
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.recordings_info = defaultdict(list)
|
self.recordings_info: dict[str, Any] = defaultdict(list)
|
||||||
self.end_time_cache = {}
|
self.end_time_cache: dict[str, Tuple[datetime.datetime, float]] = {}
|
||||||
|
|
||||||
def move_files(self):
|
def move_files(self) -> None:
|
||||||
cache_files = sorted(
|
cache_files = sorted(
|
||||||
[
|
[
|
||||||
d
|
d
|
||||||
@ -77,14 +67,14 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# group recordings by camera
|
# group recordings by camera
|
||||||
grouped_recordings = defaultdict(list)
|
grouped_recordings: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
for f in cache_files:
|
for cache in cache_files:
|
||||||
# Skip files currently in use
|
# Skip files currently in use
|
||||||
if f in files_in_use:
|
if cache in files_in_use:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cache_path = os.path.join(CACHE_DIR, f)
|
cache_path = os.path.join(CACHE_DIR, cache)
|
||||||
basename = os.path.splitext(f)[0]
|
basename = os.path.splitext(cache)[0]
|
||||||
camera, date = basename.rsplit("-", maxsplit=1)
|
camera, date = basename.rsplit("-", maxsplit=1)
|
||||||
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
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..."
|
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]
|
to_remove = grouped_recordings[camera][:-keep_count]
|
||||||
for f in to_remove:
|
for rec in to_remove:
|
||||||
cache_path = f["cache_path"]
|
cache_path = rec["cache_path"]
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||||
@ -138,7 +128,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
# Just delete files if recordings are turned off
|
# Just delete files if recordings are turned off
|
||||||
if (
|
if (
|
||||||
not camera in self.config.cameras
|
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)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
@ -170,7 +160,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
else:
|
else:
|
||||||
if duration == -1:
|
if duration == -1:
|
||||||
logger.warning(
|
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(
|
logger.warning(
|
||||||
@ -241,7 +231,9 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
camera, start_time, end_time, duration, cache_path, record_mode
|
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
|
active_count = 0
|
||||||
motion_count = 0
|
motion_count = 0
|
||||||
for frame in self.recordings_info[camera]:
|
for frame in self.recordings_info[camera]:
|
||||||
@ -266,13 +258,13 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
def store_segment(
|
def store_segment(
|
||||||
self,
|
self,
|
||||||
camera,
|
camera: str,
|
||||||
start_time: datetime.datetime,
|
start_time: datetime.datetime,
|
||||||
end_time: datetime.datetime,
|
end_time: datetime.datetime,
|
||||||
duration,
|
duration: float,
|
||||||
cache_path,
|
cache_path: str,
|
||||||
store_mode: RetainModeEnum,
|
store_mode: RetainModeEnum,
|
||||||
):
|
) -> None:
|
||||||
motion_count, active_count = self.segment_stats(camera, start_time, end_time)
|
motion_count, active_count = self.segment_stats(camera, start_time, end_time)
|
||||||
|
|
||||||
# check if the segment shouldn't be stored
|
# check if the segment shouldn't be stored
|
||||||
@ -363,9 +355,9 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
# clear end_time cache
|
# clear end_time cache
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
# Check for new files every 5 seconds
|
# Check for new files every 5 seconds
|
||||||
wait_time = 5
|
wait_time = 5.0
|
||||||
while not self.stop_event.wait(wait_time):
|
while not self.stop_event.wait(wait_time):
|
||||||
run_start = datetime.datetime.now().timestamp()
|
run_start = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
@ -380,7 +372,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
regions,
|
regions,
|
||||||
) = self.recordings_info_queue.get(False)
|
) = 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(
|
self.recordings_info[camera].append(
|
||||||
(
|
(
|
||||||
frame_time,
|
frame_time,
|
||||||
@ -403,231 +395,3 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
wait_time = max(0, 5 - duration)
|
wait_time = max(0, 5 - duration)
|
||||||
|
|
||||||
logger.info(f"Exiting recording maintenance...")
|
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
53
frigate/record/record.py
Normal 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
19
frigate/record/util.py
Normal 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)
|
||||||
@ -114,7 +114,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_event_list(self):
|
def test_get_event_list(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi()
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "7890.random"
|
id2 = "7890.random"
|
||||||
@ -143,7 +149,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_good_event(self):
|
def test_get_good_event(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi()
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
@ -157,7 +169,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_bad_event(self):
|
def test_get_bad_event(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi()
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
bad_id = "654321.other"
|
bad_id = "654321.other"
|
||||||
@ -170,7 +188,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_delete_event(self):
|
def test_delete_event(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi()
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
@ -185,7 +209,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_event_retention(self):
|
def test_event_retention(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi()
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
@ -204,7 +234,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_set_delete_sub_label(self):
|
def test_set_delete_sub_label(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi()
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
@ -232,7 +268,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_sub_label_list(self):
|
def test_sub_label_list(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi()
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
@ -255,6 +297,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -270,6 +313,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -288,6 +332,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
mock_stats.return_value = self.test_stats
|
mock_stats.return_value = self.test_stats
|
||||||
|
|||||||
@ -24,6 +24,10 @@ class CameraMetricsTypes(TypedDict):
|
|||||||
skipped_fps: Synchronized
|
skipped_fps: Synchronized
|
||||||
|
|
||||||
|
|
||||||
|
class RecordMetricsTypes(TypedDict):
|
||||||
|
record_enabled: Synchronized
|
||||||
|
|
||||||
|
|
||||||
class StatsTrackingTypes(TypedDict):
|
class StatsTrackingTypes(TypedDict):
|
||||||
camera_metrics: dict[str, CameraMetricsTypes]
|
camera_metrics: dict[str, CameraMetricsTypes]
|
||||||
detectors: dict[str, ObjectDetectProcess]
|
detectors: dict[str, ObjectDetectProcess]
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
click == 8.1.*
|
click == 8.1.*
|
||||||
Flask == 2.2.*
|
Flask == 2.2.*
|
||||||
imutils == 0.5.*
|
imutils == 0.5.*
|
||||||
matplotlib == 3.6.*
|
matplotlib == 3.7.*
|
||||||
mypy == 0.942
|
mypy == 0.942
|
||||||
numpy == 1.23.*
|
numpy == 1.23.*
|
||||||
|
onvif_zeep == 0.2.12
|
||||||
opencv-python-headless == 4.5.5.*
|
opencv-python-headless == 4.5.5.*
|
||||||
paho-mqtt == 1.6.*
|
paho-mqtt == 1.6.*
|
||||||
peewee == 3.15.*
|
peewee == 3.15.*
|
||||||
peewee_migrate == 1.6.*
|
peewee_migrate == 1.7.*
|
||||||
psutil == 5.9.*
|
psutil == 5.9.*
|
||||||
pydantic == 1.10.*
|
pydantic == 1.10.*
|
||||||
PyYAML == 6.0
|
PyYAML == 6.0
|
||||||
pytz == 2023.3
|
pytz == 2023.3
|
||||||
tzlocal == 4.2
|
tzlocal == 4.3
|
||||||
types-PyYAML == 6.0.*
|
types-PyYAML == 6.0.*
|
||||||
requests == 2.28.*
|
requests == 2.28.*
|
||||||
types-requests == 2.28.*
|
types-requests == 2.28.*
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
scikit-build == 0.17.1
|
scikit-build == 0.17.*
|
||||||
nvidia-pyindex
|
nvidia-pyindex
|
||||||
|
|||||||
741
web/package-lock.json
generated
741
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.0.5",
|
"@cycjimmy/jsmpeg-player": "^6.0.5",
|
||||||
"axios": "^1.3.5",
|
"axios": "^1.3.6",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"idb-keyval": "^6.2.0",
|
"idb-keyval": "^6.2.0",
|
||||||
@ -36,23 +36,23 @@
|
|||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/preact": "^3.2.3",
|
"@testing-library/preact": "^3.2.3",
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.58.0",
|
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||||
"@typescript-eslint/parser": "^5.58.0",
|
"@typescript-eslint/parser": "^5.59.1",
|
||||||
"@vitest/coverage-c8": "^0.30.1",
|
"@vitest/coverage-c8": "^0.30.1",
|
||||||
"@vitest/ui": "^0.30.1",
|
"@vitest/ui": "^0.30.1",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"eslint": "^8.38.0",
|
"eslint": "^8.39.0",
|
||||||
"eslint-config-preact": "^1.3.0",
|
"eslint-config-preact": "^1.3.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-vitest-globals": "^1.3.1",
|
"eslint-plugin-vitest-globals": "^1.3.1",
|
||||||
"fake-indexeddb": "^4.0.1",
|
"fake-indexeddb": "^4.0.1",
|
||||||
"jsdom": "^21.1.1",
|
"jsdom": "^21.1.1",
|
||||||
"msw": "^1.2.1",
|
"msw": "^1.2.1",
|
||||||
"postcss": "^8.4.19",
|
"postcss": "^8.4.23",
|
||||||
"prettier": "^2.8.7",
|
"prettier": "^2.8.8",
|
||||||
"tailwindcss": "^3.3.1",
|
"tailwindcss": "^3.3.2",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4",
|
||||||
"vite": "^4.2.1",
|
"vite": "^4.3.2",
|
||||||
"vitest": "^0.30.1"
|
"vitest": "^0.30.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,6 +120,15 @@ export function useSnapshotsState(camera) {
|
|||||||
return { payload, send, connected };
|
return { payload, send, connected };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePtzCommand(camera) {
|
||||||
|
const {
|
||||||
|
value: { payload },
|
||||||
|
send,
|
||||||
|
connected,
|
||||||
|
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
|
||||||
|
return { payload, send, connected };
|
||||||
|
}
|
||||||
|
|
||||||
export function useRestart() {
|
export function useRestart() {
|
||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
|
|||||||
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 = '' }) {
|
export function ArrowRightDouble({ className = '' }) {
|
||||||
return (
|
return (
|
||||||
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg
|
||||||
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className={`${className}`}
|
||||||
|
>
|
||||||
|
<path d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 WebRtcPlayer from '../components/WebRtcPlayer';
|
||||||
import MsePlayer from '../components/MsePlayer';
|
import MsePlayer from '../components/MsePlayer';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import { useMemo } from 'preact/hooks';
|
||||||
|
import CameraControlPanel from '../components/CameraControlPanel';
|
||||||
|
|
||||||
export default function Birdseye() {
|
export default function Birdseye() {
|
||||||
const { data: config } = useSWR('config');
|
const { data: config } = useSWR('config');
|
||||||
@ -16,6 +18,16 @@ export default function Birdseye() {
|
|||||||
);
|
);
|
||||||
const sourceValues = ['mse', 'webrtc', 'jsmpeg'];
|
const sourceValues = ['mse', 'webrtc', 'jsmpeg'];
|
||||||
|
|
||||||
|
const ptzCameras = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(config.cameras)
|
||||||
|
.filter(([_, conf]) => conf.onvif?.host)
|
||||||
|
.map(([_, camera]) => camera.name);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
if (!config || !sourceIsLoaded) {
|
if (!config || !sourceIsLoaded) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
@ -25,7 +37,7 @@ export default function Birdseye() {
|
|||||||
if ('MediaSource' in window) {
|
if ('MediaSource' in window) {
|
||||||
player = (
|
player = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="max-w-5xl">
|
<div className="max-w-5xl xl:w-1/2">
|
||||||
<MsePlayer camera="birdseye" />
|
<MsePlayer camera="birdseye" />
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@ -42,7 +54,7 @@ export default function Birdseye() {
|
|||||||
} else if (viewSource == 'webrtc' && config.birdseye.restream) {
|
} else if (viewSource == 'webrtc' && config.birdseye.restream) {
|
||||||
player = (
|
player = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="max-w-5xl">
|
<div className="max-w-5xl xl:w-1/2">
|
||||||
<WebRtcPlayer camera="birdseye" />
|
<WebRtcPlayer camera="birdseye" />
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@ -50,7 +62,7 @@ export default function Birdseye() {
|
|||||||
} else {
|
} else {
|
||||||
player = (
|
player = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="max-w-7xl">
|
<div className="max-w-7xl xl:w-1/2">
|
||||||
<JSMpegPlayer camera="birdseye" />
|
<JSMpegPlayer camera="birdseye" />
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@ -79,7 +91,21 @@ export default function Birdseye() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { useApiHost } from '../api';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import WebRtcPlayer from '../components/WebRtcPlayer';
|
import WebRtcPlayer from '../components/WebRtcPlayer';
|
||||||
import MsePlayer from '../components/MsePlayer';
|
import MsePlayer from '../components/MsePlayer';
|
||||||
|
import CameraControlPanel from '../components/CameraControlPanel';
|
||||||
|
|
||||||
const emptyObject = Object.freeze({});
|
const emptyObject = Object.freeze({});
|
||||||
|
|
||||||
@ -188,6 +189,13 @@ export default function Camera({ camera }) {
|
|||||||
|
|
||||||
{player}
|
{player}
|
||||||
|
|
||||||
|
{cameraConfig?.onvif?.host && (
|
||||||
|
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min">
|
||||||
|
<Heading size="sm">Control Panel</Heading>
|
||||||
|
<CameraControlPanel camera={camera} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Heading size="sm">Tracked objects</Heading>
|
<Heading size="sm">Tracked objects</Heading>
|
||||||
<div className="flex flex-wrap justify-start">
|
<div className="flex flex-wrap justify-start">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user