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
18 changes: 15 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@

## Unreleased

## 0.3.6
## 0.5.0

- Introduce self-contained widget packages under `opendisplay_studio/widgets`.
Templates, selectors, translations, and optional Python data providers now
ship with their owning widget and can be updated independently.
- Discover locally installed widget packages without hardcoded widget IDs and
expose backend-only community widgets in the Studio picker.
- Scope providers to their owning package, preventing name collisions between
independently installed community widgets.
- Use the project language across live preview, Media Source, and physical
output. Weather reuses Home Assistant condition translations and carries its
own English and Polish presentation vocabulary.
- Require Renderer App 0.5.0 so the public suite has one compatible release
line for exact previews and final images.

- 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.
- Use the corrected Home Assistant discovery service name.

## 0.3.5

Expand Down
213 changes: 104 additions & 109 deletions WIDGET_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,72 @@
# OpenDisplay Studio widget contract
# OpenDisplay Studio widget package contract

Each widget definition owns four independent concerns:
A widget is a self-contained, independently versioned package. The integration
core does not contain widget IDs, widget-specific data normalization, labels,
or rendering branches.

## Package layout

Installed packages live below `opendisplay_studio/widgets`:

```text
identity and version
configuration fields
data requirements
Liquid template
widgets/
weather/
widget.yml
widget.liquid
provider.py
translations/
en.json
pl.json
assets/
```

Widget packages are declarative and never contain executable Python. A package
can only request provider names registered by OpenDisplay Studio Integration.
This keeps installation reviewable and prevents a downloaded widget from
executing arbitrary code inside Home Assistant.
`widget.yml` declares identity, version, configuration selectors, defaults,
data requirements, the Liquid template, and the optional provider module.
`provider.py` and `translations` belong to that widget. A static widget such as
Text does not need a provider.

## Rendering invariant
The integration ships built-in packages in
`custom_components/opendisplay_studio/widgets`. Independently installed
packages use `/config/opendisplay_studio/widgets`. The registry scans both
locations; an installed package with the same widget ID replaces the bundled
version. The registry can be reloaded in place, so an installer or Store update
does not require a new integration release.

One widget package has one visual contract across all surfaces:
## Provider boundary

The core knows only the provider protocol:

```text
CLI fixture -> Liquid -> pinned TRMNL Framework -> region container
HA preview -> HA data -> Liquid -> Renderer App -> PNG
final image -> HA data -> Liquid -> Renderer App -> PNG
new_request -> add_request -> async_resolve -> values
```

The Home Assistant preview and final Media Source call the same Renderer API
with the same composed HTML, width, and height. The panel displays the returned
PNG and does not maintain a second browser-only rendering implementation.
Providers are scoped to their owning widget package. Identical provider names
in two community packages cannot overwrite or share state with each other.
Requests from multiple regions using the same widget are aggregated before the
provider runs, allowing deduplication within that package.

`--screen-w` and `--screen-h` always describe the physical device. Responsive
widget decisions use the actual region size through a CSS size container. Grid
span names are editor concepts and must not select a presentation variant.
Examples of package-owned behavior:

External assets must never fail silently. The current compatibility layer only
permits fixed TRMNL weather SVG paths, and the Renderer rejects the render if an
image is missing. The package format will carry declared assets inside the ODX
archive so installed community widgets can render deterministically without
arbitrary network access.
- `widgets/weather/provider.py` reads a Weather entity, calls
`weather.get_forecasts`, normalizes the result, and localizes it.
- `widgets/calendar/provider.py` reads calendar events and applies the
widget's date range and time format.
- `widgets/entity_state/provider.py` normalizes an entity state and selects the
icon used by that widget.

An integration release accepts only the TRMNL Framework version reported by
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.
None of these behaviors belongs in the integration composer or a central
provider table.

### Liquid engine conformance
Provider modules are executable Python inside the Home Assistant process.
Packages copied into the local config directory are therefore trusted code.
A public Store must verify package provenance and signatures before installing
or updating a provider-bearing package; downloading arbitrary unsigned Python
must never be an implicit background action.

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.
## Configuration and data requirements

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.
Legacy built-in fields can continue using the shorthand `type` property while
packages migrate. Configuration stores references, never current values.
Fields can contain native Home Assistant selector schemas. The panel passes a
selector to `ha-form` instead of reimplementing its behavior:

```yaml
fields:
Expand All @@ -69,75 +79,60 @@ fields:
domain: weather
```

Data requirements are declarative. A requirement names its provider, the
configuration key containing its source, whether the source has cardinality
`one` or `many`, and whether it is optional. Range-based providers may name a
second configuration key such as Calendar's `days`.

Example current Entity State requirement:

```json
{
"key": "entity",
"provider": "entity_state",
"configKey": "entity",
"cardinality": "one",
"optional": false
}
```
A requirement maps a configuration source to a key in the Liquid context:

