Update HA, add vacuum, grid cards, fixes

Tá an tiomantas seo le fáil i:
Florian Brinker
2021-03-21 18:41:41 +01:00
tuismitheoir 53c99e4509
tiomantas fb3af71fe3
D'athraigh 122 comhad le 12940 breiseanna agus 1532 scriosta

Féach ar an gComhad

@@ -1 +0,0 @@
"""Reolink Camera component for HomeAssistant."""

Féach ar an gComhad

@@ -1,278 +0,0 @@
"""
Reolink Camera API
"""
import requests
import datetime
import json
import logging
_LOGGER = logging.getLogger(__name__)
class ReolinkApi(object):
def __init__(self, ip, channel):
self._url = "http://" + ip + "/cgi-bin/api.cgi"
self._ip = ip
self._channel = channel
self._token = None
self._motion_state = False
self._last_motion = 0
self._device_info = None
self._motion_state = None
self._ftp_state = None
self._email_state = None
self._ir_state = None
self._rtspport = None
self._rtmpport = None
self._ptzpresets = dict()
def status(self):
if self._token is None:
return
param_channel = {"channel": self._channel}
body = [{"cmd": "GetDevInfo", "action":1, "param": param_channel},
{"cmd": "GetNetPort", "action": 1, "param": param_channel},
{"cmd": "GetFtp", "action": 1, "param": param_channel},
{"cmd": "GetEmail", "action": 1, "param": param_channel},
{"cmd": "GetIrLights", "action": 1, "param": param_channel},
{"cmd": "GetPtzPreset", "action": 1, "param": param_channel}]
param = {"token": self._token}
response = self.send(body, param)
try:
json_data = json.loads(response.text)
except:
_LOGGER.error(f"Error translating response to json")
return
for data in json_data:
try:
if data["cmd"] == "GetDevInfo":
self._device_info = data
elif data["cmd"] == "GetNetPort":
self._netport_settings = data
self._rtspport = data["value"]["NetPort"]["rtspPort"]
self._rtmpport = data["value"]["NetPort"]["rtmpPort"]
elif data["cmd"] == "GetFtp":
self._ftp_settings = data
if (data["value"]["Ftp"]["schedule"]["enable"] == 1):
self._ftp_state = True
else:
self._ftp_state = False
elif data["cmd"] == "GetEmail":
self._email_settings = data
if (data["value"]["Email"]["schedule"]["enable"] == 1):
self._email_state = True
else:
self._email_state = False
elif data["cmd"] == "GetIrLights":
self._ir_settings = data
if (data["value"]["IrLights"]["state"] == "Auto"):
self._ir_state = True
else:
self._ir_state = False
elif data["cmd"] == "GetPtzPreset":
self._ptzpresets_settings = data
for preset in data["value"]["PtzPreset"]:
if int(preset["enable"]) == 1:
preset_name = preset["name"]
preset_id = int(preset["id"])
self._ptzpresets[preset_name] = preset_id
_LOGGER.debug(f"Got preset {preset_name} with ID {preset_id}")
else:
_LOGGER.debug(f"Preset is not enabled: {preset}")
except:
continue
@property
def motion_state(self):
body = [{"cmd": "GetMdState", "action": 0, "param":{"channel":self._channel}}]
param = {"token": self._token}
response = self.send(body, param)
try:
json_data = json.loads(response.text)
if json_data is None:
_LOGGER.error(f"Unable to get Motion detection state at IP {self._ip}")
self._motion_state = False
return self._motion_state
if json_data[0]["value"]["state"] == 1:
self._motion_state = True
self._last_motion = datetime.datetime.now()
else:
self._motion_state = False
except:
self._motion_state = False
return self._motion_state
@property
def still_image(self):
response = self.send(None, f"?cmd=Snap&channel={self._channel}&token={self._token}", stream=True)
response.raw.decode_content = True
return response.raw
@property
def snapshot(self):
response = self.send(None, f"?cmd=Snap&channel={self._channel}&token={self._token}", stream=False)
return response.content
@property
def ftp_state(self):
return self._ftp_state
@property
def email_state(self):
return self._email_state
@property
def ir_state(self):
return self._ir_state
@property
def rtmpport(self):
return self._rtmpport
@property
def rtspport(self):
return self._rtspport
@property
def last_motion(self):
return self._last_motion
@property
def ptzpresets(self):
return self._ptzpresets
def login(self, username, password):
body = [{"cmd": "Login", "action": 0, "param": {"User": {"userName": username, "password": password}}}]
param = {"cmd": "Login", "token": "null"}
response = self.send(body, param)
try:
json_data = json.loads(response.text)
except:
_LOGGER.error(f"Error translating login response to json")
return
if json_data is not None:
if json_data[0]["code"] == 0:
self._token = json_data[0]["value"]["Token"]["name"]
_LOGGER.info(f"Reolink camera logged in at IP {self._ip}")
else:
_LOGGER.error(f"Failed to login at IP {self._ip}. No token available")
else:
_LOGGER.error(f"Failed to login at IP {self._ip}. Connection error.")
def logout(self):
body = [{"cmd":"Logout","action":0,"param":{}}]
param = {"cmd": "Logout", "token": self._token}
self.send(body, param)
def set_ftp(self, enabled):
self.status()
if not self._ftp_settings:
_LOGGER.error("Error while fetching current FTP settings")
return
if enabled == True:
newValue = 1
else:
newValue = 0
body = [{"cmd":"SetFtp","action":0,"param": self._ftp_settings["value"] }]
body[0]["param"]["Ftp"]["schedule"]["enable"] = newValue
response = self.send(body, {"cmd": "SetFtp", "token": self._token} )
try:
json_data = json.loads(response.text)
if json_data[0]["value"]["rspCode"] == 200:
return True
else:
return False
except:
_LOGGER.error(f"Error translating FTP response to json")
return False
def set_email(self, enabled):
self.status()
if not self._email_settings:
_LOGGER.error("Error while fetching current email settings")
return
if enabled == True:
newValue = 1
else:
newValue = 0
body = [{"cmd":"SetEmail","action":0,"param": self._email_settings["value"] }]
body[0]["param"]["Email"]["schedule"]["enable"] = newValue
response = self.send(body, {"cmd": "SetEmail", "token": self._token} )
try:
json_data = json.loads(response.text)
if json_data[0]["value"]["rspCode"] == 200:
return True
else:
return False
except:
_LOGGER.error(f"Error translating Email response to json")
return False
def set_ir_lights(self, enabled):
self.status()
if not self._ir_settings:
_LOGGER.error("Error while fetching current IR light settings")
return
if enabled == True:
newValue = "Auto"
else:
newValue = "Off"
body = [{"cmd":"SetIrLights","action":0,"param": self._ir_settings["value"] }]
body[0]["param"]["IrLights"]["state"] = newValue
response = self.send(body, {"cmd": "SetIrLights", "token": self._token} )
try:
json_data = json.loads(response.text)
if json_data[0]["value"]["rspCode"] == 200:
return True
else:
return False
except requests.exceptions.RequestException:
_LOGGER.error(f"Error translating IR Lights response to json")
return False
def send(self, body, param, stream=False):
try:
if (self._token is None and
(body is None or body[0]["cmd"] != "Login")):
_LOGGER.info(f"Reolink camera at IP {self._ip} is not logged in")
return
if body is None:
response = requests.get(self._url, params=param, stream=stream)
else:
response = requests.post(self._url, data=json.dumps(body), params=param)
return response
except Exception:
_LOGGER.error(f"Exception while calling Reolink camera API at ip {self._ip}")
return None

Féach ar an gComhad

