Skip to content

Commit

Permalink
feat: enable event picture
Browse files Browse the repository at this point in the history
  • Loading branch information
fuatakgun committed Jun 11, 2023
1 parent 23e9bf4 commit cd0b2b0
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 64 deletions.
36 changes: 28 additions & 8 deletions custom_components/eufy_security/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@
_LOGGER: logging.Logger = logging.getLogger(__package__)


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
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 = get_product_properties_by_filter(
[coordinator.devices.values(), coordinator.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]
entities = [
EufySecurityBinarySensor(coordinator, metadata)
for metadata in product_properties
]

for device in coordinator.devices.values():
entities.append(EufySecurityProductEntity(coordinator, device))
Expand All @@ -36,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
class EufySecurityBinarySensor(BinarySensorEntity, EufySecurityEntity):
"""Base binary sensor entity for integration"""

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

@property
Expand All @@ -48,14 +58,20 @@ def is_on(self):
class EufySecurityProductEntity(BinarySensorEntity, CoordinatorEntity):
"""Debug entity for integration"""

def __init__(self, coordinator: EufySecurityDataUpdateCoordinator, product: Product) -> None:
def __init__(
self, coordinator: EufySecurityDataUpdateCoordinator, product: Product
) -> None:
super().__init__(coordinator)
self.product = product
self.product.set_state_update_listener(coordinator.async_update_listeners)

self._attr_unique_id = f"{DOMAIN}_{self.product.product_type.value}_{self.product.serial_no}_debug"
self._attr_unique_id = (
f"{DOMAIN}_{self.product.product_type.value}_{self.product.serial_no}_debug"
)
self._attr_should_poll = False
self._attr_name = f"{self.product.name} Debug ({self.product.product_type.value})"
self._attr_name = (
f"{self.product.name} Debug ({self.product.product_type.value})"
)

@property
def is_on(self):
Expand All @@ -65,7 +81,11 @@ def is_on(self):
@property
def extra_state_attributes(self):
return {
"properties": self.product.properties,
"properties": {
i: self.product.properties[i]
for i in self.product.properties
if i != "picture"
},
# "metadata": self.product.metadata_org,
"commands": self.product.commands,
"voices": self.product.voices if self.product.is_camera else None,
Expand Down
116 changes: 83 additions & 33 deletions custom_components/eufy_security/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import logging

from haffmpeg.camera import CameraMjpeg

from base64 import b64decode
from homeassistant.components import ffmpeg
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.components.ffmpeg import DATA_FFMPEG
Expand All @@ -30,43 +30,69 @@
_LOGGER: logging.Logger = logging.getLogger(__package__)


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Setup camera entities."""
coordinator: EufySecurityDataUpdateCoordinator = hass.data[DOMAIN][COORDINATOR]
product_properties = []
for product in coordinator.devices.values():
if product.is_camera is True:
product_properties.append(Metadata.parse(product, {"name": "camera", "label": "Camera"}))
product_properties.append(
Metadata.parse(product, {"name": "camera", "label": "Camera"})
)

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

# register entity level services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service("generate_image", {}, "_generate_image")
platform.async_register_entity_service("start_p2p_livestream", {}, "_start_livestream")
platform.async_register_entity_service("stop_p2p_livestream", {}, "_stop_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", Schema.PTZ_SERVICE_SCHEMA.value, "_async_ptz")
platform.async_register_entity_service(
"start_p2p_livestream", {}, "_start_livestream"
)
platform.async_register_entity_service(
"stop_p2p_livestream", {}, "_stop_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", Schema.PTZ_SERVICE_SCHEMA.value, "_async_ptz"
)
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")
platform.async_register_entity_service("ptz_right", {}, "_async_ptz_right")
platform.async_register_entity_service("ptz_360", {}, "_async_ptz_360")

platform.async_register_entity_service(
"trigger_camera_alarm_with_duration", Schema.TRIGGER_ALARM_SERVICE_SCHEMA.value, "_async_alarm_trigger"
"trigger_camera_alarm_with_duration",
Schema.TRIGGER_ALARM_SERVICE_SCHEMA.value,
"_async_alarm_trigger",
)
platform.async_register_entity_service("reset_alarm", {}, "_async_reset_alarm")
platform.async_register_entity_service("quick_response", Schema.QUICK_RESPONSE_SERVICE_SCHEMA.value, "_async_quick_response")
platform.async_register_entity_service(
"quick_response",
Schema.QUICK_RESPONSE_SERVICE_SCHEMA.value,
"_async_quick_response",
)
platform.async_register_entity_service("snooze", Schema.SNOOZE.value, "_snooze")


class EufySecurityCamera(Camera, EufySecurityEntity):
"""Base camera entity for integration"""

def __init__(self, coordinator: EufySecurityDataUpdateCoordinator, metadata: Metadata) -> None:
def __init__(
self, coordinator: EufySecurityDataUpdateCoordinator, metadata: Metadata
) -> None:
Camera.__init__(self)
EufySecurityEntity.__init__(self, coordinator, metadata)
self._attr_supported_features = CameraEntityFeature.STREAM
Expand All @@ -92,7 +118,12 @@ async def handle_async_mjpeg_stream(self, request):
stream = CameraMjpeg(self.ffmpeg.binary)
await stream.open_camera(stream_source)
try:
return await async_aiohttp_proxy_stream(self.hass, request, await stream.get_reader(), self.ffmpeg.ffmpeg_stream_content_type)
return await async_aiohttp_proxy_stream(
self.hass,
request,
await stream.get_reader(),
self.ffmpeg.ffmpeg_stream_content_type,
)
finally:
await stream.close()

Expand All @@ -106,7 +137,9 @@ async def async_create_stream(self):
return await super().async_create_stream()

async def _start_hass_streaming(self):
await wait_for_value_to_equal(self.product.__dict__, "stream_status", StreamStatus.STREAMING)
await wait_for_value_to_equal(
self.product.__dict__, "stream_status", StreamStatus.STREAMING
)
await self._stop_hass_streaming()
await self.async_create_stream()
if self.stream is not None:
Expand Down Expand Up @@ -134,36 +167,45 @@ async def _get_image_from_hass_stream(self, width, height):

async def _get_image_from_stream_url(self, width, height):
while True:
result = await ffmpeg.async_get_image(self.hass, await self.stream_source(), width=width, height=height)
result = await ffmpeg.async_get_image(
self.hass, await self.stream_source(), width=width, height=height
)
if result is not None:
_LOGGER.debug(f"_get_image_from_stream_url - received {len(result)}")
return result
_LOGGER.debug(f"_get_image_from_stream_url - is_empty {result is None}")
await asyncio.sleep(STREAM_SLEEP_SECONDS)

async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
_LOGGER.debug(f"image 1 - {self.is_streaming} - {self.stream}")
if self.is_streaming is True:
if self.stream is not None:
with contextlib.suppress(asyncio.TimeoutError):
self._last_image = await asyncio.wait_for(self._get_image_from_stream_url(width, height), STREAM_TIMEOUT_SECONDS)
self._last_image = await asyncio.wait_for(
self._get_image_from_stream_url(width, height),
STREAM_TIMEOUT_SECONDS,
)
# self._last_image = await asyncio.wait_for(self._get_image_from_hass_stream(width, height), STREAM_TIMEOUT_SECONDS)
_LOGGER.debug(f"image 2 with hass stream - is_empty {self._last_image is None}")
_LOGGER.debug(
f"image 2 with hass stream - is_empty {self._last_image is None}"
)
else:
with contextlib.suppress(asyncio.TimeoutError):
self._last_image = await asyncio.wait_for(self._get_image_from_stream_url(width, height), STREAM_TIMEOUT_SECONDS)
_LOGGER.debug(f"image 2 without hass stream - is_empty {self._last_image is None}")
self._last_url = None

# until encryption is fixed on thumbnails and notifications this section is turned off
# else:
# current_url = get_child_value(self.product.properties, MessageField.PICTURE_URL.value)
# 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
# _LOGGER.debug(f"async_camera_image 4 - is_empty {self._last_image is None}")
self._last_image = await asyncio.wait_for(
self._get_image_from_stream_url(width, height),
STREAM_TIMEOUT_SECONDS,
)
_LOGGER.debug(
f"image 2 without hass stream - is_empty {self._last_image is None}"
)

else:
if self.product.picture_base64 is not None:
self._last_image = bytearray(
self.product.picture_base64["data"]["data"]
)

_LOGGER.debug(f"async_camera_image 5 - is_empty {self._last_image is None}")
if self._last_image is not None:
Expand Down Expand Up @@ -240,5 +282,13 @@ async def _generate_image(self) -> None:
async def _async_quick_response(self, voice_id: int) -> None:
await self.product.quick_response(voice_id)

async def _snooze(self, snooze_time: int, snooze_chime: bool, snooze_motion: bool, snooze_homebase: bool) -> None:
await self.product.snooze(snooze_time, snooze_chime, snooze_motion, snooze_homebase)
async def _snooze(
self,
snooze_time: int,
snooze_chime: bool,
snooze_motion: bool,
snooze_homebase: bool,
) -> None:
await self.product.snooze(
snooze_time, snooze_chime, snooze_motion, snooze_homebase
)
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ async def _set_products(self) -> None:
self._captcha_future = asyncio.get_event_loop().create_future()
self._mfa_future = asyncio.get_event_loop().create_future()
result = await self._start_listening()
_LOGGER.debug(f"_set_products 2")

if result[MessageField.STATE.value][EventSourceType.driver.name][MessageField.CONNECTED.value] is False:
await self._check_interactive_mode()

Expand Down Expand Up @@ -148,6 +150,9 @@ async def _set_schema(self, schema_version: int) -> None:
await self._send_message_get_response(OutgoingMessage(OutgoingMessageType.set_api_schema, schema_version=schema_version))

# driver level commands
async def _disconnect_driver(self) -> None:
await self._send_message_get_response(OutgoingMessage(OutgoingMessageType.disconnect))

async def _connect_driver(self) -> None:
await self._send_message_get_response(OutgoingMessage(OutgoingMessageType.connect))

Expand Down
51 changes: 40 additions & 11 deletions custom_components/eufy_security/eufy_security_api/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ class StreamProvider(Enum):
"""Stream provider"""

RTSP = "{rtsp_stream_url}" # replace with rtsp url from device
P2P = "rtsp://{server_address}:{server_port}/{serial_no}" # replace with stream name
P2P = (
"rtsp://{server_address}:{server_port}/{serial_no}" # replace with stream name
)


class PTZCommand(Enum):
Expand Down Expand Up @@ -127,7 +129,9 @@ async def _is_stream_url_ready(self) -> bool:
_LOGGER.debug("_is_stream_url_ready - 1")
with contextlib.suppress(Exception):
while True:
async with RTSPReader(self.stream_url.replace("rtsp://", "rtspt://")) as reader:
async with RTSPReader(
self.stream_url.replace("rtsp://", "rtspt://")
) as reader:
_LOGGER.debug("_is_stream_url_ready - 2 - reader opened")
async for pkt in reader.iter_packets():
_LOGGER.debug(f"_is_stream_url_ready - 3 - received {len(pkt)}")
Expand All @@ -141,15 +145,19 @@ async def start_livestream(self) -> bool:
self.set_stream_prodiver(StreamProvider.P2P)
self.stream_status = StreamStatus.PREPARING
await self.api.start_livestream(self.product_type, self.serial_no)
self.p2p_stream_thread = threading.Thread(target=self.p2p_stream_handler.setup, daemon=True)
self.p2p_stream_thread = threading.Thread(
target=self.p2p_stream_handler.setup, daemon=True
)
self.p2p_stream_thread.start()
await wait_for_value(self.p2p_stream_handler.__dict__, "port", None)

if self.codec is not None:
await self._start_ffmpeg()

with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.p2p_started_event.wait(), STREAM_TIMEOUT_SECONDS)
await asyncio.wait_for(
self.p2p_started_event.wait(), STREAM_TIMEOUT_SECONDS
)

if self.p2p_started_event.is_set() is False:
return False
Expand Down Expand Up @@ -190,27 +198,39 @@ async def stop_rtsp_livestream(self):
await self.api.stop_rtsp_livestream(self.product_type, self.serial_no)

async def ptz(self, direction: str) -> None:
await self.api.pan_and_tilt(self.product_type, self.serial_no, PTZCommand[direction].value)
await self.api.pan_and_tilt(
self.product_type, self.serial_no, PTZCommand[direction].value
)

async def ptz_up(self) -> None:
"""Look up"""
await self.api.pan_and_tilt(self.product_type, self.serial_no, PTZCommand.UP.value)
await self.api.pan_and_tilt(
self.product_type, self.serial_no, PTZCommand.UP.value
)

async def ptz_down(self) -> None:
"""Look down"""
await self.api.pan_and_tilt(self.product_type, self.serial_no, PTZCommand.DOWN.value)
await self.api.pan_and_tilt(
self.product_type, self.serial_no, PTZCommand.DOWN.value
)

async def ptz_left(self) -> None:
"""Look left"""
await self.api.pan_and_tilt(self.product_type, self.serial_no, PTZCommand.LEFT.value)
await self.api.pan_and_tilt(
self.product_type, self.serial_no, PTZCommand.LEFT.value
)

async def ptz_right(self) -> None:
"""Look right"""
await self.api.pan_and_tilt(self.product_type, self.serial_no, PTZCommand.RIGHT.value)
await self.api.pan_and_tilt(
self.product_type, self.serial_no, PTZCommand.RIGHT.value
)

async def ptz_360(self) -> None:
"""Look around 360 degrees"""
await self.api.pan_and_tilt(self.product_type, self.serial_no, PTZCommand.ROTATE360.value)
await self.api.pan_and_tilt(
self.product_type, self.serial_no, PTZCommand.ROTATE360.value
)

async def quick_response(self, voice_id: int) -> None:
"""Quick response message to camera"""
Expand All @@ -224,13 +244,22 @@ def is_rtsp_supported(self) -> bool:
@property
def is_rtsp_enabled(self) -> bool:
"""Returns True if RTSP stream is configured and enabled for camera"""
return False if self.is_rtsp_supported is False else self.properties.get(MessageField.RTSP_STREAM.value)
return (
False
if self.is_rtsp_supported is False
else self.properties.get(MessageField.RTSP_STREAM.value)
)

@property
def rtsp_stream_url(self) -> str:
"""Returns RTSP stream URL from physical device"""
return self.properties.get(MessageField.RTSP_STREAM_URL.value)

@property
def picture_base64(self) -> str:
"""Returns picture bytes in base64 format"""
return self.properties.get(MessageField.PICTURE.value)

def set_stream_prodiver(self, stream_provider: StreamProvider) -> None:
"""Set stream provider for camera instance"""
self.stream_provider = stream_provider
Expand Down
Loading

4 comments on commit cd0b2b0

@miguelpineiroo
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After updating and rebootong HA, integration not running (obtained error: cannot configure the integration). Needed to restore HA from a full backup and running again

@ajvdw
Copy link

@ajvdw ajvdw commented on cd0b2b0 Jun 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thumbnail appeared in HA but is not refreshed on events.

@stijnb1234
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After changing to the new add-on, it works great! Thanks a lot ;)

@miguelpineiroo
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After updating and rebootong HA, integration not running (obtained error: cannot configure the integration). Needed to restore HA from a full backup and running again

Thanks @stijnb1234 , I had the old add-on, and after upgrading "eufy security" it failed, but I have moved to the new add-on, and upgraded eufy security and now it is fine. Thanks!

Please sign in to comment.