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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/assets` into bounded `data:`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 48 additions & 12 deletions custom_components/opendisplay_studio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -99,24 +99,33 @@ 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}
)

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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
2 changes: 1 addition & 1 deletion custom_components/opendisplay_studio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion custom_components/opendisplay_studio/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_release_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 60 additions & 1 deletion tests/test_runtime_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)