From 132bb4e890149d28b48d794e5f3f74a153c1d2ce Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Mon, 11 May 2020 13:12:12 -0400 Subject: [PATCH] ONVIF Event Implementation (#35406) Initial implementation of ONVIF event sensors --- .coveragerc | 4 + homeassistant/components/onvif/__init__.py | 21 +- homeassistant/components/onvif/base.py | 2 +- .../components/onvif/binary_sensor.py | 84 ++++ homeassistant/components/onvif/device.py | 35 +- homeassistant/components/onvif/event.py | 169 +++++++++ homeassistant/components/onvif/manifest.json | 2 +- homeassistant/components/onvif/models.py | 16 +- homeassistant/components/onvif/parsers.py | 358 ++++++++++++++++++ homeassistant/components/onvif/sensor.py | 87 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 12 files changed, 762 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/onvif/binary_sensor.py create mode 100644 homeassistant/components/onvif/event.py create mode 100644 homeassistant/components/onvif/parsers.py create mode 100644 homeassistant/components/onvif/sensor.py diff --git a/.coveragerc b/.coveragerc index a92c092e39f..c4ca75e5681 100644 --- a/.coveragerc +++ b/.coveragerc @@ -529,8 +529,12 @@ omit = homeassistant/components/onkyo/media_player.py homeassistant/components/onvif/__init__.py homeassistant/components/onvif/base.py + homeassistant/components/onvif/binary_sensor.py homeassistant/components/onvif/camera.py homeassistant/components/onvif/device.py + homeassistant/components/onvif/event.py + homeassistant/components/onvif/parsers.py + homeassistant/components/onvif/sensor.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py homeassistant/components/openexchangerates/sensor.py diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 200254a0806..6d90c5828f9 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -30,8 +31,6 @@ from .device import ONVIFDevice CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) -PLATFORMS = ["camera"] - async def async_setup(hass: HomeAssistant, config: dict): """Set up the ONVIF component.""" @@ -79,7 +78,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.unique_id] = device - for component in PLATFORMS: + platforms = ["camera"] + + if device.capabilities.events and await device.events.async_start(): + platforms += ["binary_sensor", "sensor"] + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.events.async_stop) + + for component in platforms: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) @@ -89,11 +94,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" + + device = hass.data[DOMAIN][entry.unique_id] + platforms = ["camera"] + + if device.capabilities.events and device.events.started: + platforms += ["binary_sensor", "sensor"] + await device.events.async_stop() + return all( await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS + for component in platforms ] ) ) diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/base.py index 72c3d969d22..43a846cac37 100644 --- a/homeassistant/components/onvif/base.py +++ b/homeassistant/components/onvif/base.py @@ -9,7 +9,7 @@ from .models import Profile class ONVIFBaseEntity(Entity): """Base class common to all ONVIF entities.""" - def __init__(self, device: ONVIFDevice, profile: Profile) -> None: + def __init__(self, device: ONVIFDevice, profile: Profile = None) -> None: """Initialize the ONVIF entity.""" self.device = device self.profile = profile diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py new file mode 100644 index 00000000000..9b5469ee0d0 --- /dev/null +++ b/homeassistant/components/onvif/binary_sensor.py @@ -0,0 +1,84 @@ +"""Support for ONVIF binary sensors.""" +from typing import Optional + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.core import callback + +from .base import ONVIFBaseEntity +from .const import DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a ONVIF binary sensor.""" + device = hass.data[DOMAIN][config_entry.unique_id] + + entities = { + event.uid: ONVIFBinarySensor(event.uid, device) + for event in device.events.get_platform("binary_sensor") + } + + async_add_entities(entities.values()) + + @callback + def async_check_entities(): + """Check if we have added an entity for the event.""" + new_entities = [] + for event in device.events.get_platform("binary_sensor"): + if event.uid not in entities: + entities[event.uid] = ONVIFBinarySensor(event.uid, device) + new_entities.append(entities[event.uid]) + async_add_entities(new_entities) + + device.events.async_add_listener(async_check_entities) + + return True + + +class ONVIFBinarySensor(ONVIFBaseEntity, BinarySensorEntity): + """Representation of a binary ONVIF event.""" + + def __init__(self, uid, device): + """Initialize the ONVIF binary sensor.""" + ONVIFBaseEntity.__init__(self, device) + BinarySensorEntity.__init__(self) + + self.uid = uid + + @property + def is_on(self) -> bool: + """Return true if event is active.""" + return self.device.events.get_uid(self.uid).value + + @property + def name(self) -> str: + """Return the name of the event.""" + return self.device.events.get_uid(self.uid).name + + @property + def device_class(self) -> Optional[str]: + """Return the class of this device, from component DEVICE_CLASSES.""" + return self.device.events.get_uid(self.uid).device_class + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.uid + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self.device.events.get_uid(self.uid).entity_enabled + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.device.events.async_add_listener(self.async_write_ha_state) + ) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 49f1a589325..2743ed7eca4 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -11,6 +11,7 @@ from onvif.exceptions import ONVIFError from zeep.asyncio import AsyncTransport from zeep.exceptions import Fault +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -18,6 +19,7 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.util.dt as dt_util @@ -31,24 +33,26 @@ from .const import ( TILT_FACTOR, ZOOM_FACTOR, ) +from .event import EventManager from .models import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video class ONVIFDevice: """Manages an ONVIF device.""" - def __init__(self, hass, config_entry=None): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry = None): """Initialize the device.""" - self.hass = hass - self.config_entry = config_entry - self.available = True + self.hass: HomeAssistant = hass + self.config_entry: ConfigEntry = config_entry + self.available: bool = True - self.device = None + self.device: ONVIFCamera = None + self.events: EventManager = None - self.info = DeviceInfo() - self.capabilities = Capabilities() - self.profiles = [] - self.max_resolution = 0 + self.info: DeviceInfo = DeviceInfo() + self.capabilities: Capabilities = Capabilities() + self.profiles: List[Profile] = [] + self.max_resolution: int = 0 @property def name(self) -> str: @@ -96,6 +100,11 @@ class ONVIFDevice: if self.capabilities.ptz: self.device.create_ptz_service() + if self.capabilities.events: + self.events = EventManager( + self.hass, self.device, self.config_entry.unique_id + ) + # Determine max resolution from profiles self.max_resolution = max( profile.video.resolution.width @@ -199,14 +208,18 @@ class ONVIFDevice: async def async_get_capabilities(self): """Obtain information about the available services on the device.""" media_service = self.device.create_media_service() - capabilities = await media_service.GetServiceCapabilities() + media_capabilities = await media_service.GetServiceCapabilities() + event_service = self.device.create_events_service() + event_capabilities = await event_service.GetServiceCapabilities() ptz = False try: self.device.get_definition("ptz") ptz = True except ONVIFError: pass - return Capabilities(capabilities.SnapshotUri, ptz) + return Capabilities( + media_capabilities.SnapshotUri, event_capabilities.WSPullPointSupport, ptz + ) async def async_get_profiles(self) -> List[Profile]: """Obtain media profiles for this device.""" diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py new file mode 100644 index 00000000000..2c56d46ad39 --- /dev/null +++ b/homeassistant/components/onvif/event.py @@ -0,0 +1,169 @@ +"""ONVIF event abstraction.""" +import datetime as dt +from typing import Callable, Dict, List, Optional, Set + +from aiohttp.client_exceptions import ServerDisconnectedError +from onvif import ONVIFCamera, ONVIFService +from zeep.exceptions import Fault + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +from .const import LOGGER +from .models import Event +from .parsers import PARSERS + +UNHANDLED_TOPICS = set() + + +class EventManager: + """ONVIF Event Manager.""" + + def __init__(self, hass: HomeAssistant, device: ONVIFCamera, unique_id: str): + """Initialize event manager.""" + self.hass: HomeAssistant = hass + self.device: ONVIFCamera = device + self.unique_id: str = unique_id + self.started: bool = False + + self._subscription: ONVIFService = None + self._events: Dict[str, Event] = {} + self._listeners: List[CALLBACK_TYPE] = [] + self._unsub_refresh: Optional[CALLBACK_TYPE] = None + + super().__init__() + + @property + def platforms(self) -> Set[str]: + """Return platforms to setup.""" + return {event.platform for event in self._events.values()} + + @callback + def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: + """Listen for data updates.""" + # This is the first listener, set up polling. + if not self._listeners: + self._unsub_refresh = async_track_point_in_utc_time( + self.hass, + self.async_pull_messages, + dt_util.utcnow() + dt.timedelta(seconds=1), + ) + + self._listeners.append(update_callback) + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self.async_remove_listener(update_callback) + + return remove_listener + + @callback + def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: + """Remove data update.""" + self._listeners.remove(update_callback) + + if not self._listeners and self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + async def async_start(self) -> bool: + """Start polling events.""" + if await self.device.create_pullpoint_subscription(): + # Initialize events + pullpoint = self.device.create_pullpoint_service() + await pullpoint.SetSynchronizationPoint() + req = pullpoint.create_type("PullMessages") + req.MessageLimit = 100 + req.Timeout = dt.timedelta(seconds=5) + response = await pullpoint.PullMessages(req) + + # Parse event initialization + await self.async_parse_messages(response.NotificationMessage) + + # Create subscription manager + self._subscription = self.device.create_subscription_service( + "PullPointSubscription" + ) + + self.started = True + + return self.started + + async def async_stop(self, event=None) -> None: + """Unsubscribe from events.""" + if not self._subscription: + return + + await self._subscription.Unsubscribe() + self._subscription = None + + async def async_renew(self) -> None: + """Renew subscription.""" + if not self._subscription: + return + + await self._subscription.Renew(dt_util.utcnow() + dt.timedelta(minutes=10)) + + async def async_pull_messages(self, _now: dt = None) -> None: + """Pull messages from device.""" + try: + pullpoint = self.device.get_service("pullpoint") + req = pullpoint.create_type("PullMessages") + req.MessageLimit = 100 + req.Timeout = dt.timedelta(seconds=60) + response = await pullpoint.PullMessages(req) + + # Renew subscription if less than 60 seconds left + if (response.TerminationTime - dt_util.utcnow()).total_seconds() < 60: + await self.async_renew() + + # Parse response + await self.async_parse_messages(response.NotificationMessage) + + except ServerDisconnectedError: + pass + except Fault: + pass + + # Update entities + for update_callback in self._listeners: + update_callback() + + # Reschedule another pull + if self._listeners: + self._unsub_refresh = async_track_point_in_utc_time( + self.hass, + self.async_pull_messages, + dt_util.utcnow() + dt.timedelta(seconds=1), + ) + + # pylint: disable=protected-access + async def async_parse_messages(self, messages) -> None: + """Parse notification message.""" + for msg in messages: + # LOGGER.debug("ONVIF Event Message %s: %s", self.device.host, pformat(msg)) + topic = msg.Topic._value_1 + parser = PARSERS.get(topic) + if not parser: + if topic not in UNHANDLED_TOPICS: + LOGGER.info("No registered handler for event: %s", msg) + UNHANDLED_TOPICS.add(topic) + continue + + event = await parser(self.unique_id, msg) + + if not event: + LOGGER.warning("Unable to parse event: %s", msg) + return + + self._events[event.uid] = event + + def get_uid(self, uid) -> Event: + """Retrieve event for given id.""" + return self._events[uid] + + def get_platform(self, platform) -> List[Event]: + """Retrieve events for given platform.""" + return [event for event in self._events.values() if event.platform == platform] diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 5931f3c71c3..f291f9c6613 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,7 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": ["onvif-zeep-async==0.2.0", "WSDiscovery==2.0.0"], + "requirements": ["onvif-zeep-async==0.3.0", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py index 68ae5c6bc90..686d9fecbda 100644 --- a/homeassistant/components/onvif/models.py +++ b/homeassistant/components/onvif/models.py @@ -1,6 +1,6 @@ """ONVIF models.""" from dataclasses import dataclass -from typing import List +from typing import Any, List @dataclass @@ -55,4 +55,18 @@ class Capabilities: """Represents Service capabilities.""" snapshot: bool = False + events: bool = False ptz: bool = False + + +@dataclass +class Event: + """Represents a ONVIF event.""" + + uid: str + name: str + platform: str + device_class: str = None + unit_of_measurement: str = None + value: Any = None + entity_enabled: bool = True diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py new file mode 100644 index 00000000000..4fd4ffd2891 --- /dev/null +++ b/homeassistant/components/onvif/parsers.py @@ -0,0 +1,358 @@ +"""ONVIF event parsers.""" +from homeassistant.util import dt as dt_util +from homeassistant.util.decorator import Registry + +from .models import Event + +PARSERS = Registry() + + +@PARSERS.register("tns1:VideoSource/MotionAlarm") +# pylint: disable=protected-access +async def async_parse_motion_alarm(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:VideoSource/MotionAlarm + """ + try: + source = msg.Message._value_1.Source.SimpleItem[0].Value + return Event( + f"{uid}_{msg.Topic._value_1}_{source}", + f"{source} Motion Alarm", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") +@PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService") +@PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService") +# pylint: disable=protected-access +async def async_parse_image_too_blurry(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:VideoSource/ImageTooBlurry/* + """ + try: + source = msg.Message._value_1.Source.SimpleItem[0].Value + return Event( + f"{uid}_{msg.Topic._value_1}_{source}", + f"{source} Image Too Blurry", + "binary_sensor", + "problem", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") +@PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService") +@PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService") +# pylint: disable=protected-access +async def async_parse_image_too_dark(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:VideoSource/ImageTooDark/* + """ + try: + source = msg.Message._value_1.Source.SimpleItem[0].Value + return Event( + f"{uid}_{msg.Topic._value_1}_{source}", + f"{source} Image Too Dark", + "binary_sensor", + "problem", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") +@PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService") +@PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService") +# pylint: disable=protected-access +async def async_parse_image_too_bright(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:VideoSource/ImageTooBright/* + """ + try: + source = msg.Message._value_1.Source.SimpleItem[0].Value + return Event( + f"{uid}_{msg.Topic._value_1}_{source}", + f"{source} Image Too Bright", + "binary_sensor", + "problem", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") +@PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService") +@PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService") +# pylint: disable=protected-access +async def async_parse_scene_change(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:VideoSource/GlobalSceneChange/* + """ + try: + source = msg.Message._value_1.Source.SimpleItem[0].Value + return Event( + f"{uid}_{msg.Topic._value_1}_{source}", + f"{source} Global Scene Change", + "binary_sensor", + "problem", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") +# pylint: disable=protected-access +async def async_parse_detected_sound(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:AudioAnalytics/Audio/DetectedSound + """ + try: + audio_source = "" + audio_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "AudioSourceConfigurationToken": + audio_source = source.Value + if source.Name == "AudioAnalyticsConfigurationToken": + audio_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{audio_source}_{audio_analytics}_{rule}", + f"{rule} Detected Sound", + "binary_sensor", + "sound", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") +# pylint: disable=protected-access +async def async_parse_field_detector(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:RuleEngine/FieldDetector/ObjectsInside + """ + try: + video_source = "" + video_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + evt = Event( + f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{rule} Field Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + return evt + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") +# pylint: disable=protected-access +async def async_parse_cell_motion_detector(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:RuleEngine/CellMotionDetector/Motion + """ + try: + video_source = "" + video_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{rule} Cell Motion Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") +# pylint: disable=protected-access +async def async_parse_tamper_detector(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:RuleEngine/TamperDetector/Tamper + """ + try: + video_source = "" + video_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{rule} Tamper Detection", + "binary_sensor", + "problem", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") +# pylint: disable=protected-access +async def async_parse_storage_failure(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:Device/HardwareFailure/StorageFailure + """ + try: + source = msg.Message._value_1.Source.SimpleItem[0].Value + return Event( + f"{uid}_{msg.Topic._value_1}_{source}", + "Storage Failure", + "binary_sensor", + "problem", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:Monitoring/ProcessorUsage") +# pylint: disable=protected-access +async def async_parse_processor_usage(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:Monitoring/ProcessorUsage + """ + try: + usage = float(msg.Message._value_1.Data.SimpleItem[0].Value) + if usage <= 1: + usage *= 100 + + return Event( + f"{uid}_{msg.Topic._value_1}", + "Processor Usage", + "sensor", + None, + "percent", + int(usage), + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") +# pylint: disable=protected-access +async def async_parse_last_reboot(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:Monitoring/OperatingTime/LastReboot + """ + try: + return Event( + f"{uid}_{msg.Topic._value_1}", + "Last Reboot", + "sensor", + "timestamp", + None, + dt_util.as_local( + dt_util.parse_datetime(msg.Message._value_1.Data.SimpleItem[0].Value) + ), + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") +# pylint: disable=protected-access +async def async_parse_last_reset(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:Monitoring/OperatingTime/LastReset + """ + try: + return Event( + f"{uid}_{msg.Topic._value_1}", + "Last Reset", + "sensor", + "timestamp", + None, + dt_util.as_local( + dt_util.parse_datetime(msg.Message._value_1.Data.SimpleItem[0].Value) + ), + entity_enabled=False, + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") +# pylint: disable=protected-access +async def async_parse_last_clock_sync(uid: str, msg) -> Event: + """Handle parsing event message. + + Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization + """ + try: + return Event( + f"{uid}_{msg.Topic._value_1}", + "Last Clock Synchronization", + "sensor", + "timestamp", + None, + dt_util.as_local( + dt_util.parse_datetime(msg.Message._value_1.Data.SimpleItem[0].Value) + ), + entity_enabled=False, + ) + except (AttributeError, KeyError): + return None diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py new file mode 100644 index 00000000000..b1d7ff7986c --- /dev/null +++ b/homeassistant/components/onvif/sensor.py @@ -0,0 +1,87 @@ +"""Support for ONVIF binary sensors.""" +from typing import Optional, Union + +from homeassistant.core import callback + +from .base import ONVIFBaseEntity +from .const import DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a ONVIF binary sensor.""" + device = hass.data[DOMAIN][config_entry.unique_id] + + entities = { + event.uid: ONVIFSensor(event.uid, device) + for event in device.events.get_platform("sensor") + } + + async_add_entities(entities.values()) + + @callback + def async_check_entities(): + """Check if we have added an entity for the event.""" + new_entities = [] + for event in device.events.get_platform("sensor"): + if event.uid not in entities: + entities[event.uid] = ONVIFSensor(event.uid, device) + new_entities.append(entities[event.uid]) + async_add_entities(new_entities) + + device.events.async_add_listener(async_check_entities) + + return True + + +class ONVIFSensor(ONVIFBaseEntity): + """Representation of a ONVIF sensor event.""" + + def __init__(self, uid, device): + """Initialize the ONVIF binary sensor.""" + self.uid = uid + + super().__init__(device) + + @property + def state(self) -> Union[None, str, int, float]: + """Return the state of the entity.""" + return self.device.events.get_uid(self.uid).value + + @property + def name(self): + """Return the name of the event.""" + return self.device.events.get_uid(self.uid).name + + @property + def device_class(self) -> Optional[str]: + """Return the class of this device, from component DEVICE_CLASSES.""" + return self.device.events.get_uid(self.uid).device_class + + @property + def unit_of_measurement(self) -> Optional[str]: + """Return the unit of measurement of this entity, if any.""" + return self.device.events.get_uid(self.uid).unit_of_measurement + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.uid + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self.device.events.get_uid(self.uid).entity_enabled + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.device.events.async_add_listener(self.async_write_ha_state) + ) diff --git a/requirements_all.txt b/requirements_all.txt index 9a8f26bef66..66807333a11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -994,7 +994,7 @@ oemthermostat==1.1 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==0.2.0 +onvif-zeep-async==0.3.0 # homeassistant.components.opengarage open-garage==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3960a703459..2e704f2e38e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -414,7 +414,7 @@ numpy==1.18.4 oauth2client==4.0.0 # homeassistant.components.onvif -onvif-zeep-async==0.2.0 +onvif-zeep-async==0.3.0 # homeassistant.components.openerz openerz-api==0.1.0