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("" ? (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("" ? (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` `; } renderToolbar() { let e = this.canvasProject, t = this.canvasDisplay; - return w` + return C`
- ${e.displayId === "custom" ? w` + ${e.displayId === "custom" ? C`
this.changeCustomSize("width", e)} /> @@ -3627,11 +3649,11 @@ var Z = (e, t) => { this.changeCustomSize("height", e)} />
- ` : E} + ` : T}
@@ -3651,23 +3673,23 @@ var Z = (e, t) => { width: this.project.width, height: this.project.height }; - return w` + return C`
Step 2 · Widgets ${this.displayName(this.project)} - ${e.width}×${e.height} · ${z[this.project.palette]} · ${this.project.grid.columns}×${this.project.grid.rows} grid + ${e.width}×${e.height} · ${R[this.project.palette]} · ${this.project.grid.columns}×${this.project.grid.rows} grid
- ${Y(Ze)} Edit device & layout + ${J(Qe)} Edit device & layout
`; } 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` + >${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`Live Home Assistant data preview` : w`${this.previewError}`} + ${this.editorMode === "widgets" && (this.previewImageUrl || this.previewError) ? C` + ${this.previewImageUrl ? C`Live Home Assistant data preview` : 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`
this.updateWidgetOption(e, t)} />
- ` : e.type === "select" ? w` + ` : e.type === "select" ? C`
- ` : e.type === "text" && e.multiline ? w` + ` : e.type === "text" && e.multiline ? C`
- ` : w` + ` : C`
- 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` `; let t = e.widget ? this.widgetDefinition(e.widget.type) : void 0; - return w` + return C` `; } @@ -3837,7 +3860,7 @@ var Z = (e, t) => { width: e.width, height: e.height }; - return w` + return C`
${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 %} - - - {% endfor %} -
{{ event.time }}{{ event.summary }}{% if config.showLocation and event.location %}{{ event.location }}{% endif %}{% if config.showDescription and event.description %}{{ event.description }}{% endif %}
- {% 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 %} + + + {% endfor %} +
{{ event.time }}{{ event.summary }}{% if config.showLocation and event.location %}{{ event.location }}{% endif %}{% if config.showDescription and event.description %}{{ event.description }}{% endif %}
+ {% 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 @@
- Temperature + {{ current.labels.temperature }}
{{ current.apparent_temperature }}° - Feels Like + {{ current.labels.apparent_temperature }}
{% endif %} @@ -47,11 +47,11 @@
- Humidity + {{ current.labels.humidity }}
{{ 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 %}UV index{% else %}Precipitation probability{% endif %} + {% if day.uv_index != nil %}{{ current.labels.uv }}{% else %}{{ current.labels.precipitation }}{% endif %}
{% 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 @@
- Temperature + {{ current.labels.temperature }}
{% 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 }} - +
@@ -282,7 +282,7 @@
{{ current.temperature }}° - +
@@ -290,7 +290,7 @@
{{ current.condition_label }} - +
@@ -299,14 +299,14 @@
{% if today.templow != nil %}{{ today.templow }}° – {% endif %}{{ today.temperature }}° - +
{% if today.uv_index != nil %}{{ today.uv_label }} ({{ today.uv_index }}){% elsif today.precipitation_probability != nil %}{{ today.precipitation_probability }}%{% else %}—{% 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 {