@@ -1 +1,160 @@
"""Reolink Camera component for HomeAssistant."""
"""Reolink integration for HomeAssistant."""
import asyncio
from datetime import timedelta
import logging
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_TIMEOUT,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .base import ReolinkBase, ReolinkPush
from .const import (
BASE,
CONF_CHANNEL,
CONF_MOTION_OFF_DELAY,
CONF_PLAYBACK_MONTHS,
CONF_PLAYBACK_THUMBNAILS,
CONF_PROTOCOL,
CONF_STREAM,
CONF_THUMBNAIL_OFFSET,
COORDINATOR,
DEFAULT_PLAYBACK_THUMBNAILS,
DEFAULT_THUMBNAIL_OFFSET,
DOMAIN,
EVENT_DATA_RECEIVED,
PUSH_MANAGER,
SERVICE_PTZ_CONTROL,
SERVICE_SET_DAYNIGHT,
SERVICE_SET_SENSITIVITY,
)
SCAN_INTERVAL = timedelta(minutes=1)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["camera", "switch", "binary_sensor"]
async def async_setup(
hass: HomeAssistant, config: dict
): # pylint: disable=unused-argument
"""Set up the Reolink component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Reolink from a config entry."""
hass.data.setdefault(DOMAIN, {})
base = ReolinkBase(hass, entry.data, entry.options)
base.sync_functions.append(entry.add_update_listener(update_listener))
if not await base.connect_api():
return False
hass.data[DOMAIN][entry.entry_id] = {BASE: base}
try:
"""Get a push manager, there should be one push manager per mac address"""
push = hass.data[DOMAIN][base.push_manager]
except KeyError:
push = ReolinkPush(
hass,
base.api.host,
base.api.onvif_port,
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
await push.subscribe(base.event_id)
hass.data[DOMAIN][base.push_manager] = push
async def async_update_data():
"""Perform the actual updates."""
async with async_timeout.timeout(base.timeout):
await push.renew()
await base.update_states()
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="reolink",
update_method=async_update_data,
update_interval=SCAN_INTERVAL,
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
hass.data[DOMAIN][entry.entry_id][COORDINATOR] = coordinator
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, base.stop())
return True
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Update the configuration at the base entity and API."""
base: ReolinkBase = hass.data[DOMAIN][entry.entry_id][BASE]
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
)
await base.set_timeout(entry.options[CONF_TIMEOUT])
await base.set_protocol(entry.options[CONF_PROTOCOL])
await base.set_stream(entry.options[CONF_STREAM])
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
base = hass.data[DOMAIN][entry.entry_id][BASE]
push = hass.data[DOMAIN][base.push_manager]
if not await push.count_members() > 1:
await push.unsubscribe()
hass.data[DOMAIN].pop(base.push_manager)
await base.stop()
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
if len(hass.data[DOMAIN]) == 0:
hass.services.async_remove(DOMAIN, SERVICE_PTZ_CONTROL)
hass.services.async_remove(DOMAIN, SERVICE_SET_DAYNIGHT)
hass.services.async_remove(DOMAIN, SERVICE_SET_SENSITIVITY)
return unload_ok

Féach ar an gComhad

@@ -0,0 +1,366 @@
"""This component updates the camera API and subscription."""
import logging
import re
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_TIMEOUT,
CONF_USERNAME,
)
from homeassistant.core import 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 reolink.camera_api import Api
from reolink.subscription_manager import Manager
from .const import (
BASE,
CONF_PLAYBACK_MONTHS,
CONF_PLAYBACK_THUMBNAILS,
CONF_THUMBNAIL_OFFSET,
DEFAULT_PLAYBACK_MONTHS,
DEFAULT_PLAYBACK_THUMBNAILS,
DEFAULT_THUMBNAIL_OFFSET,
EVENT_DATA_RECEIVED,
CONF_CHANNEL,
CONF_MOTION_OFF_DELAY,
CONF_PROTOCOL,
CONF_STREAM,
DEFAULT_CHANNEL,
DEFAULT_MOTION_OFF_DELAY,
DEFAULT_PROTOCOL,
DEFAULT_STREAM,
DEFAULT_TIMEOUT,
DOMAIN,
PUSH_MANAGER,
SESSION_RENEW_THRESHOLD,
)
_LOGGER = logging.getLogger(__name__)
class ReolinkBase:
"""The implementation of the Reolink IP base class."""
def __init__(
self, hass: HomeAssistant, config: dict, options: dict
): # pylint: disable=too-many-arguments
"""Initialize a Reolink camera."""
self._username = config[CONF_USERNAME]
self._password = config[CONF_PASSWORD]
if CONF_CHANNEL not in config:
self._channel = DEFAULT_CHANNEL
else:
self._channel = config[CONF_CHANNEL]
if CONF_TIMEOUT not in options:
self._timeout = DEFAULT_TIMEOUT
else:
self._timeout = options[CONF_TIMEOUT]
if CONF_STREAM not in options:
self._stream = DEFAULT_STREAM
else:
self._stream = options[CONF_STREAM]
if CONF_PROTOCOL not in options:
self._protocol = DEFAULT_PROTOCOL
else:
self._protocol = options[CONF_PROTOCOL]
self._api = Api(
config[CONF_HOST],
config[CONF_PORT],
self._username,
self._password,
channel=self._channel - 1,
stream=self._stream,
protocol=self._protocol,
timeout=self._timeout,
)
self._hass = hass
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]
if CONF_PLAYBACK_MONTHS not in options:
self.playback_months = DEFAULT_PLAYBACK_MONTHS
else:
self.playback_months = options[CONF_PLAYBACK_MONTHS]
if CONF_PLAYBACK_THUMBNAILS not in options:
self.playback_thumbnails = DEFAULT_PLAYBACK_THUMBNAILS
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]
@property
def name(self):
"""Create the device name."""
return self._api.name
@property
def unique_id(self):
"""Create the unique ID, base for all entities."""
id = self._api.mac_address.replace(":", "")
return f"{id}-{self.channel}"
@property
def event_id(self):
"""Create the event ID string."""
event_id = self._api.mac_address.replace(":", "")
return f"{EVENT_DATA_RECEIVED}-{event_id}"
@property
def push_manager(self):
"""Create the event ID string."""
push_id = self._api.mac_address.replace(":", "")
return f"{PUSH_MANAGER}-{push_id}"
@property
def timeout(self):
"""Return the timeout setting."""
return self._timeout
@property
def channel(self):
"""Return the channel setting."""
return self._channel
@property
def api(self):
"""Return the API object."""
return self._api
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.is_admin()
return True
async def set_channel(self, channel):
"""Set the API channel."""
self._channel = channel
await self._api.set_channel(channel - 1)
async def set_protocol(self, protocol):
"""Set the protocol."""
self._protocol = protocol
await self._api.set_protocol(protocol)
async def set_stream(self, stream):
"""Set the stream."""
self._stream = stream
await self._api.set_stream(stream)
async def set_timeout(self, timeout):
"""Set the API timeout."""
self._timeout = timeout
await self._api.set_timeout(timeout)
async def update_states(self):
"""Call the API of the camera device to update the states."""
await self._api.get_states()
async def update_settings(self):
"""Call the API of the camera device to update the settings."""
await self._api.get_settings()
async def disconnect_api(self):
"""Disconnect from the API, so the connection will be released."""
await self._api.logout()
async def stop(self):
"""Disconnect the API and deregister the event listener."""
await self.disconnect_api()
for func in self.sync_functions:
await self._hass.async_add_executor_job(func)
class ReolinkPush:
"""The implementation of the Reolink IP base class."""
def __init__(
self, hass: HomeAssistant, host, port, username, password
): # pylint: disable=too-many-arguments
"""Initialize a Reolink camera."""
self._host = host
self._port = port
self._username = username
self._password = password
self._hass = hass
self._sman = None
self._webhook_url = None
self._webhook_id = None
self._event_id = None
@property
def sman(self):
"""Return the session manager object."""
return self._sman
async def subscribe(self, event_id):
"""Subscribe to motion events and set the webhook as callback."""
self._event_id = event_id
self._webhook_id = await self.register_webhook()
self._webhook_url = "{}{}".format(
get_url(self._hass, prefer_external=False),
self._hass.components.webhook.async_generate_path(self._webhook_id),
)
self._sman = Manager(self._host, self._port, self._username, self._password)
if await self._sman.subscribe(self._webhook_url):
_LOGGER.info(
"Host %s subscribed successfully to webhook %s",
self._host,
self._webhook_url,
)
await self.set_available(True)
else:
await self.set_available(False)
return True
async def register_webhook(self):
"""
Register a webhook for motion events if it does not exist yet (in case of NVR).
The webhook name (in info) contains the event id (contains mac address op the camera).
So when motion triggers the webhook, it triggers this event. The event is handled by
the binary sensor, in case of NVR the binary sensor also figures out what channel has
the motion. So the flow is: camera onvif event->webhook->HA event->binary sensor.
"""
_LOGGER.debug("Registering webhook for event ID %s", self._event_id)
webhook_id = self._hass.components.webhook.async_generate_id()
self._hass.components.webhook.async_register(
DOMAIN, self._event_id, webhook_id, handle_webhook
)
return webhook_id
async def renew(self):
"""Renew the subscription of the motion events (lease time is set to 15 minutes)."""
if self._sman.renewtimer <= SESSION_RENEW_THRESHOLD:
if not await self._sman.renew():
_LOGGER.error(
"Host %s error renewing the Reolink subscription",
self._host,
)
await self.set_available(False)
await self._sman.subscribe(self._webhook_url)
else:
await self.set_available(True)
else:
await self.set_available(True)
async def set_available(self, available: bool):
"""Set the availability state to the base object."""
self._hass.bus.async_fire(self._event_id, {"available": available})
async def unsubscribe(self):
"""Unsubscribe from the motion events."""
await self.set_available(False)
await self.unregister_webhook()
return await self._sman.unsubscribe()
async def unregister_webhook(self):
"""Unregister the webhook for motion events."""
_LOGGER.debug("Unregistering webhook %s", self._webhook_id)
self._hass.components.webhook.async_unregister(self._webhook_id)
async def count_members(self):
"""Count the number of camera's using this push manager."""
members = 0
for entry_id in self._hass.data[DOMAIN]:
_LOGGER.debug("Got data entry: %s", entry_id)
if PUSH_MANAGER in entry_id:
continue # Count config entries only
try:
base = self._hass.data[DOMAIN][entry_id][BASE]
if base.event_id == self._event_id:
members += 1
except AttributeError:
pass
except KeyError:
pass
_LOGGER.debug("Found %d listeners for event %s", members, self._event_id)
return members
async def handle_webhook(hass, webhook_id, request):
"""Handle incoming webhook from Reolink for inbound messages and calls."""
_LOGGER.debug("Reolink webhook triggered")
if not request.body_exists:
_LOGGER.debug("Webhook triggered without payload")
data = await request.text()
if not data:
_LOGGER.debug("Webhook triggered with unknown payload")
return
_LOGGER.debug(data)
matches = re.findall(r'Name="IsMotion" Value="(.+?)"', data)
if matches:
is_motion = matches[0] == "true"
else:
_LOGGER.debug("Webhook triggered with unknown payload")
return
event_id = await get_event_by_webhook(hass, webhook_id)
if not event_id:
_LOGGER.error("Webhook triggered without event to fire")
hass.bus.async_fire(event_id, {"motion": is_motion})
async def get_webhook_by_event(hass: HomeAssistant, event_id):
"""Find the webhook_id by the event_id."""
try:
handlers = hass.data["webhook"]
except KeyError:
return
for wid, info in handlers.items():
_LOGGER.debug("Webhook: %s", wid)
_LOGGER.debug(info)
if info["name"] == event_id:
return wid
async def get_event_by_webhook(hass: HomeAssistant, webhook_id):
"""Find the event_id by the webhook_id."""
try:
handlers = hass.data["webhook"]
except KeyError:
return
for wid, info in handlers.items():
if wid == webhook_id:
event_id = info["name"]
return event_id

