From 287c1662135083e3e423338beb7db729875dc79a Mon Sep 17 00:00:00 2001 From: Misiu Date: Wed, 26 Aug 2026 18:21:37 +0200 Subject: [PATCH] Make Weather preview data contract total --- CHANGELOG.md | 10 ++++ WIDGET_CONTRACT.md | 9 +++ .../opendisplay_studio/composer.py | 7 ++- custom_components/opendisplay_studio/const.py | 6 +- .../opendisplay_studio/data_providers.py | 44 +++++++++++--- .../frontend/opendisplay-studio.js | 2 +- .../opendisplay_studio/manifest.json | 2 +- .../opendisplay_studio/websocket.py | 11 ++-- .../widget_templates/weather.liquid | 10 +++- .../opendisplay_studio/widgets.py | 2 +- frontend-src/package-lock.json | 36 +++++++++++- frontend-src/package.json | 3 +- .../src/weather-liquid-contract.test.ts | 57 +++++++++++++++++++ frontend-src/src/widgets/weather/widget.ts | 2 +- pyproject.toml | 2 +- tests/test_composer.py | 35 +++++++++++- tests/test_config_flow.py | 2 +- tests/test_const.py | 2 + tests/test_data_providers.py | 31 ++++++++++ tests/test_release_version.py | 2 +- tests/test_renderer.py | 10 ++-- tests/test_websocket.py | 1 - 22 files changed, 249 insertions(+), 37 deletions(-) create mode 100644 frontend-src/src/weather-liquid-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9353e7a..9dea4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +## 0.3.6 + +- Make the Weather data contract total: current conditions render when the + entity is not selected, forecast data is absent, or `weather.get_forecasts` + fails. +- Fix successful unsaved previews crashing in diagnostic logging when the + normalized project has no `id`. +- Require Renderer App 0.2.4 with the corrected Home Assistant discovery + service name. + ## 0.3.5 - Restore display creation when Home Assistant is served from a context where diff --git a/WIDGET_CONTRACT.md b/WIDGET_CONTRACT.md index 892f9d5..372fe65 100644 --- a/WIDGET_CONTRACT.md +++ b/WIDGET_CONTRACT.md @@ -43,6 +43,15 @@ its compatible Renderer App. A widget package declaring another version must be rejected or installed alongside an explicitly compatible renderer; it must never be rendered against a silently substituted framework version. +### Liquid engine conformance + +The CLI uses its exact pinned LiquidJS version while Home Assistant uses the +bounded Python Liquid runtime. Published built-in widgets must render the same +contract fixtures successfully in both engines. Every optional nested field +needs a fixture where the key is absent, not only present with an empty or null +value. Widget templates must not rely on engine-specific short-circuit or +undefined-value behavior. + The panel builds controls from `fields`. A field can contain a native Home Assistant `selector` object, using the same schema as blueprint inputs. The panel passes that object to `ha-form` without recreating selector behavior. diff --git a/custom_components/opendisplay_studio/composer.py b/custom_components/opendisplay_studio/composer.py index c749cf9..cb62f6d 100644 --- a/custom_components/opendisplay_studio/composer.py +++ b/custom_components/opendisplay_studio/composer.py @@ -15,6 +15,7 @@ CalendarProvider, EntityStateProvider, WeatherForecastProvider, + weather_placeholder, ) from .liquid_renderer import LIQUID from .projects import Project @@ -207,7 +208,11 @@ def _resolve_widget_data( for source in sources ] elif requirement["provider"] == "weather_forecast": - values = [weather.get(source) for source in sources] + values = [ + weather.get(source, weather_placeholder(source)) for source in sources + ] + if not values and requirement.get("cardinality") != "many": + values = [weather_placeholder()] data[requirement["key"]] = ( values if requirement.get("cardinality") == "many" diff --git a/custom_components/opendisplay_studio/const.py b/custom_components/opendisplay_studio/const.py index 61bed5a..4cac4ab 100644 --- a/custom_components/opendisplay_studio/const.py +++ b/custom_components/opendisplay_studio/const.py @@ -6,10 +6,10 @@ DOMAIN = "opendisplay_studio" NAME = "OpenDisplay Studio" -INTEGRATION_VERSION = "0.3.5" +INTEGRATION_VERSION = "0.3.6" API_VERSION = 1 -MIN_RENDERER_VERSION = "0.2.3" +MIN_RENDERER_VERSION = "0.2.4" TRMNL_FRAMEWORK_VERSION = "3.2.0" DEFAULT_WIDTH = 800 DEFAULT_HEIGHT = 480 @@ -30,7 +30,7 @@ APP_REPOSITORY_SLUG = "bd833593" ADDON_SLUG = f"{APP_REPOSITORY_SLUG}_{APP_CONFIG_SLUG}" ADDON_NAME = "OpenDisplay Studio Renderer" -DISCOVERY_SERVICE = APP_CONFIG_SLUG +DISCOVERY_SERVICE = DOMAIN RENDER_CACHE_TTL_SECONDS = 300 RENDER_CACHE_MAX_ITEMS = 32 diff --git a/custom_components/opendisplay_studio/data_providers.py b/custom_components/opendisplay_studio/data_providers.py index f41d068..327a080 100644 --- a/custom_components/opendisplay_studio/data_providers.py +++ b/custom_components/opendisplay_studio/data_providers.py @@ -14,8 +14,11 @@ SERVICE_GET_FORECASTS, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util +from .const import LOGGER + ICON_PATHS = { "calendar": "M7 11H9V13H7V11M21 5V19C21 20.11 20.11 21 19 21H5C3.89 21 3 20.1 3 19V5C3 3.9 3.9 3 5 3H6V1H8V3H16V1H18V3H19C20.11 3 21 3.9 21 5M5 7H19V5H5V7M19 19V9H5V19H19M15 13V11H17V13H15M11 13V11H13V13H11M7 15H9V17H7V15M15 17V15H17V17H15M11 17V15H13V17H11Z", "default": "M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z", @@ -147,6 +150,23 @@ def _weather_icon(condition: str) -> str: return f"{WEATHER_ICON_BASE_URL}/{filename}" +def weather_placeholder(entity_id: str = "") -> dict[str, Any]: + """Return the complete Weather template contract without live data.""" + return { + "entity_id": entity_id, + "name": entity_id or "Choose a weather entity", + "condition": "unavailable", + "condition_label": "Unavailable", + "icon": _weather_icon("exceptional"), + "temperature": "—", + "temperature_unit": "", + "apparent_temperature": None, + "humidity": None, + "updated_at": "", + "forecast": [], + } + + def _uv_label(value: object) -> str | None: """Convert a numeric UV index into the conventional exposure category.""" if not isinstance(value, int | float) or isinstance(value, bool): @@ -189,14 +209,22 @@ async def async_get_many(self, entity_ids: set[str]) -> dict[str, dict[str, Any] return {} sorted_entity_ids = sorted(entity_ids) - response = await self._hass.services.async_call( - WEATHER_DOMAIN, - SERVICE_GET_FORECASTS, - {"type": "daily"}, - blocking=True, - target={"entity_id": sorted_entity_ids}, - return_response=True, - ) + try: + response = await self._hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {"type": "daily"}, + blocking=True, + target={"entity_id": sorted_entity_ids}, + return_response=True, + ) + except HomeAssistantError as err: + LOGGER.warning( + "Daily weather forecast is unavailable; rendering current " + "conditions without forecast: %s", + err, + ) + response = {} forecast_response = response if isinstance(response, dict) else {} today = dt_util.now().date() result: dict[str, dict[str, Any]] = {} diff --git a/custom_components/opendisplay_studio/frontend/opendisplay-studio.js b/custom_components/opendisplay_studio/frontend/opendisplay-studio.js index ebd9ba6..03b8490 100644 --- a/custom_components/opendisplay_studio/frontend/opendisplay-studio.js +++ b/custom_components/opendisplay_studio/frontend/opendisplay-studio.js @@ -3043,7 +3043,7 @@ var et = (e) => e <= 1.6 ? { }, { id: "weather", - version: 3, + version: 4, name: "Weather", description: "Current conditions and a daily Home Assistant forecast.", icon: Qe, diff --git a/custom_components/opendisplay_studio/manifest.json b/custom_components/opendisplay_studio/manifest.json index 72f7c27..81c36c4 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": ["python-liquid==2.3.1"], "single_config_entry": true, - "version": "0.3.5" + "version": "0.3.6" } diff --git a/custom_components/opendisplay_studio/websocket.py b/custom_components/opendisplay_studio/websocket.py index 710386a..5f9d8bb 100644 --- a/custom_components/opendisplay_studio/websocket.py +++ b/custom_components/opendisplay_studio/websocket.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .composer import ProjectComposeError, async_compose_project -from .const import DOMAIN, LOGGER, RENDER_HTTP_PATH +from .const import DOMAIN, LOGGER, MIN_RENDERER_VERSION, RENDER_HTTP_PATH from .liquid_renderer import TemplateRenderError from .projects import ( ProjectStore, @@ -28,10 +28,11 @@ def _renderer_client(hass: HomeAssistant) -> RendererClient: """Return the domain Renderer shared by preview and Media Source.""" client = getattr(hass.data[DOMAIN], "renderer", None) if client is None: - raise ProjectComposeError( - "Renderer App is not connected. Update and start Renderer App 0.2.3, " - "then reload the OpenDisplay Studio integration" + message = ( + "Renderer App is not connected. Update and start Renderer App " + f"{MIN_RENDERER_VERSION}, then reload the OpenDisplay Studio integration" ) + raise ProjectComposeError(message) return cast("RendererClient", client) @@ -168,7 +169,7 @@ async def websocket_compose_preview( LOGGER.debug( "Rendered live preview project=%s size=%dx%d data=%.2f ms liquid=%.2f ms " "compose=%.2f ms renderer=%s pipeline=%.2f ms bytes=%d", - project["id"], + project.get("id", "unsaved-preview"), project["width"], project["height"], composed.data_ms, diff --git a/custom_components/opendisplay_studio/widget_templates/weather.liquid b/custom_components/opendisplay_studio/widget_templates/weather.liquid index 387ac7a..099d430 100644 --- a/custom_components/opendisplay_studio/widget_templates/weather.liquid +++ b/custom_components/opendisplay_studio/widget_templates/weather.liquid @@ -1,5 +1,9 @@ {% assign current = data.weather %} -{% assign forecast = current.forecast %} +{% if current contains 'forecast' %} + {% assign forecast = current.forecast %} +{% else %} + {% assign forecast = nil %} +{% endif %} {% assign today = nil %} {% assign tomorrow = nil %} {% for forecast_day in forecast limit: 2 %} @@ -64,7 +68,7 @@ - {% if config.showForecast and forecast.size > 0 %} + {% if config.showForecast and forecast != nil and forecast != empty %}
@@ -217,7 +221,7 @@
- {% if config.showForecast and forecast.size > 0 %} + {% if config.showForecast and forecast != nil and forecast != empty %}
{% for day in forecast limit: 2 %} diff --git a/custom_components/opendisplay_studio/widgets.py b/custom_components/opendisplay_studio/widgets.py index 21a6d4b..3720e56 100644 --- a/custom_components/opendisplay_studio/widgets.py +++ b/custom_components/opendisplay_studio/widgets.py @@ -88,7 +88,7 @@ }, { "id": "weather", - "version": 3, + "version": 4, "name": "Weather", "description": "Current conditions and a daily Home Assistant forecast.", "icon": "mdi:weather-partly-cloudy", diff --git a/frontend-src/package-lock.json b/frontend-src/package-lock.json index 66ead4a..57c173d 100644 --- a/frontend-src/package-lock.json +++ b/frontend-src/package-lock.json @@ -1,17 +1,18 @@ { "name": "opendisplay-studio-ha-panel", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendisplay-studio-ha-panel", - "version": "0.3.5", + "version": "0.3.6", "dependencies": { "@mdi/js": "^7.4.47", "lit": "^3.3.3" }, "devDependencies": { + "liquidjs": "10.29.0", "typescript": "~6.0.2", "vite": "^8.2.0", "vite-plugin-css-injected-by-js": "^5.0.2", @@ -489,6 +490,16 @@ "node": ">=18" } }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -827,6 +838,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/liquidjs": { + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.29.0.tgz", + "integrity": "sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^10.0.0" + }, + "bin": { + "liquid": "bin/liquid.js", + "liquidjs": "bin/liquid.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/liquidjs" + } + }, "node_modules/lit": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", diff --git a/frontend-src/package.json b/frontend-src/package.json index 3f5ded6..b2ecedd 100644 --- a/frontend-src/package.json +++ b/frontend-src/package.json @@ -1,7 +1,7 @@ { "name": "opendisplay-studio-ha-panel", "private": true, - "version": "0.3.5", + "version": "0.3.6", "type": "module", "scripts": { "dev": "vite build --watch", @@ -13,6 +13,7 @@ "lit": "^3.3.3" }, "devDependencies": { + "liquidjs": "10.29.0", "typescript": "~6.0.2", "vite": "^8.2.0", "vite-plugin-css-injected-by-js": "^5.0.2", diff --git a/frontend-src/src/weather-liquid-contract.test.ts b/frontend-src/src/weather-liquid-contract.test.ts new file mode 100644 index 0000000..baa2ce1 --- /dev/null +++ b/frontend-src/src/weather-liquid-contract.test.ts @@ -0,0 +1,57 @@ +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { Liquid } from 'liquidjs' + +const templateUrl = new URL( + '../../custom_components/opendisplay_studio/widget_templates/weather.liquid', + import.meta.url, +) +const engine = new Liquid({ + dynamicPartials: true, + strictFilters: true, + strictVariables: true, +}) + +const current = { + entity_id: 'weather.home', + name: 'Home', + condition: 'rainy', + condition_label: 'Rain', + icon: 'https://trmnl.com/images/plugins/weather/wi-rain.svg', + temperature: 12, + temperature_unit: '°C', + apparent_temperature: 9, + humidity: 88, + updated_at: '18:02', +} + +const render = async ( + weather: Record, + showForecast = true, +): Promise => { + const template = await readFile(templateUrl, 'utf8') + return engine.parseAndRender(template, { + config: { + showHumidity: true, + showFeelsLike: true, + showForecast, + }, + data: { weather }, + region: { shape: 'square' }, + }) +} + +describe('Weather LiquidJS contract', () => { + it('renders current conditions when forecast is absent', async () => { + const html = await render(current) + + expect(html).toContain('12°') + expect(html).toContain('Rain') + expect(html).toContain('18:02') + }) + + it('renders with an empty forecast or forecast disabled', async () => { + await expect(render({ ...current, forecast: [] })).resolves.toContain('12°') + await expect(render(current, false)).resolves.toContain('12°') + }) +}) diff --git a/frontend-src/src/widgets/weather/widget.ts b/frontend-src/src/widgets/weather/widget.ts index 794a38c..400c037 100644 --- a/frontend-src/src/widgets/weather/widget.ts +++ b/frontend-src/src/widgets/weather/widget.ts @@ -6,7 +6,7 @@ import { weatherStyles } from './styles' export const weatherWidget: WidgetDefinition = { id: 'weather', - version: 3, + version: 4, name: 'Weather', description: 'Current conditions and a daily Home Assistant forecast.', icon: mdiWeatherPartlyCloudy, diff --git a/pyproject.toml b/pyproject.toml index 026cb3e..8b194b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opendisplay-studio-integration" -version = "0.3.5" +version = "0.3.6" requires-python = ">=3.14.2" [tool.pytest.ini_options] diff --git a/tests/test_composer.py b/tests/test_composer.py index 73b7370..ee19661 100644 --- a/tests/test_composer.py +++ b/tests/test_composer.py @@ -165,7 +165,6 @@ async def test_weather_widget_renders_normalized_home_assistant_data(hass) -> No "apparent_temperature": 9, "humidity": 88, "updated_at": "08:15", - "forecast": [], } } @@ -182,3 +181,37 @@ async def test_weather_widget_renders_normalized_home_assistant_data(hass) -> No assert "--studio-gap:0px" in result.html assert 'data-region-width="190.000"' in result.html assert 'data-region-height="228.000"' in result.html + + +async def test_weather_widget_renders_placeholder_before_entity_selection(hass) -> None: + """A newly added Weather widget must render before its selector is configured.""" + project = { + "palette": "bw", + "width": 400, + "height": 300, + "grid": {"columns": 1, "rows": 1}, + "regions": [ + { + "id": "weather", + "row": 1, + "column": 1, + "rowSpan": 1, + "columnSpan": 1, + "widget": { + "type": "weather", + "version": 4, + "config": {"weather": "", "showForecast": False}, + }, + } + ], + } + + with patch( + "custom_components.opendisplay_studio.composer.WeatherForecastProvider.async_get_many", + AsyncMock(return_value={}), + ) as get_many: + result = await async_compose_project(hass, project) + + get_many.assert_awaited_once_with(set()) + assert "Choose a weather entity" in result.html + assert "—°" in result.html diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0c662a2..a7c6d9c 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_container_uses_external_renderer_flow(hass, aioclient_mock) -> No "http://renderer:8099/health", json={ "status": "ok", - "version": "0.2.3", + "version": "0.2.4", "apiVersion": 1, "trmnlFrameworkVersion": "3.2.0", }, diff --git a/tests/test_const.py b/tests/test_const.py index bbb07c8..2dbcb16 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -6,6 +6,7 @@ ADDON_SLUG, APP_CONFIG_SLUG, APP_REPOSITORY_URL, + DISCOVERY_SERVICE, TRMNL_FRAMEWORK_VERSION, ) @@ -16,3 +17,4 @@ def test_addon_slug_matches_current_supervisor_algorithm() -> None: ).hexdigest()[:8] assert f"{repository_slug}_{APP_CONFIG_SLUG}" == ADDON_SLUG assert TRMNL_FRAMEWORK_VERSION == "3.2.0" + assert DISCOVERY_SERVICE == "opendisplay_studio" diff --git a/tests/test_data_providers.py b/tests/test_data_providers.py index 41e241f..96bdfc2 100644 --- a/tests/test_data_providers.py +++ b/tests/test_data_providers.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, patch +from homeassistant.exceptions import HomeAssistantError + from custom_components.opendisplay_studio.data_providers import ( EntityStateProvider, WeatherForecastProvider, @@ -134,3 +136,32 @@ async def test_weather_provider_uses_explicit_nulls_for_optional_values(hass) -> assert current["forecast"][0]["uv_index"] is None assert current["forecast"][0]["uv_label"] is None assert current["forecast"][0]["precipitation_probability"] is None + + +async def test_weather_provider_keeps_current_conditions_when_forecast_fails( + hass, +) -> None: + """A forecast service failure must not blank the entire display.""" + hass.states.async_set( + "weather.home", + "rainy", + { + "friendly_name": "Home", + "temperature": 12, + "temperature_unit": "°C", + "humidity": 88, + }, + ) + + with patch.object( + type(hass.services), + "async_call", + AsyncMock(side_effect=HomeAssistantError("forecast unavailable")), + ): + result = await WeatherForecastProvider(hass).async_get_many({"weather.home"}) + + current = result["weather.home"] + assert current["condition"] == "rainy" + assert current["temperature"] == 12 + assert current["humidity"] == 88 + assert current["forecast"] == [] diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 59a5001..b94a671 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.3.5" + assert INTEGRATION_VERSION == "0.3.6" def test_panel_does_not_bundle_home_assistant_component_implementations() -> None: diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 21a02f7..62f4f8c 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -18,7 +18,7 @@ async def health(_request): return web.json_response( { "status": "ok", - "version": "0.2.3", + "version": "0.2.4", "apiVersion": 1, "trmnlFrameworkVersion": "3.2.0", } @@ -50,7 +50,7 @@ async def render(request): client = RendererClient(session, str(server.make_url("/")), "secret") assert await client.async_health() == { "status": "ok", - "version": "0.2.3", + "version": "0.2.4", "apiVersion": 1, "trmnlFrameworkVersion": "3.2.0", } @@ -91,9 +91,9 @@ async def incompatible_health(_request): @pytest.mark.parametrize( ("version", "framework_version"), [ - ("0.2.2", "3.2.0"), - ("0.2.3-dev", "3.2.0"), - ("0.2.3", "3.1.0"), + ("0.2.3", "3.2.0"), + ("0.2.4-dev", "3.2.0"), + ("0.2.4", "3.1.0"), ], ) async def test_health_rejects_incompatible_renderer_or_framework( diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 63fada8..be1b11d 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -18,7 +18,6 @@ PNG = b"\x89PNG\r\n\x1a\n" + b"preview" PROJECT = { - "id": "preview-id", "width": 800, "height": 480, }