diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 02248efa068..b93cb961449 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -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: diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index f68683a152d..67e83189fc5 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -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 diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 210c9d92e3c..3f9720f3adb 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -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) diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 01351723716..138065f086b 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -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" diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index fe8c5367093..b7205431bb5 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -6,7 +6,8 @@ "pyatmo==4.0.0" ], "after_dependencies": [ - "cloud" + "cloud", + "media_source" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py new file mode 100644 index 00000000000..a862252f02e --- /dev/null +++ b/homeassistant/components/netatmo/media_source.py @@ -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 diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py new file mode 100644 index 00000000000..0405317f03e --- /dev/null +++ b/tests/components/netatmo/test_media_source.py @@ -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": "Paulus 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": "Tobias 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" + )