Féach ar an gComhad

@@ -0,0 +1,106 @@
"""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)
async_add_devices([sensor], update_before_add=False)
class MotionSensor(ReolinkEntity, BinarySensorEntity):
"""An implementation of a Reolink IP camera motion sensor."""
def __init__(self, hass, config):
"""Initialize a the switch."""
ReolinkEntity.__init__(self, hass, config)
BinarySensorEntity.__init__(self)
self._available = False
self._event_state = False
self._last_motion = datetime.datetime.min
@property
def unique_id(self):
"""Return Unique ID string."""
return f"reolink_motion_{self._base.unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return f"{self._base.name} motion"
@property
def is_on(self):
"""Return the state of the sensor."""
if not self._base.motion_detection_state:
self._state = False
return self._state
if self._event_state or self._base.motion_off_delay == 0:
self._state = self._event_state
return self._state
if (
datetime.datetime.now() - self._last_motion
).total_seconds() < self._base.motion_off_delay:
self._state = True
else:
self._state = False
return self._state
@property
def available(self):
"""Return True if entity is available."""
return self._available
@property
def device_class(self):
"""Return the class of this device."""
return DEFAULT_DEVICE_CLASS
async def async_added_to_hass(self) -> None:
"""Entity created."""
await super().async_added_to_hass()
self.hass.bus.async_listen(self._base.event_id, self.handle_event)
async def handle_event(self, event):
"""Handle incoming event for motion detection and availability."""
try:
self._available = event.data["available"]
return
except KeyError:
pass
if not self._available:
return
try:
self._event_state = event.data["motion"]
except KeyError:
return
if self._base.api.channels > 1:
# Pull the motion state for the NVR channel, it has only 1 event
self._event_state = await self._base.api.get_motion_state()
if self._event_state:
self._last_motion = datetime.datetime.now()
else:
if self._base.motion_off_delay > 0:
await asyncio.sleep(self._base.motion_off_delay)
self.async_schedule_update_ha_state()

Féach ar an gComhad

@@ -1,174 +1,147 @@
"""This component provides basic support for Reolink IP cameras."""
import logging
"""This component provides support for Reolink IP cameras."""
import asyncio
import voluptuous as vol
import datetime
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, SUPPORT_STREAM, ENTITY_IMAGE_URL
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant.helpers import config_validation as cv
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.helpers.aiohttp_client import async_aiohttp_proxy_stream
from custom_components.reolink_dev.ReolinkPyPi.camera import ReolinkApi
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_web,
async_get_clientsession,
)
from .const import (
SERVICE_PTZ_CONTROL,
SERVICE_SET_BACKLIGHT,
SERVICE_SET_DAYNIGHT,
SERVICE_SET_SENSITIVITY,
)
from .entity import ReolinkEntity
_LOGGER = logging.getLogger(__name__)
STATE_MOTION = "motion"
STATE_NO_MOTION = "no_motion"
STATE_IDLE = "idle"
DEFAULT_NAME = "Reolink Camera"
DEFAULT_STREAM = "main"
DEFAULT_PROTOCOL = "rtmp"
DEFAULT_CHANNEL = 0
CONF_STREAM = "stream"
CONF_PROTOCOL = "protocol"
CONF_CHANNEL = "channel"
DOMAIN = "camera"
SERVICE_ENABLE_FTP = 'enable_ftp'
SERVICE_DISABLE_FTP = 'disable_ftp'
SERVICE_ENABLE_EMAIL = 'enable_email'
SERVICE_DISABLE_EMAIL = 'disable_email'
SERVICE_ENABLE_IR_LIGHTS = 'enable_ir_lights'
SERVICE_DISABLE_IR_LIGHTS = 'disable_ir_lights'
DEFAULT_BRAND = 'Reolink'
DOMAIN_DATA = 'reolink_devices'
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_STREAM, default=DEFAULT_STREAM): vol.In(["main", "sub"]),
vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.In(["rtmp", "rtsp"]),
vol.Optional(CONF_CHANNEL, default=DEFAULT_CHANNEL): cv.positive_int,
}
)
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up a Reolink IP Camera."""
host = config.get(CONF_HOST)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
stream = config.get(CONF_STREAM)
protocol = config.get(CONF_PROTOCOL)
channel = config.get(CONF_CHANNEL)
name = config.get(CONF_NAME)
platform = entity_platform.current_platform.get()
camera = ReolinkCamera(hass, config_entry)
session = ReolinkApi(host, channel)
session.login(username, password)
platform.async_register_entity_service(
SERVICE_SET_SENSITIVITY,
{
vol.Required("sensitivity"): cv.positive_int,
vol.Optional("preset"): cv.positive_int,
},
SERVICE_SET_SENSITIVITY,
)
async_add_devices([ReolinkCamera(hass, session, host, username, password, stream, protocol, channel, name)], update_before_add=True)
platform.async_register_entity_service(
SERVICE_SET_DAYNIGHT,
{
vol.Required("mode"): cv.string,
},
SERVICE_SET_DAYNIGHT,
)
# Event enable FTP
def handler_enable_ftp(call):
component = hass.data.get(DOMAIN)
entity = component.get_entity(call.data.get(ATTR_ENTITY_ID))
platform.async_register_entity_service(
SERVICE_SET_BACKLIGHT,
{
vol.Required("mode"): cv.string,
},
SERVICE_SET_BACKLIGHT,
)
if entity:
entity.enable_ftp_upload()
hass.services.async_register(DOMAIN, SERVICE_ENABLE_FTP, handler_enable_ftp)
platform.async_register_entity_service(
SERVICE_PTZ_CONTROL,
{
vol.Required("command"): cv.string,
vol.Optional("preset"): cv.positive_int,
vol.Optional("speed"): cv.positive_int,
},
SERVICE_PTZ_CONTROL,
)
# Event disable FTP
def handler_disable_ftp(call):
component = hass.data.get(DOMAIN)
entity = component.get_entity(call.data.get(ATTR_ENTITY_ID))
if entity:
entity.disable_ftp_upload()
hass.services.async_register(DOMAIN, SERVICE_DISABLE_FTP, handler_disable_ftp)
# Event enable email
def handler_enable_email(call):
component = hass.data.get(DOMAIN)
entity = component.get_entity(call.data.get(ATTR_ENTITY_ID))
if entity:
entity.enable_email()
hass.services.async_register(DOMAIN, SERVICE_ENABLE_EMAIL, handler_enable_email)
# Event disable email
def handler_disable_email(call):
component = hass.data.get(DOMAIN)
entity = component.get_entity(call.data.get(ATTR_ENTITY_ID))
if entity:
entity.disable_email()
hass.services.async_register(DOMAIN, SERVICE_DISABLE_EMAIL, handler_disable_email)
# Event enable ir lights
def handler_enable_ir_lights(call):
component = hass.data.get(DOMAIN)
entity = component.get_entity(call.data.get(ATTR_ENTITY_ID))
if entity:
entity.enable_ir_lights()
hass.services.async_register(DOMAIN, SERVICE_ENABLE_IR_LIGHTS, handler_enable_ir_lights)
# Event disable ir lights
def handler_disable_ir_lights(call):
component = hass.data.get(DOMAIN)
entity = component.get_entity(call.data.get(ATTR_ENTITY_ID))
if entity:
entity.disable_ir_lights()
hass.services.async_register(DOMAIN, SERVICE_DISABLE_IR_LIGHTS, handler_disable_ir_lights)
async_add_devices([camera])
class ReolinkCamera(Camera):
class ReolinkCamera(ReolinkEntity, Camera):
"""An implementation of a Reolink IP camera."""
def __init__(self, hass, session, host, username, password, stream, protocol, channel, name):
def __init__(self, hass, config):
"""Initialize a Reolink camera."""
ReolinkEntity.__init__(self, hass, config)
Camera.__init__(self)
super().__init__()
self._host = host
self._username = username
self._password = password
self._stream = stream
self._protocol = protocol
self._channel = channel
self._name = name
self._reolinkSession = session
self._hass = hass
self._manager = self._hass.data[DATA_FFMPEG]
self._last_update = 0
self._ffmpeg = self._hass.data[DATA_FFMPEG]
self._last_image = None
self._last_motion = 0
self._ftp_state = None
self._email_state = None
self._ir_state = None
self._ptzpresets = dict()
self._state = STATE_IDLE
self._ptz_commands = {
"AUTO": "Auto",
"DOWN": "Down",
"FOCUSDEC": "FocusDec",
"FOCUSINC": "FocusInc",
"LEFT": "Left",
"LEFTDOWN": "LeftDown",
"LEFTUP": "LeftUp",
"RIGHT": "Right",
"RIGHTDOWN": "RightDown",
"RIGHTUP": "RightUp",
"STOP": "Stop",
"TOPOS": "ToPos",
"UP": "Up",
"ZOOMDEC": "ZoomDec",
"ZOOMINC": "ZoomInc",
}
self._daynight_modes = {
"AUTO": "Auto",
"COLOR": "Color",
"BLACKANDWHITE": "Black&White",
}
self._hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.disconnect)
self._backlight_modes = {
"BACKLIGHTCONTROL": "BackLightControl",
"DYNAMICRANGECONTROL": "DynamicRangeControl",
"OFF": "Off",
}
@property
def state_attributes(self):
def unique_id(self):
"""Return Unique ID string."""
return f"reolink_camera_{self._base.unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return self._base.name
@property
def ptz_support(self):
"""Return whether the camera has PTZ support."""
return self._base.api.ptz_support
@property
def device_state_attributes(self):
"""Return the camera state attributes."""
attrs = {"access_token": self.access_tokens[-1]}
attrs = {}
if self._base.api.ptz_support:
attrs["ptz_presets"] = self._base.api.ptz_presets
if self._last_motion:
attrs["last_motion"] = self._last_motion
if self._last_update:
attrs["last_update"] = self._last_update
for key, value in self._backlight_modes.items():
if value == self._base.api.backlight_state:
attrs["backlight_state"] = key
attrs["ftp_enabled"] = self._ftp_state
attrs["email_enabled"] = self._email_state
attrs["ir_lights_enabled"] = self._ir_state
attrs["ptzpresets"] = self._ptzpresets
for key, value in self._daynight_modes.items():
if value == self._base.api.daynight_state:
attrs["daynight_state"] = key
if self._base.api.sensitivity_presets:
attrs["sensitivity"] = self.get_sensitivity_presets()
return attrs
@@ -177,136 +150,72 @@ class ReolinkCamera(Camera):
"""Return supported features."""
return SUPPORT_STREAM
@property
def name(self):
"""Return the name of this camera."""
return self._name
@property
def should_poll(self):
"""Polling needed for the device status."""
return True
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def ftp_state(self):
"""Camera Motion recording Status."""
return self._ftp_state
@property
def email_state(self):
"""Camera email Status."""
return self._email_state
@property
def ptzpresets(self):
"""Camera PTZ presets list."""
return self._ptzpresets
async def stream_source(self):
"""Return the source of the stream."""
if self._protocol == "rtsp":
rtspChannel = f"{self._channel+1:02d}"
stream_source = f"rtsp://{self._username}:{self._password}@{self._host}:{self._reolinkSession.rtspport}/h264Preview_{rtspChannel}_{self._stream}"
else:
stream_source = f"rtmp://{self._host}:{self._reolinkSession.rtmpport}/bcs/channel{self._channel}_{self._stream}.bcs?channel={self._channel}&stream=0&user={self._username}&password={self._password}"
return stream_source
return await self._base.api.get_stream_source()
async def handle_async_mjpeg_stream(self, request):
"""Generate an HTTP MJPEG stream from the camera."""
stream_source = await self.stream_source()
stream = CameraMjpeg(self._manager.binary, loop=self._hass.loop)
await stream.open_camera(stream_source)
websession = async_get_clientsession(self._hass)
stream_coro = websession.get(stream_source, timeout=10)
try:
stream_reader = await stream.get_reader()
return await async_aiohttp_proxy_stream(
self._hass,
request,
stream_reader,
self._manager.ffmpeg_stream_content_type,
)
finally:
await stream.close()
def camera_image(self):
"""Return bytes of camera image."""
return self._reolinkSession.still_image
return await async_aiohttp_proxy_web(self._hass, request, stream_coro)
async def async_camera_image(self):
"""Return a still image response from the camera."""
return self._reolinkSession.snapshot
return await self._base.api.get_snapshot()
def enable_ftp_upload(self):
"""Enable motion ftp recording in camera."""
if self._reolinkSession.set_ftp(True):
self._ftp_state = True
self._hass.states.set(self.entity_id, self.state, self.state_attributes)
async def ptz_control(self, command, **kwargs):
"""Pass PTZ command to the camera."""
if not self.ptz_support:
_LOGGER.error("PTZ is not supported on this device")
return
def disable_ftp_upload(self):
"""Disable motion ftp recording."""
if self._reolinkSession.set_ftp(False):
self._ftp_state = False
self._hass.states.set(self.entity_id, self.state, self.state_attributes)
await self._base.api.set_ptz_command(
command=self._ptz_commands[command], **kwargs
)
def enable_email(self):
"""Enable email motion detection in camera."""
if self._reolinkSession.set_email(True):
self._email_state = True
self._hass.states.set(self.entity_id, self.state, self.state_attributes)
def get_sensitivity_presets(self):
"""Get formatted sensitivity presets."""
presets = list()
preset = dict()
def disable_email(self):
"""Disable email motion detection."""
if self._reolinkSession.set_email(False):
self._email_state = False
self._hass.states.set(self.entity_id, self.state, self.state_attributes)
for api_preset in self._base.api.sensitivity_presets:
preset["id"] = api_preset["id"]
preset["sensitivity"] = api_preset["sensitivity"]
def enable_ir_lights(self):
"""Enable IR lights."""
if self._reolinkSession.set_ir_lights(True):
self._ir_state = True
self._hass.states.set(self.entity_id, self.state, self.state_attributes)
time_string = f'{api_preset["beginHour"]}:{api_preset["beginMin"]}'
begin = datetime.strptime(time_string, "%H:%M")
preset["begin"] = begin.strftime("%H:%M")
def disable_ir_lights(self):
"""Disable IR lights."""
if self._reolinkSession.set_ir_lights(False):
self._ir_state = False
self._hass.states.set(self.entity_id, self.state, self.state_attributes)
time_string = f'{api_preset["endHour"]}:{api_preset["endMin"]}'
end = datetime.strptime(time_string, "%H:%M")
preset["end"] = end.strftime("%H:%M")
async def update_motion_state(self):
if self._reolinkSession.motion_state == True:
self._state = STATE_MOTION
self._last_motion = self._reolinkSession.last_motion
else:
self._state = STATE_NO_MOTION
async def update_status(self):
self._reolinkSession.status()
presets.append(preset.copy())
self._last_update = datetime.datetime.now()
self._ftp_state = self._reolinkSession.ftp_state
self._email_state = self._reolinkSession.email_state
self._ir_state = self._reolinkSession.ir_state
self._ptzpresets = self._reolinkSession.ptzpresets
return presets
def update(self):
"""Update the data from the camera."""
try:
self._hass.loop.create_task(self.update_motion_state())
async def set_sensitivity(self, sensitivity, **kwargs):
"""Set the sensitivity to the camera."""
if "preset" in kwargs:
kwargs["preset"] += 1 # The camera preset ID's on the GUI are always +1
await self._base.api.set_sensitivity(value=sensitivity, **kwargs)
if (self._last_update == 0 or
(datetime.datetime.now() - self._last_update).total_seconds() >= 30):
self._hass.loop.create_task(self.update_status())
async def set_daynight(self, mode):
"""Set the day and night mode to the camera."""
await self._base.api.set_daynight(value=self._daynight_modes[mode])
except Exception as ex:
_LOGGER.error("Got exception while fetching the state: %s", ex)
async def set_backlight(self, mode):
"""Set the backlight mode to the camera."""
await self._base.api.set_backlight(value=self._backlight_modes[mode])
def disconnect(self, event):
_LOGGER.info("Disconnecting from Reolink camera")
self._reolinkSession.logout()
async def async_enable_motion_detection(self):
"""Predefined camera service implementation."""
self._base.motion_detection_state = True
async def async_disable_motion_detection(self):
"""Predefined camera service implementation."""
self._base.motion_detection_state = False

