Home Assistant's MQTT user flow shows no value for set_ca_cert and set_client_cert (a reconfigure pre-fills them from the entry), and refuses a submit without them — found against the pinned 2026.9.3 in a throwaway instance.
487 lines
24 KiB
TypeScript
487 lines
24 KiB
TypeScript
// How home-assistant's provisions step brings Home Assistant's integrations in line with what the
|
|
// mesh bound: the MQTT integration to `mqtt-topic`, the Sonarr, Radarr and Lidarr integrations to
|
|
// `sonarr-api`, `radarr-api` and `lidarr-api`. Pure logic over two seams — Home Assistant's config
|
|
// flows (hass.ts) and the broker/apps — so it is tested against fakes (test/provisions.test.ts).
|
|
//
|
|
// The half that reads files and talks HTTP lives beside it (mesh.ts, hass.ts, probe.ts, index.ts).
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
import type { Hass, SchemaField } from "./hass.js";
|
|
import type { Probe } from "./probe.js";
|
|
|
|
/** What the mesh wrote at `binds.<provision>` (the controller's binding document). */
|
|
export interface Binding {
|
|
provision?: string;
|
|
from?: string;
|
|
at?: string;
|
|
as?: string;
|
|
serves?: Record<string, unknown>;
|
|
}
|
|
|
|
/** How one provision came out. Never carries a credential. */
|
|
export type Outcome =
|
|
| { what: string; result: "unchanged"; note?: string }
|
|
| { what: string; result: "written"; fields: string[]; note?: string }
|
|
| { what: string; result: "equivalent"; note: string }
|
|
| { what: string; result: "refused"; problem: string };
|
|
|
|
/** A port the binding serves, or undefined when it names none usable. */
|
|
export function portOf(serves: Record<string, unknown> | undefined): number | undefined {
|
|
const port = Number(serves?.port);
|
|
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined;
|
|
}
|
|
|
|
/** A host as it goes into a URL: an IPv6 literal bracketed. */
|
|
export function urlHost(host: string): string {
|
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
}
|
|
|
|
/**
|
|
* What this step last wrote, per target, as a digest: the only way to know "already as the mesh
|
|
* says" for a credential Home Assistant will not show back. A sha256 over the target and the values,
|
|
* never the values; kept in the module's own placed directory.
|
|
*/
|
|
export interface Marks {
|
|
get(name: string): Promise<string | undefined>;
|
|
set(name: string, digest: string): Promise<void>;
|
|
}
|
|
|
|
export function digest(...parts: (string | number)[]): string {
|
|
return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex");
|
|
}
|
|
|
|
/** An error as text with the credential taken out, raw and URL-encoded. */
|
|
export function scrub(err: unknown, ...secrets: (string | undefined)[]): string {
|
|
let text = err instanceof Error ? err.message : String(err);
|
|
for (const s of secrets) {
|
|
if (!s) continue;
|
|
for (const form of new Set([s, encodeURIComponent(s)])) text = text.split(form).join("***");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* What a form would submit if a person pressed "submit" without touching it: each field's
|
|
* suggested value (what Home Assistant pre-fills from the entry), else its default; a section's
|
|
* fields nested under its name. The step lays only the connection fields over this, so every other
|
|
* choice the entry carries is sent back exactly as Home Assistant showed it.
|
|
*/
|
|
export function formValues(schema: readonly SchemaField[] | null | undefined): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const field of schema ?? []) {
|
|
if (Array.isArray(field.schema)) {
|
|
out[field.name] = formValues(field.schema);
|
|
continue;
|
|
}
|
|
const suggested = field.description?.suggested_value;
|
|
if (suggested !== undefined && suggested !== null) out[field.name] = suggested;
|
|
else if (field.default !== undefined) out[field.name] = field.default;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Whether a form has a field of this name at its top level. */
|
|
export function hasField(schema: readonly SchemaField[] | null | undefined, name: string): boolean {
|
|
return (schema ?? []).some((f) => f.name === name);
|
|
}
|
|
|
|
// ---- MQTT ----
|
|
|
|
// Home Assistant's MQTT integration, pointed at the broker the mesh bound — `mqtt-topic`.
|
|
//
|
|
// **Why a step.** Home Assistant keeps its broker, login and password in its MQTT config entry
|
|
// (`.storage/core.config_entries`), not in a file the mesh could fill with `${bound:mqtt-topic:at}`.
|
|
// So this reads the binding and the pair credential and makes the entry say the same thing, through
|
|
// the MQTT integration's own reconfigure flow — the flow its "Reconfigure" button runs, which tests
|
|
// the connection itself and saves nothing it could not connect with.
|
|
//
|
|
// **Only the connection, and only when it differs.** Broker, port, username, password. The protocol
|
|
// version, client id, keepalive, TLS choices and discovery options the entry holds are sent back
|
|
// exactly as Home Assistant pre-filled them. Whether the password already matches cannot be read
|
|
// back (Home Assistant never shows a stored password), so the step keeps a digest of what it last
|
|
// wrote: equal broker/port/username and an equal digest is "already as the mesh says".
|
|
//
|
|
// **Nothing loses its connection without someone seeing it.** Before Home Assistant is touched the
|
|
// broker itself is asked whether it takes the delivered login (the provisioner creates it within
|
|
// seconds of the grant): if not, nothing is written and the step fails saying why, and Home
|
|
// Assistant keeps the login it has — the carried `luffy` on ace, which mosquitto keeps. If Home
|
|
// Assistant's own connection test refuses the new settings, the flow saves nothing, and the step
|
|
// fails with Home Assistant's reason. A login that may not subscribe to the discovery topics is said
|
|
// as a warning: discovery would find nothing.
|
|
|
|
export const MQTT_PROVISION = "mqtt-topic";
|
|
/** Home Assistant's discovery prefix, subscribed to whenever discovery is on (the default). */
|
|
export const DISCOVERY_FILTER = "homeassistant/#";
|
|
|
|
export interface MqttWanted {
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
export type Wanted = { ok: true; want: MqttWanted } | { ok: false; problem: string };
|
|
|
|
/** The broker, port and login the mesh says Home Assistant uses. */
|
|
export function wantedMqtt(binding: Binding | undefined, credential: string | undefined): Wanted {
|
|
if (!binding) return { ok: false, problem: `no binding for ${MQTT_PROVISION} was delivered — the mesh writes it before this step runs` };
|
|
const host = typeof binding.at === "string" ? binding.at.trim() : "";
|
|
if (!host) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no host (at)` };
|
|
const port = portOf(binding.serves);
|
|
if (port === undefined) return { ok: false, problem: `the ${MQTT_PROVISION} binding serves no usable port (${String(binding.serves?.port)})` };
|
|
const scheme = binding.serves?.scheme;
|
|
if (scheme !== undefined && scheme !== "mqtt") {
|
|
return { ok: false, problem: `the ${MQTT_PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` };
|
|
}
|
|
const username = typeof binding.as === "string" ? binding.as.trim() : "";
|
|
if (!username) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no login (as)` };
|
|
const password = (credential ?? "").replace(/\n$/, "");
|
|
if (!password) return { ok: false, problem: `the ${MQTT_PROVISION} credential is empty or was not delivered` };
|
|
return { ok: true, want: { host, port, username, password } };
|
|
}
|
|
|
|
export interface MqttDeps {
|
|
hass: Hass;
|
|
probe: Probe;
|
|
marks: Marks;
|
|
}
|
|
|
|
const markFor = (entryId: string, w: MqttWanted): string => digest("mqtt", entryId, w.host, w.port, w.username, w.password);
|
|
|
|
/** Bring Home Assistant's MQTT entry in line with the mesh. Never throws: every failure is an outcome. */
|
|
export async function reconcileMqtt(deps: MqttDeps, binding: Binding | undefined, credential: string | undefined): Promise<Outcome> {
|
|
const what = "mqtt";
|
|
const w = wantedMqtt(binding, credential);
|
|
if ("problem" in w) return { what, result: "refused", problem: w.problem };
|
|
const want = w.want;
|
|
|
|
// The broker first: a login it does not take is never written into Home Assistant.
|
|
let note: string | undefined;
|
|
try {
|
|
const probe = await deps.probe(want.host, want.port, want.username, want.password, DISCOVERY_FILTER);
|
|
if (probe.connack === 4 || probe.connack === 5) {
|
|
return {
|
|
what,
|
|
result: "refused",
|
|
problem:
|
|
`the broker at ${want.host}:${want.port} does not (yet) take the login ${want.username} with the delivered ` +
|
|
`password (CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was ` +
|
|
`written, and Home Assistant keeps the broker login it has`,
|
|
};
|
|
}
|
|
if (probe.connack !== 0) {
|
|
return { what, result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` };
|
|
}
|
|
if (probe.suback === 0x80) {
|
|
note =
|
|
`warning: ${want.username} may not subscribe to ${DISCOVERY_FILTER} — MQTT discovery will find nothing; ` +
|
|
`grant it with the mqtt-topic contribution's \`topics\``;
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
what,
|
|
result: "refused",
|
|
problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const entries = (await deps.hass.entries("mqtt")).filter((e) => e.domain === "mqtt");
|
|
if (entries.length > 1) {
|
|
return { what, result: "refused", problem: `Home Assistant has ${entries.length} MQTT entries; which one the mesh owns is not guessed` };
|
|
}
|
|
if (entries.length === 0) return await createEntry(deps, want, note);
|
|
|
|
const entry = entries[0];
|
|
const flow = await deps.hass.startFlow("mqtt", entry.entry_id);
|
|
if (flow.type !== "form" || !flow.flow_id || !flow.data_schema) {
|
|
if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id);
|
|
return { what, result: "refused", problem: `Home Assistant's MQTT reconfigure flow answered ${flow.type}${flow.reason ? ` (${flow.reason})` : ""}` };
|
|
}
|
|
const current = formValues(flow.data_schema);
|
|
const fields: string[] = [];
|
|
if (String(current.broker ?? "") !== want.host) fields.push("broker");
|
|
if (Number(current.port ?? 0) !== want.port) fields.push("port");
|
|
if (String(current.username ?? "") !== want.username) fields.push("username");
|
|
if ((await deps.marks.get("mqtt")) !== markFor(entry.entry_id, want)) fields.push("password");
|
|
if (fields.length === 0) {
|
|
await deps.hass.abortFlow(flow.flow_id);
|
|
return note ? { what, result: "unchanged", note } : { what, result: "unchanged" };
|
|
}
|
|
|
|
const saved = await deps.hass.stepFlow(flow.flow_id, {
|
|
...current,
|
|
broker: want.host,
|
|
port: want.port,
|
|
username: want.username,
|
|
password: want.password,
|
|
});
|
|
if (saved.type === "abort" && saved.reason === "reconfigure_successful") {
|
|
await deps.marks.set("mqtt", markFor(entry.entry_id, want));
|
|
return { what, result: "written", fields, ...(note ? { note } : {}) };
|
|
}
|
|
if (saved.flow_id) await deps.hass.abortFlow(saved.flow_id);
|
|
return {
|
|
what,
|
|
result: "refused",
|
|
problem:
|
|
`Home Assistant's own connection test refused ${want.username}@${want.host}:${want.port} ` +
|
|
`(${describe(saved)}); its MQTT entry is unchanged`,
|
|
};
|
|
} catch (err) {
|
|
return { what, result: "refused", problem: scrub(err, want.password) };
|
|
}
|
|
}
|
|
|
|
/** A fresh Home Assistant has no MQTT entry: made through the integration's user flow. */
|
|
async function createEntry(deps: MqttDeps, want: MqttWanted, note?: string): Promise<Outcome> {
|
|
const what = "mqtt";
|
|
let flow = await deps.hass.startFlow("mqtt");
|
|
if (flow.type === "form" && flow.step_id !== "broker" && flow.flow_id) {
|
|
// Anything before the broker form (none outside the Supervisor) is not this step's to answer.
|
|
await deps.hass.abortFlow(flow.flow_id);
|
|
return { what, result: "refused", problem: `Home Assistant's MQTT user flow asked ${flow.step_id} before the broker` };
|
|
}
|
|
if (flow.type !== "form" || !flow.flow_id) {
|
|
return { what, result: "refused", problem: `Home Assistant's MQTT user flow answered ${describe(flow)}` };
|
|
}
|
|
const shown = formValues(flow.data_schema);
|
|
// A new entry's form has no value for its two certificate choices (a reconfigure pre-fills them
|
|
// from the entry): plain MQTT, so neither a CA nor a client certificate.
|
|
const other = (shown.other_settings ?? {}) as Record<string, unknown>;
|
|
if (flow.data_schema?.some((f) => f.name === "other_settings")) {
|
|
shown.other_settings = { set_ca_cert: "off", set_client_cert: false, ...other };
|
|
}
|
|
flow = await deps.hass.stepFlow(flow.flow_id, {
|
|
...shown,
|
|
broker: want.host,
|
|
port: want.port,
|
|
username: want.username,
|
|
password: want.password,
|
|
});
|
|
if (flow.type === "create_entry") {
|
|
const id = (flow.result as { entry_id?: string } | undefined)?.entry_id;
|
|
if (id) await deps.marks.set("mqtt", markFor(id, want));
|
|
return { what, result: "written", fields: ["entry"], ...(note ? { note } : {}) };
|
|
}
|
|
if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id);
|
|
return { what, result: "refused", problem: `Home Assistant refused a new MQTT entry for ${want.host}:${want.port} (${describe(flow)})` };
|
|
}
|
|
|
|
export function describe(r: { type: string; reason?: string; errors?: Record<string, string> | null }): string {
|
|
const errors = r.errors ? Object.entries(r.errors).map(([k, v]) => `${k}: ${v}`).join(", ") : "";
|
|
return [r.type, r.reason, errors].filter(Boolean).join(" — ");
|
|
}
|
|
|
|
// ---- Sonarr, Radarr, Lidarr ----
|
|
|
|
// Home Assistant's Sonarr, Radarr and Lidarr integrations, pointed at the apps the mesh bound —
|
|
// `sonarr-api`, `radarr-api`, `lidarr-api` (their providers: mesh-catalog #156).
|
|
//
|
|
// **What Home Assistant lets anyone change, and what it does not.** Each integration keeps a URL and
|
|
// an API key in its config entry. None of the three has a reconfigure flow: Home Assistant changes
|
|
// them only through the flow its UI runs —
|
|
// - a **user flow** makes a new entry (validated against the app);
|
|
// - a **reauth flow**, which Home Assistant starts by itself when the app refuses the key it holds,
|
|
// takes a new key (Sonarr) or a new URL and key (Radarr, Lidarr);
|
|
// - anything else — the URL of a working entry — only by removing the integration and adding it
|
|
// again, which throws away its entities' names, areas and history links. **This step never
|
|
// removes an entry.**
|
|
// So, per app:
|
|
// 1. The bound key is tried against the bound app first. Refused, nothing is written: until the
|
|
// operator accepts the app's own key for this pair, the mesh delivers a value it minted, which
|
|
// no Servarr app takes (novox/hq ADR 0092) — the failure names the `secret accept` that fixes it.
|
|
// 2. No entry: one is made through the user flow.
|
|
// 3. A reauth flow Home Assistant started for the entry: finished with the bound key (and URL,
|
|
// where the integration's reauth asks for one).
|
|
// 4. An entry whose URL (read from the device the integration registered, `configuration_url`)
|
|
// is the bound one and which is loaded: already as the mesh says. The key needs no digest here:
|
|
// a Servarr app has one key, so an entry loaded against the app holds the key the app took.
|
|
// 5. A working entry at a different URL that reaches **the same app** — the same process, by the
|
|
// app's own status (start time, data folder, version) — is left as it is and said: ace's entries
|
|
// say `127.0.0.1:<port>` and the binding says `ace.internal:<port>`, one Sonarr either way.
|
|
// 6. Anything else is refused, loudly, with what the operator can do; nothing is removed.
|
|
|
|
|
|
export interface ServarrApp {
|
|
/** The integration's domain, also the app. */
|
|
domain: "sonarr" | "radarr" | "lidarr";
|
|
/** The provision it is required as: the `requires`, `binds` and `secrets` key. */
|
|
provision: string;
|
|
/** The app's status endpoint: answers 401 to a wrong key, and says which process answered. */
|
|
statusPath: string;
|
|
}
|
|
|
|
export const APPS: readonly ServarrApp[] = [
|
|
{ domain: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
|
|
{ domain: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status" },
|
|
{ domain: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" },
|
|
];
|
|
|
|
/** The HTTP the step needs toward the apps, so a test can stand fakes in. */
|
|
export interface Http {
|
|
fetch(url: string, init?: { method?: string; headers?: Record<string, string> }): Promise<{ status: number; text(): Promise<string> }>;
|
|
}
|
|
|
|
export type AppWanted = { ok: true; url: string; key: string; from: string } | { ok: false; problem: string };
|
|
|
|
/** The URL and key the mesh says Home Assistant uses for this app. */
|
|
export function wantedApp(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): AppWanted {
|
|
if (!binding) return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` };
|
|
const at = typeof binding.at === "string" ? binding.at.trim() : "";
|
|
if (!at) return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
|
|
const port = portOf(binding.serves);
|
|
if (port === undefined) return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(binding.serves?.port)})` };
|
|
const scheme = typeof binding.serves?.scheme === "string" && binding.serves.scheme ? binding.serves.scheme : "http";
|
|
if (scheme !== "http" && scheme !== "https") return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}` };
|
|
const base = typeof binding.serves?.["url-base"] === "string" ? String(binding.serves["url-base"]).trim().replace(/^\/+|\/+$/g, "") : "";
|
|
const key = (credential ?? "").trim();
|
|
if (!key) return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
|
|
return {
|
|
ok: true,
|
|
url: `${scheme}://${urlHost(at)}:${port}${base ? `/${base}` : ""}`,
|
|
key,
|
|
from: typeof binding.from === "string" ? binding.from : "",
|
|
};
|
|
}
|
|
|
|
/** Two URLs naming the same place: scheme, host, port (explicit or default) and base path. */
|
|
export function sameUrl(a: string | null | undefined, b: string): boolean {
|
|
if (!a) return false;
|
|
try {
|
|
const x = new URL(a);
|
|
const y = new URL(b);
|
|
const port = (u: URL) => u.port || (u.protocol === "https:" ? "443" : "80");
|
|
const path = (u: URL) => u.pathname.replace(/\/+$/, "");
|
|
return x.protocol === y.protocol && x.hostname.toLowerCase() === y.hostname.toLowerCase() && port(x) === port(y) && path(x) === path(y);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type Status = { taken: true; status: Record<string, unknown> } | { taken: false };
|
|
|
|
/** The app's status with this key: `taken: false` when it refuses the key; throws when it cannot be asked. */
|
|
export async function appStatus(http: Http, spec: ServarrApp, url: string, key: string): Promise<Status> {
|
|
const res = await http.fetch(`${url.replace(/\/+$/, "")}${spec.statusPath}`, {
|
|
method: "GET",
|
|
headers: { "X-Api-Key": key, Accept: "application/json" },
|
|
});
|
|
if (res.status === 401 || res.status === 403) return { taken: false };
|
|
if (res.status < 200 || res.status >= 300) throw new Error(`${spec.domain} answered ${res.status} at ${spec.statusPath}`);
|
|
return { taken: true, status: JSON.parse(await res.text()) as Record<string, unknown> };
|
|
}
|
|
|
|
/** Whether two status answers came from one running app. */
|
|
export function sameInstance(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
|
const facts = ["startTime", "appData", "version"];
|
|
return facts.every((k) => a[k] !== undefined && a[k] !== null && a[k] === b[k]);
|
|
}
|
|
|
|
/** The remedy for a refused key, in the controller's words (ADR 0092). */
|
|
export function acceptRemedy(spec: ServarrApp, from: string): string {
|
|
return (
|
|
`${spec.domain} refuses the ${spec.provision} credential the mesh delivered, so nothing was written into ` +
|
|
`Home Assistant. A Servarr app has one API key and the mesh cannot make it: accept ${spec.domain}'s own key ` +
|
|
`for this pair — \`secret accept <this node> home-assistant ${spec.provision} --provider ${from || "<its node>"} ` +
|
|
`--from <file holding ${spec.domain}'s ApiKey>\``
|
|
);
|
|
}
|
|
|
|
export interface ServarrDeps {
|
|
hass: Hass;
|
|
http: Http;
|
|
}
|
|
|
|
/** The input a Servarr form takes: what it shows, with the URL (where asked) and the key laid over. */
|
|
function servarrInput(schema: readonly SchemaField[] | null | undefined, url: string, key: string): Record<string, unknown> {
|
|
const input = formValues(schema);
|
|
if (hasField(schema, "url")) input.url = url;
|
|
if (hasField(schema, "api_key")) input.api_key = key;
|
|
return input;
|
|
}
|
|
|
|
/** Bring Home Assistant's entry for one app in line with the mesh. Never throws. */
|
|
export async function reconcileApp(deps: ServarrDeps, spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Promise<Outcome> {
|
|
const what = spec.domain;
|
|
const w = wantedApp(spec, binding, credential);
|
|
if ("problem" in w) return { what, result: "refused", problem: w.problem };
|
|
|
|
let bound: Status;
|
|
try {
|
|
bound = await appStatus(deps.http, spec, w.url, w.key);
|
|
} catch (err) {
|
|
return { what, result: "refused", problem: `${spec.domain} could not be asked at ${w.url}: ${scrub(err, w.key)}` };
|
|
}
|
|
if (!bound.taken) return { what, result: "refused", problem: acceptRemedy(spec, w.from) };
|
|
|
|
try {
|
|
const entries = (await deps.hass.entries(spec.domain)).filter((e) => e.domain === spec.domain);
|
|
if (entries.length > 1) {
|
|
return { what, result: "refused", problem: `Home Assistant has ${entries.length} ${spec.domain} entries; which one the mesh owns is not guessed` };
|
|
}
|
|
|
|
// No entry: made, through the integration's own user flow, which validates the key itself.
|
|
if (entries.length === 0) {
|
|
const flow = await deps.hass.startFlow(spec.domain);
|
|
if (flow.type !== "form" || !flow.flow_id) return { what, result: "refused", problem: `Home Assistant's ${spec.domain} user flow answered ${describe(flow)}` };
|
|
const made = await deps.hass.stepFlow(flow.flow_id, servarrInput(flow.data_schema, w.url, w.key));
|
|
if (made.type === "create_entry") return { what, result: "written", fields: ["entry"] };
|
|
if (made.flow_id) await deps.hass.abortFlow(made.flow_id);
|
|
return { what, result: "refused", problem: `Home Assistant refused a new ${spec.domain} entry at ${w.url} (${describe(made)})` };
|
|
}
|
|
|
|
const entry = entries[0];
|
|
if (entry.disabled_by) return { what, result: "unchanged", note: `the ${spec.domain} entry is disabled (by ${entry.disabled_by}); left alone` };
|
|
|
|
// A reauth Home Assistant started because the app refused its key: finished with the bound one.
|
|
const reauth = (await deps.hass.flowsInProgress()).find(
|
|
(f) => f.handler === spec.domain && f.context?.source === "reauth" && f.context?.entry_id === entry.entry_id,
|
|
);
|
|
if (reauth) {
|
|
let step = await deps.hass.stepFlow(reauth.flow_id, {}); // reauth_confirm: a confirmation, no fields
|
|
if (step.type === "form" && step.flow_id && step.step_id !== "reauth_confirm") {
|
|
const input = servarrInput(step.data_schema, w.url, w.key);
|
|
const fields = ["api_key", ...(hasField(step.data_schema, "url") ? ["url"] : [])];
|
|
step = await deps.hass.stepFlow(step.flow_id, input);
|
|
if (step.type === "abort" && step.reason === "reauth_successful") return { what, result: "written", fields };
|
|
}
|
|
return { what, result: "refused", problem: `Home Assistant's ${spec.domain} reauth did not take the bound key and URL (${describe(step)})` };
|
|
}
|
|
|
|
const device = (await deps.hass.devices()).find((d) => d.config_entries?.includes(entry.entry_id) && d.configuration_url);
|
|
const current = device?.configuration_url ?? undefined;
|
|
if (entry.state === "loaded" && sameUrl(current, w.url)) return { what, result: "unchanged" };
|
|
|
|
if (entry.state === "loaded" && current) {
|
|
let there: Status | undefined;
|
|
try {
|
|
there = await appStatus(deps.http, spec, current, w.key);
|
|
} catch {
|
|
there = undefined;
|
|
}
|
|
if (there?.taken && sameInstance(there.status, bound.status)) {
|
|
return {
|
|
what,
|
|
result: "equivalent",
|
|
note:
|
|
`Home Assistant reaches ${spec.domain} at ${current}, the same running app the mesh bound at ${w.url}; ` +
|
|
`Home Assistant has no way to change a working ${spec.domain} entry's URL short of removing it, so it is left as it is`,
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
what,
|
|
result: "refused",
|
|
problem:
|
|
`Home Assistant's ${spec.domain} entry (${entry.state ?? "unknown state"}) points at ${current ?? "an unknown URL"}, ` +
|
|
`not the ${spec.domain} the mesh bound at ${w.url}. Home Assistant only lets a working entry's URL change by ` +
|
|
`removing and re-adding the integration, which this step never does: remove it in Home Assistant ` +
|
|
`(Settings → Devices & services → ${spec.domain}) and the next run adds it at the bound URL`,
|
|
};
|
|
} catch (err) {
|
|
return { what, result: "refused", problem: scrub(err, w.key) };
|
|
}
|
|
}
|