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 %}
- {% 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,
}