Skip to content

Commit

Permalink
support 2 factor authentication - 2fa - mfa
Browse files Browse the repository at this point in the history
  • Loading branch information
fuatakgun committed Dec 27, 2022
1 parent 5e22cce commit c560a68
Show file tree
Hide file tree
Showing 12 changed files with 202 additions and 68 deletions.
39 changes: 39 additions & 0 deletions custom_components/eufy_security/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import logging

from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import COORDINATOR, DOMAIN, Platform, PlatformToPropertyType
from .coordinator import EufySecurityDataUpdateCoordinator
from .entity import EufySecurityEntity
from .eufy_security_api.metadata import Metadata
from .eufy_security_api.product import Product
from .eufy_security_api.util import get_child_value
from .util import get_device_info, get_product_properties_by_filter

_LOGGER: logging.Logger = logging.getLogger(__package__)


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
"""Setup binary sensor entities."""
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
product_properties = []
for product in coordinator.api.devices.values():
product_properties.append(Metadata.parse(product, {"name": metadata.name, "label": metadata.value}))

entities = [EufySecurityBinarySensor(coordinator, metadata) for metadata in product_properties]
async_add_entities(entities)


class EufySecurityBinarySensor(ButtonEntity, EufySecurityEntity):
"""Base button entity for integration"""

def __init__(self, coordinator: EufySecurityDataUpdateCoordinator, metadata: Metadata) -> None:
super().__init__(coordinator, metadata)

@property
def is_on(self):
"""Return true if the binary sensor is on."""
return bool(get_child_value(self.product.properties, self.metadata.name))
64 changes: 35 additions & 29 deletions custom_components/eufy_security/camera.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import asyncio
import logging

from haffmpeg.camera import CameraMjpeg
Expand Down Expand Up @@ -41,10 +42,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn

