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/.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/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/.gitignore b/card-src/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/card-src/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/card-src/README.md b/card-src/README.md new file mode 100644 index 0000000..eeae032 --- /dev/null +++ b/card-src/README.md @@ -0,0 +1,57 @@ +# Webasto Connect Card + +Custom Home Assistant Lovelace card. + +## Files +- `webasto-connect-card.js`: source for the custom card +- `../custom_components/webastoconnect/card/webasto-connect-card.js`: built single-file card module (generated) +- `localize/localize.js`: translation lookup and language fallback +- `translations/*.json`: per-language strings +- `webasto_connect_card.yaml`: example card configuration + +## Build (separate from release) +From repository root: + +```bash +cd card-src +npm install +npm run build +``` + +For iterative testing while developing: + +```bash +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` + +Manual install is optional: + +From this repository root: + +```bash +mkdir -p config/www/webastoconnect +cp custom_components/webastoconnect/card/webasto-connect-card.js config/www/webastoconnect/webasto-connect-card.js +``` + +Then add a Lovelace resource: +- URL: `/local/webastoconnect/webasto-connect-card.js` +- Type: `module` + +Use the example from `card-src/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-src/VERSION b/card-src/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/card-src/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/card-src/build.mjs b/card-src/build.mjs new file mode 100644 index 0000000..039102b --- /dev/null +++ b/card-src/build.mjs @@ -0,0 +1,31 @@ +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"], + bundle: true, + format: "esm", + target: ["es2020"], + minify: true, + sourcemap: false, + outfile: "../custom_components/webastoconnect/card/webasto-connect-card.js", + logLevel: "info", + loader: { + ".json": "json", + }, + banner: { + js: `globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "${cardVersion}";`, + }, +}; + +if (watch) { + const ctx = await context(buildOptions); + await ctx.watch(); + console.log("Watching for changes..."); +} else { + await build(buildOptions); +} diff --git a/card-src/localize/localize.js b/card-src/localize/localize.js new file mode 100644 index 0000000..6a23c06 --- /dev/null +++ b/card-src/localize/localize.js @@ -0,0 +1,48 @@ +import da from "../translations/da.json"; +import en from "../translations/en.json"; + +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-src/package.json b/card-src/package.json new file mode 100644 index 0000000..019fcf7 --- /dev/null +++ b/card-src/package.json @@ -0,0 +1,13 @@ +{ + "name": "webasto-connect-card", + "private": true, + "version": "0.1.0b19", + "type": "module", + "scripts": { + "build": "node build.mjs", + "build:watch": "node build.mjs --watch" + }, + "devDependencies": { + "esbuild": "0.25.2" + } +} diff --git a/card-src/translations/da.json b/card-src/translations/da.json new file mode 100644 index 0000000..7d67c10 --- /dev/null +++ b/card-src/translations/da.json @@ -0,0 +1,19 @@ +{ + "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", + "close": "Luk", + "map_unavailable": "Lokation er ikke tilgængelig" + } + } +} diff --git a/card-src/translations/en.json b/card-src/translations/en.json new file mode 100644 index 0000000..2083b35 --- /dev/null +++ b/card-src/translations/en.json @@ -0,0 +1,19 @@ +{ + "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" + } + } +} diff --git a/card-src/webasto-connect-card.js b/card-src/webasto-connect-card.js new file mode 100644 index 0000000..c526697 --- /dev/null +++ b/card-src/webasto-connect-card.js @@ -0,0 +1,667 @@ +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", + 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 "--"; + } + + _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; + } + + 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"); + 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}
+
+
+
+ +
${outputName}
+
${label}
+
+
+
+
+ ${tempText} + ${batteryText} +
+
${locationText}
+
+
+ ${mapPopup ? ` +
+ +
+ ` : ""} + `; + + 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(); + } + }; + } + + 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() { + 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 = ` + +
+ + + + + + + +
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, +}); diff --git a/card-src/webasto_connect_card.yaml b/card-src/webasto_connect_card.yaml new file mode 100644 index 0000000..761b6b7 --- /dev/null +++ b/card-src/webasto_connect_card.yaml @@ -0,0 +1,20 @@ +# 1) Add this resource in Home Assistant: +# URL: /local/webastoconnect/webasto-connect-card.js +# Type: module +# +# 2) Assets are auto-installed by the integration on load/update. +# Manual copy (optional): +# mkdir -p config/www/webastoconnect +# cp custom_components/webastoconnect/card/webasto-connect-card.js config/www/webastoconnect/webasto-connect-card.js +# +# 3) Use this card config: + +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: diff --git a/custom_components/webastoconnect/__init__.py b/custom_components/webastoconnect/__init__.py index eed83d9..d03eef3 100644 --- a/custom_components/webastoconnect/__init__.py +++ b/custom_components/webastoconnect/__init__.py @@ -3,8 +3,16 @@ from collections.abc import Callable from dataclasses import dataclass import logging +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 @@ -15,7 +23,8 @@ from pywebasto.exceptions import InvalidRequestException, UnauthorizedException from .api import WebastoConnectUpdateCoordinator -from .const import DOMAIN, PLATFORMS, STARTUP +from .card_install import ensure_card_installed +from .const import CARD_FILENAME, CARD_WWW_SUBDIR, DOMAIN, PLATFORMS, STARTUP LOGGER = logging.getLogger(__name__) @@ -105,6 +114,32 @@ 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), + Path(hass.config.path("www")), + ) + if installed: + LOGGER.info( + "Installed Webasto Connect Card v%s at /local/webastoconnect/%s", + card_version, + CARD_FILENAME, + ) + elif card_version is None: + 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, + ) + await _async_ensure_lovelace_card_resource(hass, card_version) coordinator = WebastoConnectUpdateCoordinator(hass, entry) try: @@ -132,6 +167,57 @@ async def _async_setup( return coordinator +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_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( + "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 []: + 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": + 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 + + 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) diff --git a/custom_components/webastoconnect/card/webasto-connect-card.js b/custom_components/webastoconnect/card/webasto-connect-card.js new file mode 100644 index 0000000..f52d8ef --- /dev/null +++ b/custom_components/webastoconnect/card/webasto-connect-card.js @@ -0,0 +1,280 @@ +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=` + +
+ +
${z}
+
${C}
+
${q}
+
${u}
+
+
+
+ +
${k}
+
${$}
+
+
+
+
+ ${M} + ${S} +
+
${T}
+
+
+ ${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(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}); diff --git a/custom_components/webastoconnect/card_install.py b/custom_components/webastoconnect/card_install.py new file mode 100644 index 0000000..8be16a7 --- /dev/null +++ b/custom_components/webastoconnect/card_install.py @@ -0,0 +1,76 @@ +"""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_WWW_SUBDIR, +) + +CARD_VERSION_PATTERN = re.compile( + r"__WEBASTO_CONNECT_CARD_VERSION__\s*=\s*['\"]([^'\"]+)['\"]" +) + + +def read_card_version(path: Path) -> str | None: + """Read card version marker directly from the JavaScript bundle.""" + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + + if match := CARD_VERSION_PATTERN.search(content): + return match.group(1) + + return 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.""" + if source_version is None: + return False + if not installed_entry_file.exists(): + return True + 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]: + """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 = 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 + installed_version = read_card_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) + + shutil.copy2(source_entry, target_entry) + return True, source_version diff --git a/custom_components/webastoconnect/const.py b/custom_components/webastoconnect/const.py index 472b886..00aec95 100644 --- a/custom_components/webastoconnect/const.py +++ b/custom_components/webastoconnect/const.py @@ -16,3 +16,7 @@ PLATFORMS = ["sensor", "switch", "device_tracker", "binary_sensor", "number"] NEW_DATA = "webasto_signal" + +CARD_FILENAME = "webasto-connect-card.js" +CARD_SOURCE_DIR = "card" +CARD_WWW_SUBDIR = "webastoconnect" 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" 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 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 new file mode 100644 index 0000000..1b23124 --- /dev/null +++ b/tests/test_card_install.py @@ -0,0 +1,80 @@ +"""Tests for bundled card install/version handling.""" + +from pathlib import Path + +from custom_components.webastoconnect.card_install import ( + ensure_card_installed, + read_card_version, + should_install_card, +) + + +def _prepare_source_tree(base: Path, version: str) -> Path: + integration_path = base / "integration" + source_dir = integration_path / "card" + source_dir.mkdir(parents=True) + + (source_dir / "webasto-connect-card.js").write_text( + f'globalThis.__WEBASTO_CONNECT_CARD_VERSION__ = "{version}";\n' + "console.log('webasto card');", + encoding="utf-8", + ) + return integration_path + + +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: + """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, source, target) is True + + 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: + """Built card should be copied to www folder.""" + integration_path = _prepare_source_tree(tmp_path, "0.1.0") + www_path = tmp_path / "www" + + installed, version = ensure_card_installed(integration_path, www_path) + + assert installed is True + assert version == "0.1.0" + assert (www_path / "webastoconnect" / "webasto-connect-card.js").exists() + + +def test_ensure_card_installed_skips_when_up_to_date(tmp_path: Path) -> None: + """Second install with same version should be skipped.""" + integration_path = _prepare_source_tree(tmp_path, "0.1.0") + www_path = tmp_path / "www" + + first_installed, _ = ensure_card_installed(integration_path, www_path) + second_installed, version = ensure_card_installed(integration_path, www_path) + + assert first_installed is True + assert second_installed is False + assert version == "0.1.0" 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)