mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 00:38:58 +03:00
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* check for a valid frame before using its shape With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first. * fix the has_clip self-heal for events with no recordings `vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code. * return 403 for a snapshot or thumbnail on another camera The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates. * find DST transitions to the second `get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition. * don't run page shortcuts for keys a dialog already handled Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render. * fix train image filtering for a class with a dash The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now. * don't edit a chat message while a reply streams The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends. * fix restart failing under non-root restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it. * show runtime overrides in the settings form The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config. * fix mobile overflowing icons in system due to new health pane * fix genai settings keeping a stale model and dropping roles after save Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces. * close onvif sessions on shutdown `OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it. * fixes * fixes
1164 lines
46 KiB
Python
1164 lines
46 KiB
Python
"""Configure and control camera via onvif."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
import time
|
|
from enum import Enum
|
|
from importlib.util import find_spec
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy
|
|
from onvif import ONVIFCamera, ONVIFError, ONVIFService
|
|
from zeep.exceptions import Fault, TransportError
|
|
|
|
from frigate.camera import PTZMetrics
|
|
from frigate.config import FrigateConfig, ZoomingModeEnum
|
|
from frigate.config.camera.updater import (
|
|
CameraConfigUpdateEnum,
|
|
CameraConfigUpdateSubscriber,
|
|
)
|
|
from frigate.util.builtin import find_by_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OnvifCommandEnum(str, Enum):
|
|
"""Holds all possible move commands"""
|
|
|
|
init = "init"
|
|
move_down = "move_down"
|
|
move_left = "move_left"
|
|
move_relative = "move_relative"
|
|
move_right = "move_right"
|
|
move_up = "move_up"
|
|
preset = "preset"
|
|
stop = "stop"
|
|
zoom_in = "zoom_in"
|
|
zoom_out = "zoom_out"
|
|
focus_in = "focus_in"
|
|
focus_out = "focus_out"
|
|
|
|
|
|
class OnvifController:
|
|
ptz_metrics: dict[str, PTZMetrics]
|
|
|
|
def __init__(
|
|
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics]
|
|
) -> None:
|
|
self.cams: dict[str, dict] = {}
|
|
self.failed_cams: dict[str, dict] = {}
|
|
self.max_retries = 5
|
|
self.reset_timeout = 900 # 15 minutes
|
|
self.config = config
|
|
self.ptz_metrics = ptz_metrics
|
|
|
|
self.status_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
# Create a dedicated event loop and run it in a separate thread
|
|
self.loop = asyncio.new_event_loop()
|
|
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
|
self.loop_thread.start()
|
|
|
|
self.camera_configs = {}
|
|
for cam_name, cam in config.cameras.items():
|
|
if not cam.enabled:
|
|
continue
|
|
if cam.onvif.host:
|
|
self.camera_configs[cam_name] = cam
|
|
self.status_locks[cam_name] = asyncio.Lock()
|
|
|
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
|
self.config,
|
|
self.config.cameras,
|
|
[
|
|
CameraConfigUpdateEnum.onvif,
|
|
CameraConfigUpdateEnum.add,
|
|
CameraConfigUpdateEnum.remove,
|
|
],
|
|
)
|
|
|
|
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
|
|
asyncio.run_coroutine_threadsafe(self._poll_config_updates(), self.loop)
|
|
|
|
def _run_event_loop(self) -> None:
|
|
"""Run the event loop in a separate thread."""
|
|
asyncio.set_event_loop(self.loop)
|
|
try:
|
|
self.loop.run_forever()
|
|
except Exception as e:
|
|
logger.error(f"Onvif event loop terminated unexpectedly: {e}")
|
|
|
|
async def _init_cameras(self) -> None:
|
|
"""Initialize all configured cameras."""
|
|
for cam_name in self.camera_configs:
|
|
await self._init_single_camera(cam_name)
|
|
|
|
async def _poll_config_updates(self) -> None:
|
|
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
try:
|
|
updates = self.config_subscriber.check_for_updates()
|
|
for update_type, cameras in updates.items():
|
|
if update_type == CameraConfigUpdateEnum.onvif.name:
|
|
for cam_name in cameras:
|
|
await self._reinit_camera(cam_name)
|
|
elif update_type == CameraConfigUpdateEnum.add.name:
|
|
# a camera added at runtime only needs ONVIF set up if
|
|
# it actually has an onvif host configured
|
|
for cam_name in cameras:
|
|
cam = self.config.cameras.get(cam_name)
|
|
if cam and cam.onvif.host:
|
|
await self._reinit_camera(cam_name)
|
|
elif update_type == CameraConfigUpdateEnum.remove.name:
|
|
for cam_name in cameras:
|
|
await self._remove_camera(cam_name)
|
|
except Exception:
|
|
logger.error("Error checking for ONVIF config updates")
|
|
|
|
async def _close_camera(self, cam_name: str) -> None:
|
|
"""Close the ONVIF client session for a camera."""
|
|
cam_state = self.cams.get(cam_name)
|
|
if cam_state and "onvif" in cam_state:
|
|
try:
|
|
await cam_state["onvif"].close()
|
|
except Exception:
|
|
logger.debug(f"Error closing ONVIF session for {cam_name}")
|
|
|
|
async def _remove_camera(self, cam_name: str) -> None:
|
|
"""Tear down the ONVIF session for a camera removed at runtime."""
|
|
if cam_name not in self.cams and cam_name not in self.camera_configs:
|
|
return
|
|
|
|
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
|
|
await self._close_camera(cam_name)
|
|
self.cams.pop(cam_name, None)
|
|
self.camera_configs.pop(cam_name, None)
|
|
self.failed_cams.pop(cam_name, None)
|
|
self.status_locks.pop(cam_name, None)
|
|
|
|
async def _reinit_camera(self, cam_name: str) -> None:
|
|
"""Re-initialize a camera after config change."""
|
|
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
|
|
|
# close existing session before re-init
|
|
await self._close_camera(cam_name)
|
|
|
|
cam = self.config.cameras.get(cam_name)
|
|
if not cam or not cam.onvif.host:
|
|
# ONVIF removed from config, clean up
|
|
self.cams.pop(cam_name, None)
|
|
self.camera_configs.pop(cam_name, None)
|
|
self.failed_cams.pop(cam_name, None)
|
|
return
|
|
|
|
# update stored config and reset state
|
|
self.camera_configs[cam_name] = cam
|
|
if cam_name not in self.status_locks:
|
|
self.status_locks[cam_name] = asyncio.Lock()
|
|
self.cams.pop(cam_name, None)
|
|
self.failed_cams.pop(cam_name, None)
|
|
|
|
await self._init_single_camera(cam_name)
|
|
|
|
async def _init_single_camera(self, cam_name: str) -> bool:
|
|
"""Initialize a single camera by name.
|
|
|
|
Args:
|
|
cam_name: The name of the camera to initialize
|
|
|
|
Returns:
|
|
bool: True if initialization succeeded, False otherwise
|
|
"""
|
|
if cam_name not in self.camera_configs:
|
|
logger.error(f"No configuration found for camera {cam_name}")
|
|
return False
|
|
|
|
cam = self.camera_configs[cam_name]
|
|
try:
|
|
self.cams[cam_name] = {
|
|
"onvif": ONVIFCamera(
|
|
cam.onvif.host,
|
|
cam.onvif.port,
|
|
cam.onvif.user,
|
|
cam.onvif.password,
|
|
wsdl_dir=str(Path(find_spec("onvif").origin).parent / "wsdl"),
|
|
adjust_time=cam.onvif.ignore_time_mismatch,
|
|
encrypt=not cam.onvif.tls_insecure,
|
|
),
|
|
"init": False,
|
|
"active": False,
|
|
"features": [],
|
|
"presets": {},
|
|
"profiles": [],
|
|
}
|
|
return True
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
|
|
# track initial failures
|
|
self.failed_cams[cam_name] = {
|
|
"retry_attempts": 0,
|
|
"last_error": str(e),
|
|
"last_attempt": time.time(),
|
|
}
|
|
return False
|
|
|
|
async def _init_onvif(self, camera_name: str) -> bool:
|
|
camera_config = self.config.cameras.get(camera_name)
|
|
|
|
if camera_config is None:
|
|
return False
|
|
|
|
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
|
try:
|
|
await onvif.update_xaddrs()
|
|
except Exception as e:
|
|
logger.error(f"Onvif connection failed for {camera_name}: {e}")
|
|
return False
|
|
|
|
# create init services
|
|
media: ONVIFService = await onvif.create_media_service()
|
|
logger.debug(f"Onvif media xaddr for {camera_name}: {media.xaddr}")
|
|
|
|
try:
|
|
# this will fire an exception if camera is not a ptz
|
|
capabilities = onvif.get_definition("ptz")
|
|
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.error(
|
|
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
|
|
)
|
|
return False
|
|
|
|
try:
|
|
profiles = await media.GetProfiles()
|
|
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.error(
|
|
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
|
)
|
|
return False
|
|
|
|
# build list of valid PTZ profiles
|
|
valid_profiles = [
|
|
p
|
|
for p in profiles
|
|
if p.VideoEncoderConfiguration
|
|
and p.PTZConfiguration
|
|
and (
|
|
p.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace is not None
|
|
or p.PTZConfiguration.DefaultContinuousZoomVelocitySpace is not None
|
|
)
|
|
]
|
|
|
|
# store available profiles for API response and log for debugging
|
|
self.cams[camera_name]["profiles"] = [
|
|
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
|
for p in valid_profiles
|
|
]
|
|
for p in valid_profiles:
|
|
logger.debug(
|
|
"Onvif profile for %s: name='%s', token='%s'",
|
|
camera_name,
|
|
getattr(p, "Name", None),
|
|
p.token,
|
|
)
|
|
|
|
configured_profile = camera_config.onvif.profile
|
|
profile = None
|
|
|
|
if configured_profile is not None:
|
|
# match by exact token first, then by name
|
|
for p in valid_profiles:
|
|
if p.token == configured_profile:
|
|
profile = p
|
|
break
|
|
if profile is None:
|
|
for p in valid_profiles:
|
|
if getattr(p, "Name", None) == configured_profile:
|
|
profile = p
|
|
break
|
|
if profile is None:
|
|
available = [
|
|
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
|
for p in valid_profiles
|
|
]
|
|
logger.error(
|
|
"Onvif profile '%s' not found for camera %s. Available profiles: %s",
|
|
configured_profile,
|
|
camera_name,
|
|
available,
|
|
)
|
|
return False
|
|
else:
|
|
# use the first profile that has a valid ptz configuration
|
|
profile = valid_profiles[0] if valid_profiles else None
|
|
|
|
if profile is None:
|
|
logger.error(
|
|
f"No appropriate Onvif profiles found for camera: {camera_name}."
|
|
)
|
|
return False
|
|
|
|
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
|
|
|
# get the PTZ config for the profile
|
|
try:
|
|
configs = profile.PTZConfiguration
|
|
logger.debug(
|
|
f"Onvif ptz config for media profile in {camera_name}: {configs}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Invalid Onvif PTZ configuration for camera: {camera_name}: {e}"
|
|
)
|
|
return False
|
|
|
|
ptz: ONVIFService = await onvif.create_ptz_service()
|
|
self.cams[camera_name]["ptz"] = ptz
|
|
|
|
try:
|
|
imaging: ONVIFService = await onvif.create_imaging_service()
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
|
imaging = None
|
|
self.cams[camera_name]["imaging"] = imaging
|
|
try:
|
|
video_sources = await media.GetVideoSources()
|
|
if video_sources and len(video_sources) > 0:
|
|
self.cams[camera_name]["video_source_token"] = video_sources[0].token
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
|
|
self.cams[camera_name]["video_source_token"] = None
|
|
|
|
# setup continuous moving request
|
|
move_request = ptz.create_type("ContinuousMove")
|
|
move_request.ProfileToken = profile.token
|
|
self.cams[camera_name]["move_request"] = move_request
|
|
|
|
# get PTZ configuration options for feature detection and relative movement
|
|
ptz_config = None
|
|
fov_space_id = None
|
|
|
|
try:
|
|
request = ptz.create_type("GetConfigurationOptions")
|
|
request.ConfigurationToken = profile.PTZConfiguration.token
|
|
ptz_config = await ptz.GetConfigurationOptions(request)
|
|
logger.debug(
|
|
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
|
)
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.debug(
|
|
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
|
)
|
|
|
|
# detect FOV translation space for relative movement
|
|
if ptz_config is not None:
|
|
try:
|
|
fov_space_id = next(
|
|
(
|
|
i
|
|
for i, space in enumerate(
|
|
ptz_config.Spaces.RelativePanTiltTranslationSpace
|
|
)
|
|
if "TranslationSpaceFov" in space["URI"]
|
|
),
|
|
None,
|
|
)
|
|
except (AttributeError, TypeError):
|
|
fov_space_id = None
|
|
|
|
autotracking_config = camera_config.onvif.autotracking
|
|
autotracking_enabled = (
|
|
autotracking_config.enabled_in_config and autotracking_config.enabled
|
|
)
|
|
|
|
# these are local and cost nothing to build, and autotracking can be enabled
|
|
# after a camera is initialized, so always create them rather than baking the
|
|
# current config value into init state
|
|
status_request = ptz.create_type("GetStatus")
|
|
status_request.ProfileToken = profile.token
|
|
self.cams[camera_name]["status_request"] = status_request
|
|
|
|
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
|
self.cams[camera_name]["service_capabilities_request"] = (
|
|
service_capabilities_request
|
|
)
|
|
|
|
# setup relative move request when FOV relative movement is supported
|
|
if (
|
|
fov_space_id is not None
|
|
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
|
):
|
|
# one-off GetStatus to seed Translation field
|
|
status = None
|
|
try:
|
|
one_off_status_request = ptz.create_type("GetStatus")
|
|
one_off_status_request.ProfileToken = profile.token
|
|
status = await ptz.GetStatus(one_off_status_request)
|
|
logger.debug(f"Onvif status for {camera_name}: {status}")
|
|
except Exception as e:
|
|
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
|
|
|
rel_move_request = ptz.create_type("RelativeMove")
|
|
rel_move_request.ProfileToken = profile.token
|
|
logger.debug(f"{camera_name}: Relative move request: {rel_move_request}")
|
|
|
|
fov_uri = ptz_config["Spaces"]["RelativePanTiltTranslationSpace"][
|
|
fov_space_id
|
|
]["URI"]
|
|
|
|
if rel_move_request.Translation is None:
|
|
if status is not None:
|
|
# seed from current position
|
|
rel_move_request.Translation = status.Position
|
|
rel_move_request.Translation.PanTilt.space = fov_uri
|
|
else:
|
|
# fallback: construct Translation explicitly
|
|
rel_move_request.Translation = {
|
|
"PanTilt": {"x": 0, "y": 0, "space": fov_uri}
|
|
}
|
|
|
|
# configure zoom on relative move request
|
|
if (
|
|
autotracking_enabled
|
|
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
|
):
|
|
zoom_space_id = next(
|
|
(
|
|
i
|
|
for i, space in enumerate(
|
|
ptz_config.Spaces.RelativeZoomTranslationSpace
|
|
)
|
|
if "TranslationGenericSpace" in space["URI"]
|
|
),
|
|
None,
|
|
)
|
|
try:
|
|
if zoom_space_id is not None:
|
|
rel_move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
|
"RelativeZoomTranslationSpace"
|
|
][zoom_space_id]["URI"]
|
|
except Exception as e:
|
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
|
logger.warning(
|
|
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
|
)
|
|
else:
|
|
# remove zoom fields from relative move request
|
|
if (
|
|
rel_move_request["Translation"] is not None
|
|
and "Zoom" in rel_move_request["Translation"]
|
|
):
|
|
del rel_move_request["Translation"]["Zoom"]
|
|
if (
|
|
rel_move_request["Speed"] is not None
|
|
and "Zoom" in rel_move_request["Speed"]
|
|
):
|
|
del rel_move_request["Speed"]["Zoom"]
|
|
logger.debug(
|
|
f"{camera_name}: Relative move request after deleting zoom: {rel_move_request}"
|
|
)
|
|
|
|
if rel_move_request.Speed is None:
|
|
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
|
logger.debug(
|
|
f"{camera_name}: Relative move request after setup: {rel_move_request}"
|
|
)
|
|
self.cams[camera_name]["relative_move_request"] = rel_move_request
|
|
|
|
# setup absolute move request
|
|
abs_move_request = ptz.create_type("AbsoluteMove")
|
|
abs_move_request.ProfileToken = profile.token
|
|
self.cams[camera_name]["absolute_move_request"] = abs_move_request
|
|
|
|
# setup existing presets
|
|
try:
|
|
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
|
|
presets = []
|
|
|
|
for preset in presets:
|
|
preset_name = getattr(preset, "Name") or f"preset {preset['token']}"
|
|
# Some cameras (e.g. Reolink) return UTF-8 bytes that zeep decodes
|
|
# as latin-1, producing mojibake. Detect that and repair it by
|
|
# round-tripping through latin-1 -> utf-8.
|
|
try:
|
|
preset_name = preset_name.encode("latin-1").decode("utf-8")
|
|
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
pass
|
|
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
|
|
|
|
# get list of supported features
|
|
supported_features = []
|
|
|
|
if configs.DefaultContinuousPanTiltVelocitySpace:
|
|
supported_features.append("pt")
|
|
|
|
if configs.DefaultContinuousZoomVelocitySpace:
|
|
supported_features.append("zoom")
|
|
|
|
if configs.DefaultRelativePanTiltTranslationSpace:
|
|
supported_features.append("pt-r")
|
|
|
|
if configs.DefaultRelativeZoomTranslationSpace:
|
|
supported_features.append("zoom-r")
|
|
if ptz_config is not None:
|
|
try:
|
|
self.cams[camera_name]["relative_zoom_range"] = (
|
|
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
|
)
|
|
except Exception as e:
|
|
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
|
logger.warning(
|
|
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
|
)
|
|
|
|
if configs.DefaultAbsoluteZoomPositionSpace:
|
|
supported_features.append("zoom-a")
|
|
if ptz_config is not None:
|
|
try:
|
|
self.cams[camera_name]["absolute_zoom_range"] = (
|
|
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
|
)
|
|
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
|
except Exception as e:
|
|
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
|
logger.warning(
|
|
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
|
)
|
|
|
|
# disable autotracking zoom if required ranges are unavailable
|
|
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
|
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
|
if "relative_zoom_range" not in self.cams[camera_name]:
|
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
|
logger.warning(
|
|
f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable"
|
|
)
|
|
if autotracking_config.zooming == ZoomingModeEnum.absolute:
|
|
if "absolute_zoom_range" not in self.cams[camera_name]:
|
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
|
logger.warning(
|
|
f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable"
|
|
)
|
|
|
|
if (
|
|
self.cams[camera_name]["video_source_token"] is not None
|
|
and imaging is not None
|
|
):
|
|
try:
|
|
imaging_capabilities = await imaging.GetImagingSettings(
|
|
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
|
)
|
|
if (
|
|
hasattr(imaging_capabilities, "Focus")
|
|
and imaging_capabilities.Focus
|
|
):
|
|
supported_features.append("focus")
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
|
|
|
# detect FOV relative movement support
|
|
if (
|
|
fov_space_id is not None
|
|
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
|
):
|
|
supported_features.append("pt-r-fov")
|
|
self.cams[camera_name]["relative_fov_range"] = (
|
|
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
|
)
|
|
|
|
self.cams[camera_name]["features"] = supported_features
|
|
self.cams[camera_name]["init"] = True
|
|
return True
|
|
|
|
async def _stop(self, camera_name: str) -> None:
|
|
move_request = self.cams[camera_name]["move_request"]
|
|
await self.cams[camera_name]["ptz"].Stop(
|
|
{
|
|
"ProfileToken": move_request.ProfileToken,
|
|
"PanTilt": True,
|
|
"Zoom": True,
|
|
}
|
|
)
|
|
if (
|
|
"focus" in self.cams[camera_name]["features"]
|
|
and self.cams[camera_name]["video_source_token"]
|
|
and self.cams[camera_name]["imaging"] is not None
|
|
):
|
|
try:
|
|
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
|
stop_request.VideoSourceToken = self.cams[camera_name][
|
|
"video_source_token"
|
|
]
|
|
await self.cams[camera_name]["imaging"].Stop(stop_request)
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
|
|
self.cams[camera_name]["active"] = False
|
|
|
|
async 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..."
|
|
)
|
|
await self._stop(camera_name)
|
|
|
|
if "pt" not in self.cams[camera_name]["features"]:
|
|
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
|
|
return
|
|
|
|
self.cams[camera_name]["active"] = True
|
|
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,
|
|
}
|
|
}
|
|
|
|
try:
|
|
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
|
|
|
|
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
|
if "pt-r-fov" not in self.cams[camera_name]["features"]:
|
|
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
|
return
|
|
|
|
metrics = self.ptz_metrics.get(camera_name)
|
|
|
|
if metrics is None:
|
|
return
|
|
|
|
logger.debug(
|
|
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
|
)
|
|
|
|
if self.cams[camera_name]["active"]:
|
|
logger.warning(
|
|
f"{camera_name} is already performing an action, not moving..."
|
|
)
|
|
return
|
|
|
|
self.cams[camera_name]["active"] = True
|
|
|
|
# only track start_time for autotracking
|
|
if metrics.autotracker_enabled.value:
|
|
metrics.motor_stopped.clear()
|
|
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
|
metrics.start_time.value = metrics.frame_time.value
|
|
metrics.stop_time.value = 0
|
|
|
|
move_request = self.cams[camera_name]["relative_move_request"]
|
|
|
|
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
|
|
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
|
|
pan = numpy.interp(
|
|
pan,
|
|
[-1, 1],
|
|
[
|
|
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
|
|
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
|
|
],
|
|
)
|
|
tilt = numpy.interp(
|
|
tilt,
|
|
[-1, 1],
|
|
[
|
|
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
|
|
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
|
|
],
|
|
)
|
|
|
|
move_request.Speed = {
|
|
"PanTilt": {
|
|
"x": speed,
|
|
"y": speed,
|
|
},
|
|
}
|
|
|
|
move_request.Translation.PanTilt.x = pan
|
|
move_request.Translation.PanTilt.y = tilt
|
|
|
|
# include zoom if requested and camera supports relative zoom
|
|
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
|
move_request.Speed = {
|
|
"PanTilt": {
|
|
"x": speed,
|
|
"y": speed,
|
|
},
|
|
"Zoom": {"x": speed},
|
|
}
|
|
move_request["Translation"]["Zoom"] = {"x": zoom}
|
|
|
|
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
|
|
|
# reset after the move request
|
|
move_request.Translation.PanTilt.x = 0
|
|
move_request.Translation.PanTilt.y = 0
|
|
|
|
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
|
del move_request["Translation"]["Zoom"]
|
|
|
|
self.cams[camera_name]["active"] = False
|
|
|
|
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
|
preset = preset.lower()
|
|
|
|
if preset not in self.cams[camera_name]["presets"]:
|
|
logger.error(f"{preset} is not a valid preset for {camera_name}")
|
|
return
|
|
|
|
metrics = self.ptz_metrics.get(camera_name)
|
|
|
|
if metrics is None:
|
|
return
|
|
|
|
self.cams[camera_name]["active"] = True
|
|
metrics.start_time.value = 0
|
|
metrics.stop_time.value = 0
|
|
move_request = self.cams[camera_name]["move_request"]
|
|
preset_token = self.cams[camera_name]["presets"][preset]
|
|
|
|
await self.cams[camera_name]["ptz"].GotoPreset(
|
|
{
|
|
"ProfileToken": move_request.ProfileToken,
|
|
"PresetToken": preset_token,
|
|
}
|
|
)
|
|
|
|
self.cams[camera_name]["active"] = False
|
|
|
|
async 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..."
|
|
)
|
|
await self._stop(camera_name)
|
|
|
|
if "zoom" not in self.cams[camera_name]["features"]:
|
|
logger.error(f"{camera_name} does not support ONVIF zooming.")
|
|
return
|
|
|
|
self.cams[camera_name]["active"] = True
|
|
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}}
|
|
|
|
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
|
|
|
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
|
if "zoom-a" not in self.cams[camera_name]["features"]:
|
|
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
|
return
|
|
|
|
metrics = self.ptz_metrics.get(camera_name)
|
|
|
|
if metrics is None:
|
|
return
|
|
|
|
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
|
|
|
|
if self.cams[camera_name]["active"]:
|
|
logger.warning(
|
|
f"{camera_name} is already performing an action, not moving..."
|
|
)
|
|
return
|
|
|
|
self.cams[camera_name]["active"] = True
|
|
metrics.motor_stopped.clear()
|
|
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
|
metrics.start_time.value = metrics.frame_time.value
|
|
metrics.stop_time.value = 0
|
|
move_request = self.cams[camera_name]["absolute_move_request"]
|
|
|
|
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
|
|
zoom = numpy.interp(
|
|
zoom,
|
|
[0, 1],
|
|
[
|
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
|
],
|
|
)
|
|
|
|
move_request.Speed = {"Zoom": speed}
|
|
move_request.Position = {"Zoom": zoom}
|
|
|
|
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
|
|
|
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
|
|
|
|
self.cams[camera_name]["active"] = False
|
|
|
|
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
|
if self.cams[camera_name]["active"]:
|
|
logger.warning(
|
|
f"{camera_name} is already performing an action, not moving..."
|
|
)
|
|
await self._stop(camera_name)
|
|
|
|
if (
|
|
"focus" not in self.cams[camera_name]["features"]
|
|
or not self.cams[camera_name]["video_source_token"]
|
|
or self.cams[camera_name]["imaging"] is None
|
|
):
|
|
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
|
return
|
|
|
|
self.cams[camera_name]["active"] = True
|
|
move_request = self.cams[camera_name]["imaging"].create_type("Move")
|
|
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"]
|
|
move_request.Focus = {
|
|
"Continuous": {
|
|
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
|
|
}
|
|
}
|
|
|
|
try:
|
|
await self.cams[camera_name]["imaging"].Move(move_request)
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
|
|
self.cams[camera_name]["active"] = False
|
|
|
|
async def handle_command_async(
|
|
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
|
) -> None:
|
|
"""Handle ONVIF commands asynchronously"""
|
|
if camera_name not in self.cams.keys():
|
|
logger.error(f"ONVIF is not configured for {camera_name}")
|
|
return
|
|
|
|
if not self.cams[camera_name]["init"]:
|
|
if not await self._init_onvif(camera_name):
|
|
return
|
|
|
|
try:
|
|
if command == OnvifCommandEnum.init:
|
|
# already init
|
|
return
|
|
elif command == OnvifCommandEnum.stop:
|
|
await self._stop(camera_name)
|
|
elif command == OnvifCommandEnum.preset:
|
|
await self._move_to_preset(camera_name, param)
|
|
elif command == OnvifCommandEnum.move_relative:
|
|
parts = param.split("_")
|
|
if len(parts) == 3:
|
|
_, pan, tilt = parts
|
|
zoom = 0.0
|
|
elif len(parts) == 4:
|
|
_, pan, tilt, zoom = parts
|
|
else:
|
|
logger.error(f"Invalid move_relative params: {param}")
|
|
return
|
|
await self._move_relative(
|
|
camera_name, float(pan), float(tilt), float(zoom), 1
|
|
)
|
|
elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
|
|
await self._zoom(camera_name, command)
|
|
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
|
|
await self._focus(camera_name, command)
|
|
else:
|
|
await self._move(camera_name, command)
|
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
|
logger.error(f"Unable to handle onvif command: {e}")
|
|
|
|
def handle_command(
|
|
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
|
) -> None:
|
|
"""
|
|
Handle ONVIF commands by scheduling them in the event loop.
|
|
"""
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self.handle_command_async(camera_name, command, param), self.loop
|
|
)
|
|
|
|
try:
|
|
# Wait with a timeout to prevent blocking indefinitely
|
|
future.result(timeout=10)
|
|
except TimeoutError:
|
|
logger.error(f"Command {command} timed out for camera {camera_name}")
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error executing command {command} for camera {camera_name}: {e}"
|
|
)
|
|
|
|
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
|
|
"""
|
|
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
|
|
but not initialized.
|
|
|
|
Returns camera details including features and presets if available.
|
|
"""
|
|
camera_config = self.config.cameras.get(camera_name)
|
|
|
|
if camera_config is None:
|
|
return {}
|
|
|
|
if not camera_config.enabled:
|
|
logger.debug(
|
|
f"Camera {camera_name} disabled, won't try to initialize ONVIF"
|
|
)
|
|
return {}
|
|
|
|
if camera_name not in self.cams.keys() and (not camera_config.onvif.host):
|
|
logger.debug(f"ONVIF is not configured for {camera_name}")
|
|
return {}
|
|
|
|
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
|
|
return {
|
|
"name": camera_name,
|
|
"features": self.cams[camera_name]["features"],
|
|
"presets": list(self.cams[camera_name]["presets"].keys()),
|
|
"profiles": self.cams[camera_name].get("profiles", []),
|
|
}
|
|
|
|
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
|
|
success = await self._init_single_camera(camera_name)
|
|
if not success:
|
|
return {}
|
|
|
|
# Reset retry count after timeout
|
|
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
|
|
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
|
|
|
|
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
|
|
logger.debug(f"Resetting retry count for {camera_name} after timeout")
|
|
attempts = 0
|
|
self.failed_cams[camera_name]["retry_attempts"] = 0
|
|
|
|
# Attempt initialization/reconnection
|
|
if attempts < self.max_retries:
|
|
logger.info(
|
|
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
|
|
)
|
|
try:
|
|
if await self._init_onvif(camera_name):
|
|
if camera_name in self.failed_cams:
|
|
del self.failed_cams[camera_name]
|
|
return {
|
|
"name": camera_name,
|
|
"features": self.cams[camera_name]["features"],
|
|
"presets": list(self.cams[camera_name]["presets"].keys()),
|
|
}
|
|
else:
|
|
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error during ONVIF initialization for {camera_name}: {e}"
|
|
)
|
|
if camera_name not in self.failed_cams:
|
|
self.failed_cams[camera_name] = {"retry_attempts": 0}
|
|
self.failed_cams[camera_name].update(
|
|
{
|
|
"retry_attempts": attempts + 1,
|
|
"last_error": str(e),
|
|
"last_attempt": time.time(),
|
|
}
|
|
)
|
|
|
|
if attempts >= self.max_retries:
|
|
remaining_time = max(
|
|
0, int((self.reset_timeout - (time.time() - last_attempt)) / 60)
|
|
)
|
|
logger.error(
|
|
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
|
|
)
|
|
|
|
logger.debug(f"Could not initialize ONVIF for {camera_name}")
|
|
return {}
|
|
|
|
async def get_service_capabilities(self, camera_name: str) -> None:
|
|
if camera_name not in self.cams.keys():
|
|
logger.error(f"ONVIF is not configured for {camera_name}")
|
|
return {}
|
|
|
|
if not self.cams[camera_name]["init"]:
|
|
await self._init_onvif(camera_name)
|
|
|
|
service_capabilities_request = self.cams[camera_name][
|
|
"service_capabilities_request"
|
|
]
|
|
try:
|
|
service_capabilities = await self.cams[camera_name][
|
|
"ptz"
|
|
].GetServiceCapabilities(service_capabilities_request)
|
|
|
|
logger.debug(
|
|
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
|
)
|
|
|
|
# MoveStatus is required for autotracking - should return "true" if supported
|
|
return find_by_key(vars(service_capabilities), "MoveStatus")
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Camera {camera_name} does not support the ONVIF GetServiceCapabilities method. Autotracking will not function correctly and must be disabled in your config. Exception: {e}"
|
|
)
|
|
return False
|
|
|
|
async def get_camera_status(self, camera_name: str) -> None:
|
|
async with self.status_locks[camera_name]:
|
|
if camera_name not in self.cams.keys():
|
|
logger.error(f"ONVIF is not configured for {camera_name}")
|
|
return
|
|
|
|
metrics = self.ptz_metrics.get(camera_name)
|
|
camera_config = self.config.cameras.get(camera_name)
|
|
|
|
if metrics is None or camera_config is None:
|
|
return
|
|
|
|
if not self.cams[camera_name]["init"]:
|
|
if not await self._init_onvif(camera_name):
|
|
return
|
|
|
|
status_request = self.cams[camera_name]["status_request"]
|
|
try:
|
|
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
|
|
except Exception:
|
|
pass # We're unsupported, that'll be reported in the next check.
|
|
|
|
try:
|
|
pan_tilt_status = getattr(status.MoveStatus, "PanTilt", None)
|
|
zoom_status = getattr(status.MoveStatus, "Zoom", None)
|
|
|
|
# if it's not an attribute, see if MoveStatus even exists in the status result
|
|
if pan_tilt_status is None:
|
|
pan_tilt_status = getattr(status, "MoveStatus", None)
|
|
|
|
# we're unsupported
|
|
if pan_tilt_status is None or pan_tilt_status not in [
|
|
"IDLE",
|
|
"MOVING",
|
|
]:
|
|
raise Exception
|
|
except Exception:
|
|
logger.warning(
|
|
f"Camera {camera_name} does not support the ONVIF GetStatus method. Autotracking will not function correctly and must be disabled in your config."
|
|
)
|
|
return
|
|
|
|
logger.debug(
|
|
f"{camera_name}: Pan/tilt status: {pan_tilt_status}, Zoom status: {zoom_status}"
|
|
)
|
|
|
|
if pan_tilt_status == "IDLE" and (
|
|
zoom_status is None or zoom_status == "IDLE"
|
|
):
|
|
self.cams[camera_name]["active"] = False
|
|
if not metrics.motor_stopped.is_set():
|
|
metrics.motor_stopped.set()
|
|
|
|
logger.debug(
|
|
f"{camera_name}: PTZ stop time: {metrics.frame_time.value}"
|
|
)
|
|
|
|
metrics.stop_time.value = metrics.frame_time.value
|
|
else:
|
|
self.cams[camera_name]["active"] = True
|
|
if metrics.motor_stopped.is_set():
|
|
metrics.motor_stopped.clear()
|
|
|
|
logger.debug(
|
|
f"{camera_name}: PTZ start time: {metrics.frame_time.value}"
|
|
)
|
|
|
|
metrics.start_time.value = metrics.frame_time.value
|
|
metrics.stop_time.value = 0
|
|
|
|
if camera_config.onvif.autotracking.zooming != ZoomingModeEnum.disabled:
|
|
# store absolute zoom level as 0 to 1 interpolated from the values of the camera
|
|
metrics.zoom_level.value = numpy.interp(
|
|
round(status.Position.Zoom.x, 2),
|
|
[
|
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
|
],
|
|
[0, 1],
|
|
)
|
|
logger.debug(
|
|
f"{camera_name}: Camera zoom level: {metrics.zoom_level.value}"
|
|
)
|
|
|
|
# some hikvision cams won't update MoveStatus, so warn if it hasn't changed
|
|
if (
|
|
not metrics.motor_stopped.is_set()
|
|
and not metrics.reset.is_set()
|
|
and metrics.start_time.value != 0
|
|
and metrics.frame_time.value > (metrics.start_time.value + 10)
|
|
and metrics.stop_time.value == 0
|
|
):
|
|
logger.debug(
|
|
f"Start time: {metrics.start_time.value}, Stop time: {metrics.stop_time.value}, Frame time: {metrics.frame_time.value}"
|
|
)
|
|
# set the stop time so we don't come back into this again and spam the logs
|
|
metrics.stop_time.value = metrics.frame_time.value
|
|
logger.warning(
|
|
f"Camera {camera_name} is still in ONVIF 'MOVING' status."
|
|
)
|
|
|
|
async def _shutdown(self) -> None:
|
|
"""Close the camera sessions and cancel the tasks running on the loop."""
|
|
for cam_name in list(self.cams):
|
|
await self._close_camera(cam_name)
|
|
|
|
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
|
|
for task in tasks:
|
|
task.cancel()
|
|
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
def close(self) -> None:
|
|
"""Gracefully shut down the ONVIF controller."""
|
|
if not hasattr(self, "loop") or self.loop.is_closed():
|
|
logger.debug("ONVIF controller already closed")
|
|
return
|
|
|
|
logger.info("Exiting ONVIF controller...")
|
|
|
|
# anything left open here is garbage collected during interpreter
|
|
# shutdown, where its warnings can no longer be logged cleanly
|
|
try:
|
|
asyncio.run_coroutine_threadsafe(self._shutdown(), self.loop).result(
|
|
timeout=5
|
|
)
|
|
except TimeoutError:
|
|
logger.debug("Timed out closing ONVIF sessions")
|
|
|
|
self.config_subscriber.stop()
|
|
|
|
def stop_and_cleanup():
|
|
try:
|
|
self.loop.stop()
|
|
except Exception as e:
|
|
logger.error(f"Error during loop cleanup: {e}")
|
|
|
|
# Schedule stop and cleanup in the loop thread
|
|
self.loop.call_soon_threadsafe(stop_and_cleanup)
|
|
|
|
self.loop_thread.join()
|