From f36736a4715855da4e4b6dbb3d8a0682483cbf07 Mon Sep 17 00:00:00 2001 From: Misiu Date: Fri, 28 Aug 2026 07:45:56 +0200 Subject: [PATCH 1/2] Fix Renderer discovery during App upgrades --- CHANGELOG.md | 8 +++ README.md | 2 +- .../opendisplay_studio/__init__.py | 60 ++++++++++++++---- custom_components/opendisplay_studio/const.py | 2 +- .../opendisplay_studio/manifest.json | 2 +- pyproject.toml | 2 +- tests/test_runtime_recovery.py | 61 ++++++++++++++++++- 7 files changed, 120 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898c26a..437a2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## 0.6.1 + +- Use Supervisor discovery only for Renderer transport details and verify API + compatibility through the authenticated health endpoint, allowing clean + upgrades while discovery identity metadata is briefly stale. +- Accept numeric discovery ports serialized as either JSON numbers or strings + and log sanitized diagnostics when transport data is invalid. + ## 0.6.0 - Resolve package-owned files from `widgets//assets` into bounded `data:` diff --git a/README.md b/README.md index 125d9d9..f61a12b 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ token. No LAN port configuration is shown on HA OS/Supervised. At every config-entry setup, a managed Renderer is checked through `AddonManager`. A missing App is scheduled for installation, a stopped App is scheduled to start, and Home Assistant retries the entry. A running App must -also pass `/health` with API version 1 and report its pinned TRMNL Framework +also pass `/health` with API version 2 and report its pinned TRMNL Framework version. The entry stores whether OpenDisplay Studio installed the Renderer. Removing diff --git a/custom_components/opendisplay_studio/__init__.py b/custom_components/opendisplay_studio/__init__.py index af77ca5..64e866a 100644 --- a/custom_components/opendisplay_studio/__init__.py +++ b/custom_components/opendisplay_studio/__init__.py @@ -19,8 +19,8 @@ from .cache import RenderCache from .const import ( ADDON_SLUG, - API_VERSION, CONF_ADDON_SLUG, + CONF_API_VERSION, CONF_AUTH_TOKEN, CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, @@ -99,9 +99,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: OpenDisplayStudioConfigEntry ) -> bool: """Set up and health-check the configured Renderer.""" + renderer_url = entry.data[CONF_URL] + renderer_token = entry.data.get(CONF_AUTH_TOKEN, "") if entry.data.get(CONF_USE_ADDON): await _async_ensure_addon_running(hass) connection = await _async_get_addon_connection(hass) + renderer_url = connection[CONF_URL] + renderer_token = connection[CONF_AUTH_TOKEN] if any(entry.data.get(key) != value for key, value in connection.items()): hass.config_entries.async_update_entry( entry, data={**entry.data, **connection} @@ -109,14 +113,19 @@ async def async_setup_entry( client = RendererClient( async_get_clientsession(hass), - entry.data[CONF_URL], - entry.data.get(CONF_AUTH_TOKEN, ""), + renderer_url, + renderer_token, ) try: health = await client.async_health() except RendererError as err: message = f"Renderer health check failed: {err}" raise ConfigEntryNotReady(message) from err + if entry.data.get(CONF_API_VERSION) != health["apiVersion"]: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_API_VERSION: health["apiVersion"]}, + ) entry.runtime_data = OpenDisplayStudioRuntimeData( client=client, health=health, @@ -179,17 +188,26 @@ async def _async_ensure_addon_running(hass: HomeAssistant) -> None: raise ConfigEntryNotReady("Renderer App start scheduled") -async def _async_get_addon_connection(hass: HomeAssistant) -> dict[str, object]: - """Resolve internal hostname and token from supported Supervisor discovery.""" +async def _async_get_addon_connection(hass: HomeAssistant) -> dict[str, str]: + """ + Resolve the transport fields from Supervisor discovery. + + Renderer identity and API compatibility are verified by the authenticated + health endpoint. Discovery metadata can briefly be stale while Supervisor + processes an App update, so it must not prevent that authoritative check. + """ try: discovery = await get_addon_manager(hass).async_get_addon_discovery_info() except AddonError as err: raise ConfigEntryNotReady("Renderer App discovery is unavailable") from err host = discovery.get("host") - port = discovery.get("port") + raw_port = discovery.get("port") token = discovery.get(CONF_AUTH_TOKEN) - instance_id = discovery.get("instance_id") - api_version = discovery.get("api_version") + port = ( + int(raw_port) + if isinstance(raw_port, str) and raw_port.isdecimal() + else raw_port + ) if ( not isinstance(host, str) or not host @@ -198,14 +216,32 @@ async def _async_get_addon_connection(hass: HomeAssistant) -> dict[str, object]: or not 1 <= port <= 65535 or not isinstance(token, str) or not token - or not isinstance(instance_id, str) - or api_version != API_VERSION ): + LOGGER.warning( + "Renderer App discovery transport is invalid: keys=%s " + "host_type=%s port=%r port_type=%s auth_token_present=%s " + "instance_id_present=%s api_version=%r", + sorted(str(key) for key in discovery), + type(host).__name__, + raw_port, + type(raw_port).__name__, + isinstance(token, str) and bool(token), + isinstance(discovery.get("instance_id"), str) + and bool(discovery.get("instance_id")), + discovery.get("api_version"), + ) raise ConfigEntryNotReady("Renderer App discovery is invalid") + LOGGER.debug( + "Renderer App discovery transport resolved: host=%s port=%d " + "instance_id_present=%s discovery_api_version=%r", + host, + port, + isinstance(discovery.get("instance_id"), str) + and bool(discovery.get("instance_id")), + discovery.get("api_version"), + ) return { CONF_URL: f"http://{host}:{port}", CONF_AUTH_TOKEN: token, - "instance_id": instance_id, - "api_version": api_version, CONF_ADDON_SLUG: ADDON_SLUG, } diff --git a/custom_components/opendisplay_studio/const.py b/custom_components/opendisplay_studio/const.py index 5f355a4..4732dc5 100644 --- a/custom_components/opendisplay_studio/const.py +++ b/custom_components/opendisplay_studio/const.py @@ -6,7 +6,7 @@ DOMAIN = "opendisplay_studio" NAME = "OpenDisplay Studio" -INTEGRATION_VERSION = "0.6.0" +INTEGRATION_VERSION = "0.6.1" API_VERSION = 2 MIN_RENDERER_VERSION = "0.6.0" diff --git a/custom_components/opendisplay_studio/manifest.json b/custom_components/opendisplay_studio/manifest.json index e769854..b1f5a4f 100644 --- a/custom_components/opendisplay_studio/manifest.json +++ b/custom_components/opendisplay_studio/manifest.json @@ -11,5 +11,5 @@ "issue_tracker": "https://github.com/Misiu/OpenDisplay-Studio-Integration/issues", "requirements": ["trmnl-liquid-py==0.1.0"], "single_config_entry": true, - "version": "0.6.0" + "version": "0.6.1" } diff --git a/pyproject.toml b/pyproject.toml index c6bf7e1..65f26ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opendisplay-studio-integration" -version = "0.6.0" +version = "0.6.1" requires-python = ">=3.14.2" [tool.pytest.ini_options] diff --git a/tests/test_runtime_recovery.py b/tests/test_runtime_recovery.py index dce27f5..de75ae3 100644 --- a/tests/test_runtime_recovery.py +++ b/tests/test_runtime_recovery.py @@ -7,7 +7,15 @@ from homeassistant.components.hassio import AddonState from homeassistant.exceptions import ConfigEntryNotReady -from custom_components.opendisplay_studio import _async_ensure_addon_running +from custom_components.opendisplay_studio import ( + _async_ensure_addon_running, + _async_get_addon_connection, +) +from custom_components.opendisplay_studio.const import ( + ADDON_SLUG, + CONF_ADDON_SLUG, + CONF_AUTH_TOKEN, +) @pytest.mark.parametrize( @@ -34,3 +42,54 @@ async def test_runtime_schedules_recovery(hass, state, scheduled_method) -> None await _async_ensure_addon_running(hass) getattr(manager, scheduled_method).assert_called_once_with(catch_error=True) + + +@pytest.mark.parametrize("port", [8099, "8099"]) +async def test_runtime_discovery_uses_transport_before_health_check(hass, port) -> None: + """Allow stale identity metadata while an App update is settling.""" + manager = SimpleNamespace( + async_get_addon_discovery_info=AsyncMock( + return_value={ + "host": "renderer-host", + "port": port, + "auth_token": "secret", + "instance_id": "renderer-instance", + "api_version": 1, + } + ) + ) + with patch( + "custom_components.opendisplay_studio.get_addon_manager", + return_value=manager, + ): + connection = await _async_get_addon_connection(hass) + + assert connection == { + "url": "http://renderer-host:8099", + CONF_AUTH_TOKEN: "secret", + CONF_ADDON_SLUG: ADDON_SLUG, + } + + +@pytest.mark.parametrize( + "discovery", + [ + {"host": "", "port": 8099, "auth_token": "secret"}, + {"host": "renderer-host", "port": 0, "auth_token": "secret"}, + {"host": "renderer-host", "port": True, "auth_token": "secret"}, + {"host": "renderer-host", "port": 8099, "auth_token": ""}, + ], +) +async def test_runtime_discovery_rejects_invalid_transport(hass, discovery) -> None: + """Reject discovery values that cannot authenticate or reach the App.""" + manager = SimpleNamespace( + async_get_addon_discovery_info=AsyncMock(return_value=discovery) + ) + with ( + patch( + "custom_components.opendisplay_studio.get_addon_manager", + return_value=manager, + ), + pytest.raises(ConfigEntryNotReady), + ): + await _async_get_addon_connection(hass) From 19dc7884ac046b5e54fe79cdcd8ef5fffa818497 Mon Sep 17 00:00:00 2001 From: Misiu Date: Fri, 28 Aug 2026 07:48:40 +0200 Subject: [PATCH 2/2] Update integration release version test --- tests/test_release_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index f2255ec..090226b 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -23,7 +23,7 @@ def test_release_rejects_mismatch(tmp_path) -> None: def test_panel_build_version_matches_release() -> None: - assert INTEGRATION_VERSION == "0.6.0" + assert INTEGRATION_VERSION == "0.6.1" def test_panel_does_not_bundle_home_assistant_component_implementations() -> None: