1 """ONVIF device abstraction."""
3 from __future__
import annotations
6 from contextlib
import suppress
10 from typing
import Any
12 from httpx
import RequestError
14 from onvif
import ONVIFCamera
15 from onvif.exceptions
import ONVIFError
16 from zeep.exceptions
import Fault, TransportError, XMLParseError, XMLSyntaxError
34 DEFAULT_ENABLE_WEBHOOKS,
35 GET_CAPABILITIES_EXCEPTIONS,
44 from .event
import EventManager
45 from .models
import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video
49 """Manages an ONVIF device."""
54 def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) ->
None:
55 """Initialize the device."""
56 self.hass: HomeAssistant = hass
57 self.config_entry: ConfigEntry = config_entry
59 self.available: bool =
True
64 self.
profilesprofiles: list[Profile] = []
66 self.platforms: list[Platform] = []
71 self, hass: HomeAssistant, entry: ConfigEntry
73 """Handle options update."""
75 hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
79 """Return the name of this device."""
80 return self.config_entry.data[CONF_NAME]
84 """Return the host of this device."""
85 return self.config_entry.data[CONF_HOST]
89 """Return the port of this device."""
90 return self.config_entry.data[CONF_PORT]
94 """Return the username of this device."""
95 return self.config_entry.data[CONF_USERNAME]
99 """Return the password of this device."""
100 return self.config_entry.data[CONF_PASSWORD]
103 """Set up the device."""
106 host=self.config_entry.data[CONF_HOST],
107 port=self.config_entry.data[CONF_PORT],
108 username=self.config_entry.data[CONF_USERNAME],
109 password=self.config_entry.data[CONF_PASSWORD],
113 await self.
devicedevice.update_xaddrs()
114 LOGGER.debug(
"%s: xaddrs = %s", self.
namename, self.
devicedevice.xaddrs)
122 assert self.config_entry.unique_id
127 LOGGER.debug(
"%s: camera info = %s", self.
namename, self.
infoinfo)
137 LOGGER.debug(
"%s: fetching initial capabilities", self.
namename)
140 LOGGER.debug(
"%s: fetching profiles", self.
namename)
142 LOGGER.debug(
"Camera %s profiles = %s", self.
namename, self.
profilesprofiles)
146 raise ONVIFError(
"No camera profiles found")
149 LOGGER.debug(
"%s: creating PTZ service", self.
namename)
150 await self.
devicedevice.create_ptz_service()
154 profile.video.resolution.width
155 for profile
in self.
profilesprofiles
156 if profile.video.encoding ==
"H264"
161 LOGGER.debug(
"%s: starting events", self.
namename)
163 LOGGER.debug(
"Camera %s capabilities = %s", self.
namename, self.
capabilitiescapabilities)
169 self.config_entry.async_on_unload(
174 """Shut it all down."""
177 await self.
devicedevice.close()
180 """Set Date and Time Manually using SetSystemDateAndTime command."""
181 device_mgmt = await self.
devicedevice.create_devicemgmt_service()
184 device_time = await device_mgmt.GetSystemDateAndTime()
186 system_date = dt_util.utcnow()
187 LOGGER.debug(
"System date (UTC): %s", system_date)
189 dt_param = device_mgmt.create_type(
"SetSystemDateAndTime")
190 dt_param.DateTimeType =
"Manual"
192 dt_param.DaylightSavings = bool(time.localtime().tm_isdst)
193 dt_param.UTCDateTime = {
195 "Year": system_date.year,
196 "Month": system_date.month,
197 "Day": system_date.day,
200 "Hour": system_date.hour,
201 "Minute": system_date.minute,
202 "Second": system_date.second,
206 system_timezone =
str(system_date.astimezone().tzinfo)
207 timezone_names: list[str |
None] = [system_timezone]
208 if (time_zone := device_time.TimeZone)
and system_timezone != time_zone.TZ:
209 timezone_names.append(time_zone.TZ)
210 timezone_names.append(
None)
211 timezone_max_idx = len(timezone_names) - 1
213 "%s: SetSystemDateAndTime: timezone_names:%s", self.
namename, timezone_names
215 for idx, timezone_name
in enumerate(timezone_names):
216 dt_param.TimeZone = timezone_name
217 LOGGER.debug(
"%s: SetSystemDateAndTime: %s", self.
namename, dt_param)
219 await device_mgmt.SetSystemDateAndTime(dt_param)
220 LOGGER.debug(
"%s: SetSystemDateAndTime: success", self.
namename)
223 except (IndexError, Fault):
224 if idx == timezone_max_idx:
230 """Warns if device and system date not synced."""
231 LOGGER.debug(
"%s: Setting up the ONVIF device management service", self.
namename)
232 device_mgmt = await self.
devicedevice.create_devicemgmt_service()
233 system_date = dt_util.utcnow()
235 LOGGER.debug(
"%s: Retrieving current device date/time", self.
namename)
237 device_time = await device_mgmt.GetSystemDateAndTime()
238 except RequestError
as err:
240 "Couldn't get device '%s' date/time. Error: %s", self.
namename, err
246 """Couldn't get device '%s' date/time.
247 GetSystemDateAndTime() return null/empty""",
252 LOGGER.debug(
"%s: Device time: %s", self.
namename, device_time)
254 tzone = dt_util.get_default_time_zone()
255 cdate = device_time.LocalDateTime
256 if device_time.UTCDateTime:
258 cdate = device_time.UTCDateTime
259 elif device_time.TimeZone:
260 tzone = await dt_util.async_get_time_zone(device_time.TimeZone.TZ)
or tzone
263 LOGGER.warning(
"%s: Could not retrieve date/time on this camera", self.
namename)
266 cam_date = dt.datetime(
277 cam_date_utc = cam_date.astimezone(dt_util.UTC)
280 "%s: Device date/time: %s | System date/time: %s",
286 dt_diff = cam_date - system_date
293 if device_time.DateTimeType !=
"Manual":
300 except (RequestError, TransportError, IndexError, Fault):
301 LOGGER.warning(
"%s: Could not sync date/time on this camera", self.
namename)
306 self, cam_date_utc: dt.datetime, system_date: dt.datetime
308 """Log a warning if the camera and system date/time are not synced."""
311 "The date/time on %s (UTC) is '%s', "
312 "which is different from the system '%s', "
313 "this could lead to authentication issues"
321 """Obtain information about this device."""
322 device_mgmt = await self.
devicedevice.create_devicemgmt_service()
325 firmware_version =
None
328 device_info = await device_mgmt.GetDeviceInformation()
329 except (XMLParseError, XMLSyntaxError, TransportError)
as ex:
332 LOGGER.warning(
"%s: Failed to fetch device information: %s", self.
namename, ex)
334 manufacturer = device_info.Manufacturer
335 model = device_info.Model
336 firmware_version = device_info.FirmwareVersion
337 serial_number = device_info.SerialNumber
342 network_interfaces = await device_mgmt.GetNetworkInterfaces()
343 for interface
in network_interfaces:
344 if interface.Enabled:
345 mac = interface.Info.HwAddress
346 except Fault
as fault:
347 if "not implemented" not in fault.message:
351 "Couldn't get network interfaces from ONVIF device '%s'. Error: %s",
365 """Obtain information about the available services on the device."""
367 with suppress(*GET_CAPABILITIES_EXCEPTIONS):
368 media_service = await self.
devicedevice.create_media_service()
369 media_capabilities = await media_service.GetServiceCapabilities()
370 snapshot = media_capabilities
and media_capabilities.SnapshotUri
373 with suppress(*GET_CAPABILITIES_EXCEPTIONS):
374 self.
devicedevice.get_definition(
"ptz")
378 with suppress(*GET_CAPABILITIES_EXCEPTIONS):
379 await self.
devicedevice.create_imaging_service()
382 return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging)
385 """Start the event handler."""
386 with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError):
388 pull_point_support = (onvif_capabilities.get(
"Events")
or {}).
get(
391 LOGGER.debug(
"%s: WSPullPointSupport: %s", self.
namename, pull_point_support)
398 self.config_entry.options.get(
399 CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS
406 """Obtain media profiles for this device."""
407 media_service = await self.
devicedevice.create_media_service()
408 LOGGER.debug(
"%s: xaddr for media_service: %s", self.
namename, media_service.xaddr)
410 result = await media_service.GetProfiles()
411 except GET_CAPABILITIES_EXCEPTIONS:
413 "%s: Could not get profiles from ONVIF device", self.
namename, exc_info=
True
416 profiles: list[Profile] = []
418 if not isinstance(result, list):
421 for key, onvif_profile
in enumerate(result):
424 not onvif_profile.VideoEncoderConfiguration
425 or onvif_profile.VideoEncoderConfiguration.Encoding !=
"H264"
434 onvif_profile.VideoEncoderConfiguration.Encoding,
436 onvif_profile.VideoEncoderConfiguration.Resolution.Width,
437 onvif_profile.VideoEncoderConfiguration.Resolution.Height,
443 if self.
capabilitiescapabilities.ptz
and onvif_profile.PTZConfiguration:
445 onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace
447 onvif_profile.PTZConfiguration.DefaultRelativePanTiltTranslationSpace
449 onvif_profile.PTZConfiguration.DefaultAbsolutePantTiltPositionSpace
454 ptz_service = await self.
devicedevice.create_ptz_service()
455 presets = await ptz_service.GetPresets(profile.token)
456 profile.ptz.presets = [preset.token
for preset
in presets
if preset]
457 except GET_CAPABILITIES_EXCEPTIONS:
459 profile.ptz.presets = []
462 if self.
capabilitiescapabilities.imaging
and onvif_profile.VideoSourceConfiguration:
463 profile.video_source_token = (
464 onvif_profile.VideoSourceConfiguration.SourceToken
467 profiles.append(profile)
472 """Get the stream URI for a specified profile."""
473 media_service = await self.
devicedevice.create_media_service()
474 req = media_service.create_type(
"GetStreamUri")
475 req.ProfileToken = profile.token
477 "Stream":
"RTP-Unicast",
478 "Transport": {
"Protocol":
"RTSP"},
480 result = await media_service.GetStreamUri(req)
495 """Perform a PTZ action on the camera."""
497 LOGGER.warning(
"PTZ actions are not supported on device '%s'", self.
namename)
500 ptz_service = await self.
devicedevice.create_ptz_service()
502 pan_val = distance * PAN_FACTOR.get(pan, 0)
503 tilt_val = distance * TILT_FACTOR.get(tilt, 0)
504 zoom_val = distance * ZOOM_FACTOR.get(zoom, 0)
509 "Calling %s PTZ | Pan = %4.2f | Tilt = %4.2f | Zoom = %4.2f | Speed ="
510 " %4.2f | Preset = %s"
520 req = ptz_service.create_type(move_mode)
521 req.ProfileToken = profile.token
522 if move_mode == CONTINUOUS_MOVE:
524 if not profile.ptz
or not profile.ptz.continuous:
526 "ContinuousMove not supported on device '%s'", self.
namename
531 if pan
is not None or tilt
is not None:
532 velocity[
"PanTilt"] = {
"x": pan_val,
"y": tilt_val}
534 velocity[
"Zoom"] = {
"x": zoom_val}
536 req.Velocity = velocity
538 await ptz_service.ContinuousMove(req)
539 await asyncio.sleep(continuous_duration)
540 req = ptz_service.create_type(
"Stop")
541 req.ProfileToken = profile.token
542 await ptz_service.Stop(
543 {
"ProfileToken": req.ProfileToken,
"PanTilt":
True,
"Zoom":
False}
545 elif move_mode == RELATIVE_MOVE:
547 if not profile.ptz
or not profile.ptz.relative:
549 "RelativeMove not supported on device '%s'", self.
namename
554 "PanTilt": {
"x": pan_val,
"y": tilt_val},
555 "Zoom": {
"x": zoom_val},
558 "PanTilt": {
"x": speed_val,
"y": speed_val},
559 "Zoom": {
"x": speed_val},
561 await ptz_service.RelativeMove(req)
562 elif move_mode == ABSOLUTE_MOVE:
564 if not profile.ptz
or not profile.ptz.absolute:
566 "AbsoluteMove not supported on device '%s'", self.
namename
571 "PanTilt": {
"x": pan_val,
"y": tilt_val},
572 "Zoom": {
"x": zoom_val},
575 "PanTilt": {
"x": speed_val,
"y": speed_val},
576 "Zoom": {
"x": speed_val},
578 await ptz_service.AbsoluteMove(req)
579 elif move_mode == GOTOPRESET_MOVE:
581 if not profile.ptz
or not profile.ptz.presets:
583 "Absolute Presets not supported on device '%s'", self.
namename
586 if preset_val
not in profile.ptz.presets:
589 "PTZ preset '%s' does not exist on device '%s'. Available"
594 ", ".join(profile.ptz.presets),
598 req.PresetToken = preset_val
600 "PanTilt": {
"x": speed_val,
"y": speed_val},
601 "Zoom": {
"x": speed_val},
603 await ptz_service.GotoPreset(req)
604 elif move_mode == STOP_MOVE:
605 await ptz_service.Stop(req)
606 except ONVIFError
as err:
607 if "Bad Request" in err.reason:
608 LOGGER.warning(
"Device '%s' doesn't support PTZ", self.
namename)
610 LOGGER.error(
"Error trying to perform PTZ action: %s", err)
617 """Execute a PTZ auxiliary command on the camera."""
619 LOGGER.warning(
"PTZ actions are not supported on device '%s'", self.
namename)
622 ptz_service = await self.
devicedevice.create_ptz_service()
625 "Running Aux Command | Cmd = %s",
629 req = ptz_service.create_type(
"SendAuxiliaryCommand")
630 req.ProfileToken = profile.token
631 req.AuxiliaryData = cmd
632 await ptz_service.SendAuxiliaryCommand(req)
633 except ONVIFError
as err:
634 if "Bad Request" in err.reason:
635 LOGGER.warning(
"Device '%s' doesn't support PTZ", self.
namename)
637 LOGGER.error(
"Error trying to send PTZ auxiliary command: %s", err)
644 """Set an imaging setting on the ONVIF imaging service."""
649 "The imaging service is not supported on device '%s'", self.
namename
653 imaging_service = await self.
devicedevice.create_imaging_service()
655 LOGGER.debug(
"Setting Imaging Setting | Settings = %s", settings)
657 req = imaging_service.create_type(
"SetImagingSettings")
658 req.VideoSourceToken = profile.video_source_token
659 req.ImagingSettings = settings
660 await imaging_service.SetImagingSettings(req)
661 except ONVIFError
as err:
662 if "Bad Request" in err.reason:
664 "Device '%s' doesn't support the Imaging Service", self.
namename
667 LOGGER.error(
"Error trying to set Imaging settings: %s", err)
674 username: str |
None,
675 password: str |
None,
677 """Get ONVIFCamera instance."""
683 f
"{os.path.dirname(onvif.__file__)}/wsdl/",
DeviceInfo async_get_device_info(self)
None async_set_imaging_settings(self, Profile profile, dict settings)
None __init__(self, HomeAssistant hass, ConfigEntry config_entry)
str async_get_stream_uri(self, Profile profile)
def async_start_events(self)
list[Profile] async_get_profiles(self)
def async_stop(self, event=None)
None async_manually_set_date_and_time(self)
None _async_log_time_out_of_sync(self, dt.datetime cam_date_utc, dt.datetime system_date)
None async_check_date_and_time(self)
def async_perform_ptz(self, Profile profile, distance, speed, move_mode, continuous_duration, preset, pan=None, tilt=None, zoom=None)
None async_run_aux_command(self, Profile profile, str cmd)
def async_get_capabilities(self)
None _async_update_listener(self, HomeAssistant hass, ConfigEntry entry)
web.Response get(self, web.Request request, str config_key)
None async_start(HomeAssistant hass, str discovery_topic, ConfigEntry config_entry)
ONVIFCamera get_device(HomeAssistant hass, str host, int port, str|None username, str|None password)
Sequence[str]|None get_capabilities(Sequence[str] capabilities)