From 573fa797f3942b8c384b80e971e5f969dd1d9cd0 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:08:10 +0000 Subject: [PATCH 01/25] Move Webasto Connect Card assets to dedicated card folder --- card/README.md | 20 ++++ card/webasto-connect-card.js | 186 +++++++++++++++++++++++++++++++++ card/webasto_connect_card.yaml | 19 ++++ 3 files changed, 225 insertions(+) create mode 100644 card/README.md create mode 100644 card/webasto-connect-card.js create mode 100644 card/webasto_connect_card.yaml diff --git a/card/README.md b/card/README.md new file mode 100644 index 0000000..9664e2e --- /dev/null +++ b/card/README.md @@ -0,0 +1,20 @@ +# Webasto Connect Card + +Custom Home Assistant Lovelace card. + +## Files +- `webasto-connect-card.js`: custom card module (add as Lovelace resource) +- `webasto_connect_card.yaml`: example card configuration + +## Install in Home Assistant +From this repository root: + +```bash +cp card/webasto-connect-card.js config/www/webasto-connect-card.js +``` + +Then add a Lovelace resource: +- URL: `/local/webasto-connect-card.js` +- Type: `module` + +Use the example from `card/webasto_connect_card.yaml` and set your own entity IDs. diff --git a/card/webasto-connect-card.js b/card/webasto-connect-card.js new file mode 100644 index 0000000..4593f66 --- /dev/null +++ b/card/webasto-connect-card.js @@ -0,0 +1,186 @@ +class WebastoConnectCard extends HTMLElement { + setConfig(config) { + if (!config.main_output_entity) { + throw new Error("Missing required config: main_output_entity"); + } + this._config = { + ventilation_mode_entity: config.ventilation_mode_entity, + end_time_entity: config.end_time_entity, + title_geo_fence: "Geo-fence", + title_mode: "Modus", + title_timers: "Timere", + title_map: "Kort", + ...config, + }; + } + + set hass(hass) { + this._hass = hass; + this._render(); + } + + connectedCallback() { + if (!this.shadowRoot) { + this.attachShadow({ mode: "open" }); + } + this._render(); + } + + _getState(entityId) { + return entityId ? this._hass?.states?.[entityId] : undefined; + } + + _computeLabel(endEntity) { + if (!endEntity || !endEntity.state || endEntity.state === "unknown" || endEntity.state === "unavailable") { + return "Ikke aktiv"; + } + + const end = new Date(endEntity.state); + if (Number.isNaN(end.getTime())) { + return "Ikke aktiv"; + } + + const leftMinutes = Math.round((end.getTime() - Date.now()) / 60000); + if (leftMinutes <= 0) { + return "Slutter nu"; + } + return `${leftMinutes} minutter tilbage`; + } + + _computeOutputName(ventModeState) { + return ventModeState?.state === "on" ? "Ventilation" : "Heater"; + } + + _toggleMainOutput() { + if (!this._hass || !this._config?.main_output_entity) { + return; + } + this._hass.callService("switch", "toggle", { + entity_id: this._config.main_output_entity, + }); + } + + _render() { + if (!this.shadowRoot || !this._config || !this._hass) { + return; + } + + const main = this._getState(this._config.main_output_entity); + const vent = this._getState(this._config.ventilation_mode_entity); + const end = this._getState(this._config.end_time_entity); + + const isOn = main?.state === "on"; + const ringColor = isOn ? "#d33131" : "#2ea44f"; + const outputName = this._computeOutputName(vent); + const label = this._computeLabel(end); + const icon = this._config.center_icon || "mdi:car-defrost-rear"; + + this.shadowRoot.innerHTML = ` + + +
${this._config.title_geo_fence}
+
${this._config.title_mode}
+
${this._config.title_timers}
+
${this._config.title_map}
+
+ +
${outputName}
+
${label}
+
+
+ `; + + const center = this.shadowRoot.getElementById("center-toggle"); + if (center) { + center.onclick = () => this._toggleMainOutput(); + center.onkeydown = (ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + this._toggleMainOutput(); + } + }; + } + } + + getCardSize() { + return 4; + } +} + +customElements.define("webasto-connect-card", WebastoConnectCard); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "webasto-connect-card", + name: "Webasto Connect Card", + description: "Webasto Connect card with center toggle for main output", +}); diff --git a/card/webasto_connect_card.yaml b/card/webasto_connect_card.yaml new file mode 100644 index 0000000..ec6f991 --- /dev/null +++ b/card/webasto_connect_card.yaml @@ -0,0 +1,19 @@ +# 1) Add this resource in Home Assistant: +# URL: /local/webasto-connect-card.js +# Type: module +# +# 2) Place `card/webasto-connect-card.js` in your HA www folder: +# config/www/webasto-connect-card.js +# +# 3) Use this card config: + +type: custom:webasto-connect-card +main_output_entity: switch.webasto_main_output +ventilation_mode_entity: switch.webasto_ventilation_mode +end_time_entity: sensor.webasto_main_output_end_time + +# Optional labels: +# title_geo_fence: Geo-fence +# title_mode: Modus +# title_timers: Timere +# title_map: Kort From 8e4979e080f4e7aff35c4079ff4f2b571a6a0ad0 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:10:31 +0000 Subject: [PATCH 02/25] Add language-aware translations to Webasto Connect Card --- card/README.md | 9 ++++ card/webasto-connect-card.js | 81 +++++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/card/README.md b/card/README.md index 9664e2e..8b0f53c 100644 --- a/card/README.md +++ b/card/README.md @@ -18,3 +18,12 @@ Then add a Lovelace resource: - Type: `module` Use the example from `card/webasto_connect_card.yaml` and set your own entity IDs. + +## Language / translations +- The card auto-selects text from Home Assistant language (`hass.language`). +- Built-in translations currently include `da` and `en` (fallback: `en`). +- You can still override corner titles with: + - `title_geo_fence` + - `title_mode` + - `title_timers` + - `title_map` diff --git a/card/webasto-connect-card.js b/card/webasto-connect-card.js index 4593f66..65c2407 100644 --- a/card/webasto-connect-card.js +++ b/card/webasto-connect-card.js @@ -1,4 +1,31 @@ class WebastoConnectCard extends HTMLElement { + static TRANSLATIONS = { + en: { + geo_fence: "Geo-fence", + mode: "Mode", + timers: "Timers", + map: "Map", + inactive: "Inactive", + ending_now: "Ending now", + minutes_left: "{minutes} minutes left", + heater: "Heater", + ventilation: "Ventilation", + toggle_output: "Toggle output", + }, + da: { + geo_fence: "Geo-fence", + mode: "Modus", + timers: "Timere", + map: "Kort", + inactive: "Ikke aktiv", + ending_now: "Slutter nu", + minutes_left: "{minutes} minutter tilbage", + heater: "Heater", + ventilation: "Ventilation", + toggle_output: "Skift output", + }, + }; + setConfig(config) { if (!config.main_output_entity) { throw new Error("Missing required config: main_output_entity"); @@ -6,10 +33,6 @@ class WebastoConnectCard extends HTMLElement { this._config = { ventilation_mode_entity: config.ventilation_mode_entity, end_time_entity: config.end_time_entity, - title_geo_fence: "Geo-fence", - title_mode: "Modus", - title_timers: "Timere", - title_map: "Kort", ...config, }; } @@ -30,25 +53,50 @@ class WebastoConnectCard extends HTMLElement { return entityId ? this._hass?.states?.[entityId] : undefined; } + _lang() { + const raw = String(this._hass?.language || "en").toLowerCase(); + if (WebastoConnectCard.TRANSLATIONS[raw]) { + return raw; + } + const short = raw.split("-")[0]; + return WebastoConnectCard.TRANSLATIONS[short] ? short : "en"; + } + + _t(key, vars = {}) { + const lang = this._lang(); + let text = + WebastoConnectCard.TRANSLATIONS[lang][key] || + WebastoConnectCard.TRANSLATIONS.en[key] || + key; + + Object.entries(vars).forEach(([name, value]) => { + text = text.replace(`{${name}}`, String(value)); + }); + + return text; + } + _computeLabel(endEntity) { if (!endEntity || !endEntity.state || endEntity.state === "unknown" || endEntity.state === "unavailable") { - return "Ikke aktiv"; + return this._t("inactive"); } const end = new Date(endEntity.state); if (Number.isNaN(end.getTime())) { - return "Ikke aktiv"; + return this._t("inactive"); } const leftMinutes = Math.round((end.getTime() - Date.now()) / 60000); if (leftMinutes <= 0) { - return "Slutter nu"; + return this._t("ending_now"); } - return `${leftMinutes} minutter tilbage`; + return this._t("minutes_left", { minutes: leftMinutes }); } _computeOutputName(ventModeState) { - return ventModeState?.state === "on" ? "Ventilation" : "Heater"; + return ventModeState?.state === "on" + ? this._t("ventilation") + : this._t("heater"); } _toggleMainOutput() { @@ -74,6 +122,11 @@ class WebastoConnectCard extends HTMLElement { const outputName = this._computeOutputName(vent); const label = this._computeLabel(end); const icon = this._config.center_icon || "mdi:car-defrost-rear"; + const titleGeoFence = this._config.title_geo_fence || this._t("geo_fence"); + const titleMode = this._config.title_mode || this._t("mode"); + const titleTimers = this._config.title_timers || this._t("timers"); + const titleMap = this._config.title_map || this._t("map"); + const toggleLabel = this._t("toggle_output"); this.shadowRoot.innerHTML = ` -
${this._config.title_geo_fence}
-
${this._config.title_mode}
-
${this._config.title_timers}
-
${this._config.title_map}
-
+
${titleGeoFence}
+
${titleMode}
+
${titleTimers}
+
${titleMap}
+
${outputName}
${label}
From 5c71ba6971b9724b6b4daa220caccd633b2d5798 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:19:02 +0000 Subject: [PATCH 03/25] Refactor card translations using localize module and language files --- card/README.md | 7 ++- card/localize/localize.js | 48 +++++++++++++++++++++ card/translations/da.js | 16 +++++++ card/translations/en.js | 16 +++++++ card/webasto-connect-card.js | 79 ++++++++-------------------------- card/webasto_connect_card.yaml | 7 +-- 6 files changed, 108 insertions(+), 65 deletions(-) create mode 100644 card/localize/localize.js create mode 100644 card/translations/da.js create mode 100644 card/translations/en.js diff --git a/card/README.md b/card/README.md index 8b0f53c..6013938 100644 --- a/card/README.md +++ b/card/README.md @@ -4,17 +4,20 @@ Custom Home Assistant Lovelace card. ## Files - `webasto-connect-card.js`: custom card module (add as Lovelace resource) +- `localize/localize.js`: translation lookup and language fallback +- `translations/*.js`: per-language strings - `webasto_connect_card.yaml`: example card configuration ## Install in Home Assistant From this repository root: ```bash -cp card/webasto-connect-card.js config/www/webasto-connect-card.js +mkdir -p config/www/webasto-connect-card +cp -r card/* config/www/webasto-connect-card/ ``` Then add a Lovelace resource: -- URL: `/local/webasto-connect-card.js` +- URL: `/local/webasto-connect-card/webasto-connect-card.js` - Type: `module` Use the example from `card/webasto_connect_card.yaml` and set your own entity IDs. diff --git a/card/localize/localize.js b/card/localize/localize.js new file mode 100644 index 0000000..8c656c5 --- /dev/null +++ b/card/localize/localize.js @@ -0,0 +1,48 @@ +import { da } from "../translations/da.js"; +import { en } from "../translations/en.js"; + +const languages = { + da, + en, +}; + +function getNestedTranslation(obj, path) { + if (!obj) return undefined; + + const keys = path.split("."); + let result = obj; + + for (const key of keys) { + if (result === undefined || result === null || typeof result !== "object") { + return undefined; + } + result = result[key]; + } + + return typeof result === "string" ? result : undefined; +} + +function resolveLanguage(language) { + const raw = String(language || "en").toLowerCase(); + if (languages[raw]) return raw; + + const short = raw.split("-")[0]; + if (languages[short]) return short; + + return "en"; +} + +export function localize(hass, key, vars = {}) { + const lang = resolveLanguage(hass?.language); + + let translated = + getNestedTranslation(languages[lang], key) ?? + getNestedTranslation(languages.en, key) ?? + key; + + Object.entries(vars).forEach(([name, value]) => { + translated = translated.replace(`{${name}}`, String(value)); + }); + + return translated; +} diff --git a/card/translations/da.js b/card/translations/da.js new file mode 100644 index 0000000..e95e12d --- /dev/null +++ b/card/translations/da.js @@ -0,0 +1,16 @@ +export const da = { + card: { + ui: { + geo_fence: "Geo-fence", + mode: "Modus", + timers: "Timere", + map: "Kort", + inactive: "Ikke aktiv", + ending_now: "Slutter nu", + minutes_left: "{minutes} minutter tilbage", + heater: "Heater", + ventilation: "Ventilation", + toggle_output: "Skift output", + }, + }, +}; diff --git a/card/translations/en.js b/card/translations/en.js new file mode 100644 index 0000000..29fb2c3 --- /dev/null +++ b/card/translations/en.js @@ -0,0 +1,16 @@ +export const en = { + card: { + ui: { + geo_fence: "Geo-fence", + mode: "Mode", + timers: "Timers", + map: "Map", + inactive: "Inactive", + ending_now: "Ending now", + minutes_left: "{minutes} minutes left", + heater: "Heater", + ventilation: "Ventilation", + toggle_output: "Toggle output", + }, + }, +}; diff --git a/card/webasto-connect-card.js b/card/webasto-connect-card.js index 65c2407..b177be0 100644 --- a/card/webasto-connect-card.js +++ b/card/webasto-connect-card.js @@ -1,30 +1,6 @@ +import { localize } from "./localize/localize.js"; + class WebastoConnectCard extends HTMLElement { - static TRANSLATIONS = { - en: { - geo_fence: "Geo-fence", - mode: "Mode", - timers: "Timers", - map: "Map", - inactive: "Inactive", - ending_now: "Ending now", - minutes_left: "{minutes} minutes left", - heater: "Heater", - ventilation: "Ventilation", - toggle_output: "Toggle output", - }, - da: { - geo_fence: "Geo-fence", - mode: "Modus", - timers: "Timere", - map: "Kort", - inactive: "Ikke aktiv", - ending_now: "Slutter nu", - minutes_left: "{minutes} minutter tilbage", - heater: "Heater", - ventilation: "Ventilation", - toggle_output: "Skift output", - }, - }; setConfig(config) { if (!config.main_output_entity) { @@ -53,50 +29,29 @@ class WebastoConnectCard extends HTMLElement { return entityId ? this._hass?.states?.[entityId] : undefined; } - _lang() { - const raw = String(this._hass?.language || "en").toLowerCase(); - if (WebastoConnectCard.TRANSLATIONS[raw]) { - return raw; - } - const short = raw.split("-")[0]; - return WebastoConnectCard.TRANSLATIONS[short] ? short : "en"; - } - - _t(key, vars = {}) { - const lang = this._lang(); - let text = - WebastoConnectCard.TRANSLATIONS[lang][key] || - WebastoConnectCard.TRANSLATIONS.en[key] || - key; - - Object.entries(vars).forEach(([name, value]) => { - text = text.replace(`{${name}}`, String(value)); - }); - - return text; - } - _computeLabel(endEntity) { if (!endEntity || !endEntity.state || endEntity.state === "unknown" || endEntity.state === "unavailable") { - return this._t("inactive"); + return localize(this._hass, "card.ui.inactive"); } const end = new Date(endEntity.state); if (Number.isNaN(end.getTime())) { - return this._t("inactive"); + return localize(this._hass, "card.ui.inactive"); } const leftMinutes = Math.round((end.getTime() - Date.now()) / 60000); if (leftMinutes <= 0) { - return this._t("ending_now"); + return localize(this._hass, "card.ui.ending_now"); } - return this._t("minutes_left", { minutes: leftMinutes }); + return localize(this._hass, "card.ui.minutes_left", { + minutes: leftMinutes, + }); } _computeOutputName(ventModeState) { return ventModeState?.state === "on" - ? this._t("ventilation") - : this._t("heater"); + ? localize(this._hass, "card.ui.ventilation") + : localize(this._hass, "card.ui.heater"); } _toggleMainOutput() { @@ -122,11 +77,15 @@ class WebastoConnectCard extends HTMLElement { const outputName = this._computeOutputName(vent); const label = this._computeLabel(end); const icon = this._config.center_icon || "mdi:car-defrost-rear"; - const titleGeoFence = this._config.title_geo_fence || this._t("geo_fence"); - const titleMode = this._config.title_mode || this._t("mode"); - const titleTimers = this._config.title_timers || this._t("timers"); - const titleMap = this._config.title_map || this._t("map"); - const toggleLabel = this._t("toggle_output"); + const titleGeoFence = + this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); + const titleMode = + this._config.title_mode || localize(this._hass, "card.ui.mode"); + const titleTimers = + this._config.title_timers || localize(this._hass, "card.ui.timers"); + const titleMap = + this._config.title_map || localize(this._hass, "card.ui.map"); + const toggleLabel = localize(this._hass, "card.ui.toggle_output"); this.shadowRoot.innerHTML = ` + +
${titleGeoFence}
+
${titleMode}
+
${titleTimers}
+
${titleMap}
+
+ +
${outputName}
+
${label}
+
+
+ `; + + const center = this.shadowRoot.getElementById("center-toggle"); + if (center) { + center.onclick = () => this._toggleMainOutput(); + center.onkeydown = (ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + this._toggleMainOutput(); + } + }; + } + } + + getCardSize() { + return 4; + } +} + +customElements.define("webasto-connect-card", WebastoConnectCard); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "webasto-connect-card", + name: "Webasto Connect Card", + description: "Webasto Connect card with center toggle for main output", +}); diff --git a/custom_components/webastoconnect/card/webasto_connect_card.yaml b/card-src/webasto_connect_card.yaml similarity index 82% rename from custom_components/webastoconnect/card/webasto_connect_card.yaml rename to card-src/webasto_connect_card.yaml index 0ef081e..5ac235b 100644 --- a/custom_components/webastoconnect/card/webasto_connect_card.yaml +++ b/card-src/webasto_connect_card.yaml @@ -5,7 +5,7 @@ # 2) Assets are auto-installed by the integration on load/update. # Manual copy (optional): # mkdir -p config/www/webastoconnect -# cp -r custom_components/webastoconnect/card/* config/www/webastoconnect/ +# cp custom_components/webastoconnect/card/webasto-connect-card.js config/www/webastoconnect/webasto-connect-card.js # # 3) Use this card config: diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index e15581a..a14cd9c 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -1,7 +1,78 @@ -import { localize } from "./localize/localize.js"; +const WEBASTO_CONNECT_CARD_TRANSLATIONS = { + da: { + card: { + ui: { + geo_fence: "Geo-fence", + mode: "Modus", + timers: "Timere", + map: "Kort", + inactive: "Ikke aktiv", + ending_now: "Slutter nu", + minutes_left: "{minutes} minutter tilbage", + output: "Output", + toggle_output: "Skift output", + }, + }, + }, + en: { + card: { + ui: { + geo_fence: "Geo-fence", + mode: "Mode", + timers: "Timers", + map: "Map", + inactive: "Inactive", + ending_now: "Ending now", + minutes_left: "{minutes} minutes left", + output: "Output", + toggle_output: "Toggle output", + }, + }, + }, +}; -class WebastoConnectCard extends HTMLElement { +function getNestedTranslation(obj, path) { + if (!obj) return undefined; + + const keys = path.split("."); + let result = obj; + + for (const key of keys) { + if (result === undefined || result === null || typeof result !== "object") { + return undefined; + } + result = result[key]; + } + + return typeof result === "string" ? result : undefined; +} + +function resolveLanguage(language) { + const raw = String(language || "en").toLowerCase(); + if (WEBASTO_CONNECT_CARD_TRANSLATIONS[raw]) return raw; + + const short = raw.split("-")[0]; + if (WEBASTO_CONNECT_CARD_TRANSLATIONS[short]) return short; + + return "en"; +} +function localize(hass, key, vars = {}) { + const lang = resolveLanguage(hass?.language); + + let translated = + getNestedTranslation(WEBASTO_CONNECT_CARD_TRANSLATIONS[lang], key) ?? + getNestedTranslation(WEBASTO_CONNECT_CARD_TRANSLATIONS.en, key) ?? + key; + + Object.entries(vars).forEach(([name, value]) => { + translated = translated.replace(`{${name}}`, String(value)); + }); + + return translated; +} + +class WebastoConnectCard extends HTMLElement { setConfig(config) { if (!config.main_output_entity) { throw new Error("Missing required config: main_output_entity"); @@ -30,7 +101,12 @@ class WebastoConnectCard extends HTMLElement { } _computeLabel(endEntity) { - if (!endEntity || !endEntity.state || endEntity.state === "unknown" || endEntity.state === "unavailable") { + if ( + !endEntity || + !endEntity.state || + endEntity.state === "unknown" || + endEntity.state === "unavailable" + ) { return localize(this._hass, "card.ui.inactive"); } @@ -80,12 +156,10 @@ class WebastoConnectCard extends HTMLElement { const icon = this._config.center_icon || "mdi:car-defrost-rear"; const titleGeoFence = this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); - const titleMode = - this._config.title_mode || localize(this._hass, "card.ui.mode"); + const titleMode = this._config.title_mode || localize(this._hass, "card.ui.mode"); const titleTimers = this._config.title_timers || localize(this._hass, "card.ui.timers"); - const titleMap = - this._config.title_map || localize(this._hass, "card.ui.map"); + const titleMap = this._config.title_map || localize(this._hass, "card.ui.map"); const toggleLabel = localize(this._hass, "card.ui.toggle_output"); this.shadowRoot.innerHTML = ` @@ -110,13 +184,8 @@ class WebastoConnectCard extends HTMLElement { padding: 16px; box-sizing: border-box; } - .q.tr, .q.br { - justify-content: flex-end; - text-align: right; - } - .q.bl, .q.br { - align-items: flex-end; - } + .q.tr, .q.br { justify-content: flex-end; text-align: right; } + .q.bl, .q.br { align-items: flex-end; } .q.tl { left: 0; top: 0; width: calc(50% - 8px); height: calc(50% - 8px); } .q.tr { right: 0; top: 0; width: calc(50% - 8px); height: calc(50% - 8px); } .q.bl { left: 0; bottom: 0; width: calc(50% - 8px); height: calc(50% - 8px); } @@ -140,16 +209,8 @@ class WebastoConnectCard extends HTMLElement { user-select: none; transition: border-color 150ms ease; } - .icon { - color: #2a4677; - margin-bottom: 10px; - } - .name { - color: #20334d; - font-size: 36px; - line-height: 1.1; - font-weight: 500; - } + .icon { color: #2a4677; margin-bottom: 10px; } + .name { color: #20334d; font-size: 36px; line-height: 1.1; font-weight: 500; } .label { color: #20334d; font-size: 24px; diff --git a/custom_components/webastoconnect/card_install.py b/custom_components/webastoconnect/card_install.py index 8ba1151..06b4e08 100644 --- a/custom_components/webastoconnect/card_install.py +++ b/custom_components/webastoconnect/card_install.py @@ -11,8 +11,6 @@ CARD_WWW_SUBDIR, ) -CARD_ASSET_DIRECTORIES = ("localize", "translations") - def read_version_file(path: Path) -> str | None: """Read a version marker file, returning None when missing/empty.""" @@ -58,10 +56,5 @@ def ensure_card_installed(integration_path: Path, www_path: Path) -> tuple[bool, shutil.copy2(source_entry, target_entry) - for dirname in CARD_ASSET_DIRECTORIES: - source_assets = source_dir / dirname - if source_assets.is_dir(): - shutil.copytree(source_assets, target_dir / dirname, dirs_exist_ok=True) - target_version_file.write_text(source_version, encoding="utf-8") return True, source_version diff --git a/tests/test_card_install.py b/tests/test_card_install.py index 3c5c302..31defd9 100644 --- a/tests/test_card_install.py +++ b/tests/test_card_install.py @@ -12,19 +12,12 @@ def _prepare_source_tree(base: Path, version: str) -> Path: integration_path = base / "integration" source_dir = integration_path / "card" - (source_dir / "localize").mkdir(parents=True) - (source_dir / "translations").mkdir(parents=True) + source_dir.mkdir(parents=True) (source_dir / "webasto-connect-card.js").write_text( "console.log('webasto card');", encoding="utf-8" ) (source_dir / "VERSION").write_text(version, encoding="utf-8") - (source_dir / "localize" / "localize.js").write_text( - "export const x = 1;", encoding="utf-8" - ) - (source_dir / "translations" / "en.json").write_text( - '{"card":{"ui":{"mode":"Mode"}}}', encoding="utf-8" - ) return integration_path @@ -45,8 +38,8 @@ def test_should_install_card_logic(tmp_path: Path) -> None: assert should_install_card(None, "0.1.0", target) is False -def test_ensure_card_installed_copies_assets_and_version(tmp_path: Path) -> None: - """Card files should be copied to www folder and version persisted.""" +def test_ensure_card_installed_copies_bundle_and_version(tmp_path: Path) -> None: + """Built card should be copied to www folder and version persisted.""" integration_path = _prepare_source_tree(tmp_path, "0.1.0") www_path = tmp_path / "www" @@ -55,8 +48,6 @@ def test_ensure_card_installed_copies_assets_and_version(tmp_path: Path) -> None assert installed is True assert version == "0.1.0" assert (www_path / "webastoconnect" / "webasto-connect-card.js").exists() - assert (www_path / "webastoconnect" / "localize" / "localize.js").exists() - assert (www_path / "webastoconnect" / "translations" / "en.json").exists() assert ( (www_path / "webastoconnect" / "webasto-connect-card.version").read_text( encoding="utf-8" From e980663b976041c865a8d4a7d0c066f313dec9d0 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:40:59 +0000 Subject: [PATCH 09/25] Read card version from JS marker and remove VERSION file --- card-src/webasto-connect-card.js | 2 ++ custom_components/webastoconnect/card/VERSION | 1 - .../card/webasto-connect-card.js | 2 ++ .../webastoconnect/card_install.py | 27 ++++++++------- custom_components/webastoconnect/const.py | 3 -- tests/test_card_install.py | 34 +++++++++++-------- 6 files changed, 39 insertions(+), 30 deletions(-) delete mode 100644 custom_components/webastoconnect/card/VERSION diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index e15581a..1e20c13 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -1,3 +1,5 @@ +globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0"; + import { localize } from "./localize/localize.js"; class WebastoConnectCard extends HTMLElement { diff --git a/custom_components/webastoconnect/card/VERSION b/custom_components/webastoconnect/card/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/custom_components/webastoconnect/card/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index a14cd9c..cef0fbd 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -1,3 +1,5 @@ +globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0"; + const WEBASTO_CONNECT_CARD_TRANSLATIONS = { da: { card: { diff --git a/custom_components/webastoconnect/card_install.py b/custom_components/webastoconnect/card_install.py index 06b4e08..936d3bf 100644 --- a/custom_components/webastoconnect/card_install.py +++ b/custom_components/webastoconnect/card_install.py @@ -1,24 +1,31 @@ """Helpers for installing/updating the bundled Webasto Connect Lovelace card.""" from pathlib import Path +import re import shutil from .const import ( CARD_FILENAME, CARD_SOURCE_DIR, - CARD_SOURCE_VERSION_FILE, - CARD_VERSION_FILENAME, CARD_WWW_SUBDIR, ) +CARD_VERSION_PATTERN = re.compile( + r"__WEBASTO_CONNECT_CARD_VERSION__\s*=\s*['\"]([^'\"]+)['\"]" +) + -def read_version_file(path: Path) -> str | None: - """Read a version marker file, returning None when missing/empty.""" +def read_card_version(path: Path) -> str | None: + """Read card version marker directly from the JavaScript bundle.""" try: - value = path.read_text(encoding="utf-8").strip() + content = path.read_text(encoding="utf-8") except OSError: return None - return value or None + + if match := CARD_VERSION_PATTERN.search(content): + return match.group(1) + + return None def should_install_card( @@ -38,16 +45,14 @@ def ensure_card_installed(integration_path: Path, www_path: Path) -> tuple[bool, """Copy bundled card assets into Home Assistant www directory when needed.""" source_dir = integration_path / CARD_SOURCE_DIR source_entry = source_dir / CARD_FILENAME - source_version_file = source_dir / CARD_SOURCE_VERSION_FILE - source_version = read_version_file(source_version_file) + source_version = read_card_version(source_entry) if source_version is None or not source_entry.exists(): return False, None target_dir = www_path / CARD_WWW_SUBDIR target_entry = target_dir / CARD_FILENAME - target_version_file = target_dir / CARD_VERSION_FILENAME - installed_version = read_version_file(target_version_file) + installed_version = read_card_version(target_entry) if not should_install_card(source_version, installed_version, target_entry): return False, source_version @@ -55,6 +60,4 @@ def ensure_card_installed(integration_path: Path, www_path: Path) -> tuple[bool, target_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(source_entry, target_entry) - - target_version_file.write_text(source_version, encoding="utf-8") return True, source_version diff --git a/custom_components/webastoconnect/const.py b/custom_components/webastoconnect/const.py index fc399b3..00aec95 100644 --- a/custom_components/webastoconnect/const.py +++ b/custom_components/webastoconnect/const.py @@ -18,8 +18,5 @@ NEW_DATA = "webasto_signal" CARD_FILENAME = "webasto-connect-card.js" -CARD_VERSION_FILENAME = "webasto-connect-card.version" CARD_SOURCE_DIR = "card" -CARD_SOURCE_FILE = CARD_FILENAME -CARD_SOURCE_VERSION_FILE = "VERSION" CARD_WWW_SUBDIR = "webastoconnect" diff --git a/tests/test_card_install.py b/tests/test_card_install.py index 31defd9..f1f7482 100644 --- a/tests/test_card_install.py +++ b/tests/test_card_install.py @@ -4,7 +4,7 @@ from custom_components.webastoconnect.card_install import ( ensure_card_installed, - read_version_file, + read_card_version, should_install_card, ) @@ -15,15 +15,27 @@ def _prepare_source_tree(base: Path, version: str) -> Path: source_dir.mkdir(parents=True) (source_dir / "webasto-connect-card.js").write_text( - "console.log('webasto card');", encoding="utf-8" + f'globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "{version}";\n' + "console.log('webasto card');", + encoding="utf-8", ) - (source_dir / "VERSION").write_text(version, encoding="utf-8") return integration_path -def test_read_version_file_returns_none_on_missing(tmp_path: Path) -> None: - """Missing version file should return None.""" - assert read_version_file(tmp_path / "missing") is None +def test_read_card_version_returns_none_on_missing(tmp_path: Path) -> None: + """Missing card file should return None.""" + assert read_card_version(tmp_path / "missing.js") is None + + +def test_read_card_version_from_js_marker(tmp_path: Path) -> None: + """Card version should be parsed from JavaScript marker line.""" + card_file = tmp_path / "card.js" + card_file.write_text( + 'globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0";', + encoding="utf-8", + ) + + assert read_card_version(card_file) == "0.1.0" def test_should_install_card_logic(tmp_path: Path) -> None: @@ -38,8 +50,8 @@ def test_should_install_card_logic(tmp_path: Path) -> None: assert should_install_card(None, "0.1.0", target) is False -def test_ensure_card_installed_copies_bundle_and_version(tmp_path: Path) -> None: - """Built card should be copied to www folder and version persisted.""" +def test_ensure_card_installed_copies_bundle(tmp_path: Path) -> None: + """Built card should be copied to www folder.""" integration_path = _prepare_source_tree(tmp_path, "0.1.0") www_path = tmp_path / "www" @@ -48,12 +60,6 @@ def test_ensure_card_installed_copies_bundle_and_version(tmp_path: Path) -> None assert installed is True assert version == "0.1.0" assert (www_path / "webastoconnect" / "webasto-connect-card.js").exists() - assert ( - (www_path / "webastoconnect" / "webasto-connect-card.version").read_text( - encoding="utf-8" - ) - == "0.1.0" - ) def test_ensure_card_installed_skips_when_up_to_date(tmp_path: Path) -> None: From 12ee2d5b95933345f5145b489249faba9e9d31aa Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:41:49 +0000 Subject: [PATCH 10/25] Add debug logs for card install status during setup --- custom_components/webastoconnect/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/custom_components/webastoconnect/__init__.py b/custom_components/webastoconnect/__init__.py index 1d58775..5b4ffaa 100644 --- a/custom_components/webastoconnect/__init__.py +++ b/custom_components/webastoconnect/__init__.py @@ -107,6 +107,10 @@ async def _async_setup( STARTUP, integration.version, ) + LOGGER.debug( + "Checking Webasto Connect Card installation status for /local/webastoconnect/%s", + CARD_FILENAME, + ) installed, card_version = await hass.async_add_executor_job( ensure_card_installed, Path(integration.file_path), @@ -122,6 +126,12 @@ async def _async_setup( LOGGER.warning( "Webasto Connect Card assets not found in integration package; skipping install" ) + else: + LOGGER.debug( + "Webasto Connect Card already up to date (v%s) at /local/webastoconnect/%s", + card_version, + CARD_FILENAME, + ) coordinator = WebastoConnectUpdateCoordinator(hass, entry) try: From 1a53da03e5ac2c2077173ebf4f58e733ac17b947 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:44:29 +0000 Subject: [PATCH 11/25] Inject card version from package.json during build --- card-src/README.md | 3 +++ card-src/build.mjs | 6 ++++++ card-src/webasto-connect-card.js | 2 -- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/card-src/README.md b/card-src/README.md index 4525041..eeae032 100644 --- a/card-src/README.md +++ b/card-src/README.md @@ -25,6 +25,9 @@ cd card-src npm run build:watch ``` +Card version marker (`__WEBASTO_CONNECT_CARD_VERSION__`) is injected automatically +from `card-src/package.json` during build. + ## Install in Home Assistant The integration auto-installs card assets on load/update to: - `config/www/webastoconnect/webasto-connect-card.js` diff --git a/card-src/build.mjs b/card-src/build.mjs index d1f3d54..039102b 100644 --- a/card-src/build.mjs +++ b/card-src/build.mjs @@ -1,6 +1,9 @@ import { build, context } from "esbuild"; +import { readFileSync } from "node:fs"; const watch = process.argv.includes("--watch"); +const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url))); +const cardVersion = packageJson.version; const buildOptions = { entryPoints: ["webasto-connect-card.js"], @@ -14,6 +17,9 @@ const buildOptions = { loader: { ".json": "json", }, + banner: { + js: `globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "${cardVersion}";`, + }, }; if (watch) { diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index 1e20c13..e15581a 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -1,5 +1,3 @@ -globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0"; - import { localize } from "./localize/localize.js"; class WebastoConnectCard extends HTMLElement { From a1f3619a98faff7aad8f8797d7559376779fe52b Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:50:05 +0000 Subject: [PATCH 12/25] Auto-register Lovelace resource for Webasto Connect Card --- custom_components/webastoconnect/__init__.py | 51 +++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/custom_components/webastoconnect/__init__.py b/custom_components/webastoconnect/__init__.py index 5b4ffaa..4bd3836 100644 --- a/custom_components/webastoconnect/__init__.py +++ b/custom_components/webastoconnect/__init__.py @@ -6,6 +6,13 @@ from pathlib import Path from typing import Any, TypeAlias +from homeassistant.components.lovelace.const import ( + CONF_RESOURCE_TYPE_WS, + CONF_TYPE, + CONF_URL, + LOVELACE_DATA, + MODE_STORAGE, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant, callback @@ -17,7 +24,7 @@ from .api import WebastoConnectUpdateCoordinator from .card_install import ensure_card_installed -from .const import CARD_FILENAME, DOMAIN, PLATFORMS, STARTUP +from .const import CARD_FILENAME, CARD_WWW_SUBDIR, DOMAIN, PLATFORMS, STARTUP LOGGER = logging.getLogger(__name__) @@ -132,6 +139,7 @@ async def _async_setup( card_version, CARD_FILENAME, ) + await _async_ensure_lovelace_card_resource(hass) coordinator = WebastoConnectUpdateCoordinator(hass, entry) try: @@ -159,6 +167,47 @@ async def _async_setup( return coordinator +async def _async_ensure_lovelace_card_resource(hass: HomeAssistant) -> None: + """Ensure the Webasto Connect card resource exists in Lovelace storage mode.""" + resource_url = f"/local/{CARD_WWW_SUBDIR}/{CARD_FILENAME}" + + if (lovelace_data := hass.data.get(LOVELACE_DATA)) is None: + LOGGER.debug( + "Lovelace not loaded yet; cannot auto-register resource %s", resource_url + ) + return + + if lovelace_data.resource_mode != MODE_STORAGE: + LOGGER.debug( + "Lovelace resource mode is '%s'; skipping auto-registration of %s", + lovelace_data.resource_mode, + resource_url, + ) + return + + resources = lovelace_data.resources + for resource in resources.async_items() or []: + if resource.get(CONF_URL) != resource_url: + continue + + if resource.get(CONF_TYPE) != "module": + await resources.async_update_item( + resource["id"], + {CONF_RESOURCE_TYPE_WS: "module"}, + ) + LOGGER.info( + "Updated Lovelace resource type to module for %s", resource_url + ) + else: + LOGGER.debug("Lovelace resource already present for %s", resource_url) + return + + await resources.async_create_item( + {CONF_URL: resource_url, CONF_RESOURCE_TYPE_WS: "module"} + ) + LOGGER.info("Created Lovelace resource for %s", resource_url) + + async def async_unload_entry(hass: HomeAssistant, entry: WebastoConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From 2d9f29a215ec8d359ce52763e7eb059dd171c601 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:55:56 +0000 Subject: [PATCH 13/25] Add Lovelace visual editor for Webasto Connect Card --- card-src/webasto-connect-card.js | 136 ++++++++++++++++- .../card/webasto-connect-card.js | 137 +++++++++++++++++- 2 files changed, 271 insertions(+), 2 deletions(-) diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index e15581a..db8f9dc 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -1,6 +1,25 @@ import { localize } from "./localize/localize.js"; +function escapeAttr(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + class WebastoConnectCard extends HTMLElement { + static getConfigElement() { + return document.createElement("webasto-connect-card-editor"); + } + + static getStubConfig() { + return { + main_output_entity: "switch.webasto_main_output", + ventilation_mode_entity: "switch.webasto_ventilation_mode", + end_time_entity: "sensor.webasto_main_output_end_time", + }; + } setConfig(config) { if (!config.main_output_entity) { @@ -189,11 +208,126 @@ class WebastoConnectCard extends HTMLElement { } } -customElements.define("webasto-connect-card", WebastoConnectCard); +class WebastoConnectCardEditor extends HTMLElement { + setConfig(config) { + this._config = { ...config }; + this._render(); + } + + set hass(hass) { + this._hass = hass; + this._render(); + } + + connectedCallback() { + if (!this.shadowRoot) { + this.attachShadow({ mode: "open" }); + } + this._render(); + } + + _handleInput(ev) { + const field = ev.target?.dataset?.field; + if (!field) { + return; + } + + const value = String(ev.target.value || "").trim(); + const next = { ...(this._config || {}) }; + if (value === "") { + delete next[field]; + } else { + next[field] = value; + } + + this._config = next; + this.dispatchEvent( + new CustomEvent("config-changed", { + detail: { config: next }, + bubbles: true, + composed: true, + }) + ); + } + + _render() { + if (!this.shadowRoot) { + return; + } + + const cfg = this._config || {}; + this.shadowRoot.innerHTML = ` + +
+ + + + + + + + +
+ `; + + this.shadowRoot.querySelectorAll("input").forEach((input) => { + input.addEventListener("change", (ev) => this._handleInput(ev)); + }); + } +} + +if (!customElements.get("webasto-connect-card")) { + customElements.define("webasto-connect-card", WebastoConnectCard); +} +if (!customElements.get("webasto-connect-card-editor")) { + customElements.define("webasto-connect-card-editor", WebastoConnectCardEditor); +} window.customCards = window.customCards || []; window.customCards.push({ type: "webasto-connect-card", name: "Webasto Connect Card", description: "Webasto Connect card with center toggle for main output", + preview: true, }); diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index cef0fbd..3e2df1f 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -74,7 +74,27 @@ function localize(hass, key, vars = {}) { return translated; } +function escapeAttr(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + class WebastoConnectCard extends HTMLElement { + static getConfigElement() { + return document.createElement("webasto-connect-card-editor"); + } + + static getStubConfig() { + return { + main_output_entity: "switch.webasto_main_output", + ventilation_mode_entity: "switch.webasto_ventilation_mode", + end_time_entity: "sensor.webasto_main_output_end_time", + }; + } + setConfig(config) { if (!config.main_output_entity) { throw new Error("Missing required config: main_output_entity"); @@ -252,11 +272,126 @@ class WebastoConnectCard extends HTMLElement { } } -customElements.define("webasto-connect-card", WebastoConnectCard); +class WebastoConnectCardEditor extends HTMLElement { + setConfig(config) { + this._config = { ...config }; + this._render(); + } + + set hass(hass) { + this._hass = hass; + this._render(); + } + + connectedCallback() { + if (!this.shadowRoot) { + this.attachShadow({ mode: "open" }); + } + this._render(); + } + + _handleInput(ev) { + const field = ev.target?.dataset?.field; + if (!field) { + return; + } + + const value = String(ev.target.value || "").trim(); + const next = { ...(this._config || {}) }; + if (value === "") { + delete next[field]; + } else { + next[field] = value; + } + + this._config = next; + this.dispatchEvent( + new CustomEvent("config-changed", { + detail: { config: next }, + bubbles: true, + composed: true, + }) + ); + } + + _render() { + if (!this.shadowRoot) { + return; + } + + const cfg = this._config || {}; + this.shadowRoot.innerHTML = ` + +
+ + + + + + + + +
+ `; + + this.shadowRoot.querySelectorAll("input").forEach((input) => { + input.addEventListener("change", (ev) => this._handleInput(ev)); + }); + } +} + +if (!customElements.get("webasto-connect-card")) { + customElements.define("webasto-connect-card", WebastoConnectCard); +} +if (!customElements.get("webasto-connect-card-editor")) { + customElements.define("webasto-connect-card-editor", WebastoConnectCardEditor); +} window.customCards = window.customCards || []; window.customCards.push({ type: "webasto-connect-card", name: "Webasto Connect Card", description: "Webasto Connect card with center toggle for main output", + preview: true, }); From 525b98d58a00537f2d7099ede309d3cbba060deb Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:57:44 +0000 Subject: [PATCH 14/25] Handle missing main_output_entity gracefully in card UI --- card-src/translations/da.json | 1 + card-src/translations/en.json | 1 + card-src/webasto-connect-card.js | 18 ++++++++++++----- card-src/webasto_connect_card.yaml | 6 +++--- .../card/webasto-connect-card.js | 20 ++++++++++++++----- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/card-src/translations/da.json b/card-src/translations/da.json index 11a8a42..78b5416 100644 --- a/card-src/translations/da.json +++ b/card-src/translations/da.json @@ -8,6 +8,7 @@ "inactive": "Ikke aktiv", "ending_now": "Slutter nu", "minutes_left": "{minutes} minutter tilbage", + "main_output_missing": "Vælg Main output entity i kortindstillinger", "output": "Output", "toggle_output": "Skift output" } diff --git a/card-src/translations/en.json b/card-src/translations/en.json index 6db3233..88061b1 100644 --- a/card-src/translations/en.json +++ b/card-src/translations/en.json @@ -8,6 +8,7 @@ "inactive": "Inactive", "ending_now": "Ending now", "minutes_left": "{minutes} minutes left", + "main_output_missing": "Select Main output entity in card settings", "output": "Output", "toggle_output": "Toggle output" } diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index db8f9dc..b4d8ca1 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -76,11 +76,16 @@ class WebastoConnectCard extends HTMLElement { } _toggleMainOutput() { - if (!this._hass || !this._config?.main_output_entity) { + const entityId = this._config?.main_output_entity; + if (!this._hass || !entityId || !this._hass.states?.[entityId]) { + console.warn( + "[webasto-connect-card] Missing or unavailable main_output_entity:", + entityId + ); return; } this._hass.callService("switch", "toggle", { - entity_id: this._config.main_output_entity, + entity_id: entityId, }); } @@ -92,10 +97,13 @@ class WebastoConnectCard extends HTMLElement { const main = this._getState(this._config.main_output_entity); const end = this._getState(this._config.end_time_entity); - const isOn = main?.state === "on"; - const ringColor = isOn ? "#d33131" : "#2ea44f"; + const isMainAvailable = Boolean(main); + const isOn = isMainAvailable && main.state === "on"; + const ringColor = !isMainAvailable ? "#9aa4b5" : isOn ? "#d33131" : "#2ea44f"; const outputName = this._computeOutputName(main); - const label = this._computeLabel(end); + const label = isMainAvailable + ? this._computeLabel(end) + : localize(this._hass, "card.ui.main_output_missing"); const icon = this._config.center_icon || "mdi:car-defrost-rear"; const titleGeoFence = this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); diff --git a/card-src/webasto_connect_card.yaml b/card-src/webasto_connect_card.yaml index 5ac235b..4b575ff 100644 --- a/card-src/webasto_connect_card.yaml +++ b/card-src/webasto_connect_card.yaml @@ -10,9 +10,9 @@ # 3) Use this card config: type: custom:webasto-connect-card -main_output_entity: switch.webasto_main_output -ventilation_mode_entity: switch.webasto_ventilation_mode -end_time_entity: sensor.webasto_main_output_end_time +main_output_entity: switch. +ventilation_mode_entity: switch. +end_time_entity: sensor. # Optional labels: # title_geo_fence: Geo-fence diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index 3e2df1f..21ddc13 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -11,6 +11,7 @@ const WEBASTO_CONNECT_CARD_TRANSLATIONS = { inactive: "Ikke aktiv", ending_now: "Slutter nu", minutes_left: "{minutes} minutter tilbage", + main_output_missing: "Vælg Main output entity i kortindstillinger", output: "Output", toggle_output: "Skift output", }, @@ -26,6 +27,7 @@ const WEBASTO_CONNECT_CARD_TRANSLATIONS = { inactive: "Inactive", ending_now: "Ending now", minutes_left: "{minutes} minutes left", + main_output_missing: "Select Main output entity in card settings", output: "Output", toggle_output: "Toggle output", }, @@ -155,11 +157,16 @@ class WebastoConnectCard extends HTMLElement { } _toggleMainOutput() { - if (!this._hass || !this._config?.main_output_entity) { + const entityId = this._config?.main_output_entity; + if (!this._hass || !entityId || !this._hass.states?.[entityId]) { + console.warn( + "[webasto-connect-card] Missing or unavailable main_output_entity:", + entityId + ); return; } this._hass.callService("switch", "toggle", { - entity_id: this._config.main_output_entity, + entity_id: entityId, }); } @@ -171,10 +178,13 @@ class WebastoConnectCard extends HTMLElement { const main = this._getState(this._config.main_output_entity); const end = this._getState(this._config.end_time_entity); - const isOn = main?.state === "on"; - const ringColor = isOn ? "#d33131" : "#2ea44f"; + const isMainAvailable = Boolean(main); + const isOn = isMainAvailable && main.state === "on"; + const ringColor = !isMainAvailable ? "#9aa4b5" : isOn ? "#d33131" : "#2ea44f"; const outputName = this._computeOutputName(main); - const label = this._computeLabel(end); + const label = isMainAvailable + ? this._computeLabel(end) + : localize(this._hass, "card.ui.main_output_missing"); const icon = this._config.center_icon || "mdi:car-defrost-rear"; const titleGeoFence = this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); From 8beb9f0fdc0672c480de29a7ea916de38a9b715f Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 16:58:14 +0000 Subject: [PATCH 15/25] Use button gray ring color when output is off --- card-src/webasto-connect-card.js | 2 +- custom_components/webastoconnect/card/webasto-connect-card.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index b4d8ca1..315c46c 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -99,7 +99,7 @@ class WebastoConnectCard extends HTMLElement { const isMainAvailable = Boolean(main); const isOn = isMainAvailable && main.state === "on"; - const ringColor = !isMainAvailable ? "#9aa4b5" : isOn ? "#d33131" : "#2ea44f"; + const ringColor = isOn ? "#d33131" : "#c5cfdf"; const outputName = this._computeOutputName(main); const label = isMainAvailable ? this._computeLabel(end) diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index 21ddc13..c9c2394 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -180,7 +180,7 @@ class WebastoConnectCard extends HTMLElement { const isMainAvailable = Boolean(main); const isOn = isMainAvailable && main.state === "on"; - const ringColor = !isMainAvailable ? "#9aa4b5" : isOn ? "#d33131" : "#2ea44f"; + const ringColor = isOn ? "#d33131" : "#c5cfdf"; const outputName = this._computeOutputName(main); const label = isMainAvailable ? this._computeLabel(end) From c1b892c8dc792176b0400d0db8646fcab6fdd3d6 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 17:00:16 +0000 Subject: [PATCH 16/25] Add temperature battery and location info rows to card --- card-src/webasto-connect-card.js | 112 +++++++++++++++-- card-src/webasto_connect_card.yaml | 3 + .../card/webasto-connect-card.js | 117 ++++++++++++++++-- 3 files changed, 212 insertions(+), 20 deletions(-) diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index 315c46c..1d80cba 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -18,6 +18,9 @@ class WebastoConnectCard extends HTMLElement { main_output_entity: "switch.webasto_main_output", ventilation_mode_entity: "switch.webasto_ventilation_mode", end_time_entity: "sensor.webasto_main_output_end_time", + temperature_entity: "sensor.webasto_temperature", + battery_entity: "sensor.webasto_battery_voltage", + location_entity: "device_tracker.webasto_location", }; } @@ -89,6 +92,36 @@ class WebastoConnectCard extends HTMLElement { }); } + _stateWithUnit(entity) { + if (!entity) { + return "--"; + } + const state = entity.state; + if (state === "unknown" || state === "unavailable") { + return "--"; + } + const unit = entity.attributes?.unit_of_measurement; + return unit ? `${state} ${unit}` : String(state); + } + + _locationText(entity) { + if (!entity) { + return "--"; + } + const state = String(entity.state ?? ""); + if (state !== "" && state !== "unknown" && state !== "unavailable" && state !== "not_home") { + return state; + } + + const lat = entity.attributes?.latitude; + const lon = entity.attributes?.longitude; + if (typeof lat === "number" && typeof lon === "number") { + return `${lat.toFixed(5)}, ${lon.toFixed(5)}`; + } + + return "--"; + } + _render() { if (!this.shadowRoot || !this._config || !this._hass) { return; @@ -96,6 +129,9 @@ class WebastoConnectCard extends HTMLElement { const main = this._getState(this._config.main_output_entity); const end = this._getState(this._config.end_time_entity); + const temp = this._getState(this._config.temperature_entity); + const battery = this._getState(this._config.battery_entity); + const location = this._getState(this._config.location_entity); const isMainAvailable = Boolean(main); const isOn = isMainAvailable && main.state === "on"; @@ -104,6 +140,9 @@ class WebastoConnectCard extends HTMLElement { const label = isMainAvailable ? this._computeLabel(end) : localize(this._hass, "card.ui.main_output_missing"); + const tempText = this._stateWithUnit(temp); + const batteryText = this._stateWithUnit(battery); + const locationText = this._locationText(location); const icon = this._config.center_icon || "mdi:car-defrost-rear"; const titleGeoFence = this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); @@ -118,6 +157,11 @@ class WebastoConnectCard extends HTMLElement { this.shadowRoot.innerHTML = ` - -
${titleGeoFence}
-
${titleMode}
-
${titleTimers}
-
${titleMap}
-
- -
${outputName}
-
${label}
+
+ +
${titleGeoFence}
+
${titleMode}
+
${titleTimers}
+
${titleMap}
+
+ +
${outputName}
+
${label}
+
+
+
+
+ ${tempText} + ${batteryText} +
+
${locationText}
- +
`; const center = this.shadowRoot.getElementById("center-toggle"); @@ -301,6 +384,15 @@ class WebastoConnectCardEditor extends HTMLElement { + + + diff --git a/card-src/webasto_connect_card.yaml b/card-src/webasto_connect_card.yaml index 4b575ff..ffb79f0 100644 --- a/card-src/webasto_connect_card.yaml +++ b/card-src/webasto_connect_card.yaml @@ -13,6 +13,9 @@ type: custom:webasto-connect-card main_output_entity: switch. ventilation_mode_entity: switch. end_time_entity: sensor. +temperature_entity: sensor. +battery_entity: sensor. +location_entity: sensor. # Optional labels: # title_geo_fence: Geo-fence diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index c9c2394..dccd5b7 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -94,6 +94,9 @@ class WebastoConnectCard extends HTMLElement { main_output_entity: "switch.webasto_main_output", ventilation_mode_entity: "switch.webasto_ventilation_mode", end_time_entity: "sensor.webasto_main_output_end_time", + temperature_entity: "sensor.webasto_temperature", + battery_entity: "sensor.webasto_battery_voltage", + location_entity: "device_tracker.webasto_location", }; } @@ -170,6 +173,41 @@ class WebastoConnectCard extends HTMLElement { }); } + _stateWithUnit(entity) { + if (!entity) { + return "--"; + } + const state = entity.state; + if (state === "unknown" || state === "unavailable") { + return "--"; + } + const unit = entity.attributes?.unit_of_measurement; + return unit ? `${state} ${unit}` : String(state); + } + + _locationText(entity) { + if (!entity) { + return "--"; + } + const state = String(entity.state ?? ""); + if ( + state !== "" && + state !== "unknown" && + state !== "unavailable" && + state !== "not_home" + ) { + return state; + } + + const lat = entity.attributes?.latitude; + const lon = entity.attributes?.longitude; + if (typeof lat === "number" && typeof lon === "number") { + return `${lat.toFixed(5)}, ${lon.toFixed(5)}`; + } + + return "--"; + } + _render() { if (!this.shadowRoot || !this._config || !this._hass) { return; @@ -177,6 +215,9 @@ class WebastoConnectCard extends HTMLElement { const main = this._getState(this._config.main_output_entity); const end = this._getState(this._config.end_time_entity); + const temp = this._getState(this._config.temperature_entity); + const battery = this._getState(this._config.battery_entity); + const location = this._getState(this._config.location_entity); const isMainAvailable = Boolean(main); const isOn = isMainAvailable && main.state === "on"; @@ -185,6 +226,9 @@ class WebastoConnectCard extends HTMLElement { const label = isMainAvailable ? this._computeLabel(end) : localize(this._hass, "card.ui.main_output_missing"); + const tempText = this._stateWithUnit(temp); + const batteryText = this._stateWithUnit(battery); + const locationText = this._locationText(location); const icon = this._config.center_icon || "mdi:car-defrost-rear"; const titleGeoFence = this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); @@ -197,6 +241,11 @@ class WebastoConnectCard extends HTMLElement { this.shadowRoot.innerHTML = ` - -
${titleGeoFence}
-
${titleMode}
-
${titleTimers}
-
${titleMap}
-
- -
${outputName}
-
${label}
+
+ +
${titleGeoFence}
+
${titleMode}
+
${titleTimers}
+
${titleMap}
+
+ +
${outputName}
+
${label}
+
+
+
+
+ ${tempText} + ${batteryText} +
+
${locationText}
- +
`; const center = this.shadowRoot.getElementById("center-toggle"); @@ -367,6 +455,15 @@ class WebastoConnectCardEditor extends HTMLElement { + + + From 6b24368aeeac48c0be4db71fc148014164eef925 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 17:01:33 +0000 Subject: [PATCH 17/25] Use homeassistant.toggle for configured main_output_entity --- card-src/webasto-connect-card.js | 2 +- custom_components/webastoconnect/card/webasto-connect-card.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index 1d80cba..9a1e3ef 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -87,7 +87,7 @@ class WebastoConnectCard extends HTMLElement { ); return; } - this._hass.callService("switch", "toggle", { + this._hass.callService("homeassistant", "toggle", { entity_id: entityId, }); } diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index dccd5b7..533237a 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -168,7 +168,7 @@ class WebastoConnectCard extends HTMLElement { ); return; } - this._hass.callService("switch", "toggle", { + this._hass.callService("homeassistant", "toggle", { entity_id: entityId, }); } From ec4e0b533553013091d9448aa888de2e79505e5c Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 17:03:10 +0000 Subject: [PATCH 18/25] Add versioned Lovelace resource URL for card cache busting --- custom_components/webastoconnect/__init__.py | 32 +++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/custom_components/webastoconnect/__init__.py b/custom_components/webastoconnect/__init__.py index 4bd3836..d03eef3 100644 --- a/custom_components/webastoconnect/__init__.py +++ b/custom_components/webastoconnect/__init__.py @@ -139,7 +139,7 @@ async def _async_setup( card_version, CARD_FILENAME, ) - await _async_ensure_lovelace_card_resource(hass) + await _async_ensure_lovelace_card_resource(hass, card_version) coordinator = WebastoConnectUpdateCoordinator(hass, entry) try: @@ -167,9 +167,16 @@ async def _async_setup( return coordinator -async def _async_ensure_lovelace_card_resource(hass: HomeAssistant) -> None: +async def _async_ensure_lovelace_card_resource( + hass: HomeAssistant, card_version: str | None +) -> None: """Ensure the Webasto Connect card resource exists in Lovelace storage mode.""" - resource_url = f"/local/{CARD_WWW_SUBDIR}/{CARD_FILENAME}" + resource_base_url = f"/local/{CARD_WWW_SUBDIR}/{CARD_FILENAME}" + resource_url = ( + f"{resource_base_url}?v={card_version}" + if card_version + else resource_base_url + ) if (lovelace_data := hass.data.get(LOVELACE_DATA)) is None: LOGGER.debug( @@ -187,17 +194,20 @@ async def _async_ensure_lovelace_card_resource(hass: HomeAssistant) -> None: resources = lovelace_data.resources for resource in resources.async_items() or []: - if resource.get(CONF_URL) != resource_url: + existing_url = resource.get(CONF_URL) or "" + existing_base_url = existing_url.split("?", 1)[0] + if existing_base_url != resource_base_url: continue + update_data: dict[str, str] = {} + if existing_url != resource_url: + update_data[CONF_URL] = resource_url if resource.get(CONF_TYPE) != "module": - await resources.async_update_item( - resource["id"], - {CONF_RESOURCE_TYPE_WS: "module"}, - ) - LOGGER.info( - "Updated Lovelace resource type to module for %s", resource_url - ) + update_data[CONF_RESOURCE_TYPE_WS] = "module" + + if update_data: + await resources.async_update_item(resource["id"], update_data) + LOGGER.info("Updated Lovelace resource to %s", resource_url) else: LOGGER.debug("Lovelace resource already present for %s", resource_url) return From 972a54e08f81ecad0ffe54512c251b8228f2196a Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 17:04:15 +0000 Subject: [PATCH 19/25] Add lovelace to after_dependencies for hassfest --- custom_components/webastoconnect/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/webastoconnect/manifest.json b/custom_components/webastoconnect/manifest.json index 976a92b..018f0fd 100644 --- a/custom_components/webastoconnect/manifest.json +++ b/custom_components/webastoconnect/manifest.json @@ -2,7 +2,8 @@ "domain": "webastoconnect", "name": "Webasto Connect (ThermoConnect)", "after_dependencies": [ - "http" + "http", + "lovelace" ], "codeowners": [ "@MTrab" From bff457b5b10e8eaf3e142a3fa1bd3a8e268c8127 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 17:08:17 +0000 Subject: [PATCH 20/25] Update develop --- scripts/develop | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/develop b/scripts/develop index f0d5711..e6b20fd 100755 --- a/scripts/develop +++ b/scripts/develop @@ -44,5 +44,8 @@ if [[ -f "${LOCK_FILE}" ]]; then rm -f "${LOCK_FILE}" fi +# Remove old webasto card, to ensure we are testing the latest version. +rm -rf "${PWD}/config/www/webastoconnect" || true + # Start Home Assistant hass --config "${PWD}/config" --debug \ No newline at end of file From 44822781a5b9fb70a8777a0f49fe5e69b3b6cde6 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 17:33:03 +0000 Subject: [PATCH 21/25] feat(card): improve editor/time labels and install flow; add Node in devcontainer --- .devcontainer.json | 6 +- card-src/package.json | 2 +- card-src/translations/da.json | 3 +- card-src/translations/en.json | 3 +- card-src/webasto-connect-card.js | 146 ++++++++++++----- card-src/webasto_connect_card.yaml | 4 - .../card/webasto-connect-card.js | 152 +++++++++++++----- .../webastoconnect/card_install.py | 17 +- scripts/setup | 2 +- tests/test_card_install.py | 17 +- 10 files changed, 259 insertions(+), 93 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index 9e23891..8fad533 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -41,7 +41,9 @@ }, "remoteUser": "vscode", "features": { - "ghcr.io/devcontainers/features/rust:1": {} + "ghcr.io/devcontainers/features/node:1": { + "version": "lts" + } }, "mounts": [ "source=${localWorkspaceFolder}/config,target=/config,type=bind,consistency=cached", @@ -49,4 +51,4 @@ "source=${localWorkspaceFolder}/../pywebasto,target=/development/github/homeassistant/pywebasto,type=bind", "source=gh-config,target=/home/vscode/.config/gh,type=volume" ] -} \ No newline at end of file +} diff --git a/card-src/package.json b/card-src/package.json index b549308..7512080 100644 --- a/card-src/package.json +++ b/card-src/package.json @@ -1,7 +1,7 @@ { "name": "webasto-connect-card", "private": true, - "version": "0.1.0", + "version": "0.1.0b6", "type": "module", "scripts": { "build": "node build.mjs", diff --git a/card-src/translations/da.json b/card-src/translations/da.json index 78b5416..e544248 100644 --- a/card-src/translations/da.json +++ b/card-src/translations/da.json @@ -5,9 +5,10 @@ "mode": "Modus", "timers": "Timere", "map": "Kort", + "active": "Aktiv", "inactive": "Ikke aktiv", "ending_now": "Slutter nu", - "minutes_left": "{minutes} minutter tilbage", + "left": "tilbage", "main_output_missing": "Vælg Main output entity i kortindstillinger", "output": "Output", "toggle_output": "Skift output" diff --git a/card-src/translations/en.json b/card-src/translations/en.json index 88061b1..76a6a78 100644 --- a/card-src/translations/en.json +++ b/card-src/translations/en.json @@ -5,9 +5,10 @@ "mode": "Mode", "timers": "Timers", "map": "Map", + "active": "Active", "inactive": "Inactive", "ending_now": "Ending now", - "minutes_left": "{minutes} minutes left", + "left": "left", "main_output_missing": "Select Main output entity in card settings", "output": "Output", "toggle_output": "Toggle output" diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index 9a1e3ef..90c700c 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -51,23 +51,44 @@ class WebastoConnectCard extends HTMLElement { return entityId ? this._hass?.states?.[entityId] : undefined; } - _computeLabel(endEntity) { - if (!endEntity || !endEntity.state || endEntity.state === "unknown" || endEntity.state === "unavailable") { - return localize(this._hass, "card.ui.inactive"); + _parseEndDate(value) { + if (value === null || value === undefined || value === "") { + return null; } - const end = new Date(endEntity.state); - if (Number.isNaN(end.getTime())) { + // Accept both ISO datetime and Unix timestamp (seconds or milliseconds). + const numeric = Number(value); + if (Number.isFinite(numeric)) { + const millis = numeric < 1e12 ? numeric * 1000 : numeric; + const tsDate = new Date(millis); + return Number.isNaN(tsDate.getTime()) ? null : tsDate; + } + + const date = new Date(String(value)); + return Number.isNaN(date.getTime()) ? null : date; + } + + _computeLabel(mainEntity, endEntity) { + if (!mainEntity || mainEntity.state !== "on") { return localize(this._hass, "card.ui.inactive"); } - const leftMinutes = Math.round((end.getTime() - Date.now()) / 60000); + if (!endEntity || !endEntity.state || endEntity.state === "unknown" || endEntity.state === "unavailable") { + return localize(this._hass, "card.ui.active"); + } + + const end = this._parseEndDate(endEntity.state); + if (!end) { + return localize(this._hass, "card.ui.active"); + } + + const leftMinutes = Math.ceil((end.getTime() - Date.now()) / 60000); if (leftMinutes <= 0) { return localize(this._hass, "card.ui.ending_now"); } - return localize(this._hass, "card.ui.minutes_left", { - minutes: leftMinutes, - }); + const hours = Math.floor(leftMinutes / 60); + const minutes = leftMinutes % 60; + return `${hours}:${String(minutes).padStart(2, "0")} ${localize(this._hass, "card.ui.left")}`; } _computeOutputName(mainOutputState) { @@ -138,20 +159,19 @@ class WebastoConnectCard extends HTMLElement { const ringColor = isOn ? "#d33131" : "#c5cfdf"; const outputName = this._computeOutputName(main); const label = isMainAvailable - ? this._computeLabel(end) + ? this._computeLabel(main, end) : localize(this._hass, "card.ui.main_output_missing"); const tempText = this._stateWithUnit(temp); const batteryText = this._stateWithUnit(battery); const locationText = this._locationText(location); - const icon = this._config.center_icon || "mdi:car-defrost-rear"; - const titleGeoFence = - this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); - const titleMode = - this._config.title_mode || localize(this._hass, "card.ui.mode"); - const titleTimers = - this._config.title_timers || localize(this._hass, "card.ui.timers"); - const titleMap = - this._config.title_map || localize(this._hass, "card.ui.map"); + const icon = + this._config.center_icon || + main?.attributes?.icon || + "mdi:car-defrost-rear"; + const titleGeoFence = localize(this._hass, "card.ui.geo_fence"); + const titleMode = localize(this._hass, "card.ui.mode"); + const titleTimers = localize(this._hass, "card.ui.timers"); + const titleMap = localize(this._hass, "card.ui.map"); const toggleLabel = localize(this._hass, "card.ui.toggle_output"); this.shadowRoot.innerHTML = ` @@ -192,6 +212,23 @@ class WebastoConnectCard extends HTMLElement { .q.tr { right: 0; top: 0; width: calc(50% - 8px); height: calc(50% - 8px); } .q.bl { left: 0; bottom: 0; width: calc(50% - 8px); height: calc(50% - 8px); } .q.br { right: 0; bottom: 0; width: calc(50% - 8px); height: calc(50% - 8px); } + .divider-v, .divider-h { + position: absolute; + background: #e7edf8; + pointer-events: none; + } + .divider-v { + left: calc(50% - 8px); + top: 0; + width: 16px; + height: 100%; + } + .divider-h { + left: 0; + top: calc(50% - 8px); + width: 100%; + height: 16px; + } .center-wrap { position: absolute; left: 50%; @@ -266,6 +303,8 @@ class WebastoConnectCard extends HTMLElement {
${titleMode}
${titleTimers}
${titleMap}
+
+
${outputName}
@@ -307,6 +346,10 @@ class WebastoConnectCardEditor extends HTMLElement { set hass(hass) { this._hass = hass; + if (!this._suggestionsLoaded) { + this._suggestionsLoaded = true; + this._loadSuggestions(); + } this._render(); } @@ -317,6 +360,38 @@ class WebastoConnectCardEditor extends HTMLElement { this._render(); } + async _loadSuggestions() { + if (!this._hass) { + return; + } + + try { + const registry = await this._hass.callWS({ + type: "config/entity_registry/list", + }); + const webastoEntities = registry + .filter((entry) => entry.platform === "webastoconnect" && !entry.hidden_by) + .map((entry) => entry.entity_id); + + this._entitySuggestions = [...new Set(webastoEntities)].sort(); + } catch (_err) { + const fallback = Object.keys(this._hass.states || {}).filter((entityId) => + entityId.includes("webasto") + ); + this._entitySuggestions = [...new Set(fallback)].sort(); + } + + this._render(); + } + + _datalistOptions(domains) { + const suggestions = this._entitySuggestions || []; + return suggestions + .filter((entityId) => domains.includes(entityId.split(".")[0])) + .map((entityId) => ``) + .join(""); + } + _handleInput(ev) { const field = ev.target?.dataset?.field; if (!field) { @@ -373,42 +448,39 @@ class WebastoConnectCardEditor extends HTMLElement { border-radius: 8px; padding: 8px 10px; } + .hint { + margin-top: 2px; + font-size: 12px; + color: var(--secondary-text-color); + }
- - - - +
Suggestions are limited to entities from the Webasto Connect integration.
+ ${this._datalistOptions(["switch"])} + ${this._datalistOptions(["sensor"])} + ${this._datalistOptions(["sensor", "device_tracker"])} `; this.shadowRoot.querySelectorAll("input").forEach((input) => { diff --git a/card-src/webasto_connect_card.yaml b/card-src/webasto_connect_card.yaml index ffb79f0..761b6b7 100644 --- a/card-src/webasto_connect_card.yaml +++ b/card-src/webasto_connect_card.yaml @@ -18,7 +18,3 @@ battery_entity: sensor. location_entity: sensor. # Optional labels: -# title_geo_fence: Geo-fence -# title_mode: Modus -# title_timers: Timere -# title_map: Kort diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index 533237a..6c66f9b 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -1,4 +1,4 @@ -globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0"; +globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0b6"; const WEBASTO_CONNECT_CARD_TRANSLATIONS = { da: { @@ -8,9 +8,10 @@ const WEBASTO_CONNECT_CARD_TRANSLATIONS = { mode: "Modus", timers: "Timere", map: "Kort", + active: "Aktiv", inactive: "Ikke aktiv", ending_now: "Slutter nu", - minutes_left: "{minutes} minutter tilbage", + left: "tilbage", main_output_missing: "Vælg Main output entity i kortindstillinger", output: "Output", toggle_output: "Skift output", @@ -24,9 +25,10 @@ const WEBASTO_CONNECT_CARD_TRANSLATIONS = { mode: "Mode", timers: "Timers", map: "Map", + active: "Active", inactive: "Inactive", ending_now: "Ending now", - minutes_left: "{minutes} minutes left", + left: "left", main_output_missing: "Select Main output entity in card settings", output: "Output", toggle_output: "Toggle output", @@ -127,28 +129,49 @@ class WebastoConnectCard extends HTMLElement { return entityId ? this._hass?.states?.[entityId] : undefined; } - _computeLabel(endEntity) { + _parseEndDate(value) { + if (value === null || value === undefined || value === "") { + return null; + } + + // Accept both ISO datetime and Unix timestamp (seconds or milliseconds). + const numeric = Number(value); + if (Number.isFinite(numeric)) { + const millis = numeric < 1e12 ? numeric * 1000 : numeric; + const tsDate = new Date(millis); + return Number.isNaN(tsDate.getTime()) ? null : tsDate; + } + + const date = new Date(String(value)); + return Number.isNaN(date.getTime()) ? null : date; + } + + _computeLabel(mainEntity, endEntity) { + if (!mainEntity || mainEntity.state !== "on") { + return localize(this._hass, "card.ui.inactive"); + } + if ( !endEntity || !endEntity.state || endEntity.state === "unknown" || endEntity.state === "unavailable" ) { - return localize(this._hass, "card.ui.inactive"); + return localize(this._hass, "card.ui.active"); } - const end = new Date(endEntity.state); - if (Number.isNaN(end.getTime())) { - return localize(this._hass, "card.ui.inactive"); + const end = this._parseEndDate(endEntity.state); + if (!end) { + return localize(this._hass, "card.ui.active"); } - const leftMinutes = Math.round((end.getTime() - Date.now()) / 60000); + const leftMinutes = Math.ceil((end.getTime() - Date.now()) / 60000); if (leftMinutes <= 0) { return localize(this._hass, "card.ui.ending_now"); } - return localize(this._hass, "card.ui.minutes_left", { - minutes: leftMinutes, - }); + const hours = Math.floor(leftMinutes / 60); + const minutes = leftMinutes % 60; + return `${hours}:${String(minutes).padStart(2, "0")} ${localize(this._hass, "card.ui.left")}`; } _computeOutputName(mainOutputState) { @@ -224,18 +247,19 @@ class WebastoConnectCard extends HTMLElement { const ringColor = isOn ? "#d33131" : "#c5cfdf"; const outputName = this._computeOutputName(main); const label = isMainAvailable - ? this._computeLabel(end) + ? this._computeLabel(main, end) : localize(this._hass, "card.ui.main_output_missing"); const tempText = this._stateWithUnit(temp); const batteryText = this._stateWithUnit(battery); const locationText = this._locationText(location); - const icon = this._config.center_icon || "mdi:car-defrost-rear"; - const titleGeoFence = - this._config.title_geo_fence || localize(this._hass, "card.ui.geo_fence"); - const titleMode = this._config.title_mode || localize(this._hass, "card.ui.mode"); - const titleTimers = - this._config.title_timers || localize(this._hass, "card.ui.timers"); - const titleMap = this._config.title_map || localize(this._hass, "card.ui.map"); + const icon = + this._config.center_icon || + main?.attributes?.icon || + "mdi:car-defrost-rear"; + const titleGeoFence = localize(this._hass, "card.ui.geo_fence"); + const titleMode = localize(this._hass, "card.ui.mode"); + const titleTimers = localize(this._hass, "card.ui.timers"); + const titleMap = localize(this._hass, "card.ui.map"); const toggleLabel = localize(this._hass, "card.ui.toggle_output"); this.shadowRoot.innerHTML = ` @@ -271,6 +295,23 @@ class WebastoConnectCard extends HTMLElement { .q.tr { right: 0; top: 0; width: calc(50% - 8px); height: calc(50% - 8px); } .q.bl { left: 0; bottom: 0; width: calc(50% - 8px); height: calc(50% - 8px); } .q.br { right: 0; bottom: 0; width: calc(50% - 8px); height: calc(50% - 8px); } + .divider-v, .divider-h { + position: absolute; + background: #e7edf8; + pointer-events: none; + } + .divider-v { + left: calc(50% - 8px); + top: 0; + width: 16px; + height: 100%; + } + .divider-h { + left: 0; + top: calc(50% - 8px); + width: 100%; + height: 16px; + } .center-wrap { position: absolute; left: 50%; @@ -337,6 +378,8 @@ class WebastoConnectCard extends HTMLElement {
${titleMode}
${titleTimers}
${titleMap}
+
+
${outputName}
@@ -378,6 +421,10 @@ class WebastoConnectCardEditor extends HTMLElement { set hass(hass) { this._hass = hass; + if (!this._suggestionsLoaded) { + this._suggestionsLoaded = true; + this._loadSuggestions(); + } this._render(); } @@ -388,6 +435,38 @@ class WebastoConnectCardEditor extends HTMLElement { this._render(); } + async _loadSuggestions() { + if (!this._hass) { + return; + } + + try { + const registry = await this._hass.callWS({ + type: "config/entity_registry/list", + }); + const webastoEntities = registry + .filter((entry) => entry.platform === "webastoconnect" && !entry.hidden_by) + .map((entry) => entry.entity_id); + + this._entitySuggestions = [...new Set(webastoEntities)].sort(); + } catch (_err) { + const fallback = Object.keys(this._hass.states || {}).filter((entityId) => + entityId.includes("webasto") + ); + this._entitySuggestions = [...new Set(fallback)].sort(); + } + + this._render(); + } + + _datalistOptions(domains) { + const suggestions = this._entitySuggestions || []; + return suggestions + .filter((entityId) => domains.includes(entityId.split(".")[0])) + .map((entityId) => ``) + .join(""); + } + _handleInput(ev) { const field = ev.target?.dataset?.field; if (!field) { @@ -444,42 +523,39 @@ class WebastoConnectCardEditor extends HTMLElement { border-radius: 8px; padding: 8px 10px; } + .hint { + margin-top: 2px; + font-size: 12px; + color: var(--secondary-text-color); + }
- - - - +
Suggestions are limited to entities from the Webasto Connect integration.
+ ${this._datalistOptions(["switch"])} + ${this._datalistOptions(["sensor"])} + ${this._datalistOptions(["sensor", "device_tracker"])} `; this.shadowRoot.querySelectorAll("input").forEach((input) => { diff --git a/custom_components/webastoconnect/card_install.py b/custom_components/webastoconnect/card_install.py index 936d3bf..8be16a7 100644 --- a/custom_components/webastoconnect/card_install.py +++ b/custom_components/webastoconnect/card_install.py @@ -31,6 +31,7 @@ def read_card_version(path: Path) -> str | None: def should_install_card( source_version: str | None, installed_version: str | None, + source_entry_file: Path, installed_entry_file: Path, ) -> bool: """Determine whether bundled card assets should be installed/updated.""" @@ -38,7 +39,14 @@ def should_install_card( return False if not installed_entry_file.exists(): return True - return installed_version != source_version + if installed_version != source_version: + return True + + # Reinstall when bundle content changed but version marker was not bumped. + try: + return source_entry_file.read_bytes() != installed_entry_file.read_bytes() + except OSError: + return True def ensure_card_installed(integration_path: Path, www_path: Path) -> tuple[bool, str | None]: @@ -54,7 +62,12 @@ def ensure_card_installed(integration_path: Path, www_path: Path) -> tuple[bool, target_entry = target_dir / CARD_FILENAME installed_version = read_card_version(target_entry) - if not should_install_card(source_version, installed_version, target_entry): + if not should_install_card( + source_version, + installed_version, + source_entry, + target_entry, + ): return False, source_version target_dir.mkdir(parents=True, exist_ok=True) diff --git a/scripts/setup b/scripts/setup index 2cc898d..5633f15 100755 --- a/scripts/setup +++ b/scripts/setup @@ -27,4 +27,4 @@ python3 -m pip install ffmpeg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null sudo apt update -sudo apt install tcpdump libpcap0.8-dev ffmpeg libturbojpeg0 ripgrep gh -y \ No newline at end of file +sudo apt install tcpdump libpcap0.8-dev ffmpeg libturbojpeg0 ripgrep gh nodejs npm -y diff --git a/tests/test_card_install.py b/tests/test_card_install.py index f1f7482..1b23124 100644 --- a/tests/test_card_install.py +++ b/tests/test_card_install.py @@ -39,15 +39,20 @@ def test_read_card_version_from_js_marker(tmp_path: Path) -> None: def test_should_install_card_logic(tmp_path: Path) -> None: - """Install should happen when file missing or version differs.""" + """Install should happen when file missing, version differs, or content changes.""" + source = tmp_path / "source-webasto-connect-card.js" target = tmp_path / "webasto-connect-card.js" + source.write_text("same-content", encoding="utf-8") - assert should_install_card("0.1.0", None, target) is True + assert should_install_card("0.1.0", None, source, target) is True - target.write_text("x", encoding="utf-8") - assert should_install_card("0.1.0", "0.1.0", target) is False - assert should_install_card("0.1.0", "0.0.9", target) is True - assert should_install_card(None, "0.1.0", target) is False + target.write_text("same-content", encoding="utf-8") + assert should_install_card("0.1.0", "0.1.0", source, target) is False + assert should_install_card("0.1.0", "0.0.9", source, target) is True + assert should_install_card(None, "0.1.0", source, target) is False + + target.write_text("changed-content", encoding="utf-8") + assert should_install_card("0.1.0", "0.1.0", source, target) is True def test_ensure_card_installed_copies_bundle(tmp_path: Path) -> None: From 85931376c9f0c584a39adfae62bbc81e71f36245 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 17:34:07 +0000 Subject: [PATCH 22/25] feat(card): remove Geo-fence label from top-left quadrant --- card-src/webasto-connect-card.js | 2 +- custom_components/webastoconnect/card/webasto-connect-card.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index 90c700c..0b392d0 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -168,7 +168,7 @@ class WebastoConnectCard extends HTMLElement { this._config.center_icon || main?.attributes?.icon || "mdi:car-defrost-rear"; - const titleGeoFence = localize(this._hass, "card.ui.geo_fence"); + const titleGeoFence = ""; const titleMode = localize(this._hass, "card.ui.mode"); const titleTimers = localize(this._hass, "card.ui.timers"); const titleMap = localize(this._hass, "card.ui.map"); diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index 6c66f9b..d81e91c 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -256,7 +256,7 @@ class WebastoConnectCard extends HTMLElement { this._config.center_icon || main?.attributes?.icon || "mdi:car-defrost-rear"; - const titleGeoFence = localize(this._hass, "card.ui.geo_fence"); + const titleGeoFence = ""; const titleMode = localize(this._hass, "card.ui.mode"); const titleTimers = localize(this._hass, "card.ui.timers"); const titleMap = localize(this._hass, "card.ui.map"); From de0ea4b738e58b776ca224f2fef094e86f3eb8cc Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 18:49:19 +0000 Subject: [PATCH 23/25] feat(card): iterate map popup behavior and document known issues --- README.md | 33 +- card-src/package.json | 2 +- card-src/translations/da.json | 4 +- card-src/translations/en.json | 4 +- card-src/webasto-connect-card.js | 164 +++++- .../card/webasto-connect-card.js | 530 ++++-------------- 6 files changed, 300 insertions(+), 437 deletions(-) diff --git a/README.md b/README.md index 6f07662..1882517 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ Buy Me A Coffee -This module provides a way of integrating with Webasto ThermoConnect devices. +This integration provides support for Webasto ThermoConnect devices. # Please note -Webasto DOES NOT provide any public API or documentation of such, so I cannot provide any guarantees that this will continue to work for all eternity. +Webasto DOES NOT provide any public API or documentation for it, so I cannot provide any guarantees that this will continue to work long-term. -# Table of Content +# Table of Contents **[Installation](#installation)**
**[Setup](#setup)**
-**[My heater doesn't show up](#my-heater-doesnt-show-up)**
+**[Known Issues](#known-issues)**
# Installation: @@ -29,18 +29,20 @@ Webasto DOES NOT provide any public API or documentation of such, so I ca # Setup -My Home Assistant shortcut:
+Open setup in Home Assistant:
[![](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=webastoconnect) Or go to Home Assistant > Settings > Integrations -Add "Webasto Connect (ThermoConnect)" integration *(If it doesn't show, try CTRL+F5 to force a refresh of the page)* +Add "Webasto Connect (ThermoConnect)" integration *(If it doesn't appear, try a hard refresh with Ctrl+F5.)* Enter your Webasto account email and password -# My heater doesn't show up +# Known Issues -If your heater doesn't show up in the integration, please make sure it is connected to the e-mail used. +## My heater doesn't show up + +If your heater doesn't show up in the integration, please make sure it is connected to the email used. * Login to https://my.webastoconnect.com _USING THE SAME EMAIL AND PASSWORD_ as used in the integration * Press `Account` @@ -51,19 +53,14 @@ If your device is NOT listed under devices: * Open the ThermoConnect app on your phone * Select the missing device (If you have more than one connected) -* Click on the `"My Webasto Connect` button in the lower left +* Click on the `My Webasto Connect` button in the lower left * Choose `Login with mobile browser` * Login with your existing email and password -The device should now be linked to your email account and will show up after a restart of Home Assistant, or after reloading the integration - -# Development: Coverage in VS Code - -The devcontainer includes `ryanluker.vscode-coverage-gutters`. +The device should now be linked to your email account and will show up after a Home Assistant restart or after reloading the integration. -To generate coverage for it: +## Webasto Connect Card map popup centering -* Run the VS Code task `Run tests with coverage.xml`, or run: - * `python3 -m pytest tests --cov=custom_components --cov-report=term-missing --cov-report=xml:coverage.xml` +When opening the `Map` popup in the custom `Webasto Connect Card`, the tracked entity marker can appear visually lower than expected instead of perfectly centered. -Coverage Gutters is configured to read `coverage.xml` from the workspace root. +This behavior comes from Home Assistant's built-in map rendering in popup or modal contexts. The integration currently uses the built-in map implementation and does not override this behavior. diff --git a/card-src/package.json b/card-src/package.json index 7512080..10fc5de 100644 --- a/card-src/package.json +++ b/card-src/package.json @@ -1,7 +1,7 @@ { "name": "webasto-connect-card", "private": true, - "version": "0.1.0b6", + "version": "0.1.0b19", "type": "module", "scripts": { "build": "node build.mjs", diff --git a/card-src/translations/da.json b/card-src/translations/da.json index e544248..7d67c10 100644 --- a/card-src/translations/da.json +++ b/card-src/translations/da.json @@ -11,7 +11,9 @@ "left": "tilbage", "main_output_missing": "Vælg Main output entity i kortindstillinger", "output": "Output", - "toggle_output": "Skift output" + "toggle_output": "Skift output", + "close": "Luk", + "map_unavailable": "Lokation er ikke tilgængelig" } } } diff --git a/card-src/translations/en.json b/card-src/translations/en.json index 76a6a78..2083b35 100644 --- a/card-src/translations/en.json +++ b/card-src/translations/en.json @@ -11,7 +11,9 @@ "left": "left", "main_output_missing": "Select Main output entity in card settings", "output": "Output", - "toggle_output": "Toggle output" + "toggle_output": "Toggle output", + "close": "Close", + "map_unavailable": "Location is unavailable" } } } diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js index 0b392d0..c526697 100644 --- a/card-src/webasto-connect-card.js +++ b/card-src/webasto-connect-card.js @@ -143,6 +143,59 @@ class WebastoConnectCard extends HTMLElement { return "--"; } + _isMapEnabled(locationEntityId, locationEntity) { + if (!locationEntityId || !locationEntityId.startsWith("device_tracker.")) { + return false; + } + if (!locationEntity) { + return false; + } + return locationEntity.state !== "unknown" && locationEntity.state !== "unavailable"; + } + + _openMapPopup() { + const entityId = this._config?.location_entity; + const location = this._getState(entityId); + if (!this._isMapEnabled(entityId, location)) { + return; + } + this._mapPopupOpen = true; + this._render(); + } + + _closeMapPopup() { + this._mapPopupOpen = false; + this._render(); + } + + async _renderMapPopup(entityId) { + const host = this.shadowRoot?.getElementById("map-card-host"); + if (!host || !this._hass || !entityId) { + return; + } + + host.innerHTML = ""; + try { + const helpers = await window.loadCardHelpers?.(); + const mapCard = await helpers?.createCardElement?.({ + type: "map", + entities: [entityId], + }); + + if (!mapCard) { + host.innerHTML = `
${escapeAttr(localize(this._hass, "card.ui.map_unavailable"))}
`; + return; + } + + mapCard.hass = this._hass; + mapCard.style.display = "block"; + mapCard.style.height = "360px"; + host.appendChild(mapCard); + } catch (_err) { + host.innerHTML = `
${escapeAttr(localize(this._hass, "card.ui.map_unavailable"))}
`; + } + } + _render() { if (!this.shadowRoot || !this._config || !this._hass) { return; @@ -173,6 +226,12 @@ class WebastoConnectCard extends HTMLElement { const titleTimers = localize(this._hass, "card.ui.timers"); const titleMap = localize(this._hass, "card.ui.map"); const toggleLabel = localize(this._hass, "card.ui.toggle_output"); + const mapEnabled = this._isMapEnabled(this._config.location_entity, location); + const mapClass = mapEnabled ? "map-enabled" : "map-disabled"; + const mapTabIndex = mapEnabled ? "0" : "-1"; + const mapAriaDisabled = mapEnabled ? "false" : "true"; + const mapPopup = this._mapPopupOpen && mapEnabled; + const closeText = localize(this._hass, "card.ui.close"); this.shadowRoot.innerHTML = `
${titleGeoFence}
${titleMode}
${titleTimers}
-
${titleMap}
+
${titleMap}
@@ -319,6 +441,17 @@ class WebastoConnectCard extends HTMLElement {
${locationText}
+ ${mapPopup ? ` +
+ +
+ ` : ""} `; const center = this.shadowRoot.getElementById("center-toggle"); @@ -331,6 +464,35 @@ class WebastoConnectCard extends HTMLElement { } }; } + + const mapAction = this.shadowRoot.getElementById("map-action"); + if (mapAction && mapEnabled) { + mapAction.onclick = () => this._openMapPopup(); + mapAction.onkeydown = (ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + this._openMapPopup(); + } + }; + } + + if (mapPopup) { + void this._renderMapPopup(this._config.location_entity); + + const close = this.shadowRoot.getElementById("map-modal-close"); + if (close) { + close.onclick = () => this._closeMapPopup(); + } + + const backdrop = this.shadowRoot.getElementById("map-modal-backdrop"); + if (backdrop) { + backdrop.onclick = (ev) => { + if (ev.target === backdrop) { + this._closeMapPopup(); + } + }; + } + } } getCardSize() { diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js index d81e91c..f52d8ef 100644 --- a/custom_components/webastoconnect/card/webasto-connect-card.js +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -1,268 +1,5 @@ -globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0b6"; - -const WEBASTO_CONNECT_CARD_TRANSLATIONS = { - da: { - card: { - ui: { - geo_fence: "Geo-fence", - mode: "Modus", - timers: "Timere", - map: "Kort", - active: "Aktiv", - inactive: "Ikke aktiv", - ending_now: "Slutter nu", - left: "tilbage", - main_output_missing: "Vælg Main output entity i kortindstillinger", - output: "Output", - toggle_output: "Skift output", - }, - }, - }, - en: { - card: { - ui: { - geo_fence: "Geo-fence", - mode: "Mode", - timers: "Timers", - map: "Map", - active: "Active", - inactive: "Inactive", - ending_now: "Ending now", - left: "left", - main_output_missing: "Select Main output entity in card settings", - output: "Output", - toggle_output: "Toggle output", - }, - }, - }, -}; - -function getNestedTranslation(obj, path) { - if (!obj) return undefined; - - const keys = path.split("."); - let result = obj; - - for (const key of keys) { - if (result === undefined || result === null || typeof result !== "object") { - return undefined; - } - result = result[key]; - } - - return typeof result === "string" ? result : undefined; -} - -function resolveLanguage(language) { - const raw = String(language || "en").toLowerCase(); - if (WEBASTO_CONNECT_CARD_TRANSLATIONS[raw]) return raw; - - const short = raw.split("-")[0]; - if (WEBASTO_CONNECT_CARD_TRANSLATIONS[short]) return short; - - return "en"; -} - -function localize(hass, key, vars = {}) { - const lang = resolveLanguage(hass?.language); - - let translated = - getNestedTranslation(WEBASTO_CONNECT_CARD_TRANSLATIONS[lang], key) ?? - getNestedTranslation(WEBASTO_CONNECT_CARD_TRANSLATIONS.en, key) ?? - key; - - Object.entries(vars).forEach(([name, value]) => { - translated = translated.replace(`{${name}}`, String(value)); - }); - - return translated; -} - -function escapeAttr(value) { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll('"', """) - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -class WebastoConnectCard extends HTMLElement { - static getConfigElement() { - return document.createElement("webasto-connect-card-editor"); - } - - static getStubConfig() { - return { - main_output_entity: "switch.webasto_main_output", - ventilation_mode_entity: "switch.webasto_ventilation_mode", - end_time_entity: "sensor.webasto_main_output_end_time", - temperature_entity: "sensor.webasto_temperature", - battery_entity: "sensor.webasto_battery_voltage", - location_entity: "device_tracker.webasto_location", - }; - } - - setConfig(config) { - if (!config.main_output_entity) { - throw new Error("Missing required config: main_output_entity"); - } - this._config = { - ventilation_mode_entity: config.ventilation_mode_entity, - end_time_entity: config.end_time_entity, - ...config, - }; - } - - set hass(hass) { - this._hass = hass; - this._render(); - } - - connectedCallback() { - if (!this.shadowRoot) { - this.attachShadow({ mode: "open" }); - } - this._render(); - } - - _getState(entityId) { - return entityId ? this._hass?.states?.[entityId] : undefined; - } - - _parseEndDate(value) { - if (value === null || value === undefined || value === "") { - return null; - } - - // Accept both ISO datetime and Unix timestamp (seconds or milliseconds). - const numeric = Number(value); - if (Number.isFinite(numeric)) { - const millis = numeric < 1e12 ? numeric * 1000 : numeric; - const tsDate = new Date(millis); - return Number.isNaN(tsDate.getTime()) ? null : tsDate; - } - - const date = new Date(String(value)); - return Number.isNaN(date.getTime()) ? null : date; - } - - _computeLabel(mainEntity, endEntity) { - if (!mainEntity || mainEntity.state !== "on") { - return localize(this._hass, "card.ui.inactive"); - } - - if ( - !endEntity || - !endEntity.state || - endEntity.state === "unknown" || - endEntity.state === "unavailable" - ) { - return localize(this._hass, "card.ui.active"); - } - - const end = this._parseEndDate(endEntity.state); - if (!end) { - return localize(this._hass, "card.ui.active"); - } - - const leftMinutes = Math.ceil((end.getTime() - Date.now()) / 60000); - if (leftMinutes <= 0) { - return localize(this._hass, "card.ui.ending_now"); - } - const hours = Math.floor(leftMinutes / 60); - const minutes = leftMinutes % 60; - return `${hours}:${String(minutes).padStart(2, "0")} ${localize(this._hass, "card.ui.left")}`; - } - - _computeOutputName(mainOutputState) { - const friendlyName = mainOutputState?.attributes?.friendly_name; - if (typeof friendlyName === "string" && friendlyName.trim() !== "") { - return friendlyName; - } - return localize(this._hass, "card.ui.output"); - } - - _toggleMainOutput() { - const entityId = this._config?.main_output_entity; - if (!this._hass || !entityId || !this._hass.states?.[entityId]) { - console.warn( - "[webasto-connect-card] Missing or unavailable main_output_entity:", - entityId - ); - return; - } - this._hass.callService("homeassistant", "toggle", { - entity_id: entityId, - }); - } - - _stateWithUnit(entity) { - if (!entity) { - return "--"; - } - const state = entity.state; - if (state === "unknown" || state === "unavailable") { - return "--"; - } - const unit = entity.attributes?.unit_of_measurement; - return unit ? `${state} ${unit}` : String(state); - } - - _locationText(entity) { - if (!entity) { - return "--"; - } - const state = String(entity.state ?? ""); - if ( - state !== "" && - state !== "unknown" && - state !== "unavailable" && - state !== "not_home" - ) { - return state; - } - - const lat = entity.attributes?.latitude; - const lon = entity.attributes?.longitude; - if (typeof lat === "number" && typeof lon === "number") { - return `${lat.toFixed(5)}, ${lon.toFixed(5)}`; - } - - return "--"; - } - - _render() { - if (!this.shadowRoot || !this._config || !this._hass) { - return; - } - - const main = this._getState(this._config.main_output_entity); - const end = this._getState(this._config.end_time_entity); - const temp = this._getState(this._config.temperature_entity); - const battery = this._getState(this._config.battery_entity); - const location = this._getState(this._config.location_entity); - - const isMainAvailable = Boolean(main); - const isOn = isMainAvailable && main.state === "on"; - const ringColor = isOn ? "#d33131" : "#c5cfdf"; - const outputName = this._computeOutputName(main); - const label = isMainAvailable - ? this._computeLabel(main, end) - : localize(this._hass, "card.ui.main_output_missing"); - const tempText = this._stateWithUnit(temp); - const batteryText = this._stateWithUnit(battery); - const locationText = this._locationText(location); - const icon = - this._config.center_icon || - main?.attributes?.icon || - "mdi:car-defrost-rear"; - const titleGeoFence = ""; - const titleMode = localize(this._hass, "card.ui.mode"); - const titleTimers = localize(this._hass, "card.ui.timers"); - const titleMap = localize(this._hass, "card.ui.map"); - const toggleLabel = localize(this._hass, "card.ui.toggle_output"); - - this.shadowRoot.innerHTML = ` +globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "0.1.0b19"; +var x={card:{ui:{geo_fence:"Geo-fence",mode:"Modus",timers:"Timere",map:"Kort",active:"Aktiv",inactive:"Ikke aktiv",ending_now:"Slutter nu",left:"tilbage",main_output_missing:"V\xE6lg Main output entity i kortindstillinger",output:"Output",toggle_output:"Skift output",close:"Luk",map_unavailable:"Lokation er ikke tilg\xE6ngelig"}}};var v={card:{ui:{geo_fence:"Geo-fence",mode:"Mode",timers:"Timers",map:"Map",active:"Active",inactive:"Inactive",ending_now:"Ending now",left:"left",main_output_missing:"Select Main output entity in card settings",output:"Output",toggle_output:"Toggle output",close:"Close",map_unavailable:"Location is unavailable"}}};var p={da:x,en:v};function w(o,t){if(!o)return;let e=t.split("."),i=o;for(let a of e){if(i==null||typeof i!="object")return;i=i[a]}return typeof i=="string"?i:void 0}function I(o){let t=String(o||"en").toLowerCase();if(p[t])return t;let e=t.split("-")[0];return p[e]?e:"en"}function n(o,t,e={}){let i=I(o?.language),a=w(p[i],t)??w(p.en,t)??t;return Object.entries(e).forEach(([l,c])=>{a=a.replace(`{${l}}`,String(c))}),a}function r(o){return String(o??"").replaceAll("&","&").replaceAll('"',""").replaceAll("<","<").replaceAll(">",">")}var g=class extends HTMLElement{static getConfigElement(){return document.createElement("webasto-connect-card-editor")}static getStubConfig(){return{main_output_entity:"switch.webasto_main_output",ventilation_mode_entity:"switch.webasto_ventilation_mode",end_time_entity:"sensor.webasto_main_output_end_time",temperature_entity:"sensor.webasto_temperature",battery_entity:"sensor.webasto_battery_voltage",location_entity:"device_tracker.webasto_location"}}setConfig(t){if(!t.main_output_entity)throw new Error("Missing required config: main_output_entity");this._config={ventilation_mode_entity:t.ventilation_mode_entity,end_time_entity:t.end_time_entity,...t}}set hass(t){this._hass=t,this._render()}connectedCallback(){this.shadowRoot||this.attachShadow({mode:"open"}),this._render()}_getState(t){return t?this._hass?.states?.[t]:void 0}_parseEndDate(t){if(t==null||t==="")return null;let e=Number(t);if(Number.isFinite(e)){let a=e<1e12?e*1e3:e,l=new Date(a);return Number.isNaN(l.getTime())?null:l}let i=new Date(String(t));return Number.isNaN(i.getTime())?null:i}_computeLabel(t,e){if(!t||t.state!=="on")return n(this._hass,"card.ui.inactive");if(!e||!e.state||e.state==="unknown"||e.state==="unavailable")return n(this._hass,"card.ui.active");let i=this._parseEndDate(e.state);if(!i)return n(this._hass,"card.ui.active");let a=Math.ceil((i.getTime()-Date.now())/6e4);if(a<=0)return n(this._hass,"card.ui.ending_now");let l=Math.floor(a/60),c=a%60;return`${l}:${String(c).padStart(2,"0")} ${n(this._hass,"card.ui.left")}`}_computeOutputName(t){let e=t?.attributes?.friendly_name;return typeof e=="string"&&e.trim()!==""?e:n(this._hass,"card.ui.output")}_toggleMainOutput(){let t=this._config?.main_output_entity;if(!this._hass||!t||!this._hass.states?.[t]){console.warn("[webasto-connect-card] Missing or unavailable main_output_entity:",t);return}this._hass.callService("homeassistant","toggle",{entity_id:t})}_stateWithUnit(t){if(!t)return"--";let e=t.state;if(e==="unknown"||e==="unavailable")return"--";let i=t.attributes?.unit_of_measurement;return i?`${e} ${i}`:String(e)}_locationText(t){if(!t)return"--";let e=String(t.state??"");if(e!==""&&e!=="unknown"&&e!=="unavailable"&&e!=="not_home")return e;let i=t.attributes?.latitude,a=t.attributes?.longitude;return typeof i=="number"&&typeof a=="number"?`${i.toFixed(5)}, ${a.toFixed(5)}`:"--"}_isMapEnabled(t,e){return!t||!t.startsWith("device_tracker.")||!e?!1:e.state!=="unknown"&&e.state!=="unavailable"}_openMapPopup(){let t=this._config?.location_entity,e=this._getState(t);this._isMapEnabled(t,e)&&(this._mapPopupOpen=!0,this._render())}_closeMapPopup(){this._mapPopupOpen=!1,this._render()}async _renderMapPopup(t){let e=this.shadowRoot?.getElementById("map-card-host");if(!(!e||!this._hass||!t)){e.innerHTML="";try{let a=await(await window.loadCardHelpers?.())?.createCardElement?.({type:"map",entities:[t]});if(!a){e.innerHTML=`
${r(n(this._hass,"card.ui.map_unavailable"))}
`;return}a.hass=this._hass,a.style.display="block",a.style.height="360px",e.appendChild(a)}catch{e.innerHTML=`
${r(n(this._hass,"card.ui.map_unavailable"))}
`}}}_render(){if(!this.shadowRoot||!this._config||!this._hass)return;let t=this._getState(this._config.main_output_entity),e=this._getState(this._config.end_time_entity),i=this._getState(this._config.temperature_entity),a=this._getState(this._config.battery_entity),l=this._getState(this._config.location_entity),c=!!t,y=c&&t.state==="on"?"#d33131":"#c5cfdf",k=this._computeOutputName(t),$=c?this._computeLabel(t,e):n(this._hass,"card.ui.main_output_missing"),M=this._stateWithUnit(i),S=this._stateWithUnit(a),T=this._locationText(l),E=this._config.center_icon||t?.attributes?.icon||"mdi:car-defrost-rear",z="",C=n(this._hass,"card.ui.mode"),q=n(this._hass,"card.ui.timers"),u=n(this._hass,"card.ui.map"),L=n(this._hass,"card.ui.toggle_output"),d=this._isMapEnabled(this._config.location_entity,l),O=d?"map-enabled":"map-disabled",N=d?"0":"-1",P=d?"false":"true",b=this._mapPopupOpen&&d,R=n(this._hass,"card.ui.close");this.shadowRoot.innerHTML=`
-
${titleGeoFence}
-
${titleMode}
-
${titleTimers}
-
${titleMap}
+
${z}
+
${C}
+
${q}
+
${u}
-
- -
${outputName}
-
${label}
+
+ +
${k}
+
${$}
- ${tempText} - ${batteryText} + ${M} + ${S}
-
${locationText}
+
${T}
- `; - - const center = this.shadowRoot.getElementById("center-toggle"); - if (center) { - center.onclick = () => this._toggleMainOutput(); - center.onkeydown = (ev) => { - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - this._toggleMainOutput(); - } - }; - } - } - - getCardSize() { - return 4; - } -} - -class WebastoConnectCardEditor extends HTMLElement { - setConfig(config) { - this._config = { ...config }; - this._render(); - } - - set hass(hass) { - this._hass = hass; - if (!this._suggestionsLoaded) { - this._suggestionsLoaded = true; - this._loadSuggestions(); - } - this._render(); - } - - connectedCallback() { - if (!this.shadowRoot) { - this.attachShadow({ mode: "open" }); - } - this._render(); - } - - async _loadSuggestions() { - if (!this._hass) { - return; - } - - try { - const registry = await this._hass.callWS({ - type: "config/entity_registry/list", - }); - const webastoEntities = registry - .filter((entry) => entry.platform === "webastoconnect" && !entry.hidden_by) - .map((entry) => entry.entity_id); - - this._entitySuggestions = [...new Set(webastoEntities)].sort(); - } catch (_err) { - const fallback = Object.keys(this._hass.states || {}).filter((entityId) => - entityId.includes("webasto") - ); - this._entitySuggestions = [...new Set(fallback)].sort(); - } - - this._render(); - } - - _datalistOptions(domains) { - const suggestions = this._entitySuggestions || []; - return suggestions - .filter((entityId) => domains.includes(entityId.split(".")[0])) - .map((entityId) => ``) - .join(""); - } - - _handleInput(ev) { - const field = ev.target?.dataset?.field; - if (!field) { - return; - } - - const value = String(ev.target.value || "").trim(); - const next = { ...(this._config || {}) }; - if (value === "") { - delete next[field]; - } else { - next[field] = value; - } - - this._config = next; - this.dispatchEvent( - new CustomEvent("config-changed", { - detail: { config: next }, - bubbles: true, - composed: true, - }) - ); - } - - _render() { - if (!this.shadowRoot) { - return; - } - - const cfg = this._config || {}; - this.shadowRoot.innerHTML = ` + ${b?` +
+ +
+ `:""} + `;let h=this.shadowRoot.getElementById("center-toggle");h&&(h.onclick=()=>this._toggleMainOutput(),h.onkeydown=s=>{(s.key==="Enter"||s.key===" ")&&(s.preventDefault(),this._toggleMainOutput())});let m=this.shadowRoot.getElementById("map-action");if(m&&d&&(m.onclick=()=>this._openMapPopup(),m.onkeydown=s=>{(s.key==="Enter"||s.key===" ")&&(s.preventDefault(),this._openMapPopup())}),b){this._renderMapPopup(this._config.location_entity);let s=this.shadowRoot.getElementById("map-modal-close");s&&(s.onclick=()=>this._closeMapPopup());let _=this.shadowRoot.getElementById("map-modal-backdrop");_&&(_.onclick=A=>{A.target===_&&this._closeMapPopup()})}}getCardSize(){return 4}},f=class extends HTMLElement{setConfig(t){this._config={...t},this._render()}set hass(t){this._hass=t,this._suggestionsLoaded||(this._suggestionsLoaded=!0,this._loadSuggestions()),this._render()}connectedCallback(){this.shadowRoot||this.attachShadow({mode:"open"}),this._render()}async _loadSuggestions(){if(this._hass){try{let e=(await this._hass.callWS({type:"config/entity_registry/list"})).filter(i=>i.platform==="webastoconnect"&&!i.hidden_by).map(i=>i.entity_id);this._entitySuggestions=[...new Set(e)].sort()}catch{let e=Object.keys(this._hass.states||{}).filter(i=>i.includes("webasto"));this._entitySuggestions=[...new Set(e)].sort()}this._render()}}_datalistOptions(t){return(this._entitySuggestions||[]).filter(i=>t.includes(i.split(".")[0])).map(i=>``).join("")}_handleInput(t){let e=t.target?.dataset?.field;if(!e)return;let i=String(t.target.value||"").trim(),a={...this._config||{}};i===""?delete a[e]:a[e]=i,this._config=a,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:a},bubbles:!0,composed:!0}))}_render(){if(!this.shadowRoot)return;let t=this._config||{};this.shadowRoot.innerHTML=`
Suggestions are limited to entities from the Webasto Connect integration.
${this._datalistOptions(["switch"])} ${this._datalistOptions(["sensor"])} - ${this._datalistOptions(["sensor", "device_tracker"])} - `; - - this.shadowRoot.querySelectorAll("input").forEach((input) => { - input.addEventListener("change", (ev) => this._handleInput(ev)); - }); - } -} - -if (!customElements.get("webasto-connect-card")) { - customElements.define("webasto-connect-card", WebastoConnectCard); -} -if (!customElements.get("webasto-connect-card-editor")) { - customElements.define("webasto-connect-card-editor", WebastoConnectCardEditor); -} - -window.customCards = window.customCards || []; -window.customCards.push({ - type: "webasto-connect-card", - name: "Webasto Connect Card", - description: "Webasto Connect card with center toggle for main output", - preview: true, -}); + ${this._datalistOptions(["sensor","device_tracker"])} + `,this.shadowRoot.querySelectorAll("input").forEach(e=>{e.addEventListener("change",i=>this._handleInput(i))})}};customElements.get("webasto-connect-card")||customElements.define("webasto-connect-card",g);customElements.get("webasto-connect-card-editor")||customElements.define("webasto-connect-card-editor",f);window.customCards=window.customCards||[];window.customCards.push({type:"webasto-connect-card",name:"Webasto Connect Card",description:"Webasto Connect card with center toggle for main output",preview:!0}); From 872afb7c56fe6dfb87fe1e5489f4cac89758adc9 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 18:54:37 +0000 Subject: [PATCH 24/25] test: align setup refresh tests with card install bootstrap --- tests/test_setup_initial_refresh.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_setup_initial_refresh.py b/tests/test_setup_initial_refresh.py index 710002a..afb1539 100644 --- a/tests/test_setup_initial_refresh.py +++ b/tests/test_setup_initial_refresh.py @@ -9,6 +9,15 @@ import custom_components.webastoconnect as integration +def _mock_hass_for_setup() -> SimpleNamespace: + """Build a minimal hass mock compatible with integration._async_setup.""" + return SimpleNamespace( + async_add_executor_job=AsyncMock(return_value=(False, None)), + config=SimpleNamespace(path=lambda *_args, **_kwargs: "/tmp"), + data={}, + ) + + @pytest.mark.asyncio async def test_setup_skips_first_refresh_when_connect_hydrates_devices(monkeypatch) -> None: """Skip the coordinator first refresh if connect already provided devices.""" @@ -30,12 +39,12 @@ def coordinator_factory(*_args, **_kwargs): monkeypatch.setattr( integration, "async_get_integration", - AsyncMock(return_value=SimpleNamespace(version="test")), + AsyncMock(return_value=SimpleNamespace(version="test", file_path="/tmp")), ) migrate_mock = AsyncMock() monkeypatch.setattr(integration, "_async_migrate_unique_ids", migrate_mock) - hass = SimpleNamespace() + hass = _mock_hass_for_setup() entry = SimpleNamespace(entry_id="entry-1", data={CONF_EMAIL: "a@b.c"}, options={}) result = await integration._async_setup(hass, entry) @@ -68,10 +77,10 @@ def coordinator_factory(*_args, **_kwargs): monkeypatch.setattr( integration, "async_get_integration", - AsyncMock(return_value=SimpleNamespace(version="test")), + AsyncMock(return_value=SimpleNamespace(version="test", file_path="/tmp")), ) - hass = SimpleNamespace() + hass = _mock_hass_for_setup() entry = SimpleNamespace(entry_id="entry-1", data={CONF_EMAIL: "a@b.c"}, options={}) result = await integration._async_setup(hass, entry) From 625043a8a9aa38af34c76005b60097059dba356d Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Mon, 2 Mar 2026 18:59:28 +0000 Subject: [PATCH 25/25] ci: enforce committed card bundle and add release card build --- .github/workflows/ci.yml | 15 +++++++++++++++ .github/workflows/release.yml | 9 +++++++++ card-src/package.json | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8afe7b..2ee0a06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + - name: Set up Python uses: actions/setup-python@v6 with: @@ -26,6 +31,16 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt + - name: Build Webasto Connect Card + run: | + cd card-src + npm install + npm run build + + - name: Verify card bundle is committed + run: | + git diff --exit-code -- custom_components/webastoconnect/card/webasto-connect-card.js + - name: Run Ruff run: ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a23eda2..9ca7217 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,10 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" - name: Update manifest.json version to ${{ github.event.release.tag_name }} run: | python3 ${{ github.workspace }}/.github/scripts/update_hacs_manifest.py --version ${{ github.event.release.tag_name }} --path /custom_components/webastoconnect/ @@ -25,6 +29,11 @@ jobs: git add ./custom_components/webastoconnect/manifest.json git commit -m "Updated manifest.json" git push origin HEAD:main + - name: Build Webasto Connect Card + run: | + cd card-src + npm install + npm run build - name: Create zip run: | cd custom_components/webastoconnect diff --git a/card-src/package.json b/card-src/package.json index 10fc5de..019fcf7 100644 --- a/card-src/package.json +++ b/card-src/package.json @@ -8,6 +8,6 @@ "build:watch": "node build.mjs --watch" }, "devDependencies": { - "esbuild": "^0.25.2" + "esbuild": "0.25.2" } }