From 7561109661687dc60a5d9ba0243572b56881e7fd Mon Sep 17 00:00:00 2001 From: Steffen Froehlich Date: Sun, 8 Nov 2026 16:13:31 +0100 Subject: [PATCH 1/4] source vaillant_ebus: read and write the programs through the Vaillant eBUS integration instead of MQTT - new options source (mqtt | vaillant_ebus), refresh_interval, entry_id - adapters/vaillant-ebus.ts: read_time_program / write_time_program actions, one shared read per program for all cards - write job: a day can be written by an action that reads it back itself - fake integration on the dev page (?source=vaillant_ebus), unit and browser smoke tests - three new texts in all languages, options documented --- dev/main.ts | 18 +++ docs/options.md | 21 +++ scripts/smoke.mjs | 24 ++++ src/adapters/vaillant-ebus.ts | 217 +++++++++++++++++++++++++++++ src/components/busy-panel.ts | 2 + src/config.ts | 27 ++++ src/ebusd-time-program-card.ts | 63 +++++++-- src/hass.ts | 13 +- src/i18n/da.ts | 3 + src/i18n/de.ts | 3 + src/i18n/el.ts | 3 + src/i18n/en-GB.ts | 4 + src/i18n/en.ts | 3 + src/i18n/es.ts | 3 + src/i18n/fr.ts | 3 + src/i18n/hu.ts | 3 + src/i18n/it.ts | 3 + src/i18n/nl.ts | 3 + src/i18n/pl.ts | 3 + src/i18n/ro.ts | 3 + src/i18n/sv.ts | 3 + src/i18n/uk.ts | 3 + src/write-job.ts | 41 +++++- test/config.test.ts | 7 + test/fake-ebusd.ts | 47 +++++++ test/vaillant-ebus-adapter.test.ts | 200 ++++++++++++++++++++++++++ 26 files changed, 712 insertions(+), 11 deletions(-) create mode 100644 src/adapters/vaillant-ebus.ts create mode 100644 test/vaillant-ebus-adapter.test.ts diff --git a/dev/main.ts b/dev/main.ts index c9b0186..2d2fb66 100644 --- a/dev/main.ts +++ b/dev/main.ts @@ -12,6 +12,8 @@ import { applyHaTheme, mountSettingsBar } from "./settings-bar.js"; // ignore=Z1Timer_Tuesday,... writes the fake controller ACKs but never applies (-> failure) // timeout=5 verify timeout in s (default 30) // nomqtt=1 hass without callService +// source=vaillant_ebus read/write through the (fake) Vaillant eBUS integration's actions instead of MQTT +// refresh=60 vaillant_ebus: seconds between reads (default 300) // state=startup|weekend|slot|offline|nohwc start in a sensor-state scenario (toolbar on the page) // programs=z1,z2,hwc programs to show (the fake system has no Z2/Z3 sensors) // lang=de|fr|en-GB|… card language (default: hass.language = en) @@ -43,6 +45,10 @@ if (params.get("nolabel")) config.preview_label = false; const layout = params.get("layout"); if (layout) config.layout = layout as NonNullable; applyHaTheme(params.get("ha")); +const source = params.get("source"); +if (source) config.source = source as NonNullable; +const refresh = params.get("refresh"); +if (refresh) config.refresh_interval = Number(refresh); const timeout = params.get("timeout"); if (timeout) config.verify_timeout = Number(timeout); @@ -56,6 +62,18 @@ const cards: HTMLElementTagNameMap["ebusd-time-program-card"][] = []; ebusd.onUpdate = (h) => { for (const card of cards) (card as unknown as { hass: Hass }).hass = hassFor(h); }; +// log the integration's actions the card calls +const hassOf = ebusd.hass.bind(ebusd); +ebusd.hass = () => { + const hass = hassOf(); + return { + ...hass, + callService: (domain, name, data, ...rest) => { + if (domain === "vaillant_ebus") console.info(`${domain}.${name}`, data); + return hass.callService!(domain, name, data, ...rest); + }, + }; +}; // log what the card publishes, like the MQTT broker would see it const publish = ebusd.publish.bind(ebusd); ebusd.publish = (topic, payload) => { diff --git a/docs/options.md b/docs/options.md index 82bda42..c0ba6fc 100644 --- a/docs/options.md +++ b/docs/options.md @@ -41,6 +41,27 @@ type: custom:ebusd-time-program-card | `verify_timeout` | Seconds to wait per day for the controller to confirm a write | `30` | | `setback_entity` | Entity with the set-back temperature of zone 1, shown and changed in the ⓘ dialog (a `number` or `input_number` can be changed, a custom `sensor` is only shown). Other zones: per program | `sensor.` or `number.{prefix}_{circuit}_z1setbacktemp`, else `…_z1nighttemp` | +## Source + +By default the card reads ebusd's MQTT sensors and writes with `mqtt.publish`. Alternatively it can use the +[Vaillant eBUS](https://github.com/MarkBovee/vaillant-ebus) integration, which talks to ebusd over TCP without MQTT. +It needs a version with the `vaillant_ebus.read_time_program` and `vaillant_ebus.write_time_program` actions. + +| Option | Description | Default | +|---|---|---| +| `source` | `mqtt`, or `vaillant_ebus` for the Vaillant eBUS integration | `mqtt` | +| `refresh_interval` | `vaillant_ebus`: seconds between reads of the programs (from ebusd's cache). `0`: only when the dashboard opens and after a write | `300` | +| `entry_id` | `vaillant_ebus`: config entry of the integration, only needed when several are set up | | + +With `vaillant_ebus`, the MQTT options below are not used. The set-back temperature of a zone needs +`setback_entity` (e.g. the integration's `number` entity). + +```yaml +type: custom:ebusd-time-program-card +source: vaillant_ebus +programs: [z1, hwc, cc] +``` + ## ebusd See [ebusd & entities](ebusd.md#different-names) for how entity ids and topics are built. diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 90d4811..9dda020 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -88,6 +88,30 @@ await check("ebusd offline: program shows the error", "state=offline", async (ca if (!status?.includes("not available")) throw new Error(`status should say offline: ${status}`); }); +await check("source vaillant_ebus: read through the action and write a temperature", "source=vaillant_ebus&delay=0", async (card) => { + await card.locator(".slot-row").first().waitFor(); + expectEqual(await card.locator(".slot-row").count(), 2, "Z1 slots Mo–Fr"); + await card.locator(".field.pill").first().click(); + const sheet = card.locator("dialog[open]"); + await sheet.locator(".round-btn").nth(1).click(); // +0.5 °C + await sheet.locator(".btn.primary").click(); // Done + await card.locator(".apply-bar .btn.primary").click(); // Apply to 5 days + await card.locator(".busy-panel.done").waitFor({ timeout: 15_000 }); + expectEqual(await text(card.locator(".field.pill").first()), "21.5 °C", "temperature after write"); +}); + +await check("source vaillant_ebus: a day the controller ignores stops the write", "source=vaillant_ebus&delay=0&ignore=Z1Timer_Tuesday", async (card) => { + await card.locator(".slot-row").first().waitFor(); + await card.locator(".field.pill").first().click(); + const sheet = card.locator("dialog[open]"); + await sheet.locator(".round-btn").nth(1).click(); + await sheet.locator(".btn.primary").click(); + await card.locator(".apply-bar .btn.primary").click(); + await card.locator(".busy-panel.failed").waitFor({ timeout: 15_000 }); + const panel = await text(card.locator(".busy-panel")); + if (!panel.includes("did not store")) throw new Error(`failure should name the reason: ${panel}`); +}); + await browser.close(); await server.close(); console.log(failed ? `\n${failed} check(s) failed` : "\nall checks passed"); diff --git a/src/adapters/vaillant-ebus.ts b/src/adapters/vaillant-ebus.ts new file mode 100644 index 0000000..cb5f92e --- /dev/null +++ b/src/adapters/vaillant-ebus.ts @@ -0,0 +1,217 @@ +import type { ResolvedConfig } from "../config.js"; +import type { Hass } from "../hass.js"; +import { DAYS, type Day, type ProgramSpec, type Slot } from "../model/types.js"; +import { normalizeSlots } from "../model/week.js"; +import { errorMessage } from "../write-job.js"; +import type { ProgramRead } from "./mqtt.js"; + +/** + * Source `vaillant_ebus`: the time programs come from the Vaillant eBUS integration + * (github.com/MarkBovee/vaillant-ebus), which talks to ebusd over TCP instead of MQTT. + * It has no sensor per slot; the card calls its actions instead: + * - `vaillant_ebus.read_time_program` returns all days of a program, + * - `vaillant_ebus.write_time_program` writes a day, reads it back and returns what the controller stored. + */ +export const DOMAIN = "vaillant_ebus"; +export const READ_SERVICE = "read_time_program"; +export const WRITE_SERVICE = "write_time_program"; + +/** One slot as the integration's actions read and write it. */ +export interface ServiceSlot { + start: string; + end: string; + temperature?: number | null; +} + +/** Response of `read_time_program`. */ +export interface ReadResponse { + program: string; + circuit: string; + temperature: boolean; + days: Partial>; + /** Days with slots the configuration cannot read (shown as unknown, never rewritten). */ + incomplete?: Day[]; +} + +/** Response of `write_time_program`: the written days as the controller stored them. */ +export interface WriteResponse { + program: string; + circuit: string; + days: Partial>; +} + +/** Card slots from the integration's slots; the temperature only for programs that have one. */ +export function fromService(slots: readonly ServiceSlot[], spec: ProgramSpec): Slot[] { + return normalizeSlots( + slots.map((s) => { + const slot: Slot = { start: s.start, end: s.end }; + if (spec.temp && typeof s.temperature === "number") slot.temp = s.temperature; + return slot; + }), + spec, + ); +} + +/** The integration's slots from card slots. */ +export function toService(slots: readonly Slot[], spec: ProgramSpec): ServiceSlot[] { + return normalizeSlots(slots, spec).map((s) => + spec.temp && s.temp !== undefined ? { start: s.start, end: s.end, temperature: s.temp } : { start: s.start, end: s.end }, + ); +} + +/** A program read from a `read_time_program` response. */ +export function readFromResponse(response: ReadResponse, spec: ProgramSpec): ProgramRead { + const incomplete = new Set(response.incomplete ?? []); + const days = Object.fromEntries( + DAYS.map((d) => { + const raw = response.days?.[d]; + return [d, raw && !incomplete.has(d) ? fromService(raw, spec) : null]; + }), + ) as Record; + const complete = DAYS.every((d) => days[d] !== null); + return { status: complete ? "ok" : "loading", days, countMessage: null, missing: [] }; +} + +/** Readable text of a rejected service call (HA rejects with `{ code, message }`, not an Error). */ +export const errorText = errorMessage; + +/** + * Whether an error means the program cannot be read at all (integration missing, too + * old, or the controller has no such program), rather than a temporary failure. + */ +export function isMissing(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error ? String((error as { code: unknown }).code) : ""; + const text = errorText(error); + return code === "service_not_found" || /not found|not available on this controller|does not support response/i.test(text); +} + +const empty = (): Record => Object.fromEntries(DAYS.map((d) => [d, null])) as Record; + +/** The read shown while a program has not been read yet. */ +export const loadingRead = (): ProgramRead => ({ status: "loading", days: empty(), countMessage: null, missing: [] }); + +/** A program read after a failed read; `previous` keeps the last known days on a temporary failure. */ +export function errorRead(error: unknown, spec: ProgramSpec, previous?: ProgramRead): ProgramRead { + if (isMissing(error)) { + return { + status: "missing", + days: empty(), + countMessage: null, + missing: [`${DOMAIN}.${READ_SERVICE} (${spec.messagePrefix}): ${errorText(error)}`], + }; + } + return { status: "offline", days: previous?.days ?? empty(), countMessage: null, missing: [] }; +} + +/** Service data shared by both actions. */ +function baseData(cfg: ResolvedConfig, spec: ProgramSpec): Record { + const data: Record = { program: spec.messagePrefix }; + if (cfg.entryId) data.entry_id = cfg.entryId; + return data; +} + +/** `response` of HA's callService result (`{ context, response }`). */ +function responseOf(result: unknown): T { + const response = result && typeof result === "object" && "response" in result ? (result as { response: unknown }).response : undefined; + if (!response || typeof response !== "object") throw new Error("The action returned no data (Vaillant eBUS too old?)"); + return response as T; +} + +/** Call `read_time_program` for one program. */ +export async function readProgramService(hass: Hass, cfg: ResolvedConfig, spec: ProgramSpec): Promise { + if (!hass.callService) throw new Error("This Home Assistant connection cannot call actions"); + const result = await hass.callService(DOMAIN, READ_SERVICE, baseData(cfg, spec), undefined, false, true); + return readFromResponse(responseOf(result), spec); +} + +/** + * Call `write_time_program` for one day. Resolves with the slots the controller stored + * (the integration reads the day back); rejects when ebusd refused a write or the + * controller did not store the slots. + */ +export async function writeDayService(hass: Hass, cfg: ResolvedConfig, spec: ProgramSpec, day: Day, slots: readonly Slot[]): Promise { + if (!hass.callService) throw new Error("This Home Assistant connection cannot call actions"); + const data = { ...baseData(cfg, spec), days: [day], slots: toService(slots, spec) }; + const result = await hass.callService(DOMAIN, WRITE_SERVICE, data, undefined, false, true); + const stored = responseOf(result).days?.[day]; + if (!stored) throw new Error(`The action did not report ${day}`); + return fromService(stored, spec); +} + +/** + * Program reads shared by all cards on the page: a preview and an edit card of the same + * program read it once, and a write updates both. + */ +export class ServiceStore { + /** `at`: time of the last finished read, null before the first one. */ + private entries = new Map | null }>(); + private listeners = new Set<() => void>(); + /** Bumped on every change, for cheap change detection in the cards. */ + version = 0; + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + get(cfg: ResolvedConfig, spec: ProgramSpec): ProgramRead { + return this.entries.get(storeKey(cfg, spec))?.read ?? loadingRead(); + } + + /** + * Read a program unless a read is running or the last one is younger than `maxAgeMs` + * (after a failed read: younger than RETRY_MS at most). + */ + load(hass: Hass, cfg: ResolvedConfig, spec: ProgramSpec, maxAgeMs: number, now = Date.now()): Promise { + const key = storeKey(cfg, spec); + const entry = this.entries.get(key); + if (entry?.pending) return entry.pending; + if (entry && entry.at !== null) { + const failed = entry.read.status === "offline" || entry.read.status === "missing"; + if (now - entry.at < (failed ? Math.min(maxAgeMs, RETRY_MS) : maxAgeMs)) return Promise.resolve(); + } + const pending = readProgramService(hass, cfg, spec) + .then((read) => this.set(key, read, now)) + .catch((error: unknown) => { + console.warn(`ebusd-time-program-card: reading ${spec.messagePrefix} failed:`, errorText(error)); + this.set(key, errorRead(error, spec, this.entries.get(key)?.read), now); + }); + this.entries.set(key, { read: entry?.read ?? loadingRead(), at: entry?.at ?? null, pending }); + return pending; + } + + /** Take over a day the controller confirmed after a write. */ + setDay(cfg: ResolvedConfig, spec: ProgramSpec, day: Day, slots: Slot[]): void { + const key = storeKey(cfg, spec); + const entry = this.entries.get(key); + const read = entry?.read ?? loadingRead(); + const days = { ...read.days, [day]: slots }; + const status = DAYS.every((d) => days[d] !== null) ? "ok" : read.status; + this.entries.set(key, { read: { ...read, days, status }, at: entry?.at ?? null, pending: entry?.pending ?? null }); + this.changed(); + } + + /** Forget everything (tests). */ + clear(): void { + this.entries.clear(); + this.changed(); + } + + private set(key: string, read: ProgramRead, at: number): void { + this.entries.set(key, { read, at, pending: null }); + this.changed(); + } + + private changed(): void { + this.version++; + this.listeners.forEach((l) => l()); + } +} + +/** Retry a failed read after this long, whatever the refresh interval. */ +export const RETRY_MS = 30_000; + +const storeKey = (cfg: ResolvedConfig, spec: ProgramSpec) => `${cfg.entryId ?? ""}|${spec.messagePrefix}`; + +/** The page-wide store. */ +export const serviceStore = new ServiceStore(); diff --git a/src/components/busy-panel.ts b/src/components/busy-panel.ts index 6d22d83..4ad8c7b 100644 --- a/src/components/busy-panel.ts +++ b/src/components/busy-panel.ts @@ -35,6 +35,8 @@ export function reasonText(reason: FailReason | undefined, timeoutS: number): st return UI.reason.timeout(timeoutS); case "publish": return UI.reason.publish(reason.message); + case "service": + return UI.reason.service(reason.message); } } diff --git a/src/config.ts b/src/config.ts index cfb79d9..12215d2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,9 @@ export const THEMES: readonly CardTheme[] = ["brand", "default"]; export type CardLayout = "auto" | "wide" | "narrow"; export const LAYOUTS: readonly CardLayout[] = ["auto", "wide", "narrow"]; export type CardMode = "edit" | "preview"; +/** Where the time programs come from: ebusd's MQTT sensors, or the Vaillant eBUS integration's actions. */ +export type CardSource = "mqtt" | "vaillant_ebus"; +export const SOURCES: readonly CardSource[] = ["mqtt", "vaillant_ebus"]; export type TempLabels = "auto" | "always" | "never"; export const TEMP_LABELS: readonly TempLabels[] = ["auto", "always", "never"]; export const MODES: readonly CardMode[] = ["edit", "preview"]; @@ -28,6 +31,15 @@ export interface CardConfig { title?: string; /** UI language, e.g. `de`, `en-GB`; default: the HA user's language. */ language?: string; + /** + * `mqtt` (default): ebusd's MQTT sensors and `mqtt.publish`. + * `vaillant_ebus`: the Vaillant eBUS integration's `read_time_program` / `write_time_program` actions. + */ + source?: CardSource; + /** vaillant_ebus: config entry of the integration, only needed when several are set up. */ + entry_id?: string; + /** vaillant_ebus: seconds between reads of the programs (default 300, 0 = only on load and after writes). */ + refresh_interval?: number; mqtt_prefix?: string; circuit?: string; topic_template?: string; @@ -78,6 +90,10 @@ export interface ResolvedConfig { title: string | null; /** null = follow HA's language. */ language: string | null; + source: CardSource; + entryId: string | null; + /** Seconds between reads (vaillant_ebus source); 0 = no periodic reads. */ + refreshInterval: number; prefix: string; circuit: string; topicTemplate: string; @@ -134,6 +150,14 @@ export function resolveConfig(config: CardConfig): ResolvedConfig { if (!MODES.includes(mode)) { throw new ConfigError(`Unknown mode "${String(mode)}" (supported: ${MODES.join(", ")})`); } + const source = config.source ?? "mqtt"; + if (!SOURCES.includes(source)) { + throw new ConfigError(`Unknown source "${String(source)}" (supported: ${SOURCES.join(", ")})`); + } + const refreshInterval = config.refresh_interval ?? 300; + if (typeof refreshInterval !== "number" || !Number.isFinite(refreshInterval) || refreshInterval < 0) { + throw new ConfigError("`refresh_interval` must be a number of seconds (0 or more)"); + } const tempLabels = config.temp_labels ?? "auto"; if (!TEMP_LABELS.includes(tempLabels)) { throw new ConfigError(`Unknown temp_labels "${String(tempLabels)}" (supported: ${TEMP_LABELS.join(", ")})`); @@ -158,6 +182,9 @@ export function resolveConfig(config: CardConfig): ResolvedConfig { return { title: config.title ?? null, language: config.language?.trim() || null, + source, + entryId: config.entry_id?.trim() || null, + refreshInterval, prefix: config.mqtt_prefix ?? DEFAULTS.prefix, circuit: config.circuit ?? DEFAULTS.circuit, topicTemplate: config.topic_template ?? DEFAULTS.topicTemplate, diff --git a/src/ebusd-time-program-card.ts b/src/ebusd-time-program-card.ts index f933719..bca4dec 100644 --- a/src/ebusd-time-program-card.ts +++ b/src/ebusd-time-program-card.ts @@ -1,5 +1,6 @@ import { html, LitElement, nothing, type PropertyValues, type TemplateResult } from "lit"; import { programFingerprint, readProgram, type ProgramRead, type ReadStatus } from "./adapters/mqtt.js"; +import { serviceStore, writeDayService } from "./adapters/vaillant-ebus.js"; import { renderApplyBar } from "./components/apply-bar.js"; import { renderBusyPanel } from "./components/busy-panel.js"; import { renderDayPicker, selectionLabel } from "./components/day-picker.js"; @@ -120,6 +121,8 @@ export class EbusdTimeProgramCard extends LitElement { private _waiters: (() => void)[] = []; private _successTimer: ReturnType | undefined; private _clock: ReturnType | undefined; + /** Source `vaillant_ebus`: unsubscribe from the shared program reads. */ + private _unsubscribe: (() => void) | undefined; /** Rendered as a thumbnail in HA's "Add card" dialog: show a picture instead of live data. */ private _inPicker = false; @@ -154,6 +157,7 @@ export class EbusdTimeProgramCard extends LitElement { this._sheet = null; this._job = null; this._fingerprint = ""; + this._loadService(); this._refresh(); this.dataset.theme = this._cfg.theme; this.requestUpdate(); // _cfg is not reactive; HA calls setConfig again on YAML edits @@ -161,6 +165,7 @@ export class EbusdTimeProgramCard extends LitElement { set hass(hass: Hass) { this._hass = hass; + this._loadService(); this._refresh(); const waiters = this._waiters; this._waiters = []; @@ -172,7 +177,12 @@ export class EbusdTimeProgramCard extends LitElement { this._inPicker = insideCardPicker(this); window.addEventListener("beforeunload", this._onBeforeUnload); this._now = new Date(); - this._clock = setInterval(() => (this._now = new Date()), 60_000); + this._clock = setInterval(() => { + this._now = new Date(); + this._loadService(); + }, 60_000); + this._unsubscribe = serviceStore.subscribe(() => this._refresh()); + this._loadService(); } override disconnectedCallback(): void { @@ -180,6 +190,8 @@ export class EbusdTimeProgramCard extends LitElement { window.removeEventListener("beforeunload", this._onBeforeUnload); clearTimeout(this._successTimer); clearInterval(this._clock); + this._unsubscribe?.(); + this._unsubscribe = undefined; } private get _busy(): boolean { @@ -217,19 +229,38 @@ export class EbusdTimeProgramCard extends LitElement { // ---------- data ---------- - /** Re-read only when one of the program sensors changed. */ + /** + * Source `vaillant_ebus`: read the programs through the integration's action when + * the last read is older than the refresh interval (shared by all cards on the page). + */ + private _loadService(): void { + const cfg = this._cfg; + const hass = this._hass; + if (!cfg || !hass || cfg.source !== "vaillant_ebus" || !this.isConnected || this._inPicker) return; + const maxAge = cfg.refreshInterval > 0 ? cfg.refreshInterval * 1000 : Infinity; + for (const p of cfg.programs) void serviceStore.load(hass, cfg, p.spec, maxAge); + } + + /** Re-read only when one of the program sensors (or the shared service reads) changed. */ private _refresh(): void { const cfg = this._cfg; const hass = this._hass; if (!cfg || !hass) return; + const service = cfg.source === "vaillant_ebus"; const entities = cfg.programs.map((p) => setbackEntityId(cfg, p, hass.states)); const setback = entities.map((id) => (id ? hass.states[id]?.state : undefined)); - const fp = - cfg.programs.map((p) => programFingerprint(hass, cfg, p.spec)).join("#") + `#${entities.join("|")}#${setback.join("|")}`; + const programs = service + ? `service:${serviceStore.version}` + : cfg.programs.map((p) => programFingerprint(hass, cfg, p.spec)).join("#"); + const fp = programs + `#${entities.join("|")}#${setback.join("|")}`; if (fp === this._fingerprint) return; this._fingerprint = fp; - this._reads = cfg.programs.map((p) => readProgram(hass, cfg, p.spec)); - this._setbackTargets = cfg.programs.map((p, i) => ({ entity: entities[i]!, write: setbackWrite(cfg, p, entities[i]!) })); + this._reads = cfg.programs.map((p) => (service ? serviceStore.get(cfg, p.spec) : readProgram(hass, cfg, p.spec))); + this._setbackTargets = cfg.programs.map((p, i) => { + const write = setbackWrite(cfg, p, entities[i]!); + // without MQTT, only a number entity (e.g. the integration's own) can be written + return { entity: entities[i]!, write: service && write?.kind === "mqtt" ? null : write }; + }); this._setback = setback.map((s) => { const value = s === undefined ? NaN : Number.parseFloat(s); return Number.isFinite(value) ? value : null; @@ -383,6 +414,7 @@ export class EbusdTimeProgramCard extends LitElement { const program = this._active; const spec = cfg.programs[program]!.spec; const countMessage = this._reads[program]?.countMessage ?? cfg.countMessage ?? COUNT_MESSAGE_CANDIDATES[0]; + const service = cfg.source === "vaillant_ebus"; this._sheet = null; this._notice = null; const runner = new WriteJob( @@ -392,7 +424,15 @@ export class EbusdTimeProgramCard extends LitElement { publish: async (topic, payload) => { await this._hass!.callService!("mqtt", "publish", { topic, payload }); }, - readDay: (day) => (this._hass ? readProgram(this._hass, cfg, spec).days[day] : null), + writeDay: service + ? async (day, slots) => { + const stored = await writeDayService(this._hass!, cfg, spec, day, slots); + serviceStore.setDay(cfg, spec, day, stored); + return stored; + } + : undefined, + readDay: (day) => + service ? serviceStore.get(cfg, spec).days[day] : this._hass ? readProgram(this._hass, cfg, spec).days[day] : null, waitForUpdate: (ms) => new Promise((resolve) => { const timer = setTimeout(resolve, ms); @@ -531,7 +571,7 @@ export class EbusdTimeProgramCard extends LitElement {
    ${read.missing.map((id) => html`
  • ${id}
  • `)}
