Skip to content

Commit 3dd841c

Browse files
cryptomilkclaude
andcommitted
feat: add Media Source platform for Media Browser integration
Expose rendered dashboard PNGs in HA's Media Browser so they can be sent to screens running OpenDisplay or any media-consuming integration. The platform is auto-discovered by HA. Each configured dashboard appears as a playable image item resolving to the existing public PNG endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ba384f2 commit 3dd841c

6 files changed

Lines changed: 360 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,5 @@ When adding new HA imports to production code, add matching stubs in
144144
half-finished code. A reviewer should be able to check out any single
145145
commit and get a working tree.
146146

147-
Home Assistant Core Sources: `./.tmp/core`
148-
Home Assistant Frontend Sources: `./.tmp/frontend`
147+
Home Assistant Core Sources: `./.tmp/core/`
148+
Home Assistant Frontend Sources: `./.tmp/frontend/`

custom_components/eink_dashboard/manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"domain": "eink_dashboard",
33
"name": "E-Ink Dashboard",
4+
"after_dependencies": ["media_source"],
45
"codeowners": ["@cryptomilk"],
56
"config_flow": true,
67
"documentation": "https://github.com/cryptomilk/hass-eink-dashboard",
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Expose e-ink dashboard images as a Media Source."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.components.media_player import (
6+
BrowseError,
7+
MediaClass,
8+
)
9+
from homeassistant.components.media_source import (
10+
BrowseMediaSource,
11+
MediaSource,
12+
MediaSourceItem,
13+
PlayMedia,
14+
Unresolvable,
15+
)
16+
from homeassistant.core import HomeAssistant
17+
18+
from .const import DOMAIN
19+
20+
21+
async def async_get_media_source(
22+
hass: HomeAssistant,
23+
) -> EinkDashboardMediaSource:
24+
"""Return the e-ink dashboard media source."""
25+
return EinkDashboardMediaSource(hass)
26+
27+
28+
class EinkDashboardMediaSource(MediaSource):
29+
"""Provide rendered dashboard PNGs as browsable media."""
30+
31+
name = "E-Ink Dashboard"
32+
33+
def __init__(self, hass: HomeAssistant) -> None:
34+
"""Initialise the media source."""
35+
super().__init__(DOMAIN)
36+
self.hass = hass
37+
38+
async def async_resolve_media(
39+
self,
40+
item: MediaSourceItem,
41+
) -> PlayMedia:
42+
"""Resolve a dashboard entry to its image proxy URL.
43+
44+
Args:
45+
item: Parsed media source item whose identifier
46+
is a config entry ID.
47+
48+
Returns:
49+
PlayMedia pointing at the authenticated image
50+
proxy endpoint.
51+
52+
Raises:
53+
Unresolvable: If the entry ID is not found.
54+
"""
55+
entry_data = self.hass.data.get(DOMAIN, {}).get(
56+
item.identifier,
57+
)
58+
if not isinstance(entry_data, dict) or "entry" not in entry_data:
59+
raise Unresolvable(f"Unknown dashboard: {item.identifier}")
60+
entity = entry_data["entity"]
61+
return PlayMedia(
62+
f"/api/image_proxy/{entity.entity_id}",
63+
"image/png",
64+
)
65+
66+
async def async_browse_media(
67+
self,
68+
item: MediaSourceItem,
69+
) -> BrowseMediaSource:
70+
"""List configured dashboards as browsable media items.
71+
72+
When called with an empty identifier, returns a root
73+
node whose children are the individual dashboards.
74+
Each child is directly playable (can_play=True)
75+
and cannot be expanded further.
76+
77+
Args:
78+
item: Parsed media source item. Only the root
79+
(empty identifier) is supported.
80+
81+
Returns:
82+
Root BrowseMediaSource with one child per
83+
dashboard config entry.
84+
85+
Raises:
86+
BrowseError: If a non-root identifier is requested.
87+
"""
88+
if item.identifier:
89+
raise BrowseError(f"Unknown item: {item.identifier}")
90+
91+
entries = self.hass.data.get(DOMAIN, {})
92+
children = [
93+
BrowseMediaSource(
94+
domain=DOMAIN,
95+
identifier=entry_id,
96+
media_class=MediaClass.IMAGE,
97+
media_content_type="image/png",
98+
title=entry_data["entry"].title,
99+
thumbnail=f"/api/image_proxy/{entry_data['entity'].entity_id}",
100+
can_play=True,
101+
can_expand=False,
102+
)
103+
for entry_id, entry_data in entries.items()
104+
if isinstance(entry_data, dict) and "entry" in entry_data
105+
]
106+
107+
return BrowseMediaSource(
108+
domain=DOMAIN,
109+
identifier=None,
110+
media_class=MediaClass.APP,
111+
media_content_type="",
112+
title="E-Ink Dashboard",
113+
can_play=False,
114+
can_expand=True,
115+
children_media_class=MediaClass.IMAGE,
116+
children=children,
117+
)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ include = [
6565
"custom_components/eink_dashboard/config_flow.py",
6666
"custom_components/eink_dashboard/image.py",
6767
"custom_components/eink_dashboard/http.py",
68+
"custom_components/eink_dashboard/media_source.py",
6869
"custom_components/eink_dashboard/sensor.py",
6970
"custom_components/eink_dashboard/store.py",
7071
]

