Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ slideshow rotation. Drivers talk to devices on your network.
- **Your library, not a photo dump.** Upload once, organize into albums, reuse across frames and scenes.
- **A gallery wall out of the box.** Curated public-domain art packs install with one click.
- **Set it and forget it.** Daily agenda, skills, schedules.
- **Multi-vendor local drivers.** Fraimic / community e‑ink, Meural Canvas (local), experimental Samsung EM32DX.
- **Multi-vendor local drivers.** Fraimic / community e‑ink, Meural Canvas (local), experimental Samsung EM32DX, Roku TV (cast via HA's own Roku integration).

## Quick start

Expand All @@ -40,6 +40,7 @@ We develop against hardware the maintainers own. Other vendors (and panel sizes
| Fraimic / Spectra e‑ink (official + common clones) | Primary development target |
| Meural Canvas **local** LAN | Implemented; volunteer reports welcome |
| Samsung EM32DX (MDC local) | Experimental; **needs volunteer hardware** |
| Roku TV (cast via HA core `roku` integration) | Implemented; **needs volunteer hardware** |
| InkJoy | Out of scope for now (MQTT control plane) |
| Meural **cloud pin / album push** | Optional (link Netgear account on Meural options) |

Expand Down
16 changes: 15 additions & 1 deletion custom_components/digital_frames/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,15 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
DigitalFramesFrameStatusView,
DigitalFramesOnboardingView,
DigitalFramesOpenApiView,
DigitalFramesRokuContentView,
DigitalFramesSamsungContentView,
DigitalFramesSendImageView,
)
hass.http.register_view(DigitalFramesSendImageView())
hass.http.register_view(DigitalFramesOnboardingView())
hass.http.register_view(DigitalFramesFrameStatusView())
hass.http.register_view(DigitalFramesSamsungContentView())
hass.http.register_view(DigitalFramesRokuContentView())
hass.http.register_view(DigitalFramesFramePullBinView())
hass.http.register_view(DigitalFramesOpenApiView())

Expand Down Expand Up @@ -467,7 +469,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: "ConfigEntry") -> bool:
_register_services(hass)
return True

from .const import CONF_DRIVER, CONF_SIZE, CONF_WIDTH, CONF_HEIGHT, DRIVER_MEURAL, DRIVER_SAMSUNG # noqa: PLC0415
from .const import ( # noqa: PLC0415
CONF_DRIVER,
CONF_HEIGHT,
CONF_SIZE,
CONF_WIDTH,
DRIVER_MEURAL,
DRIVER_ROKU,
DRIVER_SAMSUNG,
)

# Auto-migrate 31.5" frame entries created before the panel's true wire
# geometry was reverse-engineered (2026-08-04): the panel is portrait-
Expand All @@ -493,6 +503,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: "ConfigEntry") -> bool:
from .samsung_coordinator import SamsungCoordinator # noqa: PLC0415

coordinator = SamsungCoordinator(hass, entry)
elif entry.data.get(CONF_DRIVER) == DRIVER_ROKU:
from .roku_coordinator import RokuCoordinator # noqa: PLC0415

coordinator = RokuCoordinator(hass, entry)
else:
coordinator = DigitalFramesCoordinator(hass, entry)

