Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions dev/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -43,6 +45,10 @@ if (params.get("nolabel")) config.preview_label = false;
const layout = params.get("layout");
if (layout) config.layout = layout as NonNullable<CardConfig["layout"]>;
applyHaTheme(params.get("ha"));
const source = params.get("source");
if (source) config.source = source as NonNullable<CardConfig["source"]>;
const refresh = params.get("refresh");
if (refresh) config.refresh_interval = Number(refresh);
const timeout = params.get("timeout");
if (timeout) config.verify_timeout = Number(timeout);

Expand All @@ -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) => {
Expand Down
21 changes: 21 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ebusd-time-program-card",
"version": "0.4.0",
"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,
Expand Down
24 changes: 24 additions & 0 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
217 changes: 217 additions & 0 deletions src/adapters/vaillant-ebus.ts
Original file line number Diff line number Diff line change
@@ -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<Record<Day, ServiceSlot[]>>;
/** 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<Record<Day, ServiceSlot[]>>;
}

/** 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<Day, Slot[] | null>;
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<Day, Slot[] | null> => Object.fromEntries(DAYS.map((d) => [d, null])) as Record<Day, Slot[] | null>;

/** 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<string, unknown> {
const data: Record<string, unknown> = { program: spec.messagePrefix };
if (cfg.entryId) data.entry_id = cfg.entryId;
return data;
}

/** `response` of HA's callService result (`{ context, response }`). */
function responseOf<T>(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<ProgramRead> {
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<ReadResponse>(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<Slot[]> {
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<WriteResponse>(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<string, { read: ProgramRead; at: number | null; pending: Promise<void> | 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<void> {
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();
2 changes: 2 additions & 0 deletions src/components/busy-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Loading
Loading