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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions WIDGET_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion custom_components/opendisplay_studio/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
CalendarProvider,
EntityStateProvider,
WeatherForecastProvider,
weather_placeholder,
)
from .liquid_renderer import LIQUID
from .projects import Project
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions custom_components/opendisplay_studio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 36 additions & 8 deletions custom_components/opendisplay_studio/data_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]] = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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": ["python-liquid==2.3.1"],
"single_config_entry": true,
"version": "0.3.5"
"version": "0.3.6"
}
11 changes: 6 additions & 5 deletions custom_components/opendisplay_studio/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)


Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}
Expand Down Expand Up @@ -64,7 +68,7 @@
</div>
</div>

{% if config.showForecast and forecast.size > 0 %}
{% if config.showForecast and forecast != nil and forecast != empty %}
<div class="divider"></div>
<div class="grid">
<div class="col gap--large">
Expand Down Expand Up @@ -217,7 +221,7 @@
</div>
</div>
</div>
{% if config.showForecast and forecast.size > 0 %}
{% if config.showForecast and forecast != nil and forecast != empty %}
<div class="divider"></div>
<div class="grid grid--cols-2 portrait:grid--cols-1 portrait:gap">
{% for day in forecast limit: 2 %}
Expand Down
2 changes: 1 addition & 1 deletion custom_components/opendisplay_studio/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 34 additions & 2 deletions frontend-src/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend-src/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions frontend-src/src/weather-liquid-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
showForecast = true,
): Promise<string> => {
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°')
})
})
2 changes: 1 addition & 1 deletion frontend-src/src/widgets/weather/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
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.3.5"
version = "0.3.6"
requires-python = ">=3.14.2"

[tool.pytest.ini_options]
Expand Down
Loading
Loading