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

@@ -0,0 +1,119 @@
"""Support for Deebot Vaccums."""
import asyncio
import logging
import async_timeout
import time
import random
import string
import base64
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from datetime import timedelta
from deebotozmo import *
from homeassistant.util import Throttle
from homeassistant.helpers import discovery
from homeassistant.helpers.entity import Entity
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
REQUIREMENTS = ['deebotozmo==1.7.8']
CONF_COUNTRY = "country"
CONF_CONTINENT = "continent"
CONF_DEVICEID = "deviceid"
CONF_LIVEMAPPATH = "livemappath"
CONF_LIVEMAP = "live_map"
CONF_SHOWCOLORROOMS = "show_color_rooms"
DEEBOT_DEVICES = "deebot_devices"
# Generate a random device ID on each bootup
DEEBOT_API_DEVICEID = "".join(
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
)
_LOGGER = logging.getLogger(__name__)
HUB = None
DOMAIN = 'deebot'
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string),
vol.Required(CONF_DEVICEID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_LIVEMAP, default=True): cv.boolean,
vol.Optional(CONF_SHOWCOLORROOMS, default=False): cv.boolean,
vol.Optional(CONF_LIVEMAPPATH, default='www/'): cv.string
}),
}, extra=vol.ALLOW_EXTRA)
def setup(hass, config):
"""Set up the Deebot."""
global HUB
HUB = DeebotHub(config[DOMAIN])
for component in ('sensor', 'binary_sensor', 'vacuum'):
discovery.load_platform(hass, component, DOMAIN, {}, config)
return True
class DeebotHub(Entity):
"""Deebot Hub"""
def __init__(self, domain_config):
"""Initialize the Deebot Vacuum."""
self.config = domain_config
self._lock = threading.Lock()
self.ecovacs_api = EcoVacsAPI(
DEEBOT_API_DEVICEID,
domain_config.get(CONF_USERNAME),
EcoVacsAPI.md5(domain_config.get(CONF_PASSWORD)),
domain_config.get(CONF_COUNTRY),
domain_config.get(CONF_CONTINENT)
)
devices = self.ecovacs_api.devices()
liveMapEnabled = domain_config.get(CONF_LIVEMAP)
liveMapRooms = domain_config.get(CONF_SHOWCOLORROOMS)
country = domain_config.get(CONF_COUNTRY).lower()
continent = domain_config.get(CONF_CONTINENT).lower()
self.vacbots = []
# CREATE VACBOT FOR EACH DEVICE
for device in devices:
if device['name'] in domain_config.get(CONF_DEVICEID):
vacbot = VacBot(
self.ecovacs_api.uid,
self.ecovacs_api.resource,
self.ecovacs_api.user_access_token,
device,
country,
continent,
liveMapEnabled,
liveMapRooms
)
_LOGGER.debug("New vacbot found: " + device['name'])
self.vacbots.append(vacbot)
_LOGGER.debug("Hub initialized")
@Throttle(timedelta(seconds=10))
def update(self):
""" Update all statuses. """
try:
for vacbot in self.vacbots:
vacbot.request_all_statuses()
except Exception as ex:
_LOGGER.error('Update failed: %s', ex)
raise
@property
def name(self):
""" Return the name of the hub."""
return "Deebot Hub"

Ní thaispeántar comhad dénártha.

Ní thaispeántar comhad dénártha.

Ní thaispeántar comhad dénártha.

Ní thaispeántar comhad dénártha.

Féach ar an gComhad

