frontend/src/state/connection-mixin.ts

327 lines
11 KiB
TypeScript

import type { Auth, Connection, HassConfig } from "home-assistant-js-websocket";
import {
callService,
ERR_CONNECTION_LOST,
ERR_INVALID_AUTH,
subscribeConfig,
subscribeEntities,
subscribeServices,
} from "home-assistant-js-websocket";
import { fireEvent } from "../common/dom/fire_event";
import { subscribeAreaRegistry } from "../data/area_registry";
import { broadcastConnectionStatus } from "../data/connection-status";
import { subscribeDeviceRegistry } from "../data/device_registry";
import { subscribeFrontendUserData } from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { DEFAULT_PANEL } from "../data/panel";
import { serviceCallWillDisconnect } from "../data/service";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../data/translation";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import { fetchWithAuth } from "../util/fetch-with-auth";
import { getState } from "../util/ha-pref-storage";
import hassCallApi, { hassCallApiRaw } from "../util/hass-call-api";
import type { HassBaseEl } from "./hass-base-mixin";
import { promiseTimeout } from "../common/util/promise-timeout";
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribeEntityRegistryDisplay } from "../data/ws-entity_registry_display";
export const connectionMixin = <T extends Constructor<HassBaseEl>>(
superClass: T
) =>
class extends superClass {
private __backendPingInterval?: ReturnType<typeof setInterval>;
protected initializeHass(auth: Auth, conn: Connection) {
const language = getLocalLanguage();
this.hass = {
auth,
connection: conn,
connected: true,
states: null as any,
entities: null as any,
devices: null as any,
areas: null as any,
floors: null as any,
config: null as any,
themes: null as any,
selectedTheme: null,
panels: null as any,
services: null as any,
user: null as any,
panelUrl: (this as any)._panelUrl,
defaultPanel: DEFAULT_PANEL,
language,
selectedLanguage: null,
locale: {
language,
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
resources: null as any,
localize: () => "",
translationMetadata,
dockedSidebar: "docked",
vibrate: true,
debugConnection: false,
suspendWhenHidden: true,
enableShortcuts: true,
moreInfoEntityId: null,
hassUrl: (path = "") => new URL(path, auth.data.hassUrl).toString(),
callService: async (
domain,
service,
serviceData,
target,
notifyOnError = true,
returnResponse = false
) => {
if (__DEV__ || this.hass?.debugConnection) {
// eslint-disable-next-line no-console
console.log(
"Calling service",
domain,
service,
serviceData,
target
);
}
try {
return (await callService(
conn,
domain,
service,
serviceData ?? {},
target,
returnResponse
)) as ServiceCallResponse;
} catch (err: any) {
if (
err.error?.code === ERR_CONNECTION_LOST &&
serviceCallWillDisconnect(domain, service, serviceData)
) {
return { context: { id: "" } };
}
if (__DEV__ || this.hass?.debugConnection) {
// eslint-disable-next-line no-console
console.error(
"Error calling service",
domain,
service,
serviceData,
target
);
}
if (notifyOnError) {
forwardHaptic("failure");
const lokalize = await this.hass!.loadBackendTranslation(
"exceptions",
err.translation_domain
);
const localizedErrorMessage = lokalize(
`component.${err.translation_domain}.exceptions.${err.translation_key}.message`,
err.translation_placeholders
);
const message =
localizedErrorMessage ||
(this as any).hass.localize(
"ui.notification_toast.action_failed",
"service",
`${domain}/${service}`
) +
` ${
err.message ||
(err.error?.code === ERR_CONNECTION_LOST
? "connection lost"
: "unknown error")
}`;
fireEvent(this as any, "hass-notification", {
message,
duration: 10000,
});
}
throw err;
}
},
callApi: async (method, path, parameters, headers) =>
hassCallApi(auth, method, path, parameters, headers),
// callApiRaw introduced in 2024.11
callApiRaw: async (method, path, parameters, headers, signal) =>
hassCallApiRaw(auth, method, path, parameters, headers, signal),
fetchWithAuth: (
path: string,
init: Parameters<typeof fetchWithAuth>[2]
) => fetchWithAuth(auth, `${auth.data.hassUrl}${path}`, init),
// For messages that do not get a response
sendWS: (msg) => {
if (__DEV__ || this.hass?.debugConnection) {
// eslint-disable-next-line no-console
console.log("Sending", msg);
}
conn.sendMessage(msg);
},
// For messages that expect a response
callWS: <R>(msg) => {
if (__DEV__ || this.hass?.debugConnection) {
// eslint-disable-next-line no-console
console.log("Sending", msg);
}
const resp = conn.sendMessagePromise<R>(msg);
if (__DEV__ || this.hass?.debugConnection) {
resp.then(
// eslint-disable-next-line no-console
(result) => console.log("Received", result),
// eslint-disable-next-line no-console
(err) => console.error("Error", err)
);
}
return resp;
},
loadBackendTranslation: (category, integration?, configFlow?) =>
// @ts-ignore
this._loadHassTranslations(
this.hass?.language,
category,
integration,
configFlow
),
loadFragmentTranslation: (fragment) =>
// @ts-ignore
this._loadFragmentTranslations(this.hass?.language, fragment),
formatEntityState: (stateObj, state) =>
(state != null ? state : stateObj.state) ?? "",
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
...getState(),
...this._pendingHass,
};
this.hassConnected();
}
protected hassConnected() {
super.hassConnected();
const conn = this.hass!.connection;
broadcastConnectionStatus("connected");
conn.addEventListener("ready", () => this.hassReconnected());
conn.addEventListener("disconnected", () => this.hassDisconnected());
// If we reconnect after losing connection and auth is no longer valid.
conn.addEventListener("reconnect-error", (_conn, err) => {
if (err === ERR_INVALID_AUTH) {
broadcastConnectionStatus("auth-invalid");
location.reload();
}
});
subscribeEntities(conn, (states) => this._updateHass({ states }));
subscribeEntityRegistryDisplay(conn, (entityReg) => {
const entities: HomeAssistant["entities"] = {};
for (const entity of entityReg.entities) {
entities[entity.ei] = {
entity_id: entity.ei,
device_id: entity.di,
area_id: entity.ai,
labels: entity.lb,
translation_key: entity.tk,
platform: entity.pl,
entity_category:
entity.ec !== undefined
? entityReg.entity_categories[entity.ec]
: undefined,
name: entity.en,
icon: entity.ic,
hidden: entity.hb,
display_precision: entity.dp,
};
}
this._updateHass({ entities });
});
subscribeDeviceRegistry(conn, (deviceReg) => {
const devices: HomeAssistant["devices"] = {};
for (const device of deviceReg) {
devices[device.id] = device;
}
this._updateHass({ devices });
});
subscribeAreaRegistry(conn, (areaReg) => {
const areas: HomeAssistant["areas"] = {};
for (const area of areaReg) {
areas[area.area_id] = area;
}
this._updateHass({ areas });
});
subscribeFloorRegistry(conn, (floorReg) => {
const floors: HomeAssistant["floors"] = {};
for (const floor of floorReg) {
floors[floor.floor_id] = floor;
}
this._updateHass({ floors });
});
subscribeConfig(conn, (config) => this._updateHass({ config }));
subscribeServices(conn, (services) => this._updateHass({ services }));
subscribePanels(conn, (panels) => this._updateHass({ panels }));
subscribeFrontendUserData(conn, "core", (userData) =>
this._updateHass({ userData })
);
clearInterval(this.__backendPingInterval);
this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) {
promiseTimeout(5000, this.hass?.connection.ping()).catch(() => {
if (!this.hass?.connected) {
return;
}
// eslint-disable-next-line no-console
console.log("Websocket died, forcing reconnect...");
this.hass?.connection.reconnect(true);
});
}
}, 10000);
}
protected hassReconnected() {
super.hassReconnected();
this._updateHass({ connected: true });
broadcastConnectionStatus("connected");
// on reconnect always fetch config as we might miss an update while we were disconnected
// @ts-ignore
this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => {
if (config.safe_mode) {
// @ts-ignore Firefox supports forceGet
location.reload(true);
}
this._updateHass({ config });
this.checkDataBaseMigration();
});
}
protected hassDisconnected() {
super.hassDisconnected();
this._updateHass({ connected: false });
broadcastConnectionStatus("disconnected");
clearInterval(this.__backendPingInterval);
}
};