1
1

Update custom components

Este cometimento está contido em:
Florian Brinker
2021-08-28 21:21:19 +02:00
ascendente 4b4a1af316
cometimento 10c34b84f1
1658 ficheiros modificados com 3572 adições e 701 eliminações

Ver ficheiro

@@ -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

Ver ficheiro

@@ -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,
)

Ver ficheiro

@@ -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

Ver ficheiro

@@ -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()

Ver ficheiro

@@ -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(

Ver ficheiro

@@ -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}"

Ver ficheiro

@@ -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,
)

Ver ficheiro

@@ -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

Ver ficheiro

@@ -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],
)

Ver ficheiro

@@ -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

Ver ficheiro

@@ -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",

Ver ficheiro

@@ -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

Ver ficheiro

@@ -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

Ver ficheiro

@@ -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"

Ver ficheiro

@@ -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"
}
}
}

Ver ficheiro

@@ -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 = []

Ver ficheiro

@@ -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"
}
}
}

Ver ficheiro

@@ -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

Ver ficheiro

@@ -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)