diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9dea4ad..95280cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/WIDGET_CONTRACT.md b/WIDGET_CONTRACT.md
index 372fe65..ba9275c 100644
--- a/WIDGET_CONTRACT.md
+++ b/WIDGET_CONTRACT.md
@@ -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:
@@ -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.
diff --git a/custom_components/opendisplay_studio/__init__.py b/custom_components/opendisplay_studio/__init__.py
index 2129956..af77ca5 100644
--- a/custom_components/opendisplay_studio/__init__.py
+++ b/custom_components/opendisplay_studio/__init__.py
@@ -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
@@ -38,6 +40,7 @@
RendererHealth,
)
from .websocket import async_register_commands
+from .widgets import BUILTIN_WIDGET_DIRECTORY, WidgetRegistry
@dataclass(slots=True)
@@ -56,6 +59,7 @@ class OpenDisplayStudioData:
cache: RenderCache
projects: ProjectStore
+ widgets: WidgetRegistry
renderer: RendererClient | None = None
renderer_health: RendererHealth | None = None
@@ -67,7 +71,15 @@ 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(
@@ -75,6 +87,7 @@ async def async_setup(hass: HomeAssistant, _config: ConfigType) -> bool:
max_items=RENDER_CACHE_MAX_ITEMS,
),
projects=projects,
+ widgets=widgets,
)
hass.http.register_view(RenderedImageView(hass))
async_register_commands(hass)
diff --git a/custom_components/opendisplay_studio/composer.py b/custom_components/opendisplay_studio/composer.py
index cb62f6d..29ec07b 100644
--- a/custom_components/opendisplay_studio/composer.py
+++ b/custom_components/opendisplay_studio/composer.py
@@ -10,16 +10,10 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from .data_providers import (
- ICON_PATHS,
- CalendarProvider,
- EntityStateProvider,
- WeatherForecastProvider,
- weather_placeholder,
-)
+from .const import DOMAIN
from .liquid_renderer import LIQUID
from .projects import Project
-from .widgets import TEMPLATES, definition, with_defaults
+from .widgets import DEFAULT_REGISTRY, WidgetRegistry, definition, with_defaults
@dataclass(frozen=True, slots=True)
@@ -115,104 +109,41 @@ def _requirement_sources(
return [str(value)] if value else []
-def _calendar_value(
- events: list[dict[str, str | bool | int]],
- *,
- days: int,
- time_24h: bool,
-) -> dict[str, list[dict[str, str | bool | int]]]:
- """Apply presentation range and time format to normalized events."""
- visible_events = [
- dict(event) for event in events if int(event.get("dayOffset", 0)) < days
- ]
- if not time_24h:
- for event in visible_events:
- time_value = str(event["time"])
- if time_value != "All day":
- hours, minutes = (int(part) for part in time_value.split(":"))
- suffix = "AM" if hours < 12 else "PM"
- event["time"] = f"{hours % 12 or 12}:{minutes:02d} {suffix}"
- return {"events": visible_events}
-
-
def _collect_requirements(
- project: Project,
-) -> tuple[set[str], dict[str, int], set[str]]:
+ project: Project, registry: WidgetRegistry
+) -> dict[tuple[str, str], object]:
"""Compile widget declarations into one deduplicated screen request."""
- entity_ids: set[str] = set()
- calendar_requests: dict[str, int] = {}
- weather_requests: set[str] = set()
+ requests: dict[tuple[str, str], object] = {}
for region in project["regions"]:
widget = region.get("widget")
if widget is None:
continue
- config = with_defaults(widget["type"], widget["config"])
- for requirement in definition(widget["type"])["dataRequirements"]:
+ config = with_defaults(widget["type"], widget["config"], registry)
+ for requirement in definition(widget["type"], registry)["dataRequirements"]:
sources = _requirement_sources(config, requirement)
- if requirement["provider"] == "entity_state":
- entity_ids.update(sources)
- elif requirement["provider"] == "calendar":
- range_key = requirement.get("rangeConfigKey", "days")
- days = max(1, min(31, int(config.get(range_key, 7))))
- for entity_id in sources:
- calendar_requests[entity_id] = max(
- calendar_requests.get(entity_id, 0), days
- )
- elif requirement["provider"] == "weather_forecast":
- weather_requests.update(sources)
- return entity_ids, calendar_requests, weather_requests
+ provider_name = requirement["provider"]
+ provider = registry.provider(widget["type"], provider_name)
+ provider_key = (widget["type"], provider_name)
+ request = requests.setdefault(provider_key, provider.new_request())
+ provider.add_request(request, sources, config, requirement)
+ return requests
def _resolve_widget_data(
widget_type: str,
config: dict[str, Any],
- entities: dict[str, dict[str, str]],
- calendars: dict[str, list[dict[str, str | bool | int]]],
- weather: dict[str, dict[str, Any]],
+ resolved: dict[tuple[str, str], object],
+ registry: WidgetRegistry,
) -> dict[str, Any]:
"""Hydrate declared requirements with normalized provider values."""
data: dict[str, Any] = {}
- for requirement in definition(widget_type)["dataRequirements"]:
+ for requirement in definition(widget_type, registry)["dataRequirements"]:
sources = _requirement_sources(config, requirement)
- values: list[Any] = []
- if requirement["provider"] == "entity_state":
- values = [
- entities.get(
- source,
- {
- "state": "Unavailable",
- "unit": "",
- "name": source,
- "iconPath": ICON_PATHS["default"],
- },
- )
- for source in sources
- ]
- if not values and requirement.get("cardinality") != "many":
- values = [
- {
- "state": "Unavailable",
- "unit": "",
- "name": "Choose an entity",
- "iconPath": ICON_PATHS["default"],
- }
- ]
- elif requirement["provider"] == "calendar":
- range_key = requirement.get("rangeConfigKey", "days")
- values = [
- _calendar_value(
- calendars.get(source, []),
- days=int(config.get(range_key, 7)),
- time_24h=bool(config.get("time24h", True)),
- )
- for source in sources
- ]
- elif requirement["provider"] == "weather_forecast":
- values = [
- weather.get(source, weather_placeholder(source)) for source in sources
- ]
- if not values and requirement.get("cardinality") != "many":
- values = [weather_placeholder()]
+ provider_name = requirement["provider"]
+ provider = registry.provider(widget_type, provider_name)
+ values = provider.values(
+ resolved[(widget_type, provider_name)], sources, config, requirement
+ )
data[requirement["key"]] = (
values
if requirement.get("cardinality") == "many"
@@ -228,15 +159,23 @@ async def async_compose_project(
) -> ComposedProject:
"""Collect requirements once, render fragments, and create one screen."""
started = perf_counter()
- entity_ids, calendar_requests, weather_requests = _collect_requirements(project)
- entities = EntityStateProvider(hass).get_many(entity_ids)
+ domain_data = hass.data.get(DOMAIN)
+ registry = getattr(domain_data, "widgets", DEFAULT_REGISTRY)
+ requests = _collect_requirements(project, registry)
+ language = str(project.get("language", "system"))
+ if language == "system":
+ language = hass.config.language
try:
- calendars, weather = await asyncio.gather(
- CalendarProvider(hass).async_get_many(calendar_requests),
- WeatherForecastProvider(hass).async_get_many(weather_requests),
+ provider_keys = list(requests)
+ provider_values = await asyncio.gather(
+ *(
+ registry.provider(*key).async_resolve(hass, requests[key], language)
+ for key in provider_keys
+ )
)
except HomeAssistantError as err:
raise ProjectComposeError("Could not resolve widget data") from err
+ resolved = dict(zip(provider_keys, provider_values, strict=True))
data_ms = (perf_counter() - started) * 1_000
liquid_ms = 0.0
@@ -258,19 +197,15 @@ async def async_compose_project(
fragment = ""
if widget is not None:
widget_type = widget["type"]
- config = with_defaults(widget_type, widget["config"])
+ config = with_defaults(widget_type, widget["config"], registry)
data = _resolve_widget_data(
widget_type,
config,
- entities,
- calendars,
- weather,
+ resolved,
+ registry,
)
- if widget_type == "entity-state" and data.get("entity") is not None:
- title = str(config.get("title", "")).strip()
- data["entity"]["displayName"] = title or data["entity"]["name"]
result = LIQUID.render(
- TEMPLATES[widget_type],
+ registry.template(widget_type),
{
"config": config,
"data": data,
diff --git a/custom_components/opendisplay_studio/const.py b/custom_components/opendisplay_studio/const.py
index 4cac4ab..454fce6 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.6"
+INTEGRATION_VERSION = "0.5.0"
API_VERSION = 1
-MIN_RENDERER_VERSION = "0.2.4"
+MIN_RENDERER_VERSION = "0.5.0"
TRMNL_FRAMEWORK_VERSION = "3.2.0"
DEFAULT_WIDTH = 800
DEFAULT_HEIGHT = 480
diff --git a/custom_components/opendisplay_studio/data_providers.py b/custom_components/opendisplay_studio/data_providers.py
deleted file mode 100644
index 327a080..0000000
--- a/custom_components/opendisplay_studio/data_providers.py
+++ /dev/null
@@ -1,354 +0,0 @@
-"""Resolve explicitly requested Home Assistant data for widgets."""
-
-from __future__ import annotations
-
-import asyncio
-from datetime import date, datetime, timedelta
-from typing import Any
-
-from homeassistant.components.calendar import DATA_COMPONENT, CalendarEntity
-from homeassistant.components.weather import (
- DOMAIN as WEATHER_DOMAIN,
-)
-from homeassistant.components.weather import (
- 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",
- "heat": "M7.95,3L6.53,5.19L7.95,7.4H7.94L5.95,10.5L4.22,9.6L5.64,7.39L4.22,5.19L6.22,2.09L7.95,3M13.95,2.89L12.53,5.1L13.95,7.3L13.94,7.31L11.95,10.4L10.22,9.5L11.64,7.3L10.22,5.1L12.22,2L13.95,2.89M20,2.89L18.56,5.1L20,7.3V7.31L18,10.4L16.25,9.5L17.67,7.3L16.25,5.1L18.25,2L20,2.89M2,22V14A2,2 0 0,1 4,12H20A2,2 0 0,1 22,14V22H20V20H4V22H2M6,14A1,1 0 0,0 5,15V17A1,1 0 0,0 6,18A1,1 0 0,0 7,17V15A1,1 0 0,0 6,14M10,14A1,1 0 0,0 9,15V17A1,1 0 0,0 10,18A1,1 0 0,0 11,17V15A1,1 0 0,0 10,14M14,14A1,1 0 0,0 13,15V17A1,1 0 0,0 14,18A1,1 0 0,0 15,17V15A1,1 0 0,0 14,14M18,14A1,1 0 0,0 17,15V17A1,1 0 0,0 18,18A1,1 0 0,0 19,17V15A1,1 0 0,0 18,14Z",
- "humidity": "M12,3.25C12,3.25 6,10 6,14C6,17.32 8.69,20 12,20A6,6 0 0,0 18,14C18,10 12,3.25 12,3.25M14.47,9.97L15.53,11.03L9.53,17.03L8.47,15.97M9.75,10A1.25,1.25 0 0,1 11,11.25A1.25,1.25 0 0,1 9.75,12.5A1.25,1.25 0 0,1 8.5,11.25A1.25,1.25 0 0,1 9.75,10M14.25,14.5A1.25,1.25 0 0,1 15.5,15.75A1.25,1.25 0 0,1 14.25,17A1.25,1.25 0 0,1 13,15.75A1.25,1.25 0 0,1 14.25,14.5Z",
- "illuminance": "M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C21.27,16.39 17,19.5 12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C15.76,17.5 19.17,15.36 20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12Z",
- "light": "M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z",
- "power": "M7,2H17L13.5,9H17L10,22V14H7V2M9,4V12H12V14.66L14,11H10.24L13.76,4H9Z",
- "pressure": "M12,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,4A8,8 0 0,1 20,12C20,14.4 19,16.5 17.3,18C15.9,16.7 14,16 12,16C10,16 8.2,16.7 6.7,18C5,16.5 4,14.4 4,12A8,8 0 0,1 12,4M14,5.89C13.62,5.9 13.26,6.15 13.1,6.54L11.81,9.77L11.71,10C11,10.13 10.41,10.6 10.14,11.26C9.73,12.29 10.23,13.45 11.26,13.86C12.29,14.27 13.45,13.77 13.86,12.74C14.12,12.08 14,11.32 13.57,10.76L13.67,10.5L14.96,7.29L14.97,7.26C15.17,6.75 14.92,6.17 14.41,5.96C14.28,5.91 14.15,5.89 14,5.89M10,6A1,1 0 0,0 9,7A1,1 0 0,0 10,8A1,1 0 0,0 11,7A1,1 0 0,0 10,6M7,9A1,1 0 0,0 6,10A1,1 0 0,0 7,11A1,1 0 0,0 8,10A1,1 0 0,0 7,9M17,9A1,1 0 0,0 16,10A1,1 0 0,0 17,11A1,1 0 0,0 18,10A1,1 0 0,0 17,9Z",
- "switch": "M17 6H7C3.69 6 1 8.69 1 12S3.69 18 7 18H17C20.31 18 23 15.31 23 12S20.31 6 17 6M17 16H7C4.79 16 3 14.21 3 12S4.79 8 7 8H17C19.21 8 21 9.79 21 12S19.21 16 17 16M17 9C15.34 9 14 10.34 14 12S15.34 15 17 15 20 13.66 20 12 18.66 9 17 9Z",
- "temperature": "M15 13V5A3 3 0 0 0 9 5V13A5 5 0 1 0 15 13M12 4A1 1 0 0 1 13 5V8H11V5A1 1 0 0 1 12 4Z",
-}
-
-MDI_ICON_KEYS = {
- "mdi:flash-outline": "power",
- "mdi:gauge": "pressure",
- "mdi:lightbulb-outline": "light",
- "mdi:radiator": "heat",
- "mdi:thermometer": "temperature",
- "mdi:toggle-switch-outline": "switch",
- "mdi:water-percent": "humidity",
-}
-
-WEATHER_CONDITION_LABELS = {
- "clear-night": "Clear night",
- "cloudy": "Cloudy",
- "exceptional": "Exceptional",
- "fog": "Fog",
- "hail": "Hail",
- "lightning": "Lightning",
- "lightning-rainy": "Thunderstorms and rain",
- "partlycloudy": "Partly cloudy",
- "pouring": "Heavy rain",
- "rainy": "Rain",
- "snowy": "Snow",
- "snowy-rainy": "Snow and rain",
- "sunny": "Sunny",
- "windy": "Windy",
- "windy-variant": "Windy and cloudy",
-}
-
-WEATHER_CONDITION_ICONS = {
- "clear-night": "wi-night-clear.svg",
- "cloudy": "wi-cloudy.svg",
- "exceptional": "wi-na.svg",
- "fog": "wi-fog.svg",
- "hail": "wi-hail.svg",
- "lightning": "wi-lightning.svg",
- "lightning-rainy": "wi-thunderstorm.svg",
- "partlycloudy": "wi-day-cloudy.svg",
- "pouring": "wi-rain-wind.svg",
- "rainy": "wi-rain.svg",
- "snowy": "wi-snow.svg",
- "snowy-rainy": "wi-rain-mix.svg",
- "sunny": "wi-day-sunny.svg",
- "windy": "wi-strong-wind.svg",
- "windy-variant": "wi-cloudy-windy.svg",
-}
-
-WEATHER_ICON_BASE_URL = "https://trmnl.com/images/plugins/weather"
-
-
-def _entity_icon_path(entity_id: str, attributes: dict[str, object]) -> str:
- """Choose a deterministic local icon without external image requests."""
- configured = attributes.get("icon")
- if isinstance(configured, str) and configured in MDI_ICON_KEYS:
- return ICON_PATHS[MDI_ICON_KEYS[configured]]
- device_class = str(attributes.get("device_class", ""))
- key = {
- "current": "power",
- "energy": "power",
- "humidity": "humidity",
- "illuminance": "illuminance",
- "power": "power",
- "pressure": "pressure",
- "temperature": "temperature",
- "voltage": "power",
- }.get(device_class)
- if key is None:
- key = {
- "calendar": "calendar",
- "climate": "heat",
- "light": "light",
- "switch": "switch",
- }.get(entity_id.partition(".")[0], "default")
- return ICON_PATHS[key]
-
-
-class EntityStateProvider:
- """Normalize current state-machine values."""
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize the provider."""
- self._hass = hass
-
- def get_many(self, entity_ids: set[str]) -> dict[str, dict[str, str]]:
- """Resolve each unique entity once."""
- result: dict[str, dict[str, str]] = {}
- for entity_id in entity_ids:
- state = self._hass.states.get(entity_id)
- if state is None:
- result[entity_id] = {
- "state": "Unavailable",
- "unit": "",
- "name": entity_id,
- "iconPath": ICON_PATHS["default"],
- }
- continue
- result[entity_id] = {
- "state": state.state,
- "unit": str(state.attributes.get("unit_of_measurement", "")),
- "name": str(state.attributes.get("friendly_name", entity_id)),
- "iconPath": _entity_icon_path(entity_id, state.attributes),
- }
- return result
-
-
-def _weather_condition_label(condition: str) -> str:
- """Return an English fallback label for a Home Assistant condition."""
- return WEATHER_CONDITION_LABELS.get(
- condition,
- condition.replace("-", " ").capitalize() if condition else "Unavailable",
- )
-
-
-def _weather_icon(condition: str) -> str:
- """Map a Home Assistant condition to a TRMNL weather icon."""
- filename = WEATHER_CONDITION_ICONS.get(condition, "wi-na.svg")
- 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):
- return None
- if value >= 11:
- return "Extreme"
- if value >= 8:
- return "Very high"
- if value >= 6:
- return "High"
- if value >= 3:
- return "Moderate"
- return "Low"
-
-
-def _forecast_date_label(value: object, today: date) -> str:
- """Create a compact local label for a forecast timestamp."""
- parsed = dt_util.parse_datetime(str(value)) if value else None
- if parsed is None:
- return ""
- local_date = dt_util.as_local(parsed).date()
- offset = (local_date - today).days
- if offset == 0:
- return "Today"
- if offset == 1:
- return "Tomorrow"
- return local_date.strftime("%a")
-
-
-class WeatherForecastProvider:
- """Resolve current weather state and daily forecasts."""
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize the provider."""
- self._hass = hass
-
- async def async_get_many(self, entity_ids: set[str]) -> dict[str, dict[str, Any]]:
- """Resolve selected weather entities with one forecast action call."""
- if not entity_ids:
- return {}
-
- sorted_entity_ids = sorted(entity_ids)
- 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]] = {}
-
- for entity_id in sorted_entity_ids:
- state = self._hass.states.get(entity_id)
- attributes = state.attributes if state is not None else {}
- condition = state.state if state is not None else "unavailable"
- response_item = forecast_response.get(entity_id, {})
- raw_forecasts = (
- response_item.get("forecast", [])
- if isinstance(response_item, dict)
- else []
- )
- forecasts: list[dict[str, Any]] = []
- if isinstance(raw_forecasts, list):
- for item in raw_forecasts:
- if not isinstance(item, dict):
- continue
- forecast_condition = str(item.get("condition") or "exceptional")
- uv_index = item.get("uv_index")
- forecasts.append(
- {
- "datetime": str(item.get("datetime") or ""),
- "date_label": _forecast_date_label(
- item.get("datetime"), today
- ),
- "condition": forecast_condition,
- "condition_label": _weather_condition_label(
- forecast_condition
- ),
- "icon": _weather_icon(forecast_condition),
- "temperature": item.get("temperature"),
- "templow": item.get("templow"),
- "uv_index": uv_index,
- "uv_label": _uv_label(uv_index),
- "precipitation_probability": item.get(
- "precipitation_probability"
- ),
- }
- )
-
- result[entity_id] = {
- "entity_id": entity_id,
- "name": str(attributes.get("friendly_name", entity_id)),
- "condition": condition,
- "condition_label": _weather_condition_label(condition),
- "icon": _weather_icon(condition),
- "temperature": attributes.get("temperature", "—"),
- "temperature_unit": str(attributes.get("temperature_unit", "")),
- "apparent_temperature": attributes.get("apparent_temperature"),
- "humidity": attributes.get("humidity"),
- "updated_at": (
- dt_util.as_local(state.last_updated).strftime("%H:%M")
- if state is not None
- else ""
- ),
- "forecast": forecasts,
- }
- return result
-
-
-class CalendarProvider:
- """Fetch and normalize events from requested calendar entities."""
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize the provider."""
- self._hass = hass
-
- async def async_get_many(
- self, requests: dict[str, int]
- ) -> dict[str, list[dict[str, str | bool | int]]]:
- """Resolve each calendar once using its maximum requested horizon."""
- pairs = await asyncio.gather(
- *(self._async_get(entity_id, days) for entity_id, days in requests.items())
- )
- return dict(pairs)
-
- async def _async_get(
- self, entity_id: str, days: int
- ) -> tuple[str, list[dict[str, str | bool | int]]]:
- component = self._hass.data.get(DATA_COMPONENT)
- entity = component.get_entity(entity_id) if component is not None else None
- if not isinstance(entity, CalendarEntity):
- return entity_id, []
- start = dt_util.now()
- events = await entity.async_get_events(
- self._hass, start, start + timedelta(days=days)
- )
- normalized: list[dict[str, str | bool | int]] = []
- for event in events:
- event_start = event.start
- all_day = isinstance(event_start, date) and not isinstance(
- event_start, datetime
- )
- if isinstance(event_start, datetime):
- local_start = dt_util.as_local(event_start)
- start_value = local_start.isoformat()
- time_value = local_start.strftime("%H:%M")
- date_value = local_start.strftime("%a %d %b")
- else:
- start_value = event_start.isoformat()
- time_value = "All day"
- date_value = event_start.strftime("%a %d %b")
- normalized.append(
- {
- "summary": event.summary or "Untitled event",
- "start": start_value,
- "time": time_value,
- "date": date_value,
- "location": event.location or "",
- "description": event.description or "",
- "allDay": all_day,
- "dayOffset": max(
- 0,
- (
- (
- event_start.date()
- if isinstance(event_start, datetime)
- else event_start
- )
- - start.date()
- ).days,
- ),
- }
- )
- return entity_id, normalized
diff --git a/custom_components/opendisplay_studio/frontend/opendisplay-studio.js b/custom_components/opendisplay_studio/frontend/opendisplay-studio.js
index 03b8490..49a33f7 100644
--- a/custom_components/opendisplay_studio/frontend/opendisplay-studio.js
+++ b/custom_components/opendisplay_studio/frontend/opendisplay-studio.js
@@ -58,23 +58,23 @@ var e = globalThis, t = e.ShadowRoot && (e.ShadyCSS === void 0 || e.ShadyCSS.nat
}
return n;
}
-}, h = (e, t) => !l(e, t), oe = {
+}, oe = (e, t) => !l(e, t), se = {
attribute: !0,
type: String,
converter: m,
reflect: !1,
useDefault: !1,
- hasChanged: h
+ hasChanged: oe
};
Symbol.metadata ??= Symbol("metadata"), f.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
-var g = class extends HTMLElement {
+var h = class extends HTMLElement {
static addInitializer(e) {
this._$Ei(), (this.l ??= []).push(e);
}
static get observedAttributes() {
return this.finalize(), this._$Eh && [...this._$Eh.keys()];
}
- static createProperty(e, t = oe) {
+ static createProperty(e, t = se) {
if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) {
let n = Symbol(), r = this.getPropertyDescriptor(e, n, t);
r !== void 0 && u(this.prototype, e, r);
@@ -100,7 +100,7 @@ var g = class extends HTMLElement {
};
}
static getPropertyOptions(e) {
- return this.elementProperties.get(e) ?? oe;
+ return this.elementProperties.get(e) ?? se;
}
static _$Ei() {
if (this.hasOwnProperty(p("elementProperties"))) return;
@@ -187,7 +187,7 @@ var g = class extends HTMLElement {
requestUpdate(e, t, n, r = !1, i) {
if (e !== void 0) {
let a = this.constructor;
- if (!1 === r && (i = this[e]), n ??= a.getPropertyOptions(e), !((n.hasChanged ?? h)(i, t) || n.useDefault && n.reflect && i === this._$Ej?.get(e) && !this.hasAttribute(a._$Eu(e, n)))) return;
+ if (!1 === r && (i = this[e]), n ??= a.getPropertyOptions(e), !((n.hasChanged ?? oe)(i, t) || n.useDefault && n.reflect && i === this._$Ej?.get(e) && !this.hasAttribute(a._$Eu(e, n)))) return;
this.C(e, t, n);
}
!1 === this.isUpdatePending && (this._$ES = this._$EP());
@@ -251,89 +251,89 @@ var g = class extends HTMLElement {
updated(e) {}
firstUpdated(e) {}
};
-g.elementStyles = [], g.shadowRootOptions = { mode: "open" }, g[p("elementProperties")] = /* @__PURE__ */ new Map(), g[p("finalized")] = /* @__PURE__ */ new Map(), ae?.({ ReactiveElement: g }), (f.reactiveElementVersions ??= []).push("2.1.2");
+h.elementStyles = [], h.shadowRootOptions = { mode: "open" }, h[p("elementProperties")] = /* @__PURE__ */ new Map(), h[p("finalized")] = /* @__PURE__ */ new Map(), ae?.({ ReactiveElement: h }), (f.reactiveElementVersions ??= []).push("2.1.2");
//#endregion
//#region node_modules/lit-html/lit-html.js
-var se = globalThis, ce = (e) => e, _ = se.trustedTypes, le = _ ? _.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, ue = "$lit$", v = `lit$${Math.random().toFixed(9).slice(2)}$`, de = "?" + v, fe = `<${de}>`, y = document, b = () => y.createComment(""), x = (e) => e === null || typeof e != "object" && typeof e != "function", pe = Array.isArray, me = (e) => pe(e) || typeof e?.[Symbol.iterator] == "function", he = "[ \n\f\r]", S = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ge = /-->/g, _e = />/g, C = RegExp(`>|${he}(?:([^\\s"'>=/]+)(${he}*=${he}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), ve = /'/g, ye = /"/g, be = /^(?:script|style|textarea|title)$/i, w = ((e) => (t, ...n) => ({
+var ce = globalThis, le = (e) => e, g = ce.trustedTypes, ue = g ? g.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, de = "$lit$", _ = `lit$${Math.random().toFixed(9).slice(2)}$`, fe = "?" + _, pe = `<${fe}>`, v = document, y = () => v.createComment(""), b = (e) => e === null || typeof e != "object" && typeof e != "function", me = Array.isArray, he = (e) => me(e) || typeof e?.[Symbol.iterator] == "function", ge = "[ \n\f\r]", x = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, _e = /-->/g, ve = />/g, S = RegExp(`>|${ge}(?:([^\\s"'>=/]+)(${ge}*=${ge}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), ye = /'/g, be = /"/g, xe = /^(?:script|style|textarea|title)$/i, C = ((e) => (t, ...n) => ({
_$litType$: e,
strings: t,
values: n
-}))(1), T = Symbol.for("lit-noChange"), E = Symbol.for("lit-nothing"), xe = /* @__PURE__ */ new WeakMap(), D = y.createTreeWalker(y, 129);
-function Se(e, t) {
- if (!pe(e) || !e.hasOwnProperty("raw")) throw Error("invalid template strings array");
- return le === void 0 ? t : le.createHTML(t);
+}))(1), w = Symbol.for("lit-noChange"), T = Symbol.for("lit-nothing"), Se = /* @__PURE__ */ new WeakMap(), E = v.createTreeWalker(v, 129);
+function Ce(e, t) {
+ if (!me(e) || !e.hasOwnProperty("raw")) throw Error("invalid template strings array");
+ return ue === void 0 ? t : ue.createHTML(t);
}
-var Ce = (e, t) => {
- let n = e.length - 1, r = [], i, a = t === 2 ? "" : t === 3 ? "" : "", o = S;
+var we = (e, t) => {
+ let n = e.length - 1, r = [], i, a = t === 2 ? "" : t === 3 ? "" : "", o = x;
for (let t = 0; t < n; t++) {
let n = e[t], s, c, l = -1, u = 0;
- for (; u < n.length && (o.lastIndex = u, c = o.exec(n), c !== null);) u = o.lastIndex, o === S ? c[1] === "!--" ? o = ge : c[1] === void 0 ? c[2] === void 0 ? c[3] !== void 0 && (o = C) : (be.test(c[2]) && (i = RegExp("" + c[2], "g")), o = C) : o = _e : o === C ? c[0] === ">" ? (o = i ?? S, l = -1) : c[1] === void 0 ? l = -2 : (l = o.lastIndex - c[2].length, s = c[1], o = c[3] === void 0 ? C : c[3] === "\"" ? ye : ve) : o === ye || o === ve ? o = C : o === ge || o === _e ? o = S : (o = C, i = void 0);
- let d = o === C && e[t + 1].startsWith("/>") ? " " : "";
- a += o === S ? n + fe : l >= 0 ? (r.push(s), n.slice(0, l) + ue + n.slice(l) + v + d) : n + v + (l === -2 ? t : d);
+ for (; u < n.length && (o.lastIndex = u, c = o.exec(n), c !== null);) u = o.lastIndex, o === x ? c[1] === "!--" ? o = _e : c[1] === void 0 ? c[2] === void 0 ? c[3] !== void 0 && (o = S) : (xe.test(c[2]) && (i = RegExp("" + c[2], "g")), o = S) : o = ve : o === S ? c[0] === ">" ? (o = i ?? x, l = -1) : c[1] === void 0 ? l = -2 : (l = o.lastIndex - c[2].length, s = c[1], o = c[3] === void 0 ? S : c[3] === "\"" ? be : ye) : o === be || o === ye ? o = S : o === _e || o === ve ? o = x : (o = S, i = void 0);
+ let d = o === S && e[t + 1].startsWith("/>") ? " " : "";
+ a += o === x ? n + pe : l >= 0 ? (r.push(s), n.slice(0, l) + de + n.slice(l) + _ + d) : n + _ + (l === -2 ? t : d);
}
- return [Se(e, a + (e[n] || ">") + (t === 2 ? " " : t === 3 ? " " : "")), r];
-}, O = class e {
+ return [Ce(e, a + (e[n] || ">") + (t === 2 ? " " : t === 3 ? "" : "")), r];
+}, D = class e {
constructor({ strings: t, _$litType$: n }, r) {
let i;
this.parts = [];
- let a = 0, o = 0, s = t.length - 1, c = this.parts, [l, u] = Ce(t, n);
- if (this.el = e.createElement(l, r), D.currentNode = this.el.content, n === 2 || n === 3) {
+ let a = 0, o = 0, s = t.length - 1, c = this.parts, [l, u] = we(t, n);
+ if (this.el = e.createElement(l, r), E.currentNode = this.el.content, n === 2 || n === 3) {
let e = this.el.content.firstChild;
e.replaceWith(...e.childNodes);
}
- for (; (i = D.nextNode()) !== null && c.length < s;) {
+ for (; (i = E.nextNode()) !== null && c.length < s;) {
if (i.nodeType === 1) {
- if (i.hasAttributes()) for (let e of i.getAttributeNames()) if (e.endsWith(ue)) {
- let t = u[o++], n = i.getAttribute(e).split(v), r = /([.?@])?(.*)/.exec(t);
+ if (i.hasAttributes()) for (let e of i.getAttributeNames()) if (e.endsWith(de)) {
+ let t = u[o++], n = i.getAttribute(e).split(_), r = /([.?@])?(.*)/.exec(t);
c.push({
type: 1,
index: a,
name: r[2],
strings: n,
- ctor: r[1] === "." ? Te : r[1] === "?" ? Ee : r[1] === "@" ? De : j
+ ctor: r[1] === "." ? Ee : r[1] === "?" ? De : r[1] === "@" ? Oe : A
}), i.removeAttribute(e);
- } else e.startsWith(v) && (c.push({
+ } else e.startsWith(_) && (c.push({
type: 6,
index: a
}), i.removeAttribute(e));
- if (be.test(i.tagName)) {
- let e = i.textContent.split(v), t = e.length - 1;
+ if (xe.test(i.tagName)) {
+ let e = i.textContent.split(_), t = e.length - 1;
if (t > 0) {
- i.textContent = _ ? _.emptyScript : "";
- for (let n = 0; n < t; n++) i.append(e[n], b()), D.nextNode(), c.push({
+ i.textContent = g ? g.emptyScript : "";
+ for (let n = 0; n < t; n++) i.append(e[n], y()), E.nextNode(), c.push({
type: 2,
index: ++a
});
- i.append(e[t], b());
+ i.append(e[t], y());
}
}
} else if (i.nodeType === 8) {
- if (i.data === de) c.push({
+ if (i.data === fe) c.push({
type: 2,
index: a
});
else {
let e = -1;
- for (; (e = i.data.indexOf(v, e + 1)) !== -1;) c.push({
+ for (; (e = i.data.indexOf(_, e + 1)) !== -1;) c.push({
type: 7,
index: a
- }), e += v.length - 1;
+ }), e += _.length - 1;
}
}
a++;
}
}
static createElement(e, t) {
- let n = y.createElement("template");
+ let n = v.createElement("template");
return n.innerHTML = e, n;
}
};
-function k(e, t, n = e, r) {
- if (t === T) return t;
- let i = r === void 0 ? n._$Cl : n._$Co?.[r], a = x(t) ? void 0 : t._$litDirective$;
- return i?.constructor !== a && (i?._$AO?.(!1), a === void 0 ? i = void 0 : (i = new a(e), i._$AT(e, n, r)), r === void 0 ? n._$Cl = i : (n._$Co ??= [])[r] = i), i !== void 0 && (t = k(e, i._$AS(e, t.values), i, r)), t;
+function O(e, t, n = e, r) {
+ if (t === w) return t;
+ let i = r === void 0 ? n._$Cl : n._$Co?.[r], a = b(t) ? void 0 : t._$litDirective$;
+ return i?.constructor !== a && (i?._$AO?.(!1), a === void 0 ? i = void 0 : (i = new a(e), i._$AT(e, n, r)), r === void 0 ? n._$Cl = i : (n._$Co ??= [])[r] = i), i !== void 0 && (t = O(e, i._$AS(e, t.values), i, r)), t;
}
-var we = class {
+var Te = class {
constructor(e, t) {
this._$AV = [], this._$AN = void 0, this._$AD = e, this._$AM = t;
}
@@ -344,28 +344,28 @@ var we = class {
return this._$AM._$AU;
}
u(e) {
- let { el: { content: t }, parts: n } = this._$AD, r = (e?.creationScope ?? y).importNode(t, !0);
- D.currentNode = r;
- let i = D.nextNode(), a = 0, o = 0, s = n[0];
+ let { el: { content: t }, parts: n } = this._$AD, r = (e?.creationScope ?? v).importNode(t, !0);
+ E.currentNode = r;
+ let i = E.nextNode(), a = 0, o = 0, s = n[0];
for (; s !== void 0;) {
if (a === s.index) {
let t;
- s.type === 2 ? t = new A(i, i.nextSibling, this, e) : s.type === 1 ? t = new s.ctor(i, s.name, s.strings, this, e) : s.type === 6 && (t = new Oe(i, this, e)), this._$AV.push(t), s = n[++o];
+ s.type === 2 ? t = new k(i, i.nextSibling, this, e) : s.type === 1 ? t = new s.ctor(i, s.name, s.strings, this, e) : s.type === 6 && (t = new ke(i, this, e)), this._$AV.push(t), s = n[++o];
}
- a !== s?.index && (i = D.nextNode(), a++);
+ a !== s?.index && (i = E.nextNode(), a++);
}
- return D.currentNode = y, r;
+ return E.currentNode = v, r;
}
p(e) {
let t = 0;
for (let n of this._$AV) n !== void 0 && (n.strings === void 0 ? n._$AI(e[t]) : (n._$AI(e, n, t), t += n.strings.length - 2)), t++;
}
-}, A = class e {
+}, k = class e {
get _$AU() {
return this._$AM?._$AU ?? this._$Cv;
}
constructor(e, t, n, r) {
- this.type = 2, this._$AH = E, this._$AN = void 0, this._$AA = e, this._$AB = t, this._$AM = n, this.options = r, this._$Cv = r?.isConnected ?? !0;
+ this.type = 2, this._$AH = T, this._$AN = void 0, this._$AA = e, this._$AB = t, this._$AM = n, this.options = r, this._$Cv = r?.isConnected ?? !0;
}
get parentNode() {
let e = this._$AA.parentNode, t = this._$AM;
@@ -378,7 +378,7 @@ var we = class {
return this._$AB;
}
_$AI(e, t = this) {
- e = k(this, e, t), x(e) ? e === E || e == null || e === "" ? (this._$AH !== E && this._$AR(), this._$AH = E) : e !== this._$AH && e !== T && this._(e) : e._$litType$ === void 0 ? e.nodeType === void 0 ? me(e) ? this.k(e) : this._(e) : this.T(e) : this.$(e);
+ e = O(this, e, t), b(e) ? e === T || e == null || e === "" ? (this._$AH !== T && this._$AR(), this._$AH = T) : e !== this._$AH && e !== w && this._(e) : e._$litType$ === void 0 ? e.nodeType === void 0 ? he(e) ? this.k(e) : this._(e) : this.T(e) : this.$(e);
}
O(e) {
return this._$AA.parentNode.insertBefore(e, this._$AB);
@@ -387,36 +387,36 @@ var we = class {
this._$AH !== e && (this._$AR(), this._$AH = this.O(e));
}
_(e) {
- this._$AH !== E && x(this._$AH) ? this._$AA.nextSibling.data = e : this.T(y.createTextNode(e)), this._$AH = e;
+ this._$AH !== T && b(this._$AH) ? this._$AA.nextSibling.data = e : this.T(v.createTextNode(e)), this._$AH = e;
}
$(e) {
- let { values: t, _$litType$: n } = e, r = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = O.createElement(Se(n.h, n.h[0]), this.options)), n);
+ let { values: t, _$litType$: n } = e, r = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = D.createElement(Ce(n.h, n.h[0]), this.options)), n);
if (this._$AH?._$AD === r) this._$AH.p(t);
else {
- let e = new we(r, this), n = e.u(this.options);
+ let e = new Te(r, this), n = e.u(this.options);
e.p(t), this.T(n), this._$AH = e;
}
}
_$AC(e) {
- let t = xe.get(e.strings);
- return t === void 0 && xe.set(e.strings, t = new O(e)), t;
+ let t = Se.get(e.strings);
+ return t === void 0 && Se.set(e.strings, t = new D(e)), t;
}
k(t) {
- pe(this._$AH) || (this._$AH = [], this._$AR());
+ me(this._$AH) || (this._$AH = [], this._$AR());
let n = this._$AH, r, i = 0;
- for (let a of t) i === n.length ? n.push(r = new e(this.O(b()), this.O(b()), this, this.options)) : r = n[i], r._$AI(a), i++;
+ for (let a of t) i === n.length ? n.push(r = new e(this.O(y()), this.O(y()), this, this.options)) : r = n[i], r._$AI(a), i++;
i < n.length && (this._$AR(r && r._$AB.nextSibling, i), n.length = i);
}
_$AR(e = this._$AA.nextSibling, t) {
for (this._$AP?.(!1, !0, t); e !== this._$AB;) {
- let t = ce(e).nextSibling;
- ce(e).remove(), e = t;
+ let t = le(e).nextSibling;
+ le(e).remove(), e = t;
}
}
setConnected(e) {
this._$AM === void 0 && (this._$Cv = e, this._$AP?.(e));
}
-}, j = class {
+}, A = class {
get tagName() {
return this.element.tagName;
}
@@ -424,47 +424,47 @@ var we = class {
return this._$AM._$AU;
}
constructor(e, t, n, r, i) {
- this.type = 1, this._$AH = E, this._$AN = void 0, this.element = e, this.name = t, this._$AM = r, this.options = i, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(/* @__PURE__ */ new String()), this.strings = n) : this._$AH = E;
+ this.type = 1, this._$AH = T, this._$AN = void 0, this.element = e, this.name = t, this._$AM = r, this.options = i, n.length > 2 || n[0] !== "" || n[1] !== "" ? (this._$AH = Array(n.length - 1).fill(/* @__PURE__ */ new String()), this.strings = n) : this._$AH = T;
}
_$AI(e, t = this, n, r) {
let i = this.strings, a = !1;
- if (i === void 0) e = k(this, e, t, 0), a = !x(e) || e !== this._$AH && e !== T, a && (this._$AH = e);
+ if (i === void 0) e = O(this, e, t, 0), a = !b(e) || e !== this._$AH && e !== w, a && (this._$AH = e);
else {
let r = e, o, s;
- for (e = i[0], o = 0; o < i.length - 1; o++) s = k(this, r[n + o], t, o), s === T && (s = this._$AH[o]), a ||= !x(s) || s !== this._$AH[o], s === E ? e = E : e !== E && (e += (s ?? "") + i[o + 1]), this._$AH[o] = s;
+ for (e = i[0], o = 0; o < i.length - 1; o++) s = O(this, r[n + o], t, o), s === w && (s = this._$AH[o]), a ||= !b(s) || s !== this._$AH[o], s === T ? e = T : e !== T && (e += (s ?? "") + i[o + 1]), this._$AH[o] = s;
}
a && !r && this.j(e);
}
j(e) {
- e === E ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? "");
+ e === T ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? "");
}
-}, Te = class extends j {
+}, Ee = class extends A {
constructor() {
super(...arguments), this.type = 3;
}
j(e) {
- this.element[this.name] = e === E ? void 0 : e;
+ this.element[this.name] = e === T ? void 0 : e;
}
-}, Ee = class extends j {
+}, De = class extends A {
constructor() {
super(...arguments), this.type = 4;
}
j(e) {
- this.element.toggleAttribute(this.name, !!e && e !== E);
+ this.element.toggleAttribute(this.name, !!e && e !== T);
}
-}, De = class extends j {
+}, Oe = class extends A {
constructor(e, t, n, r, i) {
super(e, t, n, r, i), this.type = 5;
}
_$AI(e, t = this) {
- if ((e = k(this, e, t, 0) ?? E) === T) return;
- let n = this._$AH, r = e === E && n !== E || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, i = e !== E && (n === E || r);
+ if ((e = O(this, e, t, 0) ?? T) === w) return;
+ let n = this._$AH, r = e === T && n !== T || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, i = e !== T && (n === T || r);
r && this.element.removeEventListener(this.name, this, n), i && this.element.addEventListener(this.name, this, e), this._$AH = e;
}
handleEvent(e) {
typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, e) : this._$AH.handleEvent(e);
}
-}, Oe = class {
+}, ke = class {
constructor(e, t, n) {
this.element = e, this.type = 6, this._$AN = void 0, this._$AM = t, this.options = n;
}
@@ -472,18 +472,18 @@ var we = class {
return this._$AM._$AU;
}
_$AI(e) {
- k(this, e);
+ O(this, e);
}
-}, ke = se.litHtmlPolyfillSupport;
-ke?.(O, A), (se.litHtmlVersions ??= []).push("3.3.3");
-var Ae = (e, t, n) => {
+}, Ae = ce.litHtmlPolyfillSupport;
+Ae?.(D, k), (ce.litHtmlVersions ??= []).push("3.3.3");
+var je = (e, t, n) => {
let r = n?.renderBefore ?? t, i = r._$litPart$;
if (i === void 0) {
let e = n?.renderBefore ?? null;
- r._$litPart$ = i = new A(t.insertBefore(b(), e), e, void 0, n ?? {});
+ r._$litPart$ = i = new k(t.insertBefore(y(), e), e, void 0, n ?? {});
}
return i._$AI(e), i;
-}, M = globalThis, N = class extends g {
+}, j = globalThis, M = class extends h {
constructor() {
super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
}
@@ -493,7 +493,7 @@ var Ae = (e, t, n) => {
}
update(e) {
let t = this.render();
- this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = Ae(t, this.renderRoot, this.renderOptions);
+ this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = je(t, this.renderRoot, this.renderOptions);
}
connectedCallback() {
super.connectedCallback(), this._$Do?.setConnected(!0);
@@ -502,25 +502,25 @@ var Ae = (e, t, n) => {
super.disconnectedCallback(), this._$Do?.setConnected(!1);
}
render() {
- return T;
+ return w;
}
};
-N._$litElement$ = !0, N.finalized = !0, M.litElementHydrateSupport?.({ LitElement: N });
-var je = M.litElementPolyfillSupport;
-je?.({ LitElement: N }), (M.litElementVersions ??= []).push("4.2.2");
+M._$litElement$ = !0, M.finalized = !0, j.litElementHydrateSupport?.({ LitElement: M });
+var Me = j.litElementPolyfillSupport;
+Me?.({ LitElement: M }), (j.litElementVersions ??= []).push("4.2.2");
//#endregion
//#region node_modules/@lit/reactive-element/decorators/custom-element.js
-var Me = (e) => (t, n) => {
+var Ne = (e) => (t, n) => {
n === void 0 ? customElements.define(e, t) : n.addInitializer(() => {
customElements.define(e, t);
});
-}, Ne = {
+}, Pe = {
attribute: !0,
type: String,
converter: m,
reflect: !1,
- hasChanged: h
-}, Pe = (e = Ne, t, n) => {
+ hasChanged: oe
+}, Fe = (e = Pe, t, n) => {
let { kind: r, metadata: i } = n, a = globalThis.litPropertyMetadata.get(i);
if (a === void 0 && globalThis.litPropertyMetadata.set(i, a = /* @__PURE__ */ new Map()), r === "setter" && ((e = Object.create(e)).wrapped = !0), a.set(n.name, e), r === "accessor") {
let { name: r } = n;
@@ -543,16 +543,16 @@ var Me = (e) => (t, n) => {
}
throw Error("Unsupported decorator location: " + r);
};
-function Fe(e) {
- return (t, n) => typeof n == "object" ? Pe(e, t, n) : ((e, t, n) => {
+function Ie(e) {
+ return (t, n) => typeof n == "object" ? Fe(e, t, n) : ((e, t, n) => {
let r = t.hasOwnProperty(n);
return t.constructor.createProperty(n, e), r ? Object.getOwnPropertyDescriptor(t, n) : void 0;
})(e, t, n);
}
//#endregion
//#region node_modules/@lit/reactive-element/decorators/state.js
-function P(e) {
- return Fe({
+function N(e) {
+ return Ie({
...e,
state: !0,
attribute: !1
@@ -560,10 +560,10 @@ function P(e) {
}
//#endregion
//#region node_modules/@lit/reactive-element/decorators/base.js
-var Ie = (e, t, n) => (n.configurable = !0, n.enumerable = !0, Reflect.decorate && typeof t != "object" && Object.defineProperty(e, t, n), n);
+var Le = (e, t, n) => (n.configurable = !0, n.enumerable = !0, Reflect.decorate && typeof t != "object" && Object.defineProperty(e, t, n), n);
//#endregion
//#region node_modules/@lit/reactive-element/decorators/query.js
-function F(e, t) {
+function P(e, t) {
return (n, r, i) => {
let a = (t) => t.renderRoot?.querySelector(e) ?? null;
if (t) {
@@ -578,29 +578,29 @@ function F(e, t) {
}
};
})();
- return Ie(n, r, { get() {
+ return Le(n, r, { get() {
let n = e.call(this);
return n === void 0 && (n = a(this), (n !== null || this.hasUpdated) && t.call(this, n)), n;
} });
}
- return Ie(n, r, { get() {
+ return Le(n, r, { get() {
return a(this);
} });
};
}
//#endregion
//#region node_modules/lit-html/directive.js
-var Le = {
+var Re = {
ATTRIBUTE: 1,
CHILD: 2,
PROPERTY: 3,
BOOLEAN_ATTRIBUTE: 4,
EVENT: 5,
ELEMENT: 6
-}, Re = (e) => (...t) => ({
+}, ze = (e) => (...t) => ({
_$litDirective$: e,
values: t
-}), ze = class {
+}), Be = class {
constructor(e) {}
get _$AU() {
return this._$AM._$AU;
@@ -614,9 +614,9 @@ var Le = {
update(e, t) {
return this.render(...t);
}
-}, Be = "important", Ve = " !" + Be, I = Re(class extends ze {
+}, Ve = "important", He = " !" + Ve, F = ze(class extends Be {
constructor(e) {
- if (super(e), e.type !== Le.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.");
+ if (super(e), e.type !== Re.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.");
}
render(e) {
return Object.keys(e).reduce((t, n) => {
@@ -632,13 +632,13 @@ var Le = {
let r = t[e];
if (r != null) {
this.ft.add(e);
- let t = typeof r == "string" && r.endsWith(Ve);
- e.includes("-") || t ? n.setProperty(e, t ? r.slice(0, -11) : r, t ? Be : "") : n[e] = r;
+ let t = typeof r == "string" && r.endsWith(He);
+ e.includes("-") || t ? n.setProperty(e, t ? r.slice(0, -11) : r, t ? Ve : "") : n[e] = r;
}
}
- return T;
+ return w;
}
-}), He = "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", Ue = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z", We = "M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z", Ge = "M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8,9H16V19H8V9M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z", Ke = "M18.5,4L19.66,8.35L18.7,8.61C18.25,7.74 17.79,6.87 17.26,6.43C16.73,6 16.11,6 15.5,6H13V16.5C13,17 13,17.5 13.33,17.75C13.67,18 14.33,18 15,18V19H9V18C9.67,18 10.33,18 10.67,17.75C11,17.5 11,17 11,16.5V6H8.5C7.89,6 7.27,6 6.74,6.43C6.21,6.87 5.75,7.74 5.3,8.61L4.34,8.35L5.5,4H18.5Z", qe = "M19 8C20.11 8 21 8.9 21 10V16.76C21.61 17.31 22 18.11 22 19C22 20.66 20.66 22 19 22C17.34 22 16 20.66 16 19C16 18.11 16.39 17.31 17 16.76V10C17 8.9 17.9 8 19 8M19 9C18.45 9 18 9.45 18 10V11H20V10C20 9.45 19.55 9 19 9M12 5.69L7 10.19V18H14.1L14 19L14.1 20H5V12H2L12 3L16.4 6.96C15.89 7.4 15.5 7.97 15.25 8.61L12 5.69Z", Je = "M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z", Ye = "M15 16L11 20H21V16H15M12.06 7.19L3 16.25V20H6.75L15.81 10.94L12.06 7.19M5.92 18H5V17.08L12.06 10L13 10.94L5.92 18M18.71 8.04C19.1 7.65 19.1 7 18.71 6.63L16.37 4.29C16.17 4.09 15.92 4 15.66 4C15.41 4 15.15 4.1 14.96 4.29L13.13 6.12L16.88 9.87L18.71 8.04Z", Xe = "M15 13V5A3 3 0 0 0 9 5V13A5 5 0 1 0 15 13M12 4A1 1 0 0 1 13 5V8H11V5A1 1 0 0 1 12 4Z", Ze = "M8 13C6.14 13 4.59 14.28 4.14 16H2V18H4.14C4.59 19.72 6.14 21 8 21S11.41 19.72 11.86 18H22V16H11.86C11.41 14.28 9.86 13 8 13M8 19C6.9 19 6 18.1 6 17C6 15.9 6.9 15 8 15S10 15.9 10 17C10 18.1 9.1 19 8 19M19.86 6C19.41 4.28 17.86 3 16 3S12.59 4.28 12.14 6H2V8H12.14C12.59 9.72 14.14 11 16 11S19.41 9.72 19.86 8H22V6H19.86M16 9C14.9 9 14 8.1 14 7C14 5.9 14.9 5 16 5S18 5.9 18 7C18 8.1 17.1 9 16 9Z", Qe = "M12.74,5.47C15.1,6.5 16.35,9.03 15.92,11.46C17.19,12.56 18,14.19 18,16V16.17C18.31,16.06 18.65,16 19,16A3,3 0 0,1 22,19A3,3 0 0,1 19,22H6A4,4 0 0,1 2,18A4,4 0 0,1 6,14H6.27C5,12.45 4.6,10.24 5.5,8.26C6.72,5.5 9.97,4.24 12.74,5.47M11.93,7.3C10.16,6.5 8.09,7.31 7.31,9.07C6.85,10.09 6.93,11.22 7.41,12.13C8.5,10.83 10.16,10 12,10C12.7,10 13.38,10.12 14,10.34C13.94,9.06 13.18,7.86 11.93,7.3M13.55,3.64C13,3.4 12.45,3.23 11.88,3.12L14.37,1.82L15.27,4.71C14.76,4.29 14.19,3.93 13.55,3.64M6.09,4.44C5.6,4.79 5.17,5.19 4.8,5.63L4.91,2.82L7.87,3.5C7.25,3.71 6.65,4.03 6.09,4.44M18,9.71C17.91,9.12 17.78,8.55 17.59,8L19.97,9.5L17.92,11.73C18.03,11.08 18.05,10.4 18,9.71M3.04,11.3C3.11,11.9 3.24,12.47 3.43,13L1.06,11.5L3.1,9.28C3,9.93 2.97,10.61 3.04,11.3M19,18H16V16A4,4 0 0,0 12,12A4,4 0 0,0 8,16H6A2,2 0 0,0 4,18A2,2 0 0,0 6,20H19A1,1 0 0,0 20,19A1,1 0 0,0 19,18Z", $e = o`
+}), Ue = "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", We = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z", Ge = "M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z", Ke = "M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8,9H16V19H8V9M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z", qe = "M18.5,4L19.66,8.35L18.7,8.61C18.25,7.74 17.79,6.87 17.26,6.43C16.73,6 16.11,6 15.5,6H13V16.5C13,17 13,17.5 13.33,17.75C13.67,18 14.33,18 15,18V19H9V18C9.67,18 10.33,18 10.67,17.75C11,17.5 11,17 11,16.5V6H8.5C7.89,6 7.27,6 6.74,6.43C6.21,6.87 5.75,7.74 5.3,8.61L4.34,8.35L5.5,4H18.5Z", Je = "M19 8C20.11 8 21 8.9 21 10V16.76C21.61 17.31 22 18.11 22 19C22 20.66 20.66 22 19 22C17.34 22 16 20.66 16 19C16 18.11 16.39 17.31 17 16.76V10C17 8.9 17.9 8 19 8M19 9C18.45 9 18 9.45 18 10V11H20V10C20 9.45 19.55 9 19 9M12 5.69L7 10.19V18H14.1L14 19L14.1 20H5V12H2L12 3L16.4 6.96C15.89 7.4 15.5 7.97 15.25 8.61L12 5.69Z", Ye = "M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z", Xe = "M15 16L11 20H21V16H15M12.06 7.19L3 16.25V20H6.75L15.81 10.94L12.06 7.19M5.92 18H5V17.08L12.06 10L13 10.94L5.92 18M18.71 8.04C19.1 7.65 19.1 7 18.71 6.63L16.37 4.29C16.17 4.09 15.92 4 15.66 4C15.41 4 15.15 4.1 14.96 4.29L13.13 6.12L16.88 9.87L18.71 8.04Z", Ze = "M15 13V5A3 3 0 0 0 9 5V13A5 5 0 1 0 15 13M12 4A1 1 0 0 1 13 5V8H11V5A1 1 0 0 1 12 4Z", Qe = "M8 13C6.14 13 4.59 14.28 4.14 16H2V18H4.14C4.59 19.72 6.14 21 8 21S11.41 19.72 11.86 18H22V16H11.86C11.41 14.28 9.86 13 8 13M8 19C6.9 19 6 18.1 6 17C6 15.9 6.9 15 8 15S10 15.9 10 17C10 18.1 9.1 19 8 19M19.86 6C19.41 4.28 17.86 3 16 3S12.59 4.28 12.14 6H2V8H12.14C12.59 9.72 14.14 11 16 11S19.41 9.72 19.86 8H22V6H19.86M16 9C14.9 9 14 8.1 14 7C14 5.9 14.9 5 16 5S18 5.9 18 7C18 8.1 17.1 9 16 9Z", $e = "M12.74,5.47C15.1,6.5 16.35,9.03 15.92,11.46C17.19,12.56 18,14.19 18,16V16.17C18.31,16.06 18.65,16 19,16A3,3 0 0,1 22,19A3,3 0 0,1 19,22H6A4,4 0 0,1 2,18A4,4 0 0,1 6,14H6.27C5,12.45 4.6,10.24 5.5,8.26C6.72,5.5 9.97,4.24 12.74,5.47M11.93,7.3C10.16,6.5 8.09,7.31 7.31,9.07C6.85,10.09 6.93,11.22 7.41,12.13C8.5,10.83 10.16,10 12,10C12.7,10 13.38,10.12 14,10.34C13.94,9.06 13.18,7.86 11.93,7.3M13.55,3.64C13,3.4 12.45,3.23 11.88,3.12L14.37,1.82L15.27,4.71C14.76,4.29 14.19,3.93 13.55,3.64M6.09,4.44C5.6,4.79 5.17,5.19 4.8,5.63L4.91,2.82L7.87,3.5C7.25,3.71 6.65,4.03 6.09,4.44M18,9.71C17.91,9.12 17.78,8.55 17.59,8L19.97,9.5L17.92,11.73C18.03,11.08 18.05,10.4 18,9.71M3.04,11.3C3.11,11.9 3.24,12.47 3.43,13L1.06,11.5L3.1,9.28C3,9.93 2.97,10.61 3.04,11.3M19,18H16V16A4,4 0 0,0 12,12A4,4 0 0,0 8,16H6A2,2 0 0,0 4,18A2,2 0 0,0 6,20H19A1,1 0 0,0 20,19A1,1 0 0,0 19,18Z", et = o`
:host {
--odx-blue: var(--primary-color, #03a9f4);
--odx-blue-strong: #0086c5;
@@ -1890,11 +1890,13 @@ var Le = {
background: color-mix(in srgb, var(--odx-blue) 8%, var(--odx-surface));
}
- .widget-choice svg {
- width: 22px;
- height: 22px;
- fill: var(--odx-blue-strong);
- }
+ .widget-choice svg,
+ .widget-choice ha-icon {
+ width: 22px;
+ height: 22px;
+ color: var(--odx-blue-strong);
+ fill: currentColor;
+ }
.widget-choice strong,
.widget-choice span {
@@ -2205,9 +2207,9 @@ var Le = {
transition-duration: 0.01ms !important;
}
}
-`, L = "https://opendisplay.org/firmware/toolbox/index.html";
-`${L}`, `${L}`, `${L}`, `${L}`;
-var et = (e) => e <= 1.6 ? {
+`, I = "https://opendisplay.org/firmware/toolbox/index.html";
+`${I}`, `${I}`, `${I}`, `${I}`;
+var tt = (e) => e <= 1.6 ? {
landscape: {
columns: 1,
rows: 1
@@ -2261,7 +2263,7 @@ var et = (e) => e <= 1.6 ? {
columns: 3,
rows: 3
}
-}, R = (e, t, n, r, i, a, o) => ({
+}, L = (e, t, n, r, i, a, o) => ({
id: `custom-${e}`,
manufacturer: "Custom",
family: "Custom",
@@ -2272,34 +2274,34 @@ var et = (e) => e <= 1.6 ? {
nativeOrientation: r >= i ? "landscape" : "portrait",
palettes: a === "bw" ? ["bw"] : ["bw", a],
defaultPalette: a,
- grid: et(n),
- source: L,
+ grid: tt(n),
+ source: I,
connectorPins: o,
toolboxId: e
-}), tt = [
- R("ep154-200x200", "1.54″ Monochrome · 200×200", 1.54, 200, 200, "bw", [24]),
- R("ep154-152x152", "1.54″ B/W/R · 152×152", 1.54, 152, 152, "bwr", [24]),
- R("ep154yr-200x200", "1.54″ B/W/R/Y · 200×200", 1.54, 200, 200, "bwry", [24]),
- R("ep213-122x250", "2.13″ Monochrome · 122×250", 2.13, 122, 250, "bw", [24]),
- R("ep213r-122x250", "2.13″ B/W/R/Y · 122×250", 2.13, 122, 250, "bwry", [24]),
- R("ep266yr-184x360", "2.66″ B/W/R/Y · 184×360", 2.66, 184, 360, "bwry", [24]),
- R("ep29-128x296", "2.9″ Flexible monochrome · 128×296", 2.9, 128, 296, "bw", [24]),
- R("ep29r-128x296", "2.9″ B/W/R/Y · 128×296", 2.9, 128, 296, "bwry", [24]),
- R("ep29yr-168x384", "2.9″ B/W/R/Y · 168×384", 2.9, 168, 384, "bwry", [24]),
- R("ep35yr-184x384", "3.5″ B/W/R/Y · 184×384", 3.5, 184, 384, "bwry", [24]),
- R("ep397-800x480", "3.97″ Monochrome · 800×480", 3.97, 800, 480, "bw", [24]),
- R("ep397-800x480-4gray", "3.97″ 4-level grayscale · 800×480", 3.97, 800, 480, "gray4", [24]),
- R("ep397yr-800x480", "3.97″ B/W/R/Y · 800×480", 3.97, 800, 480, "bwry", [24]),
- R("ep42-400x300", "4.2″ Monochrome · 400×300", 4.2, 400, 300, "bw", [24]),
- R("ep42yr-400x300", "4.2″ B/W/R/Y · 400×300", 4.2, 400, 300, "bwry", [24]),
- R("ep426-800x480", "4.26″ Monochrome · 800×480", 4.26, 800, 480, "bw", [24]),
- R("ep426-800x480-4g", "4.26″ 4-level grayscale · 800×480", 4.26, 800, 480, "gray4", [24]),
- R("ep583-648x480", "5.83″ Monochrome · 648×480", 5.83, 648, 480, "bw", [24]),
- R("ep75-800x480", "7.5″ Monochrome · 800×480", 7.5, 800, 480, "bw", [24]),
- R("ep75-800x480-4gray", "7.5″ 4-level grayscale · 800×480", 7.5, 800, 480, "gray4", [24]),
- R("ep73-spectra-800x480", "7.3″ Spectra 6 · 800×480", 7.3, 800, 480, "spectra6", [50]),
- R("ep75-bwry-800x480", "7.5″ B/W/R/Y · 800×480", 7.5, 800, 480, "bwry", [24])
-], z = {
+}), nt = [
+ L("ep154-200x200", "1.54″ Monochrome · 200×200", 1.54, 200, 200, "bw", [24]),
+ L("ep154-152x152", "1.54″ B/W/R · 152×152", 1.54, 152, 152, "bwr", [24]),
+ L("ep154yr-200x200", "1.54″ B/W/R/Y · 200×200", 1.54, 200, 200, "bwry", [24]),
+ L("ep213-122x250", "2.13″ Monochrome · 122×250", 2.13, 122, 250, "bw", [24]),
+ L("ep213r-122x250", "2.13″ B/W/R/Y · 122×250", 2.13, 122, 250, "bwry", [24]),
+ L("ep266yr-184x360", "2.66″ B/W/R/Y · 184×360", 2.66, 184, 360, "bwry", [24]),
+ L("ep29-128x296", "2.9″ Flexible monochrome · 128×296", 2.9, 128, 296, "bw", [24]),
+ L("ep29r-128x296", "2.9″ B/W/R/Y · 128×296", 2.9, 128, 296, "bwry", [24]),
+ L("ep29yr-168x384", "2.9″ B/W/R/Y · 168×384", 2.9, 168, 384, "bwry", [24]),
+ L("ep35yr-184x384", "3.5″ B/W/R/Y · 184×384", 3.5, 184, 384, "bwry", [24]),
+ L("ep397-800x480", "3.97″ Monochrome · 800×480", 3.97, 800, 480, "bw", [24]),
+ L("ep397-800x480-4gray", "3.97″ 4-level grayscale · 800×480", 3.97, 800, 480, "gray4", [24]),
+ L("ep397yr-800x480", "3.97″ B/W/R/Y · 800×480", 3.97, 800, 480, "bwry", [24]),
+ L("ep42-400x300", "4.2″ Monochrome · 400×300", 4.2, 400, 300, "bw", [24]),
+ L("ep42yr-400x300", "4.2″ B/W/R/Y · 400×300", 4.2, 400, 300, "bwry", [24]),
+ L("ep426-800x480", "4.26″ Monochrome · 800×480", 4.26, 800, 480, "bw", [24]),
+ L("ep426-800x480-4g", "4.26″ 4-level grayscale · 800×480", 4.26, 800, 480, "gray4", [24]),
+ L("ep583-648x480", "5.83″ Monochrome · 648×480", 5.83, 648, 480, "bw", [24]),
+ L("ep75-800x480", "7.5″ Monochrome · 800×480", 7.5, 800, 480, "bw", [24]),
+ L("ep75-800x480-4gray", "7.5″ 4-level grayscale · 800×480", 7.5, 800, 480, "gray4", [24]),
+ L("ep73-spectra-800x480", "7.3″ Spectra 6 · 800×480", 7.3, 800, 480, "spectra6", [50]),
+ L("ep75-bwry-800x480", "7.5″ B/W/R/Y · 800×480", 7.5, 800, 480, "bwry", [24])
+], R = {
bw: "Monochrome · black / white",
gray4: "Grayscale · 4 levels",
gray16: "Grayscale · 16 levels",
@@ -2307,7 +2309,7 @@ var et = (e) => e <= 1.6 ? {
bwy: "Black / white / yellow",
bwry: "Black / white / red / yellow",
spectra6: "Spectra 6 · B/W/R/Y/B/G"
-}, nt = "https://www.solum-group.com/esl-n-iot/product-lineup/professional-esl/newton-pro", B = "https://opendisplay.org/what-hardware-to-buy.html", V = (e, t, n, r, i, a, o, s, c, l) => ({
+}, rt = "https://www.solum-group.com/esl-n-iot/product-lineup/professional-esl/newton-pro", z = "https://opendisplay.org/what-hardware-to-buy.html", B = (e, t, n, r, i, a, o, s, c, l) => ({
id: e,
manufacturer: t,
family: "OpenDisplay",
@@ -2322,8 +2324,8 @@ var et = (e) => e <= 1.6 ? {
landscape: c,
portrait: l
},
- source: B
-}), H = (e, t, n, r, i, a, o, s = !1) => ({
+ source: z
+}), V = (e, t, n, r, i, a, o, s = !1) => ({
id: e,
manufacturer: "SOLUM",
family: "Newton Pro",
@@ -2339,170 +2341,170 @@ var et = (e) => e <= 1.6 ? {
portrait: o
},
freezer: s,
- source: nt
-}), U = [
- H("solum-newton-pro-1-6-v", "Newton Pro 1.6″ V", 1.6, 200, 200, {
+ source: rt
+}), H = [
+ V("solum-newton-pro-1-6-v", "Newton Pro 1.6″ V", 1.6, 200, 200, {
columns: 1,
rows: 1
}, {
columns: 1,
rows: 1
}),
- H("solum-newton-pro-1-6-h", "Newton Pro 1.6″ H", 1.6, 200, 200, {
+ V("solum-newton-pro-1-6-h", "Newton Pro 1.6″ H", 1.6, 200, 200, {
columns: 1,
rows: 1
}, {
columns: 1,
rows: 1
}),
- H("solum-newton-pro-2-2", "Newton Pro 2.2″", 2.2, 296, 160, {
+ V("solum-newton-pro-2-2", "Newton Pro 2.2″", 2.2, 296, 160, {
columns: 2,
rows: 1
}, {
columns: 1,
rows: 2
}),
- H("solum-newton-pro-2-2-f", "Newton Pro 2.2″ Freezer", 2.2, 296, 160, {
+ V("solum-newton-pro-2-2-f", "Newton Pro 2.2″ Freezer", 2.2, 296, 160, {
columns: 2,
rows: 1
}, {
columns: 1,
rows: 2
}, !0),
- H("solum-newton-pro-2-6", "Newton Pro 2.6″", 2.6, 360, 184, {
+ V("solum-newton-pro-2-6", "Newton Pro 2.6″", 2.6, 360, 184, {
columns: 2,
rows: 1
}, {
columns: 1,
rows: 2
}),
- H("solum-newton-pro-2-6-f", "Newton Pro 2.6″ Freezer", 2.6, 360, 184, {
+ V("solum-newton-pro-2-6-f", "Newton Pro 2.6″ Freezer", 2.6, 360, 184, {
columns: 2,
rows: 1
}, {
columns: 1,
rows: 2
}, !0),
- H("solum-newton-pro-2-7", "Newton Pro 2.7″", 2.7, 300, 200, {
+ V("solum-newton-pro-2-7", "Newton Pro 2.7″", 2.7, 300, 200, {
columns: 2,
rows: 1
}, {
columns: 1,
rows: 2
}),
- H("solum-newton-pro-2-9", "Newton Pro 2.9″", 2.9, 384, 168, {
+ V("solum-newton-pro-2-9", "Newton Pro 2.9″", 2.9, 384, 168, {
columns: 3,
rows: 1
}, {
columns: 1,
rows: 3
}),
- H("solum-newton-pro-2-9-f", "Newton Pro 2.9″ Freezer", 2.9, 384, 168, {
+ V("solum-newton-pro-2-9-f", "Newton Pro 2.9″ Freezer", 2.9, 384, 168, {
columns: 3,
rows: 1
}, {
columns: 1,
rows: 3
}, !0),
- H("solum-newton-pro-3-45", "Newton Pro 3.5″ · 3.45 panel", 3.45, 480, 224, {
+ V("solum-newton-pro-3-45", "Newton Pro 3.5″ · 3.45 panel", 3.45, 480, 224, {
columns: 3,
rows: 1
}, {
columns: 1,
rows: 3
}),
- H("solum-newton-pro-3-45-f", "Newton Pro 3.5″ Freezer · 3.45 panel", 3.45, 480, 224, {
+ V("solum-newton-pro-3-45-f", "Newton Pro 3.5″ Freezer · 3.45 panel", 3.45, 480, 224, {
columns: 3,
rows: 1
}, {
columns: 1,
rows: 3
}, !0),
- H("solum-newton-pro-3-52", "Newton Pro 3.5″ · 3.52 panel", 3.52, 384, 180, {
+ V("solum-newton-pro-3-52", "Newton Pro 3.5″ · 3.52 panel", 3.52, 384, 180, {
columns: 3,
rows: 1
}, {
columns: 1,
rows: 3
}),
- H("solum-newton-pro-3-52-f", "Newton Pro 3.5″ Freezer · 3.52 panel", 3.52, 384, 180, {
+ V("solum-newton-pro-3-52-f", "Newton Pro 3.5″ Freezer · 3.52 panel", 3.52, 384, 180, {
columns: 3,
rows: 1
}, {
columns: 1,
rows: 3
}, !0),
- H("solum-newton-pro-4-2", "Newton Pro 4.2″", 4.2, 400, 300, {
+ V("solum-newton-pro-4-2", "Newton Pro 4.2″", 4.2, 400, 300, {
columns: 2,
rows: 2
}, {
columns: 2,
rows: 2
}),
- H("solum-newton-pro-4-3", "Newton Pro 4.3″", 4.3, 522, 152, {
+ V("solum-newton-pro-4-3", "Newton Pro 4.3″", 4.3, 522, 152, {
columns: 4,
rows: 1
}, {
columns: 1,
rows: 4
}),
- H("solum-newton-pro-4-5", "Newton Pro 4.5″", 4.5, 480, 176, {
+ V("solum-newton-pro-4-5", "Newton Pro 4.5″", 4.5, 480, 176, {
columns: 4,
rows: 1
}, {
columns: 1,
rows: 4
}),
- H("solum-newton-pro-5-8", "Newton Pro 5.8″", 5.8, 792, 272, {
+ V("solum-newton-pro-5-8", "Newton Pro 5.8″", 5.8, 792, 272, {
columns: 4,
rows: 2
}, {
columns: 2,
rows: 4
}),
- H("solum-newton-pro-5-8-f", "Newton Pro 5.8″ Freezer", 5.8, 792, 272, {
+ V("solum-newton-pro-5-8-f", "Newton Pro 5.8″ Freezer", 5.8, 792, 272, {
columns: 4,
rows: 2
}, {
columns: 2,
rows: 4
}, !0),
- H("solum-newton-pro-6-1", "Newton Pro 6.1″", 6.1, 648, 480, {
+ V("solum-newton-pro-6-1", "Newton Pro 6.1″", 6.1, 648, 480, {
columns: 3,
rows: 3
}, {
columns: 3,
rows: 3
}),
- H("solum-newton-pro-7-5", "Newton Pro 7.5″", 7.5, 480, 800, {
+ V("solum-newton-pro-7-5", "Newton Pro 7.5″", 7.5, 480, 800, {
columns: 3,
rows: 3
}, {
columns: 3,
rows: 3
}),
- H("solum-newton-pro-9-7", "Newton Pro 9.7″", 9.7, 672, 960, {
+ V("solum-newton-pro-9-7", "Newton Pro 9.7″", 9.7, 672, 960, {
columns: 4,
rows: 3
}, {
columns: 3,
rows: 4
}),
- H("solum-newton-pro-11-6", "Newton Pro 11.6″", 11.6, 640, 960, {
+ V("solum-newton-pro-11-6", "Newton Pro 11.6″", 11.6, 640, 960, {
columns: 4,
rows: 3
}, {
columns: 3,
rows: 4
}),
- H("solum-newton-pro-12-2", "Newton Pro 12.2″", 12.2, 768, 960, {
+ V("solum-newton-pro-12-2", "Newton Pro 12.2″", 12.2, 768, 960, {
columns: 4,
rows: 4
}, {
columns: 4,
rows: 4
}),
- V("opendisplay-e1001", "Seeed Studio", "reTerminal E1001 7.5″", 7.5, 800, 480, ["bw"], "bw", {
+ B("opendisplay-e1001", "Seeed Studio", "reTerminal E1001 7.5″", 7.5, 800, 480, ["bw"], "bw", {
columns: 3,
rows: 3
}, {
@@ -2530,7 +2532,7 @@ var et = (e) => e <= 1.6 ? {
rows: 3
}
},
- source: B
+ source: z
},
{
id: "opendisplay-e1002",
@@ -2553,7 +2555,7 @@ var et = (e) => e <= 1.6 ? {
rows: 3
}
},
- source: B
+ source: z
},
{
id: "opendisplay-e1003",
@@ -2580,58 +2582,58 @@ var et = (e) => e <= 1.6 ? {
rows: 4
}
},
- source: B
+ source: z
},
- V("opendisplay-e1004", "Seeed Studio", "reTerminal E1004 13.3″ Spectra 6", 13.3, 1200, 1600, ["bw", "spectra6"], "spectra6", {
+ B("opendisplay-e1004", "Seeed Studio", "reTerminal E1004 13.3″ Spectra 6", 13.3, 1200, 1600, ["bw", "spectra6"], "spectra6", {
columns: 4,
rows: 3
}, {
columns: 3,
rows: 4
}),
- V("opendisplay-xiao-7-5", "Seeed Studio", "XIAO 7.5″ ePaper kit", 7.5, 800, 480, ["bw"], "bw", {
+ B("opendisplay-xiao-7-5", "Seeed Studio", "XIAO 7.5″ ePaper kit", 7.5, 800, 480, ["bw"], "bw", {
columns: 3,
rows: 3
}, {
columns: 3,
rows: 3
}),
- V("opendisplay-seeed-7-5-diy", "Seeed Studio", "7.5″ DIY · EE04", 7.5, 800, 480, ["bw"], "bw", {
+ B("opendisplay-seeed-7-5-diy", "Seeed Studio", "7.5″ DIY · EE04", 7.5, 800, 480, ["bw"], "bw", {
columns: 3,
rows: 3
}, {
columns: 3,
rows: 3
}),
- V("opendisplay-4-26-mono-kit", "OpenDisplay", "OpenDisplay 4.26″ Mono Kit", 4.26, 800, 480, ["bw"], "bw", {
+ B("opendisplay-4-26-mono-kit", "OpenDisplay", "OpenDisplay 4.26″ Mono Kit", 4.26, 800, 480, ["bw"], "bw", {
columns: 2,
rows: 2
}, {
columns: 2,
rows: 2
}),
- V("opendisplay-7-3-color-kit", "OpenDisplay", "OpenDisplay 7.3″ Color Kit", 7.3, 800, 480, ["bw", "spectra6"], "spectra6", {
+ B("opendisplay-7-3-color-kit", "OpenDisplay", "OpenDisplay 7.3″ Color Kit", 7.3, 800, 480, ["bw", "spectra6"], "spectra6", {
columns: 3,
rows: 3
}, {
columns: 3,
rows: 3
}),
- V("opendisplay-waveshare-photopainter", "Waveshare", "ESP32-S3 PhotoPainter 7.3″", 7.3, 800, 480, ["bw", "spectra6"], "spectra6", {
+ B("opendisplay-waveshare-photopainter", "Waveshare", "ESP32-S3 PhotoPainter 7.3″", 7.3, 800, 480, ["bw", "spectra6"], "spectra6", {
columns: 3,
rows: 3
}, {
columns: 3,
rows: 3
}),
- ...tt
-], W = (e) => U.find((t) => t.id === e) ?? U.find((e) => e.id === "solum-newton-pro-5-8") ?? U[0], rt = (e, t) => e.nativeOrientation === t ? {
+ ...nt
+], U = (e) => H.find((t) => t.id === e) ?? H.find((e) => e.id === "solum-newton-pro-5-8") ?? H[0], it = (e, t) => e.nativeOrientation === t ? {
width: e.nativeWidth,
height: e.nativeHeight
} : {
width: e.nativeHeight,
height: e.nativeWidth
-}, it = 0, G = (e = globalThis.crypto) => {
+}, at = 0, W = (e = globalThis.crypto) => {
if (typeof e?.randomUUID == "function") return e.randomUUID();
if (e) {
let t = e.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
@@ -2639,34 +2641,34 @@ var et = (e) => e <= 1.6 ? {
let n = Array.from(t, (e) => e.toString(16).padStart(2, "0")).join("");
return `${n.slice(0, 8)}-${n.slice(8, 12)}-${n.slice(12, 16)}-${n.slice(16, 20)}-${n.slice(20)}`;
}
- return it += 1, `odx-${Date.now().toString(36)}-${it.toString(36)}`;
-}, K = (e) => {
+ return at += 1, `odx-${Date.now().toString(36)}-${at.toString(36)}`;
+}, G = (e) => {
let t = [];
for (let n = 1; n <= e.rows; n += 1) for (let r = 1; r <= e.columns; r += 1) t.push({
- id: G(),
+ id: W(),
row: n,
column: r,
rowSpan: 1,
columnSpan: 1
});
return t;
-}, at = (e, t) => t.row >= e.row && t.row < e.row + e.rowSpan && t.column >= e.column && t.column < e.column + e.columnSpan, ot = (e, t, n) => {
+}, ot = (e, t) => t.row >= e.row && t.row < e.row + e.rowSpan && t.column >= e.column && t.column < e.column + e.columnSpan, st = (e, t, n) => {
let r = Math.min(t.row, n.row), i = Math.min(t.column, n.column), a = Math.max(t.row, n.row), o = Math.max(t.column, n.column), s = e.filter((e) => e.row >= r && e.column >= i && e.row + e.rowSpan - 1 <= a && e.column + e.columnSpan - 1 <= o);
if ((a - r + 1) * (o - i + 1) !== s.reduce((e, t) => e + t.rowSpan * t.columnSpan, 0) || s.length === 0) return null;
let c = s.find((e) => e.widget)?.widget, l = new Set(s.map((e) => e.id));
return [...e.filter((e) => !l.has(e.id)), {
- id: G(),
+ id: W(),
row: r,
column: i,
rowSpan: a - r + 1,
columnSpan: o - i + 1,
widget: c
}];
-}, st = (e, t) => {
+}, ct = (e, t) => {
let n = e.find((e) => e.id === t);
if (!n || n.rowSpan === 1 && n.columnSpan === 1 && !n.label) return e;
if (n.rowSpan === 1 && n.columnSpan === 1) return e.map((e) => e.id === t ? {
- id: G(),
+ id: W(),
row: e.row,
column: e.column,
rowSpan: 1,
@@ -2674,14 +2676,14 @@ var et = (e) => e <= 1.6 ? {
} : e);
let r = [];
for (let e = n.row; e < n.row + n.rowSpan; e += 1) for (let t = n.column; t < n.column + n.columnSpan; t += 1) r.push({
- id: G(),
+ id: W(),
row: e,
column: t,
rowSpan: 1,
columnSpan: 1
});
return [...e.filter((e) => e.id !== t), ...r];
-}, ct = (e, t, n, r) => t.columns === n.rows && t.rows === n.columns ? e.map((e) => r === "clockwise" ? {
+}, lt = (e, t, n, r) => t.columns === n.rows && t.rows === n.columns ? e.map((e) => r === "clockwise" ? {
...e,
row: e.column,
column: t.rows - e.row - e.rowSpan + 2,
@@ -2693,38 +2695,39 @@ var et = (e) => e <= 1.6 ? {
column: e.row,
rowSpan: e.columnSpan,
columnSpan: e.rowSpan
-}) : K(n), lt = (e, t) => ({ ...e.grid[t] }), ut = "solum-newton-pro-5-8", dt = (e = "Kitchen display") => {
- let t = W(ut), n = "landscape", r = lt(t, n), i = (/* @__PURE__ */ new Date()).toISOString();
+}) : G(n), ut = (e, t) => ({ ...e.grid[t] }), dt = "solum-newton-pro-5-8", ft = (e = "Kitchen display", t = "en") => {
+ let n = U(dt), r = "landscape", i = ut(n, r), a = (/* @__PURE__ */ new Date()).toISOString();
return {
- id: G(),
+ id: W(),
schemaVersion: 1,
name: e,
status: "draft",
- displayId: t.id,
- orientation: n,
- palette: t.defaultPalette,
- width: t.nativeWidth,
- height: t.nativeHeight,
- grid: r,
- regions: K(r),
- createdAt: i,
- updatedAt: i
+ language: t,
+ displayId: n.id,
+ orientation: r,
+ palette: n.defaultPalette,
+ width: n.nativeWidth,
+ height: n.nativeHeight,
+ grid: i,
+ regions: G(i),
+ createdAt: a,
+ updatedAt: a
};
-}, q = (e, t) => String(e[t] ?? ""), ft = (e, t) => Number(e[t] ?? 0), J = (e, t = "") => w`
+}, K = (e, t) => String(e[t] ?? ""), pt = (e, t) => Number(e[t] ?? 0), q = (e, t = "") => C`
-`, Y = (e) => w`
+`, J = (e) => C`
-`, pt = [
+`, mt = [
{
id: "calendar",
- version: 1,
+ version: "0.5.0",
name: "Calendar",
description: "An agenda from one or more calendar entities.",
- icon: He,
+ icon: Ue,
styles: o`
.event-list {
min-height: 0;
@@ -2836,15 +2839,15 @@ var et = (e) => e <= 1.6 ? {
"Family lunch"
]
].slice(0, n);
- return w`
+ return C`
- ${J(He)}
- ${q(e, "title")}
- ${ft(e, "days")} days
+ ${q(Ue)}
+ ${K(e, "title")}
+ ${pt(e, "days")} days
- ${r.map(([e, t, n]) => w`
+ ${r.map(([e, t, n]) => C`
${e}
${t}
@@ -2858,10 +2861,10 @@ var et = (e) => e <= 1.6 ? {
},
{
id: "entity-state",
- version: 1,
+ version: "0.5.0",
name: "Entity State",
description: "A prominent value from a single entity.",
- icon: qe,
+ icon: Je,
styles: o`
.entity-widget {
align-items: flex-start;
@@ -2941,20 +2944,20 @@ var et = (e) => e <= 1.6 ? {
type: "toggle"
}
],
- render: (e) => w`
+ render: (e) => C`
- ${J(Xe, "Entity state")}
- ${q(e, "title") || q(e, "entity") || "Choose an entity"}
+ ${q(Ze, "Entity state")}
+ ${K(e, "title") || K(e, "entity") || "Choose an entity"}
Live data
`
},
{
id: "text",
- version: 1,
+ version: "0.5.0",
name: "Text",
description: "A simple message or heading.",
- icon: Ke,
+ icon: qe,
styles: o`
.note-widget {
justify-content: center;
@@ -3034,19 +3037,19 @@ var et = (e) => e <= 1.6 ? {
]
}
],
- render: (e) => w`
-
-
${q(e, "title")}
-
${q(e, "text")}
+ render: (e) => C`
+
+ ${K(e, "title")}
+ ${K(e, "text")}
`
},
{
id: "weather",
- version: 4,
+ version: "0.5.0",
name: "Weather",
description: "Current conditions and a daily Home Assistant forecast.",
- icon: Qe,
+ icon: $e,
styles: o`
.weather-widget-placeholder {
align-items: center;
@@ -3101,15 +3104,41 @@ var et = (e) => e <= 1.6 ? {
selector: { boolean: {} }
}
],
- render: (e) => w`
+ render: (e) => C`
- ${J(Qe, "Weather")}
+ ${q($e, "Weather")}
Weather
- ${q(e, "weather") || "Choose a weather entity"}
+ ${K(e, "weather") || "Choose a weather entity"}
`
}
-], mt = (e) => pt.find((t) => t.id === e), ht = pt.map((e) => e.styles), gt = o`
+], Y = (e) => mt.find((t) => t.id === e), ht = (e) => {
+ let t = Y(e.id);
+ return t ? {
+ ...t,
+ version: e.version,
+ name: e.name,
+ description: e.description,
+ defaults: e.defaults,
+ options: e.fields
+ } : {
+ id: e.id,
+ version: e.version,
+ name: e.name,
+ description: e.description,
+ icon: e.icon,
+ defaults: e.defaults,
+ options: e.fields,
+ styles: o``,
+ render: () => C`
+
+
+ ${e.name}
+ Loading exact preview…
+
+ `
+ };
+}, gt = mt.map((e) => e.styles), _t = o`
.widget {
width: 100%;
height: 100%;
@@ -3172,24 +3201,24 @@ var Z = (e, t) => {
if (typeof n.code == "string" && n.code) return `${t} (${n.code})`;
}
return t;
-}, _t = (e) => {
+}, vt = (e) => {
let t = (/* @__PURE__ */ new Date()).toISOString();
return {
...structuredClone(e),
- id: G(),
+ id: W(),
name: `${e.name} copy`,
createdAt: t,
updatedAt: t,
regions: e.regions.map((e) => ({
...structuredClone(e),
- id: G()
+ id: W()
}))
};
}, Q = (e) => {
let t = e + 1, n = "";
for (; t > 0;) --t, n = String.fromCharCode(65 + t % 26) + n, t = Math.floor(t / 26);
return n;
-}, $ = class extends N {
+}, $ = class extends M {
constructor(...e) {
super(...e), this.store = {
schemaVersion: 1,
@@ -3199,9 +3228,9 @@ var Z = (e, t) => {
}
static {
this.styles = [
- $e,
- gt,
- ...ht
+ et,
+ _t,
+ ...gt
];
}
firstUpdated() {
@@ -3223,7 +3252,7 @@ var Z = (e, t) => {
return this.layoutDraft ?? this.project;
}
get canvasDisplay() {
- return W(this.canvasProject.displayId);
+ return U(this.canvasProject.displayId);
}
get canvasPixels() {
return {
@@ -3232,21 +3261,14 @@ var Z = (e, t) => {
};
}
displayName(e) {
- return e.displayId === "custom" ? "Custom display" : W(e.displayId).name;
+ return e.displayId === "custom" ? "Custom display" : U(e.displayId).name;
}
get selectedRegion() {
return this.project.regions.find((e) => e.id === this.selectedRegionId);
}
widgetDefinition(e) {
- let t = mt(e), n = this.widgetMetadata.find((t) => t.id === e);
- return !t || !n ? t : {
- ...t,
- version: n.version,
- name: n.name,
- description: n.description,
- defaults: n.defaults,
- options: n.fields
- };
+ let t = Y(e), n = this.widgetMetadata.find((t) => t.id === e);
+ return n ? ht(n) : t;
}
updatePreviewScale() {
if (!this.previewBoundary || !this.screenFit || !this.screenBezel) return;
@@ -3360,7 +3382,7 @@ var Z = (e, t) => {
}
async addProject() {
try {
- let e = dt(`Untitled display ${this.store.projects.length + 1}`), t = (await this.hass.callWS({
+ let e = ft(`Untitled display ${this.store.projects.length + 1}`, this.hass.language), t = (await this.hass.callWS({
type: "opendisplay_studio/create_project",
project: e
})).project;
@@ -3374,7 +3396,7 @@ var Z = (e, t) => {
}
}
async duplicateProject() {
- let e = _t(this.project), t = (await this.hass.callWS({
+ let e = vt(this.project), t = (await this.hass.callWS({
type: "opendisplay_studio/create_project",
project: e
})).project;
@@ -3413,10 +3435,10 @@ var Z = (e, t) => {
})), this.renameDialog?.close(), this.showToast("Name updated"));
}
applyDisplayProfile(e, t) {
- let n = W(e), r = lt(n, this.canvasProject.orientation), i = r.columns === this.canvasProject.grid.columns && r.rows === this.canvasProject.grid.rows, a = this.canvasProject.regions.flatMap((e) => e.widget ? [e.widget] : []), o = i ? this.canvasProject.regions : K(r).map((e, t) => ({
+ let n = U(e), r = ut(n, this.canvasProject.orientation), i = r.columns === this.canvasProject.grid.columns && r.rows === this.canvasProject.grid.rows, a = this.canvasProject.regions.flatMap((e) => e.widget ? [e.widget] : []), o = i ? this.canvasProject.regions : G(r).map((e, t) => ({
...e,
widget: a[t]
- })), s = rt(n, this.canvasProject.orientation);
+ })), s = it(n, this.canvasProject.orientation);
this.updateLayoutDraft((i) => ({
...i,
displayId: e,
@@ -3452,10 +3474,10 @@ var Z = (e, t) => {
let t = this.canvasProject.displayId === "custom" ? {
columns: this.canvasProject.grid.rows,
rows: this.canvasProject.grid.columns
- } : lt(this.canvasDisplay, e), n = e === "portrait" ? "clockwise" : "counterclockwise", r = ct(this.canvasProject.regions, this.canvasProject.grid, t, n), i = this.canvasProject.displayId === "custom" ? {
+ } : ut(this.canvasDisplay, e), n = e === "portrait" ? "clockwise" : "counterclockwise", r = lt(this.canvasProject.regions, this.canvasProject.grid, t, n), i = this.canvasProject.displayId === "custom" ? {
width: this.canvasProject.height,
height: this.canvasProject.width
- } : rt(this.canvasDisplay, e);
+ } : it(this.canvasDisplay, e);
this.updateLayoutDraft((n) => ({
...n,
orientation: e,
@@ -3475,7 +3497,7 @@ var Z = (e, t) => {
let n = Math.max(1, Math.min(24, Number(t.currentTarget.value))), r = {
...this.canvasProject.grid,
[e]: n
- }, i = this.canvasProject.regions.flatMap((e) => e.widget ? [e.widget] : []), a = K(r).map((e, t) => ({
+ }, i = this.canvasProject.regions.flatMap((e) => e.widget ? [e.widget] : []), a = G(r).map((e, t) => ({
...e,
widget: i[t]
}));
@@ -3494,7 +3516,7 @@ var Z = (e, t) => {
});
}
selectMergeCell(e) {
- let t = this.canvasProject.regions.find((t) => at(t, e));
+ let t = this.canvasProject.regions.find((t) => ot(t, e));
if (t && (t.label || t.rowSpan > 1 || t.columnSpan > 1)) return;
if (!this.mergeAnchor) {
this.mergeAnchor = e, this.mergeHover = e;
@@ -3504,7 +3526,7 @@ var Z = (e, t) => {
this.mergeAnchor = void 0, this.mergeHover = void 0, this.showToast("Remove the existing region before drawing across it");
return;
}
- let n = ot(this.canvasProject.regions, this.mergeAnchor, e);
+ let n = st(this.canvasProject.regions, this.mergeAnchor, e);
if (!n) {
this.mergeAnchor = void 0, this.mergeHover = void 0, this.showToast("The selected rectangle crosses an existing merged region");
return;
@@ -3524,7 +3546,7 @@ var Z = (e, t) => {
let t = this.canvasProject.regions.find((t) => t.id === e);
!t || t.rowSpan === 1 && t.columnSpan === 1 && !t.label || (this.updateLayoutDraft((t) => ({
...t,
- regions: st(t.regions, e)
+ regions: ct(t.regions, e)
})), this.selectedRegionId = "", this.mergeAnchor = void 0, this.mergeHover = void 0, this.showToast("Region removed"));
}
assignWidget(e) {
@@ -3567,7 +3589,7 @@ var Z = (e, t) => {
}));
}
renderProjectRail() {
- return w`
+ return C`
Displays + New
@@ -3576,40 +3598,40 @@ var Z = (e, t) => {
width: e.width,
height: e.height
};
- return w`
+ return C`
this.selectProject(e.id)}>
- ${e.grid.columns}×${e.grid.rows}
- ${e.name} ${this.displayName(e)} · ${e.status === "ready" ? "Ready" : "Draft"} ${e.status === "ready" ? w`media-source://opendisplay_studio/${e.id}` : E}
+ ${e.grid.columns}×${e.grid.rows}
+ ${e.name} ${this.displayName(e)} · ${e.status === "ready" ? "Ready" : "Draft"} ${e.status === "ready" ? C`media-source://opendisplay_studio/${e.id}` : T}
`;
})}
- ${J(Ge)} Delete
+ ${q(Ke)} Delete
`;
}
renderToolbar() {
let e = this.canvasProject, t = this.canvasDisplay;
- return w`
+ return C`
Device model
- ${U.filter((e) => e.family === "Newton Pro").map((e) => w`
+ ${H.filter((e) => e.family === "Newton Pro").map((e) => C`
${e.name} · ${e.nativeWidth}×${e.nativeHeight}${e.freezer ? " · mono" : ""}
`)}
- ${U.filter((e) => e.family === "OpenDisplay" && e.manufacturer === "Seeed Studio").map((e) => w`
+ ${H.filter((e) => e.family === "OpenDisplay" && e.manufacturer === "Seeed Studio").map((e) => C`
${e.name} · ${e.nativeWidth}×${e.nativeHeight}
`)}
- ${U.filter((e) => e.family === "OpenDisplay" && e.manufacturer !== "Seeed Studio").map((e) => w`
+ ${H.filter((e) => e.family === "OpenDisplay" && e.manufacturer !== "Seeed Studio").map((e) => C`
${e.name} · ${e.nativeWidth}×${e.nativeHeight}
`)}
@@ -3618,7 +3640,7 @@ var Z = (e, t) => {
- ${e.displayId === "custom" ? w`
+ ${e.displayId === "custom" ? C`
Width
this.changeCustomSize("width", e)} />
@@ -3627,11 +3649,11 @@ var Z = (e, t) => {
Height
this.changeCustomSize("height", e)} />
- ` : E}
+ ` : T}
Palette
- ${(e.displayId === "custom" ? Object.keys(z) : t.palettes).map((e) => w`${z[e]} `)}
+ ${(e.displayId === "custom" ? Object.keys(R) : t.palettes).map((e) => C`${R[e]} `)}
@@ -3651,23 +3673,23 @@ var Z = (e, t) => {
width: this.project.width,
height: this.project.height
};
- return w`
+ return C`
`;
}
renderScreenRegion(e) {
let t = e.widget ? this.widgetDefinition(e.widget.type) : void 0, n = e.columnSpan === 1 || e.rowSpan === 1, r = this.editorMode === "layout", i = !r && !!(this.previewImageUrl || this.previewError), a = !!e.label || e.rowSpan > 1 || e.columnSpan > 1, o = this.canvasProject.regions.filter((e) => e.label || e.rowSpan > 1 || e.columnSpan > 1).sort((e, t) => e.row - t.row || e.column - t.column), s = a ? e.label ?? Q(o.findIndex((t) => t.id === e.id)) : `${e.column}.${e.row}`;
- return w`
+ return C`
{
r && this.splitSelectedRegion(e.id);
}}
>
- ${i ? E : r ? a ? w`${s} ${e.columnSpan}×${e.rowSpan} region
` : E : t && e.widget ? t.render(e.widget.config, {
+ ${i ? T : r ? a ? C`${s} ${e.columnSpan}×${e.rowSpan} region
` : T : t && e.widget ? t.render(e.widget.config, {
compact: n,
palette: this.project.palette
- }) : w`Add widget ${e.columnSpan}×${e.rowSpan} region
`}
+ }) : C`Add widget ${e.columnSpan}×${e.rowSpan} region
`}
`;
}
renderMergeLayer() {
- if (this.editorMode !== "layout") return w``;
+ if (this.editorMode !== "layout") return C``;
let e = Array.from({ length: this.canvasProject.grid.columns * this.canvasProject.grid.rows }, (e, t) => ({
row: Math.floor(t / this.canvasProject.grid.columns) + 1,
column: t % this.canvasProject.grid.columns + 1
})), t = this.mergeHover ?? this.mergeAnchor, n = !!(this.mergeAnchor && t && this.selectionContainsComposedRegion(this.mergeAnchor, t));
- return w`
+ return C`
{
this.mergeHover = void 0;
}}>
${e.map((e) => {
- let r = this.canvasProject.regions.find((t) => at(t, e)), i = !!(r && (r.label || r.rowSpan > 1 || r.columnSpan > 1)), a = !!(this.mergeAnchor && t && e.row >= Math.min(this.mergeAnchor.row, t.row) && e.row <= Math.max(this.mergeAnchor.row, t.row) && e.column >= Math.min(this.mergeAnchor.column, t.column) && e.column <= Math.max(this.mergeAnchor.column, t.column));
- return w`
+ let r = this.canvasProject.regions.find((t) => ot(t, e)), i = !!(r && (r.label || r.rowSpan > 1 || r.columnSpan > 1)), a = !!(this.mergeAnchor && t && e.row >= Math.min(this.mergeAnchor.row, t.row) && e.row <= Math.max(this.mergeAnchor.row, t.row) && e.column >= Math.min(this.mergeAnchor.column, t.column) && e.column <= Math.max(this.mergeAnchor.column, t.column));
+ return C`
{
@dblclick=${(e) => {
e.preventDefault(), e.stopPropagation(), i && r && this.splitSelectedRegion(r.id);
}}
- >${i ? E : `${e.column}.${e.row}`}
+ >${i ? T : `${e.column}.${e.row}`}
`;
})}
@@ -3720,7 +3742,7 @@ var Z = (e, t) => {
width: e.width,
height: e.height
};
- return w`
+ return C`
${e.displayId === "custom" ? "CUSTOM DISPLAY" : `${t.manufacturer} · ${t.diagonal}″`} ${n.width} × ${n.height} px
@@ -3731,24 +3753,24 @@ var Z = (e, t) => {
id="display-screen"
class="display-screen ${this.editorMode === "widgets" && (this.previewImageUrl || this.previewError) ? "live-preview" : ""}"
data-palette=${e.palette}
- style=${I({
+ style=${F({
"--grid-columns": String(e.grid.columns),
"--grid-rows": String(e.grid.rows),
width: `${n.width}px`,
height: `${n.height}px`
})}
>
- ${this.editorMode === "widgets" && (this.previewImageUrl || this.previewError) ? w`
- ${this.previewImageUrl ? w`
` : w`
${this.previewError} `}
+ ${this.editorMode === "widgets" && (this.previewImageUrl || this.previewError) ? C`
+ ${this.previewImageUrl ? C`
` : C`
${this.previewError} `}
${e.regions.map((e) => this.renderScreenRegion(e))}
- ` : w`
+ ` : C`
${e.regions.map((e) => this.renderScreenRegion(e))}
${this.renderMergeLayer()}
`}
@@ -3756,14 +3778,14 @@ var Z = (e, t) => {
- ${this.editorMode === "layout" ? this.mergeAnchor ? w`
First corner selected. Move across the grid and click the opposite corner.
` : w`
Draw a region: Click two opposite corners. Double-click a region to remove it.
` : w`
Live preview: ${this.previewError ? this.previewError : this.previewLoading ? "Refreshing current Home Assistant data…" : this.previewTimings ? `Exact Renderer preview in ${this.previewTimings.pipeline.toFixed(1)} ms (${this.previewTimings.renderer.toFixed(1)} ms render). Select a region to configure it.` : "Select a region to configure its content."}
`}
+ ${this.editorMode === "layout" ? this.mergeAnchor ? C`
First corner selected. Move across the grid and click the opposite corner.
` : C`
Draw a region: Click two opposite corners. Double-click a region to remove it.
` : C`
Live preview: ${this.previewError ? this.previewError : this.previewLoading ? "Refreshing current Home Assistant data…" : this.previewTimings ? `Exact Renderer preview in ${this.previewTimings.pipeline.toFixed(1)} ms (${this.previewTimings.renderer.toFixed(1)} ms render). Select a region to configure it.` : "Select a region to configure its content."}
`}
`;
}
renderOption(e) {
let t = this.selectedRegion?.widget, n = t?.config[e.key] ?? (t ? this.widgetDefinition(t.type)?.defaults[e.key] : void 0), r = e.selector ?? (e.type === "calendar" ? { entity: { filter: { domain: "calendar" } } } : e.type === "entities" ? { entity: { multiple: !0 } } : e.type === "entity" ? { entity: {} } : void 0);
- return r ? w`
+ return r ? C`
{
}]}
@value-changed=${(t) => this.updateWidgetValue(e, t.detail.value[e.key])}
>
- ` : e.type === "toggle" ? w`
+ ` : e.type === "toggle" ? C`
${e.label} this.updateWidgetOption(e, t)} />
- ` : e.type === "select" ? w`
+ ` : e.type === "select" ? C`
${e.label}
this.updateWidgetOption(e, t)}>
- ${e.options?.map((e) => w`${e.label} `)}
+ ${e.options?.map((e) => C`${e.label} `)}
- ` : e.type === "text" && e.multiline ? w`
+ ` : e.type === "text" && e.multiline ? C`
${e.label}
- ` : w`
+ ` : C`
${e.label}
- this.updateWidgetOption(e, t)} />
+ this.updateWidgetOption(e, t)} />
`;
}
@@ -3814,21 +3836,22 @@ var Z = (e, t) => {
}
renderInspector() {
let e = this.selectedRegion;
- if (!e) return w`
+ if (!e) return C`
Region settings Select a region Choose a region on the display to assign a widget and configure its data.
`;
let t = e.widget ? this.widgetDefinition(e.widget.type) : void 0;
- return w`
+ return C`
Region settings R${e.row}:C${e.column} · ${e.columnSpan}×${e.rowSpan}
- ${pt.map((e) => w`
+ ${this.widgetMetadata.map((e) => C`
this.assignWidget(e.id)}>
- ${J(e.icon)}${e.name} ${e.description}
+ ${Y(e.id) ? q(Y(e.id).icon) : C` `}
+ ${e.name} ${e.description}
`)}
- ${t ? w`${t.options.map((e) => this.renderOption(e))}
${Y(Ge)} Remove widget
` : w`Choose a widget Each widget brings its own data source and configuration fields.
`}
+ ${t ? C`${t.options.map((e) => this.renderOption(e))}
${J(Ke)} Remove widget
` : C`Choose a widget Each widget brings its own data source and configuration fields.
`}
`;
}
@@ -3837,7 +3860,7 @@ var Z = (e, t) => {
width: e.width,
height: e.height
};
- return w`
+ return C`
Step 1 · Device & layout
Prepare the canvas
@@ -3848,6 +3871,24 @@ var Z = (e, t) => {
Grid ${e.grid.columns} × ${e.grid.rows}
Regions ${e.regions.length}
+ {
+ let t = e.detail.value.language;
+ t && this.updateLayoutDraft((e) => ({
+ ...e,
+ language: t
+ }));
+ }}
+ >
+ The selected language is used by live preview, Media Source, and the physical display.
Click the first corner of a new region.
Move across the grid and click the opposite corner.
@@ -3857,7 +3898,7 @@ var Z = (e, t) => {
`;
}
renderRenameDialog() {
- return w`
+ return C`
Rename display Use a name that describes where this display will be installed.
Display name {
@@ -3870,12 +3911,12 @@ var Z = (e, t) => {
`;
}
renderWelcome() {
- return w`
+ return C`
@@ -3889,7 +3930,7 @@ var Z = (e, t) => {
Design an e-paper screen that fits the device.
Choose a verified display, compose its native-pixel layout, then add widgets and export the exact screen as PNG or JPG.
- ${Y(Je)} Create your first display
+ ${J(Ye)} Create your first display
1 Select hardware Model, palette and orientation
@@ -3911,11 +3952,11 @@ var Z = (e, t) => {
- ${this.toastMessage ? w`
${this.toastMessage}
` : E}
+ ${this.toastMessage ? C`
${this.toastMessage}
` : T}
`;
}
render() {
- return this.loading ? w`
Loading OpenDisplay Studio…
` : this.loadError ? w`
${this.loadError} Retry
` : this.store.projects.length === 0 ? this.renderWelcome() : w`
+ return this.loading ? C`
Loading OpenDisplay Studio…
` : this.loadError ? C`
${this.loadError} Retry
` : this.store.projects.length === 0 ? this.renderWelcome() : C`
- ${this.editorMode === "layout" ? w`Cancel ${Y(Ue)} Apply layout ` : w`
- ${Y(Ye)} Rename
- ${Y(We)} Duplicate
+ ${this.editorMode === "layout" ? C`Cancel ${J(We)} Apply layout ` : C`
+ ${J(Xe)} Rename
+ ${J(Ge)} Duplicate
this.setProjectStatus(this.project.status === "ready" ? "draft" : "ready")}>${this.project.status === "ready" ? "Move to Draft" : "Mark Ready"}
`}
@@ -3942,10 +3983,10 @@ var Z = (e, t) => {
${this.renderRenameDialog()}
- ${this.toastMessage ? w`
${this.toastMessage}
` : E}
+ ${this.toastMessage ? C`
${this.toastMessage}
` : T}
`;
}
};
-X([Fe({ attribute: !1 })], $.prototype, "hass", void 0), X([P()], $.prototype, "store", void 0), X([P()], $.prototype, "selectedRegionId", void 0), X([P()], $.prototype, "mergeAnchor", void 0), X([P()], $.prototype, "mergeHover", void 0), X([P()], $.prototype, "toastMessage", void 0), X([P()], $.prototype, "loading", void 0), X([P()], $.prototype, "saving", void 0), X([P()], $.prototype, "loadError", void 0), X([P()], $.prototype, "renameDraft", void 0), X([P()], $.prototype, "editorMode", void 0), X([P()], $.prototype, "layoutDraft", void 0), X([P()], $.prototype, "widgetMetadata", void 0), X([P()], $.prototype, "previewImageUrl", void 0), X([P()], $.prototype, "previewLoading", void 0), X([P()], $.prototype, "previewError", void 0), X([P()], $.prototype, "previewTimings", void 0), X([F(".preview-boundary")], $.prototype, "previewBoundary", void 0), X([F(".screen-fit")], $.prototype, "screenFit", void 0), X([F(".screen-bezel")], $.prototype, "screenBezel", void 0), X([F("#rename-dialog")], $.prototype, "renameDialog", void 0), $ = X([Me("opendisplay-studio-panel")], $);
+X([Ie({ attribute: !1 })], $.prototype, "hass", void 0), X([N()], $.prototype, "store", void 0), X([N()], $.prototype, "selectedRegionId", void 0), X([N()], $.prototype, "mergeAnchor", void 0), X([N()], $.prototype, "mergeHover", void 0), X([N()], $.prototype, "toastMessage", void 0), X([N()], $.prototype, "loading", void 0), X([N()], $.prototype, "saving", void 0), X([N()], $.prototype, "loadError", void 0), X([N()], $.prototype, "renameDraft", void 0), X([N()], $.prototype, "editorMode", void 0), X([N()], $.prototype, "layoutDraft", void 0), X([N()], $.prototype, "widgetMetadata", void 0), X([N()], $.prototype, "previewImageUrl", void 0), X([N()], $.prototype, "previewLoading", void 0), X([N()], $.prototype, "previewError", void 0), X([N()], $.prototype, "previewTimings", void 0), X([P(".preview-boundary")], $.prototype, "previewBoundary", void 0), X([P(".screen-fit")], $.prototype, "screenFit", void 0), X([P(".screen-bezel")], $.prototype, "screenBezel", void 0), X([P("#rename-dialog")], $.prototype, "renameDialog", void 0), $ = X([Ne("opendisplay-studio-panel")], $);
//#endregion
export { $ as OdxApp };
diff --git a/custom_components/opendisplay_studio/manifest.json b/custom_components/opendisplay_studio/manifest.json
index 81c36c4..764a88f 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.6"
+ "version": "0.5.0"
}
diff --git a/custom_components/opendisplay_studio/projects.py b/custom_components/opendisplay_studio/projects.py
index 40a74ae..af210d1 100644
--- a/custom_components/opendisplay_studio/projects.py
+++ b/custom_components/opendisplay_studio/projects.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+import re
from collections.abc import Iterable
from copy import deepcopy
from datetime import UTC, datetime
@@ -13,7 +14,7 @@
from homeassistant.helpers.storage import Store
from .const import STORAGE_KEY, STORAGE_VERSION
-from .widgets import WIDGET_DEFINITIONS
+from .widgets import DEFAULT_REGISTRY, WidgetRegistry
type Project = dict[str, Any]
@@ -22,7 +23,10 @@
MAX_NAME_LENGTH = 100
MAX_TEXT_LENGTH = 4_096
PALETTES = {"bw", "gray4", "gray16", "bwr", "bwy", "bwry", "spectra6"}
-WIDGET_TYPES = {item["id"] for item in WIDGET_DEFINITIONS}
+LANGUAGE_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
+WIDGET_VERSION_PATTERN = re.compile(
+ r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
+)
class ProjectValidationError(ValueError):
@@ -54,20 +58,42 @@ def _string(value: object, name: str, maximum: int = MAX_TEXT_LENGTH) -> str:
return result
-def _validate_widget(value: object) -> dict[str, Any] | None:
+def _widget_version(value: object) -> str:
+ """Validate one semantic widget version."""
+ if isinstance(value, str) and WIDGET_VERSION_PATTERN.fullmatch(value):
+ return value
+ raise ProjectValidationError("widget.version must be a semantic version")
+
+
+def _validate_widget(value: object, registry: WidgetRegistry) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, dict):
raise ProjectValidationError("widget must be an object")
widget_type = _string(value.get("type"), "widget.type", 50)
- if widget_type not in WIDGET_TYPES:
+ if widget_type not in registry.widget_types:
message = f"Unsupported widget type: {widget_type}"
raise _invalid(message)
config = value.get("config", {})
if not isinstance(config, dict):
raise ProjectValidationError("widget.config must be an object")
+ widget_definition = registry.definition(widget_type)
+ allowed_keys = set(widget_definition["defaults"])
+ allowed_keys.update(
+ field["key"]
+ for field in widget_definition["fields"]
+ if isinstance(field.get("key"), str)
+ )
+ unknown_keys = set(config) - allowed_keys
+ if unknown_keys:
+ message = (
+ f"{widget_type} widget has unknown configuration: "
+ f"{', '.join(sorted(str(key) for key in unknown_keys))}"
+ )
+ raise ProjectValidationError(message)
+ merged_config = {**widget_definition["defaults"], **config}
normalized_config: dict[str, str | int | float | bool | list[str]] = {}
- for key, item in config.items():
+ for key, item in merged_config.items():
if not isinstance(key, str) or not key or len(key) > 64:
raise ProjectValidationError("widget configuration key is invalid")
scalar = isinstance(item, str | int | float | bool) and not isinstance(
@@ -86,51 +112,47 @@ def _validate_widget(value: object) -> dict[str, Any] | None:
normalized_config[key] = [str(value) for value in item]
else:
normalized_config[key] = item
- if widget_type == "entity-state":
- layout = normalized_config.get("layout", "large")
- normalized_config = {
- "entity": str(normalized_config.get("entity", "")),
- "title": str(normalized_config.get("title", ""))[:MAX_NAME_LENGTH],
- "layout": str(layout) if layout in {"large", "compact"} else "large",
- "showIcon": bool(normalized_config.get("showIcon", True)),
- "showName": bool(normalized_config.get("showName", True)),
- "showUnit": bool(normalized_config.get("showUnit", True)),
- }
- elif widget_type == "calendar":
- days = normalized_config.get("days", 7)
- if isinstance(days, bool) or not isinstance(days, int) or not 1 <= days <= 31:
- raise ProjectValidationError("calendar days must be between 1 and 31")
- normalized_config = {
- "calendar": str(normalized_config.get("calendar", "")),
- "title": str(normalized_config.get("title", "Upcoming"))[:MAX_NAME_LENGTH],
- "days": days,
- "showLocation": bool(normalized_config.get("showLocation", True)),
- "showDescription": bool(normalized_config.get("showDescription", False)),
- "time24h": bool(normalized_config.get("time24h", True)),
- }
- elif widget_type == "weather":
- normalized_config = {
- "weather": str(normalized_config.get("weather", "")),
- "showHumidity": bool(normalized_config.get("showHumidity", True)),
- "showFeelsLike": bool(normalized_config.get("showFeelsLike", True)),
- "showForecast": bool(normalized_config.get("showForecast", True)),
- }
- elif widget_type == "text":
- align = normalized_config.get("align", "left")
- normalized_config = {
- "title": str(normalized_config.get("title", ""))[:MAX_NAME_LENGTH],
- "text": str(normalized_config.get("text", "Text"))[:MAX_TEXT_LENGTH],
- "align": align if align in {"left", "center", "right"} else "left",
- }
+ for field in widget_definition["fields"]:
+ key = field.get("key")
+ if not isinstance(key, str) or key not in normalized_config:
+ continue
+ item = normalized_config[key]
+ field_type = field.get("type")
+ selector = field.get("selector")
+ if field_type == "toggle" or (
+ isinstance(selector, dict) and "boolean" in selector
+ ):
+ normalized_config[key] = bool(item)
+ elif field_type == "number":
+ if isinstance(item, bool) or not isinstance(item, int | float):
+ message = f"{widget_type} {key} must be numeric"
+ raise ProjectValidationError(message)
+ minimum = field.get("min")
+ maximum = field.get("max")
+ if isinstance(minimum, int | float) and item < minimum:
+ message = f"{widget_type} {key} is too small"
+ raise ProjectValidationError(message)
+ if isinstance(maximum, int | float) and item > maximum:
+ message = f"{widget_type} {key} is too large"
+ raise ProjectValidationError(message)
+ elif field_type == "select":
+ allowed = {
+ option.get("value")
+ for option in field.get("options", [])
+ if isinstance(option, dict)
+ }
+ if item not in allowed:
+ normalized_config[key] = widget_definition["defaults"].get(key, "")
return {
"type": widget_type,
- "version": _integer(value.get("version", 1), "widget.version", 1, 100),
+ "version": _widget_version(value.get("version", widget_definition["version"])),
"config": normalized_config,
}
-def validate_project(value: object) -> Project:
+def validate_project(value: object, registry: WidgetRegistry | None = None) -> Project:
"""Validate and normalize a complete project payload."""
+ registry = registry or DEFAULT_REGISTRY
if not isinstance(value, dict):
raise ProjectValidationError("project must be an object")
name = _string(value.get("name"), "name", MAX_NAME_LENGTH)
@@ -145,6 +167,11 @@ def validate_project(value: object) -> Project:
status = value.get("status", "draft")
if status not in {"draft", "ready"}:
raise ProjectValidationError("status is invalid")
+ language = value.get("language", "system")
+ if not isinstance(language, str) or (
+ language != "system" and LANGUAGE_PATTERN.fullmatch(language) is None
+ ):
+ raise ProjectValidationError("language is invalid")
grid = value.get("grid")
if not isinstance(grid, dict):
raise ProjectValidationError("grid must be an object")
@@ -191,7 +218,7 @@ def validate_project(value: object) -> Project:
label = region.get("label")
if isinstance(label, str) and label.strip():
normalized_region["label"] = label.strip()[:MAX_NAME_LENGTH]
- widget = _validate_widget(region.get("widget"))
+ widget = _validate_widget(region.get("widget"), registry)
if widget is not None:
normalized_region["widget"] = widget
normalized_regions.append(normalized_region)
@@ -201,23 +228,53 @@ def validate_project(value: object) -> Project:
widget = region.get("widget")
if widget is None:
continue
+ widget_definition = registry.definition(widget["type"])
config = widget["config"]
- if widget["type"] == "entity-state" and not config["entity"]:
- raise ProjectValidationError(
- "Ready Entity State widgets require an entity"
+ for requirement in widget_definition["dataRequirements"]:
+ if requirement.get("optional"):
+ continue
+ config_key = requirement.get("configKey")
+ field: dict[str, Any] = next(
+ (
+ item
+ for item in widget_definition["fields"]
+ if item.get("key") == config_key
+ ),
+ {},
+ )
+ label = str(field.get("label", config_key or "data source")).lower()
+ source_value = (
+ config.get(config_key) if isinstance(config_key, str) else None
)
- if widget["type"] == "calendar" and not str(config["calendar"]).startswith(
- "calendar."
- ):
- raise ProjectValidationError(
- "Ready Calendar widgets require a calendar entity"
+ sources = (
+ source_value
+ if isinstance(source_value, list)
+ else [source_value]
+ if source_value
+ else []
)
- if widget["type"] == "weather" and not str(config["weather"]).startswith(
- "weather."
- ):
- raise ProjectValidationError(
- "Ready Weather widgets require a weather entity"
+ expected_domain = (
+ "calendar" if field.get("type") == "calendar" else None
)
+ selector = field.get("selector")
+ if isinstance(selector, dict):
+ entity_selector = selector.get("entity")
+ if isinstance(entity_selector, dict):
+ selector_filter = entity_selector.get("filter")
+ if isinstance(selector_filter, dict):
+ domain = selector_filter.get("domain")
+ if isinstance(domain, str):
+ expected_domain = domain
+ if sources and (
+ expected_domain is None
+ or all(
+ str(source).startswith(f"{expected_domain}.")
+ for source in sources
+ )
+ ):
+ continue
+ message = f"Ready {widget['type']} widgets require {label}"
+ raise ProjectValidationError(message)
display_id = value.get("displayId", "custom")
if not isinstance(display_id, str) or len(display_id) > 100:
@@ -226,6 +283,7 @@ def validate_project(value: object) -> Project:
"schemaVersion": 1,
"name": name,
"status": status,
+ "language": language,
"displayId": display_id or "custom",
"width": width,
"height": height,
@@ -239,13 +297,23 @@ def validate_project(value: object) -> Project:
class ProjectStore:
"""Home Assistant-native authoritative project storage."""
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(
+ self, hass: HomeAssistant, registry: WidgetRegistry | None = None
+ ) -> None:
"""Initialize the versioned Store."""
self._store: Store[dict[str, Any]] = Store(
hass, STORAGE_VERSION, STORAGE_KEY, atomic_writes=True
)
self._projects: dict[str, Project] = {}
self._lock = asyncio.Lock()
+ self._default_language = hass.config.language
+ self._registry = registry or DEFAULT_REGISTRY
+
+ def _resolve_language(self, project: Project) -> Project:
+ """Pin legacy projects to the Home Assistant system language."""
+ if project["language"] == "system":
+ project["language"] = self._default_language
+ return project
async def async_load(self) -> None:
"""Load projects and discard malformed legacy entries safely."""
@@ -255,7 +323,9 @@ async def async_load(self) -> None:
return
for raw in raw_projects:
try:
- normalized = validate_project(raw)
+ normalized = self._resolve_language(
+ validate_project(raw, self._registry)
+ )
project_id = _string(raw.get("id"), "id", 64)
except ProjectValidationError:
continue
@@ -285,7 +355,7 @@ def get(self, project_id: str) -> Project | None:
async def async_create(self, value: object) -> Project:
"""Create a project with server-owned identity and timestamps."""
- normalized = validate_project(value)
+ normalized = self._resolve_language(validate_project(value, self._registry))
async with self._lock:
if len(self._projects) >= MAX_PROJECTS:
message = f"At most {MAX_PROJECTS} projects are allowed"
@@ -299,7 +369,7 @@ async def async_create(self, value: object) -> Project:
async def async_update(self, project_id: str, value: object) -> Project:
"""Replace editable fields while preserving stable identity."""
- normalized = validate_project(value)
+ normalized = self._resolve_language(validate_project(value, self._registry))
async with self._lock:
existing = self._projects.get(project_id)
if existing is None:
diff --git a/custom_components/opendisplay_studio/websocket.py b/custom_components/opendisplay_studio/websocket.py
index 5f9d8bb..f7d5869 100644
--- a/custom_components/opendisplay_studio/websocket.py
+++ b/custom_components/opendisplay_studio/websocket.py
@@ -17,7 +17,7 @@
validate_project,
)
from .renderer import RendererClient, RendererError
-from .widgets import WIDGET_DEFINITIONS
+from .widgets import DEFAULT_REGISTRY, WidgetRegistry
def _store(hass: HomeAssistant) -> ProjectStore:
@@ -36,6 +36,14 @@ def _renderer_client(hass: HomeAssistant) -> RendererClient:
return cast("RendererClient", client)
+def _widgets(hass: HomeAssistant) -> WidgetRegistry:
+ """Return the discovered package registry used by every render surface."""
+ return cast(
+ "WidgetRegistry",
+ getattr(hass.data[DOMAIN], "widgets", DEFAULT_REGISTRY),
+ )
+
+
def _error(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
@@ -55,7 +63,7 @@ def websocket_bootstrap(
"""Return authoritative projects and widget metadata."""
connection.send_result(
msg["id"],
- {"projects": _store(hass).list(), "widgets": WIDGET_DEFINITIONS},
+ {"projects": _store(hass).list(), "widgets": _widgets(hass).definitions},
)
@@ -141,7 +149,7 @@ async def websocket_compose_preview(
) -> None:
"""Render the same live PNG used by Media Source."""
try:
- project = validate_project(msg["project"])
+ project = validate_project(msg["project"], _widgets(hass))
composed = await async_compose_project(hass, project)
rendered = await _renderer_client(hass).async_render(
html=composed.html,
diff --git a/custom_components/opendisplay_studio/widgets.py b/custom_components/opendisplay_studio/widgets.py
deleted file mode 100644
index 3720e56..0000000
--- a/custom_components/opendisplay_studio/widgets.py
+++ /dev/null
@@ -1,224 +0,0 @@
-"""Built-in widget definitions and Liquid templates."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any, Final
-
-TEMPLATE_DIRECTORY: Final = Path(__file__).with_name("widget_templates")
-
-WIDGET_DEFINITIONS: Final[list[dict[str, Any]]] = [
- {
- "id": "entity-state",
- "version": 1,
- "name": "Entity State",
- "description": "Current state of a Home Assistant entity.",
- "icon": "mdi:gauge",
- "defaults": {
- "entity": "",
- "title": "",
- "layout": "large",
- "showIcon": True,
- "showName": True,
- "showUnit": True,
- },
- "fields": [
- {"key": "entity", "label": "Entity", "type": "entity", "required": True},
- {"key": "title", "label": "Title", "type": "text"},
- {
- "key": "layout",
- "label": "Layout",
- "type": "select",
- "options": [
- {"label": "Large value", "value": "large"},
- {"label": "Compact", "value": "compact"},
- ],
- },
- {"key": "showIcon", "label": "Show icon", "type": "toggle"},
- {"key": "showName", "label": "Show name", "type": "toggle"},
- {"key": "showUnit", "label": "Show unit", "type": "toggle"},
- ],
- "dataRequirements": [
- {
- "key": "entity",
- "provider": "entity_state",
- "configKey": "entity",
- "cardinality": "one",
- "optional": False,
- }
- ],
- },
- {
- "id": "calendar",
- "version": 1,
- "name": "Calendar",
- "description": "Upcoming events from one Home Assistant calendar.",
- "icon": "mdi:calendar",
- "defaults": {
- "calendar": "",
- "title": "Upcoming",
- "days": 7,
- "showLocation": True,
- "showDescription": False,
- "time24h": True,
- },
- "fields": [
- {
- "key": "calendar",
- "label": "Calendar",
- "type": "calendar",
- "required": True,
- },
- {"key": "title", "label": "Title", "type": "text"},
- {"key": "days", "label": "Days", "type": "number", "min": 1, "max": 31},
- {"key": "showLocation", "label": "Show location", "type": "toggle"},
- {"key": "showDescription", "label": "Show description", "type": "toggle"},
- {"key": "time24h", "label": "24-hour time", "type": "toggle"},
- ],
- "dataRequirements": [
- {
- "key": "calendar",
- "provider": "calendar",
- "configKey": "calendar",
- "cardinality": "one",
- "optional": False,
- "rangeConfigKey": "days",
- }
- ],
- },
- {
- "id": "weather",
- "version": 4,
- "name": "Weather",
- "description": "Current conditions and a daily Home Assistant forecast.",
- "icon": "mdi:weather-partly-cloudy",
- "defaults": {
- "weather": "",
- "showHumidity": True,
- "showFeelsLike": True,
- "showForecast": True,
- },
- "fields": [
- {
- "key": "weather",
- "label": "Weather entity",
- "required": True,
- "selector": {
- "entity": {
- "filter": {
- "domain": "weather",
- }
- }
- },
- },
- {
- "key": "showHumidity",
- "label": "Show humidity",
- "selector": {"boolean": {}},
- },
- {
- "key": "showFeelsLike",
- "label": "Show feels like",
- "selector": {"boolean": {}},
- },
- {
- "key": "showForecast",
- "label": "Show forecast",
- "selector": {"boolean": {}},
- },
- ],
- "dataRequirements": [
- {
- "key": "weather",
- "provider": "weather_forecast",
- "configKey": "weather",
- "cardinality": "one",
- "optional": False,
- "forecastType": "daily",
- }
- ],
- },
- {
- "id": "text",
- "version": 1,
- "name": "Text",
- "description": "Static heading and body text.",
- "icon": "mdi:text",
- "defaults": {"title": "", "text": "Text", "align": "left"},
- "fields": [
- {"key": "title", "label": "Title", "type": "text"},
- {"key": "text", "label": "Text", "type": "text", "multiline": True},
- {
- "key": "align",
- "label": "Alignment",
- "type": "select",
- "options": [
- {"label": "Left", "value": "left"},
- {"label": "Center", "value": "center"},
- {"label": "Right", "value": "right"},
- ],
- },
- ],
- "dataRequirements": [],
- },
-]
-
-ENTITY_TEMPLATE: Final = """
-
-
- {% if config.showIcon %}
-
- {% endif %}
- {% if config.showName %}
-
{{ data.entity.displayName }}
- {% endif %}
-
-
- {{ data.entity.state }}
- {% if config.showUnit and data.entity.unit %}{{ data.entity.unit }} {% endif %}
-
-
-
-"""
-
-CALENDAR_TEMPLATE: Final = """
-
-
{{ config.title }}
- {% if data.calendar.events.size > 0 %}
-
- {% for event in data.calendar.events %}
- {{ event.time }}
- {{ event.summary }} {% if config.showLocation and event.location %}{{ event.location }} {% endif %}{% if config.showDescription and event.description %}{{ event.description }} {% endif %}
- {% endfor %}
-
- {% else %}
No upcoming events {% endif %}
-
-"""
-
-TEXT_TEMPLATE: Final = """
-
- {% if config.title %}{{ config.title }} {% endif %}
- {{ config.text }}
-
-"""
-
-WEATHER_TEMPLATE: Final = (TEMPLATE_DIRECTORY / "weather.liquid").read_text(
- encoding="utf-8"
-)
-
-TEMPLATES: Final = {
- "entity-state": ENTITY_TEMPLATE,
- "calendar": CALENDAR_TEMPLATE,
- "weather": WEATHER_TEMPLATE,
- "text": TEXT_TEMPLATE,
-}
-
-
-def definition(widget_type: str) -> dict[str, Any]:
- """Return one built-in definition."""
- return next(item for item in WIDGET_DEFINITIONS if item["id"] == widget_type)
-
-
-def with_defaults(widget_type: str, config: dict[str, Any]) -> dict[str, Any]:
- """Apply definition defaults without trusting missing frontend fields."""
- return {**definition(widget_type)["defaults"], **config}
diff --git a/custom_components/opendisplay_studio/widgets/__init__.py b/custom_components/opendisplay_studio/widgets/__init__.py
new file mode 100644
index 0000000..678b0ce
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/__init__.py
@@ -0,0 +1,277 @@
+"""Discover installed widget packages and expose their runtime contracts."""
+
+from __future__ import annotations
+
+import importlib
+import importlib.util
+import re
+import sys
+from collections.abc import Iterable
+from hashlib import sha256
+from pathlib import Path
+from typing import Any, Final, Protocol, cast
+
+import yaml # type: ignore[import-untyped]
+
+BUILTIN_WIDGET_DIRECTORY: Final = Path(__file__).parent
+WIDGET_ID_PATTERN: Final = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
+VERSION_PATTERN: Final = re.compile(
+ r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
+)
+
+
+class WidgetPackageError(ValueError):
+ """An installed widget package is malformed or unsafe to load."""
+
+
+class DataProvider(Protocol):
+ """Runtime interface implemented by reusable and package-owned providers."""
+
+ name: str
+
+ def new_request(self) -> object:
+ """Create an empty aggregate request."""
+ ...
+
+ def add_request(
+ self,
+ request: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> None:
+ """Add one widget requirement to the aggregate request."""
+ ...
+
+ async def async_resolve(self, hass: Any, request: object, language: str) -> object:
+ """Resolve one aggregate request."""
+ ...
+
+ def values(
+ self,
+ resolved: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> list[Any]:
+ """Map aggregate data back to one widget requirement."""
+ ...
+
+
+def _safe_package_file(directory: Path, value: object, field: str) -> Path:
+ """Resolve a package-local file without permitting path traversal."""
+ if not isinstance(value, str) or not value or Path(value).name != value:
+ message = f"{field} must name a file in the widget package"
+ raise WidgetPackageError(message)
+ path = directory / value
+ if not path.is_file():
+ message = f"Missing widget package file: {value}"
+ raise WidgetPackageError(message)
+ return path
+
+
+def _load_provider(path: Path) -> DataProvider:
+ """Load a trusted package provider through its stable PROVIDER export."""
+ if path.is_relative_to(BUILTIN_WIDGET_DIRECTORY):
+ relative_module = path.relative_to(BUILTIN_WIDGET_DIRECTORY).with_suffix("")
+ module_name = f"{__package__}.{'.'.join(relative_module.parts)}"
+ module = importlib.import_module(module_name)
+ else:
+ digest = sha256(str(path.resolve()).encode()).hexdigest()[:16]
+ module_name = f"opendisplay_studio_widget_provider_{digest}"
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ if spec is None or spec.loader is None:
+ message = f"Could not load provider: {path}"
+ raise WidgetPackageError(message)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ try:
+ spec.loader.exec_module(module)
+ except Exception:
+ sys.modules.pop(module_name, None)
+ raise
+ provider = getattr(module, "PROVIDER", None)
+ if provider is None or not isinstance(getattr(provider, "name", None), str):
+ message = f"Provider {path} does not export PROVIDER"
+ raise WidgetPackageError(message)
+ return cast("DataProvider", provider)
+
+
+def _load_definition(
+ directory: Path,
+) -> tuple[dict[str, Any], str, DataProvider | None]:
+ """Load and validate one widget package directory."""
+ manifest_path = directory / "widget.yml"
+ raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
+ if not isinstance(raw, dict):
+ message = f"Widget manifest must be an object: {manifest_path}"
+ raise WidgetPackageError(message)
+ widget_id = raw.get("id")
+ expected_directory = (
+ widget_id.replace("-", "_") if isinstance(widget_id, str) else ""
+ )
+ if (
+ not isinstance(widget_id, str)
+ or WIDGET_ID_PATTERN.fullmatch(widget_id) is None
+ or expected_directory != directory.name
+ ):
+ message = (
+ "Widget id must be lowercase and match its directory name "
+ f"(hyphens map to underscores): {directory.name}"
+ )
+ raise WidgetPackageError(message)
+ for key in ("name", "description", "icon"):
+ if not isinstance(raw.get(key), str) or not raw[key]:
+ message = f"Widget {widget_id} requires {key}"
+ raise WidgetPackageError(message)
+ version = raw.get("version")
+ if not isinstance(version, str) or VERSION_PATTERN.fullmatch(version) is None:
+ message = f"Widget {widget_id} requires a semantic version"
+ raise WidgetPackageError(message)
+ defaults = raw.get("defaults", {})
+ fields = raw.get("fields", [])
+ requirements = raw.get("dataRequirements", [])
+ if (
+ not isinstance(defaults, dict)
+ or not isinstance(fields, list)
+ or not isinstance(requirements, list)
+ ):
+ message = f"Widget {widget_id} has an invalid contract"
+ raise WidgetPackageError(message)
+ if not all(isinstance(item, dict) for item in fields + requirements):
+ message = f"Widget {widget_id} fields and requirements must be objects"
+ raise WidgetPackageError(message)
+ template_path = _safe_package_file(
+ directory, raw.get("template", "widget.liquid"), "template"
+ )
+ definition = {key: raw[key] for key in ("id", "name", "description", "icon")}
+ definition["version"] = version
+ definition.update(
+ {
+ "defaults": defaults,
+ "fields": fields,
+ "dataRequirements": requirements,
+ }
+ )
+ provider = None
+ if provider_name := raw.get("provider"):
+ provider = _load_provider(
+ _safe_package_file(directory, provider_name, "provider")
+ )
+ return definition, template_path.read_text(encoding="utf-8"), provider
+
+
+class WidgetRegistry:
+ """All installed widget definitions, templates, and data providers."""
+
+ def __init__(self) -> None:
+ """Initialize the reusable provider set."""
+ self._definitions: dict[str, dict[str, Any]] = {}
+ self._templates: dict[str, str] = {}
+ self._providers: dict[tuple[str, str], DataProvider] = {}
+
+ def reload_from_directories(self, roots: Iterable[Path]) -> None:
+ """Atomically replace discovered packages while keeping this registry."""
+ candidate = self.from_directories(roots)
+ definitions, templates, providers = candidate._contents() # noqa: SLF001
+ self._definitions = definitions
+ self._templates = templates
+ self._providers = providers
+
+ def _contents(
+ self,
+ ) -> tuple[
+ dict[str, dict[str, Any]],
+ dict[str, str],
+ dict[tuple[str, str], DataProvider],
+ ]:
+ """Return internal mappings for an atomic same-class replacement."""
+ return self._definitions, self._templates, self._providers
+
+ @classmethod
+ def from_directories(cls, roots: Iterable[Path]) -> WidgetRegistry:
+ """Discover packages; later roots override bundled packages by id."""
+ registry = cls()
+ for root in roots:
+ if not root.is_dir():
+ continue
+ for directory in sorted(root.iterdir()):
+ if not directory.is_dir() or not (directory / "widget.yml").is_file():
+ continue
+ definition, template, provider = _load_definition(directory)
+ widget_id = definition["id"]
+ registry._providers = {
+ key: value
+ for key, value in registry._providers.items()
+ if key[0] != widget_id
+ }
+ registry._definitions[widget_id] = definition
+ registry._templates[widget_id] = template
+ if provider is not None:
+ registry._providers[(widget_id, provider.name)] = provider
+ for widget_definition in registry._definitions.values():
+ for requirement in widget_definition["dataRequirements"]:
+ provider_name = requirement.get("provider")
+ if (widget_definition["id"], provider_name) not in registry._providers:
+ message = (
+ f"Widget {widget_definition['id']} requires unknown "
+ f"provider {provider_name}"
+ )
+ raise WidgetPackageError(message)
+ return registry
+
+ @property
+ def definitions(self) -> list[dict[str, Any]]:
+ """Return definitions sorted by package id."""
+ return [self._definitions[key] for key in sorted(self._definitions)]
+
+ @property
+ def templates(self) -> dict[str, str]:
+ """Return the immutable-by-convention template mapping."""
+ return dict(self._templates)
+
+ @property
+ def widget_types(self) -> set[str]:
+ """Return every installed widget id."""
+ return set(self._definitions)
+
+ def definition(self, widget_type: str) -> dict[str, Any]:
+ """Return one installed widget definition."""
+ try:
+ return self._definitions[widget_type]
+ except KeyError as err:
+ message = f"Unsupported widget type: {widget_type}"
+ raise WidgetPackageError(message) from err
+
+ def template(self, widget_type: str) -> str:
+ """Return one installed Liquid template."""
+ return self._templates[widget_type]
+
+ def provider(self, widget_type: str, name: str) -> DataProvider:
+ """Return a provider scoped to its owning widget package."""
+ try:
+ return self._providers[(widget_type, name)]
+ except KeyError as err:
+ message = f"Widget {widget_type} has no data provider named {name}"
+ raise WidgetPackageError(message) from err
+
+
+DEFAULT_REGISTRY: Final = WidgetRegistry.from_directories([BUILTIN_WIDGET_DIRECTORY])
+WIDGET_DEFINITIONS: Final = DEFAULT_REGISTRY.definitions
+TEMPLATES: Final = DEFAULT_REGISTRY.templates
+
+
+def definition(
+ widget_type: str, registry: WidgetRegistry | None = None
+) -> dict[str, Any]:
+ """Return one installed definition."""
+ return (registry or DEFAULT_REGISTRY).definition(widget_type)
+
+
+def with_defaults(
+ widget_type: str,
+ config: dict[str, Any],
+ registry: WidgetRegistry | None = None,
+) -> dict[str, Any]:
+ """Apply package defaults without trusting missing frontend fields."""
+ return {**definition(widget_type, registry)["defaults"], **config}
diff --git a/custom_components/opendisplay_studio/widgets/calendar/__init__.py b/custom_components/opendisplay_studio/widgets/calendar/__init__.py
new file mode 100644
index 0000000..d7332fd
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/calendar/__init__.py
@@ -0,0 +1 @@
+"""Calendar widget package."""
diff --git a/custom_components/opendisplay_studio/widgets/calendar/provider.py b/custom_components/opendisplay_studio/widgets/calendar/provider.py
new file mode 100644
index 0000000..71a8cf0
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/calendar/provider.py
@@ -0,0 +1,134 @@
+"""Home Assistant data provider owned by the Calendar widget."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import date, datetime, timedelta
+from typing import Any, cast
+
+from homeassistant.components.calendar import DATA_COMPONENT, CalendarEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.util import dt as dt_util
+
+
+async def _async_get_events(
+ hass: HomeAssistant, entity_id: str, days: int
+) -> tuple[str, list[dict[str, str | bool | int]]]:
+ """Fetch and normalize events from one calendar entity."""
+ component = hass.data.get(DATA_COMPONENT)
+ entity = component.get_entity(entity_id) if component is not None else None
+ if not isinstance(entity, CalendarEntity):
+ return entity_id, []
+ start = dt_util.now()
+ events = await entity.async_get_events(hass, start, start + timedelta(days=days))
+ normalized: list[dict[str, str | bool | int]] = []
+ for event in events:
+ event_start = event.start
+ all_day = isinstance(event_start, date) and not isinstance(
+ event_start, datetime
+ )
+ if isinstance(event_start, datetime):
+ local_start = dt_util.as_local(event_start)
+ start_value = local_start.isoformat()
+ time_value = local_start.strftime("%H:%M")
+ date_value = local_start.strftime("%a %d %b")
+ else:
+ start_value = event_start.isoformat()
+ time_value = "All day"
+ date_value = event_start.strftime("%a %d %b")
+ event_date = (
+ event_start.date() if isinstance(event_start, datetime) else event_start
+ )
+ normalized.append(
+ {
+ "summary": event.summary or "Untitled event",
+ "start": start_value,
+ "time": time_value,
+ "date": date_value,
+ "location": event.location or "",
+ "description": event.description or "",
+ "allDay": all_day,
+ "dayOffset": max(0, (event_date - start.date()).days),
+ }
+ )
+ return entity_id, normalized
+
+
+def _calendar_value(
+ events: list[dict[str, str | bool | int]],
+ *,
+ days: int,
+ time_24h: bool,
+) -> dict[str, list[dict[str, str | bool | int]]]:
+ """Apply this widget's display range and time format."""
+ visible_events = [
+ dict(event) for event in events if int(event.get("dayOffset", 0)) < days
+ ]
+ if not time_24h:
+ for event in visible_events:
+ time_value = str(event["time"])
+ if time_value != "All day":
+ hours, minutes = (int(part) for part in time_value.split(":"))
+ suffix = "AM" if hours < 12 else "PM"
+ event["time"] = f"{hours % 12 or 12}:{minutes:02d} {suffix}"
+ return {"events": visible_events}
+
+
+class CalendarDataProvider:
+ """Collect and resolve requirements for the Calendar widget package."""
+
+ name = "calendar"
+
+ def new_request(self) -> dict[str, int]:
+ """Create an empty calendar range mapping."""
+ return {}
+
+ def add_request(
+ self,
+ request: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> None:
+ """Add selected calendars and their maximum requested ranges."""
+ requests = cast("dict[str, int]", request)
+ range_key = str(requirement.get("rangeConfigKey", "days"))
+ days = max(1, min(31, int(config.get(range_key, 7))))
+ for entity_id in sources:
+ requests[entity_id] = max(requests.get(entity_id, 0), days)
+
+ async def async_resolve(
+ self, hass: HomeAssistant, request: object, language: str
+ ) -> object:
+ """Resolve events for every aggregate calendar request."""
+ del language
+ requests = cast("dict[str, int]", request)
+ pairs = await asyncio.gather(
+ *(
+ _async_get_events(hass, entity_id, days)
+ for entity_id, days in requests.items()
+ )
+ )
+ return dict(pairs)
+
+ def values(
+ self,
+ resolved: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> list[Any]:
+ """Map normalized calendar events back to one widget."""
+ calendars = cast("dict[str, list[dict[str, str | bool | int]]]", resolved)
+ range_key = str(requirement.get("rangeConfigKey", "days"))
+ return [
+ _calendar_value(
+ calendars.get(source, []),
+ days=int(config.get(range_key, 7)),
+ time_24h=bool(config.get("time24h", True)),
+ )
+ for source in sources
+ ]
+
+
+PROVIDER = CalendarDataProvider()
diff --git a/custom_components/opendisplay_studio/widgets/calendar/widget.liquid b/custom_components/opendisplay_studio/widgets/calendar/widget.liquid
new file mode 100644
index 0000000..f2e2070
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/calendar/widget.liquid
@@ -0,0 +1,12 @@
+
+
{{ config.title }}
+ {% if data.calendar.events.size > 0 %}
+
+ {% for event in data.calendar.events %}
+ {{ event.time }}
+ {{ event.summary }} {% if config.showLocation and event.location %}{{ event.location }} {% endif %}{% if config.showDescription and event.description %}{{ event.description }} {% endif %}
+ {% endfor %}
+
+ {% else %}
No upcoming events {% endif %}
+
+
diff --git a/custom_components/opendisplay_studio/widgets/calendar/widget.yml b/custom_components/opendisplay_studio/widgets/calendar/widget.yml
new file mode 100644
index 0000000..fede8d7
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/calendar/widget.yml
@@ -0,0 +1,47 @@
+id: calendar
+name: Calendar
+version: "0.5.0"
+description: Upcoming events from one Home Assistant calendar.
+icon: mdi:calendar
+framework: 3.2.0
+template: widget.liquid
+provider: provider.py
+
+defaults:
+ calendar: ""
+ title: Upcoming
+ days: 7
+ showLocation: true
+ showDescription: false
+ time24h: true
+
+fields:
+ - key: calendar
+ label: Calendar
+ type: calendar
+ required: true
+ - key: title
+ label: Title
+ type: text
+ - key: days
+ label: Days
+ type: number
+ min: 1
+ max: 31
+ - key: showLocation
+ label: Show location
+ type: toggle
+ - key: showDescription
+ label: Show description
+ type: toggle
+ - key: time24h
+ label: 24-hour time
+ type: toggle
+
+dataRequirements:
+ - key: calendar
+ provider: calendar
+ configKey: calendar
+ cardinality: one
+ optional: false
+ rangeConfigKey: days
diff --git a/custom_components/opendisplay_studio/widgets/entity_state/__init__.py b/custom_components/opendisplay_studio/widgets/entity_state/__init__.py
new file mode 100644
index 0000000..5b10034
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/entity_state/__init__.py
@@ -0,0 +1 @@
+"""Entity State widget package."""
diff --git a/custom_components/opendisplay_studio/widgets/entity_state/provider.py b/custom_components/opendisplay_studio/widgets/entity_state/provider.py
new file mode 100644
index 0000000..9ea3b47
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/entity_state/provider.py
@@ -0,0 +1,153 @@
+"""Home Assistant data provider owned by the Entity State widget package."""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+from homeassistant.core import HomeAssistant
+
+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",
+ "heat": "M7.95,3L6.53,5.19L7.95,7.4H7.94L5.95,10.5L4.22,9.6L5.64,7.39L4.22,5.19L6.22,2.09L7.95,3M13.95,2.89L12.53,5.1L13.95,7.3L13.94,7.31L11.95,10.4L10.22,9.5L11.64,7.3L10.22,5.1L12.22,2L13.95,2.89M20,2.89L18.56,5.1L20,7.3V7.31L18,10.4L16.25,9.5L17.67,7.3L16.25,5.1L18.25,2L20,2.89M2,22V14A2,2 0 0,1 4,12H20A2,2 0 0,1 22,14V22H20V20H4V22H2M6,14A1,1 0 0,0 5,15V17A1,1 0 0,0 6,18A1,1 0 0,0 7,17V15A1,1 0 0,0 6,14M10,14A1,1 0 0,0 9,15V17A1,1 0 0,0 10,18A1,1 0 0,0 11,17V15A1,1 0 0,0 10,14M14,14A1,1 0 0,0 13,15V17A1,1 0 0,0 14,18A1,1 0 0,0 15,17V15A1,1 0 0,0 14,14M18,14A1,1 0 0,0 17,15V17A1,1 0 0,0 18,18A1,1 0 0,0 19,17V15A1,1 0 0,0 18,14Z",
+ "humidity": "M12,3.25C12,3.25 6,10 6,14C6,17.32 8.69,20 12,20A6,6 0 0,0 18,14C18,10 12,3.25 12,3.25M14.47,9.97L15.53,11.03L9.53,17.03L8.47,15.97M9.75,10A1.25,1.25 0 0,1 11,11.25A1.25,1.25 0 0,1 9.75,12.5A1.25,1.25 0 0,1 8.5,11.25A1.25,1.25 0 0,1 9.75,10M14.25,14.5A1.25,1.25 0 0,1 15.5,15.75A1.25,1.25 0 0,1 14.25,17A1.25,1.25 0 0,1 13,15.75A1.25,1.25 0 0,1 14.25,14.5Z",
+ "illuminance": "M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C21.27,16.39 17,19.5 12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C15.76,17.5 19.17,15.36 20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12Z",
+ "light": "M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z",
+ "power": "M7,2H17L13.5,9H17L10,22V14H7V2M9,4V12H12V14.66L14,11H10.24L13.76,4H9Z",
+ "pressure": "M12,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,4A8,8 0 0,1 20,12C20,14.4 19,16.5 17.3,18C15.9,16.7 14,16 12,16C10,16 8.2,16.7 6.7,18C5,16.5 4,14.4 4,12A8,8 0 0,1 12,4M14,5.89C13.62,5.9 13.26,6.15 13.1,6.54L11.81,9.77L11.71,10C11,10.13 10.41,10.6 10.14,11.26C9.73,12.29 10.23,13.45 11.26,13.86C12.29,14.27 13.45,13.77 13.86,12.74C14.12,12.08 14,11.32 13.57,10.76L13.67,10.5L14.96,7.29L14.97,7.26C15.17,6.75 14.92,6.17 14.41,5.96C14.28,5.91 14.15,5.89 14,5.89M10,6A1,1 0 0,0 9,7A1,1 0 0,0 10,8A1,1 0 0,0 11,7A1,1 0 0,0 10,6M7,9A1,1 0 0,0 6,10A1,1 0 0,0 7,11A1,1 0 0,0 8,10A1,1 0 0,0 7,9M17,9A1,1 0 0,0 16,10A1,1 0 0,0 17,11A1,1 0 0,0 18,10A1,1 0 0,0 17,9Z",
+ "switch": "M17 6H7C3.69 6 1 8.69 1 12S3.69 18 7 18H17C20.31 18 23 15.31 23 12S20.31 6 17 6M17 16H7C4.79 16 3 14.21 3 12S4.79 8 7 8H17C19.21 8 21 9.79 21 12S19.21 16 17 16M17 9C15.34 9 14 10.34 14 12S15.34 15 17 15 20 13.66 20 12 18.66 9 17 9Z",
+ "temperature": "M15 13V5A3 3 0 0 0 9 5V13A5 5 0 1 0 15 13M12 4A1 1 0 0 1 13 5V8H11V5A1 1 0 0 1 12 4Z",
+}
+
+MDI_ICON_KEYS = {
+ "mdi:flash-outline": "power",
+ "mdi:gauge": "pressure",
+ "mdi:lightbulb-outline": "light",
+ "mdi:radiator": "heat",
+ "mdi:thermometer": "temperature",
+ "mdi:toggle-switch-outline": "switch",
+ "mdi:water-percent": "humidity",
+}
+
+
+def _entity_icon_path(entity_id: str, attributes: dict[str, object]) -> str:
+ """Choose a deterministic local icon without external image requests."""
+ configured = attributes.get("icon")
+ if isinstance(configured, str) and configured in MDI_ICON_KEYS:
+ return ICON_PATHS[MDI_ICON_KEYS[configured]]
+ device_class = str(attributes.get("device_class", ""))
+ key = {
+ "current": "power",
+ "energy": "power",
+ "humidity": "humidity",
+ "illuminance": "illuminance",
+ "power": "power",
+ "pressure": "pressure",
+ "temperature": "temperature",
+ "voltage": "power",
+ }.get(device_class)
+ if key is None:
+ key = {
+ "calendar": "calendar",
+ "climate": "heat",
+ "light": "light",
+ "switch": "switch",
+ }.get(entity_id.partition(".")[0], "default")
+ return ICON_PATHS[key]
+
+
+class EntityStateProvider:
+ """Normalize current state-machine values."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the state reader."""
+ self._hass = hass
+
+ def get_many(self, entity_ids: set[str]) -> dict[str, dict[str, str]]:
+ """Return normalized values for every requested entity."""
+ result: dict[str, dict[str, str]] = {}
+ for entity_id in entity_ids:
+ state = self._hass.states.get(entity_id)
+ if state is None:
+ result[entity_id] = {
+ "state": "Unavailable",
+ "unit": "",
+ "name": entity_id,
+ "iconPath": ICON_PATHS["default"],
+ }
+ continue
+ result[entity_id] = {
+ "state": state.state,
+ "unit": str(state.attributes.get("unit_of_measurement", "")),
+ "name": str(state.attributes.get("friendly_name", entity_id)),
+ "iconPath": _entity_icon_path(entity_id, state.attributes),
+ }
+ return result
+
+
+class EntityStateDataProvider:
+ """Generic aggregate provider for entity-state widget requirements."""
+
+ name = "entity_state"
+
+ def new_request(self) -> set[str]:
+ """Create an empty entity ID set."""
+ return set()
+
+ def add_request(
+ self,
+ request: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> None:
+ """Add selected entity IDs to the aggregate request."""
+ del config, requirement
+ cast("set[str]", request).update(sources)
+
+ async def async_resolve(
+ self, hass: HomeAssistant, request: object, language: str
+ ) -> object:
+ """Resolve current states for every aggregate entity ID."""
+ del language
+ return EntityStateProvider(hass).get_many(cast("set[str]", request))
+
+ def values(
+ self,
+ resolved: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> list[Any]:
+ """Map normalized entity states back to one widget."""
+ entities = cast("dict[str, dict[str, str]]", resolved)
+ values = [
+ dict(
+ entities.get(
+ source,
+ {
+ "state": "Unavailable",
+ "unit": "",
+ "name": source,
+ "iconPath": ICON_PATHS["default"],
+ },
+ )
+ )
+ for source in sources
+ ]
+ if not values and requirement.get("cardinality") != "many":
+ values = [
+ {
+ "state": "Unavailable",
+ "unit": "",
+ "name": "Choose an entity",
+ "iconPath": ICON_PATHS["default"],
+ }
+ ]
+ title = str(config.get("title", "")).strip()
+ for value in values:
+ value["displayName"] = title or value["name"]
+ return values
+
+
+PROVIDER = EntityStateDataProvider()
diff --git a/custom_components/opendisplay_studio/widgets/entity_state/widget.liquid b/custom_components/opendisplay_studio/widgets/entity_state/widget.liquid
new file mode 100644
index 0000000..e3a4d33
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/entity_state/widget.liquid
@@ -0,0 +1,15 @@
+
+
+ {% if config.showIcon %}
+
+ {% endif %}
+ {% if config.showName %}
+
{{ data.entity.displayName }}
+ {% endif %}
+
+
+ {{ data.entity.state }}
+ {% if config.showUnit and data.entity.unit %}{{ data.entity.unit }} {% endif %}
+
+
+
diff --git a/custom_components/opendisplay_studio/widgets/entity_state/widget.yml b/custom_components/opendisplay_studio/widgets/entity_state/widget.yml
new file mode 100644
index 0000000..7796e3b
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/entity_state/widget.yml
@@ -0,0 +1,49 @@
+id: entity-state
+name: Entity State
+version: "0.5.0"
+description: Current state of a selected Home Assistant entity.
+icon: mdi:gauge
+framework: 3.2.0
+template: widget.liquid
+provider: provider.py
+
+defaults:
+ entity: ""
+ title: ""
+ layout: large
+ showIcon: true
+ showName: true
+ showUnit: true
+
+fields:
+ - key: entity
+ label: Entity
+ type: entity
+ required: true
+ - key: title
+ label: Title
+ type: text
+ - key: layout
+ label: Layout
+ type: select
+ options:
+ - label: Large value
+ value: large
+ - label: Compact
+ value: compact
+ - key: showIcon
+ label: Show icon
+ type: toggle
+ - key: showName
+ label: Show name
+ type: toggle
+ - key: showUnit
+ label: Show unit
+ type: toggle
+
+dataRequirements:
+ - key: entity
+ provider: entity_state
+ configKey: entity
+ cardinality: one
+ optional: false
diff --git a/custom_components/opendisplay_studio/widgets/text/widget.liquid b/custom_components/opendisplay_studio/widgets/text/widget.liquid
new file mode 100644
index 0000000..e4ce12a
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/text/widget.liquid
@@ -0,0 +1,5 @@
+
+ {% if config.title %}{{ config.title }} {% endif %}
+ {{ config.text }}
+
+
diff --git a/custom_components/opendisplay_studio/widgets/text/widget.yml b/custom_components/opendisplay_studio/widgets/text/widget.yml
new file mode 100644
index 0000000..c6de97e
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/text/widget.yml
@@ -0,0 +1,33 @@
+id: text
+name: Text
+version: "0.5.0"
+description: Static heading and body text.
+icon: mdi:text
+framework: 3.2.0
+template: widget.liquid
+
+defaults:
+ title: ""
+ text: Text
+ align: left
+
+fields:
+ - key: title
+ label: Title
+ type: text
+ - key: text
+ label: Text
+ type: text
+ multiline: true
+ - key: align
+ label: Alignment
+ type: select
+ options:
+ - label: Left
+ value: left
+ - label: Center
+ value: center
+ - label: Right
+ value: right
+
+dataRequirements: []
diff --git a/custom_components/opendisplay_studio/widgets/weather/__init__.py b/custom_components/opendisplay_studio/widgets/weather/__init__.py
new file mode 100644
index 0000000..e6709c7
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/weather/__init__.py
@@ -0,0 +1 @@
+"""Weather widget package."""
diff --git a/custom_components/opendisplay_studio/widgets/weather/provider.py b/custom_components/opendisplay_studio/widgets/weather/provider.py
new file mode 100644
index 0000000..197bb3e
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/weather/provider.py
@@ -0,0 +1,319 @@
+"""Home Assistant data provider owned by the Weather widget package."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from dataclasses import dataclass
+from datetime import date
+from pathlib import Path
+from typing import Any, cast
+
+from babel.core import UnknownLocaleError
+from babel.dates import format_date
+from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
+from homeassistant.components.weather import SERVICE_GET_FORECASTS
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.translation import async_get_translations
+from homeassistant.util import dt as dt_util
+
+LOGGER_NAME = "custom_components.opendisplay_studio"
+LOGGER = logging.getLogger(LOGGER_NAME)
+TRANSLATIONS_DIRECTORY = Path(__file__).with_name("translations")
+WEATHER_ICON_BASE_URL = "https://trmnl.com/images/plugins/weather"
+
+WEATHER_CONDITION_ICONS = {
+ "clear-night": "wi-night-clear.svg",
+ "cloudy": "wi-cloudy.svg",
+ "exceptional": "wi-na.svg",
+ "fog": "wi-fog.svg",
+ "hail": "wi-hail.svg",
+ "lightning": "wi-lightning.svg",
+ "lightning-rainy": "wi-thunderstorm.svg",
+ "partlycloudy": "wi-day-cloudy.svg",
+ "pouring": "wi-rain-wind.svg",
+ "rainy": "wi-rain.svg",
+ "snowy": "wi-snow.svg",
+ "snowy-rainy": "wi-rain-mix.svg",
+ "sunny": "wi-day-sunny.svg",
+ "windy": "wi-strong-wind.svg",
+ "windy-variant": "wi-cloudy-windy.svg",
+}
+
+
+def _translation_file(language: str) -> Path:
+ """Resolve a validated language name inside this widget package."""
+ return TRANSLATIONS_DIRECTORY / f"{language}.json"
+
+
+def _read_translation_file(path: Path) -> dict[str, str]:
+ """Read one flat widget translation resource."""
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ return {}
+ return {
+ str(key): str(label) for key, label in value.items() if isinstance(label, str)
+ }
+
+
+def _load_widget_labels(language: str) -> dict[str, str]:
+ """Load English, base-language, and exact-locale widget labels."""
+ labels = _read_translation_file(_translation_file("en"))
+ candidates = [language.partition("-")[0], language]
+ for candidate in dict.fromkeys(candidates):
+ path = _translation_file(candidate)
+ if candidate != "en" and path.is_file():
+ labels.update(_read_translation_file(path))
+ return labels
+
+
+@dataclass(frozen=True, slots=True)
+class WeatherLocalizer:
+ """Localized Weather vocabulary from the widget and Home Assistant."""
+
+ language: str
+ labels: dict[str, str]
+ condition_labels: dict[str, str]
+
+ @classmethod
+ def english(cls) -> WeatherLocalizer:
+ """Return the package's deterministic standalone fallback."""
+ return cls(language="en", labels=_load_widget_labels("en"), condition_labels={})
+
+ @classmethod
+ async def async_create(cls, hass: HomeAssistant, language: str) -> WeatherLocalizer:
+ """Load widget labels and reuse Home Assistant's Weather translations."""
+ weather_entity, weather_title, labels = await asyncio.gather(
+ async_get_translations(
+ hass, language, "entity_component", integrations={WEATHER_DOMAIN}
+ ),
+ async_get_translations(
+ hass, language, "title", integrations={WEATHER_DOMAIN}
+ ),
+ hass.async_add_executor_job(_load_widget_labels, language),
+ )
+ labels["weather"] = weather_title.get(
+ "component.weather.title", labels["weather"]
+ )
+ attribute_prefix = "component.weather.entity_component._.state_attributes."
+ for key in ("temperature", "apparent_temperature", "humidity"):
+ labels[key] = weather_entity.get(
+ f"{attribute_prefix}{key}.name", labels[key]
+ )
+ state_prefix = "component.weather.entity_component._.state."
+ condition_labels = {
+ key.removeprefix(state_prefix): value
+ for key, value in weather_entity.items()
+ if key.startswith(state_prefix)
+ }
+ return cls(language=language, labels=labels, condition_labels=condition_labels)
+
+ def condition(self, condition: str) -> str:
+ """Return a localized Home Assistant weather condition."""
+ return self.condition_labels.get(
+ condition,
+ condition.replace("-", " ").capitalize()
+ if condition
+ else self.labels["unavailable"],
+ )
+
+
+def _weather_icon(condition: str) -> str:
+ """Map a Home Assistant condition to the widget's weather icon."""
+ filename = WEATHER_CONDITION_ICONS.get(condition, "wi-na.svg")
+ return f"{WEATHER_ICON_BASE_URL}/{filename}"
+
+
+def _placeholder(entity_id: str, localizer: WeatherLocalizer) -> dict[str, Any]:
+ """Return the complete Weather template contract without live data."""
+ return {
+ "entity_id": entity_id,
+ "name": entity_id or localizer.labels["choose_weather_entity"],
+ "condition": "unavailable",
+ "condition_label": localizer.labels["unavailable"],
+ "icon": _weather_icon("exceptional"),
+ "temperature": "—",
+ "temperature_unit": "",
+ "apparent_temperature": None,
+ "humidity": None,
+ "updated_at": "",
+ "forecast": [],
+ "labels": localizer.labels,
+ }
+
+
+def _uv_label(value: object, labels: dict[str, str]) -> str | None:
+ """Convert a numeric UV index into the localized exposure category."""
+ if not isinstance(value, int | float) or isinstance(value, bool):
+ return None
+ if value >= 11:
+ return labels["uv_extreme"]
+ if value >= 8:
+ return labels["uv_very_high"]
+ if value >= 6:
+ return labels["uv_high"]
+ if value >= 3:
+ return labels["uv_moderate"]
+ return labels["uv_low"]
+
+
+def _forecast_date_label(
+ value: object, today: date, localizer: WeatherLocalizer
+) -> str:
+ """Create a compact localized label for a forecast timestamp."""
+ parsed = dt_util.parse_datetime(str(value)) if value else None
+ if parsed is None:
+ return ""
+ local_date = dt_util.as_local(parsed).date()
+ offset = (local_date - today).days
+ if offset == 0:
+ return localizer.labels["today"]
+ if offset == 1:
+ return localizer.labels["tomorrow"]
+ try:
+ return format_date(
+ local_date, format="EEE", locale=localizer.language.replace("-", "_")
+ )
+ except UnknownLocaleError, ValueError:
+ return local_date.strftime("%a")
+
+
+@dataclass(frozen=True, slots=True)
+class WeatherResult:
+ """Resolved values and render language for one compose pass."""
+
+ values: dict[str, dict[str, Any]]
+ localizer: WeatherLocalizer
+
+
+class WeatherDataProvider:
+ """Collect and resolve Weather requirements for the generic composer."""
+
+ name = "weather_forecast"
+
+ def new_request(self) -> set[str]:
+ """Create an empty deduplicated request."""
+ return set()
+
+ def add_request(
+ self,
+ request: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> None:
+ """Add selected weather entity IDs."""
+ del config, requirement
+ cast("set[str]", request).update(sources)
+
+ async def async_resolve(
+ self, hass: HomeAssistant, request: object, language: str
+ ) -> WeatherResult:
+ """Resolve current state, daily forecast, and package translations."""
+ localizer = await WeatherLocalizer.async_create(hass, language)
+ entity_ids = cast("set[str]", request)
+ if not entity_ids:
+ return WeatherResult({}, localizer)
+
+ sorted_entity_ids = sorted(entity_ids)
+ try:
+ response = await 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]] = {}
+
+ for entity_id in sorted_entity_ids:
+ state = hass.states.get(entity_id)
+ attributes = state.attributes if state is not None else {}
+ condition = state.state if state is not None else "unavailable"
+ response_item = forecast_response.get(entity_id, {})
+ raw_forecasts = (
+ response_item.get("forecast", [])
+ if isinstance(response_item, dict)
+ else []
+ )
+ forecasts: list[dict[str, Any]] = []
+ if isinstance(raw_forecasts, list):
+ for item in raw_forecasts:
+ if not isinstance(item, dict):
+ continue
+ forecast_condition = str(item.get("condition") or "exceptional")
+ uv_index = item.get("uv_index")
+ forecasts.append(
+ {
+ "datetime": str(item.get("datetime") or ""),
+ "date_label": _forecast_date_label(
+ item.get("datetime"), today, localizer
+ ),
+ "condition": forecast_condition,
+ "condition_label": localizer.condition(forecast_condition),
+ "icon": _weather_icon(forecast_condition),
+ "temperature": item.get("temperature"),
+ "templow": item.get("templow"),
+ "uv_index": uv_index,
+ "uv_label": _uv_label(uv_index, localizer.labels),
+ "precipitation_probability": item.get(
+ "precipitation_probability"
+ ),
+ }
+ )
+
+ result[entity_id] = {
+ "entity_id": entity_id,
+ "name": str(attributes.get("friendly_name", entity_id)),
+ "condition": condition,
+ "condition_label": localizer.condition(condition),
+ "icon": _weather_icon(condition),
+ "temperature": attributes.get("temperature", "—"),
+ "temperature_unit": str(attributes.get("temperature_unit", "")),
+ "apparent_temperature": attributes.get("apparent_temperature"),
+ "humidity": attributes.get("humidity"),
+ "updated_at": (
+ dt_util.as_local(state.last_updated).strftime("%H:%M")
+ if state is not None
+ else ""
+ ),
+ "forecast": forecasts,
+ "labels": localizer.labels,
+ }
+ return WeatherResult(result, localizer)
+
+ def values(
+ self,
+ resolved: object,
+ sources: list[str],
+ config: dict[str, Any],
+ requirement: dict[str, Any],
+ ) -> list[Any]:
+ """Map resolved entities to the requirement's declared cardinality."""
+ del config
+ weather = cast("WeatherResult", resolved)
+ values = [
+ {
+ **weather.values.get(source, _placeholder(source, weather.localizer)),
+ "labels": weather.localizer.labels,
+ }
+ for source in sources
+ ]
+ if not values and requirement.get("cardinality") != "many":
+ values = [_placeholder("", weather.localizer)]
+ return values
+
+
+PROVIDER = WeatherDataProvider()
diff --git a/custom_components/opendisplay_studio/widgets/weather/translations/en.json b/custom_components/opendisplay_studio/widgets/weather/translations/en.json
new file mode 100644
index 0000000..05020d5
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/weather/translations/en.json
@@ -0,0 +1,22 @@
+{
+ "weather": "Weather",
+ "temperature": "Temperature",
+ "apparent_temperature": "Apparent temperature",
+ "humidity": "Humidity",
+ "right_now": "Right now",
+ "low": "Low",
+ "high": "High",
+ "low_high": "L/H",
+ "today": "Today",
+ "tomorrow": "Tomorrow",
+ "precipitation": "Precipitation",
+ "details": "Details",
+ "uv": "UV",
+ "uv_low": "Low",
+ "uv_moderate": "Moderate",
+ "uv_high": "High",
+ "uv_very_high": "Very high",
+ "uv_extreme": "Extreme",
+ "unavailable": "Unavailable",
+ "choose_weather_entity": "Choose a weather entity"
+}
diff --git a/custom_components/opendisplay_studio/widgets/weather/translations/pl.json b/custom_components/opendisplay_studio/widgets/weather/translations/pl.json
new file mode 100644
index 0000000..7899406
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/weather/translations/pl.json
@@ -0,0 +1,19 @@
+{
+ "weather": "Pogoda",
+ "right_now": "Teraz",
+ "low": "Min.",
+ "high": "Maks.",
+ "low_high": "Min./maks.",
+ "today": "Dzisiaj",
+ "tomorrow": "Jutro",
+ "precipitation": "Opady",
+ "details": "Szczegóły",
+ "uv": "UV",
+ "uv_low": "Niskie",
+ "uv_moderate": "Umiarkowane",
+ "uv_high": "Wysokie",
+ "uv_very_high": "Bardzo wysokie",
+ "uv_extreme": "Ekstremalne",
+ "unavailable": "Niedostępne",
+ "choose_weather_entity": "Wybierz encję pogody"
+}
diff --git a/custom_components/opendisplay_studio/widget_templates/weather.liquid b/custom_components/opendisplay_studio/widgets/weather/widget.liquid
similarity index 84%
rename from custom_components/opendisplay_studio/widget_templates/weather.liquid
rename to custom_components/opendisplay_studio/widgets/weather/widget.liquid
index 099d430..a93b441 100644
--- a/custom_components/opendisplay_studio/widget_templates/weather.liquid
+++ b/custom_components/opendisplay_studio/widgets/weather/widget.liquid
@@ -26,7 +26,7 @@
{{ current.temperature }}°
- Temperature ({{ current.updated_at }})
+ {{ current.labels.temperature }} ({{ current.updated_at }})
@@ -35,11 +35,11 @@
-
+
{{ current.apparent_temperature }}°
- Feels Like
+ {{ current.labels.apparent_temperature }}
{% endif %}
@@ -47,11 +47,11 @@
-
+
{{ current.humidity }}%
- Humidity
+ {{ current.labels.humidity }}
{% endif %}
@@ -62,7 +62,7 @@
{{ current.condition_label }}
- Right Now
+ {{ current.labels.right_now }}
@@ -88,11 +88,11 @@
-
+
{% if day.uv_index != nil %}{{ day.uv_label }} ({{ day.uv_index }}){% else %}{{ day.precipitation_probability }}%{% endif %}
- {% if day.uv_index != nil %}UV{% else %}Precipitation{% endif %}
+ {% if day.uv_index != nil %}{{ current.labels.uv }}{% else %}{{ current.labels.precipitation }}{% endif %}
{% endif %}
@@ -100,17 +100,17 @@
-
+
{% if day.templow != nil %}{{ day.templow | round }}°{% else %}—{% endif %}
- Low
+ {{ current.labels.low }}
{{ day.temperature | round }}°
- High
+ {{ current.labels.high }}
@@ -124,7 +124,7 @@
-
Weather
+
{{ current.labels.weather }}
{{ current.name }}
@@ -140,7 +140,7 @@
{{ current.temperature }}°
- Temperature
+ {{ current.labels.temperature }}
{{ current.updated_at }}
@@ -150,7 +150,7 @@
{{ current.condition_label }}
- Right Now
+ {{ current.labels.right_now }}
{% if config.showForecast and today %}
@@ -158,14 +158,14 @@
{% if today.templow != nil %}{{ today.templow }}° – {% endif %}{{ today.temperature }}°
- {% if today.templow != nil %}L/H{% else %}High{% endif %}
+ {% if today.templow != nil %}{{ current.labels.low_high }}{% else %}{{ current.labels.high }}{% endif %}
{% if today.uv_index != nil %}{{ today.uv_label }} ({{ today.uv_index }}){% elsif today.precipitation_probability != nil %}{{ today.precipitation_probability }}%{% else %}—{% endif %}
- {% if today.uv_index != nil %}UV{% elsif today.precipitation_probability != nil %}Precipitation{% else %}Details{% endif %}
+ {% if today.uv_index != nil %}{{ current.labels.uv }}{% elsif today.precipitation_probability != nil %}{{ current.labels.precipitation }}{% else %}{{ current.labels.details }}{% endif %}
{% endif %}
@@ -176,21 +176,21 @@
{{ tomorrow.condition_label }}
- Tomorrow
+ {{ current.labels.tomorrow }}
{% if tomorrow.templow != nil %}{{ tomorrow.templow }}° – {% endif %}{{ tomorrow.temperature }}°
- {% if tomorrow.templow != nil %}L/H{% else %}High{% endif %}
+ {% if tomorrow.templow != nil %}{{ current.labels.low_high }}{% else %}{{ current.labels.high }}{% endif %}
{% if tomorrow.uv_index != nil %}{{ tomorrow.uv_label }} ({{ tomorrow.uv_index }}){% elsif tomorrow.precipitation_probability != nil %}{{ tomorrow.precipitation_probability }}%{% else %}—{% endif %}
- {% if tomorrow.uv_index != nil %}UV{% elsif tomorrow.precipitation_probability != nil %}Precipitation{% else %}Details{% endif %}
+ {% if tomorrow.uv_index != nil %}{{ current.labels.uv }}{% elsif tomorrow.precipitation_probability != nil %}{{ current.labels.precipitation }}{% else %}{{ current.labels.details }}{% endif %}
{% endif %}
@@ -215,7 +215,7 @@
{{ current.temperature }}°
- Temperature
+ {{ current.labels.temperature }}
{{ current.updated_at }}
@@ -231,21 +231,21 @@
{% if forloop.first %}{{ current.condition_label }}{% else %}{{ day.condition_label }}{% endif %}
- {% if forloop.first %}Right Now{% else %}Tomorrow{% endif %}
+ {% if forloop.first %}{{ current.labels.right_now }}{% else %}{{ current.labels.tomorrow }}{% endif %}
{% if day.templow != nil %}{{ day.templow }}° – {% endif %}{{ day.temperature }}°
- {% if day.templow != nil %}L/H{% else %}High{% endif %}
+ {% if day.templow != nil %}{{ current.labels.low_high }}{% else %}{{ current.labels.high }}{% endif %}
{% if day.uv_index != nil %}{{ day.uv_label }} ({{ day.uv_index }}){% elsif day.precipitation_probability != nil %}{{ day.precipitation_probability }}%{% else %}—{% endif %}
- {% if day.uv_index != nil %}UV{% elsif day.precipitation_probability != nil %}Precipitation{% else %}Details{% endif %}
+ {% if day.uv_index != nil %}{{ current.labels.uv }}{% elsif day.precipitation_probability != nil %}{{ current.labels.precipitation }}{% else %}{{ current.labels.details }}{% endif %}
@@ -272,7 +272,7 @@
{{ current.condition_label }}
- Right Now
+ {{ current.labels.right_now }}
@@ -282,7 +282,7 @@
{{ current.temperature }}°
- Temperature
+ {{ current.labels.temperature }}
@@ -290,7 +290,7 @@
{{ current.condition_label }}
- Right Now
+ {{ current.labels.right_now }}
@@ -299,14 +299,14 @@
{% if today.templow != nil %}{{ today.templow }}° – {% endif %}{{ today.temperature }}°
- {% if today.templow != nil %}L/H{% else %}High{% endif %}
+ {% if today.templow != nil %}{{ current.labels.low_high }}{% else %}{{ current.labels.high }}{% endif %}
{% if today.uv_index != nil %}{{ today.uv_label }} ({{ today.uv_index }}){% elsif today.precipitation_probability != nil %}{{ today.precipitation_probability }}%{% else %}—{% endif %}
- {% if today.uv_index != nil %}UV{% elsif today.precipitation_probability != nil %}Precipitation{% else %}Details{% endif %}
+ {% if today.uv_index != nil %}{{ current.labels.uv }}{% elsif today.precipitation_probability != nil %}{{ current.labels.precipitation }}{% else %}{{ current.labels.details }}{% endif %}
{% endif %}
diff --git a/custom_components/opendisplay_studio/widgets/weather/widget.yml b/custom_components/opendisplay_studio/widgets/weather/widget.yml
new file mode 100644
index 0000000..52adc5b
--- /dev/null
+++ b/custom_components/opendisplay_studio/widgets/weather/widget.yml
@@ -0,0 +1,43 @@
+id: weather
+name: Weather
+version: "0.5.0"
+description: Current conditions and a daily Home Assistant forecast.
+icon: mdi:weather-partly-cloudy
+framework: 3.2.0
+template: widget.liquid
+provider: provider.py
+
+defaults:
+ weather: ""
+ showHumidity: true
+ showFeelsLike: true
+ showForecast: true
+
+fields:
+ - key: weather
+ label: Weather entity
+ required: true
+ selector:
+ entity:
+ filter:
+ domain: weather
+ - key: showHumidity
+ label: Show humidity
+ selector:
+ boolean: {}
+ - key: showFeelsLike
+ label: Show feels like
+ selector:
+ boolean: {}
+ - key: showForecast
+ label: Show forecast
+ selector:
+ boolean: {}
+
+dataRequirements:
+ - key: weather
+ provider: weather_forecast
+ configKey: weather
+ cardinality: one
+ optional: false
+ forecastType: daily
diff --git a/frontend-src/package-lock.json b/frontend-src/package-lock.json
index 57c173d..3313e66 100644
--- a/frontend-src/package-lock.json
+++ b/frontend-src/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "opendisplay-studio-ha-panel",
- "version": "0.3.6",
+ "version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opendisplay-studio-ha-panel",
- "version": "0.3.6",
+ "version": "0.5.0",
"dependencies": {
"@mdi/js": "^7.4.47",
"lit": "^3.3.3"
diff --git a/frontend-src/package.json b/frontend-src/package.json
index b2ecedd..886731b 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.6",
+ "version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite build --watch",
diff --git a/frontend-src/src/app-styles.ts b/frontend-src/src/app-styles.ts
index 01a279d..53e276f 100644
--- a/frontend-src/src/app-styles.ts
+++ b/frontend-src/src/app-styles.ts
@@ -1252,11 +1252,13 @@ export const appStyles = css`
background: color-mix(in srgb, var(--odx-blue) 8%, var(--odx-surface));
}
- .widget-choice svg {
- width: 22px;
- height: 22px;
- fill: var(--odx-blue-strong);
- }
+ .widget-choice svg,
+ .widget-choice ha-icon {
+ width: 22px;
+ height: 22px;
+ color: var(--odx-blue-strong);
+ fill: currentColor;
+ }
.widget-choice strong,
.widget-choice span {
diff --git a/frontend-src/src/dev.ts b/frontend-src/src/dev.ts
index 0466235..0686545 100644
--- a/frontend-src/src/dev.ts
+++ b/frontend-src/src/dev.ts
@@ -36,6 +36,7 @@ let projects: ScreenProject[] = [
schemaVersion: 1,
name: 'Kitchen dashboard',
status: 'ready',
+ language: 'en',
displayId: 'opendisplay-e1001',
width: 800,
height: 480,
@@ -52,7 +53,7 @@ let projects: ScreenProject[] = [
columnSpan: 1,
widget: {
type: 'entity-state',
- version: 1,
+ version: '0.5.0',
config: { entity: 'sensor.kitchen_temperature', title: 'Kitchen', layout: 'large', showUnit: true },
},
},
@@ -65,7 +66,7 @@ let projects: ScreenProject[] = [
columnSpan: 2,
widget: {
type: 'calendar',
- version: 1,
+ version: '0.5.0',
config: { calendar: 'calendar.family', title: 'Upcoming', days: 7, showLocation: true },
},
},
@@ -78,7 +79,7 @@ let projects: ScreenProject[] = [
columnSpan: 3,
widget: {
type: 'text',
- version: 1,
+ version: '0.5.0',
config: { title: 'HOME', text: 'OpenDisplay Studio', align: 'center' },
},
},
@@ -89,6 +90,7 @@ let projects: ScreenProject[] = [
]
const hass: HomeAssistant = {
+ language: 'en',
async callWS(message: Record): Promise {
if (message.type === 'opendisplay_studio/bootstrap') {
return { projects, widgets: [] } as T
diff --git a/frontend-src/src/odx-app.ts b/frontend-src/src/odx-app.ts
index 2ed7377..df40bd2 100644
--- a/frontend-src/src/odx-app.ts
+++ b/frontend-src/src/odx-app.ts
@@ -40,7 +40,7 @@ import type {
WidgetConfigValue,
WidgetOption,
} from './types'
-import { WIDGETS, getWidgetDefinition, widgetStyles } from './widgets/registry'
+import { getRuntimeWidgetDefinition, getWidgetDefinition, widgetStyles } from './widgets/registry'
import { renderButtonIcon, renderIcon } from './widgets/shared'
import { sharedWidgetStyles } from './widgets/shared-styles'
@@ -168,15 +168,8 @@ export class OdxApp extends LitElement {
private widgetDefinition(widgetId: string): WidgetDefinition | undefined {
const local = getWidgetDefinition(widgetId)
const backend = this.widgetMetadata.find((item) => item.id === widgetId)
- if (!local || !backend) return local
- return {
- ...local,
- version: backend.version,
- name: backend.name,
- description: backend.description,
- defaults: backend.defaults,
- options: backend.fields,
- }
+ if (!backend) return local
+ return getRuntimeWidgetDefinition(backend)
}
private updatePreviewScale(): void {
@@ -369,7 +362,10 @@ export class OdxApp extends LitElement {
private async addProject(): Promise {
try {
- const draft = createProject(`Untitled display ${this.store.projects.length + 1}`)
+ const draft = createProject(
+ `Untitled display ${this.store.projects.length + 1}`,
+ this.hass.language,
+ )
const response = await this.hass.callWS<{ project: ScreenProject }>({
type: 'opendisplay_studio/create_project',
project: draft,
@@ -900,9 +896,12 @@ export class OdxApp extends LitElement {
Region settings R${region.row}:C${region.column} · ${region.columnSpan}×${region.rowSpan}
- ${WIDGETS.map((widget) => html`
+ ${this.widgetMetadata.map((widget) => html`
this.assignWidget(widget.id)}>
- ${renderIcon(widget.icon)}${widget.name} ${widget.description}
+ ${getWidgetDefinition(widget.id)
+ ? renderIcon(getWidgetDefinition(widget.id)!.icon)
+ : html` `}
+ ${widget.name} ${widget.description}
`)}
@@ -927,6 +926,21 @@ export class OdxApp extends LitElement {
Grid ${project.grid.columns} × ${project.grid.rows}
Regions ${project.regions.length}
+ ) => {
+ const language = event.detail.value.language
+ if (language) this.updateLayoutDraft((draft) => ({ ...draft, language }))
+ }}
+ >
+ The selected language is used by live preview, Media Source, and the physical display.
Click the first corner of a new region.
Move across the grid and click the opposite corner.
diff --git a/frontend-src/src/services/storage.test.ts b/frontend-src/src/services/storage.test.ts
new file mode 100644
index 0000000..3e58a20
--- /dev/null
+++ b/frontend-src/src/services/storage.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { createProject } from './storage'
+
+describe('project language', () => {
+ it('pins a new display to the current Home Assistant language', () => {
+ expect(createProject('Kuchnia', 'pl').language).toBe('pl')
+ })
+
+ it('keeps English as the standalone development fallback', () => {
+ expect(createProject().language).toBe('en')
+ })
+})
diff --git a/frontend-src/src/services/storage.ts b/frontend-src/src/services/storage.ts
index 5ac109f..661e568 100644
--- a/frontend-src/src/services/storage.ts
+++ b/frontend-src/src/services/storage.ts
@@ -4,7 +4,7 @@ import { getDisplayProfile } from '../data/display-profiles'
const DEFAULT_DISPLAY = 'solum-newton-pro-5-8'
-export const createProject = (name = 'Kitchen display'): ScreenProject => {
+export const createProject = (name = 'Kitchen display', language = 'en'): ScreenProject => {
const display = getDisplayProfile(DEFAULT_DISPLAY)
const orientation = 'landscape' as const
const grid = gridForOrientation(display, orientation)
@@ -14,6 +14,7 @@ export const createProject = (name = 'Kitchen display'): ScreenProject => {
schemaVersion: 1,
name,
status: 'draft',
+ language,
displayId: display.id,
orientation,
palette: display.defaultPalette,
diff --git a/frontend-src/src/types.ts b/frontend-src/src/types.ts
index 3d9addc..1c554cd 100644
--- a/frontend-src/src/types.ts
+++ b/frontend-src/src/types.ts
@@ -43,7 +43,7 @@ export interface CustomDriverProfile {
export interface WidgetInstance {
type: string
- version: number
+ version: string
config: WidgetConfig
}
@@ -62,6 +62,7 @@ export interface ScreenProject {
schemaVersion: 1
name: string
status: 'draft' | 'ready'
+ language: string
displayId: string
driverId?: string
orientation: Orientation
@@ -106,7 +107,7 @@ export interface WidgetRenderContext {
export interface WidgetDefinition {
id: string
- version: number
+ version: string
name: string
description: string
icon: string
@@ -121,6 +122,7 @@ export interface WidgetDefinition {
export interface HomeAssistant {
callWS(message: Record): Promise
+ language: string
states?: Record
}
@@ -139,7 +141,7 @@ export interface BootstrapResponse {
projects: ScreenProject[]
widgets: Array<{
id: string
- version: number
+ version: string
name: string
description: string
icon: string
diff --git a/frontend-src/src/weather-liquid-contract.test.ts b/frontend-src/src/weather-liquid-contract.test.ts
index baa2ce1..1ae3973 100644
--- a/frontend-src/src/weather-liquid-contract.test.ts
+++ b/frontend-src/src/weather-liquid-contract.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { Liquid } from 'liquidjs'
const templateUrl = new URL(
- '../../custom_components/opendisplay_studio/widget_templates/weather.liquid',
+ '../../custom_components/opendisplay_studio/widgets/weather/widget.liquid',
import.meta.url,
)
const engine = new Liquid({
@@ -12,6 +12,29 @@ const engine = new Liquid({
strictVariables: true,
})
+const englishLabels = {
+ weather: 'Weather',
+ temperature: 'Temperature',
+ apparent_temperature: 'Apparent temperature',
+ humidity: 'Humidity',
+ right_now: 'Right now',
+ low: 'Low',
+ high: 'High',
+ low_high: 'L/H',
+ today: 'Today',
+ tomorrow: 'Tomorrow',
+ precipitation: 'Precipitation',
+ details: 'Details',
+ uv: 'UV',
+ uv_low: 'Low',
+ uv_moderate: 'Moderate',
+ uv_high: 'High',
+ uv_very_high: 'Very high',
+ uv_extreme: 'Extreme',
+ unavailable: 'Unavailable',
+ choose_weather_entity: 'Choose a weather entity',
+}
+
const current = {
entity_id: 'weather.home',
name: 'Home',
@@ -23,6 +46,7 @@ const current = {
apparent_temperature: 9,
humidity: 88,
updated_at: '18:02',
+ labels: englishLabels,
}
const render = async (
@@ -54,4 +78,25 @@ describe('Weather LiquidJS contract', () => {
await expect(render({ ...current, forecast: [] })).resolves.toContain('12°')
await expect(render(current, false)).resolves.toContain('12°')
})
+
+ it('renders localized vocabulary supplied by Home Assistant', async () => {
+ const html = await render({
+ ...current,
+ condition_label: 'deszczowo',
+ labels: {
+ ...englishLabels,
+ weather: 'Pogoda',
+ temperature: 'Temperatura',
+ apparent_temperature: 'Odczuwalna temperatura',
+ humidity: 'Wilgotność',
+ right_now: 'Teraz',
+ },
+ })
+
+ expect(html).toContain('deszczowo')
+ expect(html).toContain('Temperatura')
+ expect(html).toContain('Odczuwalna temperatura')
+ expect(html).toContain('Wilgotność')
+ expect(html).toContain('Teraz')
+ })
})
diff --git a/frontend-src/src/widgets/calendar/widget.ts b/frontend-src/src/widgets/calendar/widget.ts
index 95e3c22..6554bea 100644
--- a/frontend-src/src/widgets/calendar/widget.ts
+++ b/frontend-src/src/widgets/calendar/widget.ts
@@ -6,7 +6,7 @@ import { calendarStyles } from './styles'
export const calendarWidget: WidgetDefinition = {
id: 'calendar',
- version: 1,
+ version: '0.5.0',
name: 'Calendar',
description: 'An agenda from one or more calendar entities.',
icon: mdiCalendarMonthOutline,
diff --git a/frontend-src/src/widgets/entity-value/widget.ts b/frontend-src/src/widgets/entity-value/widget.ts
index d822f71..61feac1 100644
--- a/frontend-src/src/widgets/entity-value/widget.ts
+++ b/frontend-src/src/widgets/entity-value/widget.ts
@@ -6,7 +6,7 @@ import { entityValueStyles } from './styles'
export const entityValueWidget: WidgetDefinition = {
id: 'entity-state',
- version: 1,
+ version: '0.5.0',
name: 'Entity State',
description: 'A prominent value from a single entity.',
icon: mdiHomeThermometerOutline,
diff --git a/frontend-src/src/widgets/registry.test.ts b/frontend-src/src/widgets/registry.test.ts
new file mode 100644
index 0000000..0497aea
--- /dev/null
+++ b/frontend-src/src/widgets/registry.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import { getRuntimeWidgetDefinition } from './registry'
+
+describe('runtime widget registry', () => {
+ it('creates a selectable definition for a backend-only widget package', () => {
+ const widget = getRuntimeWidgetDefinition({
+ id: 'air-quality',
+ version: '0.5.0',
+ name: 'Air Quality',
+ description: 'Current air quality from a selected entity.',
+ icon: 'mdi:air-filter',
+ defaults: { entity: '', showIndex: true },
+ fields: [
+ {
+ key: 'entity',
+ label: 'Entity',
+ selector: { entity: { filter: { domain: 'sensor' } } },
+ },
+ ],
+ dataRequirements: [
+ {
+ key: 'entity',
+ provider: 'entity_state',
+ configKey: 'entity',
+ cardinality: 'one',
+ optional: false,
+ },
+ ],
+ })
+
+ expect(widget.id).toBe('air-quality')
+ expect(widget.version).toBe('0.5.0')
+ expect(widget.defaults).toEqual({ entity: '', showIndex: true })
+ expect(widget.options[0].selector).toEqual({
+ entity: { filter: { domain: 'sensor' } },
+ })
+ })
+
+ it('uses backend package metadata with a built-in visual fallback', () => {
+ const widget = getRuntimeWidgetDefinition({
+ id: 'weather',
+ version: '0.5.1',
+ name: 'Weather package',
+ description: 'Updated without rebuilding the panel.',
+ icon: 'mdi:weather-partly-cloudy',
+ defaults: { weather: 'weather.home' },
+ fields: [],
+ dataRequirements: [],
+ })
+
+ expect(widget.version).toBe('0.5.1')
+ expect(widget.name).toBe('Weather package')
+ expect(widget.defaults).toEqual({ weather: 'weather.home' })
+ })
+})
diff --git a/frontend-src/src/widgets/registry.ts b/frontend-src/src/widgets/registry.ts
index 6638a46..422f031 100644
--- a/frontend-src/src/widgets/registry.ts
+++ b/frontend-src/src/widgets/registry.ts
@@ -1,3 +1,5 @@
+import { css, html } from 'lit'
+import type { BootstrapResponse, WidgetDefinition } from '../types'
import { calendarWidget } from './calendar/widget'
import { entityValueWidget } from './entity-value/widget'
import { textNoteWidget } from './text-note/widget'
@@ -13,4 +15,35 @@ export const WIDGETS = [
export const getWidgetDefinition = (id: string) =>
WIDGETS.find((widget) => widget.id === id)
+export const getRuntimeWidgetDefinition = (
+ backend: BootstrapResponse['widgets'][number],
+): WidgetDefinition => {
+ const local = getWidgetDefinition(backend.id)
+ if (!local) return {
+ id: backend.id,
+ version: backend.version,
+ name: backend.name,
+ description: backend.description,
+ icon: backend.icon,
+ defaults: backend.defaults,
+ options: backend.fields,
+ styles: css``,
+ render: () => html`
+
+
+ ${backend.name}
+ Loading exact preview…
+
+ `,
+ }
+ return {
+ ...local,
+ version: backend.version,
+ name: backend.name,
+ description: backend.description,
+ defaults: backend.defaults,
+ options: backend.fields,
+ }
+}
+
export const widgetStyles = WIDGETS.map((widget) => widget.styles)
diff --git a/frontend-src/src/widgets/text-note/widget.ts b/frontend-src/src/widgets/text-note/widget.ts
index 8c7b539..59e638a 100644
--- a/frontend-src/src/widgets/text-note/widget.ts
+++ b/frontend-src/src/widgets/text-note/widget.ts
@@ -6,7 +6,7 @@ import { textNoteStyles } from './styles'
export const textNoteWidget: WidgetDefinition = {
id: 'text',
- version: 1,
+ version: '0.5.0',
name: 'Text',
description: 'A simple message or heading.',
icon: mdiFormatText,
diff --git a/frontend-src/src/widgets/weather/widget.ts b/frontend-src/src/widgets/weather/widget.ts
index 400c037..fbefb29 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: 4,
+ version: '0.5.0',
name: 'Weather',
description: 'Current conditions and a daily Home Assistant forecast.',
icon: mdiWeatherPartlyCloudy,
diff --git a/pyproject.toml b/pyproject.toml
index 8b194b9..5a45673 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "opendisplay-studio-integration"
-version = "0.3.6"
+version = "0.5.0"
requires-python = ">=3.14.2"
[tool.pytest.ini_options]
@@ -39,8 +39,7 @@ ignore = [
"tests/*" = ["ANN", "D", "PLR2004", "S101", "SLF001"]
"tests/test_liquid_renderer.py" = ["E501"]
"custom_components/opendisplay_studio/screens.py" = ["E501", "RUF001"]
-"custom_components/opendisplay_studio/widgets.py" = ["E501"]
-"custom_components/opendisplay_studio/data_providers.py" = ["E501"]
+"custom_components/opendisplay_studio/widgets/entity_state/provider.py" = ["E501"]
"custom_components/opendisplay_studio/composer.py" = ["E501"]
"custom_components/opendisplay_studio/projects.py" = ["PLR0915"]
diff --git a/tests/test_composer.py b/tests/test_composer.py
index ee19661..a47ff1e 100644
--- a/tests/test_composer.py
+++ b/tests/test_composer.py
@@ -1,6 +1,7 @@
"""Tests for render-time data aggregation and one-page composition."""
-from unittest.mock import AsyncMock, Mock, patch
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.exceptions import HomeAssistantError
@@ -11,7 +12,10 @@
_requirement_sources,
async_compose_project,
)
-from custom_components.opendisplay_studio.widgets import definition
+from custom_components.opendisplay_studio.widgets import DEFAULT_REGISTRY, definition
+from custom_components.opendisplay_studio.widgets.weather.provider import (
+ WeatherLocalizer,
+)
def test_requirement_contract_supports_many_and_optional_sources() -> None:
@@ -55,7 +59,7 @@ def test_entity_tile_shape_uses_physical_region_ratio() -> None:
async def test_entity_requirements_are_deduplicated(hass) -> None:
widget = {
"type": "entity-state",
- "version": 1,
+ "version": "0.5.0",
"config": {"entity": "sensor.office", "showUnit": True},
}
project = {
@@ -80,7 +84,7 @@ async def test_entity_requirements_are_deduplicated(hass) -> None:
},
],
}
- get_many = Mock(
+ resolve = AsyncMock(
return_value={
"sensor.office": {
"state": "22.8",
@@ -91,18 +95,15 @@ async def test_entity_requirements_are_deduplicated(hass) -> None:
}
)
with (
- patch(
- "custom_components.opendisplay_studio.composer.EntityStateProvider.get_many",
- get_many,
- ),
- patch(
- "custom_components.opendisplay_studio.composer.CalendarProvider.async_get_many",
- AsyncMock(return_value={}),
+ patch.object(
+ DEFAULT_REGISTRY.provider("entity-state", "entity_state"),
+ "async_resolve",
+ resolve,
),
):
result = await async_compose_project(hass, project)
- get_many.assert_called_once_with({"sensor.office"})
+ resolve.assert_awaited_once_with(hass, {"sensor.office"}, hass.config.language)
assert result.html.count("22.8") == 2
assert result.html.startswith(
' None:
project = {
"palette": "bw",
"grid": {"columns": 1, "rows": 1},
- "regions": [],
+ "regions": [
+ {
+ "id": "entity",
+ "row": 1,
+ "column": 1,
+ "rowSpan": 1,
+ "columnSpan": 1,
+ "widget": {
+ "type": "entity-state",
+ "version": "0.5.0",
+ "config": {"entity": "sensor.office"},
+ },
+ }
+ ],
}
with (
- patch(
- "custom_components.opendisplay_studio.composer.CalendarProvider.async_get_many",
+ patch.object(
+ DEFAULT_REGISTRY.provider("entity-state", "entity_state"),
+ "async_resolve",
AsyncMock(side_effect=HomeAssistantError("calendar unavailable")),
),
pytest.raises(ProjectComposeError),
@@ -147,7 +162,7 @@ async def test_weather_widget_renders_normalized_home_assistant_data(hass) -> No
"columnSpan": 1,
"widget": {
"type": "weather",
- "version": 2,
+ "version": "0.5.0",
"config": {"weather": "weather.home"},
},
}
@@ -168,13 +183,19 @@ async def test_weather_widget_renders_normalized_home_assistant_data(hass) -> No
}
}
- with patch(
- "custom_components.opendisplay_studio.composer.WeatherForecastProvider.async_get_many",
- AsyncMock(return_value=weather),
- ) as get_many:
+ resolve = AsyncMock(
+ return_value=SimpleNamespace(
+ values=weather, localizer=WeatherLocalizer.english()
+ )
+ )
+ with patch.object(
+ DEFAULT_REGISTRY.provider("weather", "weather_forecast"),
+ "async_resolve",
+ resolve,
+ ):
result = await async_compose_project(hass, project)
- get_many.assert_awaited_once_with({"weather.home"})
+ resolve.assert_awaited_once_with(hass, {"weather.home"}, hass.config.language)
assert "od-weather" in result.html
assert "12°" in result.html
assert "08:15" in result.html
@@ -199,19 +220,94 @@ async def test_weather_widget_renders_placeholder_before_entity_selection(hass)
"columnSpan": 1,
"widget": {
"type": "weather",
- "version": 4,
+ "version": "0.5.0",
"config": {"weather": "", "showForecast": False},
},
}
],
}
- with patch(
- "custom_components.opendisplay_studio.composer.WeatherForecastProvider.async_get_many",
- AsyncMock(return_value={}),
- ) as get_many:
+ resolve = AsyncMock(
+ return_value=SimpleNamespace(values={}, localizer=WeatherLocalizer.english())
+ )
+ with patch.object(
+ DEFAULT_REGISTRY.provider("weather", "weather_forecast"),
+ "async_resolve",
+ resolve,
+ ):
result = await async_compose_project(hass, project)
- get_many.assert_awaited_once_with(set())
+ resolve.assert_awaited_once_with(hass, set(), hass.config.language)
assert "Choose a weather entity" in result.html
assert "—°" in result.html
+
+
+async def test_weather_widget_uses_project_language_for_every_render_surface(
+ hass,
+) -> None:
+ project = {
+ "language": "pl",
+ "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": "0.5.0",
+ "config": {"weather": "weather.home"},
+ },
+ }
+ ],
+ }
+ labels = {
+ **WeatherLocalizer.english().labels,
+ "weather": "Pogoda",
+ "temperature": "Temperatura",
+ "apparent_temperature": "Odczuwalna temperatura",
+ "humidity": "Wilgotność",
+ "right_now": "Teraz",
+ "uv_moderate": "Umiarkowane",
+ }
+ localizer = WeatherLocalizer(
+ language="pl",
+ labels=labels,
+ condition_labels={"sunny": "słonecznie"},
+ )
+ weather = {
+ "weather.home": {
+ "entity_id": "weather.home",
+ "name": "OpenWeatherMap",
+ "condition": "sunny",
+ "condition_label": "słonecznie",
+ "icon": "https://trmnl.com/images/plugins/weather/wi-day-sunny.svg",
+ "temperature": 11,
+ "temperature_unit": "°C",
+ "apparent_temperature": 10,
+ "humidity": 93,
+ "updated_at": "06:02",
+ "forecast": [],
+ }
+ }
+ resolve = AsyncMock(
+ return_value=SimpleNamespace(values=weather, localizer=localizer)
+ )
+ with patch.object(
+ DEFAULT_REGISTRY.provider("weather", "weather_forecast"),
+ "async_resolve",
+ resolve,
+ ):
+ result = await async_compose_project(hass, project)
+
+ resolve.assert_awaited_once_with(hass, {"weather.home"}, "pl")
+ assert "słonecznie" in result.html
+ assert "Temperatura" in result.html
+ assert "Odczuwalna temperatura" in result.html
+ assert "Wilgotność" in result.html
+ assert "Teraz" in result.html
diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py
index a7c6d9c..dedba06 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.4",
+ "version": "0.5.0",
"apiVersion": 1,
"trmnlFrameworkVersion": "3.2.0",
},
diff --git a/tests/test_media_source.py b/tests/test_media_source.py
index 2bc7687..656418e 100644
--- a/tests/test_media_source.py
+++ b/tests/test_media_source.py
@@ -13,6 +13,7 @@
OpenDisplayStudioMediaSource,
)
from custom_components.opendisplay_studio.renderer import RenderResult
+from custom_components.opendisplay_studio.widgets import DEFAULT_REGISTRY
PNG = b"\x89PNG\r\n\x1a\n" + b"dynamic"
@@ -40,6 +41,7 @@ async def test_resolve_renders_and_publishes_temporary_png(hass) -> None:
hass.data[DOMAIN] = OpenDisplayStudioData(
cache=RenderCache(ttl_seconds=300, max_items=32),
projects=projects,
+ widgets=DEFAULT_REGISTRY,
renderer=client,
)
source = OpenDisplayStudioMediaSource(hass)
diff --git a/tests/test_projects.py b/tests/test_projects.py
index 3c77a94..d75b182 100644
--- a/tests/test_projects.py
+++ b/tests/test_projects.py
@@ -30,7 +30,7 @@ def project_payload() -> dict:
"columnSpan": 1,
"widget": {
"type": "entity-state",
- "version": 1,
+ "version": "0.5.0",
"config": {"entity": "sensor.office"},
},
},
@@ -56,7 +56,7 @@ def test_weather_widget_config_is_normalized() -> None:
payload = project_payload()
payload["regions"][0]["widget"] = {
"type": "weather",
- "version": 2,
+ "version": "0.5.0",
"config": {
"weather": "weather.home",
"showHumidity": False,
@@ -69,7 +69,7 @@ def test_weather_widget_config_is_normalized() -> None:
assert result["regions"][0]["widget"] == {
"type": "weather",
- "version": 2,
+ "version": "0.5.0",
"config": {
"weather": "weather.home",
"showHumidity": False,
@@ -79,12 +79,38 @@ def test_weather_widget_config_is_normalized() -> None:
}
+def test_widget_version_requires_semver() -> None:
+ payload = project_payload()
+ payload["regions"][0]["widget"]["version"] = 4
+
+ with pytest.raises(ProjectValidationError, match="semantic version"):
+ validate_project(payload)
+
+
+def test_project_language_defaults_to_system_and_accepts_bcp47() -> None:
+ payload = project_payload()
+
+ assert validate_project(payload)["language"] == "system"
+
+ payload["language"] = "pt-BR"
+ assert validate_project(payload)["language"] == "pt-BR"
+
+
+@pytest.mark.parametrize("language", ["", "polish", "../pl", "pl_PL"])
+def test_project_language_rejects_invalid_values(language: str) -> None:
+ payload = project_payload()
+ payload["language"] = language
+
+ with pytest.raises(ProjectValidationError, match="language"):
+ validate_project(payload)
+
+
def test_ready_weather_widget_requires_weather_entity() -> None:
payload = project_payload()
payload["status"] = "ready"
payload["regions"][0]["widget"] = {
"type": "weather",
- "version": 2,
+ "version": "0.5.0",
"config": {"weather": "sensor.outdoor_temperature"},
}
@@ -102,6 +128,7 @@ async def test_project_identity_survives_rename_and_ready(hass) -> None:
assert updated["id"] == created["id"]
assert updated["createdAt"] == created["createdAt"]
+ assert updated["language"] == hass.config.language
assert [item["name"] for item in store.list(ready_only=True)] == ["Main kitchen"]
@@ -121,4 +148,5 @@ async def test_projects_reload_from_versioned_store(hass) -> None:
loaded = store.get("stable-id")
assert loaded is not None
assert loaded["name"] == "Kitchen"
+ assert loaded["language"] == hass.config.language
assert loaded["createdAt"] == "2026-08-24T08:00:00+00:00"
diff --git a/tests/test_release_version.py b/tests/test_release_version.py
index b94a671..f035f1b 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.6"
+ assert INTEGRATION_VERSION == "0.5.0"
def test_panel_does_not_bundle_home_assistant_component_implementations() -> None:
diff --git a/tests/test_renderer.py b/tests/test_renderer.py
index 62f4f8c..1d62f83 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.4",
+ "version": "0.5.0",
"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.4",
+ "version": "0.5.0",
"apiVersion": 1,
"trmnlFrameworkVersion": "3.2.0",
}
@@ -91,9 +91,9 @@ async def incompatible_health(_request):
@pytest.mark.parametrize(
("version", "framework_version"),
[
- ("0.2.3", "3.2.0"),
- ("0.2.4-dev", "3.2.0"),
- ("0.2.4", "3.1.0"),
+ ("0.4.9", "3.2.0"),
+ ("0.5.0-dev", "3.2.0"),
+ ("0.5.0", "3.1.0"),
],
)
async def test_health_rejects_incompatible_renderer_or_framework(
diff --git a/tests/test_data_providers.py b/tests/test_widget_providers.py
similarity index 60%
rename from tests/test_data_providers.py
rename to tests/test_widget_providers.py
index 96bdfc2..85ed605 100644
--- a/tests/test_data_providers.py
+++ b/tests/test_widget_providers.py
@@ -1,16 +1,17 @@
-"""Tests for normalized Home Assistant widget providers."""
+"""Tests for package-owned Home Assistant widget providers."""
from unittest.mock import AsyncMock, patch
from homeassistant.exceptions import HomeAssistantError
-from custom_components.opendisplay_studio.data_providers import (
- EntityStateProvider,
- WeatherForecastProvider,
+from custom_components.opendisplay_studio.widgets import DEFAULT_REGISTRY
+from custom_components.opendisplay_studio.widgets.weather.provider import (
+ WeatherDataProvider,
+ WeatherLocalizer,
)
-def test_entity_provider_returns_normalized_current_state(hass) -> None:
+async def test_entity_provider_returns_normalized_current_state(hass) -> None:
hass.states.async_set(
"sensor.office_temperature",
"22.8",
@@ -21,7 +22,9 @@ def test_entity_provider_returns_normalized_current_state(hass) -> None:
},
)
- result = EntityStateProvider(hass).get_many({"sensor.office_temperature"})
+ result = await DEFAULT_REGISTRY.provider(
+ "entity-state", "entity_state"
+ ).async_resolve(hass, {"sensor.office_temperature"}, "en")
assert result == {
"sensor.office_temperature": {
@@ -36,6 +39,52 @@ def test_entity_provider_returns_normalized_current_state(hass) -> None:
}
+async def test_weather_localizer_uses_home_assistant_and_widget_translations(
+ hass,
+) -> None:
+ attribute_prefix = "component.weather.entity_component._.state_attributes."
+ entity_translations = {
+ "component.weather.entity_component._.state.sunny": "słonecznie",
+ f"{attribute_prefix}temperature.name": "Temperatura",
+ f"{attribute_prefix}apparent_temperature.name": "Odczuwalna temperatura",
+ f"{attribute_prefix}humidity.name": "Wilgotność",
+ }
+ title_translations = {"component.weather.title": "Pogoda"}
+ with patch(
+ "custom_components.opendisplay_studio.widgets.weather.provider.async_get_translations",
+ AsyncMock(
+ side_effect=[
+ entity_translations,
+ title_translations,
+ ]
+ ),
+ ):
+ localizer = await WeatherLocalizer.async_create(hass, "pl")
+
+ assert localizer.language == "pl"
+ assert localizer.condition("sunny") == "słonecznie"
+ assert localizer.labels["weather"] == "Pogoda"
+ assert localizer.labels["temperature"] == "Temperatura"
+ assert localizer.labels["apparent_temperature"] == "Odczuwalna temperatura"
+ assert localizer.labels["humidity"] == "Wilgotność"
+ assert localizer.labels["right_now"] == "Teraz"
+ assert localizer.labels["uv_moderate"] == "Umiarkowane"
+
+
+async def test_weather_localizer_loads_real_polish_home_assistant_resources(
+ hass,
+) -> None:
+ localizer = await WeatherLocalizer.async_create(hass, "pl")
+
+ assert localizer.condition("sunny") == "słonecznie"
+ assert localizer.labels["weather"] == "Pogoda"
+ assert localizer.labels["temperature"] == "Temperatura"
+ assert localizer.labels["apparent_temperature"] == "Odczuwalna temperatura"
+ assert localizer.labels["humidity"] == "Wilgotność"
+ assert localizer.labels["right_now"] == "Teraz"
+ assert localizer.labels["uv_moderate"] == "Umiarkowane"
+
+
async def test_weather_provider_combines_entity_state_and_daily_forecast(hass) -> None:
hass.states.async_set(
"weather.home",
@@ -68,7 +117,7 @@ async def test_weather_provider_combines_entity_state_and_daily_forecast(hass) -
"async_call",
AsyncMock(return_value=service_response),
) as async_call:
- result = await WeatherForecastProvider(hass).async_get_many({"weather.home"})
+ result = await WeatherDataProvider().async_resolve(hass, {"weather.home"}, "en")
async_call.assert_awaited_once_with(
"weather",
@@ -78,7 +127,7 @@ async def test_weather_provider_combines_entity_state_and_daily_forecast(hass) -
target={"entity_id": ["weather.home"]},
return_response=True,
)
- current = result["weather.home"]
+ current = result.values["weather.home"]
assert current["entity_id"] == "weather.home"
assert current["name"] == "Home"
assert current["condition"] == "partlycloudy"
@@ -91,7 +140,7 @@ async def test_weather_provider_combines_entity_state_and_daily_forecast(hass) -
forecast = current["forecast"][0]
assert forecast["datetime"] == "2026-08-26T12:00:00+02:00"
assert forecast["condition"] == "rainy"
- assert forecast["condition_label"] == "Rain"
+ assert forecast["condition_label"] == "Rainy"
assert forecast["icon"] == ("https://trmnl.com/images/plugins/weather/wi-rain.svg")
assert forecast["temperature"] == 20
assert forecast["templow"] == 13
@@ -127,9 +176,9 @@ async def test_weather_provider_uses_explicit_nulls_for_optional_values(hass) ->
"async_call",
AsyncMock(return_value=service_response),
):
- result = await WeatherForecastProvider(hass).async_get_many({"weather.home"})
+ result = await WeatherDataProvider().async_resolve(hass, {"weather.home"}, "en")
- current = result["weather.home"]
+ current = result.values["weather.home"]
assert current["apparent_temperature"] is None
assert current["humidity"] is None
assert current["forecast"][0]["templow"] is None
@@ -158,9 +207,9 @@ async def test_weather_provider_keeps_current_conditions_when_forecast_fails(
"async_call",
AsyncMock(side_effect=HomeAssistantError("forecast unavailable")),
):
- result = await WeatherForecastProvider(hass).async_get_many({"weather.home"})
+ result = await WeatherDataProvider().async_resolve(hass, {"weather.home"}, "en")
- current = result["weather.home"]
+ current = result.values["weather.home"]
assert current["condition"] == "rainy"
assert current["temperature"] == 12
assert current["humidity"] == 88
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
new file mode 100644
index 0000000..262605b
--- /dev/null
+++ b/tests/test_widgets.py
@@ -0,0 +1,150 @@
+"""Tests for self-contained widget package discovery and composition."""
+
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from custom_components.opendisplay_studio.composer import async_compose_project
+from custom_components.opendisplay_studio.const import DOMAIN
+from custom_components.opendisplay_studio.projects import validate_project
+from custom_components.opendisplay_studio.widgets import (
+ BUILTIN_WIDGET_DIRECTORY,
+ WidgetPackageError,
+ WidgetRegistry,
+)
+
+QUOTE_MANIFEST = """
+id: quote
+name: Quote
+version: "{version}"
+description: A package unknown to the integration source code.
+icon: mdi:format-quote-close
+template: widget.liquid
+provider: provider.py
+defaults:
+ source: community
+fields:
+ - key: source
+ label: Source
+ type: text
+dataRequirements:
+ - key: quote
+ provider: quote_api
+ configKey: source
+ cardinality: one
+ optional: false
+"""
+
+QUOTE_PROVIDER = """
+class QuoteProvider:
+ name = "quote_api"
+
+ def new_request(self):
+ return set()
+
+ def add_request(self, request, sources, config, requirement):
+ request.update(sources)
+
+ async def async_resolve(self, hass, request, language):
+ return {source: {"message": f"Hello {source}"} for source in request}
+
+ def values(self, resolved, sources, config, requirement):
+ return [resolved[source] for source in sources]
+
+
+PROVIDER = QuoteProvider()
+"""
+
+
+def _write_quote_package(root: Path, *, version: str = "0.5.0") -> Path:
+ """Create a complete external widget package for discovery tests."""
+ package = root / "quote"
+ package.mkdir(parents=True, exist_ok=True)
+ (package / "widget.yml").write_text(
+ QUOTE_MANIFEST.format(version=version), encoding="utf-8"
+ )
+ (package / "widget.liquid").write_text(
+ '{{ data.quote.message }}
',
+ encoding="utf-8",
+ )
+ (package / "provider.py").write_text(QUOTE_PROVIDER, encoding="utf-8")
+ return package
+
+
+async def test_unknown_package_is_discovered_validated_and_composed(
+ hass, tmp_path: Path
+) -> None:
+ """Adding a package must not require an integration source-code change."""
+ _write_quote_package(tmp_path)
+ registry = WidgetRegistry.from_directories([BUILTIN_WIDGET_DIRECTORY, tmp_path])
+ project = validate_project(
+ {
+ "name": "Community widget",
+ "status": "draft",
+ "language": "en",
+ "displayId": "custom",
+ "width": 400,
+ "height": 300,
+ "orientation": "landscape",
+ "palette": "bw",
+ "grid": {"columns": 1, "rows": 1},
+ "regions": [
+ {
+ "id": "quote",
+ "row": 1,
+ "column": 1,
+ "rowSpan": 1,
+ "columnSpan": 1,
+ "widget": {
+ "type": "quote",
+ "version": "0.5.0",
+ "config": {"source": "community"},
+ },
+ }
+ ],
+ },
+ registry,
+ )
+ hass.data[DOMAIN] = SimpleNamespace(widgets=registry)
+
+ result = await async_compose_project(hass, project)
+
+ assert registry.definition("quote")["name"] == "Quote"
+ assert "Hello community" in result.html
+
+
+def test_registry_can_reload_updated_packages_without_replacing_consumers(
+ tmp_path: Path,
+) -> None:
+ """A store installer can refresh the same registry used by ProjectStore."""
+ _write_quote_package(tmp_path, version="0.5.0")
+ registry = WidgetRegistry.from_directories([tmp_path])
+
+ _write_quote_package(tmp_path, version="0.5.1")
+ registry.reload_from_directories([tmp_path])
+
+ assert registry.definition("quote")["version"] == "0.5.1"
+
+
+def test_package_files_cannot_escape_the_widget_directory(tmp_path: Path) -> None:
+ """Manifest file references are strictly package-local."""
+ package = tmp_path / "unsafe"
+ package.mkdir()
+ (package / "widget.yml").write_text(
+ """
+id: unsafe
+name: Unsafe
+version: "0.5.0"
+description: Invalid traversal fixture.
+icon: mdi:alert
+template: ../outside.liquid
+defaults: {}
+fields: []
+dataRequirements: []
+""",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(WidgetPackageError, match="package"):
+ WidgetRegistry.from_directories([tmp_path])