Add Netatmo media browser support (#39578)

This commit is contained in:
cgtobi 2020-09-04 20:21:42 +02:00 committed by GitHub
parent 08d5175d05
commit 84944cfc24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 269 additions and 1 deletions

View File

@ -28,6 +28,7 @@ class BrowseMedia:
can_expand: bool = False
media_content_type: str = None
children: List = None
thumbnail: str = None
def to_uri(self):
"""Return URI of media."""
@ -49,6 +50,7 @@ class BrowseMedia:
"media_content_id": self.to_uri(),
"can_play": self.can_play,
"can_expand": self.can_expand,
"thumbnail": self.thumbnail,
}
if self.children:

View File

@ -26,7 +26,9 @@ from . import api, config_flow
from .const import (
AUTH,
CONF_CLOUDHOOK_URL,
DATA_CAMERAS,
DATA_DEVICE_IDS,
DATA_EVENTS,
DATA_HANDLER,
DATA_HOMES,
DATA_PERSONS,
@ -62,6 +64,8 @@ async def async_setup(hass: HomeAssistant, config: dict):
hass.data[DOMAIN][DATA_DEVICE_IDS] = {}
hass.data[DOMAIN][DATA_SCHEDULES] = {}
hass.data[DOMAIN][DATA_HOMES] = {}
hass.data[DOMAIN][DATA_EVENTS] = {}
hass.data[DOMAIN][DATA_CAMERAS] = {}
if DOMAIN not in config:
return True

View File

@ -16,6 +16,8 @@ from .const import (
ATTR_PERSONS,
ATTR_PSEUDO,
CAMERA_LIGHT_MODES,
DATA_CAMERAS,
DATA_EVENTS,
DATA_HANDLER,
DATA_PERSONS,
DOMAIN,
@ -157,6 +159,8 @@ class NetatmoCamera(NetatmoBase, Camera):
)
)
self.hass.data[DOMAIN][DATA_CAMERAS][self._id] = self._device_name
@callback
def handle_event(self, event):
"""Handle webhook events."""
@ -275,6 +279,30 @@ class NetatmoCamera(NetatmoBase, Camera):
self._is_local = camera.get("is_local")
self.is_streaming = bool(self._status == "on")
if self._model == "NACamera": # Smart Indoor Camera
self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events(
self._data.events.get(self._id, {})
)
elif self._model == "NOC": # Smart Outdoor Camera
self.hass.data[DOMAIN][DATA_EVENTS][
self._id
] = self._data.outdoor_events.get(self._id, {})
def process_events(self, events):
"""Add meta data to events."""
for event in events.values():
if "video_id" not in event:
continue
if self._is_local:
event[
"media_url"
] = f"{self._localurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8"
else:
event[
"media_url"
] = f"{self._vpnurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8"
return events
def _service_set_persons_home(self, **kwargs):
"""Service to change current home schedule."""
persons = kwargs.get(ATTR_PERSONS)

View File

@ -42,7 +42,9 @@ CONF_UUID = "uuid"
OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize"
OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token"
DATA_CAMERAS = "cameras"
DATA_DEVICE_IDS = "netatmo_device_ids"
DATA_EVENTS = "netatmo_events"
DATA_HOMES = "netatmo_homes"
DATA_PERSONS = "netatmo_persons"
DATA_SCHEDULES = "netatmo_schedules"

View File