@@ -0,0 +1,48 @@
"""Support for Deebot Sensor."""
from typing import Optional
from deebotozmo import *
from homeassistant.components.binary_sensor import BinarySensorEntity
from . import HUB as hub
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Deebot binary sensor platform."""
hub.update()
for vacbot in hub.vacbots:
add_devices([DeebotMopAttachedBinarySensor(vacbot, "mop_attached")], True)
class DeebotMopAttachedBinarySensor(BinarySensorEntity):
"""Deebot mop attached binary sensor"""
def __init__(self, vacbot: VacBot, device_id: str):
"""Initialize the Sensor."""
self._vacbot = vacbot
self._id = device_id
if self._vacbot.vacuum.get("nick", None) is not None:
self._vacbot_name = "{}".format(self._vacbot.vacuum["nick"])
else:
# In case there is no nickname defined, use the device id
self._vacbot_name = "{}".format(self._vacbot.vacuum["did"])
self._name = self._vacbot_name + "_" + device_id
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def is_on(self):
return self._vacbot.mop_attached
@property
def icon(self) -> Optional[str]:
"""Return the icon to use in the frontend, if any."""
return "mdi:water" if self.is_on else "mdi:water-off"

Féach ar an gComhad

@@ -0,0 +1,11 @@
{
"domain": "deebot",
"name": "Deebot for Hassio",
"documentation": "https://github.com/And3rsL/Deebot-for-hassio",
"requirements": [
"deebotozmo==1.7.8"
],
"dependencies": [],
"codeowners": ["@And3rsL"],
"homeassistant": "0.110.0"
}

Féach ar an gComhad

@@ -0,0 +1,179 @@
"""Support for Deebot Sensor."""
from typing import Optional
from deebotozmo import *
from homeassistant.const import (STATE_UNKNOWN)
from homeassistant.helpers.entity import Entity
from . import HUB as hub
_LOGGER = logging.getLogger(__name__)
from homeassistant.components.vacuum import (
STATE_CLEANING,
STATE_DOCKED,
STATE_ERROR,
STATE_IDLE,
STATE_PAUSED,
STATE_RETURNING,
)
STATE_CODE_TO_STATE = {
'STATE_IDLE': STATE_IDLE,
'STATE_CLEANING': STATE_CLEANING,
'STATE_RETURNING': STATE_RETURNING,
'STATE_DOCKED': STATE_DOCKED,
'STATE_ERROR': STATE_ERROR,
'STATE_PAUSED': STATE_PAUSED,
}
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Deebot sensor."""
hub.update()
for vacbot in hub.vacbots:
# General
add_devices([DeebotLastCleanImageSensor(vacbot, "last_clean_image")], True)
add_devices([DeebotWaterLevelSensor(vacbot, "water_level")], True)
# Components
add_devices([DeebotComponentSensor(vacbot, COMPONENT_MAIN_BRUSH)], True)
add_devices([DeebotComponentSensor(vacbot, COMPONENT_SIDE_BRUSH)], True)
add_devices([DeebotComponentSensor(vacbot, COMPONENT_FILTER)], True)
# Stats
add_devices([DeebotStatsSensor(vacbot, "stats_area")], True)
add_devices([DeebotStatsSensor(vacbot, "stats_time")], True)
add_devices([DeebotStatsSensor(vacbot, "stats_type")], True)
class DeebotBaseSensor(Entity):
"""Deebot base sensor"""
def __init__(self, vacbot, device_id):
"""Initialize the Sensor."""
self._state = STATE_UNKNOWN
self._vacbot = vacbot
self._id = device_id
if self._vacbot.vacuum.get("nick", None) is not None:
self._vacbot_name = "{}".format(self._vacbot.vacuum["nick"])
else:
# In case there is no nickname defined, use the device id
self._vacbot_name = "{}".format(self._vacbot.vacuum["did"])
self._name = self._vacbot_name + "_" + device_id
@property
def name(self):
"""Return the name of the device."""
return self._name
class DeebotLastCleanImageSensor(DeebotBaseSensor):
"""Deebot Sensor"""
def __init__(self, vacbot, device_id):
"""Initialize the Sensor."""
super(DeebotLastCleanImageSensor, self).__init__(vacbot, device_id)
@property
def state(self):
"""Return the state of the vacuum cleaner."""
if self._vacbot.last_clean_image is not None:
return self._vacbot.last_clean_image
@property
def icon(self) -> Optional[str]:
"""Return the icon to use in the frontend, if any."""
return "mdi:image-search"
class DeebotWaterLevelSensor(DeebotBaseSensor):
"""Deebot Sensor"""
def __init__(self, vacbot, device_id):
"""Initialize the Sensor."""
super(DeebotWaterLevelSensor, self).__init__(vacbot, device_id)
@property
def state(self):
"""Return the state of the vacuum cleaner."""
if self._vacbot.water_level is not None:
return self._vacbot.water_level
@property
def icon(self) -> Optional[str]:
"""Return the icon to use in the frontend, if any."""
return "mdi:water"
class DeebotComponentSensor(DeebotBaseSensor):
"""Deebot Sensor"""
def __init__(self, vacbot, device_id):
"""Initialize the Sensor."""
super(DeebotComponentSensor, self).__init__(vacbot, device_id)
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return '%'
@property
def state(self):
"""Return the state of the vacuum cleaner."""
for key, val in self._vacbot.components.items():
if key == self._id:
return int(val)
@property
def icon(self) -> Optional[str]:
"""Return the icon to use in the frontend, if any."""
if self._id == COMPONENT_MAIN_BRUSH or self._id == COMPONENT_SIDE_BRUSH:
return "mdi:broom"
elif self._id == COMPONENT_FILTER:
return "mdi:air-filter"
class DeebotStatsSensor(DeebotBaseSensor):
"""Deebot Sensor"""
def __init__(self, vacbot, device_id):
"""Initialize the Sensor."""
super(DeebotStatsSensor, self).__init__(vacbot, device_id)
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
if self._id == 'stats_area':
return "mq"
elif self._id == 'stats_time':
return "min"
@property
def state(self):
"""Return the state of the vacuum cleaner."""
if self._id == 'stats_area' and self._vacbot.stats_area is not None:
return int(self._vacbot.stats_area)
elif self._id == 'stats_time' and self._vacbot.stats_time is not None:
return int(self._vacbot.stats_time/60)
elif self._id == 'stats_type':
return self._vacbot.stats_type
else:
return STATE_UNKNOWN
@property
def icon(self) -> Optional[str]:
"""Return the icon to use in the frontend, if any."""
if self._id == 'stats_area':
return "mdi:floor-plan"
elif self._id == 'stats_time':
return "mdi:timer-outline"
elif self._id == 'stats_type':
return "mdi:cog"

