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"
+ )