UNPKG

home-assistant-js-websocket

Version:
1,125 lines (1,110 loc) 41.8 kB
'use strict'; const ERR_CANNOT_CONNECT = 1; const ERR_INVALID_AUTH = 2; const ERR_CONNECTION_LOST = 3; const ERR_HASS_HOST_REQUIRED = 4; const ERR_INVALID_HTTPS_TO_HTTP = 5; const ERR_INVALID_AUTH_CALLBACK = 6; function auth(accessToken) { return { type: "auth", access_token: accessToken, }; } function supportedFeatures() { return { type: "supported_features", id: 1, // Always the first message after auth features: { coalesce_messages: 1 }, }; } function states() { return { type: "get_states", }; } function config() { return { type: "get_config", }; } function services() { return { type: "get_services", }; } function user() { return { type: "auth/current_user", }; } function callService$1(domain, service, serviceData, target, returnResponse) { const message = { type: "call_service", domain, service, target, return_response: returnResponse, }; if (serviceData) { message.service_data = serviceData; } return message; } function subscribeEvents(eventType) { const message = { type: "subscribe_events", }; if (eventType) { message.event_type = eventType; } return message; } function unsubscribeEvents(subscription) { return { type: "unsubscribe_events", subscription, }; } function ping() { return { type: "ping", }; } function error(code, message) { return { type: "result", success: false, error: { code, message, }, }; } function parseQuery(queryString) { const query = {}; const items = queryString.split("&"); for (let i = 0; i < items.length; i++) { const item = items[i].split("="); const key = decodeURIComponent(item[0]); const value = item.length > 1 ? decodeURIComponent(item[1]) : undefined; query[key] = value; } return query; } // From: https://davidwalsh.name/javascript-debounce-function // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. If `immediate` is passed, trigger the function on the // leading edge, instead of the trailing. // eslint-disable-next-line: ban-types const debounce = (func, wait, immediate = false) => { let timeout; // @ts-ignore return function (...args) { // @ts-ignore const context = this; const later = () => { timeout = undefined; if (!immediate) { func.apply(context, args); } }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) { func.apply(context, args); } }; }; const atLeastHaVersion = (version, major, minor, patch) => { const [haMajor, haMinor, haPatch] = version.split(".", 3); return (Number(haMajor) > major || (Number(haMajor) === major && (patch === undefined ? Number(haMinor) >= minor : Number(haMinor) > minor)) || (patch !== undefined && Number(haMajor) === major && Number(haMinor) === minor && Number(haPatch) >= patch)); }; /** * Create a web socket connection with a Home Assistant instance. */ const MSG_TYPE_AUTH_REQUIRED = "auth_required"; const MSG_TYPE_AUTH_INVALID = "auth_invalid"; const MSG_TYPE_AUTH_OK = "auth_ok"; function createSocket(options) { if (!options.auth) { throw ERR_HASS_HOST_REQUIRED; } const auth$1 = options.auth; // Start refreshing expired tokens even before the WS connection is open. // We know that we will need auth anyway. let authRefreshTask = auth$1.expired ? auth$1.refreshAccessToken().then(() => { authRefreshTask = undefined; }, () => { authRefreshTask = undefined; }) : undefined; // Convert from http:// -> ws://, https:// -> wss:// const url = auth$1.wsUrl; function connect(triesLeft, promResolve, promReject) { const socket = new WebSocket(url); // If invalid auth, we will not try to reconnect. let invalidAuth = false; const closeMessage = () => { // If we are in error handler make sure close handler doesn't also fire. socket.removeEventListener("close", closeMessage); if (invalidAuth) { promReject(ERR_INVALID_AUTH); return; } // Reject if we no longer have to retry if (triesLeft === 0) { // We never were connected and will not retry promReject(ERR_CANNOT_CONNECT); return; } const newTries = triesLeft === -1 ? -1 : triesLeft - 1; // Try again in a second setTimeout(() => connect(newTries, promResolve, promReject), 1000); }; // Auth is mandatory, so we can send the auth message right away. const handleOpen = async (event) => { try { if (auth$1.expired) { await (authRefreshTask ? authRefreshTask : auth$1.refreshAccessToken()); } socket.send(JSON.stringify(auth(auth$1.accessToken))); } catch (err) { // Refresh token failed invalidAuth = err === ERR_INVALID_AUTH; socket.close(); } }; const handleMessage = async (event) => { const message = JSON.parse(event.data); switch (message.type) { case MSG_TYPE_AUTH_INVALID: invalidAuth = true; socket.close(); break; case MSG_TYPE_AUTH_OK: socket.removeEventListener("open", handleOpen); socket.removeEventListener("message", handleMessage); socket.removeEventListener("close", closeMessage); socket.removeEventListener("error", closeMessage); socket.haVersion = message.ha_version; if (atLeastHaVersion(socket.haVersion, 2022, 9)) { socket.send(JSON.stringify(supportedFeatures())); } promResolve(socket); break; } }; socket.addEventListener("open", handleOpen); socket.addEventListener("message", handleMessage); socket.addEventListener("close", closeMessage); socket.addEventListener("error", closeMessage); } return new Promise((resolve, reject) => connect(options.setupRetry, resolve, reject)); } /** * Connection that wraps a socket and provides an interface to interact with * the Home Assistant websocket API. */ class Connection { constructor(socket, options) { this._handleMessage = (event) => { let messageGroup = JSON.parse(event.data); if (!Array.isArray(messageGroup)) { messageGroup = [messageGroup]; } messageGroup.forEach((message) => { const info = this.commands.get(message.id); switch (message.type) { case "event": if (info) { info.callback(message.event); } else { console.warn(`Received event for unknown subscription ${message.id}. Unsubscribing.`); this.sendMessagePromise(unsubscribeEvents(message.id)).catch((err) => { }); } break; case "result": // No info is fine. If just sendMessage is used, we did not store promise for result if (info) { if (message.success) { info.resolve(message.result); // Don't remove subscriptions. if (!("subscribe" in info)) { this.commands.delete(message.id); } } else { info.reject(message.error); this.commands.delete(message.id); } } break; case "pong": if (info) { info.resolve(); this.commands.delete(message.id); } else { console.warn(`Received unknown pong response ${message.id}`); } break; } }); }; this._handleClose = async () => { const oldCommands = this.commands; // reset to original state except haVersion this.commandId = 1; this.oldSubscriptions = this.commands; this.commands = new Map(); this.socket = undefined; // Reject in-flight sendMessagePromise requests oldCommands.forEach((info) => { // We don't cancel subscribeEvents commands in flight // as we will be able to recover them. if (!("subscribe" in info)) { info.reject(error(ERR_CONNECTION_LOST, "Connection lost")); } }); if (this.closeRequested) { return; } this.fireEvent("disconnected"); // Disable setupRetry, we control it here with auto-backoff const options = Object.assign(Object.assign({}, this.options), { setupRetry: 0 }); const reconnect = (tries) => { setTimeout(async () => { if (this.closeRequested) { return; } try { const socket = await options.createSocket(options); this._setSocket(socket); } catch (err) { if (this._queuedMessages) { const queuedMessages = this._queuedMessages; this._queuedMessages = undefined; for (const msg of queuedMessages) { if (msg.reject) { msg.reject(ERR_CONNECTION_LOST); } } } if (err === ERR_INVALID_AUTH) { this.fireEvent("reconnect-error", err); } else { reconnect(tries + 1); } } }, Math.min(tries, 5) * 1000); }; if (this.suspendReconnectPromise) { await this.suspendReconnectPromise; this.suspendReconnectPromise = undefined; // For the first retry after suspend, we will queue up // all messages. this._queuedMessages = []; } reconnect(0); }; // connection options // - setupRetry: amount of ms to retry when unable to connect on initial setup // - createSocket: create a new Socket connection this.options = options; // id if next command to send this.commandId = 2; // socket may send 1 at the start to enable features // info about active subscriptions and commands in flight this.commands = new Map(); // map of event listeners this.eventListeners = new Map(); // true if a close is requested by the user this.closeRequested = false; this._setSocket(socket); } get connected() { // Using conn.socket.OPEN instead of WebSocket for better node support return (this.socket !== undefined && this.socket.readyState == this.socket.OPEN); } _setSocket(socket) { this.socket = socket; this.haVersion = socket.haVersion; socket.addEventListener("message", this._handleMessage); socket.addEventListener("close", this._handleClose); const oldSubscriptions = this.oldSubscriptions; if (oldSubscriptions) { this.oldSubscriptions = undefined; oldSubscriptions.forEach((info) => { if ("subscribe" in info && info.subscribe) { info.subscribe().then((unsub) => { info.unsubscribe = unsub; // We need to resolve this in case it wasn't resolved yet. // This allows us to subscribe while we're disconnected // and recover properly. info.resolve(); }); } }); } const queuedMessages = this._queuedMessages; if (queuedMessages) { this._queuedMessages = undefined; for (const queuedMsg of queuedMessages) { queuedMsg.resolve(); } } this.fireEvent("ready"); } addEventListener(eventType, callback) { let listeners = this.eventListeners.get(eventType); if (!listeners) { listeners = []; this.eventListeners.set(eventType, listeners); } listeners.push(callback); } removeEventListener(eventType, callback) { const listeners = this.eventListeners.get(eventType); if (!listeners) { return; } const index = listeners.indexOf(callback); if (index !== -1) { listeners.splice(index, 1); } } fireEvent(eventType, eventData) { (this.eventListeners.get(eventType) || []).forEach((callback) => callback(this, eventData)); } suspendReconnectUntil(suspendPromise) { this.suspendReconnectPromise = suspendPromise; } suspend() { if (!this.suspendReconnectPromise) { throw new Error("Suspend promise not set"); } if (this.socket) { this.socket.close(); } } /** * Reconnect the websocket connection. * @param force discard old socket instead of gracefully closing it. */ reconnect(force = false) { if (!this.socket) { return; } if (!force) { this.socket.close(); return; } this.socket.removeEventListener("message", this._handleMessage); this.socket.removeEventListener("close", this._handleClose); this.socket.close(); this._handleClose(); } close() { this.closeRequested = true; if (this.socket) { this.socket.close(); } } /** * Subscribe to a specific or all events. * * @param callback Callback to be called when a new event fires * @param eventType * @returns promise that resolves to an unsubscribe function */ async subscribeEvents(callback, eventType) { return this.subscribeMessage(callback, subscribeEvents(eventType)); } ping() { return this.sendMessagePromise(ping()); } sendMessage(message, commandId) { if (!this.connected) { throw ERR_CONNECTION_LOST; } if (this._queuedMessages) { if (commandId) { throw new Error("Cannot queue with commandId"); } this._queuedMessages.push({ resolve: () => this.sendMessage(message) }); return; } if (!commandId) { commandId = this._genCmdId(); } message.id = commandId; this.socket.send(JSON.stringify(message)); } sendMessagePromise(message) { return new Promise((resolve, reject) => { if (this._queuedMessages) { this._queuedMessages.push({ reject, resolve: async () => { try { resolve(await this.sendMessagePromise(message)); } catch (err) { reject(err); } }, }); return; } const commandId = this._genCmdId(); this.commands.set(commandId, { resolve, reject }); this.sendMessage(message, commandId); }); } /** * Call a websocket command that starts a subscription on the backend. * * @param message the message to start the subscription * @param callback the callback to be called when a new item arrives * @param [options.resubscribe] re-established a subscription after a reconnect. Defaults to true. * @returns promise that resolves to an unsubscribe function */ async subscribeMessage(callback, subscribeMessage, options) { if (this._queuedMessages) { await new Promise((resolve, reject) => { this._queuedMessages.push({ resolve, reject }); }); } if (options === null || options === void 0 ? void 0 : options.preCheck) { const precheck = await options.preCheck(); if (!precheck) { throw new Error("Pre-check failed"); } } let info; await new Promise((resolve, reject) => { // Command ID that will be used const commandId = this._genCmdId(); // We store unsubscribe on info object. That way we can overwrite it in case // we get disconnected and we have to subscribe again. info = { resolve, reject, callback, subscribe: (options === null || options === void 0 ? void 0 : options.resubscribe) !== false ? () => this.subscribeMessage(callback, subscribeMessage, options) : undefined, unsubscribe: async () => { // No need to unsubscribe if we're disconnected if (this.connected) { await this.sendMessagePromise(unsubscribeEvents(commandId)); } this.commands.delete(commandId); }, }; this.commands.set(commandId, info); try { this.sendMessage(subscribeMessage, commandId); } catch (err) { // Happens when the websocket is already closing. // Don't have to handle the error, reconnect logic will pick it up. } }); return () => info.unsubscribe(); } _genCmdId() { return ++this.commandId; } } const genClientId = () => `${location.protocol}//${location.host}/`; const genExpires = (expires_in) => { return expires_in * 1000 + Date.now(); }; function genRedirectUrl() { // Get current url but without # part. const { protocol, host, pathname, search } = location; return `${protocol}//${host}${pathname}${search}`; } function genAuthorizeUrl(hassUrl, clientId, redirectUrl, state) { let authorizeUrl = `${hassUrl}/auth/authorize?response_type=code&redirect_uri=${encodeURIComponent(redirectUrl)}`; if (clientId !== null) { authorizeUrl += `&client_id=${encodeURIComponent(clientId)}`; } if (state) { authorizeUrl += `&state=${encodeURIComponent(state)}`; } return authorizeUrl; } function redirectAuthorize(hassUrl, clientId, redirectUrl, state) { // Add either ?auth_callback=1 or &auth_callback=1 redirectUrl += (redirectUrl.includes("?") ? "&" : "?") + "auth_callback=1"; document.location.href = genAuthorizeUrl(hassUrl, clientId, redirectUrl, state); } async function tokenRequest(hassUrl, clientId, data) { // Browsers don't allow fetching tokens from https -> http. // Throw an error because it's a pain to debug this. // Guard against not working in node. const l = typeof location !== "undefined" && location; if (l && l.protocol === "https:") { // Ensure that the hassUrl is hosted on https. const a = document.createElement("a"); a.href = hassUrl; if (a.protocol === "http:" && a.hostname !== "localhost") { throw ERR_INVALID_HTTPS_TO_HTTP; } } const formData = new FormData(); if (clientId !== null) { formData.append("client_id", clientId); } Object.keys(data).forEach((key) => { // @ts-ignore formData.append(key, data[key]); }); const resp = await fetch(`${hassUrl}/auth/token`, { method: "POST", credentials: "same-origin", body: formData, }); if (!resp.ok) { throw resp.status === 400 /* auth invalid */ || resp.status === 403 /* user not active */ ? ERR_INVALID_AUTH : new Error("Unable to fetch tokens"); } const tokens = await resp.json(); tokens.hassUrl = hassUrl; tokens.clientId = clientId; tokens.expires = genExpires(tokens.expires_in); return tokens; } function fetchToken(hassUrl, clientId, code) { return tokenRequest(hassUrl, clientId, { code, grant_type: "authorization_code", }); } function encodeOAuthState(state) { return btoa(JSON.stringify(state)); } function decodeOAuthState(encoded) { return JSON.parse(atob(encoded)); } class Auth { constructor(data, saveTokens) { this.data = data; this._saveTokens = saveTokens; } get wsUrl() { // Convert from http:// -> ws://, https:// -> wss:// return `ws${this.data.hassUrl.substr(4)}/api/websocket`; } get accessToken() { return this.data.access_token; } get expired() { return Date.now() > this.data.expires; } /** * Refresh the access token. */ async refreshAccessToken() { if (!this.data.refresh_token) throw new Error("No refresh_token"); const data = await tokenRequest(this.data.hassUrl, this.data.clientId, { grant_type: "refresh_token", refresh_token: this.data.refresh_token, }); // Access token response does not contain refresh token. data.refresh_token = this.data.refresh_token; this.data = data; if (this._saveTokens) this._saveTokens(data); } /** * Revoke the refresh & access tokens. */ async revoke() { if (!this.data.refresh_token) throw new Error("No refresh_token to revoke"); const formData = new FormData(); formData.append("token", this.data.refresh_token); // There is no error checking, as revoke will always return 200 await fetch(`${this.data.hassUrl}/auth/revoke`, { method: "POST", credentials: "same-origin", body: formData, }); if (this._saveTokens) { this._saveTokens(null); } } } function createLongLivedTokenAuth(hassUrl, access_token) { return new Auth({ hassUrl, clientId: null, expires: Date.now() + 1e11, refresh_token: "", access_token, expires_in: 1e11, }); } async function getAuth(options = {}) { let data; let hassUrl = options.hassUrl; // Strip trailing slash. if (hassUrl && hassUrl[hassUrl.length - 1] === "/") { hassUrl = hassUrl.substr(0, hassUrl.length - 1); } const clientId = options.clientId !== undefined ? options.clientId : genClientId(); const limitHassInstance = options.limitHassInstance === true; // Use auth code if it was passed in if (options.authCode && hassUrl) { data = await fetchToken(hassUrl, clientId, options.authCode); if (options.saveTokens) { options.saveTokens(data); } } // Check if we came back from an authorize redirect if (!data) { const query = parseQuery(location.search.substr(1)); // Check if we got redirected here from authorize page if ("auth_callback" in query) { // Restore state const state = decodeOAuthState(query.state); if (limitHassInstance && (state.hassUrl !== hassUrl || state.clientId !== clientId)) { throw ERR_INVALID_AUTH_CALLBACK; } data = await fetchToken(state.hassUrl, state.clientId, query.code); if (options.saveTokens) { options.saveTokens(data); } } } // Check for stored tokens if (!data && options.loadTokens) { data = await options.loadTokens(); } // If the token is for another url, ignore it if (data && (hassUrl === undefined || data.hassUrl === hassUrl)) { return new Auth(data, options.saveTokens); } if (hassUrl === undefined) { throw ERR_HASS_HOST_REQUIRED; } // If no tokens found but a hassUrl was passed in, let's go get some tokens! redirectAuthorize(hassUrl, clientId, options.redirectUrl || genRedirectUrl(), encodeOAuthState({ hassUrl, clientId, })); // Just don't resolve while we navigate to next page return new Promise(() => { }); } const createStore = (state) => { let listeners = []; function unsubscribe(listener) { let out = []; for (let i = 0; i < listeners.length; i++) { if (listeners[i] === listener) { listener = null; } else { out.push(listeners[i]); } } listeners = out; } function setState(update, overwrite) { state = overwrite ? update : Object.assign(Object.assign({}, state), update); let currentListeners = listeners; for (let i = 0; i < currentListeners.length; i++) { currentListeners[i](state); } } /** * An observable state container, returned from {@link createStore} * @name store */ return { get state() { return state; }, /** * Create a bound copy of the given action function. * The bound returned function invokes action() and persists the result back to the store. * If the return value of `action` is a Promise, the resolved value will be used as state. * @param {Function} action An action of the form `action(state, ...args) -> stateUpdate` * @returns {Function} boundAction() */ action(action) { function apply(result) { setState(result, false); } // Note: perf tests verifying this implementation: https://esbench.com/bench/5a295e6299634800a0349500 return function () { let args = [state]; for (let i = 0; i < arguments.length; i++) args.push(arguments[i]); // @ts-ignore let ret = action.apply(this, args); if (ret != null) { return ret instanceof Promise ? ret.then(apply) : apply(ret); } }; }, /** * Apply a partial state object to the current state, invoking registered listeners. * @param {Object} update An object with properties to be merged into state * @param {Boolean} [overwrite=false] If `true`, update will replace state instead of being merged into it */ setState, clearState() { state = undefined; }, /** * Register a listener function to be called whenever state is changed. Returns an `unsubscribe()` function. * @param {Function} listener A function to call when state changes. Gets passed the new state. * @returns {Function} unsubscribe() */ subscribe(listener) { listeners.push(listener); return () => { unsubscribe(listener); }; }, // /** // * Remove a previously-registered listener function. // * @param {Function} listener The callback previously passed to `subscribe()` that should be removed. // * @function // */ // unsubscribe, }; }; // Time to wait to unsubscribe from updates after last subscriber unsubscribes const UNSUB_GRACE_PERIOD = 5000; // 5 seconds /** * * @param conn connection * @param key the key to store it on the connection. Must be unique for each collection. * @param fetchCollection fetch the current state. If undefined assumes subscribeUpdates receives current state * @param subscribeUpdates subscribe to updates on the current state * @returns */ const getCollection = (conn, key, fetchCollection, subscribeUpdates, options = { unsubGrace: true }) => { // @ts-ignore if (conn[key]) { // @ts-ignore return conn[key]; } let active = 0; let unsubProm; let unsubTimer; let store = createStore(); const refresh = () => { if (!fetchCollection) { throw new Error("Collection does not support refresh"); } return fetchCollection(conn).then((state) => store.setState(state, true)); }; const refreshSwallow = () => refresh().catch((err) => { // Swallow errors if socket is connecting, closing or closed. // We will automatically call refresh again when we re-establish the connection. if (conn.connected) { throw err; } }); const setupUpdateSubscription = () => { if (unsubTimer !== undefined) { clearTimeout(unsubTimer); unsubTimer = undefined; return; } if (subscribeUpdates) { unsubProm = subscribeUpdates(conn, store); } if (fetchCollection) { // Fetch when connection re-established. conn.addEventListener("ready", refreshSwallow); refreshSwallow(); } conn.addEventListener("disconnected", handleDisconnect); }; const teardownUpdateSubscription = () => { unsubTimer = undefined; // Unsubscribe from changes if (unsubProm) unsubProm.then((unsub) => { unsub(); }); store.clearState(); conn.removeEventListener("ready", refresh); conn.removeEventListener("disconnected", handleDisconnect); }; const scheduleTeardownUpdateSubscription = () => { unsubTimer = setTimeout(teardownUpdateSubscription, UNSUB_GRACE_PERIOD); }; const handleDisconnect = () => { // If we're going to unsubscribe and then lose connection, // just unsubscribe immediately. if (unsubTimer) { clearTimeout(unsubTimer); teardownUpdateSubscription(); } }; // @ts-ignore conn[key] = { get state() { return store.state; }, refresh, subscribe(subscriber) { active++; // If this was the first subscriber, attach collection if (active === 1) { setupUpdateSubscription(); } const unsub = store.subscribe(subscriber); if (store.state !== undefined) { // Don't call it right away so that caller has time // to initialize all the things. setTimeout(() => subscriber(store.state), 0); } return () => { unsub(); active--; if (!active) { options.unsubGrace ? scheduleTeardownUpdateSubscription() : teardownUpdateSubscription(); } }; }, }; // @ts-ignore return conn[key]; }; // Legacy name. It gets a collection and subscribes. const createCollection = (key, fetchCollection, subscribeUpdates, conn, onChange) => getCollection(conn, key, fetchCollection, subscribeUpdates).subscribe(onChange); const getStates = (connection) => connection.sendMessagePromise(states()); const getServices = (connection) => connection.sendMessagePromise(services()); const getConfig = (connection) => connection.sendMessagePromise(config()); const getUser = (connection) => connection.sendMessagePromise(user()); const callService = (connection, domain, service, serviceData, target, returnResponse) => connection.sendMessagePromise(callService$1(domain, service, serviceData, target, returnResponse)); function processComponentLoaded(state, event) { if (state === undefined) return null; return { components: state.components.concat(event.data.component), }; } const fetchConfig = (conn) => getConfig(conn); const subscribeUpdates$2 = (conn, store) => Promise.all([ conn.subscribeEvents(store.action(processComponentLoaded), "component_loaded"), conn.subscribeEvents(() => fetchConfig(conn).then((config) => store.setState(config, true)), "core_config_updated"), ]).then((unsubs) => () => unsubs.forEach((unsub) => unsub())); const configColl = (conn) => getCollection(conn, "_cnf", fetchConfig, subscribeUpdates$2); const subscribeConfig = (conn, onChange) => configColl(conn).subscribe(onChange); const STATE_NOT_RUNNING = "NOT_RUNNING"; const STATE_STARTING = "STARTING"; const STATE_RUNNING = "RUNNING"; const STATE_STOPPING = "STOPPING"; const STATE_FINAL_WRITE = "FINAL_WRITE"; function processServiceRegistered(conn, store, event) { var _a; const state = store.state; if (state === undefined) return; const { domain, service } = event.data; if (!((_a = state.domain) === null || _a === void 0 ? void 0 : _a.service)) { const domainInfo = Object.assign(Object.assign({}, state[domain]), { [service]: { description: "", fields: {} } }); store.setState({ [domain]: domainInfo }); } debouncedFetchServices(conn, store); } function processServiceRemoved(state, event) { if (state === undefined) return null; const { domain, service } = event.data; const curDomainInfo = state[domain]; if (!curDomainInfo || !(service in curDomainInfo)) return null; const domainInfo = {}; Object.keys(curDomainInfo).forEach((sKey) => { if (sKey !== service) domainInfo[sKey] = curDomainInfo[sKey]; }); return { [domain]: domainInfo }; } const debouncedFetchServices = debounce((conn, store) => fetchServices(conn).then((services) => store.setState(services, true)), 5000); const fetchServices = (conn) => getServices(conn); const subscribeUpdates$1 = (conn, store) => Promise.all([ conn.subscribeEvents((ev) => processServiceRegistered(conn, store, ev), "service_registered"), conn.subscribeEvents(store.action(processServiceRemoved), "service_removed"), ]).then((unsubs) => () => unsubs.forEach((fn) => fn())); const servicesColl = (conn) => getCollection(conn, "_srv", fetchServices, subscribeUpdates$1); const subscribeServices = (conn, onChange) => servicesColl(conn).subscribe(onChange); function processEvent(store, updates) { const state = Object.assign({}, store.state); if (updates.a) { for (const entityId in updates.a) { const newState = updates.a[entityId]; let last_changed = new Date(newState.lc * 1000).toISOString(); state[entityId] = { entity_id: entityId, state: newState.s, attributes: newState.a, context: typeof newState.c === "string" ? { id: newState.c, parent_id: null, user_id: null } : newState.c, last_changed: last_changed, last_updated: newState.lu ? new Date(newState.lu * 1000).toISOString() : last_changed, }; } } if (updates.r) { for (const entityId of updates.r) { delete state[entityId]; } } if (updates.c) { for (const entityId in updates.c) { let entityState = state[entityId]; if (!entityState) { console.warn("Received state update for unknown entity", entityId); continue; } entityState = Object.assign({}, entityState); const { "+": toAdd, "-": toRemove } = updates.c[entityId]; const attributesChanged = (toAdd === null || toAdd === void 0 ? void 0 : toAdd.a) || (toRemove === null || toRemove === void 0 ? void 0 : toRemove.a); const attributes = attributesChanged ? Object.assign({}, entityState.attributes) : entityState.attributes; if (toAdd) { if (toAdd.s !== undefined) { entityState.state = toAdd.s; } if (toAdd.c) { if (typeof toAdd.c === "string") { entityState.context = Object.assign(Object.assign({}, entityState.context), { id: toAdd.c }); } else { entityState.context = Object.assign(Object.assign({}, entityState.context), toAdd.c); } } if (toAdd.lc) { entityState.last_updated = entityState.last_changed = new Date(toAdd.lc * 1000).toISOString(); } else if (toAdd.lu) { entityState.last_updated = new Date(toAdd.lu * 1000).toISOString(); } if (toAdd.a) { Object.assign(attributes, toAdd.a); } } if (toRemove === null || toRemove === void 0 ? void 0 : toRemove.a) { for (const key of toRemove.a) { delete attributes[key]; } } if (attributesChanged) { entityState.attributes = attributes; } state[entityId] = entityState; } } store.setState(state, true); } const subscribeUpdates = (conn, store) => conn.subscribeMessage((ev) => processEvent(store, ev), { type: "subscribe_entities", }); function legacyProcessEvent(store, event) { const state = store.state; if (state === undefined) return; const { entity_id, new_state } = event.data; if (new_state) { store.setState({ [new_state.entity_id]: new_state }); } else { const newEntities = Object.assign({}, state); delete newEntities[entity_id]; store.setState(newEntities, true); } } async function legacyFetchEntities(conn) { const states = await getStates(conn); const entities = {}; for (let i = 0; i < states.length; i++) { const state = states[i]; entities[state.entity_id] = state; } return entities; } const legacySubscribeUpdates = (conn, store) => conn.subscribeEvents((ev) => legacyProcessEvent(store, ev), "state_changed"); const entitiesColl = (conn) => atLeastHaVersion(conn.haVersion, 2022, 4, 0) ? getCollection(conn, "_ent", undefined, subscribeUpdates) : getCollection(conn, "_ent", legacyFetchEntities, legacySubscribeUpdates); const subscribeEntities = (conn, onChange) => entitiesColl(conn).subscribe(onChange); // JS extensions in imports allow tsc output to be consumed by browsers. async function createConnection(options) { const connOptions = Object.assign({ setupRetry: 0, createSocket }, options); const socket = await connOptions.createSocket(connOptions); const conn = new Connection(socket, connOptions); return conn; } exports.Auth = Auth; exports.Connection = Connection; exports.ERR_CANNOT_CONNECT = ERR_CANNOT_CONNECT; exports.ERR_CONNECTION_LOST = ERR_CONNECTION_LOST; exports.ERR_HASS_HOST_REQUIRED = ERR_HASS_HOST_REQUIRED; exports.ERR_INVALID_AUTH = ERR_INVALID_AUTH; exports.ERR_INVALID_AUTH_CALLBACK = ERR_INVALID_AUTH_CALLBACK; exports.ERR_INVALID_HTTPS_TO_HTTP = ERR_INVALID_HTTPS_TO_HTTP; exports.MSG_TYPE_AUTH_INVALID = MSG_TYPE_AUTH_INVALID; exports.MSG_TYPE_AUTH_OK = MSG_TYPE_AUTH_OK; exports.MSG_TYPE_AUTH_REQUIRED = MSG_TYPE_AUTH_REQUIRED; exports.STATE_FINAL_WRITE = STATE_FINAL_WRITE; exports.STATE_NOT_RUNNING = STATE_NOT_RUNNING; exports.STATE_RUNNING = STATE_RUNNING; exports.STATE_STARTING = STATE_STARTING; exports.STATE_STOPPING = STATE_STOPPING; exports.callService = callService; exports.configColl = configColl; exports.createCollection = createCollection; exports.createConnection = createConnection; exports.createLongLivedTokenAuth = createLongLivedTokenAuth; exports.createSocket = createSocket; exports.createStore = createStore; exports.entitiesColl = entitiesColl; exports.genClientId = genClientId; exports.genExpires = genExpires; exports.getAuth = getAuth; exports.getCollection = getCollection; exports.getConfig = getConfig; exports.getServices = getServices; exports.getStates = getStates; exports.getUser = getUser; exports.servicesColl = servicesColl; exports.subscribeConfig = subscribeConfig; exports.subscribeEntities = subscribeEntities; exports.subscribeServices = subscribeServices;