# register entity level services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service("start_p2p_livestream", {}, "start_p2p_livestream")
platform.async_register_entity_service("stop_p2p_livestream", {}, "stop_p2p_livestream")
platform.async_register_entity_service("start_rtsp_livestream", {}, "start_rtsp_livestream")
platform.async_register_entity_service("stop_rtsp_livestream", {}, "stop_rtsp_livestream")
platform.async_register_entity_service("start_p2p_livestream", {}, "_start_p2p_livestream")
platform.async_register_entity_service("stop_p2p_livestream", {}, "_stop_p2p_livestream")
platform.async_register_entity_service("start_rtsp_livestream", {}, "_start_rtsp_livestream")
platform.async_register_entity_service("stop_rtsp_livestream", {}, "_stop_rtsp_livestream")
platform.async_register_entity_service("ptz_up", {}, "_async_ptz_up")
platform.async_register_entity_service("ptz_down", {}, "_async_ptz_down")
platform.async_register_entity_service("ptz_left", {}, "_async_ptz_left")
Expand All @@ -63,7 +64,6 @@ def __init__(self, coordinator: EufySecurityDataUpdateCoordinator, metadata: Met
EufySecurityEntity.__init__(self, coordinator, metadata)
self._attr_supported_features = CameraEntityFeature.STREAM
self._attr_name = f"{self.product.name}"
self._attr_frontend_stream_type = StreamType.HLS

# camera image
self._last_url = None
Expand Down Expand Up @@ -91,11 +91,17 @@ def available(self) -> bool:

async def _start_streaming(self):
await wait_for_value_to_equal(self.product.__dict__, "stream_status", StreamStatus.STREAMING)
await asyncio.sleep(3)
await self.async_create_stream()
self.stream.add_provider("hls")
await self.stream.start()
asyncio.ensure_future(self._check_stream_availability())
await self.async_camera_image()

async def _check_stream_availability(self):
if self.is_streaming is True and self.stream is not None and self.stream.available is False:
await self.async_turn_off()
# await self._stop_streaming()

async def _stop_streaming(self):
if self.stream is not None:
await self.stream.stop()
Expand All @@ -106,60 +112,60 @@ def is_streaming(self) -> bool:
"""Return true if the device is recording."""
return self.product.stream_status == StreamStatus.STREAMING

async def start_p2p_livestream(self) -> None:
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
if self.is_streaming is True and self.stream is not None:
self._last_image = await self.stream.async_get_image(width, height)
self._last_url = None
else:
current_url = get_child_value(self.product.properties, self.metadata.name)
if current_url != self._last_url and current_url.startswith("https"):
async with async_get_clientsession(self.coordinator.hass).get(current_url) as response:
if response.status == 200:
self._last_image = await response.read()
self._last_url = current_url
return self._last_image

async def _start_p2p_livestream(self) -> None:
"""start byte based livestream on camera"""
await self.product.start_p2p_livestream(CameraMjpeg(self.ffmpeg.binary))
await self._start_streaming()

async def stop_p2p_livestream(self) -> None:
async def _stop_p2p_livestream(self) -> None:
"""stop byte based livestream on camera"""
await self._stop_streaming()
await self.product.stop_p2p_livestream()

async def start_rtsp_livestream(self) -> None:
async def _start_rtsp_livestream(self) -> None:
"""start rtsp based livestream on camera"""
await self.product.start_rtsp_livestream()
await self._start_streaming()

async def stop_rtsp_livestream(self) -> None:
async def _stop_rtsp_livestream(self) -> None:
"""stop rtsp based livestream on camera"""
await self._stop_streaming()
await self.product.stop_rtsp_livestream()

async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
if self.is_streaming is True and self.stream is not None:
self._last_image = await self.stream.async_get_image()
self._last_url = None
else:
current_url = get_child_value(self.product.properties, self.metadata.name)
if current_url != self._last_url and current_url.startswith("https"):
async with async_get_clientsession(self.coordinator.hass).get(current_url) as response:
if response.status == 200:
self._last_image = await response.read()
self._last_url = current_url
return self._last_image

async def async_alarm_trigger(self, code: str | None = None) -> None:
async def _async_alarm_trigger(self, code: str | None = None) -> None:
"""trigger alarm for a duration on camera"""
await self.product.trigger_alarm(self.metadata)

async def async_reset_alarm(self) -> None:
async def _async_reset_alarm(self) -> None:
"""reset ongoing alarm"""
await self.product.reset_alarm(self.metadata)

async def async_turn_on(self) -> None:
"""Turn off camera."""
if self.product.stream_provider == StreamProvider.RTSP:
await self.start_rtsp_livestream()
await self._start_rtsp_livestream()
else:
await self.start_p2p_livestream()
await self._start_p2p_livestream()

async def async_turn_off(self) -> None:
"""Turn off camera."""
if self.product.stream_provider == StreamProvider.RTSP:
await self.stop_rtsp_livestream()
await self._stop_rtsp_livestream()
else:
await self.stop_p2p_livestream()
await self._stop_p2p_livestream()

async def _async_ptz_up(self) -> None:
await self.product.ptz_up()
Expand Down
52 changes: 34 additions & 18 deletions custom_components/eufy_security/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ async def async_step_user(self, user_input=None):

if self.source == SOURCE_REAUTH:
coordinator = self.hass.data[DOMAIN][COORDINATOR]
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)
if coordinator.config.mfa_required is True:
mfa_input = user_input[ConfigField.mfa_input.name]
await coordinator.api.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)

if self._async_current_entries():
await self.hass.config_entries.async_reload(self.context["entry_id"])
Expand Down Expand Up @@ -119,19 +122,32 @@ async def _test_credentials(self, host, port): # pylint: disable=unused-argumen
async def async_step_reauth(self, user_input=None):
"""initialize captcha flow"""
_LOGGER.debug(f"{DOMAIN} async_step_reauth - {user_input}")
return await self._async_step_reauth_confirm()
return await self.async_step_reauth_confirm()

async def _async_step_reauth_confirm(self, user_input=None):
async def async_step_reauth_confirm(self, user_input=None):
"""Re-authenticate via captcha or mfa code"""
coordinator = self.hass.data[DOMAIN][COORDINATOR]
_LOGGER.debug(f"{DOMAIN} async_step_reauth_confirm - {coordinator.config.captcha_img}")
_LOGGER.debug(f"{DOMAIN} async_step_reauth_confirm - {coordinator.config}")
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(ConfigField.captcha_input.name): str,
}
),
description_placeholders={"captcha_img": '<img id="eufy_security_captcha" src="' + coordinator.config.captcha_img + '"/>'},
)
if coordinator.config.mfa_required is True:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(ConfigField.mfa_input.name): str,
}
),
)
else:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(ConfigField.captcha_input.name): str,
}
),
description_placeholders={
"captcha_img": '<img id="eufy_security_captcha" src="' + coordinator.config.captcha_img + '"/>'
},
)
return await self.async_step_user(user_input)
1 change: 1 addition & 0 deletions custom_components/eufy_security/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
Platform.ALARM_CONTROL_PANEL,
Platform.NUMBER,
Platform.CAMERA,
# Platform.BUTTON,
]