Féach ar an gComhad

@@ -0,0 +1,217 @@
"""Config flow for the Reolink camera component."""
import logging
import voluptuous as vol
from homeassistant import config_entries, core, data_entry_flow, exceptions
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_TIMEOUT,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from .base import ReolinkBase
from .const import (
BASE,
CONF_CHANNEL,
CONF_MOTION_OFF_DELAY,
CONF_PLAYBACK_MONTHS,
CONF_PLAYBACK_THUMBNAILS,
CONF_PROTOCOL,
CONF_STREAM,
CONF_THUMBNAIL_OFFSET,
DEFAULT_MOTION_OFF_DELAY,
DEFAULT_PLAYBACK_MONTHS,
DEFAULT_PLAYBACK_THUMBNAILS,
DEFAULT_PROTOCOL,
DEFAULT_STREAM,
DEFAULT_THUMBNAIL_OFFSET,
DEFAULT_TIMEOUT,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Reolink camera's."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
channels = 1
mac_address = None
base = None
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return ReolinkOptionsFlowHandler(config_entry)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
self.data = user_input
try:
self.info = await self.async_validate_input(self.hass, user_input)
if self.channels > 1:
return await self.async_step_nvr()
self.data[CONF_CHANNEL] = 1
await self.async_set_unique_id(
f"{self.mac_address}{user_input[CONF_CHANNEL]}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=self.info["title"], data=self.data)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidHost:
errors["host"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=80): cv.positive_int,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)
async def async_step_nvr(self, user_input=None):
"""Configure a NVR with multiple channels."""
errors = {}
if user_input is not None:
self.data.update(user_input)
await self.async_set_unique_id(
f"{self.mac_address}{user_input[CONF_CHANNEL]}"
)
self._abort_if_unique_id_configured()
await self.base.set_channel(user_input[CONF_CHANNEL])
await self.base.update_settings()
return self.async_create_entry(title=self.base.name, data=self.data)
return self.async_show_form(
step_id="nvr",
data_schema=vol.Schema(
{
vol.Required(CONF_CHANNEL): vol.All(
vol.Coerce(int), vol.Range(min=1, max=self.channels)
),
}
),
errors=errors,
)
async def async_validate_input(self, hass: core.HomeAssistant, user_input: dict):
"""Validate the user input allows us to connect."""
self.base = ReolinkBase(hass, user_input, [])
if not await self.base.connect_api():
raise CannotConnect
title = self.base.api.name
self.channels = self.base.api.channels
self.mac_address = self.base.api.mac_address
return {"title": title}
async def async_finish_flow(self, flow, result):
"""Finish flow."""
# if result['type'] == data_entry_flow.RESULT_TYPE_ABORT:
self.base.disconnect_api()
class ReolinkOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Reolink options."""
def __init__(self, config_entry):
"""Initialize Reolink options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None): # pylint: disable=unused-argument
"""Manage the Reolink options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_PROTOCOL,
default=self.config_entry.options.get(
CONF_PROTOCOL, DEFAULT_PROTOCOL
),
): vol.In(["rtmp", "rtsp"]),
vol.Required(
CONF_STREAM,
default=self.config_entry.options.get(
CONF_STREAM, DEFAULT_STREAM
),
): vol.In(["main", "sub"]),
vol.Required(
CONF_MOTION_OFF_DELAY,
default=self.config_entry.options.get(
CONF_MOTION_OFF_DELAY, DEFAULT_MOTION_OFF_DELAY
),
): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Required(
CONF_PLAYBACK_MONTHS,
default=self.config_entry.options.get(
CONF_PLAYBACK_MONTHS, DEFAULT_PLAYBACK_MONTHS
),
): cv.positive_int,
vol.Optional(
CONF_PLAYBACK_THUMBNAILS,
default=self.config_entry.options.get(
CONF_PLAYBACK_THUMBNAILS, DEFAULT_PLAYBACK_THUMBNAILS
),
): 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)),
vol.Optional(
CONF_TIMEOUT,
default=self.config_entry.options.get(
CONF_TIMEOUT, DEFAULT_TIMEOUT
),
): vol.All(vol.Coerce(int), vol.Range(min=1, max=60)),
}
),
)
class AlreadyConfigured(exceptions.HomeAssistantError):
"""Error to indicate device is already configured."""
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidHost(exceptions.HomeAssistantError):
"""Error to indicate there is an invalid hostname."""

Féach ar an gComhad

@@ -0,0 +1,31 @@
"""Constants for the Reolink Camera integration."""
DOMAIN = "reolink_dev"
DOMAIN_DATA = "reolink_dev_devices"
EVENT_DATA_RECEIVED = "reolink_dev-event"
COORDINATOR = "coordinator"
BASE = "base"
PUSH_MANAGER = "push_manager"
SESSION_RENEW_THRESHOLD = 300
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"
DEFAULT_CHANNEL = 1
DEFAULT_MOTION_OFF_DELAY = 60
DEFAULT_PROTOCOL = "rtmp"
DEFAULT_STREAM = "main"
DEFAULT_TIMEOUT = 30
DEFAULT_PLAYBACK_MONTHS = 2
DEFAULT_PLAYBACK_THUMBNAILS = False
DEFAULT_THUMBNAIL_OFFSET = 6
SERVICE_PTZ_CONTROL = "ptz_control"
SERVICE_SET_BACKLIGHT = "set_backlight"
SERVICE_SET_DAYNIGHT = "set_daynight"
SERVICE_SET_SENSITIVITY = "set_sensitivity"

Féach ar an gComhad

@@ -0,0 +1,41 @@
"""Reolink parent entity class."""
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import BASE, COORDINATOR, DOMAIN
class ReolinkEntity(CoordinatorEntity):
"""Parent class for Reolink Entities."""
def __init__(self, hass, 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._hass = hass
self._state = False
@property
def device_info(self):
"""Information about this entity/device."""
return {
"identifiers": {(DOMAIN, self._base.unique_id)},
"connections": {(CONNECTION_NETWORK_MAC, self._base.api.mac_address)},
"name": self._base.name,
"sw_version": self._base.api.sw_version,
"model": self._base.api.model,
"manufacturer": self._base.api.manufacturer,
"channel": self._base.channel
}
@property
def available(self):
"""Return True if entity is available."""
return self._base.api.session_active
async def request_refresh(self):
"""Call the coordinator to update the API."""
await self.coordinator.async_request_refresh()

Féach ar an gComhad

@@ -1,8 +1,25 @@
{
"domain": "reolink_dev",
"name": "Reolink IP camera",
"documentation": "https://www.example.com",
"dependencies": ["ffmpeg"],
"codeowners": ["@fwestenberg"],
"requirements": ["aiosmtpd==1.2"]
}
"domain": "reolink_dev",
"name": "Reolink IP camera",
"documentation": "https://github.com/fwestenberg/reolink_dev",
"issue_tracker": "https://github.com/fwestenberg/reolink_dev/issues",
"version": "0.15",
"requirements": [
"reolink==0.0.17"
],
"dependencies": [
"ffmpeg",
"webhook"
],
"after_dependencies": [
"media_source",
"http"
],
"codeowners": [
"@fwestenberg"
],
"config_flow": true,
"ssdp": [],
"zeroconf": [],
"homekit": {}
}

Féach ar an gComhad

@@ -0,0 +1,412 @@
"""Reolink Camera Media Source Implementation."""
from urllib import parse
import secrets
import datetime as dt
import logging
from typing import Optional, Tuple
from aiohttp import web
from haffmpeg.tools import IMAGE_JPEG
from dateutil import relativedelta
from homeassistant.core import HomeAssistant, callback
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,
MEDIA_CLASS_VIDEO,
MEDIA_TYPE_VIDEO,
)
from homeassistant.components.media_source.const import MEDIA_MIME_TYPES
from homeassistant.components.media_source.error import MediaSourceError, Unresolvable
from homeassistant.components.media_source.models import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
)
from homeassistant.components.stream import create_stream
from homeassistant.components.ffmpeg import async_get_image
from custom_components.reolink_dev.base import ReolinkBase
from . import typings
from .const import BASE, DEFAULT_THUMBNAIL_OFFSET, DOMAIN
_LOGGER = logging.getLogger(__name__)
# MIME_TYPE = "rtmp/mp4"
# MIME_TYPE = "video/mp4"
MIME_TYPE = "application/x-mpegURL"
NAME = "Reolink IP Camera"
class IncompatibleMediaSource(MediaSourceError):
"""Incompatible media source attributes."""
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))
return source
class ReolinkSource(MediaSource):
"""Provide Reolink camera recordings as media sources."""
name: str = NAME
def __init__(self, hass: HomeAssistant):
"""Initialize Reolink source."""
super().__init__(DOMAIN)
self.hass = hass
self.cache = {}
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"])
_LOGGER.debug("Load VOD %s", url)
stream = create_stream(self.hass, url)
stream.add_provider("hls", timeout=600)
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
# force the reference playlist instead, this seems to work
# though technically wrong
url = url.replace("master_", "")
_LOGGER.debug("Proxy %s", url)
return PlayMedia(url, MIME_TYPE)
async def async_browse_media(
self, item: MediaSourceItem, media_types: Tuple[str] = MEDIA_MIME_TYPES
) -> BrowseMediaSource:
"""Browse media."""
try:
source, camera_id, event_id = async_parse_identifier(item)
except Unresolvable as err:
raise BrowseError(str(err)) from err
_LOGGER.debug("Browsing %s, %s, %s", source, camera_id, event_id)
if camera_id and camera_id not in self.cache:
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"]
):
raise BrowseError("Event does not exist.")
return await self._async_browse_media(source, camera_id, event_id, False)
async def _async_browse_media(
self, source: str, camera_id: str, event_id: str = None, no_descend: bool = True
) -> 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]
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
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
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()}"
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
)
raise IncompatibleMediaSource
if not media.can_expand or no_descend:
return media
media.children = []
base: ReolinkBase = None
if cache is None:
for entry_id in self.hass.data[DOMAIN]:
entry = self.hass.data[DOMAIN][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
child = await self._async_browse_media(source, camera_id)
media.children.append(child)
return media
base = self.hass.data[DOMAIN][cache["entry_id"]][BASE]
# 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}"
)
media.children.append(child)
return media
cache["playback_thumbnails"] = base.playback_thumbnails
end_date = dt.datetime.combine(
start_date.date(), dt.time.max, start_date.tzinfo
)
_, files = await base.api.send_search(start_date, end_date)
if not files is None:
events = cache.setdefault("playback_events", {})
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,
)
event_id = str(start_date.timestamp())
event = events.setdefault(event_id, {})
event["start"] = start_date
event["end"] = end_date
event["file"] = file["name"]
child = await self._async_browse_media(source, camera_id, event_id)
media.children.append(child)
return media
class ReolinkSourceThumbnailView(HomeAssistantView):
""" Thumbnial view handler """
url = "/api/" + DOMAIN + "/media_proxy/{camera_id}/{event_id}"
name = "api:" + DOMAIN + ":image"
requires_auth = False
cors_allowed = True
def __init__(self, hass: HomeAssistant, source: ReolinkSource):
"""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. """
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:
_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)
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
)
raise web.HTTPInternalServerError()
@callback
def async_parse_identifier(
item: MediaSourceItem,
) -> Tuple[str, str, Optional[str]]:
"""Parse identifier."""
if not item.identifier:
return "events", "", None
source, path = item.identifier.lstrip("/").split("/", 1)
if source != "events":
raise Unresolvable("Unknown source directory.")
if "/" in path:
camera_id, event_id = path.split("/", 1)
return source, camera_id, event_id
return source, path, None

Féach ar an gComhad

@@ -1,41 +1,84 @@
enable_ftp:
description: Enable FTP upload on motion recording.
ptz_control:
name: Pan/Zoom/Tilt Control
description: Execute a PTZ command.
target:
entity:
integration: reolink_dev
domain: camera
fields:
entity_id:
description: Name of the Reolink camera entity to set.
description: Name(s) of the Reolink camera entity to execute the command on.
example: 'camera.frontdoor'
disable_ftp:
description: Disable FTP upload on motion recording.
command:
description: >-
Command to execute. Possible values are:
AUTO DOWN FOCUSDEC FOCUSINC LEFT LEFTDOWN LEFTUP
RIGHT RIGHTDOWN RIGHTUP STOP TOPOS UP ZOOMDEC ZOOMINC
example: LEFTUP
preset:
description: (Optional) In case of the command TOPOS. The available presets are listed as attribute on the camera.
example: HOME
speed:
description: (Optional) Speed at which the movement takes place.
example: 25
set_sensitivity:
name: Set Motion Sensitivity
description: Set the motion detection sensitivity.
target:
entity:
integration: reolink_dev
domain: camera
fields:
entity_id:
description: Name of the Reolink camera entity to set.
description: Name(s) of the Reolink camera entity to execute the command on.
example: 'camera.frontdoor'
enable_email:
description: Enable email functionality on motion detection.
sensitivity:
description: New sensitivity, value between 1 (low sensitivity) and 50 (high sensitivity)
example: 25
preset:
description: >-
(Optional) Set the sensitivity of a specific preset (time schedule). When no value is supplied,
all presets will be changed.
set_daynight:
name: Set Day/Night Mode
description: Set day and night parameter.
target:
entity:
integration: reolink_dev
domain: camera
fields:
entity_id:
description: Name of the Reolink camera entity to set.
description: Name(s) of the Reolink camera entity to execute the command on.
example: 'camera.frontdoor'
disable_email:
description: Disable email functionality on motion detection.
mode:
description: >-
The day and night mode parameter supports the following values:
AUTO: Auto switch between black & white mode
COLOR: Always record videos in color mode
BLACKANDWHITE: Always record videos in black & white mode
example: AUTO
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,
but it should be tested at different times of the day and night to ensure there is no negative effect.
target:
entity:
integration: reolink_dev
domain: camera
fields:
entity_id:
description: Name of the Reolink camera entity to set.
description: Name(s) of the Reolink camera entity to execute the command on.
example: 'camera.frontdoor'
enable_ir_lights:
description: Enable the infrared lights (nightvision) of the Reolink camera.
fields:
entity_id:
description: Name of the Reolink camera entity to set.
example: 'camera.frontdoor'
disable_ir_lights:
description: Disable the infrared lights (nightvision) of the Reolink camera.
fields:
entity_id:
description: Name of the Reolink camera entity to set.
example: 'camera.frontdoor'
mode:
description: >-
The backlight parameter supports the following values:
BACKLIGHTCONTROL: use Backlight Control
DYNAMICRANGECONTROL: use Dynamic Range Control
OFF: no optimization
example: DYNAMICRANGECONTROL

Féach ar an gComhad

@@ -0,0 +1,42 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"nvr": {
"data": {
"channel": "Channel"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protocol",
"stream": "Stream",
"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"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,269 @@
"""This component provides support many for Reolink IP cameras switches."""
import asyncio
import logging
from homeassistant.components.switch import DEVICE_CLASS_SWITCH
from homeassistant.helpers.entity import ToggleEntity
from .const import BASE, DOMAIN
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 = []
base = hass.data[DOMAIN][config_entry.entry_id][BASE]
for capability in await base.api.get_switch_capabilities():
if capability == "ftp":
devices.append(FTPSwitch(hass, config_entry))
elif capability == "email":
devices.append(EmailSwitch(hass, config_entry))
elif capability == "audio":
devices.append(AudioSwitch(hass, config_entry))
elif capability == "irLights":
devices.append(IRLightsSwitch(hass, config_entry))
elif capability == "recording":
devices.append(RecordingSwitch(hass, config_entry))
else:
continue
async_add_devices(devices, update_before_add=False)
class FTPSwitch(ReolinkEntity, ToggleEntity):
"""An implementation of a Reolink IP camera FTP switch."""
def __init__(self, hass, config):
"""Initialize a Reolink camera."""
ReolinkEntity.__init__(self, hass, config)
ToggleEntity.__init__(self)
@property
def unique_id(self):
"""Return Unique ID string."""
return f"reolink_ftpSwitch_{self._base.unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return f"{self._base.name} FTP"
@property
def is_on(self):
"""Camera Motion FTP upload Status."""
return self._base.api.ftp_state
@property
def device_class(self):
"""Device class of the switch."""
return DEVICE_CLASS_SWITCH
@property
def icon(self):
"""Icon of the switch."""
if self.is_on:
return "mdi:folder-upload"
return "mdi:folder-remove"
async def async_turn_on(self, **kwargs):
"""Enable motion ftp recording."""
await self._base.api.set_ftp(True)
await self.request_refresh()
async def async_turn_off(self, **kwargs):
"""Disable motion ftp recording."""
await self._base.api.set_ftp(False)
await self.request_refresh()
class EmailSwitch(ReolinkEntity, ToggleEntity):
"""An implementation of a Reolink IP camera email switch."""
def __init__(self, hass, config):
"""Initialize a Reolink camera."""
ReolinkEntity.__init__(self, hass, config)
ToggleEntity.__init__(self)
@property
def unique_id(self):
"""Return Unique ID string."""
return f"reolink_emailSwitch_{self._base.unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return f"{self._base.name} email"
@property
def is_on(self):
"""Camera Motion email upload Status."""
return self._base.api.email_state
@property
def device_class(self):
"""Device class of the switch."""
return DEVICE_CLASS_SWITCH
@property
def icon(self):
"""Icon of the switch."""
if self.is_on:
return "mdi:email"
return "mdi:email-outline"
async def async_turn_on(self, **kwargs):
"""Enable motion email notification."""
await self._base.api.set_email(True)
await self.request_refresh()
async def async_turn_off(self, **kwargs):
"""Disable motion email notification."""
await self._base.api.set_email(False)
await self.request_refresh()
class IRLightsSwitch(ReolinkEntity, ToggleEntity):
"""An implementation of a Reolink IP camera ir lights switch."""
def __init__(self, hass, config):
"""Initialize a Reolink camera."""
ReolinkEntity.__init__(self, hass, config)
ToggleEntity.__init__(self)
@property
def unique_id(self):
"""Return Unique ID string."""
return f"reolink_irLightsSwitch_{self._base.unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return f"{self._base.name} IR lights"
@property
def is_on(self):
"""Camera Motion ir lights Status."""
return self._base.api.ir_state
@property
def device_class(self):
"""Device class of the switch."""
return DEVICE_CLASS_SWITCH
@property
def icon(self):
"""Icon of the switch."""
if self.is_on:
return "mdi:flashlight"
return "mdi:flashlight-off"
async def async_turn_on(self, **kwargs):
"""Enable motion ir lights."""
await self._base.api.set_ir_lights(True)
await self.request_refresh()
async def async_turn_off(self, **kwargs):
"""Disable motion ir lights."""
await self._base.api.set_ir_lights(False)
await self.request_refresh()
class RecordingSwitch(ReolinkEntity, ToggleEntity):
"""An implementation of a Reolink IP camera recording switch."""
def __init__(self, hass, config):
"""Initialize a Reolink camera."""
ReolinkEntity.__init__(self, hass, config)
ToggleEntity.__init__(self)
@property
def unique_id(self):
"""Return Unique ID string."""
return f"reolink_recordingSwitch_{self._base.unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return f"{self._base.name} recording"
@property
def is_on(self):
"""Camera recording upload Status."""
return self._base.api.recording_state
@property
def device_class(self):
"""Device class of the switch."""
return DEVICE_CLASS_SWITCH
@property
def icon(self):
"""Icon of the switch."""
if self.is_on:
return "mdi:filmstrip"
return "mdi:filmstrip-off"
async def async_turn_on(self, **kwargs):
"""Enable recording."""
await self._base.api.set_recording(True)
await self.request_refresh()
async def async_turn_off(self, **kwargs):
"""Disable recording."""
await self._base.api.set_recording(False)
await self.request_refresh()
class AudioSwitch(ReolinkEntity, ToggleEntity):
"""An implementation of a Reolink IP camera audio switch."""
def __init__(self, hass, config):
"""Initialize a Reolink camera."""
ReolinkEntity.__init__(self, hass, config)
ToggleEntity.__init__(self)
@property
def unique_id(self):
"""Return Unique ID string."""
return f"reolink_audioSwitch_{self._base.unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return f"{self._base.name} record audio"
@property
def is_on(self):
"""Camera audio switch Status."""
return self._base.api.audio_state
@property
def device_class(self):
"""Device class of the switch."""
return DEVICE_CLASS_SWITCH
@property
def icon(self):
"""Icon of the switch."""
if self.is_on:
return "mdi:volume-high"
return "mdi:volume-off"
async def async_turn_on(self, **kwargs):
"""Enable audio recording."""
await self._base.api.set_audio(True)
await self.request_refresh()
async def async_turn_off(self, **kwargs):
"""Disable audio recording."""
await self._base.api.set_audio(False)
await self.request_refresh()

Féach ar an gComhad

@@ -0,0 +1,39 @@
{
"config": {
"abort": {
"already_configured": "Diese Kamera ist bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung zur Kamera konnte nicht hergestellt werden",
"invalid_auth": "Benutzername oder Passwort fehlerhaft",
"unknown": "Unerwarteter Fehler"
},
"step": {
"user": {
"data": {
"host": "Host",
"port": "Port",
"username": "Benutzername",
"password": "Passwort"
}
},
"nvr": {
"data": {
"channel": "Kanal"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protokoll",
"stream": "Stream",
"timeout": "Timeout (Sekunden)",
"motion_off_delay": "Bewegungssensor Ausschaltverzögerung (Sekunden)"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,42 @@
{
"config": {
"abort": {
"already_configured": "This camera is already configured"
},
"error": {
"cannot_connect": "Failed to connect with the camera",
"invalid_auth": "Invalid username or password",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password"
}
},
"nvr": {
"data": {
"channel": "Channel"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protocol",
"stream": "Stream",
"timeout": "Timeout (seconds)",
"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"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,39 @@
{
"config": {
"abort": {
"already_configured": "Esta cámara ya ha sido configurada"
},
"error": {
"cannot_connect": "No se pudo conectar con la cámara",
"invalid_auth": "Nombre de usuario o contraseña incorrecta",
"unknown": "Error inesperado"
},
"step": {
"user": {
"data": {
"host": "Dirección",
"port": "Puerto",
"username": "Usuario",
"password": "Contraseña"
}
},
"nvr": {
"data": {
"channel": "Canal"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protocolo",
"stream": "Transferencia",
"timeout": "Tiempo fuera (segundos)",
"motion_off_delay": "Sensor de movimiento apagado retardo (segundos)"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,39 @@
{
"config": {
"abort": {
"already_configured": "Cette caméra est déjà configurée"
},
"error": {
"cannot_connect": "Impossible de se connecter à la caméra",
"invalid_auth": "Mauvais nom d'utilisateur et/ou mot de passe",
"unknown": "Erreur inconnue"
},
"step": {
"user": {
"data": {
"host": "Hôte",
"port": "Port",
"username": "Nom d'utilisateur",
"password": "Mot de passe"
}
},
"nvr": {
"data": {
"channel": "Chaîne"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protocole",
"stream": "Flux",
"timeout": "Temporisation (en secondes)",
"motion_off_delay": "Délai de désactivation du capteur de mouvements (en secondes)"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,39 @@
{
"config": {
"abort": {
"already_configured": "כבר מקונפג"
},
"error": {
"cannot_connect": "אין אפשרות להתחבר",
"invalid_auth": "הרשאה לא נכונה",
"unknown": "בעיה לא ידועה"
},
"step": {
"user": {
"data": {
"host": "שרת",
"port": "פורט",
"username": "שם משתמש",
"password": "סיסמא"
}
},
"nvr": {
"data": {
"channel": "ערוץ"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "פרוטוקול",
"stream": "סטרים",
"timeout": "טיימאאוט",
"motion_off_delay": "השהיית כבוי תזוזה"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,39 @@
{
"config": {
"abort": {
"already_configured": "Deze camera is al geconfigureerd"
},
"error": {
"cannot_connect": "Kan niet verbinden met de camera",
"invalid_auth": "Ongeldige gebruikersnaam of wachtwoord",
"unknown": "Onbekende fout opgetreden"
},
"step": {
"user": {
"data": {
"host": "Host",
"port": "Poort",
"username": "Gebruikersnaam",
"password": "Wachtwoord"
}
},
"nvr": {
"data": {
"channel": "Kanaal"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protocol",
"stream": "Stream",
"timeout": "Timeout (seconden)",
"motion_off_delay": "Bewegingssensor uit vertraging (seconden)"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,39 @@
{
"config": {
"abort": {
"already_configured": "Ta kamera jest już skonfigurowana"
},
"error": {
"cannot_connect": "Nie udało się połączyć z kamerą",
"invalid_auth": "Nieprawidłowy użytkownik lub hasło",
"unknown": "Niespodziewany błąd"
},
"step": {
"user": {
"data": {
"host": "Host",
"port": "Port",
"username": "Nazwa użytkownika",
"password": "Hasło"
}
},
"nvr": {
"data": {
"channel": "Kanał"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protokół",
"stream": "Stream",
"timeout": "Timeout (sekundy)",
"motion_off_delay": "Opóźnienie wyłączenia czujnika ruchu (sekundy)"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,39 @@
{
"config": {
"abort": {
"already_configured": "Denna kamera är redan konfiguerad"
},
"error": {
"cannot_connect": "Misslyckades med att ansluta till kameran",
"invalid_auth": "Fel användarnamn eller lösenord",
"unknown": "Oförväntat fel"
},
"step": {
"user": {
"data": {
"host": "Host",
"port": "Port",
"username": "Användarnamn",
"password": "Lösenord"
}
},
"nvr": {
"data": {
"channel": "Kanal"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"protocol": "Protokoll",
"stream": "Ström",
"timeout": "Timeout (sekunder)",
"motion_off_delay": "Rörelsesensor avstängningfördröjning (sekunder)"
}
}
}
}
}

Féach ar an gComhad

@@ -0,0 +1,31 @@
""" Typing declarations for strongly typed dictionaries """
from typing import Any, Dict, List, TypedDict
from datetime import datetime, date
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,
)