Update custom components
Este cometimento está contido em:
@@ -16,6 +16,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .base import ReolinkBase, ReolinkPush
|
||||
@@ -24,17 +25,15 @@ from .const import (
|
||||
CONF_CHANNEL,
|
||||
CONF_MOTION_OFF_DELAY,
|
||||
CONF_PLAYBACK_MONTHS,
|
||||
CONF_PLAYBACK_THUMBNAILS,
|
||||
CONF_PROTOCOL,
|
||||
CONF_STREAM,
|
||||
CONF_THUMBNAIL_OFFSET,
|
||||
CONF_THUMBNAIL_PATH,
|
||||
COORDINATOR,
|
||||
DEFAULT_PLAYBACK_THUMBNAILS,
|
||||
DEFAULT_THUMBNAIL_OFFSET,
|
||||
DOMAIN,
|
||||
EVENT_DATA_RECEIVED,
|
||||
PUSH_MANAGER,
|
||||
SERVICE_PTZ_CONTROL,
|
||||
SERVICE_QUERY_VOD,
|
||||
SERVICE_SET_DAYNIGHT,
|
||||
SERVICE_SET_SENSITIVITY,
|
||||
)
|
||||
@@ -44,7 +43,7 @@ SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["camera", "switch", "binary_sensor"]
|
||||
PLATFORMS = ["camera", "switch", "binary_sensor", "sensor"]
|
||||
|
||||
|
||||
async def async_setup(
|
||||
@@ -53,6 +52,11 @@ async def async_setup(
|
||||
"""Set up the Reolink component."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# ensure default storage path is writable by scripts
|
||||
default_thumbnail_path = hass.config.path(f"{STORAGE_DIR}/{DOMAIN}")
|
||||
if default_thumbnail_path not in hass.config.allowlist_external_dirs:
|
||||
hass.config.allowlist_external_dirs.add(default_thumbnail_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -118,13 +122,8 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||
|
||||
base.motion_off_delay = entry.options[CONF_MOTION_OFF_DELAY]
|
||||
base.playback_months = entry.options[CONF_PLAYBACK_MONTHS]
|
||||
base.playback_thumbnails = entry.options.get(
|
||||
CONF_PLAYBACK_THUMBNAILS, DEFAULT_PLAYBACK_THUMBNAILS
|
||||
)
|
||||
base.playback_thumbnail_offset = entry.options.get(
|
||||
CONF_THUMBNAIL_OFFSET, DEFAULT_THUMBNAIL_OFFSET
|
||||
)
|
||||
|
||||
base.set_thumbnail_path(entry.options.get(CONF_THUMBNAIL_PATH))
|
||||
await base.set_timeout(entry.options[CONF_TIMEOUT])
|
||||
await base.set_protocol(entry.options[CONF_PROTOCOL])
|
||||
await base.set_stream(entry.options[CONF_STREAM])
|
||||
@@ -156,5 +155,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
hass.services.async_remove(DOMAIN, SERVICE_PTZ_CONTROL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SET_DAYNIGHT)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SET_SENSITIVITY)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_QUERY_VOD)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""This component updates the camera API and subscription."""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
|
||||
import datetime as dt
|
||||
from typing import Optional
|
||||
|
||||
from urllib.parse import quote_plus
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -10,24 +16,21 @@ from homeassistant.const import (
|
||||
CONF_TIMEOUT,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
async_entries_for_config_entry,
|
||||
async_get_registry as async_get_entity_registry,
|
||||
)
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from reolink.camera_api import Api
|
||||
from reolink.subscription_manager import Manager
|
||||
from reolink.typings import SearchTime
|
||||
from .typings import VoDEvent, VoDEventThumbnail
|
||||
|
||||
from .const import (
|
||||
BASE,
|
||||
CONF_PLAYBACK_MONTHS,
|
||||
CONF_PLAYBACK_THUMBNAILS,
|
||||
CONF_THUMBNAIL_OFFSET,
|
||||
CONF_THUMBNAIL_PATH,
|
||||
DEFAULT_PLAYBACK_MONTHS,
|
||||
DEFAULT_PLAYBACK_THUMBNAILS,
|
||||
DEFAULT_THUMBNAIL_OFFSET,
|
||||
EVENT_DATA_RECEIVED,
|
||||
CONF_CHANNEL,
|
||||
CONF_MOTION_OFF_DELAY,
|
||||
@@ -41,10 +44,15 @@ from .const import (
|
||||
DOMAIN,
|
||||
PUSH_MANAGER,
|
||||
SESSION_RENEW_THRESHOLD,
|
||||
THUMBNAIL_EXTENSION,
|
||||
THUMBNAIL_URL,
|
||||
VOD_URL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
|
||||
class ReolinkBase:
|
||||
"""The implementation of the Reolink IP base class."""
|
||||
@@ -89,28 +97,24 @@ class ReolinkBase:
|
||||
)
|
||||
|
||||
self._hass = hass
|
||||
self.async_functions = list()
|
||||
self.sync_functions = list()
|
||||
self.motion_detection_state = True
|
||||
|
||||
if CONF_MOTION_OFF_DELAY not in options:
|
||||
self.motion_off_delay = DEFAULT_MOTION_OFF_DELAY
|
||||
else:
|
||||
self.motion_off_delay = options[CONF_MOTION_OFF_DELAY]
|
||||
self.motion_off_delay: int = options[CONF_MOTION_OFF_DELAY]
|
||||
|
||||
if CONF_PLAYBACK_MONTHS not in options:
|
||||
self.playback_months = DEFAULT_PLAYBACK_MONTHS
|
||||
else:
|
||||
self.playback_months = options[CONF_PLAYBACK_MONTHS]
|
||||
self.playback_months: int = options[CONF_PLAYBACK_MONTHS]
|
||||
|
||||
if CONF_PLAYBACK_THUMBNAILS not in options:
|
||||
self.playback_thumbnails = DEFAULT_PLAYBACK_THUMBNAILS
|
||||
if CONF_THUMBNAIL_PATH not in options:
|
||||
self._thumbnail_path = None
|
||||
else:
|
||||
self.playback_thumbnails = options[CONF_PLAYBACK_THUMBNAILS]
|
||||
|
||||
if CONF_THUMBNAIL_OFFSET not in options:
|
||||
self.playback_thumbnail_offset = DEFAULT_THUMBNAIL_OFFSET
|
||||
else:
|
||||
self.playback_thumbnail_offset = options[CONF_THUMBNAIL_OFFSET]
|
||||
self._thumbnail_path: str = options[CONF_THUMBNAIL_PATH]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -120,8 +124,8 @@ class ReolinkBase:
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Create the unique ID, base for all entities."""
|
||||
id = self._api.mac_address.replace(":", "")
|
||||
return f"{id}-{self.channel}"
|
||||
uid = self._api.mac_address.replace(":", "")
|
||||
return f"{uid}-{self.channel}"
|
||||
|
||||
@property
|
||||
def event_id(self):
|
||||
@@ -150,13 +154,27 @@ class ReolinkBase:
|
||||
"""Return the API object."""
|
||||
return self._api
|
||||
|
||||
@property
|
||||
def thumbnail_path(self):
|
||||
""" Thumbnail storage location """
|
||||
if not self._thumbnail_path:
|
||||
self._thumbnail_path = self._hass.config.path(
|
||||
f"{STORAGE_DIR}/{DOMAIN}/{self.unique_id}"
|
||||
)
|
||||
return self._thumbnail_path
|
||||
|
||||
def set_thumbnail_path(self, value):
|
||||
""" Set custom thumbnail path"""
|
||||
self._thumbnail_path = value
|
||||
|
||||
async def connect_api(self):
|
||||
"""Connect to the Reolink API and fetch initial dataset."""
|
||||
if not await self._api.get_settings():
|
||||
return False
|
||||
if not await self._api.get_states():
|
||||
return False
|
||||
|
||||
|
||||
await self._api.get_ai_state()
|
||||
await self._api.is_admin()
|
||||
return True
|
||||
|
||||
@@ -195,9 +213,63 @@ class ReolinkBase:
|
||||
async def stop(self):
|
||||
"""Disconnect the API and deregister the event listener."""
|
||||
await self.disconnect_api()
|
||||
for func in self.async_functions:
|
||||
await func()
|
||||
for func in self.sync_functions:
|
||||
await self._hass.async_add_executor_job(func)
|
||||
|
||||
async def send_search(
|
||||
self, start: dt.datetime, end: dt.datetime, only_status: bool = False
|
||||
):
|
||||
""" Call the API of the camera device to search for VoDs """
|
||||
return await self._api.send_search(start, end, only_status)
|
||||
|
||||
async def emit_search_results(
|
||||
self,
|
||||
bus_event_id: str,
|
||||
camera_id: str,
|
||||
start: Optional[dt.datetime] = None,
|
||||
end: Optional[dt.datetime] = None,
|
||||
context: Optional[Context] = None,
|
||||
):
|
||||
""" Run search and emit VoD results to event """
|
||||
|
||||
if end is None:
|
||||
end = dt_util.now()
|
||||
if start is None:
|
||||
start = dt.datetime.combine(end.date().replace(day=1), dt.time.min)
|
||||
if self.playback_months > 1:
|
||||
start -= relativedelta(months=int(self.playback_months))
|
||||
|
||||
_, files = await self._api.send_search(start, end)
|
||||
|
||||
for file in files:
|
||||
end = searchtime_to_datetime(file["EndTime"], end.tzinfo)
|
||||
start = searchtime_to_datetime(file["StartTime"], end.tzinfo)
|
||||
event_id = str(start.timestamp())
|
||||
url = VOD_URL.format(camera_id=camera_id, event_id=quote_plus(file["name"]))
|
||||
|
||||
thumbnail = os.path.join(
|
||||
self.thumbnail_path, f"{event_id}.{THUMBNAIL_EXTENSION}"
|
||||
)
|
||||
|
||||
self._hass.bus.fire(
|
||||
bus_event_id,
|
||||
VoDEvent(
|
||||
event_id,
|
||||
start,
|
||||
end - start,
|
||||
file["name"],
|
||||
url,
|
||||
VoDEventThumbnail(
|
||||
THUMBNAIL_URL.format(camera_id=camera_id, event_id=event_id),
|
||||
os.path.isfile(thumbnail),
|
||||
thumbnail,
|
||||
),
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class ReolinkPush:
|
||||
"""The implementation of the Reolink IP base class."""
|
||||
@@ -364,3 +436,16 @@ async def get_event_by_webhook(hass: HomeAssistant, webhook_id):
|
||||
if wid == webhook_id:
|
||||
event_id = info["name"]
|
||||
return event_id
|
||||
|
||||
|
||||
def searchtime_to_datetime(self: SearchTime, timezone: dt.tzinfo):
|
||||
""" Convert SearchTime to datetime """
|
||||
return dt.datetime(
|
||||
self["year"],
|
||||
self["mon"],
|
||||
self["day"],
|
||||
self["hour"],
|
||||
self["min"],
|
||||
self["sec"],
|
||||
tzinfo=timezone,
|
||||
)
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"""This component provides support for Reolink motion events."""
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
|
||||
from .const import EVENT_DATA_RECEIVED
|
||||
from .entity import ReolinkEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DEVICE_CLASS = "motion"
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the Reolink IP Camera switches."""
|
||||
sensor = MotionSensor(hass, config_entry)
|
||||
@@ -30,6 +25,7 @@ class MotionSensor(ReolinkEntity, BinarySensorEntity):
|
||||
|
||||
self._available = False
|
||||
self._event_state = False
|
||||
self._last_event_state = False
|
||||
self._last_motion = datetime.datetime.min
|
||||
|
||||
@property
|
||||
@@ -57,7 +53,7 @@ class MotionSensor(ReolinkEntity, BinarySensorEntity):
|
||||
datetime.datetime.now() - self._last_motion
|
||||
).total_seconds() < self._base.motion_off_delay:
|
||||
self._state = True
|
||||
else:
|
||||
else:
|
||||
self._state = False
|
||||
|
||||
return self._state
|
||||
@@ -79,6 +75,7 @@ class MotionSensor(ReolinkEntity, BinarySensorEntity):
|
||||
|
||||
async def handle_event(self, event):
|
||||
"""Handle incoming event for motion detection and availability."""
|
||||
|
||||
try:
|
||||
self._available = event.data["available"]
|
||||
return
|
||||
@@ -89,6 +86,7 @@ class MotionSensor(ReolinkEntity, BinarySensorEntity):
|
||||
return
|
||||
|
||||
try:
|
||||
self._last_event_state = bool(self._event_state)
|
||||
self._event_state = event.data["motion"]
|
||||
except KeyError:
|
||||
return
|
||||
@@ -99,8 +97,35 @@ class MotionSensor(ReolinkEntity, BinarySensorEntity):
|
||||
|
||||
if self._event_state:
|
||||
self._last_motion = datetime.datetime.now()
|
||||
|
||||
if self._base.api.ai_state:
|
||||
# Pull the AI state only at motion detection
|
||||
await self._base.api.get_ai_state()
|
||||
else:
|
||||
if self._base.motion_off_delay > 0:
|
||||
await asyncio.sleep(self._base.motion_off_delay)
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attrs = super().extra_state_attributes
|
||||
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
|
||||
attrs["bus_event_id"] = self._base.event_id
|
||||
|
||||
if self._base.api.ai_state:
|
||||
for key, value in self._base.api.ai_state.items():
|
||||
if key == "channel":
|
||||
continue
|
||||
|
||||
if self._state:
|
||||
attrs[key] = value == 1
|
||||
else:
|
||||
# Reset the AI values.
|
||||
attrs[key] = False
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -3,11 +3,11 @@ import asyncio
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from haffmpeg.camera import CameraMjpeg
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import SUPPORT_STREAM, Camera
|
||||
from homeassistant.components.ffmpeg import DATA_FFMPEG
|
||||
|
||||
# from homeassistant.components.ffmpeg import DATA_FFMPEG
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_aiohttp_proxy_web,
|
||||
@@ -15,17 +15,22 @@ from homeassistant.helpers.aiohttp_client import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN_DATA,
|
||||
LAST_EVENT,
|
||||
SERVICE_PTZ_CONTROL,
|
||||
SERVICE_QUERY_VOD,
|
||||
SERVICE_SET_BACKLIGHT,
|
||||
SERVICE_SET_DAYNIGHT,
|
||||
SERVICE_SET_SENSITIVITY,
|
||||
SUPPORT_PLAYBACK,
|
||||
SUPPORT_PTZ,
|
||||
)
|
||||
from .entity import ReolinkEntity
|
||||
from .typings import VoDEvent
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up a Reolink IP Camera."""
|
||||
|
||||
@@ -65,6 +70,17 @@ async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
vol.Optional("speed"): cv.positive_int,
|
||||
},
|
||||
SERVICE_PTZ_CONTROL,
|
||||
[SUPPORT_PTZ],
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_QUERY_VOD,
|
||||
{
|
||||
vol.Required("event_id"): cv.string,
|
||||
vol.Optional("start"): cv.datetime,
|
||||
vol.Optional("end"): cv.datetime,
|
||||
},
|
||||
SERVICE_QUERY_VOD,
|
||||
[SUPPORT_PLAYBACK],
|
||||
)
|
||||
|
||||
async_add_devices([camera])
|
||||
@@ -77,10 +93,10 @@ class ReolinkCamera(ReolinkEntity, Camera):
|
||||
"""Initialize a Reolink camera."""
|
||||
ReolinkEntity.__init__(self, hass, config)
|
||||
Camera.__init__(self)
|
||||
self._entry_id = config.entry_id
|
||||
|
||||
self._hass = hass
|
||||
self._ffmpeg = self._hass.data[DATA_FFMPEG]
|
||||
self._last_image = None
|
||||
# self._ffmpeg = self._hass.data[DATA_FFMPEG]
|
||||
# self._last_image = None
|
||||
self._ptz_commands = {
|
||||
"AUTO": "Auto",
|
||||
"DOWN": "Down",
|
||||
@@ -125,6 +141,12 @@ class ReolinkCamera(ReolinkEntity, Camera):
|
||||
"""Return whether the camera has PTZ support."""
|
||||
return self._base.api.ptz_support
|
||||
|
||||
@property
|
||||
def playback_support(self):
|
||||
""" Return whethere the camera has VoDs. """
|
||||
# TODO : this should probably be like ptz above, and be a property of the api
|
||||
return bool(self._base.api.hdd_info)
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the camera state attributes."""
|
||||
@@ -143,12 +165,26 @@ class ReolinkCamera(ReolinkEntity, Camera):
|
||||
if self._base.api.sensitivity_presets:
|
||||
attrs["sensitivity"] = self.get_sensitivity_presets()
|
||||
|
||||
if self.playback_support:
|
||||
data: dict = self.hass.data.get(DOMAIN_DATA)
|
||||
data = data.get(self._base.unique_id) if data else None
|
||||
last: VoDEvent = data.get(LAST_EVENT) if data else None
|
||||
if last and last.url:
|
||||
attrs["video_url"] = last.url
|
||||
if last.thumbnail and last.thumbnail.exists:
|
||||
attrs["video_thumbnail"] = last.thumbnail.url
|
||||
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return supported features."""
|
||||
return SUPPORT_STREAM
|
||||
features = SUPPORT_STREAM
|
||||
if self.ptz_support:
|
||||
features += SUPPORT_PTZ
|
||||
if self.playback_support:
|
||||
features += SUPPORT_PLAYBACK
|
||||
return features
|
||||
|
||||
async def stream_source(self):
|
||||
"""Return the source of the stream."""
|
||||
@@ -177,6 +213,16 @@ class ReolinkCamera(ReolinkEntity, Camera):
|
||||
command=self._ptz_commands[command], **kwargs
|
||||
)
|
||||
|
||||
async def query_vods(self, event_id, **kwargs):
|
||||
""" Query camera for VoDs and emit results """
|
||||
if not self.playback_support:
|
||||
_LOGGER.error("Video Playback is not supported on this device")
|
||||
return
|
||||
|
||||
await self._base.emit_search_results(
|
||||
event_id, self._entry_id, context=self._context, **kwargs
|
||||
)
|
||||
|
||||
def get_sensitivity_presets(self):
|
||||
"""Get formatted sensitivity presets."""
|
||||
presets = list()
|
||||
|
||||
@@ -20,16 +20,13 @@ from .const import (
|
||||
CONF_CHANNEL,
|
||||
CONF_MOTION_OFF_DELAY,
|
||||
CONF_PLAYBACK_MONTHS,
|
||||
CONF_PLAYBACK_THUMBNAILS,
|
||||
CONF_PROTOCOL,
|
||||
CONF_STREAM,
|
||||
CONF_THUMBNAIL_OFFSET,
|
||||
CONF_THUMBNAIL_PATH,
|
||||
DEFAULT_MOTION_OFF_DELAY,
|
||||
DEFAULT_PLAYBACK_MONTHS,
|
||||
DEFAULT_PLAYBACK_THUMBNAILS,
|
||||
DEFAULT_PROTOCOL,
|
||||
DEFAULT_STREAM,
|
||||
DEFAULT_THUMBNAIL_OFFSET,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -183,17 +180,11 @@ class ReolinkOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
),
|
||||
): cv.positive_int,
|
||||
vol.Optional(
|
||||
CONF_PLAYBACK_THUMBNAILS,
|
||||
CONF_THUMBNAIL_PATH,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_PLAYBACK_THUMBNAILS, DEFAULT_PLAYBACK_THUMBNAILS
|
||||
CONF_THUMBNAIL_PATH, None
|
||||
),
|
||||
): cv.boolean,
|
||||
vol.Optional(
|
||||
CONF_THUMBNAIL_OFFSET,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_THUMBNAIL_OFFSET, DEFAULT_THUMBNAIL_OFFSET
|
||||
),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=0, max=60)),
|
||||
): cv.string,
|
||||
vol.Optional(
|
||||
CONF_TIMEOUT,
|
||||
default=self.config_entry.options.get(
|
||||
|
||||
@@ -7,14 +7,18 @@ COORDINATOR = "coordinator"
|
||||
BASE = "base"
|
||||
PUSH_MANAGER = "push_manager"
|
||||
SESSION_RENEW_THRESHOLD = 300
|
||||
MEDIA_SOURCE = "media_source"
|
||||
THUMBNAIL_VIEW = "thumbnail_view"
|
||||
SHORT_TOKENS = "short_tokens"
|
||||
LONG_TOKENS = "long_tokens"
|
||||
LAST_EVENT = "last_event"
|
||||
|
||||
CONF_STREAM = "stream"
|
||||
CONF_PROTOCOL = "protocol"
|
||||
CONF_CHANNEL = "channel"
|
||||
CONF_MOTION_OFF_DELAY = "motion_off_delay"
|
||||
CONF_PLAYBACK_MONTHS = "playback_months"
|
||||
CONF_PLAYBACK_THUMBNAILS = "playback_thumbnails"
|
||||
CONF_THUMBNAIL_OFFSET = "playback_thumbnail_offset"
|
||||
CONF_THUMBNAIL_PATH = "playback_thumbnail_path"
|
||||
|
||||
DEFAULT_CHANNEL = 1
|
||||
DEFAULT_MOTION_OFF_DELAY = 60
|
||||
@@ -22,10 +26,19 @@ DEFAULT_PROTOCOL = "rtmp"
|
||||
DEFAULT_STREAM = "main"
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DEFAULT_PLAYBACK_MONTHS = 2
|
||||
DEFAULT_PLAYBACK_THUMBNAILS = False
|
||||
DEFAULT_THUMBNAIL_OFFSET = 6
|
||||
|
||||
SUPPORT_PTZ = 1024
|
||||
SUPPORT_PLAYBACK = 2048
|
||||
|
||||
SERVICE_PTZ_CONTROL = "ptz_control"
|
||||
SERVICE_SET_BACKLIGHT = "set_backlight"
|
||||
SERVICE_SET_DAYNIGHT = "set_daynight"
|
||||
SERVICE_SET_SENSITIVITY = "set_sensitivity"
|
||||
|
||||
SERVICE_QUERY_VOD = "query_vods"
|
||||
|
||||
THUMBNAIL_EXTENSION = "jpg"
|
||||
|
||||
THUMBNAIL_URL = "/api/" + DOMAIN + "/media_proxy/{camera_id}/{event_id}.jpg"
|
||||
VOD_URL = "/api/" + DOMAIN + "/vod/{camera_id}/{event_id}"
|
||||
|
||||
123
custom_components/reolink_dev/device_action.py
Ficheiro normal
123
custom_components/reolink_dev/device_action.py
Ficheiro normal
@@ -0,0 +1,123 @@
|
||||
""" custom helper actions """
|
||||
|
||||
import logging
|
||||
|
||||
from typing import List, Optional
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_TYPE,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN, SERVICE_SNAPSHOT
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
|
||||
from .utils import async_get_device_entries
|
||||
from .const import DOMAIN
|
||||
|
||||
VOD_THUMB_CAP = "capture_vod_thumbnail"
|
||||
|
||||
ACTION_TYPES = {VOD_THUMB_CAP}
|
||||
|
||||
ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): vol.In(ACTION_TYPES),
|
||||
vol.Optional(CONF_ENTITY_ID): cv.entities_domain(
|
||||
[CAMERA_DOMAIN, SENSOR_DOMAIN]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_actions(hass: HomeAssistant, device_id: str):
|
||||
"""List device actions for devices."""
|
||||
|
||||
actions = []
|
||||
|
||||
(device, device_entries) = await async_get_device_entries(hass, device_id)
|
||||
|
||||
if not device or not device_entries or len(device_entries) < 2:
|
||||
return actions
|
||||
|
||||
sensor = None
|
||||
camera = None
|
||||
for entry in device_entries:
|
||||
if (
|
||||
entry.domain == SENSOR_DOMAIN
|
||||
and entry.device_class == DEVICE_CLASS_TIMESTAMP
|
||||
):
|
||||
sensor = entry
|
||||
if entry.domain == CAMERA_DOMAIN:
|
||||
camera = entry
|
||||
if sensor and camera:
|
||||
actions.append(
|
||||
{
|
||||
CONF_DOMAIN: DOMAIN,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_ENTITY_ID: [camera.entity_id, sensor.cv.entity_id],
|
||||
CONF_TYPE: VOD_THUMB_CAP,
|
||||
}
|
||||
)
|
||||
sensor = None
|
||||
camera = None
|
||||
|
||||
_LOGGER.debug("actions: %s", actions)
|
||||
return actions
|
||||
|
||||
|
||||
async def async_call_action_from_config(
|
||||
hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context]
|
||||
):
|
||||
"""Execute a device action."""
|
||||
|
||||
if config[CONF_TYPE] == VOD_THUMB_CAP:
|
||||
entity_ids: List[str] = config.get(CONF_ENTITY_ID)
|
||||
camera_entity_id: str = None
|
||||
thumbnail_path: str = None
|
||||
if entity_ids and len(entity_ids) > 0:
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
if state and state.domain == CAMERA_DOMAIN:
|
||||
camera_entity_id = entity_id
|
||||
elif state and state.domain == SENSOR_DOMAIN:
|
||||
thumbnail_path = state.attributes.get("thumbnail_path")
|
||||
|
||||
if not camera_entity_id or not thumbnail_path:
|
||||
(_, device_entries) = await async_get_device_entries(
|
||||
hass, config[CONF_DEVICE_ID]
|
||||
)
|
||||
for entry in device_entries:
|
||||
if not camera_entity_id and entry.domain == CAMERA_DOMAIN:
|
||||
camera_entity_id = entry.entity_id
|
||||
if (
|
||||
not thumbnail_path
|
||||
and entry.domain == SENSOR_DOMAIN
|
||||
and entry.device_class == DEVICE_CLASS_TIMESTAMP
|
||||
):
|
||||
state = hass.states.get(entry.entity_id)
|
||||
thumbnail_path = (
|
||||
state.attributes.get("thumbnail_path") if state else None
|
||||
)
|
||||
|
||||
service_data = {
|
||||
ATTR_ENTITY_ID: camera_entity_id,
|
||||
"filename": thumbnail_path,
|
||||
}
|
||||
_LOGGER.debug("service_data: %s", service_data)
|
||||
_LOGGER.debug("variables: %s", variables)
|
||||
return await hass.services.async_call(
|
||||
CAMERA_DOMAIN,
|
||||
SERVICE_SNAPSHOT,
|
||||
service_data,
|
||||
blocking=True,
|
||||
context=context,
|
||||
)
|
||||
108
custom_components/reolink_dev/device_condition.py
Ficheiro normal
108
custom_components/reolink_dev/device_condition.py
Ficheiro normal
@@ -0,0 +1,108 @@
|
||||
""" Additional conditions for ReoLink Camera """
|
||||
|
||||
import logging
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_FOR,
|
||||
CONF_TYPE,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .utils import async_get_device_entries
|
||||
from .const import DOMAIN
|
||||
|
||||
NO_THUMBNAIL = "vod_no_thumbnail"
|
||||
HAS_THUMBNAIL = "vod_has_thumbnail"
|
||||
|
||||
CONDITION_TYPES = {NO_THUMBNAIL, HAS_THUMBNAIL}
|
||||
|
||||
CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES),
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_domain(SENSOR_DOMAIN),
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant, device_id: str):
|
||||
"""List device conditions for devices."""
|
||||
|
||||
conditions = []
|
||||
|
||||
(device, device_entries) = await async_get_device_entries(hass, device_id)
|
||||
|
||||
if not device or not device_entries or len(device_entries) < 1:
|
||||
return conditions
|
||||
|
||||
for entry in device_entries:
|
||||
if (
|
||||
entry.domain != SENSOR_DOMAIN
|
||||
or entry.device_class != DEVICE_CLASS_TIMESTAMP
|
||||
):
|
||||
continue
|
||||
|
||||
conditions.append(
|
||||
{
|
||||
CONF_DOMAIN: DOMAIN,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_ENTITY_ID: entry.entity_id,
|
||||
CONF_TYPE: NO_THUMBNAIL,
|
||||
}
|
||||
)
|
||||
conditions.append(
|
||||
{
|
||||
CONF_DOMAIN: DOMAIN,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_ENTITY_ID: entry.entity_id,
|
||||
CONF_TYPE: HAS_THUMBNAIL,
|
||||
},
|
||||
)
|
||||
|
||||
return conditions
|
||||
|
||||
|
||||
@callback
|
||||
def async_condition_from_config(
|
||||
config: ConfigType, config_validation: bool
|
||||
) -> condition.ConditionCheckerType:
|
||||
"""Create a function to test a device condition."""
|
||||
|
||||
if config_validation:
|
||||
config = CONDITION_SCHEMA(config)
|
||||
|
||||
config_type = config[CONF_TYPE]
|
||||
|
||||
if config_type in {NO_THUMBNAIL, HAS_THUMBNAIL}:
|
||||
if config_type == NO_THUMBNAIL:
|
||||
state = "false"
|
||||
else:
|
||||
state = "true"
|
||||
|
||||
entity_id: str = config[CONF_ENTITY_ID]
|
||||
for_period = config.get(CONF_FOR)
|
||||
attribute = "has_thumbnail"
|
||||
|
||||
# @trace_condition_function
|
||||
def test_is_state(hass: HomeAssistant, variables: TemplateVarsType):
|
||||
""" Test thumbnail state """
|
||||
|
||||
return condition.state(
|
||||
hass,
|
||||
entity_id,
|
||||
state,
|
||||
for_period,
|
||||
attribute,
|
||||
)
|
||||
|
||||
return test_is_state
|
||||
110
custom_components/reolink_dev/device_trigger.py
Ficheiro normal
110
custom_components/reolink_dev/device_trigger.py
Ficheiro normal
@@ -0,0 +1,110 @@
|
||||
""" Additional triggers for ReoLink Camera """
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from homeassistant.components.automation import AutomationActionType
|
||||
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
|
||||
from homeassistant.components.homeassistant.triggers import state as state_trigger
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
|
||||
from .utils import async_get_device_entries
|
||||
from .const import DOMAIN
|
||||
|
||||
NEW_VOD = "new_vod"
|
||||
|
||||
TRIGGER_TYPES = {NEW_VOD}
|
||||
|
||||
TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
|
||||
vol.Optional(CONF_ENTITY_ID): cv.entity_domain(SENSOR_DOMAIN),
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant, device_id: str):
|
||||
""" List of device triggers """
|
||||
|
||||
(device, device_entries) = await async_get_device_entries(hass, device_id)
|
||||
|
||||
triggers = []
|
||||
|
||||
if not device or not device_entries or len(device_entries) < 1:
|
||||
return triggers
|
||||
|
||||
for entry in device_entries:
|
||||
if (
|
||||
entry.domain != SENSOR_DOMAIN
|
||||
or entry.device_class != DEVICE_CLASS_TIMESTAMP
|
||||
):
|
||||
continue
|
||||
|
||||
triggers.append(
|
||||
{
|
||||
CONF_PLATFORM: "device",
|
||||
CONF_DOMAIN: DOMAIN,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_ENTITY_ID: entry.entity_id,
|
||||
CONF_TYPE: NEW_VOD,
|
||||
}
|
||||
)
|
||||
|
||||
return triggers
|
||||
|
||||
|
||||
async def async_attach_trigger(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
action: AutomationActionType,
|
||||
automation_info: dict,
|
||||
):
|
||||
""" Attach a trigger """
|
||||
|
||||
if config[CONF_TYPE] == NEW_VOD:
|
||||
if CONF_ENTITY_ID not in config:
|
||||
(_, device_entries) = await async_get_device_entries(
|
||||
hass, config[CONF_DEVICE_ID]
|
||||
)
|
||||
config[CONF_ENTITY_ID] = (
|
||||
next(
|
||||
(
|
||||
entry.entity_id
|
||||
for entry in device_entries
|
||||
if entry.domain == SENSOR_DOMAIN
|
||||
and entry.device_class == DEVICE_CLASS_TIMESTAMP
|
||||
)
|
||||
)
|
||||
if device_entries
|
||||
else None
|
||||
)
|
||||
|
||||
state_config = state_trigger.TRIGGER_SCHEMA(
|
||||
{
|
||||
CONF_PLATFORM: "state",
|
||||
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
|
||||
}
|
||||
)
|
||||
|
||||
return await state_trigger.async_attach_trigger(
|
||||
hass,
|
||||
state_config,
|
||||
action,
|
||||
automation_info,
|
||||
platform_type=config[CONF_PLATFORM],
|
||||
)
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Reolink parent entity class."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import BASE, COORDINATOR, DOMAIN
|
||||
from .base import ReolinkBase
|
||||
|
||||
|
||||
class ReolinkEntity(CoordinatorEntity):
|
||||
"""Parent class for Reolink Entities."""
|
||||
|
||||
def __init__(self, hass, config):
|
||||
def __init__(self, hass: HomeAssistant, config):
|
||||
"""Initialize common aspects of a Reolink entity."""
|
||||
coordinator = hass.data[DOMAIN][config.entry_id][COORDINATOR]
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._base = hass.data[DOMAIN][config.entry_id][BASE]
|
||||
self._base: ReolinkBase = hass.data[DOMAIN][config.entry_id][BASE]
|
||||
self._hass = hass
|
||||
self._state = False
|
||||
|
||||
@@ -28,7 +30,7 @@ class ReolinkEntity(CoordinatorEntity):
|
||||
"sw_version": self._base.api.sw_version,
|
||||
"model": self._base.api.model,
|
||||
"manufacturer": self._base.api.manufacturer,
|
||||
"channel": self._base.channel
|
||||
"channel": self._base.channel,
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
"name": "Reolink IP camera",
|
||||
"documentation": "https://github.com/fwestenberg/reolink_dev",
|
||||
"issue_tracker": "https://github.com/fwestenberg/reolink_dev/issues",
|
||||
"version": "0.15",
|
||||
"version": "0.17",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": [
|
||||
"reolink==0.0.17"
|
||||
"reolink==0.0.19"
|
||||
],
|
||||
"dependencies": [
|
||||
"ffmpeg",
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"""Reolink Camera Media Source Implementation."""
|
||||
from urllib import parse
|
||||
import secrets
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
import os
|
||||
import secrets
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote_plus, unquote_plus
|
||||
from aiohttp import web
|
||||
from haffmpeg.tools import IMAGE_JPEG
|
||||
|
||||
from dateutil import relativedelta
|
||||
from homeassistant.components.http.const import KEY_AUTHENTICATED
|
||||
|
||||
# from homeassistant.components.http.auth import async_sign_path
|
||||
|
||||
# from homeassistant.components.http import current_request
|
||||
# from homeassistant.components.http.const import KEY_HASS_REFRESH_TOKEN_ID
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
@@ -15,7 +21,6 @@ import homeassistant.util.dt as dt_utils
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
# from homeassistant.components.http.auth import async_sign_path
|
||||
from homeassistant.components.media_player.errors import BrowseError
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_CLASS_DIRECTORY,
|
||||
@@ -32,13 +37,24 @@ from homeassistant.components.media_source.models import (
|
||||
)
|
||||
|
||||
from homeassistant.components.stream import create_stream
|
||||
from homeassistant.components.ffmpeg import async_get_image
|
||||
|
||||
from custom_components.reolink_dev.base import ReolinkBase
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import typings
|
||||
from .base import ReolinkBase, searchtime_to_datetime
|
||||
|
||||
from .const import BASE, DEFAULT_THUMBNAIL_OFFSET, DOMAIN
|
||||
# from . import typings
|
||||
|
||||
from .const import (
|
||||
BASE,
|
||||
DOMAIN,
|
||||
DOMAIN_DATA,
|
||||
LONG_TOKENS,
|
||||
MEDIA_SOURCE,
|
||||
SHORT_TOKENS,
|
||||
THUMBNAIL_EXTENSION as EXTENSION,
|
||||
THUMBNAIL_URL,
|
||||
VOD_URL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
# MIME_TYPE = "rtmp/mp4"
|
||||
@@ -47,6 +63,8 @@ MIME_TYPE = "application/x-mpegURL"
|
||||
|
||||
NAME = "Reolink IP Camera"
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
|
||||
class IncompatibleMediaSource(MediaSourceError):
|
||||
"""Incompatible media source attributes."""
|
||||
@@ -54,13 +72,16 @@ class IncompatibleMediaSource(MediaSourceError):
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant):
|
||||
"""Set up Reolink media source."""
|
||||
|
||||
_LOGGER.debug("Creating REOLink Media Source")
|
||||
source = ReolinkSource(hass)
|
||||
hass.http.register_view(ReolinkSourceThumbnailView(hass, source))
|
||||
source = ReolinkMediaSource(hass)
|
||||
hass.http.register_view(ReolinkSourceThumbnailView(hass))
|
||||
hass.http.register_view(ReolinkSourceVODView(hass))
|
||||
|
||||
return source
|
||||
|
||||
|
||||
class ReolinkSource(MediaSource):
|
||||
class ReolinkMediaSource(MediaSource):
|
||||
"""Provide Reolink camera recordings as media sources."""
|
||||
|
||||
name: str = NAME
|
||||
@@ -69,18 +90,44 @@ class ReolinkSource(MediaSource):
|
||||
"""Initialize Reolink source."""
|
||||
super().__init__(DOMAIN)
|
||||
self.hass = hass
|
||||
self.cache = {}
|
||||
self._last_token: dt.datetime = None
|
||||
|
||||
@property
|
||||
def _short_security_token(self):
|
||||
def clear_token():
|
||||
tokens.remove(token)
|
||||
|
||||
data: dict = self.hass.data.setdefault(DOMAIN_DATA, {})
|
||||
data = data.setdefault(MEDIA_SOURCE, {})
|
||||
tokens: List[str] = data.setdefault(SHORT_TOKENS, [])
|
||||
if len(tokens) < 1 or (
|
||||
self._last_token and (self._last_token - dt_utils.now()).seconds >= 1800
|
||||
):
|
||||
self._last_token = dt_utils.now()
|
||||
tokens.append(secrets.token_hex())
|
||||
async_call_later(self.hass, 3600, clear_token)
|
||||
token = next(iter(tokens), None)
|
||||
return token
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve a media item to a playable item."""
|
||||
_, camera_id, event_id = async_parse_identifier(item)
|
||||
cache: typings.MediaSourceCacheEntry = self.cache[camera_id]
|
||||
event = cache["playback_events"][event_id]
|
||||
base: ReolinkBase = self.hass.data[DOMAIN][cache["entry_id"]][BASE]
|
||||
url = await base.api.get_vod_source(event["file"])
|
||||
|
||||
data: dict = self.hass.data[self.domain]
|
||||
entry: dict = data.get(camera_id) if camera_id else None
|
||||
base: ReolinkBase = entry.get(BASE) if entry else None
|
||||
if not base:
|
||||
raise BrowseError("Camera does not exist.")
|
||||
|
||||
file = unquote_plus(event_id)
|
||||
if not file:
|
||||
raise BrowseError("Event does not exist.")
|
||||
_LOGGER.debug("file = %s", file)
|
||||
|
||||
url = await base.api.get_vod_source(file)
|
||||
_LOGGER.debug("Load VOD %s", url)
|
||||
stream = create_stream(self.hass, url)
|
||||
stream.add_provider("hls", timeout=600)
|
||||
stream.add_provider("hls", timeout=3600)
|
||||
url: str = stream.endpoint_url("hls")
|
||||
# the media browser seems to have a problem with the master_playlist
|
||||
# ( it does not load the referenced playlist ) so we will just
|
||||
@@ -102,293 +149,287 @@ class ReolinkSource(MediaSource):
|
||||
|
||||
_LOGGER.debug("Browsing %s, %s, %s", source, camera_id, event_id)
|
||||
|
||||
if camera_id and camera_id not in self.cache:
|
||||
data: dict = self.hass.data[self.domain]
|
||||
entry: dict = data.get(camera_id) if camera_id else None
|
||||
base: ReolinkBase = entry.get(BASE) if entry else None
|
||||
if camera_id and not base:
|
||||
raise BrowseError("Camera does not exist.")
|
||||
|
||||
if (
|
||||
event_id
|
||||
and not "/" in event_id
|
||||
and event_id not in self.cache[camera_id]["playback_events"]
|
||||
):
|
||||
if event_id and not "/" in event_id:
|
||||
raise BrowseError("Event does not exist.")
|
||||
|
||||
return await self._async_browse_media(source, camera_id, event_id, False)
|
||||
return await self._async_browse_media(source, camera_id, event_id, base)
|
||||
|
||||
async def _async_browse_media(
|
||||
self, source: str, camera_id: str, event_id: str = None, no_descend: bool = True
|
||||
self,
|
||||
source: str,
|
||||
camera_id: str = None,
|
||||
event_id: str = None,
|
||||
base: ReolinkBase = None,
|
||||
) -> BrowseMediaSource:
|
||||
""" actual browse after input validation """
|
||||
event: typings.VodEvent = None
|
||||
cache: typings.MediaSourceCacheEntry = None
|
||||
start_date = None
|
||||
|
||||
if camera_id and camera_id in self.cache:
|
||||
cache = self.cache[camera_id]
|
||||
start_date: dt.datetime = None
|
||||
|
||||
if cache and event_id:
|
||||
if "playback_events" in cache and event_id in cache["playback_events"]:
|
||||
event = cache["playback_events"][event_id]
|
||||
end_date = event["end"]
|
||||
start_date = event["start"]
|
||||
time = start_date.time()
|
||||
duration = end_date - start_date
|
||||
def create_item(title: str, path: str, thumbnail: bool = False):
|
||||
nonlocal self, camera_id, event_id, start_date
|
||||
|
||||
title = f"{time} {duration}"
|
||||
else:
|
||||
year, *rest = event_id.split("/", 3)
|
||||
month = rest[0] if len(rest) > 0 else None
|
||||
day = rest[1] if len(rest) > 1 else None
|
||||
if not title or not path:
|
||||
if event_id and "/" in event_id:
|
||||
year, *rest = event_id.split("/", 3)
|
||||
month = rest[0] if len(rest) > 0 else None
|
||||
day = rest[1] if len(rest) > 1 else None
|
||||
|
||||
start_date = dt.datetime.combine(
|
||||
dt.date(
|
||||
int(year), int(month) if month else 1, int(day) if day else 1
|
||||
),
|
||||
dt.time.min,
|
||||
dt_utils.now().tzinfo,
|
||||
)
|
||||
start_date = dt.datetime.combine(
|
||||
dt.date(
|
||||
int(year),
|
||||
int(month) if month else 1,
|
||||
int(day) if day else 1,
|
||||
),
|
||||
dt.time.min,
|
||||
dt_utils.now().tzinfo,
|
||||
)
|
||||
|
||||
title = f"{start_date.date()}"
|
||||
title = f"{start_date.date()}"
|
||||
path = f"{source}/{camera_id}/{event_id}"
|
||||
elif base:
|
||||
title = base.name
|
||||
path = f"{source}/{camera_id}"
|
||||
else:
|
||||
title = self.name
|
||||
path = source + "/"
|
||||
|
||||
path = f"{source}/{camera_id}/{event_id}"
|
||||
else:
|
||||
if cache is None:
|
||||
camera_id = ""
|
||||
title = NAME
|
||||
else:
|
||||
title = cache["name"]
|
||||
|
||||
path = f"{source}/{camera_id}"
|
||||
|
||||
media_class = MEDIA_CLASS_DIRECTORY if event is None else MEDIA_CLASS_VIDEO
|
||||
|
||||
media = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=path,
|
||||
media_class=media_class,
|
||||
media_content_type=MEDIA_TYPE_VIDEO,
|
||||
title=title,
|
||||
can_play=bool(not event is None and event.get("file")),
|
||||
can_expand=event is None,
|
||||
)
|
||||
|
||||
if not event is None and cache.get("playback_thumbnails", False):
|
||||
url = "/api/" + DOMAIN + f"/media_proxy/{camera_id}/{event_id}"
|
||||
|
||||
# TODO : I cannot find a way to get the current user context at this point
|
||||
# so I will have to leave the view as unauthenticated, as a temporary
|
||||
# security measure, I will add a unique token to the event to limit
|
||||
# "exposure"
|
||||
# url = async_sign_path(self.hass, None, url, dt.timedelta(minutes=30))
|
||||
if "token" not in event:
|
||||
event["token"] = secrets.token_hex()
|
||||
media.thumbnail = f"{url}?token={parse.quote_plus(event['token'])}"
|
||||
|
||||
if not media.can_play and not media.can_expand:
|
||||
_LOGGER.debug(
|
||||
"Camera %s with event %s without media url found", camera_id, event_id
|
||||
media_class = (
|
||||
MEDIA_CLASS_DIRECTORY
|
||||
if not event_id or "/" in event_id
|
||||
else MEDIA_CLASS_VIDEO
|
||||
)
|
||||
raise IncompatibleMediaSource
|
||||
|
||||
if not media.can_expand or no_descend:
|
||||
media = BrowseMediaSource(
|
||||
domain=self.domain,
|
||||
identifier=path,
|
||||
media_class=media_class,
|
||||
media_content_type=MEDIA_TYPE_VIDEO,
|
||||
title=title,
|
||||
can_play=not bool(media_class == MEDIA_CLASS_DIRECTORY),
|
||||
can_expand=bool(media_class == MEDIA_CLASS_DIRECTORY),
|
||||
)
|
||||
|
||||
if thumbnail:
|
||||
url = THUMBNAIL_URL.format(camera_id=camera_id, event_id=event_id)
|
||||
# cannot do authsign as we are in a websocket and isloated from auth and context
|
||||
# we will continue to use custom tokens
|
||||
# request = current_request.get()
|
||||
# refresh_token_id = request.get(KEY_HASS_REFRESH_TOKEN_ID)
|
||||
# if not refresh_token_id:
|
||||
# _LOGGER.debug("no token? %s", list(request.keys()))
|
||||
|
||||
# # leave expiration 30 seconds?
|
||||
# media.thumbnail = async_sign_path(
|
||||
# self.hass, refresh_token_id, url, dt.timedelta(seconds=30)
|
||||
# )
|
||||
media.thumbnail = f"{url}?token={self._short_security_token}"
|
||||
|
||||
if not media.can_play and not media.can_expand:
|
||||
_LOGGER.debug(
|
||||
"Camera %s with event %s without media url found",
|
||||
camera_id,
|
||||
event_id,
|
||||
)
|
||||
raise IncompatibleMediaSource
|
||||
|
||||
return media
|
||||
|
||||
media.children = []
|
||||
def create_root_children():
|
||||
nonlocal base, camera_id
|
||||
|
||||
base: ReolinkBase = None
|
||||
|
||||
if cache is None:
|
||||
for entry_id in self.hass.data[DOMAIN]:
|
||||
entry = self.hass.data[DOMAIN][entry_id]
|
||||
children = []
|
||||
data: Dict[str, dict] = self.hass.data[self.domain]
|
||||
for entry_id in data:
|
||||
entry = data[entry_id]
|
||||
if not isinstance(entry, dict) or not BASE in entry:
|
||||
continue
|
||||
base = entry[BASE]
|
||||
camera_id = base.unique_id
|
||||
cache = self.cache.get(camera_id, None)
|
||||
if cache is None:
|
||||
cache = self.cache[camera_id] = {
|
||||
"entry_id": entry_id,
|
||||
"unique_id": base.unique_id,
|
||||
"playback_events": {},
|
||||
}
|
||||
cache["name"] = base.name
|
||||
if not base.api.hdd_info:
|
||||
continue
|
||||
camera_id = entry_id
|
||||
child = create_item(None, None)
|
||||
children.append(child)
|
||||
|
||||
child = await self._async_browse_media(source, camera_id)
|
||||
media.children.append(child)
|
||||
return media
|
||||
return children
|
||||
|
||||
base = self.hass.data[DOMAIN][cache["entry_id"]][BASE]
|
||||
async def create_day_children():
|
||||
nonlocal event_id
|
||||
|
||||
# TODO: the cache is one way so over time it can grow and have invalid
|
||||
# records, the code should be expanded to invalidate/expire
|
||||
# entries
|
||||
|
||||
if base is None:
|
||||
raise BrowseError("Camera does not exist.")
|
||||
|
||||
if not start_date:
|
||||
if (
|
||||
"playback_day_entries" not in cache
|
||||
or cache.get("playback_months", -1) != base.playback_months
|
||||
):
|
||||
end_date = dt_utils.now()
|
||||
start_date = dt.datetime.combine(end_date.date(), dt.time.min)
|
||||
cache["playback_months"] = base.playback_months
|
||||
if cache["playback_months"] > 1:
|
||||
start_date -= relativedelta.relativedelta(
|
||||
months=int(cache["playback_months"])
|
||||
)
|
||||
|
||||
entries = cache["playback_day_entries"] = []
|
||||
|
||||
search, _ = await base.api.send_search(start_date, end_date, True)
|
||||
|
||||
if not search is None:
|
||||
for status in search:
|
||||
year = status["year"]
|
||||
month = status["mon"]
|
||||
for day, flag in enumerate(status["table"], start=1):
|
||||
if flag == "1":
|
||||
entries.append(dt.date(year, month, day))
|
||||
|
||||
entries.sort()
|
||||
else:
|
||||
entries = cache["playback_day_entries"]
|
||||
|
||||
for date in cache["playback_day_entries"]:
|
||||
child = await self._async_browse_media(
|
||||
source, camera_id, f"{date.year}/{date.month}/{date.day}"
|
||||
children = []
|
||||
end_date = dt_utils.now()
|
||||
start_date = dt.datetime.combine(
|
||||
end_date.date().replace(day=1), dt.time.min
|
||||
)
|
||||
if base.playback_months > 1:
|
||||
start_date -= relativedelta.relativedelta(
|
||||
months=int(base.playback_months)
|
||||
)
|
||||
media.children.append(child)
|
||||
|
||||
return media
|
||||
search, _ = await base.api.send_search(start_date, end_date, True)
|
||||
|
||||
cache["playback_thumbnails"] = base.playback_thumbnails
|
||||
if not search is None:
|
||||
for status in search:
|
||||
year = status["year"]
|
||||
month = status["mon"]
|
||||
for day, flag in enumerate(status["table"], start=1):
|
||||
if flag == "1":
|
||||
event_id = f"{year}/{month}/{day}"
|
||||
child = create_item(None, None)
|
||||
children.append(child)
|
||||
|
||||
end_date = dt.datetime.combine(
|
||||
start_date.date(), dt.time.max, start_date.tzinfo
|
||||
)
|
||||
children.reverse()
|
||||
return children
|
||||
|
||||
_, files = await base.api.send_search(start_date, end_date)
|
||||
async def create_vod_children():
|
||||
nonlocal base, start_date, event_id
|
||||
|
||||
if not files is None:
|
||||
events = cache.setdefault("playback_events", {})
|
||||
children = []
|
||||
end_date = dt.datetime.combine(
|
||||
start_date.date(), dt.time.max, start_date.tzinfo
|
||||
)
|
||||
|
||||
_, files = await base.send_search(start_date, end_date)
|
||||
|
||||
for file in files:
|
||||
dto = file["EndTime"]
|
||||
end_date = dt.datetime(
|
||||
dto["year"],
|
||||
dto["mon"],
|
||||
dto["day"],
|
||||
dto["hour"],
|
||||
dto["min"],
|
||||
dto["sec"],
|
||||
0,
|
||||
end_date.tzinfo,
|
||||
)
|
||||
dto = file["StartTime"]
|
||||
start_date = dt.datetime(
|
||||
dto["year"],
|
||||
dto["mon"],
|
||||
dto["day"],
|
||||
dto["hour"],
|
||||
dto["min"],
|
||||
dto["sec"],
|
||||
0,
|
||||
end_date.tzinfo,
|
||||
)
|
||||
end_date = searchtime_to_datetime(file["EndTime"], end_date.tzinfo)
|
||||
start_date = searchtime_to_datetime(file["StartTime"], end_date.tzinfo)
|
||||
event_id = str(start_date.timestamp())
|
||||
event = events.setdefault(event_id, {})
|
||||
event["start"] = start_date
|
||||
event["end"] = end_date
|
||||
event["file"] = file["name"]
|
||||
evt_id = f"{camera_id}/{quote_plus(file['name'])}"
|
||||
# self._file_cache[evt_id] = file["name"]
|
||||
thumbnail = os.path.isfile(
|
||||
f"{base.thumbnail_path}/{event_id}.{EXTENSION}"
|
||||
)
|
||||
|
||||
child = await self._async_browse_media(source, camera_id, event_id)
|
||||
media.children.append(child)
|
||||
time = start_date.time()
|
||||
duration = end_date - start_date
|
||||
child = create_item(
|
||||
f"{time} {duration}", f"{source}/{evt_id}", thumbnail
|
||||
)
|
||||
children.append(child)
|
||||
|
||||
children.reverse()
|
||||
|
||||
return children
|
||||
|
||||
if base and event_id and not "/" in event_id:
|
||||
event = base.in_memory_events[event_id]
|
||||
start_date = event.start
|
||||
|
||||
media = create_item(None, None)
|
||||
|
||||
if not media.can_expand:
|
||||
return media
|
||||
|
||||
if not camera_id:
|
||||
media.children = create_root_children()
|
||||
return media
|
||||
|
||||
if not start_date:
|
||||
media.children = await create_day_children()
|
||||
else:
|
||||
media.children = await create_vod_children()
|
||||
|
||||
return media
|
||||
|
||||
|
||||
class ReolinkSourceThumbnailView(HomeAssistantView):
|
||||
""" Thumbnial view handler """
|
||||
class ReolinkSourceVODView(HomeAssistantView):
|
||||
""" VOD security handler """
|
||||
|
||||
url = "/api/" + DOMAIN + "/media_proxy/{camera_id}/{event_id}"
|
||||
name = "api:" + DOMAIN + ":image"
|
||||
requires_auth = False
|
||||
url = VOD_URL
|
||||
name = "api:" + DOMAIN + ":video"
|
||||
cors_allowed = True
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, source: ReolinkSource):
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
"""Initialize media view """
|
||||
|
||||
self.hass = hass
|
||||
self.source = source
|
||||
|
||||
async def get(
|
||||
self, request: web.Request, camera_id: str, event_id: str
|
||||
) -> web.Response:
|
||||
""" start a GET request. """
|
||||
|
||||
authenticated = request.get(KEY_AUTHENTICATED, False)
|
||||
if not authenticated:
|
||||
token: str = request.query.get("token")
|
||||
if not token:
|
||||
raise web.HTTPUnauthorized()
|
||||
|
||||
data: dict = self.hass.data.get(DOMAIN_DATA)
|
||||
data = data.get(MEDIA_SOURCE) if data else None
|
||||
tokens: List[str] = data.get(LONG_TOKENS) if data else None
|
||||
if not tokens or not token in tokens:
|
||||
raise web.HTTPUnauthorized()
|
||||
|
||||
if not camera_id or not event_id:
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
cache: typings.MediaSourceCacheEntry = self.source.cache.get(camera_id, None)
|
||||
if cache is None or "playback_events" not in cache:
|
||||
data: Dict[str, dict] = self.hass.data[DOMAIN]
|
||||
base: ReolinkBase = (
|
||||
data[camera_id].get(BASE, None) if camera_id in data else None
|
||||
)
|
||||
if not base:
|
||||
_LOGGER.debug("camera %s not found", camera_id)
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
event = cache["playback_events"].get(event_id, None)
|
||||
if event is None:
|
||||
_LOGGER.debug("camera %s, event %s not found", camera_id, event_id)
|
||||
file = unquote_plus(event_id)
|
||||
url = await base.api.get_vod_source(file)
|
||||
return web.HTTPTemporaryRedirect(url)
|
||||
|
||||
|
||||
class ReolinkSourceThumbnailView(HomeAssistantView):
|
||||
""" Thumbnial view handler """
|
||||
|
||||
url = THUMBNAIL_URL
|
||||
name = "api:" + DOMAIN + ":image"
|
||||
cors_allowed = True
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
"""Initialize media view """
|
||||
|
||||
self.hass = hass
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request, # pylint: disable=unused-argument
|
||||
camera_id: str,
|
||||
event_id: str,
|
||||
) -> web.Response:
|
||||
""" start a GET request. """
|
||||
|
||||
authenticated = request.get(KEY_AUTHENTICATED, False)
|
||||
if not authenticated:
|
||||
token: str = request.query.get("token")
|
||||
if not token:
|
||||
raise web.HTTPUnauthorized()
|
||||
|
||||
data: dict = self.hass.data.get(DOMAIN_DATA)
|
||||
data = data.get(MEDIA_SOURCE) if data else None
|
||||
tokens: List[str] = data.get(SHORT_TOKENS) if data else None
|
||||
if not tokens or not token in tokens:
|
||||
raise web.HTTPUnauthorized()
|
||||
|
||||
if not camera_id or not event_id:
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
token = request.query.get("token")
|
||||
if (token and event.get("token") != token) or (
|
||||
not token and not self.requires_auth
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"invalid or missing token %s for camera %s, event %s",
|
||||
token,
|
||||
camera_id,
|
||||
event_id,
|
||||
)
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
_LOGGER.debug("thumbnail %s, %s", camera_id, event_id)
|
||||
|
||||
base: ReolinkBase = self.hass.data[DOMAIN][cache["entry_id"]][BASE]
|
||||
|
||||
image = event.get("thumbnail", None)
|
||||
if (
|
||||
image is None
|
||||
or cache.get("playback_thumbnail_offset", DEFAULT_THUMBNAIL_OFFSET)
|
||||
!= base.playback_thumbnail_offset
|
||||
):
|
||||
cache["playback_thumbnails"] = base.playback_thumbnails
|
||||
cache["playback_thumbnail_offset"] = base.playback_thumbnail_offset
|
||||
|
||||
if not cache["playback_thumbnails"]:
|
||||
_LOGGER.debug("Thumbnails not allowed on camera %s", camera_id)
|
||||
raise web.HTTPInternalServerError()
|
||||
|
||||
_LOGGER.debug("generating thumbnail for %s, %s", camera_id, event_id)
|
||||
|
||||
extra_cmd: str = None
|
||||
if cache["playback_thumbnail_offset"] > 0:
|
||||
extra_cmd = f"-ss {cache['playback_thumbnail_offset']}"
|
||||
|
||||
image = event["thumbail"] = await async_get_image(
|
||||
self.hass,
|
||||
await base.api.get_vod_source(event["file"]),
|
||||
extra_cmd=extra_cmd,
|
||||
)
|
||||
_LOGGER.debug("generated thumbnail for %s, %s", camera_id, event_id)
|
||||
|
||||
if image:
|
||||
return web.Response(body=image, content_type=IMAGE_JPEG)
|
||||
|
||||
_LOGGER.debug(
|
||||
"No thumbnail generated for camera %s, event %s", camera_id, event_id
|
||||
data: Dict[str, dict] = self.hass.data[DOMAIN]
|
||||
base: ReolinkBase = (
|
||||
data[camera_id].get(BASE, None) if camera_id in data else None
|
||||
)
|
||||
raise web.HTTPInternalServerError()
|
||||
if not base:
|
||||
_LOGGER.debug("camera %s not found", camera_id)
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
thumbnail = f"{base.thumbnail_path}/{event_id}.{EXTENSION}"
|
||||
return web.FileResponse(thumbnail)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
222
custom_components/reolink_dev/sensor.py
Ficheiro normal
222
custom_components/reolink_dev/sensor.py
Ficheiro normal
@@ -0,0 +1,222 @@
|
||||
"""This component provides support for Reolink IP VoD support."""
|
||||
from urllib.parse import quote_plus
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dateutil import relativedelta
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
import homeassistant.util.dt as dt_utils
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP, SensorEntity
|
||||
|
||||
from .const import (
|
||||
BASE,
|
||||
DOMAIN,
|
||||
DOMAIN_DATA,
|
||||
LAST_EVENT,
|
||||
THUMBNAIL_EXTENSION,
|
||||
THUMBNAIL_URL,
|
||||
VOD_URL,
|
||||
)
|
||||
from .entity import ReolinkEntity
|
||||
from .base import ReolinkBase, searchtime_to_datetime
|
||||
from .typings import VoDEvent, VoDEventThumbnail
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the Reolink IP Camera switches."""
|
||||
devices = []
|
||||
base: ReolinkBase = hass.data[DOMAIN][config_entry.entry_id][BASE]
|
||||
|
||||
# TODO : add playback (based off of hdd_info) to api capabilities
|
||||
await base.api.get_switch_capabilities()
|
||||
if base.api.hdd_info:
|
||||
devices.append(LastEventSensor(hass, config_entry))
|
||||
|
||||
async_add_devices(devices, update_before_add=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Attrs:
|
||||
oldest_day: dt.datetime = None
|
||||
most_recent_day: dt.datetime = None
|
||||
last_event: VoDEvent = None
|
||||
|
||||
|
||||
class LastEventSensor(ReolinkEntity, SensorEntity):
|
||||
"""An implementation of a Reolink IP camera sensor."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConfigEntry):
|
||||
"""Initialize a Reolink camera."""
|
||||
ReolinkEntity.__init__(self, hass, config)
|
||||
SensorEntity.__init__(self)
|
||||
self._attrs = _Attrs()
|
||||
self._bus_listener: CALLBACK_TYPE = None
|
||||
self._entry_id = config.entry_id
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Entity created."""
|
||||
await super().async_added_to_hass()
|
||||
self._bus_listener = self.hass.bus.async_listen(
|
||||
self._base.event_id, self.handle_event
|
||||
)
|
||||
self._hass.async_add_job(self._update_event_range)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Entity removed"""
|
||||
if self._bus_listener:
|
||||
self._bus_listener()
|
||||
self._bus_listener = None
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
async def request_refresh(self):
|
||||
""" force an update of the sensor """
|
||||
await super().request_refresh()
|
||||
self._hass.async_add_job(self._update_event_range)
|
||||
|
||||
async def async_update(self):
|
||||
""" polling update """
|
||||
await super().async_update()
|
||||
self._hass.async_add_job(self._update_event_range)
|
||||
|
||||
async def _update_event_range(self):
|
||||
end = dt_utils.now()
|
||||
start = self._attrs.most_recent_day
|
||||
if not start:
|
||||
start = dt.datetime.combine(end.date().replace(day=1), dt.time.min)
|
||||
if self._base.playback_months > 1:
|
||||
start -= relativedelta.relativedelta(
|
||||
months=int(self._base.playback_months)
|
||||
)
|
||||
search, _ = await self._base.send_search(start, end, True)
|
||||
if not search or len(search) < 1:
|
||||
return
|
||||
entry = search[0]
|
||||
self._attrs.oldest_day = dt.datetime(
|
||||
entry["year"],
|
||||
entry["mon"],
|
||||
next((i for (i, e) in enumerate(entry["table"], start=1) if e == "1")),
|
||||
tzinfo=end.tzinfo,
|
||||
)
|
||||
entry = search[-1]
|
||||
start = self._attrs.most_recent_day = dt.datetime(
|
||||
entry["year"],
|
||||
entry["mon"],
|
||||
len(entry["table"])
|
||||
- next(
|
||||
(
|
||||
i
|
||||
for (i, e) in enumerate(reversed(entry["table"]), start=0)
|
||||
if e == "1"
|
||||
)
|
||||
),
|
||||
tzinfo=end.tzinfo,
|
||||
)
|
||||
end = dt.datetime.combine(start.date(), dt.time.max, tzinfo=end.tzinfo)
|
||||
_, files = await self._base.send_search(start, end)
|
||||
file = files[-1] if files and len(files) > 0 else None
|
||||
if not file:
|
||||
return
|
||||
|
||||
end = searchtime_to_datetime(file["EndTime"], start.tzinfo)
|
||||
start = searchtime_to_datetime(file["StartTime"], end.tzinfo)
|
||||
last = self._attrs.last_event = VoDEvent(
|
||||
str(start.timestamp()),
|
||||
start,
|
||||
end - start,
|
||||
file["name"],
|
||||
)
|
||||
last.url = VOD_URL.format(
|
||||
camera_id=self._entry_id, event_id=quote_plus(file["name"])
|
||||
)
|
||||
thumbnail = last.thumbnail = VoDEventThumbnail(
|
||||
THUMBNAIL_URL.format(camera_id=self._entry_id, event_id=last.event_id),
|
||||
path=os.path.join(
|
||||
self._base.thumbnail_path, f"{last.event_id}.{THUMBNAIL_EXTENSION}"
|
||||
),
|
||||
)
|
||||
thumbnail.exists = os.path.isfile(thumbnail.path)
|
||||
data: dict = self._hass.data.setdefault(DOMAIN_DATA, {})
|
||||
data = data.setdefault(self._base.unique_id, {})
|
||||
data[LAST_EVENT] = last
|
||||
self._state = True
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def handle_event(self, event):
|
||||
"""Handle incoming event for VoD update"""
|
||||
|
||||
if not "motion" in event.data:
|
||||
return
|
||||
|
||||
self._hass.async_add_job(self._update_event_range)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return Unique ID string."""
|
||||
return f"reolink_lastevent_{self._base.unique_id}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this sensor."""
|
||||
return f"{self._base.name} Last Event"
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Device class of the sensor."""
|
||||
return DEVICE_CLASS_TIMESTAMP
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
if not self._state:
|
||||
return None
|
||||
|
||||
date = (
|
||||
self._attrs.last_event.start
|
||||
if self._attrs.last_event and self._attrs.last_event.start
|
||||
else None
|
||||
)
|
||||
if not date:
|
||||
return None
|
||||
|
||||
return date.isoformat()
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Icon of the sensor."""
|
||||
return "mdi:history"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attrs = super().extra_state_attributes
|
||||
|
||||
if self._state:
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
|
||||
if self._attrs.oldest_day:
|
||||
attrs["oldest_day"] = self._attrs.oldest_day.isoformat()
|
||||
if self._attrs.last_event:
|
||||
if self._attrs.last_event.event_id:
|
||||
attrs["vod_event_id"] = self._attrs.last_event.event_id
|
||||
if self._attrs.last_event.thumbnail:
|
||||
attrs["has_thumbnail"] = (
|
||||
"true"
|
||||
if self._attrs.last_event.thumbnail.exists
|
||||
else "false"
|
||||
)
|
||||
|
||||
attrs["thumbnail_path"] = self._attrs.last_event.thumbnail.path
|
||||
if self._attrs.last_event.duration:
|
||||
attrs["duration"] = str(self._attrs.last_event.duration)
|
||||
|
||||
return attrs
|
||||
@@ -62,10 +62,10 @@ set_daynight:
|
||||
|
||||
set_backlight:
|
||||
name: Set backlight
|
||||
description: >-
|
||||
Optimizing brightness and contrast levels to compensate for differences
|
||||
between dark and bright objects using either BLC or WDR mode.
|
||||
This may improve image clarity in high contrast situations,
|
||||
description: >-
|
||||
Optimizing brightness and contrast levels to compensate for differences
|
||||
between dark and bright objects using either BLC or WDR mode.
|
||||
This may improve image clarity in high contrast situations,
|
||||
but it should be tested at different times of the day and night to ensure there is no negative effect.
|
||||
target:
|
||||
entity:
|
||||
@@ -82,3 +82,73 @@ set_backlight:
|
||||
DYNAMICRANGECONTROL: use Dynamic Range Control
|
||||
OFF: no optimization
|
||||
example: DYNAMICRANGECONTROL
|
||||
|
||||
commit_thumbnails:
|
||||
name: Commit In-Memory Playback Thumbnails
|
||||
description: >-
|
||||
For cameras that have Video-On-Demand Playback capability, the system will capture
|
||||
thumbnails of motion events and hold them in memory until they are matched with
|
||||
recodings on the camera, this only happens automatically when using the media browser.
|
||||
This service allows this matching to occur via script or automation as well
|
||||
target:
|
||||
entity:
|
||||
integration: reolink_dev
|
||||
domain: camera
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the Reolink camera entity to execute the command on.
|
||||
example: 'camera.frontdoor'
|
||||
start:
|
||||
description: >-
|
||||
Start of date range, if not provided will use the first unmatched thumbnail in memory
|
||||
example: "1/1/2021"
|
||||
end:
|
||||
description: >-
|
||||
End of date range, if not provided will use the current date and time
|
||||
example: "1/31/2021"
|
||||
|
||||
cleanup_thumbnails:
|
||||
name: Cleanup Camera VoD playback thumbnails
|
||||
description: >-
|
||||
For cameras that have Video-On-Demand Playback capability, this will attempt to remove
|
||||
thumbnails for VoDs that are no longer present on the camera, freeing up space on your
|
||||
Home Assistant install.
|
||||
target:
|
||||
entity:
|
||||
integration: reolink_dev
|
||||
domain: camera
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the Reolink camera entity to execute the command on.
|
||||
example: 'camera.frontdoor'
|
||||
older_than:
|
||||
description: >-
|
||||
If provide will remove all thumbnails older than the specified date, irregardless
|
||||
of matching VoD
|
||||
example: "1/1/2021"
|
||||
|
||||
query_vods:
|
||||
name: Query Camera for VoD playbacks
|
||||
description: >-
|
||||
For cameras that have Video-On-Demand Playback capability, this will query the camera
|
||||
and emit an reolink_dev-vod-data event for each matching VoD that matches the search
|
||||
parameters, it will also provide the thumbail path for the expected thumbnail.
|
||||
target:
|
||||
entity:
|
||||
integration: reolink_dev
|
||||
domain: camera
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the Reolink camera entity to execute the command on.
|
||||
example: 'camera.frontdoor'
|
||||
event_id:
|
||||
description: Event to emit as
|
||||
example: 'VoD-query'
|
||||
start:
|
||||
description: >-
|
||||
Start of date range, if not provided will use the month playback range
|
||||
example: "1/1/2021"
|
||||
end:
|
||||
description: >-
|
||||
End of date range, if not provided will use the current date and time
|
||||
example: "1/31/2021"
|
||||
|
||||
@@ -33,10 +33,21 @@
|
||||
"timeout": "Timeout",
|
||||
"motion_off_delay": "Motion sensor off delay (seconds)",
|
||||
"playback_months": "Playback range (months)",
|
||||
"playback_thumbnails": "Create thumbnails for playback items",
|
||||
"playback_thumbnail_offset": "Pre-Record offset (seconds) for thumbnail"
|
||||
"playback_thumbnail_path": "Custom thumbnail path"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"trigger_type": {
|
||||
"new_vod": "New motion video detected"
|
||||
},
|
||||
"action_type": {
|
||||
"capture_vod_thumbnail": "Save snapshot as motion thumbnail"
|
||||
},
|
||||
"condition_type": {
|
||||
"vod_no_thumbnail": "Latest motion video has no thumbnail",
|
||||
"vod_has_thumbnail": "Latest motion video has a thumbnail"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ from .entity import ReolinkEntity
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the Reolink IP Camera switches."""
|
||||
devices = []
|
||||
|
||||
@@ -34,9 +34,21 @@
|
||||
"motion_off_delay": "Motion sensor off delay (seconds)",
|
||||
"playback_months": "Playback range (months)",
|
||||
"playback_thumbnails": "Create thumbnails for playback items",
|
||||
"playback_thumbnail_offset": "Pre-Record offset (seconds) for thumbnail"
|
||||
"playback_thumbnail_path": "Custom thumbnail path"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"trigger_type": {
|
||||
"new_vod": "New motion video detected"
|
||||
},
|
||||
"action_type": {
|
||||
"capture_vod_thumbnail": "Save snapshot as motion thumbnail"
|
||||
},
|
||||
"condition_type": {
|
||||
"vod_no_thumbnail": "Latest motion video has no thumbnail",
|
||||
"vod_has_thumbnail": "Latest motion video has a thumbnail"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,25 @@
|
||||
""" Typing declarations for strongly typed dictionaries """
|
||||
""" Typing Definitions """
|
||||
|
||||
from typing import Any, Dict, List, TypedDict
|
||||
from datetime import datetime, date
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
VodEvent = TypedDict(
|
||||
"VodEvent",
|
||||
{
|
||||
"start": datetime,
|
||||
"end": datetime,
|
||||
"file": str,
|
||||
"thumbnail": Any,
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
MediaSourceCacheEntry = TypedDict(
|
||||
"MediaSourceCacheEntry",
|
||||
{
|
||||
"entry_id": str,
|
||||
"unique_id": str,
|
||||
"event_id": str,
|
||||
"name": str,
|
||||
"playback_months": int,
|
||||
"playback_thumbnails": bool,
|
||||
"playback_thumbnail_offset": int,
|
||||
"playback_day_entries": List[date],
|
||||
"playback_events": Dict[str, VodEvent],
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
@dataclass
|
||||
class VoDEventThumbnail:
|
||||
""" VoD Event Thumbnail """
|
||||
|
||||
url: str = None
|
||||
exists: bool = None
|
||||
path: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoDEvent:
|
||||
""" VoD Event """
|
||||
|
||||
event_id: str = None
|
||||
start: datetime = None
|
||||
duration: timedelta = None
|
||||
file: str = None
|
||||
url: str = None
|
||||
thumbnail: VoDEventThumbnail = None
|
||||
|
||||
29
custom_components/reolink_dev/utils.py
Ficheiro normal
29
custom_components/reolink_dev/utils.py
Ficheiro normal
@@ -0,0 +1,29 @@
|
||||
""" Utility functions """
|
||||
|
||||
from typing import Union
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.device_registry import DeviceEntry, DeviceRegistry
|
||||
|
||||
|
||||
async def async_get_device_entries(
|
||||
hass: HomeAssistant, device: Union[str, DeviceEntry]
|
||||
):
|
||||
""" Get entires for the device """
|
||||
|
||||
registry = await entity_registry.async_get_registry(hass)
|
||||
if isinstance(device, str):
|
||||
device_registry: DeviceRegistry = (
|
||||
await hass.helpers.device_registry.async_get_registry()
|
||||
)
|
||||
device_entry = device_registry.async_get(device)
|
||||
else:
|
||||
device_entry = device
|
||||
|
||||
entries = (
|
||||
entity_registry.async_entries_for_device(registry, device_entry.id)
|
||||
if device_entry
|
||||
else None
|
||||
)
|
||||
|
||||
return (device_entry, entries)
|
||||
Criar uma nova questão referindo esta
Bloquear um utilizador