Féach ar an gComhad

@@ -0,0 +1,247 @@
"""Support for Deebot Vaccums."""
import base64
from typing import Optional, Dict, Any, Union, List
from deebotozmo import *
from homeassistant.util import slugify
from . import HUB as hub
CONF_COUNTRY = "country"
CONF_CONTINENT = "continent"
CONF_DEVICEID = "deviceid"
CONF_LIVEMAPPATH = "livemappath"
CONF_LIVEMAP = "live_map"
CONF_SHOWCOLORROOMS = "show_color_rooms"
DEEBOT_DEVICES = "deebot_devices"
from homeassistant.components.vacuum import (
PLATFORM_SCHEMA,
STATE_CLEANING,
STATE_DOCKED,
STATE_ERROR,
STATE_IDLE,
STATE_PAUSED,
STATE_RETURNING,
SUPPORT_BATTERY,
SUPPORT_FAN_SPEED,
SUPPORT_LOCATE,
SUPPORT_PAUSE,
SUPPORT_RETURN_HOME,
SUPPORT_SEND_COMMAND,
SUPPORT_START,
SUPPORT_STATE,
VacuumEntity,
)
_LOGGER = logging.getLogger(__name__)
SUPPORT_DEEBOT = (
SUPPORT_BATTERY
| SUPPORT_FAN_SPEED
| SUPPORT_LOCATE
| SUPPORT_PAUSE
| SUPPORT_RETURN_HOME
| SUPPORT_SEND_COMMAND
| SUPPORT_START
| SUPPORT_STATE
)
STATE_CODE_TO_STATE = {
'STATE_IDLE': STATE_IDLE,
'STATE_CLEANING': STATE_CLEANING,
'STATE_RETURNING': STATE_RETURNING,
'STATE_DOCKED': STATE_DOCKED,
'STATE_ERROR': STATE_ERROR,
'STATE_PAUSED': STATE_PAUSED,
}
ATTR_COMPONENT_PREFIX = "component_"
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Deebot vacuums."""
if DEEBOT_DEVICES not in hass.data:
hass.data[DEEBOT_DEVICES] = []
for vacbot in hub.vacbots:
vacuum = DeebotVacuum(hass, vacbot)
add_devices([vacuum])
class DeebotVacuum(VacuumEntity):
"""Deebot Vacuums"""
def __init__(self, hass, vacbot):
"""Initialize the Deebot Vacuum."""
self._hass = hass
self.device = vacbot
if self.device.vacuum.get("nick", None) is not None:
self._name = "{}".format(self.device.vacuum["nick"])
else:
# In case there is no nickname defined, use the device id
self._name = "{}".format(self.device.vacuum["did"])
self._fan_speed = None
self._live_map = None
self._live_map_path = hub.config.get(CONF_LIVEMAPPATH) + self._name + '_liveMap.png'
self.device.refresh_statuses()
_LOGGER.debug("Vacuum initialized: %s", self.name)
def on_fan_change(self, fan_speed):
self._fan_speed = fan_speed
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state."""
return True
@property
def unique_id(self) -> str:
"""Return an unique ID."""
return self.device.vacuum.get("did", None)
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def supported_features(self):
"""Flag vacuum cleaner robot features that are supported."""
return SUPPORT_DEEBOT
@property
def state(self):
"""Return the state of the vacuum cleaner."""
if self.device.vacuum_status is not None and self.device.is_available == True:
return STATE_CODE_TO_STATE[self.device.vacuum_status]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.device.is_available
async def async_return_to_base(self, **kwargs):
"""Set the vacuum cleaner to return to the dock."""
await self.hass.async_add_executor_job(self.device.Charge)
@property
def battery_level(self):
"""Return the battery level of the vacuum cleaner."""
if self.device.battery_status is not None:
return self.device.battery_status
return super().battery_level
@property
def fan_speed(self):
"""Return the fan speed of the vacuum cleaner."""
return self.device.fan_speed
async def async_set_fan_speed(self, fan_speed, **kwargs):
await self.hass.async_add_executor_job(self.device.SetFanSpeed, fan_speed)
@property
def fan_speed_list(self):
"""Get the list of available fan speed steps of the vacuum cleaner."""
return [FAN_SPEED_QUIET, FAN_SPEED_NORMAL, FAN_SPEED_MAX, FAN_SPEED_MAXPLUS]
async def async_pause(self):
"""Pause the vacuum cleaner."""
await self.hass.async_add_executor_job(self.device.CleanPause)
async def async_start(self):
"""Start the vacuum cleaner."""
await self.hass.async_add_executor_job(self.device.CleanResume)
async def async_locate(self, **kwargs):
"""Locate the vacuum cleaner."""
await self.hass.async_add_executor_job(self.device.PlaySound)
async def async_send_command(self, command, params=None, **kwargs):
"""Send a command to a vacuum cleaner."""
_LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs)
if command == 'spot_area':
await self.hass.async_add_executor_job(self.device.SpotArea, params['rooms'], params['cleanings'])
return
if command == 'custom_area':
await self.hass.async_add_executor_job(self.device.CustomArea, params['coordinates'], params['cleanings'])
return
if command == 'set_water':
await self.hass.async_add_executor_job(self.device.SetWaterLevel, params['amount'])
return
if command == 'relocate':
await self.hass.async_add_executor_job(self.device.Relocate)
return
if command == 'auto_clean':
self.hass.async_add_executor_job(self.device.Clean, params['type'])
return
if command == 'refresh_components':
await self.hass.async_add_executor_job(self.device.refresh_components)
return
if command == 'refresh_statuses':
await self.hass.async_add_executor_job(self.device.refresh_statuses)
return
if command == 'refresh_live_map':
await self.hass.async_add_executor_job(self.device.refresh_liveMap)
return
if command == 'save_live_map':
if(self._live_map != self.device.live_map):
self._live_map = self.device.live_map
with open(params['path'], "wb") as fh:
fh.write(base64.decodebytes(self.device.live_map))
await self.hass.async_add_executor_job(self.device.exc_command, command, params)
async def async_update(self):
"""Fetch state from the device."""
await self.hass.async_add_executor_job(self.device.request_all_statuses)
try:
if(self._live_map != self.device.live_map):
self._live_map = self.device.live_map
with open(self._live_map_path, "wb") as fh:
fh.write(base64.decodebytes(self.device.live_map))
except KeyError:
_LOGGER.warning("Can't access local folder: %s", self._live_map_path)
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return device specific state attributes.
Implemented by platform classes. Convention for attribute names
is lowercase snake_case.
"""
data: Dict[str, Union[int, List[int]]] = {}
# Needed for custom vacuum-card (https://github.com/denysdovhan/vacuum-card)
# Should find a better way without breaking everyone rooms script
data['status'] = STATE_CODE_TO_STATE[self.device.vacuum_status]
if self.device.getSavedRooms() is not None:
for r in self.device.getSavedRooms():
# convert room name to snake_case to meet the convention
room_name = "room_" + slugify(r["subtype"])
room_values = data.get(room_name)
if room_values is None:
data[room_name] = r["id"]
elif isinstance(room_values, list):
room_values.append(r["id"])
else:
# Convert from int to list
data[room_name] = [room_values, r["id"]]
return data

Féach ar an gComhad

@@ -0,0 +1,50 @@
DOMAIN = "fontawesome"
DATA_EXTRA_MODULE_URL = 'frontend_extra_module_url'
ICONS_URL = f'/{DOMAIN}/'
ICON_FILES = {
'regular': 'far.js',
'solid': 'fas.js',
'brands': 'fab.js',
}
async def async_setup(hass, config):
for f in ICON_FILES.values():
hass.http.register_static_path(
f"/{DOMAIN}/{f}",
hass.config.path(f"custom_components/{DOMAIN}/data/{f}"),
True
)
conf = config.get(DOMAIN)
if not conf:
return True
register_modules(hass, conf)
return True
async def async_setup_entry(hass, config_entry):
config_entry.add_update_listener(_update_listener)
register_modules(hass, config_entry.options)
return True
async def async_remove_entry(hass, config_entry):
register_modules(hass, [])
return True
async def _update_listener(hass, config_entry):
register_modules(hass, config_entry.options)
return True
def register_modules(hass, modules):
if DATA_EXTRA_MODULE_URL not in hass.data:
hass.data[DATA_EXTRA_MODULE_URL] = set()
url_set = hass.data[DATA_EXTRA_MODULE_URL]
for k, v in ICON_FILES.items():
url_set.discard(ICONS_URL+v)
if k in modules and modules[k] is not False:
url_set.add(ICONS_URL+v)

Ní thaispeántar comhad dénártha.

Féach ar an gComhad

@@ -0,0 +1,49 @@
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
_LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register("fontawesome")
class FontawesomeConfigFlow(config_entries.ConfigFlow):
async def async_step_user(self, user_input=None):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
return self.async_create_entry(title="", data={})
@staticmethod
@callback
def async_get_options_flow(config_entry):
return FontawesomeEditFlow(config_entry)
class FontawesomeEditFlow(config_entries.OptionsFlow):
def __init__(self, config_entry):
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
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.Optional(
"regular",
default=self.config_entry.options.get("regular", False),
): bool,
vol.Optional(
"solid",
default=self.config_entry.options.get("solid", False),
): bool,
vol.Optional(
"brands",
default=self.config_entry.options.get("brands", False),
): bool,
}
)
)

Cuirtear difríocht comhad faoi chois toisc go bhfuil líne amháin nó níos mó rófhada

Cuirtear difríocht comhad faoi chois toisc go bhfuil líne amháin nó níos mó rófhada

Cuirtear difríocht comhad faoi chois toisc go bhfuil líne amháin nó níos mó rófhada

Féach ar an gComhad

@@ -0,0 +1,9 @@
{
"domain": "fontawesome",
"name": "Fontawesome icons",
"documentation": "",
"dependencies": ["frontend"],
"codeowners": [],
"requirements": [],
"config_flow": true
}

Féach ar an gComhad

@@ -0,0 +1,21 @@
{
"config": {
"title": "FontAwesome",
"abort": {
"single_instance_allowed": "Only a single configuration of FontAwesome is allowed."
}
},
"options": {
"step": {
"init": {
"title": "Icon sets",
"description": "Which icon sets to include",
"data": {
"regular": "Include Regular icons (far:)",
"solid": "Include Solid icons (fas:)",
"brands": "Include Brand icons (fab:)"
}
}
}
}
}

Féach ar an gComhad

@@ -57,11 +57,11 @@ API_WORX_SENSORS = {
},
"icon": "mdi:battery",
"unit": "%",
"device_class": None,
"device_class": "battery",
},
"error": {
"state": {"error_description": "state", "error": "error_id"},
"icon": None,
"icon": "mdi:alert",
"unit": None,
"device_class": None,
},
@@ -83,6 +83,8 @@ API_WORX_SENSORS = {
"rain_delay": "raindelay",
"schedule_variation": "timeextension",
"firmware": "firmware_version",
"serial": "serial",
"mac": "mac",
},
"icon": None,
"unit": None,
@@ -130,7 +132,7 @@ async def async_setup(hass, config):
async def handle_start(call):
"""Handle start service call."""
if "id" in call.data:
ID = call.data["id"]
ID = int(call.data["id"])
for cli in client:
attrs = vars(cli)
@@ -144,7 +146,7 @@ async def async_setup(hass, config):
async def handle_pause(call):
"""Handle pause service call."""
if "id" in call.data:
ID = call.data["id"]
ID = int(call.data["id"])
for cli in client:
attrs = vars(cli)
@@ -158,7 +160,7 @@ async def async_setup(hass, config):
async def handle_home(call):
"""Handle pause service call."""
if "id" in call.data:
ID = call.data["id"]
ID = int(call.data["id"])
for cli in client:
attrs = vars(cli)
@@ -177,31 +179,46 @@ async def async_setup(hass, config):
if "id" in call.data:
_LOGGER.debug("Data from Home Assistant: %s", call.data["id"])
for cli in client:
attrs = vars(cli)
if (attrs["id"] == call.data["id"]):
if (attrs["id"] == int(call.data["id"])):
break
else:
id += 1
if "raindelay" in call.data:
tmpdata["rd"] = call.data["raindelay"]
tmpdata["rd"] = int(call.data["raindelay"])
_LOGGER.debug("Setting rain_delay for %s to %s", client[id].name, call.data["raindelay"])
sendData = True
if "timeextension" in call.data:
tmpdata["sc"] = {}
tmpdata["sc"]["p"] = call.data["timeextension"]
tmpdata["sc"] = {}
tmpdata["sc"]["p"] = int(call.data["timeextension"])
data = json.dumps(tmpdata)
_LOGGER.debug("Setting time_extension for %s to %s", client[id].name, call.data["timeextension"])
sendData = True
if "multizone_distances" in call.data:
tmpdata["mz"] = [int(x) for x in call.data["multizone_distances"]]
data = json.dumps(tmpdata)
_LOGGER.debug("Setting multizone distances for %s to %s", client[id].name, call.data["multizone_distances"])
sendData = True
if "multizone_probabilities" in call.data:
tmpdata["mzv"] = []
for idx, val in enumerate(call.data["multizone_probabilities"]):
for _ in range(val):
tmpdata["mzv"].append(idx)
data = json.dumps(tmpdata)
_LOGGER.debug("Setting multizone probabilities for %s to %s", client[id].name, call.data["multizone_probabilities"])
sendData = True
if sendData:
data = json.dumps(tmpdata)
_LOGGER.debug("Sending: %s", data)
client[id].sendData(data)
hass.services.async_register(DOMAIN, SERVICE_CONFIG, handle_config)
return True

Féach ar an gComhad

@@ -1,7 +1,9 @@
{
"domain": "landroid_cloud",
"name": "Worx Landroid Cloud",
"documentation": "https://www.home-assistant.io/integrations/landroid_cloud/",
"requirements": ["pyworxcloud==1.2.17"],
"documentation": "https://github.com/MTrab/landroid_cloud/blob/master/README.md",
"issue_tracker": "https://github.com/MTrab/landroid_cloud/issues",
"requirements": ["pyworxcloud==1.2.21"],
"version": "1.6.5",
"codeowners": ["@MTrab"]
}

Féach ar an gComhad

@@ -1,127 +1,132 @@
"""Support for monitoring Worx Landroid Sensors."""
import async_timeout
import asyncio
import logging
from homeassistant.components import sensor
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from . import API_WORX_SENSORS, LANDROID_API, UPDATE_SIGNAL
_LOGGER = logging.getLogger(__name__)
STATE_INITIALIZING = "Initializing"
STATE_OFFLINE = "Offline"
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the available sensors for Worx Landroid."""
if discovery_info is None:
return
entities = []
info = discovery_info[0]
for tSensor in API_WORX_SENSORS:
name = "{}_{}".format(info["name"].lower(), tSensor.lower())
friendly_name = "{} {}".format(info["friendly"], tSensor)
dev_id = info["id"]
api = hass.data[LANDROID_API][dev_id]
sensor_type = tSensor
_LOGGER.debug("Init Landroid %s sensor for %s", sensor_type, info["friendly"])
entity = LandroidSensor(api, name, sensor_type, friendly_name, dev_id)
entities.append(entity)
async_add_entities(entities, True)
class LandroidSensor(Entity):
"""Class to create and populate a Landroid Sensor."""
def __init__(self, api, name, sensor_type, friendly_name, dev_id):
"""Init new sensor."""
self._api = api
self._attributes = {}
self._available = False
self._name = friendly_name
self._state = STATE_INITIALIZING
self._sensor_type = sensor_type
self._dev_id = dev_id
self.entity_id = sensor.ENTITY_ID_FORMAT.format(name)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@property
def device_state_attributes(self):
"""Return sensor attributes."""
return self._attributes
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return API_WORX_SENSORS[self._sensor_type]["unit"]
@property
def icon(self):
"""Icon to use in the frontend."""
return API_WORX_SENSORS[self._sensor_type]["icon"]
@property
def should_poll(self):
"""Return False as entity is updated from the component."""
return False
@property
def state(self):
"""Return sensor state."""
return self._state
@callback
def update_callback(self):
"""Get new data and update state."""
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self):
"""Connect update callbacks."""
async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self.update_callback)
def _get_data(self):
"""Return new data from the api cache."""
data = self._api.get_data(self._sensor_type)
self._available = True
return data
async def async_update(self):
"""Update the sensor."""
_LOGGER.debug("Updating %s", self.entity_id)
data = self._get_data()
if "state" in data:
_LOGGER.debug(data)
state = data.pop("state")
_LOGGER.debug("Mower %s State %s", self._name, state)
self._attributes.update(data)
self._state = state
else:
_LOGGER.debug("No data received for %s", self.entity_id)
reachable = self._api._client.online
if not reachable:
if "_battery" in self.entity_id:
self._state = "Unknown"
else:
self._state = STATE_OFFLINE
#else:
# attrs = vars(self._api._client)
# for item in attrs:
# _LOGGER.debug("%s : %s", item, attrs[item])
"""Support for monitoring Worx Landroid Sensors."""
import async_timeout
import asyncio
import logging
from homeassistant.components import sensor
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level
from . import API_WORX_SENSORS, LANDROID_API, UPDATE_SIGNAL
_LOGGER = logging.getLogger(__name__)
STATE_INITIALIZING = "Initializing"
STATE_OFFLINE = "Offline"
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the available sensors for Worx Landroid."""
if discovery_info is None:
return
entities = []
info = discovery_info[0]
for tSensor in API_WORX_SENSORS:
name = "{}_{}".format(info["name"].lower(), tSensor.lower())
friendly_name = "{} {}".format(info["friendly"], tSensor)
dev_id = info["id"]
api = hass.data[LANDROID_API][dev_id]
sensor_type = tSensor
_LOGGER.debug("Init Landroid %s sensor for %s", sensor_type, info["friendly"])
entity = LandroidSensor(api, name, sensor_type, friendly_name, dev_id)
entities.append(entity)
async_add_entities(entities, True)
class LandroidSensor(Entity):
"""Class to create and populate a Landroid Sensor."""
def __init__(self, api, name, sensor_type, friendly_name, dev_id):
"""Init new sensor."""
self._api = api
self._attributes = {}
self._available = False
self._name = friendly_name
self._state = STATE_INITIALIZING
self._sensor_type = sensor_type
self._dev_id = dev_id
self.entity_id = sensor.ENTITY_ID_FORMAT.format(name)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@property
def device_state_attributes(self):
"""Return sensor attributes."""
return self._attributes
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return API_WORX_SENSORS[self._sensor_type]["unit"]
@property
def icon(self):
"""Icon to use in the frontend."""
if self._sensor_type == "battery" and isinstance(self.state, int):
charging = self._attributes["charging"]
return icon_for_battery_level(battery_level=self.state, charging=charging)
return API_WORX_SENSORS[self._sensor_type]["icon"]
@property
def should_poll(self):
"""Return False as entity is updated from the component."""
return False
@property
def state(self):
"""Return sensor state."""
return self._state
@callback
def update_callback(self):
"""Get new data and update state."""
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self):
"""Connect update callbacks."""
async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self.update_callback)
def _get_data(self):
"""Return new data from the api cache."""
data = self._api.get_data(self._sensor_type)
self._available = True
return data
async def async_update(self):
"""Update the sensor."""
_LOGGER.debug("Updating %s", self.entity_id)
data = self._get_data()
if "state" in data:
_LOGGER.debug(data)
state = data.pop("state")
_LOGGER.debug("Mower %s State %s", self._name, state)
self._attributes.update(data)
self._state = state
if "latitude" in self._attributes:
if self._attributes["latitude"] == None:
del self._attributes["latitude"]
del self._attributes["longitude"]
else:
_LOGGER.debug("No data received for %s", self.entity_id)
reachable = self._api._client.online
if not reachable:
if "_battery" in self.entity_id:
self._state = STATE_UNKNOWN
else:
self._state = STATE_OFFLINE

Féach ar an gComhad

@@ -28,3 +28,9 @@ config:
timeextension:
description: Set time extension. Extension in % ranging from -100 to 100
example: -23
multizone_distances:
description: Set multizone distances. Distances in meter. 0 = Disabled
example: '[15, 80, 120, 155]'
multizone_probabilities:
description: Set multizone probabilities. Probabilities in parts-of-ten. 1 = 10%, 2 = 20%, ...
example: '[5, 1, 2, 2]'

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