Expand Down
11 changes: 8 additions & 3 deletions custom_components/eufy_security/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from .eufy_security_api.api_client import ApiClient
from .eufy_security_api.exceptions import (
CaptchaRequiredException,
DriverNotConnectedError,
MultiFactorCodeRequiredException,
WebSocketConnectionError,
)
from .model import Config
Expand All @@ -38,9 +40,12 @@ async def initialize(self):
except CaptchaRequiredException as exc:
self.config.captcha_id = exc.captcha_id
self.config.captcha_img = exc.captcha_img
raise ConfigEntryAuthFailed(
"Warning: Captcha required - Go to Configurations page and enter captcha code for Eufy Security Integration"
) from exc
raise ConfigEntryAuthFailed() from exc
except MultiFactorCodeRequiredException as exc:
self.config.mfa_required = True
raise ConfigEntryAuthFailed() from exc
except DriverNotConnectedError as exc:
raise ConfigEntryNotReady() from exc
except WebSocketConnectionError as exc:
raise ConfigEntryNotReady() from exc

Expand Down
58 changes: 48 additions & 10 deletions custom_components/eufy_security/eufy_security_api/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
from .exceptions import (
CaptchaRequiredException,
DeviceNotInitializedYetException,
DriverNotConnectedError,
FailedCommandException,
IncompatibleVersionException,
MultiFactorCodeRequiredException,
UnexpectedMessageTypeException,
UnknownEventSourceException,
WebSocketConnectionErrorException,
Expand All @@ -51,7 +53,8 @@ def __init__(self, host: str, port: int, session: aiohttp.ClientSession) -> None
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.captcha_future: asyncio.Future[dict] = self.get_new_future()
self.mfa_future: asyncio.Future[dict] = self.get_new_future()

async def connect(self):
"""Set up web socket connection and set products"""
Expand All @@ -65,21 +68,30 @@ async def set_captcha_and_connect(self, captcha_id: str, captcha_input: str):
await asyncio.sleep(10)
await self._set_products()

async def set_mfa_and_connect(self, mfa_input: str):
"""Set captcha set products"""
await self._set_mfa_code(mfa_input)
await asyncio.sleep(10)
await self._set_products()

def get_new_future(self):
return self.loop.create_future()

async def _set_captcha(self, captcha_id: str, captcha_input: str) -> None:
command_type = OutgoingMessageType.set_captcha
command = EventSourceType.driver.name + "." + command_type.name
await self._send_message_get_response(
OutgoingMessage(command_type, command=command, captcha_id=captcha_id, captcha_input=captcha_input)
)

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

async def poll_refresh(self) -> None:
"""Poll cloud data for latest changes"""
command_type = OutgoingMessageType.poll_refresh
async def _set_mfa_code(self, mfa_input: str) -> None:
command_type = OutgoingMessageType.set_verify_code
command = EventSourceType.driver.name + "." + command_type.name
await self._send_message_get_response(OutgoingMessage(command_type, command=command, verify_code=mfa_input))

async def _connect_driver(self) -> None:
command_type = OutgoingMessageType.driver_connect
command = EventSourceType.driver.name + "." + "connect"
await self._send_message_get_response(OutgoingMessage(command_type, command=command))

async def _set_schema(self, schema_version: int) -> None:
Expand All @@ -88,8 +100,22 @@ async def _set_schema(self, schema_version: int) -> None:
async def _set_products(self) -> None:
result = await self._send_message_get_response(OutgoingMessage(OutgoingMessageType.start_listening))
if result[MessageField.STATE.value][EventSourceType.driver.name][MessageField.CONNECTED.value] is False:
event = await self.captcha_future
raise CaptchaRequiredException(event.data[MessageField.CAPTCHA_ID.value], event.data[MessageField.CAPTCHA_IMG.value])
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):
await self._connect_driver()
raise DriverNotConnectedError() from exc

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"])
Expand Down Expand Up @@ -236,6 +262,8 @@ 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)
if event.type == EventNameToHandler.verify_code.value:
self.mfa_future.set_result(event)

async def _on_open(self) -> None:
_LOGGER.debug("on_open - executed")
Expand All @@ -260,3 +288,13 @@ 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))

async def poll_refresh(self) -> None:
"""Poll cloud data for latest changes"""
command_type = OutgoingMessageType.poll_refresh
command = EventSourceType.driver.name + "." + command_type.name
await self._send_message_get_response(OutgoingMessage(command_type, command=command))

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

0 comments on commit c560a68

Please sign in to comment.