-

${UI.missingHelp}

+

${this._cfg?.source === "vaillant_ebus" ? UI.missingHelpService : UI.missingHelp}

${extraZone ? html`

${UI.missingZone(programName(program), program.spec.id)}

` : nothing} `; } @@ -560,7 +600,12 @@ export class EbusdTimeProgramCard extends LitElement { // no slot count while it is not known summary: `${selected.length ? selectionLabel(selected) : "–"}${loading ? "" : ` · ${UI.slot(slots.length)}`}`, slots: loading ? null : slots, - unavailable: this._reads[this._active]?.status === "offline" ? UI.offlineHelp : undefined, + unavailable: + this._reads[this._active]?.status === "offline" + ? cfg.source === "vaillant_ebus" + ? UI.offlineHelpService + : UI.offlineHelp + : undefined, issues: ctx.issues, changed: slots.map((s) => !ctx.base.slots.some((b) => sameSlot(s, b))), addBlocked: addBlocked(slots, spec), diff --git a/src/hass.ts b/src/hass.ts index baadb39..b88f1ed 100644 --- a/src/hass.ts +++ b/src/hass.ts @@ -12,5 +12,16 @@ export interface Hass { language?: string; /** Newer HA: the user's language setting. */ locale?: { language?: string }; - callService?: (domain: string, service: string, data?: Record) => Promise; + /** + * HA's `callService(domain, service, data, target, notifyOnError, returnResponse)`. + * With `returnResponse`, the service's data is in `response` (actions with `SupportsResponse`). + */ + callService?: ( + domain: string, + service: string, + data?: Record, + target?: Record, + notifyOnError?: boolean, + returnResponse?: boolean, + ) => Promise; } diff --git a/src/i18n/da.ts b/src/i18n/da.ts index bfd1c13..7d3bc3b 100644 --- a/src/i18n/da.ts +++ b/src/i18n/da.ts @@ -69,6 +69,8 @@ export const da: Strings = { offlineHelp: "Alle sensorer i dette program er utilgængelige. Kontrollér, at ebusd-tilføjelsen kører og er forbundet til MQTT.", missingLookedFor: "Søgte efter en af:", missingHelp: "Kontrollér mqtt_prefix, circuit og entity_template i kortets konfiguration.", + offlineHelpService: "Programmet kunne ikke læses via Vaillant eBUS-integrationen. Kontrollér, at den er indlæst og forbundet til ebusd.", + missingHelpService: "Kontrollér, at Vaillant eBUS-integrationen er installeret (med handlingen read_time_program), og programmets message_prefix.", missingZone: (name: string, id: string) => `Hvis anlægget ikke har ${name}, så fjern ${id} fra programs.`, startEmpty: "Start tomt", unsaved: "ikke-gemte ændringer", @@ -126,6 +128,7 @@ export const da: Strings = { `Periode ${index + 1}: regulatoren har ${actual}, forventet ${expected}`, timeout: (s: number) => `Ingen bekræftelse fra regulatoren inden for ${s} s`, publish: (message: string) => `Kunne ikke sende til MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Gendan tidligere", retry: "Prøv igen", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index d2a7b05..f57a0a7 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -68,6 +68,8 @@ export const de: Strings = { offlineHelp: "Alle Sensoren dieses Programms sind nicht verfügbar. Prüfe, ob das ebusd-Add-on läuft und mit MQTT verbunden ist.", missingLookedFor: "Gesucht wurde eine davon:", missingHelp: "Prüfe mqtt_prefix, circuit und entity_template in der Kartenkonfiguration.", + offlineHelpService: "Dieses Programm konnte nicht über die Vaillant-eBUS-Integration gelesen werden. Prüfe, ob sie geladen und mit ebusd verbunden ist.", + missingHelpService: "Prüfe, ob die Vaillant-eBUS-Integration (mit der Aktion read_time_program) installiert ist, und das message_prefix des Programms.", missingZone: (name: string, id: string) => `Hat diese Anlage keine ${name}, entferne ${id} aus programs.`, startEmpty: "Leer beginnen", unsaved: "ungespeicherte Änderungen", @@ -125,6 +127,7 @@ export const de: Strings = { `Zeitfenster ${index + 1}: Regler hat ${actual}, erwartet ${expected}`, timeout: (s: number) => `Keine Bestätigung vom Regler innerhalb von ${s} s`, publish: (message: string) => `Senden an MQTT fehlgeschlagen: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Vorheriges wiederherstellen", retry: "Wiederholen", diff --git a/src/i18n/el.ts b/src/i18n/el.ts index 0eb20dc..a43fdb5 100644 --- a/src/i18n/el.ts +++ b/src/i18n/el.ts @@ -69,6 +69,8 @@ export const el: Strings = { offlineHelp: "Όλοι οι αισθητήρες αυτού του προγράμματος δεν είναι διαθέσιμοι. Ελέγξτε ότι το πρόσθετο ebusd λειτουργεί και είναι συνδεδεμένο στο MQTT.", missingLookedFor: "Αναζητήθηκε μία από:", missingHelp: "Ελέγξτε τα mqtt_prefix, circuit και entity_template στις ρυθμίσεις της κάρτας.", + offlineHelpService: "Δεν ήταν δυνατή η ανάγνωση αυτού του προγράμματος μέσω της ενσωμάτωσης Vaillant eBUS. Ελέγξτε ότι είναι φορτωμένη και συνδεδεμένη στο ebusd.", + missingHelpService: "Ελέγξτε ότι η ενσωμάτωση Vaillant eBUS είναι εγκατεστημένη (με την ενέργεια read_time_program) και το message_prefix του προγράμματος.", missingZone: (name: string, id: string) => `Αν το σύστημα δεν έχει «${name}», αφαιρέστε το ${id} από το programs.`, startEmpty: "Έναρξη κενό", unsaved: "μη αποθηκευμένες αλλαγές", @@ -126,6 +128,7 @@ export const el: Strings = { `Διάστημα ${index + 1}: ο ελεγκτής έχει ${actual}, αναμενόταν ${expected}`, timeout: (s: number) => `Καμία επιβεβαίωση από τον ελεγκτή εντός ${s} s`, publish: (message: string) => `Αποτυχία δημοσίευσης στο MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Επαναφορά προηγούμενου", retry: "Επανάληψη", diff --git a/src/i18n/en-GB.ts b/src/i18n/en-GB.ts index 45b4435..d99492c 100644 --- a/src/i18n/en-GB.ts +++ b/src/i18n/en-GB.ts @@ -7,6 +7,10 @@ export const enGB: Strings = { differentPrograms: "Selected days have different programmes.", info: "About this time programme", offlineHelp: "All sensors of this programme are unavailable. Check that the ebusd add-on is running and connected to MQTT.", + offlineHelpService: + "Could not read this programme through the Vaillant eBUS integration. Check that it is loaded and connected to ebusd.", + missingHelpService: + "Check that the Vaillant eBUS integration is installed (with the read_time_program action) and the programme's message_prefix.", restored: "Previous programme restored", leaveWarning: "The time programme is still being written. Leaving now may leave days unchanged.", }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 793190c..cb635c2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -74,6 +74,8 @@ export const en = { offlineHelp: "All sensors of this program are unavailable. Check that the ebusd add-on is running and connected to MQTT.", missingLookedFor: "Looked for one of:", missingHelp: "Check mqtt_prefix, circuit and entity_template in the card configuration.", + offlineHelpService: "Could not read this program through the Vaillant eBUS integration. Check that it is loaded and connected to ebusd.", + missingHelpService: "Check that the Vaillant eBUS integration is installed (with the read_time_program action) and the program's message_prefix.", missingZone: (name: string, id: string) => `If this system has no ${name}, remove ${id} from programs.`, startEmpty: "Start empty", unsaved: "unsaved changes", @@ -135,6 +137,7 @@ export const en = { `Slot ${index + 1}: controller has ${actual}, expected ${expected}`, timeout: (s: number) => `No confirmation from the controller within ${s} s`, publish: (message: string) => `Could not publish to MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Restore previous", retry: "Retry", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 1701bca..3ca46d0 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -69,6 +69,8 @@ export const es: Strings = { offlineHelp: "Todos los sensores de este programa no están disponibles. Comprueba que el complemento ebusd está en marcha y conectado a MQTT.", missingLookedFor: "Se buscó una de:", missingHelp: "Revisa mqtt_prefix, circuit y entity_template en la configuración de la tarjeta.", + offlineHelpService: "No se pudo leer este programa a través de la integración Vaillant eBUS. Comprueba que esté cargada y conectada a ebusd.", + missingHelpService: "Comprueba que la integración Vaillant eBUS esté instalada (con la acción read_time_program) y el message_prefix del programa.", missingZone: (name: string, id: string) => `Si esta instalación no tiene ${name}, quita ${id} de programs.`, startEmpty: "Empezar vacío", unsaved: "cambios sin guardar", @@ -126,6 +128,7 @@ export const es: Strings = { `Franja ${index + 1}: el regulador tiene ${actual}, se esperaba ${expected}`, timeout: (s: number) => `Sin confirmación del regulador en ${s} s`, publish: (message: string) => `No se pudo publicar en MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Restaurar anterior", retry: "Reintentar", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 9ee3bb5..f520890 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -69,6 +69,8 @@ export const fr: Strings = { offlineHelp: "Tous les capteurs de ce programme sont indisponibles. Vérifiez que le module ebusd fonctionne et est connecté à MQTT.", missingLookedFor: "Recherché l’une de :", missingHelp: "Vérifiez mqtt_prefix, circuit et entity_template dans la configuration de la carte.", + offlineHelpService: "Impossible de lire ce programme via l'intégration Vaillant eBUS. Vérifiez qu'elle est chargée et connectée à ebusd.", + missingHelpService: "Vérifiez que l'intégration Vaillant eBUS est installée (avec l'action read_time_program) et le message_prefix du programme.", missingZone: (name: string, id: string) => `Si cette installation n’a pas de ${name}, retirez ${id} de programs.`, startEmpty: "Commencer vide", unsaved: "modifications non enregistrées", @@ -126,6 +128,7 @@ export const fr: Strings = { `Plage ${index + 1} : le régulateur a ${actual}, attendu ${expected}`, timeout: (s: number) => `Aucune confirmation du régulateur en ${s} s`, publish: (message: string) => `Échec de la publication MQTT : ${message}`, + service: (message: string) => `Vaillant eBUS : ${message}`, }, restorePrevious: "Restaurer l’ancien", retry: "Réessayer", diff --git a/src/i18n/hu.ts b/src/i18n/hu.ts index 2cd6721..12bcf5e 100644 --- a/src/i18n/hu.ts +++ b/src/i18n/hu.ts @@ -70,6 +70,8 @@ export const hu: Strings = { offlineHelp: "A program összes érzékelője nem érhető el. Ellenőrizze, hogy az ebusd kiegészítő fut-e és csatlakozik-e az MQTT-hez.", missingLookedFor: "Keresett entitások (egyik):", missingHelp: "Ellenőrizze a mqtt_prefix, circuit és entity_template beállítást a kártya konfigurációjában.", + offlineHelpService: "Ezt a programot nem sikerült beolvasni a Vaillant eBUS integráción keresztül. Ellenőrizze, hogy be van-e töltve és csatlakozik-e az ebusd-hez.", + missingHelpService: "Ellenőrizze, hogy a Vaillant eBUS integráció telepítve van-e (a read_time_program művelettel), valamint a program message_prefix beállítását.", missingZone: (name: string, id: string) => `Ha a rendszerben nincs ${name}, távolítsa el a(z) ${id} elemet a programs listából.`, startEmpty: "Üresen kezdés", unsaved: "nem mentett módosítások", @@ -127,6 +129,7 @@ export const hu: Strings = { `${index + 1}. idősáv: a szabályozón ${actual}, várt: ${expected}`, timeout: (s: number) => `Nincs megerősítés a szabályozótól ${s} másodpercen belül`, publish: (message: string) => `MQTT-küldés sikertelen: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Előző visszaállítása", retry: "Újra", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 178274f..7db2c07 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -69,6 +69,8 @@ export const it: Strings = { offlineHelp: "Tutti i sensori di questo programma non sono disponibili. Verifica che l’add-on ebusd sia attivo e connesso a MQTT.", missingLookedFor: "Cercata una di:", missingHelp: "Controlla mqtt_prefix, circuit ed entity_template nella configurazione della scheda.", + offlineHelpService: "Impossibile leggere questo programma tramite l'integrazione Vaillant eBUS. Verifica che sia caricata e collegata a ebusd.", + missingHelpService: "Verifica che l'integrazione Vaillant eBUS sia installata (con l'azione read_time_program) e il message_prefix del programma.", missingZone: (name: string, id: string) => `Se questo impianto non ha la ${name}, rimuovi ${id} da programs.`, startEmpty: "Inizia vuoto", unsaved: "modifiche non salvate", @@ -126,6 +128,7 @@ export const it: Strings = { `Fascia ${index + 1}: la centralina ha ${actual}, atteso ${expected}`, timeout: (s: number) => `Nessuna conferma dalla centralina entro ${s} s`, publish: (message: string) => `Impossibile pubblicare su MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Ripristina precedente", retry: "Riprova", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index dd0f7b7..9d675f7 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -69,6 +69,8 @@ export const nl: Strings = { offlineHelp: "Alle sensoren van dit programma zijn niet beschikbaar. Controleer of de ebusd-add-on draait en met MQTT verbonden is.", missingLookedFor: "Gezocht naar een van:", missingHelp: "Controleer mqtt_prefix, circuit en entity_template in de kaartconfiguratie.", + offlineHelpService: "Dit programma kon niet via de Vaillant eBUS-integratie worden gelezen. Controleer of die geladen en met ebusd verbonden is.", + missingHelpService: "Controleer of de Vaillant eBUS-integratie geïnstalleerd is (met de actie read_time_program) en de message_prefix van het programma.", missingZone: (name: string, id: string) => `Heeft deze installatie geen ${name}, verwijder dan ${id} uit programs.`, startEmpty: "Leeg beginnen", unsaved: "niet-opgeslagen wijzigingen", @@ -126,6 +128,7 @@ export const nl: Strings = { `Tijdvak ${index + 1}: regelaar heeft ${actual}, verwacht ${expected}`, timeout: (s: number) => `Geen bevestiging van de regelaar binnen ${s} s`, publish: (message: string) => `Publiceren naar MQTT mislukt: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Vorige herstellen", retry: "Opnieuw", diff --git a/src/i18n/pl.ts b/src/i18n/pl.ts index 8527f31..7f71852 100644 --- a/src/i18n/pl.ts +++ b/src/i18n/pl.ts @@ -77,6 +77,8 @@ export const pl: Strings = { offlineHelp: "Wszystkie czujniki tego programu są niedostępne. Sprawdź, czy dodatek ebusd działa i jest połączony z MQTT.", missingLookedFor: "Szukano jednej z:", missingHelp: "Sprawdź mqtt_prefix, circuit i entity_template w konfiguracji karty.", + offlineHelpService: "Nie udało się odczytać tego programu przez integrację Vaillant eBUS. Sprawdź, czy jest załadowana i połączona z ebusd.", + missingHelpService: "Sprawdź, czy integracja Vaillant eBUS jest zainstalowana (z akcją read_time_program) oraz message_prefix programu.", missingZone: (name: string, id: string) => `Jeśli ta instalacja nie ma strefy „${name}”, usuń ${id} z programs.`, startEmpty: "Zacznij od pustego", unsaved: "niezapisane zmiany", @@ -134,6 +136,7 @@ export const pl: Strings = { `Przedział ${index + 1}: regulator ma ${actual}, oczekiwano ${expected}`, timeout: (s: number) => `Brak potwierdzenia z regulatora w ciągu ${s} s`, publish: (message: string) => `Nie udało się wysłać do MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Przywróć poprzedni", retry: "Ponów", diff --git a/src/i18n/ro.ts b/src/i18n/ro.ts index a8b06c8..1106b91 100644 --- a/src/i18n/ro.ts +++ b/src/i18n/ro.ts @@ -70,6 +70,8 @@ export const ro: Strings = { offlineHelp: "Toți senzorii acestui program sunt indisponibili. Verificați dacă add-on-ul ebusd rulează și este conectat la MQTT.", missingLookedFor: "S-a căutat una dintre:", missingHelp: "Verificați mqtt_prefix, circuit și entity_template în configurația cardului.", + offlineHelpService: "Acest program nu a putut fi citit prin integrarea Vaillant eBUS. Verificați dacă este încărcată și conectată la ebusd.", + missingHelpService: "Verificați dacă integrarea Vaillant eBUS este instalată (cu acțiunea read_time_program) și message_prefix al programului.", missingZone: (name: string, id: string) => `Dacă acest sistem nu are ${name}, eliminați ${id} din programs.`, startEmpty: "Începe gol", unsaved: "modificări nesalvate", @@ -127,6 +129,7 @@ export const ro: Strings = { `Intervalul ${index + 1}: regulatorul are ${actual}, așteptat ${expected}`, timeout: (s: number) => `Nicio confirmare de la regulator în ${s} s`, publish: (message: string) => `Publicarea în MQTT a eșuat: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Restaurează anteriorul", retry: "Reîncearcă", diff --git a/src/i18n/sv.ts b/src/i18n/sv.ts index 544b143..e6da18a 100644 --- a/src/i18n/sv.ts +++ b/src/i18n/sv.ts @@ -69,6 +69,8 @@ export const sv: Strings = { offlineHelp: "Alla sensorer i detta program är otillgängliga. Kontrollera att ebusd-tillägget körs och är anslutet till MQTT.", missingLookedFor: "Letade efter någon av:", missingHelp: "Kontrollera mqtt_prefix, circuit och entity_template i kortets konfiguration.", + offlineHelpService: "Programmet kunde inte läsas via Vaillant eBUS-integrationen. Kontrollera att den är laddad och ansluten till ebusd.", + missingHelpService: "Kontrollera att Vaillant eBUS-integrationen är installerad (med åtgärden read_time_program) och programmets message_prefix.", missingZone: (name: string, id: string) => `Om anläggningen saknar ${name}, ta bort ${id} från programs.`, startEmpty: "Börja tomt", unsaved: "osparade ändringar", @@ -126,6 +128,7 @@ export const sv: Strings = { `Period ${index + 1}: regulatorn har ${actual}, förväntat ${expected}`, timeout: (s: number) => `Ingen bekräftelse från regulatorn inom ${s} s`, publish: (message: string) => `Kunde inte publicera till MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Återställ föregående", retry: "Försök igen", diff --git a/src/i18n/uk.ts b/src/i18n/uk.ts index 8b3967e..acf0562 100644 --- a/src/i18n/uk.ts +++ b/src/i18n/uk.ts @@ -77,6 +77,8 @@ export const uk: Strings = { offlineHelp: "Усі датчики цієї програми недоступні. Перевірте, чи працює доповнення ebusd і чи підключене воно до MQTT.", missingLookedFor: "Шукали одну з:", missingHelp: "Перевірте mqtt_prefix, circuit і entity_template у налаштуваннях картки.", + offlineHelpService: "Не вдалося прочитати цю програму через інтеграцію Vaillant eBUS. Перевірте, що її завантажено й підключено до ebusd.", + missingHelpService: "Перевірте, що інтеграцію Vaillant eBUS встановлено (з дією read_time_program), і message_prefix програми.", missingZone: (name: string, id: string) => `Якщо в цій системі немає «${name}», видаліть ${id} з programs.`, startEmpty: "Почати з порожнього", unsaved: "незбережені зміни", @@ -134,6 +136,7 @@ export const uk: Strings = { `Інтервал ${index + 1}: у регуляторі ${actual}, очікувалось ${expected}`, timeout: (s: number) => `Немає підтвердження від регулятора протягом ${s} с`, publish: (message: string) => `Не вдалося надіслати в MQTT: ${message}`, + service: (message: string) => `Vaillant eBUS: ${message}`, }, restorePrevious: "Відновити попередню", retry: "Повторити", diff --git a/src/write-job.ts b/src/write-job.ts index e4ecee2..52439ff 100644 --- a/src/write-job.ts +++ b/src/write-job.ts @@ -9,6 +9,11 @@ import { normalizeSlots, sameSlots } from "./model/week.js"; * day in order, then a read-back that must show the written values before the * next day starts. The controller ACKs partial or rejected writes without * applying them, so the read-back is the only real confirmation. + * + * Two ways to write a day: + * - MQTT (`publish`): the card sends each slot and waits until HA's sensors show the day, + * - an action (`writeDay`, source `vaillant_ebus`): the integration writes the day, + * reads it back and returns what the controller stored. */ export type DayState = "queued" | "writing" | "verifying" | "done" | "failed" | "skipped"; @@ -19,7 +24,8 @@ export type FailReason = | { code: "count"; actual: number; expected: number } | { code: "slot"; index: number; actual: string; expected: string } | { code: "timeout" } - | { code: "publish"; message: string }; + | { code: "publish"; message: string } + | { code: "service"; message: string }; export interface JobItem { day: Day; @@ -41,7 +47,13 @@ export interface JobSnapshot { } export interface JobDeps { + /** MQTT: send one message. */ publish(topic: string, payload: string): Promise; + /** + * When set, used instead of `publish`: write a whole day and resolve with the slots + * the controller stored (already read back), or reject. + */ + writeDay?: ((day: Day, slots: Slot[]) => Promise) | undefined; /** Current controller values of one day as HA shows them (null = unknown). */ readDay(day: Day): Slot[] | null; /** Resolves on the next hass update, or after `ms` at the latest. */ @@ -104,6 +116,7 @@ export class WriteJob { } private async writeDay(i: number): Promise { + if (this.deps.writeDay) return this.writeDayDirect(i, this.deps.writeDay); const { cfg, spec } = this.opts; const item = this.state.items[i]!; this.setItem(i, { state: "writing" }); @@ -141,6 +154,25 @@ export class WriteJob { } } + /** Write one day through an action that also reads it back. */ + private async writeDayDirect(i: number, write: NonNullable): Promise { + const item = this.state.items[i]!; + this.setItem(i, { state: "writing" }); + let stored: Slot[]; + try { + stored = await write(item.day, item.slots); + } catch (e) { + this.setItem(i, { state: "failed", reason: { code: "service", message: errorMessage(e) } }); + return false; + } + if (sameSlots(stored, item.slots, this.opts.spec)) { + this.setItem(i, { state: "done" }); + return true; + } + this.setItem(i, { state: "failed", reason: mismatch(stored, item.slots) }); + return false; + } + /** `…/get ?1` for the count message and every slot that must be read. */ private async requestRead(item: JobItem): Promise { const { cfg, spec, countMessage } = this.opts; @@ -167,6 +199,13 @@ export class WriteJob { } } +/** Text of an error; HA rejects service calls with `{ code, message }` objects. */ +export function errorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + if (e && typeof e === "object" && "message" in e) return String((e as { message: unknown }).message); + return String(e); +} + const range = (s: Slot) => `${s.start}–${s.end}${s.temp !== undefined ? ` ${formatTemp(s.temp)} °C` : ""}`; /** Describe why the read-back does not match (first difference). */ diff --git a/test/config.test.ts b/test/config.test.ts index f6b4c48..19ed1b7 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -114,10 +114,17 @@ describe("config", () => { // tab: the configured name, else the short form expect(c.programs.map((p) => programTabLabel(p))).toEqual(["CC", "Living"]); }); + it("source: MQTT by default; vaillant_ebus with entry and refresh interval", () => { + expect([cfg.source, cfg.entryId, cfg.refreshInterval]).toEqual(["mqtt", null, 300]); + const c = resolveConfig({ type: "x", source: "vaillant_ebus", entry_id: " abc ", refresh_interval: 0 }); + expect([c.source, c.entryId, c.refreshInterval]).toEqual(["vaillant_ebus", "abc", 0]); + }); it.each([ [{ programs: [] }, "non-empty"], [{ programs: ["z4"] }, "Unknown program"], [{ programs: ["cc", "cc"] }, "twice"], + [{ source: "rest" }, "Unknown source"], + [{ refresh_interval: -1 }, "refresh_interval"], ])("rejects %j", (extra, msg) => { expect(() => resolveConfig({ type: "x", ...(extra as object) })).toThrow(ConfigError); expect(() => resolveConfig({ type: "x", ...(extra as object) })).toThrow(msg); diff --git a/test/fake-ebusd.ts b/test/fake-ebusd.ts index 576e51a..54dc90b 100644 --- a/test/fake-ebusd.ts +++ b/test/fake-ebusd.ts @@ -18,6 +18,8 @@ export interface FakeOptions { ignoreWrites?: Set; /** Make `mqtt.publish` reject (broker down). */ failPublish?: boolean; + /** Make the `vaillant_ebus` actions reject like HA does when the integration is not loaded. */ + noIntegration?: boolean; } interface Pending { @@ -52,6 +54,7 @@ export class FakeEbusd { states: this.states, language: "en", callService: (domain, service, data) => { + if (domain === "vaillant_ebus") return this.vaillantService(service, data ?? {}); if (domain !== "mqtt" || service !== "publish") return Promise.reject(new Error("unsupported service")); if (this.opts.failPublish) return Promise.reject(new Error("MQTT broker not available")); return this.publish(String(data?.topic), String(data?.payload)); @@ -59,6 +62,50 @@ export class FakeEbusd { }; } + /** Every `vaillant_ebus` action call, in order. */ + readonly serviceLog: { service: string; data: Record }[] = []; + + /** + * The Vaillant eBUS integration's `read_time_program` / `write_time_program`, answered + * from the controller memory like the integration does (HA's `{ context, response }`, + * errors as `{ code, message }`). A write reads the day back; an ignored write fails. + */ + private async vaillantService(service: string, data: Record): Promise { + this.serviceLog.push({ service, data }); + const fail = (code: string, message: string) => Promise.reject({ code, message }); + if (this.opts.noIntegration) return fail("service_not_found", `Action vaillant_ebus.${service} not found.`); + const spec = Object.values(PROGRAMS).find((p) => p.messagePrefix === data.program); + if (!spec || detectCountMessage(this.hass(), this.cfg, spec) === null) { + return fail("home_assistant_error", `${String(data.program)} is not available on this controller`); + } + const out = (slots: Slot[]) => + slots.map((s) => ({ start: s.start, end: s.end, temperature: spec.temp ? (s.temp ?? null) : null })); + if (service === "read_time_program") { + await sleep(this.opts.delay ?? 0); + const days = Object.fromEntries(DAYS.map((d) => [d, out(this.controllerDay(spec, d))])); + return { context: {}, response: { program: spec.messagePrefix, circuit: "ctlv2", temperature: !!spec.temp, days, incomplete: [] } }; + } + if (service !== "write_time_program") return fail("service_not_found", `Action vaillant_ebus.${service} not found.`); + const written: Record = {}; + for (const day of (data.days as Day[]) ?? []) { + const slots = ((data.slots as { start: string; end: string; temperature?: number }[]) ?? []).map((s): Slot => { + const slot: Slot = { start: s.start, end: s.end }; + if (spec.temp && s.temperature !== undefined) slot.temp = s.temperature; + return slot; + }); + await sleep((this.opts.delay ?? 0) * (Math.max(slots.length, 1) + 4)); // slot writes + read-back + if (!this.opts.ignoreWrites?.has(writeMessage(spec, day))) this.controller.set(key(spec, day), slots); + const stored = this.controllerDay(spec, day); + if (JSON.stringify(stored) !== JSON.stringify(slots)) { + const done = Object.keys(written).join(", ") || "none"; + const name = day[0]!.toUpperCase() + day.slice(1); + return fail("home_assistant_error", `The controller did not store the new time slots for ${name} (days already written: ${done})`); + } + written[day] = out(stored); + } + return { context: {}, response: { program: spec.messagePrefix, circuit: "ctlv2", days: written } }; + } + /** Controller memory (what the VRC 720 has), independent of what HA shows. */ controllerDay(spec: ProgramSpec, day: Day): Slot[] { return this.controller.get(key(spec, day)) ?? []; diff --git a/test/vaillant-ebus-adapter.test.ts b/test/vaillant-ebus-adapter.test.ts new file mode 100644 index 0000000..c9a39e9 --- /dev/null +++ b/test/vaillant-ebus-adapter.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + errorRead, + fromService, + readFromResponse, + readProgramService, + RETRY_MS, + ServiceStore, + toService, + writeDayService, + type ReadResponse, +} from "../src/adapters/vaillant-ebus.js"; +import { resolveConfig } from "../src/config.js"; +import type { Hass } from "../src/hass.js"; +import { PROGRAMS } from "../src/model/programs.js"; +import { DAYS } from "../src/model/types.js"; +import { WriteJob, type JobDeps } from "../src/write-job.js"; +import { FakeEbusd } from "./fake-ebusd.js"; +import { sampleStates } from "./fixtures/hass-states.js"; + +const cfg = resolveConfig({ type: "x", source: "vaillant_ebus" }); +const z1 = PROGRAMS.z1; +const cc = PROGRAMS.cc; + +const week = (days: ReadResponse["days"], incomplete: ReadResponse["incomplete"] = []): ReadResponse => ({ + program: "Z1Timer", + circuit: "ctlv2", + temperature: true, + days: { ...Object.fromEntries(DAYS.map((d) => [d, []])), ...days }, + incomplete, +}); + +describe("slot conversion", () => { + it("takes the temperature only for programs that have one, sorted by start", () => { + const raw = [ + { start: "17:00", end: "24:00", temperature: 20.5 }, + { start: "06:00", end: "08:00", temperature: 21 }, + ]; + expect(fromService(raw, z1)).toEqual([ + { start: "06:00", end: "08:00", temp: 21 }, + { start: "17:00", end: "24:00", temp: 20.5 }, + ]); + expect(fromService(raw, cc)).toEqual([ + { start: "06:00", end: "08:00" }, + { start: "17:00", end: "24:00" }, + ]); + }); + + it("sends `temperature` only for programs with one", () => { + expect(toService([{ start: "06:00", end: "08:00", temp: 21 }], z1)).toEqual([{ start: "06:00", end: "08:00", temperature: 21 }]); + expect(toService([{ start: "06:00", end: "08:00", temp: 21 }], cc)).toEqual([{ start: "06:00", end: "08:00" }]); + }); +}); + +describe("reading", () => { + it("builds a complete read from the action's response", () => { + const read = readFromResponse(week({ monday: [{ start: "06:00", end: "22:00", temperature: 21 }] }), z1); + expect(read.status).toBe("ok"); + expect(read.days.monday).toEqual([{ start: "06:00", end: "22:00", temp: 21 }]); + expect(read.days.sunday).toEqual([]); + }); + + it("shows incomplete days as unknown (never as empty, so they are not overwritten)", () => { + const read = readFromResponse(week({ tuesday: [{ start: "03:00", end: "03:10" }] }, ["tuesday"]), z1); + expect(read.status).toBe("loading"); + expect(read.days.tuesday).toBeNull(); + }); + + it("tells a missing integration or program apart from a temporary failure", () => { + const previous = readFromResponse(week({ monday: [{ start: "06:00", end: "22:00", temperature: 21 }] }), z1); + const missing = errorRead({ code: "service_not_found", message: "Action vaillant_ebus.read_time_program not found." }, z1); + expect(missing.status).toBe("missing"); + expect(missing.missing[0]).toContain("vaillant_ebus.read_time_program (Z1Timer)"); + expect(errorRead({ code: "home_assistant_error", message: "Z3Timer is not available on this controller" }, z1).status).toBe( + "missing", + ); + const offline = errorRead({ code: "home_assistant_error", message: "Could not read Z1Timer_Monday0" }, z1, previous); + expect(offline.status).toBe("offline"); + expect(offline.days.monday).toEqual(previous.days.monday); // last known values stay visible + }); + + it("calls the read action with a response and the configured entry", async () => { + const callService = vi.fn().mockResolvedValue({ context: {}, response: week({}) }); + const hass: Hass = { states: {}, callService }; + await readProgramService(hass, resolveConfig({ type: "x", source: "vaillant_ebus", entry_id: "abc" }), z1); + expect(callService).toHaveBeenCalledWith("vaillant_ebus", "read_time_program", { program: "Z1Timer", entry_id: "abc" }, undefined, false, true); + }); + + it("rejects a response without data (integration too old)", async () => { + const hass: Hass = { states: {}, callService: vi.fn().mockResolvedValue({ context: {} }) }; + await expect(readProgramService(hass, cfg, z1)).rejects.toThrow(/no data/); + }); +}); + +describe("shared store", () => { + const store = new ServiceStore(); + afterEach(() => store.clear()); + + it("reads once for several cards and again only when the read is older than the interval", async () => { + const callService = vi.fn().mockResolvedValue({ context: {}, response: week({}) }); + const hass: Hass = { states: {}, callService }; + await Promise.all([store.load(hass, cfg, z1, 300_000, 0), store.load(hass, cfg, z1, 300_000, 0)]); + await store.load(hass, cfg, z1, 300_000, 299_000); + expect(callService).toHaveBeenCalledTimes(1); + expect(store.get(cfg, z1).status).toBe("ok"); + await store.load(hass, cfg, z1, 300_000, 300_000); + expect(callService).toHaveBeenCalledTimes(2); + }); + + it("retries a failed read sooner than the interval", async () => { + const callService = vi.fn().mockRejectedValue({ code: "home_assistant_error", message: "ebusd not connected" }); + const hass: Hass = { states: {}, callService }; + vi.spyOn(console, "warn").mockImplementation(() => {}); + await store.load(hass, cfg, z1, 300_000, 0); + expect(store.get(cfg, z1).status).toBe("offline"); + await store.load(hass, cfg, z1, 300_000, RETRY_MS - 1); + expect(callService).toHaveBeenCalledTimes(1); + await store.load(hass, cfg, z1, 300_000, RETRY_MS); + expect(callService).toHaveBeenCalledTimes(2); + }); + + it("takes over a written day and tells the cards", async () => { + const hass: Hass = { states: {}, callService: vi.fn().mockResolvedValue({ context: {}, response: week({}) }) }; + await store.load(hass, cfg, z1, 300_000, 0); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + store.setDay(cfg, z1, "monday", [{ start: "06:00", end: "08:00", temp: 21 }]); + expect(store.get(cfg, z1).days.monday).toEqual([{ start: "06:00", end: "08:00", temp: 21 }]); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); +}); + +describe("writing through the action", () => { + const deps = (writeDay: JobDeps["writeDay"]): JobDeps => ({ + publish: () => Promise.reject(new Error("must not publish")), + writeDay, + readDay: () => null, + waitForUpdate: () => Promise.resolve(), + now: () => 0, + }); + const job = (writeDay: JobDeps["writeDay"]) => + new WriteJob( + { + kind: "apply", + program: 0, + items: [ + { day: "monday", slots: [{ start: "06:00", end: "08:00", temp: 21 }] }, + { day: "tuesday", slots: [{ start: "06:00", end: "08:00", temp: 21 }] }, + ], + previous: {}, + }, + { cfg, spec: z1, countMessage: "Timeframes" }, + deps(writeDay), + ); + + it("writes day after day and confirms with the stored slots", async () => { + const writeDay = vi.fn(async (_day, slots) => slots); + const result = await job(writeDay).run(); + expect(result.status).toBe("done"); + expect(writeDay.mock.calls.map((c) => c[0])).toEqual(["monday", "tuesday"]); + }); + + it("stops at a day the integration reports as failed", async () => { + const result = await job(async (day, slots) => { + if (day === "tuesday") throw { code: "home_assistant_error", message: "The controller did not store the new time slots for Tuesday" }; + return slots; + }).run(); + expect(result.status).toBe("failed"); + expect(result.items[1]).toMatchObject({ state: "failed", reason: { code: "service", message: expect.stringContaining("did not store") } }); + }); + + it("fails a day whose stored slots differ from the request", async () => { + const result = await job(async () => [{ start: "06:00", end: "08:00", temp: 20 }]).run(); + expect(result.items[0]).toMatchObject({ state: "failed", reason: { code: "slot", index: 0 } }); + expect(result.items[1]!.state).toBe("skipped"); + }); + + it("sends a day to the fake integration and gets the stored day back", async () => { + const ebusd = new FakeEbusd(cfg, sampleStates()); + const slots = [ + { start: "17:00", end: "24:00", temp: 20.5 }, + { start: "06:00", end: "08:00", temp: 21 }, + ]; + const stored = await writeDayService(ebusd.hass(), cfg, z1, "saturday", slots); + expect(stored).toEqual([slots[1], slots[0]]); + expect(ebusd.controllerDay(z1, "saturday")).toEqual(stored); + expect(ebusd.serviceLog.at(-1)).toEqual({ + service: "write_time_program", + data: { + program: "Z1Timer", + days: ["saturday"], + slots: [ + { start: "06:00", end: "08:00", temperature: 21 }, + { start: "17:00", end: "24:00", temperature: 20.5 }, + ], + }, + }); + }); +}); From 2853dd2d7d5fa1535d2903466e09a947a63588c4 Mon Sep 17 00:00:00 2001 From: Steffen Froehlich Date: Sun, 8 Nov 2026 16:25:54 +0100 Subject: [PATCH 2/4] version 0.5.0-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d392b3e..bc4e4b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ebusd-time-program-card", - "version": "0.4.0", + "version": "0.5.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ebusd-time-program-card", - "version": "0.4.0", + "version": "0.5.0-beta.1", "license": "MIT", "dependencies": { "lit": "3.3.3" diff --git a/package.json b/package.json index 8c1d93b..1c33698 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ebusd-time-program-card", - "version": "0.4.0", + "version": "0.5.0-beta.1", "description": "Home Assistant Lovelace card to edit Vaillant (ebusd ctlv2) weekly time programs for several days at once", "type": "module", "private": true, From 0771aeac25bc66514a5a6e3a054000f63e060c71 Mon Sep 17 00:00:00 2001 From: Steffen Froehlich Date: Sun, 8 Nov 2026 16:37:22 +0100 Subject: [PATCH 3/4] add prerelease attribute to release workflow --- .github/workflows/release.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bd755ed..a9fc4a6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -35,5 +35,7 @@ jobs: uses: softprops/action-gh-release@v3 with: draft: true + # a tag with a "-" (v0.5.0-beta.1) is a beta: published as pre-release, never "Latest" + prerelease: ${{ contains(github.ref_name, '-') }} generate_release_notes: true files: dist/ebusd-time-program-card.js From b7d258193d0220099e28d54055c1fc6438a21970 Mon Sep 17 00:00:00 2001 From: Steffen Froehlich Date: Mon, 9 Nov 2026 20:34:35 +0100 Subject: [PATCH 4/4] set version to v0.5.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc4e4b8..3cc9808 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ebusd-time-program-card", - "version": "0.5.0-beta.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ebusd-time-program-card", - "version": "0.5.0-beta.1", + "version": "0.5.0", "license": "MIT", "dependencies": { "lit": "3.3.3" diff --git a/package.json b/package.json index 1c33698..f225308 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ebusd-time-program-card", - "version": "0.5.0-beta.1", + "version": "0.5.0", "description": "Home Assistant Lovelace card to edit Vaillant (ebusd ctlv2) weekly time programs for several days at once", "type": "module", "private": true,