diff --git a/README.md b/README.md index 1597d80..c2c3df2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ yi-hack Home Assistant is a custom integration for Yi cameras (or Sonoff camera) - yi-hack-MStar - https://github.com/roleoroleo/yi-hack-MStar - yi-hack-Allwinner - https://github.com/roleoroleo/yi-hack-Allwinner - yi-hack-Allwinner-v2 - https://github.com/roleoroleo/yi-hack-Allwinner-v2 -- yi-hack-v5 - https://github.com/alienatedsec/yi-hack-v5 +- yi-hack-v5 (partial support) - https://github.com/alienatedsec/yi-hack-v5 - sonoff-hack - https://github.com/roleoroleo/sonoff-hack
And make sure you have the latest version. @@ -30,6 +30,8 @@ The wizard will connect to your cam and will install the following entities: (*) available only if your cam supports it. +If you configure motion detection in your camera you will be able to view the videos in the "Media" section (left panel of the main page). + ## Installation **(1)** Copy the `custom_components` folder your configuration directory. It should look similar to this: @@ -46,9 +48,11 @@ It should look similar to this: | |-- const.py | |-- manifest.json | |-- media_player.py +| |-- media_source.py | |-- services.yaml | |-- strings.json | |-- switch.py +| |-- views.py ``` **(2)** Restart Home Assistant diff --git a/custom_components/yi_hack/__init__.py b/custom_components/yi_hack/__init__.py index a6dd9f6..b168c71 100644 --- a/custom_components/yi_hack/__init__.py +++ b/custom_components/yi_hack/__init__.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .common import get_mqtt_conf, get_system_conf from .const import (ALLWINNER, ALLWINNERV2, CONF_BABY_CRYING_MSG, @@ -18,6 +19,8 @@ CONF_WILL_MSG, DEFAULT_BRAND, DOMAIN, END_OF_POWER_OFF, END_OF_POWER_ON, MSTAR, PRIVACY, SONOFF, V5) +from .views import VideoProxyView + PLATFORMS = ["camera", "binary_sensor", "media_player", "switch"] PLATFORMS_NOMEDIA = ["camera", "binary_sensor", "switch"] @@ -82,6 +85,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_forward_entry_setup(entry, component) ) + session = async_get_clientsession(hass) + hass.http.register_view(VideoProxyView(hass, session)) + return True else: _LOGGER.error("Unable to get configuration from the cam") diff --git a/custom_components/yi_hack/manifest.json b/custom_components/yi_hack/manifest.json index 22a2d12..baeb36f 100644 --- a/custom_components/yi_hack/manifest.json +++ b/custom_components/yi_hack/manifest.json @@ -2,7 +2,7 @@ "domain": "yi_hack", "name": "Yi Home Cameras with yi-hack", "documentation": "https://github.com/roleoroleo/yi-hack_ha_integration", - "dependencies": ["ffmpeg", "mqtt"], + "dependencies": ["ffmpeg", "http", "media_source", "mqtt"], "codeowners": ["@roleoroleo"], "iot_class": "local_push", "config_flow": true, diff --git a/custom_components/yi_hack/media_source.py b/custom_components/yi_hack/media_source.py new file mode 100644 index 0000000..e066a2d --- /dev/null +++ b/custom_components/yi_hack/media_source.py @@ -0,0 +1,268 @@ +"""yi-hack Media Source Implementation.""" +from __future__ import annotations + +import datetime as dt +import logging +import requests +from requests.auth import HTTPBasicAuth + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_APP, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_VIDEO, + MEDIA_TYPE_VIDEO, +) +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source.error import MediaSourceError, Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant, callback + +from .const import DEFAULT_BRAND, DOMAIN, HTTP_TIMEOUT + +MIME_TYPE = "video/mp4" +_LOGGER = logging.getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> YiHackMediaSource: + """Set up yi-hack media source.""" + return YiHackMediaSource(hass) + + +class YiHackMediaSource(MediaSource): + """Provide yi-hack camera recordings as media sources.""" + + name: str = DEFAULT_BRAND + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize yi-hack source.""" + super().__init__(DOMAIN) + self.hass = hass + self._devices = [] + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + entry_id, event_dir, event_file = async_parse_identifier(item) + if entry_id is None: + return None + if event_file is None: + return None + if event_dir is None: + return None + + url = "/api/yi-hack/" + entry_id + "/" + event_dir + "/" + event_file + + return PlayMedia(url, MIME_TYPE) + + async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: + """Return media.""" + entry_id, event_dir, event_file = async_parse_identifier(item) + + if len(self._devices) == 0: + device_registry = await self.hass.helpers.device_registry.async_get_registry() + for device in device_registry.devices.values(): + if device.identifiers is not None: + domain = list(list(device.identifiers)[0])[0] + if domain == DOMAIN: + self._devices.append(device) + + return await self.hass.async_add_executor_job(self._browse_media, entry_id, event_dir) + + def _browse_media(self, entry_id:str, event_dir:str) -> BrowseMediaSource: + error = False + host = "" + port = "" + user = "" + password = "" + + if entry_id is None: + media_class = MEDIA_CLASS_DIRECTORY + media = BrowseMediaSource( + domain=DOMAIN, + identifier="root", + media_class=media_class, + media_content_type=MEDIA_TYPE_VIDEO, + title=DOMAIN, + can_play=False, + can_expand=True, +# thumbnail=thumbnail, + ) + media.children = [] + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + title = config_entry.data[CONF_NAME] + for device in self._devices: + if config_entry.data[CONF_NAME] == device.name: + title = device.name_by_user if device.name_by_user is not None else device.name + + media_class = MEDIA_CLASS_APP + child_dev = BrowseMediaSource( + domain=DOMAIN, + identifier=config_entry.data[CONF_NAME], + media_class=media_class, + media_content_type=MEDIA_TYPE_VIDEO, + title=title, + can_play=False, + can_expand=True, +# thumbnail=thumbnail, + ) + media.children.append(child_dev) + + elif event_dir is None: + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + if config_entry.data[CONF_NAME] == entry_id: + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + user = config_entry.data[CONF_USERNAME] + password = config_entry.data[CONF_PASSWORD] + if host == "": + return None + + media_class = MEDIA_CLASS_DIRECTORY + media = BrowseMediaSource( + domain=DOMAIN, + identifier=entry_id, + media_class=media_class, + media_content_type=MEDIA_TYPE_VIDEO, + title=entry_id, + can_play=False, + can_expand=True, +# thumbnail=thumbnail, + ) + try: + auth = None + if user or password: + auth = HTTPBasicAuth(user, password) + + eventsdir_url = "http://" + host + ":" + str(port) + "/cgi-bin/eventsdir.sh" + response = requests.post(eventsdir_url, timeout=HTTP_TIMEOUT, auth=auth) + if response.status_code >= 300: + _LOGGER.error("Failed to send eventsdir command to device %s", host) + error = True + except requests.exceptions.RequestException as error: + _LOGGER.error("Failed to send eventsdir command to device %s: error %s", host, error) + error = True + + if response is None: + _LOGGER.error("Failed to send eventsdir command to device %s: error unknown", host) + error = True + + if error: + return None + + records_dir = response.json()["records"] + if len(records_dir) > 0: + media.children = [] + for record_dir in records_dir: + dir_path = record_dir["dirname"].replace("/", "-") + title = record_dir["datetime"].replace("Date: ", "").replace("Time: ", "") + media_class = MEDIA_CLASS_DIRECTORY + + child_dir = BrowseMediaSource( + domain=DOMAIN, + identifier=entry_id + "/" + dir_path, + media_class=media_class, + media_content_type=MEDIA_TYPE_VIDEO, + title=title, + can_play=False, + can_expand=True, +# thumbnail=thumbnail, + ) + + media.children.append(child_dir) + + else: + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + if config_entry.data[CONF_NAME] == entry_id: + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + user = config_entry.data[CONF_USERNAME] + password = config_entry.data[CONF_PASSWORD] + if host == "": + return None + + title = event_dir + media_class = MEDIA_CLASS_VIDEO + + media = BrowseMediaSource( + domain=DOMAIN, + identifier=entry_id + "/" + event_dir, + media_class=media_class, + media_content_type=MEDIA_TYPE_VIDEO, + title=title, + can_play=False, + can_expand=True, +# thumbnail=thumbnail, + ) + + try: + auth = None + if user or password: + auth = HTTPBasicAuth(user, password) + + eventsfile_url = "http://" + host + ":" + str(port) + "/cgi-bin/eventsfile.sh?dirname=" + event_dir.replace("-", "/") + response = requests.post(eventsfile_url, timeout=HTTP_TIMEOUT, auth=auth) + if response.status_code >= 300: + _LOGGER.error("Failed to send eventsfile command to device %s", host) + error = True + except requests.exceptions.RequestException as error: + _LOGGER.error("Failed to send eventsfile command to device %s: error %s", host, error) + error = True + + if response is None: + _LOGGER.error("Failed to send eventsfile command to device %s: error unknown", host) + error = True + + if error: + return None + + records_file = response.json()["records"] + if len(records_file) > 0: + media.children = [] + for record_file in records_file: + file_path = record_file["filename"] + title = record_file["time"] + media_class = MEDIA_CLASS_VIDEO + + child_file = BrowseMediaSource( + domain=DOMAIN, + identifier=entry_id + "/" + event_dir + "/" + file_path, + media_class=media_class, + media_content_type=MEDIA_TYPE_VIDEO, + title=title, + can_play=True, + can_expand=False, +# thumbnail=thumbnail, + ) + media.children.append(child_file) + + return media + + +@callback +def async_parse_identifier( + item: MediaSourceItem, +) -> tuple[str, str, str]: + """Parse identifier.""" + if not item.identifier: + return None, None, None + if "/" not in item.identifier: + return item.identifier, None, None + entry, other = item.identifier.split("/", 1) + + if "/" not in other: + return entry, other, None + + source, path = other.split("/") + + return entry, source, path diff --git a/custom_components/yi_hack/views.py b/custom_components/yi_hack/views.py new file mode 100644 index 0000000..96755e7 --- /dev/null +++ b/custom_components/yi_hack/views.py @@ -0,0 +1,190 @@ +"""yi-hack HTTP views.""" +from __future__ import annotations + +import asyncio +from http import HTTPStatus +from ipaddress import ip_address +import logging +from typing import Any + +import aiohttp +from aiohttp import hdrs, web +from aiohttp.web_exceptions import HTTPBadGateway, HTTPUnauthorized +from multidict import CIMultiDict + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.components.http import HomeAssistantView +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_HACK_NAME, + DOMAIN, + SONOFF, +) + +_LOGGER = logging.getLogger(__name__) + + +class VideoProxyView(HomeAssistantView): + """View to handle Video requests.""" + + requires_auth = True + url = "/api/yi-hack/{entry_id}/{dir_path}/{file_path}" + name = "api:yi-hack:video" + + def __init__(self, hass: HomeAssistant, websession: aiohttp.ClientSession) -> None: + """Initialize NestEventViewBase.""" + self.hass = hass + self._websession = websession + + def _create_path(self, **kwargs: Any) -> str: + """Create path.""" + + hack_name = "" + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + if config_entry.data[CONF_NAME] == kwargs['entry_id']: + hack_name = config_entry.data[CONF_HACK_NAME] + + file_path = kwargs['file_path'] + if hack_name == SONOFF: + dir_path = kwargs['dir_path'].replace("-", "/") + return "alarm_record/" + dir_path + "/" + file_path + else: + dir_path = kwargs['dir_path'] + return "record/" + dir_path + "/" + file_path + + async def get( + self, + request: web.Request, + **kwargs: Any, + ) -> web.Response | web.StreamResponse | web.WebSocketResponse: + """Route data to service.""" + try: + return await self._handle_request(request, **kwargs) + + except aiohttp.ClientError as err: + _LOGGER.debug("Reverse proxy error for %s: %s", request.rel_url, err) + + raise HTTPBadGateway() from None + + async def _handle_request( + self, + request: web.Request, + **kwargs: Any, + ) -> web.Response | web.StreamResponse: + """Handle route for request.""" + + host = "" + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + if config_entry.data[CONF_NAME] == kwargs['entry_id']: + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + user = config_entry.data[CONF_USERNAME] + password = config_entry.data[CONF_PASSWORD] + + if host == "": + return web.Response(status=HTTPStatus.BAD_REQUEST) + + full_path = self._create_path(**kwargs) + if not full_path: + return web.Response(status=HTTPStatus.NOT_FOUND) + + url = "http://" + host + ":" + str(port) + "/" + full_path + data = await request.read() + source_header = _init_header(request) + + async with self._websession.request( + request.method, + url, + headers=source_header, + params=request.query, + allow_redirects=False, + data=data, + ) as result: + headers = _response_header(result) + + # Stream response + response = web.StreamResponse(status=result.status, headers=headers) + response.content_type = result.content_type + + try: + await response.prepare(request) + async for chunk in result.content.iter_chunked(4096): + await response.write(chunk) + + except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err: + _LOGGER.debug("Stream error for %s: %s", request.rel_url, err) + except ConnectionResetError: + # Connection is reset/closed by peer. + pass + + return response + + +def _init_header(request: web.Request) -> CIMultiDict | dict[str, str]: + """Create initial header.""" + headers = {} + + # Filter flags + for name, value in request.headers.items(): + if name in ( + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_ENCODING, + hdrs.SEC_WEBSOCKET_EXTENSIONS, + hdrs.SEC_WEBSOCKET_PROTOCOL, + hdrs.SEC_WEBSOCKET_VERSION, + hdrs.SEC_WEBSOCKET_KEY, + hdrs.HOST, + ): + continue + headers[name] = value + + # Set X-Forwarded-For + forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) + assert request.transport + connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) + if forward_for: + forward_for = f"{forward_for}, {connected_ip!s}" + else: + forward_for = f"{connected_ip!s}" + headers[hdrs.X_FORWARDED_FOR] = forward_for + + # Set X-Forwarded-Host + forward_host = request.headers.get(hdrs.X_FORWARDED_HOST) + if not forward_host: + forward_host = request.host + headers[hdrs.X_FORWARDED_HOST] = forward_host + + # Set X-Forwarded-Proto + forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO) + if not forward_proto: + forward_proto = request.url.scheme + headers[hdrs.X_FORWARDED_PROTO] = forward_proto + + return headers + + +def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: + """Create response header.""" + headers = {} + + for name, value in response.headers.items(): + if name in ( + hdrs.TRANSFER_ENCODING, + # Removing Content-Length header for streaming responses + # prevents seeking from working for mp4 files + # hdrs.CONTENT_LENGTH, + hdrs.CONTENT_TYPE, + hdrs.CONTENT_ENCODING, + ): + continue + headers[name] = value + + return headers