mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add Netatmo media browser support (#39578)
This commit is contained in:
parent
08d5175d05
commit
84944cfc24
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -6,7 +6,8 @@
|
||||
"pyatmo==4.0.0"
|
||||
],
|
||||
"after_dependencies": [
|
||||
"cloud"
|
||||
"cloud",
|
||||
"media_source"
|
||||
],
|
||||
"dependencies": [
|
||||
"webhook"
|
||||
|
141
homeassistant/components/netatmo/media_source.py
Normal file
141
homeassistant/components/netatmo/media_source.py
Normal 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
|
90
tests/components/netatmo/test_media_source.py
Normal file
90
tests/components/netatmo/test_media_source.py
Normal 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"
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user