Skip to content

Commit

Permalink
feat: enable trigger alarm for station
Browse files Browse the repository at this point in the history
  • Loading branch information
fuatakgun committed Jan 4, 2023
1 parent 0ce1349 commit f9e937f
Show file tree
Hide file tree
Showing 15 changed files with 129 additions and 81 deletions.
11 changes: 8 additions & 3 deletions custom_components/eufy_security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ async def handle_send_message(call):
_LOGGER.debug(f"{DOMAIN} - send_message - call.data: {call.data}")
message = call.data.get("message")
_LOGGER.debug(f"{DOMAIN} - end_message - message: {message}")
await coordinator.api.send_message(message)
await coordinator.send_message(message)

async def handle_force_sync(call):
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
await coordinator.async_refresh()

async def handle_log_level(call):
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
await coordinator.api.set_log_level(call.data.get("log_level"))
await coordinator.set_log_level(call.data.get("log_level"))

hass.services.async_register(DOMAIN, "force_sync", handle_force_sync)
hass.services.async_register(DOMAIN, "send_message", handle_send_message)
Expand All @@ -52,6 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
coordinator.platforms.append(platform.value)
hass.async_add_job(hass.config_entries.async_forward_entry_setup(config_entry, platform.value))

async def update(event_time_utc):
await coordinator.async_refresh()

async_track_time_interval(hass, update, timedelta(seconds=coordinator.config.sync_interval))

config_entry.add_update_listener(async_reload_entry)
return True

Expand All @@ -65,7 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
)
)
if unloaded:
await coordinator.api.disconnect()
await coordinator.disconnect()
hass.data[DOMAIN] = {}

return unloaded
Expand Down
3 changes: 1 addition & 2 deletions custom_components/eufy_security/alarm_control_panel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import asyncio
from enum import Enum, auto
import logging

Expand Down Expand Up @@ -61,7 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
"""Setup alarm control panel entities."""
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
product_properties = []
for product in coordinator.api.stations.values():
for product in coordinator.stations.values():
if product.has(MessageField.GUARD_MODE.value) is True:
product_properties.append(product.metadata[MessageField.CURRENT_MODE.value])

Expand Down
6 changes: 3 additions & 3 deletions custom_components/eufy_security/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
"""Setup binary sensor entities."""
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
product_properties = get_product_properties_by_filter(
[coordinator.api.devices.values(), coordinator.api.stations.values()], PlatformToPropertyType[Platform.BINARY_SENSOR.name].value
[coordinator.devices.values(), coordinator.stations.values()], PlatformToPropertyType[Platform.BINARY_SENSOR.name].value
)
entities = [EufySecurityBinarySensor(coordinator, metadata) for metadata in product_properties]

for device in coordinator.api.devices.values():
for device in coordinator.devices.values():
entities.append(EufySecurityProductEntity(coordinator, device))

for device in coordinator.api.stations.values():
for device in coordinator.stations.values():
entities.append(EufySecurityProductEntity(coordinator, device))
async_add_entities(entities)

Expand Down
33 changes: 16 additions & 17 deletions custom_components/eufy_security/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
"""Setup binary sensor entities."""
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
product_properties = []
for product in coordinator.api.devices.values():
if product.is_camera is True:
for command in ProductCommand:
handler_func = getattr(product, f"{command.name}", None)
if handler_func is None:
continue
if command.value.command is not None:
if command.value.command == "is_rtsp_enabled":
if product.is_rtsp_enabled is False:
continue
else:
if command.value.command not in product.commands:
continue

product_properties.append(
Metadata.parse(product, {"name": command.name, "label": command.value.description, "command": command.value})
)
for product in list(coordinator.devices.values()) + list(coordinator.stations.values()):
for command in ProductCommand:
handler_func = getattr(product, f"{command.name}", None)
if handler_func is None:
continue
if command.value.command is not None:
if command.value.command == "is_rtsp_enabled":
if product.is_rtsp_enabled is False:
continue
else:
if command.value.command not in product.commands:
continue