Expand Down
98 changes: 94 additions & 4 deletions custom_components/digital_frames/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import (
Expand All @@ -35,6 +36,7 @@
DOMAIN,
DRIVER_FRAIMIC,
DRIVER_MEURAL,
DRIVER_ROKU,
DRIVER_SAMSUNG,
KIND_SCENES_HUB,
MEURAL_DEFAULT_HEIGHT,
Expand All @@ -45,6 +47,10 @@
SAMSUNG_SIZE_LABEL,
CONF_MDC_PIN,
DEFAULT_MDC_PIN,
CONF_ROKU_ENTITY_ID,
ROKU_DEFAULT_HEIGHT,
ROKU_DEFAULT_WIDTH,
ROKU_SIZE_LABEL,
CONF_ORIENTATION,
CONF_ORIENTATION_FOLLOW_DEVICE,
ORIENTATION_AUTO,
Expand Down Expand Up @@ -284,7 +290,7 @@ async def async_step_user(
"""Primary entry: pick Fraimic/clone or Meural Canvas (local)."""
return self.async_show_menu(
step_id="user",
menu_options=["add_fraimic", "add_meural", "add_samsung"],
menu_options=["add_fraimic", "add_meural", "add_samsung", "add_roku"],
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -472,6 +478,83 @@ async def async_step_add_samsung(
step_id="add_samsung", data_schema=schema, errors=errors
)

# ------------------------------------------------------------------
# Roku TV — cast via HA core's `roku` media_player, no push protocol
# ------------------------------------------------------------------

async def async_step_add_roku(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Target an existing HA core `roku` media_player entity.

Roku has no local API to receive arbitrary image bytes, so this
driver does not discover/probe hardware directly -- it reuses
whatever `media_player.roku_*` entity HA core's own `roku`
integration already created and casts to it via `play_media`.
"""
errors: dict[str, str] = {}

registry = er.async_get(self.hass)
configured = {
entry.data.get(CONF_ROKU_ENTITY_ID)
for entry in self._async_current_entries()
if entry.data.get(CONF_DRIVER) == DRIVER_ROKU
}
available = {
reg_entry.entity_id: (
reg_entry.name or reg_entry.original_name or reg_entry.entity_id
)
for reg_entry in registry.entities.values()
if reg_entry.platform == "roku"
and reg_entry.domain == "media_player"
and reg_entry.entity_id not in configured
}

if not available:
return self.async_abort(reason="no_roku_media_players")

if user_input is not None:
entity_id = user_input[CONF_ROKU_ENTITY_ID]
name = (
(user_input.get(CONF_NAME) or "").strip()
or available.get(entity_id, entity_id)
)
width = int(user_input.get(CONF_WIDTH) or ROKU_DEFAULT_WIDTH)
height = int(user_input.get(CONF_HEIGHT) or ROKU_DEFAULT_HEIGHT)

unique = f"roku:{entity_id}"
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()

return self.async_create_entry(
title=name,
data={
CONF_DRIVER: DRIVER_ROKU,
CONF_ROKU_ENTITY_ID: entity_id,
CONF_NAME: name,
CONF_WIDTH: width,
CONF_HEIGHT: height,
CONF_SIZE: ROKU_SIZE_LABEL,
CONF_DEVICE_KEY: unique,
},
)

schema = vol.Schema(
{
vol.Required(CONF_ROKU_ENTITY_ID): vol.In(available),
vol.Optional(CONF_NAME, default=""): str,
vol.Optional(CONF_WIDTH, default=ROKU_DEFAULT_WIDTH): vol.All(
vol.Coerce(int), vol.Range(min=100, max=8000)
),
vol.Optional(CONF_HEIGHT, default=ROKU_DEFAULT_HEIGHT): vol.All(
vol.Coerce(int), vol.Range(min=100, max=8000)
),
}
)
return self.async_show_form(
step_id="add_roku", data_schema=schema, errors=errors
)

# ------------------------------------------------------------------
# Step 2 (scan path) — pick a discovered device
# ------------------------------------------------------------------
Expand Down Expand Up @@ -656,6 +739,7 @@ async def async_step_init(
) -> FlowResult:
"""Manage the options."""
is_meural = self.config_entry.data.get(CONF_DRIVER) == DRIVER_MEURAL
is_roku = self.config_entry.data.get(CONF_DRIVER) == DRIVER_ROKU
errors: dict[str, str] = {}

if user_input is not None:
Expand Down Expand Up @@ -719,10 +803,12 @@ async def async_step_init(

return self.async_create_entry(title="", data=user_input)

return await self._async_show_options_form(is_meural=is_meural, errors=errors)
return await self._async_show_options_form(
is_meural=is_meural, is_roku=is_roku, errors=errors
)

async def _async_show_options_form(
self, *, is_meural: bool, errors: dict[str, str]
self, *, is_meural: bool, is_roku: bool = False, errors: dict[str, str]
) -> FlowResult:
current_interval: int = self.config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
Expand Down Expand Up @@ -821,6 +907,10 @@ async def _async_show_options_form(
schema_dict[vol.Optional(CONF_MEURAL_EMAIL, default=linked_hint)] = str
schema_dict[vol.Optional(CONF_MEURAL_PASSWORD, default="")] = str
schema_dict[vol.Optional(CONF_MEURAL_UNLINK, default=False)] = bool
elif is_roku:
# Roku has no battery/keep-awake concept of its own -- power is
# whatever the underlying HA `roku` media_player already tracks.
pass
else:
# Battery / keep-awake controls (Fraimic family only).
schema_dict[
Expand All @@ -837,7 +927,7 @@ async def _async_show_options_form(
vol.Optional(CONF_SCAN_INTERVAL, default=current_interval)
] = vol.All(int, vol.Range(min=30))

if not is_meural:
if not is_meural and not is_roku:
# Advanced: 30s wake-hunt while a send is queued. Default off —
# prefer network device_tracker (UniFi etc.) for push-on-wake.
schema_dict[
Expand Down
8 changes: 8 additions & 0 deletions custom_components/digital_frames/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
DRIVER_FRAIMIC = "fraimic"
DRIVER_MEURAL = "meural"
DRIVER_SAMSUNG = "samsung" # Samsung EM32DX e-paper (experimental; MDC)
DRIVER_ROKU = "roku" # Cast to an existing HA core `roku` media_player entity

# Meural Canvas common native resolution (landscape). User can override
# in the Meural config flow when a panel reports differently.
Expand All @@ -102,6 +103,13 @@
CONF_MDC_PIN = "mdc_pin"
DEFAULT_MDC_PIN = "000000"

# Roku TV (cast via HA core's `roku` integration media_player, no push
# protocol of our own). Landscape native; most Roku TVs are 1080p.
ROKU_DEFAULT_WIDTH = 1920
ROKU_DEFAULT_HEIGHT = 1080
ROKU_SIZE_LABEL = "roku"
CONF_ROKU_ENTITY_ID = "roku_media_player_entity_id"

# Frame display modes
MODE_MANUAL = "manual"
MODE_AGENDA = "agenda"
Expand Down
10 changes: 6 additions & 4 deletions custom_components/digital_frames/digital-frames-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5364,11 +5364,13 @@
const hasBtns = !!frame.orientationEntityId;
orientBtns.style.display = hasBtns ? 'flex' : 'none';

// Samsung frames have no accelerometer/gsensor -- device_orientation
// is always null for that driver (see samsung_coordinator.py), so
// Rediscover would never do anything but spin and reset to "Auto".
// Samsung and Roku frames have no accelerometer/gsensor --
// device_orientation is always null for those drivers (see
// samsung_coordinator.py / roku_coordinator.py), so Rediscover would
// never do anything but spin and reset to "Auto".
const pollBtn = this.shadowRoot.getElementById('frame-info-poll');
pollBtn.style.display = frame.driver === 'samsung' ? 'none' : 'flex';
pollBtn.style.display =
frame.driver === 'samsung' || frame.driver === 'roku' ? 'none' : 'flex';

const state = this._hass && frame.entityId ? this._hass.states[frame.entityId] : null;
let batteryHtml = 'N/A';
Expand Down
10 changes: 8 additions & 2 deletions custom_components/digital_frames/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
DOMAIN,
DRIVER_FRAIMIC,
DRIVER_MEURAL,
DRIVER_ROKU,
DRIVER_SAMSUNG,
EDGE_LEFT,
ORIENTATION_AUTO,
Expand Down Expand Up @@ -150,8 +151,13 @@ def render_spec_for_entry(
if orientation is None:
orientation = entry.options.get(CONF_ORIENTATION, ORIENTATION_AUTO)
edge: str = entry.options.get(CONF_ROTATION_EDGE, EDGE_LEFT)
# RGB postcard / MDC panels: hang-sized compose, no Spectra buffer remap.
hang_sized = entry.data.get(CONF_DRIVER) in (DRIVER_MEURAL, DRIVER_SAMSUNG)
# RGB postcard / MDC / cast panels: hang-sized compose, no Spectra
# buffer remap.
hang_sized = entry.data.get(CONF_DRIVER) in (
DRIVER_MEURAL,
DRIVER_SAMSUNG,
DRIVER_ROKU,
)

eff_w, eff_h = native_w, native_h
rotation = 0
Expand Down
32 changes: 32 additions & 0 deletions custom_components/digital_frames/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ async def get(self, request: web.Request, token: str) -> web.Response:
return web.Response(status=404, text="Not found")


class DigitalFramesRokuContentView(HomeAssistantView):
"""Unauthenticated token URL for Roku's `media_player.play_media` fetch.

Same stage/token pattern as Samsung: the Roku Media Player app fetches
this URL without HA cookies, so security is the unguessable per-send
token (short TTL) rather than auth.
"""

url = "/api/digital_frames/roku/{token}/content.png"
name = "api:digital_frames:roku:content"
requires_auth = False

async def get(self, request: web.Request, token: str) -> web.Response:
hass = request.app["hass"]
domain_data = hass.data.get(DOMAIN, {})
for key, coord in domain_data.items():
if str(key).startswith("_"):
continue
getter = getattr(coord, "get_staged_content", None)
if not callable(getter):
continue
body = getter(token)
if body is not None:
return web.Response(
body=body,
content_type="image/png",
headers={"Cache-Control": "no-store"},
)
return web.Response(status=404, text="Not found")


class DigitalFramesFramePullBinView(HomeAssistantView):
"""Unauthenticated token URL for Fraimic/clone frame pull-on-wake.

Expand Down Expand Up @@ -145,6 +176,7 @@ def _is_frame_coordinator(obj) -> bool:
"_backlight",
"_mdc_reachable",
"_charging",
"_roku_reachable",
)


Expand Down
Loading
Loading