The composer aggregates every region before providers run. Two widgets asking
for the same entity cause one state-machine lookup. Calendar requests for the
same entity use the largest requested range, then each widget receives only its
configured presentation range.

## Future Table

A Table can use ordinary presentation fields plus a multi-entity requirement:

```json
{
"fields": [
{ "key": "entities", "type": "entities", "label": "Rows" },
{ "key": "nameColumn", "type": "text", "label": "First column" },
{ "key": "valueColumn", "type": "text", "label": "Second column" }
],
"dataRequirements": [
{
"key": "rows",
"provider": "entity_state",
"configKey": "entities",
"cardinality": "many",
"optional": false
}
]
}
```yaml
dataRequirements:
- key: weather
provider: weather_forecast
configKey: weather
cardinality: one
optional: false
```

Its normalized Liquid context can then contain five room-temperature rows
without exposing the full Home Assistant state machine.

## Weather

Weather declares one required entity. The provider combines its current state
with the requested daily forecast at render time:

```json
{
"dataRequirements": [
{
"key": "weather",
"provider": "weather_forecast",
"configKey": "weather",
"cardinality": "one",
"optional": false,
"forecastType": "daily"
}
]
}
The provider name above resolves only inside the Weather package. Configuration
stores references such as entity IDs, never snapshots of Home Assistant state.

## Localization

Language is selected per project so preview, Media Source, and the physical
display render the same pixels. New projects use the current Home Assistant
language and legacy projects pin the Home Assistant system language when they
are loaded.

Widget-specific vocabulary lives in that widget's `translations` directory.
A provider may additionally reuse official Home Assistant translations for the
domain it reads. Weather, for example, obtains condition and attribute names
from Home Assistant and presentation terms from
`widgets/weather/translations`. English is the package fallback.

Liquid templates receive localized labels and normalized values. They must not
contain user-facing language-specific strings. CLI fixtures carry the same
complete data contract, including `labels`, so standalone design remains
deterministic.

## Rendering invariant

One package has one visual path across every surface:

```text
CLI fixture -> Liquid -> pinned TRMNL Framework -> region container
HA preview -> provider -> Liquid -> Renderer App -> PNG
Media Source / physical display -> provider -> Liquid -> Renderer App -> PNG
```

The `weather_forecast` provider belongs to the integration. It uses the selected
entity's state for current conditions and the public `weather.get_forecasts`
action for forecasts, then exposes one normalized Liquid object. Multiple
widgets selecting the same entity share the same collected provider result.
The panel displays the PNG returned by the same renderer used for Media Source.
Its local widget component is only a temporary loading fallback; it is not a
second authoritative renderer.

`--screen-w` and `--screen-h` describe the physical device. Responsive widget
decisions use the actual CSS region container dimensions, never names such as
full or half. A package must work for arbitrary device and region sizes.

The CLI and integration use bounded Liquid engines. Published widgets need
contract fixtures covering missing optional nested fields, not only empty or
null values, and must avoid engine-specific undefined-value behavior.

External assets cannot fail silently. Published archives will carry declared
assets so rendering remains deterministic. Framework compatibility is also
declared by the package and must be checked rather than silently substituted.
15 changes: 14 additions & 1 deletion custom_components/opendisplay_studio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

from dataclasses import dataclass
from functools import partial
from pathlib import Path

from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntry
Expand Down Expand Up @@ -38,6 +40,7 @@
RendererHealth,
)
from .websocket import async_register_commands
from .widgets import BUILTIN_WIDGET_DIRECTORY, WidgetRegistry


@dataclass(slots=True)
Expand All @@ -56,6 +59,7 @@ class OpenDisplayStudioData:

cache: RenderCache
projects: ProjectStore
widgets: WidgetRegistry
renderer: RendererClient | None = None
renderer_health: RendererHealth | None = None

Expand All @@ -67,14 +71,23 @@ class OpenDisplayStudioData:

async def async_setup(hass: HomeAssistant, _config: ConfigType) -> bool:
"""Set up storage, panel APIs, and the temporary render endpoint."""
projects = ProjectStore(hass)
installed_widgets = Path(hass.config.path(DOMAIN, "widgets"))
await hass.async_add_executor_job(
partial(installed_widgets.mkdir, parents=True, exist_ok=True)
)
widgets = await hass.async_add_executor_job(
WidgetRegistry.from_directories,
[BUILTIN_WIDGET_DIRECTORY, installed_widgets],
)
projects = ProjectStore(hass, widgets)
await projects.async_load()
hass.data[DOMAIN] = OpenDisplayStudioData(
cache=RenderCache(
ttl_seconds=RENDER_CACHE_TTL_SECONDS,
max_items=RENDER_CACHE_MAX_ITEMS,
),
projects=projects,
widgets=widgets,
)
hass.http.register_view(RenderedImageView(hass))
async_register_commands(hass)
Expand Down
Loading