@ -6,7 +6,8 @@
"pyatmo==4.0.0"
],
"after_dependencies": [
"cloud"
"cloud",
"media_source"
],
"dependencies": [
"webhook"

View File

@ -0,0 +1,141 @@
"""Netatmo Media Source Implementation."""
import datetime as dt
import re
from typing import Optional, Tuple
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.media_source.const import MEDIA_MIME_TYPES
from homeassistant.components.media_source.error import Unresolvable
from homeassistant.components.media_source.models import (
BrowseMedia,
MediaSource,
MediaSourceItem,
PlayMedia,
)
from homeassistant.core import HomeAssistant, callback
from .const import DATA_CAMERAS, DATA_EVENTS, DOMAIN, MANUFACTURER
MIME_TYPE = "application/x-mpegURL"
async def async_get_media_source(hass: HomeAssistant):
"""Set up Netatmo media source."""
return NetatmoSource(hass)
class NetatmoSource(MediaSource):
"""Provide Netatmo camera recordings as media sources."""
name: str = MANUFACTURER
def __init__(self, hass: HomeAssistant):
"""Initialize Netatmo source."""
super().__init__(DOMAIN)
self.hass = hass
self.events = self.hass.data[DOMAIN][DATA_EVENTS]
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
_, camera_id, event_id = async_parse_identifier(item)
url = self.events[camera_id][event_id]["media_url"]
return PlayMedia(url, MIME_TYPE)
async def async_browse_media(
self, item: MediaSourceItem, media_types: Tuple[str] = MEDIA_MIME_TYPES
) -> Optional[BrowseMedia]:
"""Return media."""
try:
source, camera_id, event_id = async_parse_identifier(item)
except Unresolvable as err:
raise BrowseError(str(err)) from err
return self._browse_media(source, camera_id, event_id)
def _browse_media(
self, source: str, camera_id: str, event_id: int
) -> Optional[BrowseMedia]:
"""Browse media."""
if camera_id and camera_id not in self.events:
raise BrowseError("Camera does not exist.")
if event_id and event_id not in self.events[camera_id]:
raise BrowseError("Event does not exist.")
return self._build_item_response(source, camera_id, event_id)
def _build_item_response(
self, source: str, camera_id: str, event_id: int = None
) -> Optional[BrowseMedia]:
if event_id and event_id in self.events[camera_id]:
created = dt.datetime.fromtimestamp(event_id)
thumbnail = self.events[camera_id][event_id].get("snapshot", {}).get("url")
message = remove_html_tags(self.events[camera_id][event_id]["message"])
title = f"{created} - {message}"
else:
title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER)
thumbnail = None
if event_id:
path = f"{source}/{camera_id}/{event_id}"
else:
path = f"{source}/{camera_id}"
media = BrowseMedia(
DOMAIN,
path,
title,
)
media.can_play = bool(
event_id and self.events[camera_id][event_id].get("media_url")
)
media.can_expand = event_id is None
media.thumbnail = thumbnail
if not media.can_play and not media.can_expand:
return None
if not media.can_expand:
return media
media.children = []
# Append first level children
if not camera_id:
for cid in self.events:
child = self._build_item_response(source, cid)
if child:
media.children.append(child)
else:
for eid in self.events[camera_id]:
child = self._build_item_response(source, camera_id, eid)
if child:
media.children.append(child)
return media
def remove_html_tags(text):
"""Remove html tags from string."""
clean = re.compile("<.*?>")
return re.sub(clean, "", text)
@callback
def async_parse_identifier(
item: MediaSourceItem,
) -> Tuple[str, str, Optional[int]]:
"""Parse identifier."""
if not item.identifier:
return "events", "", None
source, path = item.identifier.lstrip("/").split("/", 1)
if source != "events":
raise Unresolvable("Unknown source directory.")
if "/" in path:
camera_id, event_id = path.split("/", 1)
return source, camera_id, int(event_id)
return source, path, None

View File

@ -0,0 +1,90 @@
"""Test Local Media Source."""
import pytest
from homeassistant.components import media_source
from homeassistant.components.media_source import const
from homeassistant.components.media_source.models import PlayMedia
from homeassistant.components.netatmo import DATA_CAMERAS, DATA_EVENTS, DOMAIN
from homeassistant.setup import async_setup_component
async def test_async_browse_media(hass):
"""Test browse media."""
assert await async_setup_component(hass, DOMAIN, {})
# Prepare cached Netatmo event date
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_EVENTS] = {
"12:34:56:78:90:ab": {
1599152672: {
"id": "12345",
"time": 1599152672,
"camera_id": "12:34:56:78:90:ab",
"snapshot": {
"url": "https://netatmocameraimage",
},
"video_id": "98765",
"video_status": "available",
"message": "<b>Paulus</b> seen",
"media_url": "http:///files/high/index.m3u8",
},
1599152673: {
"id": "12346",
"time": 1599152673,
"camera_id": "12:34:56:78:90:ab",
"snapshot": {
"url": "https://netatmocameraimage",
},
"message": "<b>Tobias</b> seen",
},
}
}
hass.data[DOMAIN][DATA_CAMERAS] = {"12:34:56:78:90:ab": "MyCamera"}
assert await async_setup_component(hass, const.DOMAIN, {})
await hass.async_block_till_done()
# Test camera not exists
with pytest.raises(media_source.BrowseError) as excinfo:
await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/events/98:76:54:32:10:ff"
)
assert str(excinfo.value) == "Camera does not exist."
# Test browse event
with pytest.raises(media_source.BrowseError) as excinfo:
await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/12345"
)
assert str(excinfo.value) == "Event does not exist."
# Test invalid base
with pytest.raises(media_source.BrowseError) as excinfo:
await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/invalid/base"
)
assert str(excinfo.value) == "Unknown source directory."
# Test successful listing
media = await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/events/"
)
# Test successful events listing
media = await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab"
)
# Test successful event listing
media = await media_source.async_browse_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672"
)
assert media
# Test successful event resolve
media = await media_source.async_resolve_media(
hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672"
)
assert media == PlayMedia(
url="http:///files/high/index.m3u8", mime_type="application/x-mpegURL"
)