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 @@
-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/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}
+
+
+
+
+ ${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 = `
+
+
+
+
+
+ `;
+
+ 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}
+
+
+
+
+
+
+ ${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=`
+
+
+
+
+
+ `,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)