mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 06:07:17 +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
|
can_expand: bool = False
|
||||||
media_content_type: str = None
|
media_content_type: str = None
|
||||||
children: List = None
|
children: List = None
|
||||||
|
thumbnail: str = None
|
||||||
|
|
||||||
def to_uri(self):
|
def to_uri(self):
|
||||||
"""Return URI of media."""
|
"""Return URI of media."""
|
||||||
@ -49,6 +50,7 @@ class BrowseMedia:
|
|||||||
"media_content_id": self.to_uri(),
|
"media_content_id": self.to_uri(),
|
||||||
"can_play": self.can_play,
|
"can_play": self.can_play,
|
||||||
"can_expand": self.can_expand,
|
"can_expand": self.can_expand,
|
||||||
|
"thumbnail": self.thumbnail,
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.children:
|
if self.children:
|
||||||
|
@ -26,7 +26,9 @@ from . import api, config_flow
|
|||||||
from .const import (
|
from .const import (
|
||||||
AUTH,
|
AUTH,
|
||||||
CONF_CLOUDHOOK_URL,
|
CONF_CLOUDHOOK_URL,
|
||||||
|
DATA_CAMERAS,
|
||||||
DATA_DEVICE_IDS,
|
DATA_DEVICE_IDS,
|
||||||
|
DATA_EVENTS,
|
||||||
DATA_HANDLER,
|
DATA_HANDLER,
|
||||||
DATA_HOMES,
|
DATA_HOMES,
|
||||||
DATA_PERSONS,
|
DATA_PERSONS,
|
||||||
@ -62,6 +64,8 @@ async def async_setup(hass: HomeAssistant, config: dict):
|
|||||||
hass.data[DOMAIN][DATA_DEVICE_IDS] = {}
|
hass.data[DOMAIN][DATA_DEVICE_IDS] = {}
|
||||||
hass.data[DOMAIN][DATA_SCHEDULES] = {}
|
hass.data[DOMAIN][DATA_SCHEDULES] = {}
|
||||||
hass.data[DOMAIN][DATA_HOMES] = {}
|
hass.data[DOMAIN][DATA_HOMES] = {}
|
||||||
|
hass.data[DOMAIN][DATA_EVENTS] = {}
|
||||||
|
hass.data[DOMAIN][DATA_CAMERAS] = {}
|
||||||
|
|
||||||
if DOMAIN not in config:
|
if DOMAIN not in config:
|
||||||
return True
|
return True
|
||||||
|
@ -16,6 +16,8 @@ from .const import (
|
|||||||
ATTR_PERSONS,
|
ATTR_PERSONS,
|
||||||
ATTR_PSEUDO,
|
ATTR_PSEUDO,
|
||||||
CAMERA_LIGHT_MODES,
|
CAMERA_LIGHT_MODES,
|
||||||
|
DATA_CAMERAS,
|
||||||
|
DATA_EVENTS,
|
||||||
DATA_HANDLER,
|
DATA_HANDLER,
|
||||||
DATA_PERSONS,
|
DATA_PERSONS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -157,6 +159,8 @@ class NetatmoCamera(NetatmoBase, Camera):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.hass.data[DOMAIN][DATA_CAMERAS][self._id] = self._device_name
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def handle_event(self, event):
|
def handle_event(self, event):
|
||||||
"""Handle webhook events."""
|
"""Handle webhook events."""
|
||||||
@ -275,6 +279,30 @@ class NetatmoCamera(NetatmoBase, Camera):
|
|||||||
self._is_local = camera.get("is_local")
|
self._is_local = camera.get("is_local")
|
||||||
self.is_streaming = bool(self._status == "on")
|
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):
|
def _service_set_persons_home(self, **kwargs):
|
||||||
"""Service to change current home schedule."""
|
"""Service to change current home schedule."""
|
||||||
persons = kwargs.get(ATTR_PERSONS)
|
persons = kwargs.get(ATTR_PERSONS)
|
||||||
|
@ -42,7 +42,9 @@ CONF_UUID = "uuid"
|
|||||||
OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize"
|
OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize"
|
||||||
OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token"
|
OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token"
|
||||||
|
|
||||||
|
DATA_CAMERAS = "cameras"
|
||||||
DATA_DEVICE_IDS = "netatmo_device_ids"
|
DATA_DEVICE_IDS = "netatmo_device_ids"
|
||||||
|
DATA_EVENTS = "netatmo_events"
|
||||||
DATA_HOMES = "netatmo_homes"
|
DATA_HOMES = "netatmo_homes"
|
||||||
DATA_PERSONS = "netatmo_persons"
|
DATA_PERSONS = "netatmo_persons"
|
||||||
DATA_SCHEDULES = "netatmo_schedules"
|
DATA_SCHEDULES = "netatmo_schedules"
|
||||||
|
@ -6,7 +6,8 @@
|
|||||||
"pyatmo==4.0.0"
|
"pyatmo==4.0.0"
|
||||||
],
|
],
|
||||||
"after_dependencies": [
|
"after_dependencies": [
|
||||||
"cloud"
|
"cloud",
|
||||||
|
"media_source"
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"webhook"
|
"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