Skip to content

Commit

Permalink
Add media source feature
Browse files Browse the repository at this point in the history
  • Loading branch information
roleoroleo committed Mar 6, 2022
1 parent c93b6d8 commit 54fb87a
Show file tree
Hide file tree
Showing 5 changed files with 470 additions and 2 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<br>
And make sure you have the latest version.
Expand All @@ -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:
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions custom_components/yi_hack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"]

Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion custom_components/yi_hack/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
268 changes: 268 additions & 0 deletions custom_components/yi_hack/media_source.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 54fb87a

Please sign in to comment.