tests/conftest.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def _stub_module(name: str) -> ModuleType:
1717
"homeassistant.components.frontend",
1818
"homeassistant.components.http",
1919
"homeassistant.components.image",
20+
"homeassistant.components.media_player",
21+
"homeassistant.components.media_source",
2022
"homeassistant.components.sensor",
2123
"homeassistant.components.websocket_api",
2224
"homeassistant.config_entries",
@@ -75,6 +77,91 @@ def __init__(
7577
},
7678
)
7779

80+
media_player_mod = sys.modules["homeassistant.components.media_player"]
81+
82+
83+
class _BrowseError(Exception):
84+
pass
85+
86+
87+
class _MediaClass:
88+
APP = "app"
89+
IMAGE = "image"
90+
91+
92+
media_player_mod.BrowseError = _BrowseError # type: ignore[attr-defined]
93+
media_player_mod.MediaClass = _MediaClass # type: ignore[attr-defined]
94+
95+
media_source_mod = sys.modules["homeassistant.components.media_source"]
96+
97+
98+
class _PlayMedia:
99+
def __init__(self, url: str, mime_type: str) -> None:
100+
self.url = url
101+
self.mime_type = mime_type
102+
103+
104+
class _BrowseMediaSource:
105+
def __init__(
106+
self,
107+
*,
108+
domain: str | None,
109+
identifier: str | None,
110+
media_class: str,
111+
media_content_type: str,
112+
title: str,
113+
can_play: bool,
114+
can_expand: bool,
115+
children: list | None = None,
116+
children_media_class: str | None = None,
117+
thumbnail: str | None = None,
118+
) -> None:
119+
self.domain = domain
120+
self.identifier = identifier
121+
self.media_class = media_class
122+
self.media_content_type = media_content_type
123+
self.title = title
124+
self.can_play = can_play
125+
self.can_expand = can_expand
126+
self.children = children
127+
self.children_media_class = children_media_class
128+
self.thumbnail = thumbnail
129+
130+
131+
class _MediaSource:
132+
name: str | None = None
133+
134+
def __init__(self, domain: str) -> None:
135+
self.domain = domain
136+
if not self.name:
137+
self.name = domain
138+
139+
140+
class _MediaSourceItem:
141+
# Mirrors the real MediaSourceItem dataclass signature.
142+
def __init__(
143+
self,
144+
hass: object,
145+
domain: str | None,
146+
identifier: str,
147+
target_media_player: str | None = None,
148+
) -> None:
149+
self.hass = hass
150+
self.domain = domain
151+
self.identifier = identifier
152+
self.target_media_player = target_media_player
153+
154+
155+
class _Unresolvable(Exception):
156+
pass
157+
158+
159+
media_source_mod.BrowseMediaSource = _BrowseMediaSource # type: ignore[attr-defined]
160+
media_source_mod.MediaSource = _MediaSource # type: ignore[attr-defined]
161+
media_source_mod.MediaSourceItem = _MediaSourceItem # type: ignore[attr-defined]
162+
media_source_mod.PlayMedia = _PlayMedia # type: ignore[attr-defined]
163+
media_source_mod.Unresolvable = _Unresolvable # type: ignore[attr-defined]
164+
78165
config_entries = sys.modules["homeassistant.config_entries"]
79166
config_entries.ConfigEntry = MagicMock # type: ignore[attr-defined]
80167
config_entries.ConfigFlowResult = dict # type: ignore[attr-defined]

