diff --git a/README.md b/README.md index dc5cd0a..88ee6a5 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ This integration creates a camera entity, providing a live-stream configurable f | Protocol | Switch between the RTMP or RTSP streaming protocol. | | Channel | When using a single camera, choose stream 0. When using a NVR, switch between the different camera streams. | -## Binary Sensor +## Binary Sensors -When the camera supports motion detection events, a binary sensor is created for real-time motion detection. The time to switch motion detection off can be configured via the options menu, located at the integrations page. Please notice: for using the motion detection, your Homa Assistant should be reachable (within you local network) over http (not https). +When the camera supports motion detection events, a binary sensor is created for real-time motion detection. The time to switch motion detection off can be configured via the options menu, located at the integrations page. Please notice: for using the motion detection, your Homa Assistant should be reachable (within your local network) over http (not https). | Parameter | Description | | :------------------- | :---------------------------------------------------------------------------------------------------------- | @@ -120,7 +120,22 @@ When the camera supports motion detection events, a binary sensor is created for When the camera supports AI objects detection, a binary sensor is created for each type of object (person, vehicle, pet) -## Switch +The cameras only support webhooks for motion start/stop, and not any of the AI detections (person/vehicle/pet). +This may change in future firmware, but AI detections must be polled for now. +Optionally configure camera to send an email via SMTP on AI detection, and receive it in this Home Assistant plugin. +This allows event based start of AI detection start, but not stop. +The AI detection will be cleared in the next poll update. +Camera should be configured to email on person/vehicle detection (not motion), use Home Assistant's IP address, disable SSL/TLS, and select an arbitrary SMTP port. +The SMTP port should be unique for each integration. +Text, Text with Picture, and Text with Video will all work for email content, but there may be unnecessary delay with the picture or video options. +Other email fields in the camera configuration don't matter. +Tested on individual cameras, but not NVRs. + +| Parameter | Description | +| :------------------- | :---------------------------------------------------------------------------------------------------------- | +| SMTP port | Optional port to listen for email event for AI detections. Default is 0 (disable). | + +## Switches Depending on the camera, the following switches are created: diff --git a/custom_components/reolink_dev/__init__.py b/custom_components/reolink_dev/__init__.py index 8ee8fd8..b2601a9 100644 --- a/custom_components/reolink_dev/__init__.py +++ b/custom_components/reolink_dev/__init__.py @@ -25,6 +25,7 @@ BASE, CONF_CHANNEL, CONF_USE_HTTPS, + CONF_SMTP_PORT, CONF_MOTION_OFF_DELAY, CONF_PLAYBACK_MONTHS, CONF_PROTOCOL, @@ -32,6 +33,7 @@ CONF_THUMBNAIL_PATH, CONF_STREAM_FORMAT, CONF_MOTION_STATES_UPDATE_FALLBACK_DELAY, + DEFAULT_SMTP_PORT, COORDINATOR, MOTION_UPDATE_COORDINATOR, DOMAIN, @@ -93,6 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) await push.subscribe(base.event_id) + await push.set_smtp_port(entry.options.get(CONF_SMTP_PORT, DEFAULT_SMTP_PORT)) hass.data[DOMAIN][base.push_manager] = push async def async_update_data(): @@ -159,6 +162,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry): await base.set_protocol(entry.options[CONF_PROTOCOL]) await base.set_stream(entry.options[CONF_STREAM]) await base.set_stream_format(entry.options[CONF_STREAM_FORMAT]) + await base.set_smtp_port(entry.options[CONF_SMTP_PORT]) motion_state_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][MOTION_UPDATE_COORDINATOR] @@ -182,6 +186,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): base = hass.data[DOMAIN][entry.entry_id][BASE] push = hass.data[DOMAIN][base.push_manager] + await base.set_smtp_port(0) # Stop SMTP server + if not await push.count_members() > 1: await push.unsubscribe() hass.data[DOMAIN].pop(base.push_manager) diff --git a/custom_components/reolink_dev/base.py b/custom_components/reolink_dev/base.py index 4ae6d13..92d6075 100644 --- a/custom_components/reolink_dev/base.py +++ b/custom_components/reolink_dev/base.py @@ -2,6 +2,9 @@ import logging import os import re +import base64 + +from aiosmtpd.controller import Controller import datetime as dt from typing import Optional @@ -251,6 +254,10 @@ async def set_timeout(self, timeout): self._timeout = timeout await self._api.set_timeout(timeout) + async def set_smtp_port(self, port): + push = self._hass.data[DOMAIN][self.push_manager] + await push.set_smtp_port(port) + async def update_states(self): """Call the API of the camera device to update the states.""" await self._api.get_states() @@ -345,6 +352,79 @@ def __init__( self._webhook_id = None self._event_id = None + self.smtp_motion_warn = True + self.smtp_port = 0 + self.smtp = None + + # Create/start/stop SMTP server on parameter change + async def set_smtp_port(self, port): + if self.smtp_port is not port: + if self.smtp: + _LOGGER.info("Stopping SMTP server on port %i", self.smtp_port) + self.smtp.stop() + self.smtp = None + if self.smtp is None and port is not None and port > 0: + _LOGGER.info("Starting SMTP server on port %i", port) + self.smtp = Controller(self, hostname='', port=port) + self.smtp.start() + self.smtp_port = port + + # SMTP EHLO callback + async def handle_EHLO(server, session, envelope, hostname, responses): + _LOGGER.debug("SMTP EHLO") + return "" # Force error in EHLO querry so client falls back to HELO + + # SMTP data callback + async def handle_DATA(self, server, session, envelope): + _LOGGER.debug("SMTP data") + handled = False + matches = re.findall(r'base64[\r\n]+(.+?)[\r\n]+', envelope.content.decode('ascii')) + if matches: + for x in matches: + _LOGGER.debug("SMTP data base64: %s", x) + try: + text = base64.b64decode(x).decode('ascii') + _LOGGER.debug("SMTP data ascii: %s", text) + except: + continue + if re.match(".*tested the e-mail alert.*", text) is not None: + # Full text: "If you receive this e-mail you have successfully set up and tested the e-mail alert from your IPC" + _LOGGER.warning("SMTP test email received") + handled = True + name = re.findall(r'Alarm Camera Name:\s*(.+?)\s*[\r\n]+', text) + event = re.findall(r'Alarm Event:\s*(.+?)\s*[\r\n]+', text) + if name and event: + _LOGGER.debug("SMTP name: %s", name[0]) + _LOGGER.debug("SMTP event: %s", event[0]) + if (event[0] == "Motion Detection"): + _LOGGER.info("SMTP motion detected") + handled = True + if self.smtp_motion_warn: + self.smtp_motion_warn = False + _LOGGER.warning("SMTP non-AI motion event is inferrior to webhooks," + " and probably should be disabled." + " The time limit between events may mask AI detection events." + " This warning will only print once.") + self._hass.bus.async_fire(self._event_id, {"motion": True}) + elif (event[0] == "Person Detected"): + _LOGGER.info("SMTP person detected") + handled = True + self._hass.bus.async_fire(self._event_id, {"motion": True, "smtp": "person"}) + elif (event[0] == "Vehicle Detected"): + _LOGGER.info("SMTP vehicle detected") + handled = True + self._hass.bus.async_fire(self._event_id, {"motion": True, "smtp": "vehicle"}) + elif (event[0] == "Pet Detected"): + _LOGGER.info("SMTP pet detected") + handled = True + self._hass.bus.async_fire(self._event_id, {"motion": True, "smtp": "pet"}) + + if not handled: + _LOGGER.warning("SMTP received unhandled message: %s", envelope.content.decode('ascii')) + return "541 ERROR" + else: + return "250 OK" + @property def sman(self): """Return the session manager object.""" @@ -478,11 +558,11 @@ async def handle_webhook(hass, webhook_id, request): _LOGGER.debug("Webhook called") if not request.body_exists: - _LOGGER.debug("Webhook triggered without payload") + _LOGGER.warning("Webhook triggered without payload") data = await request.text() if not data: - _LOGGER.debug("Webhook triggered with unknown payload") + _LOGGER.warning("Webhook triggered with unknown payload") return _LOGGER_DATA.debug("Webhook received payload: %s", data) @@ -490,8 +570,9 @@ async def handle_webhook(hass, webhook_id, request): matches = re.findall(r'Name="IsMotion" Value="(.+?)"', data) if matches: is_motion = matches[0] == "true" + _LOGGER_DATA.debug("Webhook received motion: %s", matches[0]) else: - _LOGGER.debug("Webhook triggered with unknown payload") + _LOGGER.warning("Webhook triggered with unknown payload") return event_id = await get_event_by_webhook(hass, webhook_id) diff --git a/custom_components/reolink_dev/binary_sensor.py b/custom_components/reolink_dev/binary_sensor.py index 8724ff8..d2e602c 100644 --- a/custom_components/reolink_dev/binary_sensor.py +++ b/custom_components/reolink_dev/binary_sensor.py @@ -294,6 +294,11 @@ async def handle_event(self, event): except KeyError: pass + if event.data.get("smtp") is self._object_type: + self._event_state = True + if self.enabled: + self.async_schedule_update_ha_state() + if event.data.get("ai_refreshed") is not True: return diff --git a/custom_components/reolink_dev/config_flow.py b/custom_components/reolink_dev/config_flow.py index 0042926..2842f17 100644 --- a/custom_components/reolink_dev/config_flow.py +++ b/custom_components/reolink_dev/config_flow.py @@ -21,6 +21,7 @@ BASE, CONF_CHANNEL, CONF_USE_HTTPS, + CONF_SMTP_PORT, CONF_MOTION_OFF_DELAY, CONF_PLAYBACK_MONTHS, CONF_PROTOCOL, @@ -28,6 +29,7 @@ CONF_STREAM_FORMAT, CONF_THUMBNAIL_PATH, CONF_MOTION_STATES_UPDATE_FALLBACK_DELAY, + DEFAULT_SMTP_PORT, DEFAULT_MOTION_OFF_DELAY, DEFAULT_USE_HTTPS, DEFAULT_PLAYBACK_MONTHS, @@ -186,6 +188,12 @@ async def async_step_init(self, user_input=None): # pylint: disable=unused-argu ), ): vol.In( ["h264", "h265"] ), + vol.Required( + CONF_SMTP_PORT, + default=self.config_entry.options.get( + CONF_SMTP_PORT, DEFAULT_SMTP_PORT + ), + ): cv.positive_int, vol.Required( CONF_MOTION_OFF_DELAY, default=self.config_entry.options.get( diff --git a/custom_components/reolink_dev/const.py b/custom_components/reolink_dev/const.py index ff0e236..e313466 100644 --- a/custom_components/reolink_dev/const.py +++ b/custom_components/reolink_dev/const.py @@ -19,6 +19,7 @@ CONF_STREAM_FORMAT = "stream_format" CONF_PROTOCOL = "protocol" CONF_CHANNEL = "channel" +CONF_SMTP_PORT = "smtp_port" CONF_MOTION_OFF_DELAY = "motion_off_delay" CONF_PLAYBACK_MONTHS = "playback_months" CONF_THUMBNAIL_PATH = "playback_thumbnail_path" @@ -26,6 +27,7 @@ DEFAULT_USE_HTTPS = True DEFAULT_CHANNEL = 1 +DEFAULT_SMTP_PORT = 0 DEFAULT_MOTION_OFF_DELAY = 60 DEFAULT_PROTOCOL = "rtmp" DEFAULT_STREAM = "main" diff --git a/custom_components/reolink_dev/manifest.json b/custom_components/reolink_dev/manifest.json index f1c84df..655a057 100644 --- a/custom_components/reolink_dev/manifest.json +++ b/custom_components/reolink_dev/manifest.json @@ -6,7 +6,8 @@ "version": "0.58", "iot_class": "local_polling", "requirements": [ - "reolink==0.0.62" + "reolink==0.0.62", + "aiosmtpd>=1.4.2" ], "dependencies": [ "ffmpeg", diff --git a/custom_components/reolink_dev/strings.json b/custom_components/reolink_dev/strings.json index 7864c78..102d7bc 100644 --- a/custom_components/reolink_dev/strings.json +++ b/custom_components/reolink_dev/strings.json @@ -32,11 +32,12 @@ "protocol": "Protocol", "stream": "Stream", "timeout": "Timeout", + "smtp_port": "SMTP port for AI detection events (0 to disable)", "motion_states_update_fallback_delay": "Motion states update fallback delay (seconds, 0 or less to disable)", "motion_off_delay": "Motion sensor off delay (seconds)", "playback_months": "Playback range (months)", "playback_thumbnail_path": "Custom thumbnail path", - "stream_format": "Stream format" + "stream_format": "Stream format" } } } diff --git a/custom_components/reolink_dev/translations/en.json b/custom_components/reolink_dev/translations/en.json index b8afeac..c446489 100644 --- a/custom_components/reolink_dev/translations/en.json +++ b/custom_components/reolink_dev/translations/en.json @@ -32,6 +32,7 @@ "protocol": "Protocol", "stream": "Stream", "timeout": "Timeout (seconds)", + "smtp_port": "SMTP port for AI detection events (0 to disable)", "motion_states_update_fallback_delay": "Motion states update fallback delay (seconds, 0 or less to disable): Reolink's event subscription is not always reliable, this will help to avoid event misses", "motion_off_delay": "Motion sensor off delay (seconds)", "playback_months": "Playback range (months)",