diff --git a/.github/workflows/check_display_theme_url_display.yml b/.github/workflows/check_display_theme_url_display.yml
new file mode 100644
index 0000000000..0cf29ba3be
--- /dev/null
+++ b/.github/workflows/check_display_theme_url_display.yml
@@ -0,0 +1,34 @@
+name: Check Display Theme URL Display
+
+on:
+ pull_request:
+ paths:
+ - packages/modules/display_themes/url_display/source/**
+ branches:
+ - master
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node: [ 24 ]
+ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js ${{ matrix.node }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: 'npm'
+ cache-dependency-path: packages/modules/display_themes/url_display/source/package-lock.json
+
+ - name: Install Dependencies and Build
+ run: |
+ cd packages/modules/display_themes/url_display/source
+ npm install
+ npm run build --if-present
diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py
index 7c03072896..bc1ce78157 100644
--- a/packages/helpermodules/setdata.py
+++ b/packages/helpermodules/setdata.py
@@ -13,10 +13,12 @@
from control import data
from helpermodules import hardware_configuration, subdata
from helpermodules.broker import BrokerClient
+from helpermodules.messaging import MessageType, pub_system_message
from helpermodules.pub import Pub
from helpermodules.utils.topic_parser import decode_payload, get_index, get_index_position
from helpermodules.update_config import UpdateConfig
import dataclass_utils
+from modules.display_themes import deserialize_display_theme
log = logging.getLogger(__name__)
mqtt_log = logging.getLogger("mqtt")
@@ -905,7 +907,14 @@ def process_optional_topic(self, msg: mqtt.MQTTMessage):
elif "openWB/set/optional/int_display/standby" in msg.topic:
self._validate_value(msg, int, [(0, 600)])
elif "openWB/set/optional/int_display/theme" in msg.topic:
- self._validate_value(msg, "json")
+ try:
+ theme = deserialize_display_theme(decode_payload(msg.payload))
+ Pub().pub(msg.topic.replace('set/', '', 1), dataclass_utils.asdict(theme))
+ Pub().pub(msg.topic, "")
+ except ValueError as exc:
+ log.warning("Ungültige Display-Theme-Konfiguration: %s", exc)
+ pub_system_message({}, str(exc), MessageType.ERROR)
+ Pub().pub(msg.topic, "")
elif "openWB/set/optional/led/active" in msg.topic:
self._validate_value(msg, bool)
else:
diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py
index 884ad3e0f4..7d262144a0 100644
--- a/packages/helpermodules/subdata.py
+++ b/packages/helpermodules/subdata.py
@@ -36,6 +36,7 @@
from modules.common.component_type import ComponentType
from modules.common.configurable_backup_cloud import ConfigurableBackupCloud
from modules.common.configurable_tariff import ConfigurableFlexibleTariff, ConfigurableGridFee
+from modules.display_themes import deserialize_display_theme
from modules.common.simcount.simcounter_state import SimCounterState
from modules.internal_chargepoint_handler.internal_chargepoint_handler_config import (
GlobalHandlerData, InternalChargepoint, RfidData)
@@ -758,7 +759,21 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage):
elif re.search("/optional/ocpp/", msg.topic) is not None:
self.set_json_payload_class(var.data.ocpp, msg)
elif re.search("/optional/int_display/", msg.topic) is not None:
- self.set_json_payload_class(var.data.int_display, msg)
+ if msg.topic == "openWB/optional/int_display/theme":
+ try:
+ var.data.int_display.theme = deserialize_display_theme(decode_payload(msg.payload))
+ except ValueError as exc:
+ log.warning("Ungültige Display-Theme-Konfiguration: %s", exc)
+ theme = decode_payload(msg.payload)
+ if isinstance(theme, dict) and theme.get("type") == "url_display":
+ if not isinstance(theme.get("configuration"), dict):
+ theme["configuration"] = {}
+ theme["configuration"]["url"] = ""
+ var.data.int_display.theme = deserialize_display_theme(theme)
+ Pub().pub(msg.topic, asdict(var.data.int_display.theme))
+ pub_system_message({}, str(exc), MessageType.ERROR)
+ else:
+ self.set_json_payload_class(var.data.int_display, msg)
if re.search("/(standby|active|rotation)$", msg.topic) is not None:
# some topics require an update of the display manager or boot settings
run_command([
diff --git a/packages/modules/display_themes/__init__.py b/packages/modules/display_themes/__init__.py
index e69de29bb2..f45147b403 100644
--- a/packages/modules/display_themes/__init__.py
+++ b/packages/modules/display_themes/__init__.py
@@ -0,0 +1,29 @@
+import importlib
+from typing import Any
+
+from dataclass_utils import dataclass_from_dict
+
+
+def deserialize_display_theme(config: dict[str, Any]) -> Any:
+ if not isinstance(config, dict):
+ raise ValueError("Die Display-Theme-Konfiguration muss ein JSON-Objekt sein.")
+
+ theme_type = config.get("type")
+ if not isinstance(theme_type, str) or not theme_type.isidentifier():
+ raise ValueError("Der Typ des Display-Themes fehlt.")
+ if "configuration" in config and not isinstance(config["configuration"], dict):
+ raise ValueError("Die Konfiguration des Display-Themes muss ein JSON-Objekt sein.")
+
+ module_name = f"modules.display_themes.{theme_type}.config"
+ try:
+ module = importlib.import_module(f".{theme_type}.config", "modules.display_themes")
+ except ModuleNotFoundError as exc:
+ if exc.name != module_name:
+ raise
+ raise ValueError(f"Unbekanntes Display-Theme: {theme_type}") from exc
+
+ theme = dataclass_from_dict(module.theme_descriptor.configuration_factory, config)
+ default_theme = module.theme_descriptor.configuration_factory()
+ if not isinstance(theme.configuration, type(default_theme.configuration)):
+ raise ValueError("Die Konfiguration des Display-Themes hat einen ungültigen Typ.")
+ return theme
diff --git a/packages/modules/display_themes/url_display/__init__.py b/packages/modules/display_themes/url_display/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/modules/display_themes/url_display/config.py b/packages/modules/display_themes/url_display/config.py
new file mode 100644
index 0000000000..70378d9653
--- /dev/null
+++ b/packages/modules/display_themes/url_display/config.py
@@ -0,0 +1,119 @@
+import ipaddress
+import json
+import socket
+import subprocess
+from typing import Optional
+from urllib.parse import urlsplit, urlunsplit
+
+from helpermodules.auto_str import auto_str
+from helpermodules.utils.run_command import run_command
+
+from modules.common.abstract_device import DeviceDescriptor
+
+
+RFC1918_NETWORKS = (
+ ipaddress.ip_network("10.0.0.0/8"),
+ ipaddress.ip_network("172.16.0.0/12"),
+ ipaddress.ip_network("192.168.0.0/16"),
+)
+
+
+def _get_local_ipv4_networks() -> tuple[ipaddress.IPv4Network, ...]:
+ try:
+ output = run_command(["ip", "-j", "-4", "address", "show", "up"])
+ if output is None:
+ raise ValueError("Der Aufruf von 'ip' hat keine Ausgabe geliefert.")
+ interfaces = json.loads(output)
+ return tuple(
+ ipaddress.IPv4Network(f"{address['local']}/{address['prefixlen']}", strict=False)
+ for interface in interfaces
+ for address in interface.get("addr_info", [])
+ if address.get("family") == "inet"
+ )
+ except (KeyError, TypeError, ValueError, subprocess.CalledProcessError) as exc:
+ raise ValueError("Die lokalen IPv4-Netzwerke konnten nicht ermittelt werden.") from exc
+
+
+def validate_url(url: str) -> str:
+ if not isinstance(url, str) or not url.strip():
+ raise ValueError("Bitte eine URL angeben.")
+
+ normalized_url = url.strip()
+ if "://" not in normalized_url:
+ normalized_url = f"http://{normalized_url}"
+
+ parsed_url = urlsplit(normalized_url)
+ if parsed_url.scheme.lower() not in ("http", "https"):
+ raise ValueError("Die URL muss das Protokoll HTTP oder HTTPS verwenden.")
+ if parsed_url.hostname is None:
+ raise ValueError("Die URL enthält keinen gültigen Hostnamen.")
+ if parsed_url.username is not None or parsed_url.password is not None:
+ raise ValueError("Die URL darf keine Zugangsdaten enthalten.")
+ try:
+ parsed_url.port
+ except ValueError as exc:
+ raise ValueError("Die URL enthält keinen gültigen Port.") from exc
+
+ try:
+ resolved_addresses = {
+ ipaddress.IPv4Address(address[4][0])
+ for address in socket.getaddrinfo(
+ parsed_url.hostname,
+ parsed_url.port,
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ )
+ }
+ except socket.gaierror as exc:
+ raise ValueError(f"Der Hostname '{parsed_url.hostname}' konnte nicht aufgelöst werden.") from exc
+
+ if not resolved_addresses:
+ raise ValueError(f"Für den Hostnamen '{parsed_url.hostname}' wurde keine IPv4-Adresse gefunden.")
+
+ addresses_outside_rfc1918 = [
+ address
+ for address in resolved_addresses
+ if not any(address in network for network in RFC1918_NETWORKS)
+ ]
+ if not addresses_outside_rfc1918:
+ return urlunsplit(parsed_url._replace(scheme=parsed_url.scheme.lower()))
+
+ local_networks = _get_local_ipv4_networks()
+ invalid_addresses = [
+ str(address)
+ for address in addresses_outside_rfc1918
+ if not any(address in network for network in local_networks)
+ ]
+ if invalid_addresses:
+ raise ValueError(
+ "Die URL muss auf eine IPv4-Adresse im lokalen Netzwerk zeigen. "
+ f"Nicht zulässig: {', '.join(sorted(invalid_addresses))}"
+ )
+
+ return urlunsplit(parsed_url._replace(scheme=parsed_url.scheme.lower()))
+
+
+@auto_str
+class UrlDisplayThemeConfiguration:
+ def __init__(self,
+ url: str = ""
+ ) -> None:
+ self.url = validate_url(url) if url else ""
+
+
+@auto_str
+class UrlDisplayTheme:
+ def __init__(self,
+ name: str = "URL Display",
+ type: str = "url_display",
+ official: bool = False,
+ userManagementSupported: bool = False,
+ configuration: Optional[UrlDisplayThemeConfiguration] = None) -> None:
+ self.name = name
+ self.type = type
+ self.official = official
+ self.userManagementSupported = userManagementSupported
+ self.configuration = configuration or UrlDisplayThemeConfiguration()
+
+
+theme_descriptor = DeviceDescriptor(configuration_factory=UrlDisplayTheme)
diff --git a/packages/modules/display_themes/url_display/config_test.py b/packages/modules/display_themes/url_display/config_test.py
new file mode 100644
index 0000000000..8f47952dd0
--- /dev/null
+++ b/packages/modules/display_themes/url_display/config_test.py
@@ -0,0 +1,135 @@
+import socket
+
+import pytest
+
+from modules.display_themes import deserialize_display_theme
+from modules.display_themes.url_display import config
+
+
+@pytest.fixture(autouse=True)
+def local_networks(monkeypatch):
+ def run_command(command):
+ assert command == ["ip", "-j", "-4", "address", "show", "up"]
+ return '[{"addr_info": [{"family": "inet", "local": "100.64.1.10", "prefixlen": 24}]}]'
+
+ monkeypatch.setattr(
+ config,
+ "run_command",
+ run_command,
+ )
+
+
+def mock_dns(monkeypatch, *addresses):
+ def getaddrinfo(host, port, *, family, type):
+ assert host
+ assert port is None or isinstance(port, int)
+ assert family == socket.AF_INET
+ assert type == socket.SOCK_STREAM
+ return [
+ (socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, 0))
+ for address in addresses
+ ]
+
+ monkeypatch.setattr(
+ config.socket,
+ "getaddrinfo",
+ getaddrinfo,
+ )
+
+
+@pytest.mark.parametrize("address", [
+ "10.0.0.1",
+ "172.16.0.1",
+ "172.31.255.254",
+ "192.168.1.1",
+])
+def test_rfc1918_address_is_allowed(monkeypatch, address):
+ mock_dns(monkeypatch, address)
+
+ assert config.validate_url("https://example.local:8443/path?value=1") == (
+ "https://example.local:8443/path?value=1"
+ )
+
+
+def test_rfc1918_validation_does_not_require_interface_lookup(monkeypatch):
+ mock_dns(monkeypatch, "192.168.1.10")
+ monkeypatch.setattr(
+ config,
+ "run_command",
+ lambda command: pytest.fail(
+ f"Interface lookup should not be called for RFC1918 addresses: {command}"
+ ),
+ )
+
+ assert config.validate_url("openwb.local") == "http://openwb.local"
+
+
+def test_address_in_local_subnet_is_allowed(monkeypatch):
+ mock_dns(monkeypatch, "100.64.1.25")
+
+ assert config.validate_url("evcc.local/status") == "http://evcc.local/status"
+
+
+@pytest.mark.parametrize("address", [
+ "8.8.8.8",
+ "100.64.2.25",
+ "172.15.255.255",
+ "172.32.0.1",
+])
+def test_non_local_address_is_rejected(monkeypatch, address):
+ mock_dns(monkeypatch, address)
+
+ with pytest.raises(ValueError, match="lokalen Netzwerk"):
+ config.validate_url("https://example.com")
+
+
+def test_all_resolved_addresses_must_be_local(monkeypatch):
+ mock_dns(monkeypatch, "192.168.1.10", "8.8.8.8")
+
+ with pytest.raises(ValueError, match="8.8.8.8"):
+ config.validate_url("https://example.local")
+
+
+@pytest.mark.parametrize("url", [
+ "",
+ "ftp://192.168.1.10",
+ "http://user:password@192.168.1.10",
+ "http://192.168.1.10:invalid",
+])
+def test_invalid_url_is_rejected(monkeypatch, url):
+ mock_dns(monkeypatch, "192.168.1.10")
+
+ with pytest.raises(ValueError):
+ config.validate_url(url)
+
+
+def test_unresolvable_hostname_is_rejected(monkeypatch):
+ def raise_gaierror(*args, **kwargs):
+ assert args or kwargs
+ raise socket.gaierror
+
+ monkeypatch.setattr(config.socket, "getaddrinfo", raise_gaierror)
+
+ with pytest.raises(ValueError, match="konnte nicht aufgelöst werden"):
+ config.validate_url("https://missing.local")
+
+
+@pytest.mark.parametrize("configuration", [None, "https://192.168.1.10", 1, []])
+def test_non_object_theme_configuration_is_rejected(configuration):
+ with pytest.raises(ValueError, match="JSON-Objekt"):
+ deserialize_display_theme({
+ "name": "URL Display",
+ "type": "url_display",
+ "configuration": configuration,
+ })
+
+
+@pytest.mark.parametrize("theme_type", ["cards", "colors", "url_display"])
+def test_display_theme_configuration_is_deserialized(theme_type):
+ theme = deserialize_display_theme({
+ "type": theme_type,
+ "configuration": {},
+ })
+
+ assert theme.type == theme_type
+ assert not isinstance(theme.configuration, dict)
diff --git a/packages/modules/display_themes/url_display/source/.gitignore b/packages/modules/display_themes/url_display/source/.gitignore
new file mode 100644
index 0000000000..c2658d7d1b
--- /dev/null
+++ b/packages/modules/display_themes/url_display/source/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/packages/modules/display_themes/url_display/source/index.html b/packages/modules/display_themes/url_display/source/index.html
new file mode 100644
index 0000000000..947ad21210
--- /dev/null
+++ b/packages/modules/display_themes/url_display/source/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ openWB Display - URL
+
+
+
+
+
+
diff --git a/packages/modules/display_themes/url_display/source/package-lock.json b/packages/modules/display_themes/url_display/source/package-lock.json
new file mode 100644
index 0000000000..06ccbb5b05
--- /dev/null
+++ b/packages/modules/display_themes/url_display/source/package-lock.json
@@ -0,0 +1,1395 @@
+{
+ "name": "openwb-display-url",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openwb-display-url",
+ "version": "0.0.0",
+ "dependencies": {
+ "mqtt": "^5.15.0"
+ },
+ "devDependencies": {
+ "vite": "^8.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
+ "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
+ "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
+ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
+ "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@oxc-project/runtime": {
+ "version": "0.115.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
+ "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.115.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
+ "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
+ "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
+ "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
+ "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
+ "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
+ "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
+ "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
+ "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
+ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/readable-stream": {
+ "version": "4.0.23",
+ "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
+ "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bl": {
+ "version": "6.1.6",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
+ "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/readable-stream": "^4.0.0",
+ "buffer": "^6.0.3",
+ "inherits": "^2.0.4",
+ "readable-stream": "^4.2.0"
+ }
+ },
+ "node_modules/broker-factory": {
+ "version": "3.1.13",
+ "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz",
+ "integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "fast-unique-numbers": "^9.0.26",
+ "tslib": "^2.8.1",
+ "worker-factory": "^7.0.48"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/commist": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
+ "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
+ "license": "MIT"
+ },
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/concat-stream/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/fast-unique-numbers": {
+ "version": "9.0.26",
+ "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz",
+ "integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.2.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/help-me": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
+ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/js-sdsl": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
+ "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/js-sdsl"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mqtt": {
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.0.tgz",
+ "integrity": "sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/readable-stream": "^4.0.21",
+ "@types/ws": "^8.18.1",
+ "commist": "^3.2.0",
+ "concat-stream": "^2.0.0",
+ "debug": "^4.4.1",
+ "help-me": "^5.0.0",
+ "lru-cache": "^10.4.3",
+ "minimist": "^1.2.8",
+ "mqtt-packet": "^9.0.2",
+ "number-allocator": "^1.0.14",
+ "readable-stream": "^4.7.0",
+ "rfdc": "^1.4.1",
+ "socks": "^2.8.6",
+ "split2": "^4.2.0",
+ "worker-timers": "^8.0.23",
+ "ws": "^8.18.3"
+ },
+ "bin": {
+ "mqtt": "build/bin/mqtt.js",
+ "mqtt_pub": "build/bin/pub.js",
+ "mqtt_sub": "build/bin/sub.js"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/mqtt-packet": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
+ "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^6.0.8",
+ "debug": "^4.3.4",
+ "process-nextick-args": "^2.0.1"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/number-allocator": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
+ "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.1",
+ "js-sdsl": "4.3.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "license": "MIT"
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
+ "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.115.0",
+ "@rolldown/pluginutils": "1.0.0-rc.9"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.9",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.7",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
+ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.0.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "license": "MIT"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
+ "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/runtime": "0.115.0",
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.9",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.0.0-alpha.31",
+ "esbuild": "^0.27.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/worker-factory": {
+ "version": "7.0.48",
+ "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz",
+ "integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "fast-unique-numbers": "^9.0.26",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/worker-timers": {
+ "version": "8.0.30",
+ "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.30.tgz",
+ "integrity": "sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "tslib": "^2.8.1",
+ "worker-timers-broker": "^8.0.15",
+ "worker-timers-worker": "^9.0.13"
+ }
+ },
+ "node_modules/worker-timers-broker": {
+ "version": "8.0.15",
+ "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz",
+ "integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "broker-factory": "^3.1.13",
+ "fast-unique-numbers": "^9.0.26",
+ "tslib": "^2.8.1",
+ "worker-timers-worker": "^9.0.13"
+ }
+ },
+ "node_modules/worker-timers-worker": {
+ "version": "9.0.13",
+ "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz",
+ "integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "tslib": "^2.8.1",
+ "worker-factory": "^7.0.48"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/packages/modules/display_themes/url_display/source/package.json b/packages/modules/display_themes/url_display/source/package.json
new file mode 100644
index 0000000000..1e6f616f6b
--- /dev/null
+++ b/packages/modules/display_themes/url_display/source/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "openwb-display-url",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host",
+ "build": "vite build --outDir=../web --emptyOutDir",
+ "preview": "vite preview --host"
+ },
+ "dependencies": {
+ "mqtt": "^5.15.0"
+ },
+ "devDependencies": {
+ "vite": "^8.0.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/modules/display_themes/url_display/source/src/main.js b/packages/modules/display_themes/url_display/source/src/main.js
new file mode 100644
index 0000000000..f78b1a1e5c
--- /dev/null
+++ b/packages/modules/display_themes/url_display/source/src/main.js
@@ -0,0 +1,139 @@
+import mqtt from "mqtt";
+import "./style.css";
+
+const THEME_TOPIC_NAME = "openWB/optional/int_display/theme";
+const VALID_PROTOCOLS = new Set(["http:", "https:"]);
+const MESSAGE_TIMEOUT_MS = 20000;
+const messageElement = document.getElementById("message");
+let messageTimeoutHandle;
+let hasProcessedValidUrl = false;
+
+function setStatus(message) {
+ if (messageElement) {
+ messageElement.textContent = message;
+ }
+}
+
+function parseThemePayload(payload) {
+ try {
+ return JSON.parse(payload.toString());
+ } catch (error) {
+ setStatus("Fehler beim Lesen der Konfiguration.");
+ return null;
+ }
+}
+
+function normalizeUrl(inputUrl) {
+ const trimmedUrl = typeof inputUrl === "string" ? inputUrl.trim() : "";
+ if (!trimmedUrl) {
+ return "";
+ }
+ if (/^https?:\/\//i.test(trimmedUrl)) {
+ return trimmedUrl;
+ }
+ return `http://${trimmedUrl}`;
+}
+
+function formatConfiguredValue(inputUrl) {
+ const value = typeof inputUrl === "string" ? inputUrl.trim() : "";
+ return value || "";
+}
+
+function validateUrl(candidateUrl, configuredValue) {
+ if (!candidateUrl) {
+ setStatus(`Fehler: Keine URL in der Theme-Konfiguration gefunden (Wert: ${configuredValue}).`);
+ return null;
+ }
+
+ let parsedUrl;
+ try {
+ parsedUrl = new URL(candidateUrl);
+ } catch (error) {
+ setStatus(`Fehler: Ungültige URL in der Konfiguration (Wert: ${configuredValue}).`);
+ return null;
+ }
+
+ if (!VALID_PROTOCOLS.has(parsedUrl.protocol)) {
+ setStatus(`Fehler: Nur http:// und https:// URLs sind erlaubt (Wert: ${configuredValue}).`);
+ return null;
+ }
+
+ if (!parsedUrl.hostname) {
+ setStatus(`Fehler: Ungültiger Hostname in der URL-Konfiguration (Wert: ${configuredValue}).`);
+ return null;
+ }
+
+ return parsedUrl.toString();
+}
+
+function startMessageTimeout() {
+ clearTimeout(messageTimeoutHandle);
+ messageTimeoutHandle = setTimeout(() => {
+ if (!hasProcessedValidUrl) {
+ setStatus("Warte auf gültige URL-Konfiguration...");
+ }
+ }, MESSAGE_TIMEOUT_MS);
+}
+
+function createClient() {
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
+ const port = parseInt(location.port, 10) || (location.protocol === "https:" ? 443 : 80);
+ const connectUrl = `${protocol}://${location.hostname}:${port}/ws`;
+
+ return mqtt.connect(connectUrl, {
+ connectTimeout: 4000,
+ reconnectPeriod: 4000,
+ clean: false,
+ clientId: Math.random().toString(36).replace(/[^a-z]+/g, "").substring(0, 8),
+ });
+}
+
+const client = createClient();
+
+client.on("connect", () => {
+ setStatus("Verbunden. Lade Konfiguration...");
+ client.subscribe(THEME_TOPIC_NAME, { qos: 0 });
+ startMessageTimeout();
+});
+
+client.on("reconnect", () => {
+ setStatus("MQTT-Verbindung verloren. Verbinde erneut...");
+});
+
+client.on("close", () => {
+ if (!client.connected) {
+ setStatus("MQTT-Verbindung geschlossen. Verbinde erneut...");
+ }
+});
+
+client.on("error", () => {
+ setStatus("MQTT-Verbindungsfehler. Versuche erneut...");
+});
+
+client.on("message", (topic, payload) => {
+ if (topic !== THEME_TOPIC_NAME) {
+ return;
+ }
+
+ const theme = parseThemePayload(payload);
+ if (!theme || !theme.configuration) {
+ setStatus("Fehler: Keine URL in der Theme-Konfiguration gefunden.");
+ hasProcessedValidUrl = false;
+ startMessageTimeout();
+ return;
+ }
+
+ const configuredValue = formatConfiguredValue(theme.configuration.url);
+ const normalizedUrl = normalizeUrl(theme.configuration.url);
+ const validatedUrl = validateUrl(normalizedUrl, configuredValue);
+ if (!validatedUrl) {
+ hasProcessedValidUrl = false;
+ startMessageTimeout();
+ return;
+ }
+
+ hasProcessedValidUrl = true;
+ clearTimeout(messageTimeoutHandle);
+ setStatus(`Lade URL: ${validatedUrl}`);
+ window.location.href = validatedUrl;
+});
diff --git a/packages/modules/display_themes/url_display/source/src/style.css b/packages/modules/display_themes/url_display/source/src/style.css
new file mode 100644
index 0000000000..1355de6fd6
--- /dev/null
+++ b/packages/modules/display_themes/url_display/source/src/style.css
@@ -0,0 +1,30 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ background-color: #000;
+ color: #fff;
+ font-family: sans-serif;
+}
+
+#status {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ flex-direction: column;
+ gap: 1em;
+ padding: 1rem;
+ text-align: center;
+}
+
+#status p {
+ font-size: 1.2em;
+}
diff --git a/packages/modules/display_themes/url_display/source/vite.config.js b/packages/modules/display_themes/url_display/source/vite.config.js
new file mode 100644
index 0000000000..4461934832
--- /dev/null
+++ b/packages/modules/display_themes/url_display/source/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ base: "/openWB/web/display/themes/url_display/",
+ build: {
+ rollupOptions: {
+ output: {
+ entryFileNames: "assets/index.js",
+ chunkFileNames: "assets/[name].js",
+ assetFileNames: "assets/[name][extname]",
+ },
+ },
+ },
+});
diff --git a/web/display/processAllMqttMsg.js b/web/display/processAllMqttMsg.js
index 0a4fca7395..31f1002c2d 100644
--- a/web/display/processAllMqttMsg.js
+++ b/web/display/processAllMqttMsg.js
@@ -19,19 +19,18 @@ function setIframeSource() {
var host = "";
var query = new URLSearchParams();
var destination = "";
- if (data["openWB/general/extern"] === true) {
+ if (data["openWB/general/extern"] === true &&
+ data["openWB/general/extern_display_mode"] !== "local") {
// load secondary display (from secondary openWB)
switch (data["openWB/general/extern_display_mode"]) {
- case "local":
- // host = location.host;
- // ...
- // break;
- // ToDo, fallback to primary
- addLog("Local display in secondary mode not yet supported! fallback to primary display");
case "primary":
default:
// retrieve display theme from primary
host = data["openWB/internal_chargepoint/global_data"]["parent_ip"];
+ if (!host) {
+ addLog("Keine primäre openWB konfiguriert.", true);
+ return;
+ }
const queryObject = {
// we need our own ip address for status information
localIp: data["openWB/system/ip_address"],
@@ -46,7 +45,6 @@ function setIframeSource() {
query.append("data", JSON.stringify(queryObject));
break;
}
- // load display from primary or local
destination = `${location.protocol}//${host}/openWB/web/display/?${query.toString()}`;
addLog(`all done, loading theme from primary`);
// no iframe here as this would result in another nesting with the wrapper on primary
@@ -54,7 +52,7 @@ function setIframeSource() {
location.href = destination;
}, 2000);
} else {
- // load primary display (from primary or secondary openWB)
+ // load configured theme locally (on primary or secondary openWB)
host = location.host;
const theme = data["openWB/optional/int_display/theme"].type;
const searchParams = new URLSearchParams(location.search);
@@ -83,13 +81,16 @@ function setIframeSource() {
iframe.classList.remove("hide");
}, 2000);
} else {
- addLog(`theme '${theme}' not found on server!`);
+ addLog(`check for theme '${theme}' failed: HTTP ${this.status} at '${destination}'`, true);
}
}
};
request.ontimeout = function () {
console.warn("onTimeout", this.readyState, this.status);
- addLog(`check for theme '${theme}' timed out!`);
+ addLog(`check for theme '${theme}' timed out at '${destination}'!`, true);
+ };
+ request.onerror = function () {
+ addLog(`network error checking theme '${theme}' at '${destination}'!`, true);
};
request.timeout = 2000;
console.debug("checking url:", destination);
@@ -97,18 +98,18 @@ function setIframeSource() {
request.send();
}
} else {
- console.debug("some topics still missing");
+ console.debug("some topics still missing", missingTopicNames());
}
}
function addLog(message, forceDisplay = false) {
const logElement = document.getElementById('log');
- let displayedMessages = logElement.innerHTML.split("\n");
+ let displayedMessages = logElement.textContent.split("\n");
if (displayedMessages.length > 25) {
displayedMessages.shift();
}
displayedMessages.push(message);
- logElement.innerHTML = displayedMessages.join("\n");
+ logElement.textContent = displayedMessages.join("\n");
if (forceDisplay) {
logElement.classList.remove("hide");
}
diff --git a/web/display/setupMqttServices.js b/web/display/setupMqttServices.js
index 8021930cd7..7ccd9def74 100644
--- a/web/display/setupMqttServices.js
+++ b/web/display/setupMqttServices.js
@@ -104,39 +104,34 @@ function publish(payload, topic) {
client.publish("openWB/set/system/topicSender", "local client uid: " + client_uid + " sent: " + topic, { qos: 2, retain: true });
}
-function totalTopicCount() {
- var counter = Object.keys(topicsToSubscribe).length;
+function requiredTopics() {
+ const topics = { ...topicsToSubscribe };
if (data["openWB/general/extern"] === true) {
- counter += Object.keys(secondaryTopicsToSubscribe).length;
+ topics["openWB/general/extern_display_mode"] =
+ secondaryTopicsToSubscribe["openWB/general/extern_display_mode"];
+ if (data["openWB/general/extern_display_mode"] === "local") {
+ Object.assign(topics, primaryTopicsToSubscribe);
+ } else {
+ Object.assign(topics, secondaryTopicsToSubscribe);
+ }
} else {
- Object.keys(primaryTopicsToSubscribe).forEach((topic) => {
- counter += primaryTopicsToSubscribe[topic];
- });
+ Object.assign(topics, primaryTopicsToSubscribe);
}
- return counter;
+ return topics;
+}
+
+function totalTopicCount() {
+ return Object.keys(requiredTopics()).length;
+}
+
+function missingTopicNames() {
+ return Object.entries(requiredTopics())
+ .filter(([, received]) => received === false)
+ .map(([topic]) => topic);
}
function missingTopics() {
- var counter = 0;
- Object.keys(topicsToSubscribe).forEach((topic) => {
- if (topicsToSubscribe[topic] === false) {
- counter++;
- };
- });
- if (data["openWB/general/extern"] === true) {
- Object.keys(secondaryTopicsToSubscribe).forEach((topic) => {
- if (secondaryTopicsToSubscribe[topic] === false) {
- counter++;
- };
- });
- } else {
- Object.keys(primaryTopicsToSubscribe).forEach((topic) => {
- if (primaryTopicsToSubscribe[topic] === false) {
- counter++;
- };
- });
- }
- return counter;
+ return missingTopicNames().length;
}
function allTopicsReceived() {
diff --git a/web/display/tests/startup.test.cjs b/web/display/tests/startup.test.cjs
new file mode 100644
index 0000000000..98005c094c
--- /dev/null
+++ b/web/display/tests/startup.test.cjs
@@ -0,0 +1,282 @@
+const assert = require("node:assert/strict");
+const { readFileSync } = require("node:fs");
+const path = require("node:path");
+const vm = require("node:vm");
+const { test } = require("node:test");
+
+function createDisplay(search = "") {
+ const elements = new Map();
+ const handlers = {};
+ const requests = [];
+ const timers = [];
+ const subscriptions = [];
+ function element(id) {
+ if (!elements.has(id)) {
+ const classes = new Set(["log", "displayTarget"].includes(id) ? ["hide"] : []);
+ elements.set(id, {
+ textContent: "",
+ src: id === "displayTarget" ? "about:blank" : "",
+ style: {},
+ classList: {
+ add: (name) => classes.add(name),
+ remove: (name) => classes.delete(name),
+ contains: (name) => classes.has(name),
+ },
+ scrollTo() {},
+ });
+ }
+ return elements.get(id);
+ }
+ const context = vm.createContext({
+ console: { debug() {}, warn() {} },
+ URLSearchParams,
+ location: {
+ protocol: "http:",
+ host: "secondary:8080",
+ hostname: "secondary",
+ port: "8080",
+ search,
+ href: "",
+ },
+ document: {
+ getElementById: element,
+ querySelector: (selector) => element(selector.slice(1)),
+ },
+ mqtt: {
+ connect: () => ({
+ on: (event, handler) => { handlers[event] = handler; },
+ subscribe: (topic) => subscriptions.push(topic),
+ }),
+ },
+ XMLHttpRequest: class {
+ constructor() { requests.push(this); }
+ open(method, url) { this.url = url; }
+ send() {}
+ },
+ setTimeout: (callback) => timers.push(callback),
+ });
+ for (const filename of ["processAllMqttMsg.js", "setupMqttServices.js"]) {
+ vm.runInContext(readFileSync(path.join(__dirname, "..", filename), "utf8"), context);
+ }
+ handlers.connect({});
+ return {
+ context, element, requests, timers, subscriptions,
+ receive(topic, payload) { handlers.message(topic, JSON.stringify(payload)); },
+ };
+}
+
+function sendCommon(display, secondary = true, bootDone = true, updating = false) {
+ for (const [topic, value] of Object.entries({
+ "openWB/system/version": "2.3.0-alpha.1",
+ "openWB/system/boot_done": bootDone,
+ "openWB/system/update_in_progress": updating,
+ "openWB/system/security/user_management_active": false,
+ "openWB/general/extern": secondary,
+ })) {
+ display.receive(topic, value);
+ }
+}
+
+function sendLocalTheme(display) {
+ display.receive("openWB/optional/int_display/theme", {
+ type: "url_display",
+ configuration: { url: "http://192.168.199.254" },
+ });
+ display.receive("openWB/optional/int_display/only_local_charge_points", false);
+}
+
+function localDisplay() {
+ const display = createDisplay();
+ sendCommon(display);
+ sendLocalTheme(display);
+ display.receive("openWB/general/extern_display_mode", "local");
+ return display;
+}
+
+test("secondary local mode starts without any parent data or secondary metadata", () => {
+ const display = localDisplay();
+ assert.equal(display.context.allTopicsReceived(), true);
+ assert.equal(display.context.totalTopicCount(), 8);
+ assert.equal(display.element("progress-value").style.width, "100%");
+ assert.equal(display.requests.length, 1);
+ assert.equal(display.requests[0].url,
+ "http://secondary:8080/openWB/web/display/themes/url_display/?");
+ Object.assign(display.requests[0], { readyState: 4, status: 200 });
+ display.requests[0].onload();
+ display.timers.forEach((callback) => callback());
+ assert.equal(display.element("displayTarget").src, display.requests[0].url);
+ assert.equal(display.element("displayTarget").classList.contains("hide"), false);
+ assert.equal(display.element("notReady").classList.contains("hide"), true);
+ assert.equal(display.context.location.href, "");
+});
+
+test("secondary waits for its display mode before choosing a destination", () => {
+ const display = createDisplay();
+ sendCommon(display);
+ sendLocalTheme(display);
+ assert.equal(display.context.allTopicsReceived(), false);
+ assert.equal(display.requests.length, 0);
+ assert.equal(display.timers.length, 0);
+ assert.ok(display.context.missingTopicNames().includes("openWB/general/extern_display_mode"));
+});
+
+test("local mode waits for local theme settings without extra on-screen logging", () => {
+ const display = createDisplay();
+ sendCommon(display);
+ display.receive("openWB/general/extern_display_mode", "local");
+ display.receive("openWB/optional/int_display/only_local_charge_points", false);
+ assert.equal(display.requests.length, 0);
+ assert.equal(display.context.missingTopics(), 1);
+ assert.deepEqual(Array.from(display.context.missingTopicNames()),
+ ["openWB/optional/int_display/theme"]);
+ assert.ok(!display.element("log").textContent.includes("Waiting for MQTT topics:"));
+ sendLocalTheme(display);
+ assert.equal(display.context.allTopicsReceived(), true);
+});
+
+test("primary loads its local theme without secondary topics", () => {
+ const display = createDisplay();
+ sendCommon(display, false);
+ sendLocalTheme(display);
+ assert.equal(display.context.allTopicsReceived(), true);
+ assert.equal(display.context.totalTopicCount(), 7);
+ assert.equal(display.requests.length, 1);
+});
+
+test("secondary primary mode still waits for parent data and forwards null mappings", () => {
+ const display = createDisplay();
+ sendCommon(display);
+ for (const [topic, value] of Object.entries({
+ "openWB/system/ip_address": "172.16.0.4",
+ "openWB/system/current_branch": "test",
+ "openWB/system/current_commit": "123456",
+ "openWB/general/extern_display_mode": "primary",
+ "openWB/internal_chargepoint/0/data/parent_cp": null,
+ "openWB/internal_chargepoint/1/data/parent_cp": null,
+ })) {
+ display.receive(topic, value);
+ }
+ assert.equal(display.context.missingTopics(), 1);
+ assert.deepEqual(Array.from(display.context.missingTopicNames()),
+ ["openWB/internal_chargepoint/global_data"]);
+ assert.equal(display.timers.length, 0);
+ display.receive("openWB/internal_chargepoint/global_data", { parent_ip: "primary" });
+ assert.equal(display.context.allTopicsReceived(), true);
+ assert.equal(display.requests.length, 0);
+ display.timers.forEach((callback) => callback());
+ const destination = new URL(display.context.location.href);
+ assert.equal(destination.host, "primary");
+ const forwarded = JSON.parse(destination.searchParams.get("data"));
+ assert.equal(forwarded.localIp, "172.16.0.4");
+ assert.equal(forwarded.parentChargePoint1, null);
+ assert.equal(forwarded.parentChargePoint2, null);
+});
+
+test("switching from primary to local mode drops missing parent requirements", () => {
+ const display = createDisplay();
+ sendCommon(display);
+ sendLocalTheme(display);
+ display.receive("openWB/general/extern_display_mode", "primary");
+ assert.equal(display.requests.length, 0);
+ display.receive("openWB/general/extern_display_mode", "local");
+ assert.equal(display.context.allTopicsReceived(), true);
+ assert.equal(display.requests.length, 1);
+});
+
+for (const [bootDone, updating, message] of [
+ [false, false, "backend still booting"],
+ [true, true, "update in progress"],
+]) {
+ test(`local mode respects startup guard: ${message}`, () => {
+ const display = createDisplay();
+ sendCommon(display, true, bootDone, updating);
+ sendLocalTheme(display);
+ display.receive("openWB/general/extern_display_mode", "local");
+ assert.equal(display.requests.length, 0);
+ assert.ok(display.element("log").textContent.endsWith(message));
+ assert.equal(display.element("displayTarget").classList.contains("hide"), true);
+ });
+}
+
+for (const [event, expected] of [
+ ["onload", "HTTP 404"],
+ ["onerror", "network error"],
+ ["ontimeout", "timed out"],
+]) {
+ test(`theme request ${event} failure is visible on the display`, () => {
+ const display = localDisplay();
+ Object.assign(display.requests[0], { readyState: 4, status: 404 });
+ display.requests[0][event]();
+ assert.ok(display.element("log").textContent.includes(expected));
+ assert.equal(display.element("log").classList.contains("hide"), false);
+ assert.equal(display.element("displayTarget").src, "about:blank");
+ });
+}
+
+test("local theme preserves forwarded data and login suppression", () => {
+ const display = createDisplay("?data=%7B%22localIp%22%3A%22secondary%22%7D");
+ sendCommon(display);
+ display.context.credentialsFetched = true;
+ sendLocalTheme(display);
+ display.receive("openWB/general/extern_display_mode", "local");
+ const destination = new URL(display.requests[0].url);
+ assert.equal(destination.searchParams.get("data"), '{"localIp":"secondary"}');
+ assert.equal(destination.searchParams.get("hide_login"), "1");
+});
+
+for (const [secondary, mode, expectedGroup] of [
+ [undefined, undefined, "primary"],
+ [false, undefined, "primary"],
+ [false, "primary", "primary"],
+ [false, "local", "primary"],
+ [true, undefined, "secondary"],
+ [true, null, "secondary"],
+ [true, "primary", "secondary"],
+ [true, "unknown", "secondary"],
+ [true, "local", "local"],
+]) {
+ test(`readiness requirements: secondary=${secondary}, mode=${mode}`, () => {
+ const { context, subscriptions } = createDisplay();
+ context.data["openWB/general/extern"] = secondary;
+ context.data["openWB/general/extern_display_mode"] = mode;
+ const common = context.topicsToSubscribe;
+ const primary = context.primaryTopicsToSubscribe;
+ const remote = context.secondaryTopicsToSubscribe;
+ const expected = [
+ ...Object.keys(common),
+ ...Object.keys(expectedGroup === "secondary" ? remote : primary),
+ ...(expectedGroup === "local" ? ["openWB/general/extern_display_mode"] : []),
+ ];
+ assert.deepEqual(Object.keys(context.requiredTopics()).sort(), expected.sort());
+ assert.equal(context.totalTopicCount(), expected.length);
+ assert.equal(context.missingTopics(), expected.length);
+ assert.deepEqual(subscriptions.sort(),
+ [...Object.keys(common), ...Object.keys(primary), ...Object.keys(remote)].sort());
+
+ for (const group of [common, primary, remote]) {
+ for (const topic of Object.keys(group)) {
+ group[topic] = true;
+ }
+ }
+ assert.equal(context.allTopicsReceived(), true);
+ for (const group of [common, primary, remote]) {
+ for (const topic of Object.keys(group)) {
+ group[topic] = false;
+ const required = expected.includes(topic);
+ assert.equal(context.missingTopics(), required ? 1 : 0, topic);
+ assert.equal(context.allTopicsReceived(), !required, topic);
+ group[topic] = true;
+ }
+ }
+ });
+}
+
+test("receiving local mode before the device role does not start the theme early", () => {
+ const display = createDisplay();
+ display.receive("openWB/general/extern_display_mode", "local");
+ sendLocalTheme(display);
+ assert.equal(display.requests.length, 0);
+ sendCommon(display);
+ assert.equal(display.context.allTopicsReceived(), true);
+ assert.equal(display.requests.length, 1);
+});
diff --git a/web/display/themes/url_display b/web/display/themes/url_display
new file mode 120000
index 0000000000..567b541a34
--- /dev/null
+++ b/web/display/themes/url_display
@@ -0,0 +1 @@
+../../../packages/modules/display_themes/url_display/web/
\ No newline at end of file