product_properties.append(
Metadata.parse(product, {"name": command.name, "label": command.value.description, "command": command.value})
)

entities = [EufySecurityButtonEntity(coordinator, metadata) for metadata in product_properties]
async_add_entities(entities)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/eufy_security/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
"""Setup camera entities."""
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
product_properties = []
for product in coordinator.api.devices.values():
for product in coordinator.devices.values():
if product.is_camera is True:
product_properties.append(Metadata.parse(product, {"name": "camera", "label": "Camera"}))

Expand Down
4 changes: 2 additions & 2 deletions custom_components/eufy_security/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ async def async_step_user(self, user_input=None):
coordinator = self.hass.data[DOMAIN][COORDINATOR]
if coordinator.config.mfa_required is True:
mfa_input = user_input[ConfigField.mfa_input.name]
await coordinator.api.set_mfa_and_connect(mfa_input)
await coordinator.set_mfa_and_connect(mfa_input)
else:
captcha_id = coordinator.config.captcha_id
captcha_input = user_input[ConfigField.captcha_input.name]
coordinator.config.captcha_id = None
coordinator.config.captcha_img = None
await coordinator.api.set_captcha_and_connect(captcha_id, captcha_input)
await coordinator.set_captcha_and_connect(captcha_id, captcha_input)

