From 2865530bb2563fbc1b89c0c896586a0b71e7ddcc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 07:40:22 +0000 Subject: [PATCH 1/2] Add Roku TV as a digital frame driver (cast via HA core roku media_player) Roku has no local API to receive arbitrary image bytes, so this driver targets an existing HA core `roku` media_player entity and casts images via `media_player.play_media`, staging the PNG behind a short-lived HA-hosted token URL (same pattern as the Samsung MDC driver) since HA's Roku integration launches its built-in Media Player app off a URL rather than accepting bytes directly. Adds config_flow add_roku step (entity picker, no discovery/probe), RokuCoordinator, wiring through init/http_api/panel_codec/helpers/sensor/ library_http, panel + strings updates, KPF 36, and backend tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KmCpFs3Y4KhAjap7dSMNco --- README.md | 3 +- custom_components/digital_frames/__init__.py | 16 +- .../digital_frames/config_flow.py | 98 ++++++- custom_components/digital_frames/const.py | 8 + .../digital_frames/digital-frames-panel.js | 10 +- custom_components/digital_frames/helpers.py | 10 +- custom_components/digital_frames/http_api.py | 32 +++ .../digital_frames/library_http.py | 24 +- .../digital_frames/manifest.json | 3 + .../digital_frames/panel_codec.py | 6 +- .../digital_frames/roku_coordinator.py | 245 ++++++++++++++++++ custom_components/digital_frames/sensor.py | 44 +++- custom_components/digital_frames/strings.json | 24 +- .../digital_frames/translations/en.json | 24 +- docs/FRAME_PORT.md | 1 + docs/KEY_PRODUCT_FLOWS.md | 65 ++++- .../config_flow/test_config_flow_roku.py | 131 ++++++++++ tests/python/unit/test_roku.py | 181 +++++++++++++ 18 files changed, 898 insertions(+), 27 deletions(-) create mode 100644 custom_components/digital_frames/roku_coordinator.py create mode 100644 tests/python/config_flow/test_config_flow_roku.py create mode 100644 tests/python/unit/test_roku.py diff --git a/README.md b/README.md index 57de139..b059e4f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) | diff --git a/custom_components/digital_frames/__init__.py b/custom_components/digital_frames/__init__.py index 46ad50b..41fb197 100644 --- a/custom_components/digital_frames/__init__.py +++ b/custom_components/digital_frames/__init__.py @@ -134,6 +134,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: DigitalFramesFrameStatusView, DigitalFramesOnboardingView, DigitalFramesOpenApiView, + DigitalFramesRokuContentView, DigitalFramesSamsungContentView, DigitalFramesSendImageView, ) @@ -141,6 +142,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: 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()) @@ -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- @@ -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) diff --git a/custom_components/digital_frames/config_flow.py b/custom_components/digital_frames/config_flow.py index c40bfd7..7e29263 100644 --- a/custom_components/digital_frames/config_flow.py +++ b/custom_components/digital_frames/config_flow.py @@ -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 ( @@ -35,6 +36,7 @@ DOMAIN, DRIVER_FRAIMIC, DRIVER_MEURAL, + DRIVER_ROKU, DRIVER_SAMSUNG, KIND_SCENES_HUB, MEURAL_DEFAULT_HEIGHT, @@ -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, @@ -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"], ) # ------------------------------------------------------------------ @@ -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 # ------------------------------------------------------------------ @@ -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: @@ -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 @@ -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[ @@ -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[ diff --git a/custom_components/digital_frames/const.py b/custom_components/digital_frames/const.py index c246662..ad66204 100644 --- a/custom_components/digital_frames/const.py +++ b/custom_components/digital_frames/const.py @@ -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. @@ -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" diff --git a/custom_components/digital_frames/digital-frames-panel.js b/custom_components/digital_frames/digital-frames-panel.js index 54d6018..bf8e865 100644 --- a/custom_components/digital_frames/digital-frames-panel.js +++ b/custom_components/digital_frames/digital-frames-panel.js @@ -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'; diff --git a/custom_components/digital_frames/helpers.py b/custom_components/digital_frames/helpers.py index 1834c42..b549099 100644 --- a/custom_components/digital_frames/helpers.py +++ b/custom_components/digital_frames/helpers.py @@ -29,6 +29,7 @@ DOMAIN, DRIVER_FRAIMIC, DRIVER_MEURAL, + DRIVER_ROKU, DRIVER_SAMSUNG, EDGE_LEFT, ORIENTATION_AUTO, @@ -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 diff --git a/custom_components/digital_frames/http_api.py b/custom_components/digital_frames/http_api.py index 3dfeeb8..b94f7a5 100644 --- a/custom_components/digital_frames/http_api.py +++ b/custom_components/digital_frames/http_api.py @@ -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. @@ -145,6 +176,7 @@ def _is_frame_coordinator(obj) -> bool: "_backlight", "_mdc_reachable", "_charging", + "_roku_reachable", ) diff --git a/custom_components/digital_frames/library_http.py b/custom_components/digital_frames/library_http.py index 432540e..0e99265 100644 --- a/custom_components/digital_frames/library_http.py +++ b/custom_components/digital_frames/library_http.py @@ -707,8 +707,10 @@ async def get(self, request: web.Request) -> web.Response: from .const import ( # noqa: PLC0415 CONF_DRIVER, DRIVER_MEURAL, + DRIVER_ROKU, DRIVER_SAMSUNG, MEURAL_SIZE_LABEL, + ROKU_SIZE_LABEL, SAMSUNG_SIZE_LABEL, CONF_ORIENTATION, CONF_ORIENTATION_FOLLOW_DEVICE, @@ -728,6 +730,9 @@ async def get(self, request: web.Request) -> web.Response: is_samsung = entry.data.get(CONF_DRIVER) == DRIVER_SAMSUNG or ( entry.data.get(CONF_SIZE) == SAMSUNG_SIZE_LABEL ) + is_roku = entry.data.get(CONF_DRIVER) == DRIVER_ROKU or ( + entry.data.get(CONF_SIZE) == ROKU_SIZE_LABEL + ) spec = render_spec_for_hass_entry(hass, entry) coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id) # The frame's own entity_ids, resolved server-side so @@ -739,6 +744,7 @@ async def get(self, request: web.Request) -> web.Response: battery_entity_id = None orientation_entity_id = None ip_entity_id = None + roku_reachable_entity_id = None for reg_entry in er.async_entries_for_config_entry( registry, entry.entry_id ): @@ -748,7 +754,11 @@ async def get(self, request: web.Request) -> web.Response: orientation_entity_id = reg_entry.entity_id elif reg_entry.unique_id == f"{entry.entry_id}_ip": ip_entity_id = reg_entry.entity_id - send_entity_id = battery_entity_id or ip_entity_id + elif reg_entry.unique_id == f"{entry.entry_id}_roku_reachable": + roku_reachable_entity_id = reg_entry.entity_id + send_entity_id = ( + battery_entity_id or ip_entity_id or roku_reachable_entity_id + ) frames.append( { "entry_id": entry.entry_id, @@ -784,7 +794,11 @@ async def get(self, request: web.Request) -> web.Response: else ( "samsung" if is_samsung - else origin_for_fraimic_entry(entry) + else ( + "roku" + if is_roku + else origin_for_fraimic_entry(entry) + ) ) ), "platform": ( @@ -793,7 +807,11 @@ async def get(self, request: web.Request) -> web.Response: else ( "Samsung EM32DX" if is_samsung - else (frame_type.platform if frame_type else None) + else ( + "Roku TV" + if is_roku + else (frame_type.platform if frame_type else None) + ) ) ), "battery_entity_id": send_entity_id, diff --git a/custom_components/digital_frames/manifest.json b/custom_components/digital_frames/manifest.json index e572331..1fb367a 100644 --- a/custom_components/digital_frames/manifest.json +++ b/custom_components/digital_frames/manifest.json @@ -12,6 +12,9 @@ "dhcp", "media_source" ], + "after_dependencies": [ + "roku" + ], "dhcp": [ { "macaddress": "1CDBD4*" diff --git a/custom_components/digital_frames/panel_codec.py b/custom_components/digital_frames/panel_codec.py index 195bf95..1a81d23 100644 --- a/custom_components/digital_frames/panel_codec.py +++ b/custom_components/digital_frames/panel_codec.py @@ -25,8 +25,10 @@ from .const import ( CONF_DRIVER, DRIVER_MEURAL, + DRIVER_ROKU, DRIVER_SAMSUNG, MEURAL_SIZE_LABEL, + ROKU_SIZE_LABEL, SAMSUNG_SIZE_LABEL, ) from .frame_types import ( @@ -122,13 +124,13 @@ def panel_codec_for_entry(entry: "ConfigEntry") -> PanelCodec: """Resolve codec for a config entry (driver, then size, then geometry).""" if entry.data.get(CONF_DRIVER) == DRIVER_MEURAL: return panel_codec_for_id(CODEC_JPEG_Q90) - if entry.data.get(CONF_DRIVER) == DRIVER_SAMSUNG: + if entry.data.get(CONF_DRIVER) in (DRIVER_SAMSUNG, DRIVER_ROKU): return panel_codec_for_id(CODEC_PNG) size = entry.data.get("size") if isinstance(size, str) and size == MEURAL_SIZE_LABEL: return panel_codec_for_id(CODEC_JPEG_Q90) - if isinstance(size, str) and size == SAMSUNG_SIZE_LABEL: + if isinstance(size, str) and size in (SAMSUNG_SIZE_LABEL, ROKU_SIZE_LABEL): return panel_codec_for_id(CODEC_PNG) if isinstance(size, str) and size in FRAME_TYPES: return panel_codec_for_frame_type_id(size) diff --git a/custom_components/digital_frames/roku_coordinator.py b/custom_components/digital_frames/roku_coordinator.py new file mode 100644 index 0000000..6658234 --- /dev/null +++ b/custom_components/digital_frames/roku_coordinator.py @@ -0,0 +1,245 @@ +"""DataUpdateCoordinator for Roku TV (cast-via-media_player driver). + +Roku has no local API to receive arbitrary image bytes the way Fraimic/ +Meural/Samsung do. Instead this driver stages a PNG behind a short-lived +HA-hosted token URL (same pattern as ``samsung_coordinator.py``) and calls +HA core's own ``roku`` integration's ``media_player.play_media`` service, +which launches Roku's built-in "Roku Media Player" app pointed at that URL. +No Roku cloud account, no protocol of our own -- the transport is an HA +service call onto a `media_player.roku_*` entity the user already has. + +Duck-types the Fraimic/Meural/Samsung coordinator surface used by library +send, scenes, walls, and preview storage. +""" + +from __future__ import annotations + +import base64 +import logging +import secrets +import time +from datetime import timedelta +from typing import TYPE_CHECKING, Any + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.network import get_url +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + API_REFRESH, + API_RESTART, + API_SLEEP, + CONF_ROKU_ENTITY_ID, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +_PREVIEW_STORE_VERSION = 1 +# Keep staged PNG fetchable while the Roku Media Player app loads it. +_CONTENT_TTL_SEC = 600 + + +class RokuCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Cast images to a Roku by calling HA core's `roku` media_player.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + self.roku_entity_id: str = config_entry.data[CONF_ROKU_ENTITY_ID] + # No IP/PIN of our own -- reuse the target entity_id as the stand-in + # identity for the frame-coordinator duck-type check and command + # logging (see http_api.py's _is_frame_coordinator). + self.host: str = self.roku_entity_id + + scan_seconds: int = config_entry.options.get( + "scan_interval", DEFAULT_SCAN_INTERVAL + ) + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} roku {self.roku_entity_id}", + update_interval=timedelta(seconds=scan_seconds), + config_entry=config_entry, + ) + self.config_entry = config_entry + + self.last_image_id: str | None = None + self.last_thumbnail: bytes | None = None + self.pending_send: dict[str, Any] | None = None + + self._content_token: str | None = None + self._content_bytes: bytes | None = None + self._content_expires: float = 0.0 + + self._preview_store = Store( + hass, + _PREVIEW_STORE_VERSION, + f"{DOMAIN}_roku_preview_{config_entry.entry_id}", + ) + + async def async_load_last_image(self) -> None: + data = await self._preview_store.async_load() + if not isinstance(data, dict): + return + self.last_image_id = data.get("image_id") + thumb_b64 = data.get("thumbnail_b64") + if thumb_b64: + try: + self.last_thumbnail = base64.b64decode(thumb_b64) + except Exception: # noqa: BLE001 + self.last_thumbnail = None + + async def async_load_pending_send(self) -> None: + return + + async def async_set_last_image( + self, + *, + image_id: str | None = None, + thumbnail: bytes | None = None, + ) -> None: + self.last_image_id = image_id + self.last_thumbnail = thumbnail + await self._preview_store.async_save( + { + "image_id": image_id, + "thumbnail_b64": ( + base64.b64encode(thumbnail).decode("ascii") if thumbnail else None + ), + } + ) + + def stage_content(self, image_bytes: bytes) -> str: + """Stage PNG bytes; return content token for the public fetch URL.""" + self._content_token = secrets.token_urlsafe(18) + self._content_bytes = image_bytes + self._content_expires = time.time() + _CONTENT_TTL_SEC + return self._content_token + + def get_staged_content(self, token: str) -> bytes | None: + if not token or token != self._content_token: + return None + if time.time() > self._content_expires: + return None + return self._content_bytes + + def content_url(self, token: str) -> str: + base = get_url( + self.hass, + prefer_external=False, + allow_cloud=False, + allow_external=True, + ).rstrip("/") + return f"{base}/api/digital_frames/roku/{token}/content.png" + + async def _async_update_data(self) -> dict[str, Any]: + state = self.hass.states.get(self.roku_entity_id) + reachable = state is not None and state.state not in ( + "unavailable", + "unknown", + ) + return { + "driver": "roku", + "host": self.roku_entity_id, + "reachable": reachable, + "ip_address": None, + "firmware_version": None, + "device_orientation": None, + } + + async def async_config_entry_updated( + self, + hass: HomeAssistant, # noqa: ARG002 + entry: ConfigEntry, + ) -> None: + self.roku_entity_id = entry.data.get(CONF_ROKU_ENTITY_ID, self.roku_entity_id) + self.host = self.roku_entity_id + await self.async_request_refresh() + + async def async_send_image(self, image_bytes: bytes) -> int: + """Stage PNG, cast via HA's roku `media_player.play_media`.""" + if self.hass.states.get(self.roku_entity_id) is None: + raise HomeAssistantError( + f"Roku media player {self.roku_entity_id} not found" + ) + + # Normalize to PNG if JPEG was somehow passed. + if image_bytes[:2] == b"\xff\xd8": + image_bytes = await self.hass.async_add_executor_job( + _jpeg_to_png, image_bytes + ) + token = self.stage_content(image_bytes) + url = self.content_url(token) + + try: + # HA core's `roku` integration has no dedicated "image" media + # type; MediaType.URL/VIDEO both launch the Roku Media Player + # app with t=v, which sniffs the fetched content itself -- this + # is the documented community pattern for casting a still photo. + await self.hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": self.roku_entity_id, + "media_content_id": url, + "media_content_type": "video", + }, + blocking=True, + ) + except Exception as err: + _LOGGER.error("Roku cast to %s failed: %s", self.roku_entity_id, err) + raise HomeAssistantError(f"Roku cast failed: {err}") from err + + return 200 + + async def async_send_image_or_queue( + self, + image_bytes: bytes, + *, + image_id: str | None = None, + thumbnail: bytes | None = None, + ) -> dict[str, Any]: + try: + await self.async_send_image(image_bytes) + except (HomeAssistantError, OSError, TimeoutError, ValueError) as err: + return {"success": False, "queued": False, "message": str(err)} + await self.async_set_last_image(image_id=image_id, thumbnail=thumbnail) + return {"success": True, "queued": False} + + async def async_send_command(self, endpoint: str) -> int: + key = (endpoint or "").strip() + if key in (API_SLEEP, "/api/sleep", "sleep"): + await self.hass.services.async_call( + "media_player", + "turn_off", + {"entity_id": self.roku_entity_id}, + blocking=True, + ) + return 200 + if key in (API_REFRESH, "/api/refresh", "refresh", "/api/wake", "wake"): + await self.hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": self.roku_entity_id}, + blocking=True, + ) + return 200 + if key in (API_RESTART, "/api/restart", "restart"): + raise HomeAssistantError("Restart is not supported on Roku") + raise HomeAssistantError(f"Unsupported Roku command: {endpoint!r}") + + +def _jpeg_to_png(jpeg_bytes: bytes) -> bytes: + import io # noqa: PLC0415 + + from PIL import Image # noqa: PLC0415 + + img = Image.open(io.BytesIO(jpeg_bytes)).convert("RGB") + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + return buf.getvalue() diff --git a/custom_components/digital_frames/sensor.py b/custom_components/digital_frames/sensor.py index dded765..c1d483e 100644 --- a/custom_components/digital_frames/sensor.py +++ b/custom_components/digital_frames/sensor.py @@ -57,7 +57,7 @@ async def async_setup_entry( return # Samsung EM32DX (experimental): IP + MDC reachability for now. - from .const import DRIVER_SAMSUNG # noqa: PLC0415 + from .const import DRIVER_ROKU, DRIVER_SAMSUNG # noqa: PLC0415 if entry.data.get(CONF_DRIVER) == DRIVER_SAMSUNG: async_add_entities( @@ -68,6 +68,12 @@ async def async_setup_entry( ) return + # Roku: no IP/battery of our own -- the linked media_player entity + # already tracks its own state; just surface whether we could see it. + if entry.data.get(CONF_DRIVER) == DRIVER_ROKU: + async_add_entities([RokuReachableSensor(coordinator, entry)]) + return + async_add_entities( [ DigitalFramesBatterySensor(coordinator, entry), @@ -90,7 +96,12 @@ def frame_device_info( ) -> DeviceInfo: """Device registry info for one frame -- shared by every entity platform (sensors, the orientation select) so they all land on the same device.""" - from .const import CONF_DRIVER, DRIVER_MEURAL, DRIVER_SAMSUNG # noqa: PLC0415 + from .const import ( # noqa: PLC0415 + CONF_DRIVER, + DRIVER_MEURAL, + DRIVER_ROKU, + DRIVER_SAMSUNG, + ) fw: str | None = None if coordinator.data: @@ -102,6 +113,9 @@ def frame_device_info( elif entry.data.get(CONF_DRIVER) == DRIVER_SAMSUNG: manufacturer = "Samsung" model = "EM32DX (experimental)" + elif entry.data.get(CONF_DRIVER) == DRIVER_ROKU: + manufacturer = "Roku" + model = "TV (cast via media_player)" else: frame_type = FRAME_TYPES.get(entry.data.get(CONF_SIZE)) if frame_type is not None: @@ -370,6 +384,32 @@ def native_value(self) -> str | None: return None +class RokuReachableSensor(DigitalFramesBaseSensor): + """Whether the linked Roku media_player entity was reachable on the last poll.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: DigitalFramesCoordinator, + entry: ConfigEntry, + ) -> None: + super().__init__(coordinator, entry) + self._attr_unique_id = f"{entry.entry_id}_roku_reachable" + self._attr_name = "Roku reachable" + + @property + def native_value(self) -> str | None: + if not self.coordinator.data: + return None + reachable = self.coordinator.data.get("reachable") + if reachable is True: + return "yes" + if reachable is False: + return "no" + return None + + class MeuralDeviceOrientationSensor(DigitalFramesBaseSensor): """Physical hang orientation from the Meural gsensor (portrait/landscape). diff --git a/custom_components/digital_frames/strings.json b/custom_components/digital_frames/strings.json index 4b68726..786be0a 100644 --- a/custom_components/digital_frames/strings.json +++ b/custom_components/digital_frames/strings.json @@ -4,11 +4,12 @@ "step": { "user": { "title": "Add a frame", - "description": "Choose the kind of display to add. Meural uses the local LAN postcard API. Samsung EM32DX uses local MDC (experimental).", + "description": "Choose the kind of display to add. Meural uses the local LAN postcard API. Samsung EM32DX uses local MDC (experimental). Roku casts to an existing Roku media player.", "menu_options": { "add_fraimic": "Fraimic / e-ink clone", "add_meural": "Meural Canvas (local)", - "add_samsung": "Samsung EM32DX (experimental)" + "add_samsung": "Samsung EM32DX (experimental)", + "add_roku": "Roku TV" } }, "add_fraimic": { @@ -71,6 +72,22 @@ "height": "Panel height (default 1440)" } }, + "add_roku": { + "title": "Add Roku TV", + "description": "Casts images to an existing Roku via Home Assistant's own `roku` integration (media_player.play_media) — no push protocol, no cloud. Set up that Roku in HA first if it's not listed below.", + "data": { + "roku_media_player_entity_id": "Roku media player", + "name": "Frame Name", + "width": "Width (pixels)", + "height": "Height (pixels)" + }, + "data_description": { + "roku_media_player_entity_id": "An existing media_player entity from HA core's Roku integration", + "name": "Friendly name for walls and scenes", + "width": "Screen width in pixels (default 1920)", + "height": "Screen height in pixels (default 1080)" + } + }, "pick_device": { "title": "Choose a Fraimic Frame", "description": "Select a frame discovered on your network.", @@ -111,7 +128,8 @@ }, "abort": { "already_configured": "This frame is already configured", - "not_fraimic_device": "This device is not a Fraimic frame" + "not_fraimic_device": "This device is not a Fraimic frame", + "no_roku_media_players": "No available Roku media players found. Set up HA core's Roku integration first (Settings → Devices & Services → Add Integration → Roku), or every discovered Roku is already added here." } }, "options": { diff --git a/custom_components/digital_frames/translations/en.json b/custom_components/digital_frames/translations/en.json index 4b68726..786be0a 100644 --- a/custom_components/digital_frames/translations/en.json +++ b/custom_components/digital_frames/translations/en.json @@ -4,11 +4,12 @@ "step": { "user": { "title": "Add a frame", - "description": "Choose the kind of display to add. Meural uses the local LAN postcard API. Samsung EM32DX uses local MDC (experimental).", + "description": "Choose the kind of display to add. Meural uses the local LAN postcard API. Samsung EM32DX uses local MDC (experimental). Roku casts to an existing Roku media player.", "menu_options": { "add_fraimic": "Fraimic / e-ink clone", "add_meural": "Meural Canvas (local)", - "add_samsung": "Samsung EM32DX (experimental)" + "add_samsung": "Samsung EM32DX (experimental)", + "add_roku": "Roku TV" } }, "add_fraimic": { @@ -71,6 +72,22 @@ "height": "Panel height (default 1440)" } }, + "add_roku": { + "title": "Add Roku TV", + "description": "Casts images to an existing Roku via Home Assistant's own `roku` integration (media_player.play_media) — no push protocol, no cloud. Set up that Roku in HA first if it's not listed below.", + "data": { + "roku_media_player_entity_id": "Roku media player", + "name": "Frame Name", + "width": "Width (pixels)", + "height": "Height (pixels)" + }, + "data_description": { + "roku_media_player_entity_id": "An existing media_player entity from HA core's Roku integration", + "name": "Friendly name for walls and scenes", + "width": "Screen width in pixels (default 1920)", + "height": "Screen height in pixels (default 1080)" + } + }, "pick_device": { "title": "Choose a Fraimic Frame", "description": "Select a frame discovered on your network.", @@ -111,7 +128,8 @@ }, "abort": { "already_configured": "This frame is already configured", - "not_fraimic_device": "This device is not a Fraimic frame" + "not_fraimic_device": "This device is not a Fraimic frame", + "no_roku_media_players": "No available Roku media players found. Set up HA core's Roku integration first (Settings → Devices & Services → Add Integration → Roku), or every discovered Roku is already added here." } }, "options": { diff --git a/docs/FRAME_PORT.md b/docs/FRAME_PORT.md index a608236..16fde65 100644 --- a/docs/FRAME_PORT.md +++ b/docs/FRAME_PORT.md @@ -374,6 +374,7 @@ it belongs in core and must use the port. | **3 – Second driver** | Local Meural (`driver=meural`): config-flow menu, `MeuralCoordinator`, JPEG `jpeg_q90` codec, postcard send; walls/scenes/library. **Meural cloud is out of scope** (not deferred). | **Done** | | **3b – Samsung MDC** | Local Samsung EM32DX (`driver=samsung`): MDC TLS content-download + HA token PNG URL ([Joyous](https://github.com/fayep/Joyous)). | **Done** (experimental; volunteer hardware) | | **3c – InkJoy** | Out of scope for now (MQTT control plane). | Out of scope | +| **3d – Roku (cast)** | `driver=roku`: no push protocol of its own — targets an existing HA core `roku` media_player entity and casts via `media_player.play_media` (HA token PNG URL, same pattern as Samsung). No cloud, no sleep-queue. | **Done** (untested on real hardware) | | **4 – Branding** | Product + domain **Digital Frames** / `digital_frames`; repo `dsackr/ha-digital-frames`; library `digital_frames_library`; types `DigitalFrames*`. | **Done** | **Immediate next:** Clean path on maintainer production HA; community diff --git a/docs/KEY_PRODUCT_FLOWS.md b/docs/KEY_PRODUCT_FLOWS.md index 5477a5c..3b51618 100644 --- a/docs/KEY_PRODUCT_FLOWS.md +++ b/docs/KEY_PRODUCT_FLOWS.md @@ -1685,6 +1685,7 @@ never happens. text rejected client-side, backend failure surfaced in the feedback div). +<<<<<<< HEAD ## 36. Wallpaper mode: one shared background image spanning a wall's frames Lives natively on the Walls tab (KPF 19) — not a separate tab, not a separate "wallpaper wall." A "🖼 Wallpaper" button next to Align Wall to Grid toggles wallpaper-editing view for the currently active wall; a "🖼 Choose Image…" button (visible only in that view) opens a library-image picker with an Album filter (`_loadWallWallpaperPickerImages`) and reuses the per-frame picker's own cell/thumb markup (`.image-picker-cell`/`.image-picker-thumb`), so its thumbnails crop to a clean, centered square instead of an arbitrary corner (see the bug note below). @@ -1731,6 +1732,65 @@ The wallpaper-spanning half of Art Factory (arranging one image across a wall's --- +## 39. Roku TV as a digital frame (cast via HA core's `roku` media_player) +User targets an existing HA core `roku` media_player entity (set up through +HA's own **Roku** integration, not this one) from the add-frame menu. Roku +has no local API to receive arbitrary image bytes, so unlike Fraimic/ +Meural/Samsung this driver pushes nothing over its own protocol: it stages +the composed PNG behind a short-lived HA-hosted token URL (same +stage/token pattern as Samsung's MDC content-download) and calls +`media_player.play_media` with `media_content_type: "video"` pointing at +that URL. HA core's `roku` integration has no dedicated "image" media +type — `video`/`url` both launch Roku's built-in "Roku Media Player" app, +which sniffs the fetched content itself; this is the documented community +technique for casting a still photo to a Roku, not a bug workaround unique +to this driver. + +**No sleep-queue, no cloud, no battery/orientation sensors.** Reachability +is read from the linked `media_player` entity's own state (`unavailable`/ +`unknown` → offline) rather than a probe of our own. `refresh`/`wake` +service calls map to `media_player.turn_on`; `sleep` maps to +`media_player.turn_off`; `restart` is unsupported (raises). Casting +interrupts whatever the Roku is currently doing (it is not a dedicated +frame) and the "Rediscover orientation" panel button is hidden, same as +Samsung. **Not validated on real hardware in this repo** (Gap: live Roku). +- **Entry points**: `const.py` (`DRIVER_ROKU`, `CONF_ROKU_ENTITY_ID`, + `ROKU_SIZE_LABEL`), `config_flow.py` (`async_step_add_roku` — entity + picker over `media_player.*` entities whose entity-registry `platform == + "roku"`; `DigitalFramesOptionsFlow`'s `is_roku` branch skips + Fraimic-only battery/rotation-edge/frame-type fields), `roku_coordinator.py` + (`RokuCoordinator.stage_content` / `content_url` / `async_send_image` / + `async_send_command`), `__init__.py` (`async_setup_entry` driver + dispatch, `DigitalFramesRokuContentView` registration), `http_api.py` + (`DigitalFramesRokuContentView`, `_ENTITY_UNIQUE_SUFFIXES`), + `panel_codec.py` (`panel_codec_for_entry` → `CODEC_PNG`), `helpers.py` + (`render_spec_for_entry`'s `hang_sized` tuple), `sensor.py` + (`RokuReachableSensor`, `frame_device_info`), `library_http.py` + (`DigitalFramesFramesView` `is_roku`/`origin`/`platform`), + `manifest.json` (`after_dependencies: ["roku"]`). +- **If it silently breaks**: add-frame shows no Roku option (HA core's + `roku` integration isn't set up, or every discovered Roku entity is + already added here); the token URL 404s because HA isn't reachable from + the Roku's LAN (same failure mode as Samsung's MDC pull); `play_media` + raises because the target `media_player.roku_*` entity was deleted/ + renamed after this entry was created; the cast silently shows nothing + useful because Roku's Media Player app didn't sniff the content as an + image (out of this driver's control — HA core's `roku` integration has + no `t=i` image media type to ask for explicitly). +- **Test status**: **Backend-tested** — `tests/python/unit/test_roku.py` + (`panel_codec_for_entry` → PNG, `RokuCoordinator.stage_content` / + `get_staged_content` round-trip + expiry, `async_send_image` calls + `media_player.play_media` with the staged content URL and + `media_content_type: video`, `async_send_command` maps + sleep/refresh/wake to `turn_off`/`turn_on` and rejects restart), + `tests/python/config_flow/test_config_flow_roku.py` (menu offers + `add_roku`, entity picker lists only `platform == "roku"` media_player + entities and excludes already-configured ones, abort when none + available, entry created with `DRIVER_ROKU` + chosen entity_id). Live + Roku hardware is manual (**Gap**). + +--- + ## Coverage summary | Phase | Scope | Status | @@ -1745,8 +1805,9 @@ The wallpaper-spanning half of Art Factory (arranging one image across a wall's | — | Panel init-load resilience, panel element lifecycle, Lovelace card (KPFs 26, 27, 29) | Done — frontend side; KPF 29's HTTP views fold into 5b | | — | Media Source & AI Auto-tagging (KPFs 30, 31) | Done | | — | Compose & send a styled text message (KPF 35) | Done | -| — | Spanned wall image across 2D physical frame layouts (KPF 36) | Done | -| — | Art Factory AI image generation & fallback (KPF 38) | Done | +| — | Wallpaper mode: one shared background image across a wall's frames (KPF 36) | Done | +| — | Art Factory AI image generation (KPF 38) | Retired — see KPF 38 | +| — | Roku TV cast driver (KPF 39) | Done (backend; live hardware untested) | Phase 5b (plus KPF 18's widget scheduling) is scoped here but not yet implemented — see [TESTING_STRATEGY.md](../TESTING_STRATEGY.md) for the diff --git a/tests/python/config_flow/test_config_flow_roku.py b/tests/python/config_flow/test_config_flow_roku.py new file mode 100644 index 0000000..b151ceb --- /dev/null +++ b/tests/python/config_flow/test_config_flow_roku.py @@ -0,0 +1,131 @@ +"""Add a Roku TV frame (KPF 36) — entity picker over HA core's `roku` +media_player, no discovery/probe of our own. + +If this silently breaks: users can't add a Roku frame at all, or an +already-configured Roku media_player is offered again (duplicate entries +targeting the same physical TV). +""" + +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.digital_frames.const import ( + CONF_DEVICE_KEY, + CONF_DRIVER, + CONF_HEIGHT, + CONF_NAME, + CONF_ROKU_ENTITY_ID, + CONF_SIZE, + CONF_WIDTH, + DOMAIN, + DRIVER_ROKU, + ROKU_DEFAULT_HEIGHT, + ROKU_DEFAULT_WIDTH, + ROKU_SIZE_LABEL, +) + + +def _register_roku_media_player(hass, unique_id: str, object_id: str) -> str: + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "media_player", + "roku", + unique_id, + suggested_object_id=object_id, + ) + return entry.entity_id + + +async def test_menu_offers_add_roku(hass): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + assert "add_roku" in result["menu_options"] + + +async def test_add_roku_no_media_players_aborts(hass): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "add_roku"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_roku_media_players" + + +async def test_add_roku_creates_entry(hass): + entity_id = _register_roku_media_player(hass, "ROKU123", "living_room_roku") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "add_roku"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_roku" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ROKU_ENTITY_ID: entity_id, + CONF_NAME: "Living Room Roku", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_DRIVER] == DRIVER_ROKU + assert result["data"][CONF_ROKU_ENTITY_ID] == entity_id + assert result["data"][CONF_SIZE] == ROKU_SIZE_LABEL + assert result["data"][CONF_WIDTH] == ROKU_DEFAULT_WIDTH + assert result["data"][CONF_HEIGHT] == ROKU_DEFAULT_HEIGHT + assert result["data"][CONF_DEVICE_KEY] == f"roku:{entity_id}" + + +async def test_add_roku_excludes_already_configured_entity(hass): + entity_id = _register_roku_media_player(hass, "ROKU456", "bedroom_roku") + + MockConfigEntry( + domain=DOMAIN, + unique_id=f"roku:{entity_id}", + data={ + CONF_DRIVER: DRIVER_ROKU, + CONF_ROKU_ENTITY_ID: entity_id, + CONF_NAME: "Bedroom Roku", + CONF_WIDTH: ROKU_DEFAULT_WIDTH, + CONF_HEIGHT: ROKU_DEFAULT_HEIGHT, + CONF_SIZE: ROKU_SIZE_LABEL, + CONF_DEVICE_KEY: f"roku:{entity_id}", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "add_roku"} + ) + # The only registered Roku media_player is already configured here. + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_roku_media_players" + + +async def test_add_roku_ignores_non_roku_media_players(hass): + registry = er.async_get(hass) + registry.async_get_or_create( + "media_player", "cast", "CAST1", suggested_object_id="living_room_tv" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "add_roku"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_roku_media_players" diff --git a/tests/python/unit/test_roku.py b/tests/python/unit/test_roku.py new file mode 100644 index 0000000..4022d70 --- /dev/null +++ b/tests/python/unit/test_roku.py @@ -0,0 +1,181 @@ +"""Roku TV cast driver (KPF 36) — no push protocol, casts via HA core's +`roku` media_player.play_media. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.digital_frames.const import ( + CONF_DRIVER, + CONF_ROKU_ENTITY_ID, + DRIVER_ROKU, + ROKU_SIZE_LABEL, +) +from custom_components.digital_frames.panel_codec import ( + CODEC_PNG, + encode_for_panel, + panel_codec_for_entry, +) +from custom_components.digital_frames.roku_coordinator import RokuCoordinator + + +def test_panel_codec_for_roku_entry(): + entry = SimpleNamespace( + entry_id="r1", + data={ + CONF_DRIVER: DRIVER_ROKU, + "width": 1920, + "height": 1080, + "size": ROKU_SIZE_LABEL, + }, + ) + assert panel_codec_for_entry(entry).id == CODEC_PNG + assert panel_codec_for_entry(entry).preferred_payload == "png" + + +def test_encode_png_for_roku_geometry(sample_image_bytes): + out = encode_for_panel( + sample_image_bytes(400, 300), + 1920, + 1080, + 0, + False, + "fast", + None, + CODEC_PNG, + ) + assert out[:8] == b"\x89PNG\r\n\x1a\n" + assert len(out) > 100 + + +def _make_coordinator(hass=None): + hass = hass or MagicMock() + entry = MagicMock() + entry.entry_id = "roku1" + entry.data = {CONF_ROKU_ENTITY_ID: "media_player.living_room_roku"} + entry.options = {} + return RokuCoordinator(hass, entry) + + +def test_roku_coordinator_stages_content(): + coord = _make_coordinator() + token = coord.stage_content(b"\x89PNG fake") + assert coord.get_staged_content(token) == b"\x89PNG fake" + assert coord.get_staged_content("wrong") is None + + +def test_roku_coordinator_host_is_entity_id(): + coord = _make_coordinator() + assert coord.host == "media_player.living_room_roku" + + +async def test_async_send_image_calls_play_media(monkeypatch): + hass = MagicMock() + hass.states.get.return_value = SimpleNamespace(state="idle") + hass.services.async_call = AsyncMock() + hass.async_add_executor_job = AsyncMock() + + coord = _make_coordinator(hass) + monkeypatch.setattr(coord, "content_url", lambda token: f"http://ha/{token}") + + status = await coord.async_send_image(b"\x89PNG\r\n\x1a\nfake") + + assert status == 200 + hass.services.async_call.assert_awaited_once() + args, kwargs = hass.services.async_call.await_args + assert args[0] == "media_player" + assert args[1] == "play_media" + call_data = args[2] + assert call_data["entity_id"] == "media_player.living_room_roku" + assert call_data["media_content_type"] == "video" + assert call_data["media_content_id"].startswith("http://ha/") + assert kwargs["blocking"] is True + + +async def test_async_send_image_missing_entity_raises(): + from homeassistant.exceptions import HomeAssistantError + + hass = MagicMock() + hass.states.get.return_value = None + + coord = _make_coordinator(hass) + with pytest.raises(HomeAssistantError): + await coord.async_send_image(b"\x89PNG fake") + + +async def test_async_send_image_or_queue_never_queues(monkeypatch): + hass = MagicMock() + hass.states.get.return_value = SimpleNamespace(state="idle") + hass.services.async_call = AsyncMock() + + coord = _make_coordinator(hass) + monkeypatch.setattr(coord, "content_url", lambda token: f"http://ha/{token}") + monkeypatch.setattr(coord, "async_set_last_image", AsyncMock()) + + result = await coord.async_send_image_or_queue( + b"\x89PNG\r\n\x1a\nfake", image_id="img1", thumbnail=b"thumb" + ) + assert result == {"success": True, "queued": False} + coord.async_set_last_image.assert_awaited_once_with( + image_id="img1", thumbnail=b"thumb" + ) + + +async def test_async_send_image_or_queue_reports_failure(): + hass = MagicMock() + hass.states.get.return_value = None + + coord = _make_coordinator(hass) + result = await coord.async_send_image_or_queue(b"\x89PNG fake") + assert result["success"] is False + assert result["queued"] is False + + +async def test_async_send_command_sleep_turns_off(): + hass = MagicMock() + hass.services.async_call = AsyncMock() + coord = _make_coordinator(hass) + + status = await coord.async_send_command("/api/sleep") + assert status == 200 + hass.services.async_call.assert_awaited_once_with( + "media_player", + "turn_off", + {"entity_id": "media_player.living_room_roku"}, + blocking=True, + ) + + +async def test_async_send_command_refresh_turns_on(): + hass = MagicMock() + hass.services.async_call = AsyncMock() + coord = _make_coordinator(hass) + + status = await coord.async_send_command("/api/refresh") + assert status == 200 + hass.services.async_call.assert_awaited_once_with( + "media_player", + "turn_on", + {"entity_id": "media_player.living_room_roku"}, + blocking=True, + ) + + +async def test_async_send_command_restart_unsupported(): + from homeassistant.exceptions import HomeAssistantError + + coord = _make_coordinator() + with pytest.raises(HomeAssistantError, match="not supported"): + await coord.async_send_command("/api/restart") + + +async def test_async_send_command_unknown_raises(): + from homeassistant.exceptions import HomeAssistantError + + coord = _make_coordinator() + with pytest.raises(HomeAssistantError, match="Unsupported"): + await coord.async_send_command("/api/bogus") From fe26c8e009b9dca64bcccf6b0c9a33be8eac395f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:17:16 +0000 Subject: [PATCH 2/2] Fix manifest.json key ordering for hassfest validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit after_dependencies must sort alphabetically with the rest of the manifest keys (after domain/name) — hassfest's validate check flagged it placed after dependencies instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KmCpFs3Y4KhAjap7dSMNco --- custom_components/digital_frames/manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/digital_frames/manifest.json b/custom_components/digital_frames/manifest.json index 1fb367a..8caf399 100644 --- a/custom_components/digital_frames/manifest.json +++ b/custom_components/digital_frames/manifest.json @@ -1,6 +1,9 @@ { "domain": "digital_frames", "name": "Digital Frames", + "after_dependencies": [ + "roku" + ], "codeowners": [ "@dsackr" ], @@ -12,9 +15,6 @@ "dhcp", "media_source" ], - "after_dependencies": [ - "roku" - ], "dhcp": [ { "macaddress": "1CDBD4*"