jackett leaves: it is registered from novox/mesh-media-catalog with sonarr, radarr, lidarr, bazarr, nzbget, qbittorrent, bookshelf, plex, tautulli, kometa and ombi (PRs 145-168 consolidated there). What those branches changed outside the chain stays here: home-assistant's provisions (sonarr-api, radarr-api, mqtt-topic — from #147) and searxng's sidecar dialling the port it was given (#154).
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
// Home Assistant's own configuration API, as the provisions step uses it — the supported way to
|
|
// change an integration's connection. Home Assistant keeps every integration in
|
|
// `.storage/core.config_entries`, a file it owns and rewrites; the mesh may not write it, and it is
|
|
// not a file the mesh could merge into. What Home Assistant offers instead is the same thing its UI
|
|
// uses: **config flows** over REST (`/api/config/config_entries/flow`) — a user flow creates an
|
|
// entry, a reconfigure flow changes one, a reauth flow (which Home Assistant starts itself when a
|
|
// credential stops working) replaces its credential — each validated by the integration's own
|
|
// connection test before anything is saved. The two things REST does not answer (which flows Home
|
|
// Assistant has started, which device an entry made) come over its WebSocket API.
|
|
//
|
|
// Nothing here reads `.storage`. Authenticated with the module's accepted long-lived access token.
|
|
|
|
/** A config entry as `GET /api/config/config_entries/entry` lists it — no data, no credentials. */
|
|
export interface ConfigEntry {
|
|
entry_id: string;
|
|
domain: string;
|
|
title?: string;
|
|
source?: string;
|
|
state?: string;
|
|
disabled_by?: string | null;
|
|
}
|
|
|
|
/** One field of a flow's form, as Home Assistant serializes a voluptuous schema. */
|
|
export interface SchemaField {
|
|
name: string;
|
|
type?: string;
|
|
required?: boolean;
|
|
optional?: boolean;
|
|
default?: unknown;
|
|
description?: { suggested_value?: unknown } | null;
|
|
/** A section (`type: "expandable"`) carries its own fields. */
|
|
schema?: SchemaField[];
|
|
}
|
|
|
|
/** What a flow answered: another form, an entry made, or the flow ended (abort). */
|
|
export interface FlowResult {
|
|
type: string;
|
|
flow_id?: string;
|
|
handler?: string;
|
|
step_id?: string;
|
|
data_schema?: SchemaField[] | null;
|
|
errors?: Record<string, string> | null;
|
|
reason?: string;
|
|
result?: { entry_id?: string } | unknown;
|
|
}
|
|
|
|
/** A flow in progress that Home Assistant started itself (a reauth, a discovery). */
|
|
export interface FlowProgress {
|
|
flow_id: string;
|
|
handler: string;
|
|
step_id?: string;
|
|
context?: { source?: string; entry_id?: string };
|
|
}
|
|
|
|
/** A device from the device registry; an integration names where its app is as configuration_url. */
|
|
export interface DeviceEntry {
|
|
id: string;
|
|
config_entries?: string[];
|
|
configuration_url?: string | null;
|
|
}
|
|
|
|
export interface Hass {
|
|
entries(domain: string): Promise<ConfigEntry[]>;
|
|
/** A user flow for `handler`, or — given an entry — a reconfigure flow for it. */
|
|
startFlow(handler: string, entryId?: string): Promise<FlowResult>;
|
|
stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult>;
|
|
abortFlow(flowId: string): Promise<void>;
|
|
flowsInProgress(): Promise<FlowProgress[]>;
|
|
devices(): Promise<DeviceEntry[]>;
|
|
}
|
|
|
|
/** Home Assistant over HTTP: REST for entries and flows, one short WebSocket session per question. */
|
|
export class HassApi implements Hass {
|
|
private readonly base: string;
|
|
|
|
constructor(url: string, private readonly token: string) {
|
|
this.base = url.replace(/\/$/, "");
|
|
}
|
|
|
|
private async rest(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
const res = await fetch(`${this.base}${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
Accept: "application/json",
|
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
// Home Assistant's error text names fields, never echoes their values.
|
|
throw new Error(`Home Assistant ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
|
|
}
|
|
return text ? (JSON.parse(text) as unknown) : undefined;
|
|
}
|
|
|
|
async entries(domain: string): Promise<ConfigEntry[]> {
|
|
return ((await this.rest("GET", `/api/config/config_entries/entry?domain=${encodeURIComponent(domain)}`)) ??
|
|
[]) as ConfigEntry[];
|
|
}
|
|
|
|
async startFlow(handler: string, entryId?: string): Promise<FlowResult> {
|
|
return (await this.rest("POST", "/api/config/config_entries/flow", {
|
|
handler,
|
|
show_advanced_options: true,
|
|
...(entryId ? { entry_id: entryId } : {}),
|
|
})) as FlowResult;
|
|
}
|
|
|
|
async stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult> {
|
|
return (await this.rest("POST", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`, input)) as FlowResult;
|
|
}
|
|
|
|
async abortFlow(flowId: string): Promise<void> {
|
|
await this.rest("DELETE", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`).catch(() => undefined);
|
|
}
|
|
|
|
async flowsInProgress(): Promise<FlowProgress[]> {
|
|
return (await this.ws("config_entries/flow/progress")) as FlowProgress[];
|
|
}
|
|
|
|
async devices(): Promise<DeviceEntry[]> {
|
|
return (await this.ws("config/device_registry/list")) as DeviceEntry[];
|
|
}
|
|
|
|
/** One WebSocket command: connect, authenticate, ask, close. */
|
|
private ws(type: string): Promise<unknown> {
|
|
const url = `${this.base.replace(/^http/, "ws")}/api/websocket`;
|
|
return new Promise((resolve, reject) => {
|
|
const socket = new WebSocket(url);
|
|
const timer = setTimeout(() => {
|
|
socket.close();
|
|
reject(new Error(`Home Assistant's WebSocket did not answer ${type} within 30s`));
|
|
}, 30_000);
|
|
const done = (fn: () => void): void => {
|
|
clearTimeout(timer);
|
|
socket.close();
|
|
fn();
|
|
};
|
|
socket.onerror = () => done(() => reject(new Error(`Home Assistant's WebSocket at ${url} failed`)));
|
|
socket.onmessage = (event: { data: unknown }) => {
|
|
const msg = JSON.parse(String(event.data)) as {
|
|
type: string;
|
|
id?: number;
|
|
success?: boolean;
|
|
result?: unknown;
|
|
error?: { message?: string };
|
|
};
|
|
if (msg.type === "auth_required") socket.send(JSON.stringify({ type: "auth", access_token: this.token }));
|
|
else if (msg.type === "auth_invalid") done(() => reject(new Error("Home Assistant refused the token")));
|
|
else if (msg.type === "auth_ok") socket.send(JSON.stringify({ id: 1, type }));
|
|
else if (msg.type === "result" && msg.id === 1) {
|
|
if (msg.success) done(() => resolve(msg.result));
|
|
else done(() => reject(new Error(`Home Assistant ${type}: ${msg.error?.message ?? "failed"}`)));
|
|
}
|
|
};
|
|
});
|
|
}
|
|
}
|