if self._async_current_entries():
await self.hass.config_entries.async_reload(self.context["entry_id"])
Expand Down
37 changes: 32 additions & 5 deletions custom_components/eufy_security/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""Module to initialize coordinator"""
from datetime import timedelta
import logging

import json
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import DOMAIN
from .eufy_security_api.api_client import ApiClient
Expand All @@ -31,12 +31,12 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
)
self._platforms = []
self.data = {}
self.api = ApiClient(self.config, aiohttp_client.async_get_clientsession(self.hass))
self._api = ApiClient(self.config, aiohttp_client.async_get_clientsession(self.hass))

async def initialize(self):
"""Initialize the integration"""
try:
await self.api.connect()
await self._api.connect()
except CaptchaRequiredException as exc:
self.config.captcha_id = exc.captcha_id
self.config.captcha_img = exc.captcha_img
Expand All @@ -54,6 +54,33 @@ def platforms(self):
"""Initialized platforms list"""
return self._platforms

@property
def devices(self) -> dict:
return self._api.devices

@property
def stations(self) -> dict:
return self._api.stations

async def set_mfa_and_connect(self, mfa_input: str):
await self._api.set_mfa_and_connect(mfa_input)

async def set_captcha_and_connect(self, captcha_id: str, captcha_input: str):
await self._api.set_captcha_and_connect(captcha_id, captcha_input)

async def send_message(self, message: dict) -> None:
"""send message to websocket api"""
_LOGGER.debug(f"send_message - {message}")
await self._api.send_message(json.dumps(message))

async def set_log_level(self, log_level: str) -> None:
"""set log level of websocket server"""
await self._api.set_log_level(log_level)

async def _update_local(self):
await self.api.poll_refresh()
await self._api.poll_refresh()
return self.data

async def disconnect(self):
"""disconnect from api"""
await self._api.disconnect()
95 changes: 56 additions & 39 deletions custom_components/eufy_security/eufy_security_api/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,50 +41,67 @@ class ApiClient:
"""Client to communicate with eufy-security-ws over websocket connection"""

def __init__(self, config, session: aiohttp.ClientSession) -> None:
self.config = config
self.session: aiohttp.ClientSession = session
self.loop = asyncio.get_event_loop()
self.client: WebSocketClient = WebSocketClient(
self.config.host, self.config.port, self.session, self._on_open, self._on_message, self._on_close, self._on_error
self._config = config
self._client: WebSocketClient = WebSocketClient(
self._config.host, self._config.port, session, self._on_open, self._on_message, self._on_close, self._on_error
)
self.result_futures: dict[str, asyncio.Future] = {}
self.devices: dict = None
self.stations: dict = None
self.captcha_future: asyncio.Future[dict] = self.loop.create_future()
self.mfa_future: asyncio.Future[dict] = self.loop.create_future()
self._result_futures: dict[str, asyncio.Future] = {}
self._devices: dict = None
self._stations: dict = None
self._captcha_future: asyncio.Future[dict] = asyncio.get_event_loop().create_future()
self._mfa_future: asyncio.Future[dict] = asyncio.get_event_loop().create_future()

@property
def devices(self) -> dict:
""" initialized devices """
return self._devices

@property
def stations(self) -> dict:
""" initialized stations """
return self._stations

async def ws_connect(self):
"""set initial websocket connection"""
await self.client.connect()
await self._client.connect()

async def connect(self):
"""Set up web socket connection and set products"""
await self.ws_connect()
await self._set_schema(SCHEMA_VERSION)
await self._set_products()

async def _check_interactive_mode(self):
# driver is not connected, wait for captcha event
try:
_LOGGER.debug(f"_start_listening 2")
await asyncio.wait_for(self._captcha_future, timeout=10)
event = self._captcha_future.result()
raise CaptchaRequiredException(event.data[MessageField.CAPTCHA_ID.value], event.data[MessageField.CAPTCHA_IMG.value])
except (asyncio.exceptions.TimeoutError, asyncio.exceptions.CancelledError):
pass
_LOGGER.debug(f"_start_listening 2")
# driver is not connected and captcha exception is not thrown, wait for mfa event
try:
_LOGGER.debug(f"_start_listening 3")
await asyncio.wait_for(self._mfa_future, timeout=5)
event = self._mfa_future.result()
raise MultiFactorCodeRequiredException()
except (asyncio.exceptions.TimeoutError, asyncio.exceptions.CancelledError) as exc:
_LOGGER.debug(f"_start_listening 4")
await self._connect_driver()
raise DriverNotConnectedError() from exc

async def _set_products(self) -> None:
_LOGGER.debug(f"_start_listening 1")
self._captcha_future = asyncio.get_event_loop().create_future()
self._mfa_future = asyncio.get_event_loop().create_future()
result = await self._send_message_get_response(OutgoingMessage(OutgoingMessageType.start_listening))
if result[MessageField.STATE.value][EventSourceType.driver.name][MessageField.CONNECTED.value] is False:
try:
await asyncio.wait_for(self.captcha_future, timeout=5)
event = self.captcha_future.result()
raise CaptchaRequiredException(event.data[MessageField.CAPTCHA_ID.value], event.data[MessageField.CAPTCHA_IMG.value])
except (asyncio.exceptions.TimeoutError, asyncio.exceptions.CancelledError):
pass

# driver is not connected and there is no captcha event, so it is probably mfa
# reconnect driver to get mfa event
try:
await asyncio.wait_for(self.mfa_future, timeout=5)
event = self.mfa_future.result()
raise MultiFactorCodeRequiredException()
except (asyncio.exceptions.TimeoutError, asyncio.exceptions.CancelledError) as exc:
await self._connect_driver()
raise DriverNotConnectedError() from exc
await self._check_interactive_mode()

self.devices = await self._get_product(ProductType.device, result[MessageField.STATE.value]["devices"])
self.stations = await self._get_product(ProductType.station, result[MessageField.STATE.value]["stations"])
self._devices = await self._get_product(ProductType.device, result[MessageField.STATE.value]["devices"])
self._stations = await self._get_product(ProductType.station, result[MessageField.STATE.value]["stations"])

async def _get_product(self, product_type: ProductType, products: list) -> dict:
response = {}
Expand All @@ -100,7 +117,7 @@ async def _get_product(self, product_type: ProductType, products: list) -> dict:
is_p2p_streaming = await self._get_is_p2p_streaming(product_type, serial_no)
voices = await self._get_voices(product_type, serial_no)
product = Camera(
self, serial_no, properties, metadata, commands, self.config, is_rtsp_streaming, is_p2p_streaming, voices
self, serial_no, properties, metadata, commands, self._config, is_rtsp_streaming, is_p2p_streaming, voices
)
else:
product = Device(self, serial_no, properties, metadata, commands)
Expand Down Expand Up @@ -282,7 +299,7 @@ async def _on_message(self, message: dict) -> None:
if "livestream video data" not in message_str and "livestream audio data" not in message_str:
_LOGGER.debug(f"_on_message - {message_str}")
if message[MessageField.TYPE.value] == IncomingMessageType.result.name:
future = self.result_futures.get(message.get(MessageField.MESSAGE_ID.value, -1), None)
future = self._result_futures.get(message.get(MessageField.MESSAGE_ID.value, -1), None)

if future is None:
return
Expand All @@ -308,7 +325,7 @@ async def _on_message(self, message: dict) -> None:
async def _handle_event(self, event: Event):
if event.data[MessageField.SOURCE.value] in [EventSourceType.station.name, EventSourceType.device.name]:
# handle device or statino specific events through specific instances
plural_product = event.data[MessageField.SOURCE.value] + "s"
plural_product = "_" + event.data[MessageField.SOURCE.value] + "s"
try:
product = self.__dict__[plural_product][event.data[MessageField.SERIAL_NUMBER.value]]
await product.process_event(event)
Expand All @@ -323,9 +340,9 @@ async def _handle_event(self, event: Event):
async def _process_driver_event(self, event: Event):
"""Process driver level events"""
if event.type == EventNameToHandler.captcha_request.value:
self.captcha_future.set_result(event)
self._captcha_future.set_result(event)
if event.type == EventNameToHandler.verify_code.value:
self.mfa_future.set_result(event)
self._mfa_future.set_result(event)

async def _on_open(self) -> None:
_LOGGER.debug("on_open - executed")
Expand All @@ -338,18 +355,18 @@ async def _on_error(self, error: str) -> None:
raise WebSocketConnectionErrorException(error)

async def _send_message_get_response(self, message: OutgoingMessage) -> dict:
future: "asyncio.Future[dict]" = self.loop.create_future()
self.result_futures[message.id] = future
future: "asyncio.Future[dict]" = asyncio.get_event_loop().create_future()
self._result_futures[message.id] = future
await self.send_message(message.content)
try:
return await future
finally:
self.result_futures.pop(message.id)
self._result_futures.pop(message.id)

async def send_message(self, message: dict) -> None:
"""send message to websocket api"""
_LOGGER.debug(f"send_message - {message}")
await self.client.send_message(json.dumps(message))
await self._client.send_message(json.dumps(message))

async def poll_refresh(self) -> None:
"""Poll cloud data for latest changes"""
Expand All @@ -359,7 +376,7 @@ async def poll_refresh(self) -> None:

async def disconnect(self):
"""Disconnect the web socket and destroy it"""
await self.client.disconnect()
await self._client.disconnect()


class IncomingMessageType(Enum):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,5 @@ async def stop(self):
await self.ffmpeg.close(timeout=1)
except:
pass
if self.camera.is_streaming is True:
await self.camera.stop_livestream()
# if self.camera.is_streaming is True:
# await self.camera.stop_livestream()
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,6 @@ def _on_close(self, future="") -> None:

async def send_message(self, message):
"""Send message to websocket"""
if self.socket is None:
raise WebSocketConnectionError()
await self.socket.send_str(message)
2 changes: 1 addition & 1 deletion custom_components/eufy_security/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
"""Setup lock entities."""
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
properties = []
for product in coordinator.api.devices.values():
for product in coordinator.devices.values():
if product.has(MessageField.LOCKED.value) is True:
properties.append(product.metadata[MessageField.LOCKED.value])

Expand Down

0 comments on commit f9e937f

Please sign in to comment.