tests/test_media_source.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Tests for the e-ink dashboard Media Source platform."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import MagicMock
6+
7+
import pytest
8+
from homeassistant.components.media_player import BrowseError
9+
from homeassistant.components.media_source import (
10+
MediaSourceItem,
11+
Unresolvable,
12+
)
13+
14+
from custom_components.eink_dashboard.media_source import (
15+
EinkDashboardMediaSource,
16+
async_get_media_source,
17+
)
18+
19+
DOMAIN = "eink_dashboard"
20+
21+
22+
def _make_hass(
23+
entries: dict | None = None,
24+
) -> MagicMock:
25+
"""Build a minimal stub hass with the given entry data."""
26+
hass = MagicMock()
27+
hass.data = {DOMAIN: entries or {}}
28+
return hass
29+
30+
31+
def _make_item(
32+
hass: object,
33+
identifier: str = "",
34+
) -> MediaSourceItem:
35+
"""Build a MediaSourceItem with the given identifier."""
36+
return MediaSourceItem(hass, DOMAIN, identifier)
37+
38+
39+
@pytest.mark.asyncio
40+
async def test_async_get_media_source() -> None:
41+
"""Factory returns an EinkDashboardMediaSource instance."""
42+
hass = _make_hass()
43+
source = await async_get_media_source(hass)
44+
assert isinstance(source, EinkDashboardMediaSource)
45+
assert source.name == "E-Ink Dashboard"
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_browse_root_lists_entries() -> None:
50+
"""Root browse returns one child per dashboard entry."""
51+
entry_a = MagicMock()
52+
entry_a.title = "Kitchen"
53+
entity_a = MagicMock()
54+
entity_a.entity_id = "image.kitchen"
55+
entry_b = MagicMock()
56+
entry_b.title = "Office"
57+
entity_b = MagicMock()
58+
entity_b.entity_id = "image.office"
59+
entries = {
60+
"aaa": {
61+
"entry": entry_a,
62+
"entity": entity_a,
63+
"widgets": [],
64+
},
65+
"bbb": {
66+
"entry": entry_b,
67+
"entity": entity_b,
68+
"widgets": [],
69+
},
70+
}
71+
hass = _make_hass(entries)
72+
source = EinkDashboardMediaSource(hass)
73+
74+
result = await source.async_browse_media(
75+
_make_item(hass),
76+
)
77+
78+
assert result.can_expand is True
79+
assert result.can_play is False
80+
assert result.title == "E-Ink Dashboard"
81+
assert len(result.children) == 2
82+
83+
ids = {c.identifier for c in result.children}
84+
assert ids == {"aaa", "bbb"}
85+
for child in result.children:
86+
assert child.can_play is True
87+
assert child.can_expand is False
88+
assert child.media_content_type == "image/png"
89+
expected_entity = (
90+
"image.kitchen" if child.identifier == "aaa" else "image.office"
91+
)
92+
assert child.thumbnail == (f"/api/image_proxy/{expected_entity}")
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_browse_root_empty_when_no_entries() -> None:
97+
"""Root browse returns an empty children list with no entries."""
98+
hass = _make_hass({})
99+
source = EinkDashboardMediaSource(hass)
100+
101+
result = await source.async_browse_media(
102+
_make_item(hass),
103+
)
104+
assert result.children == []
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_browse_non_root_raises() -> None:
109+
"""Browsing a non-root identifier raises BrowseError."""
110+
hass = _make_hass()
111+
source = EinkDashboardMediaSource(hass)
112+
113+
with pytest.raises(BrowseError):
114+
await source.async_browse_media(
115+
_make_item(hass, "some_id"),
116+
)
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_resolve_valid_entry() -> None:
121+
"""Resolving a known entry returns the image proxy URL."""
122+
entry = MagicMock()
123+
entry.title = "Kitchen"
124+
entity = MagicMock()
125+
entity.entity_id = "image.kitchen"
126+
entries = {
127+
"abc123": {
128+
"entry": entry,
129+
"entity": entity,
130+
"widgets": [],
131+
},
132+
}
133+
hass = _make_hass(entries)
134+
source = EinkDashboardMediaSource(hass)
135+
136+
result = await source.async_resolve_media(
137+
_make_item(hass, "abc123"),
138+
)
139+
assert result.url == "/api/image_proxy/image.kitchen"
140+
assert result.mime_type == "image/png"
141+
142+
143+
@pytest.mark.asyncio
144+
async def test_resolve_unknown_entry_raises() -> None:
145+
"""Resolving an unknown entry raises Unresolvable."""
146+
hass = _make_hass({})
147+
source = EinkDashboardMediaSource(hass)
148+
149+
with pytest.raises(Unresolvable):
150+
await source.async_resolve_media(
151+
_make_item(hass, "nonexistent"),
152+
)

0 commit comments

Comments
 (0)