Init Commit
This commit is contained in:
commit
60188b0574
850 files changed
+570948
No files matched your search
+39
@@ -0,0 +1,39 @@
|
||||
export type AuthData = {
|
||||
hassUrl: string;
|
||||
clientId: string | null;
|
||||
expires: number;
|
||||
refresh_token: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
export type SaveTokensFunc = (data: AuthData | null) => void;
|
||||
export type LoadTokensFunc = () => Promise<AuthData | null | undefined>;
|
||||
export type getAuthOptions = {
|
||||
hassUrl?: string;
|
||||
clientId?: string | null;
|
||||
redirectUrl?: string;
|
||||
authCode?: string;
|
||||
saveTokens?: SaveTokensFunc;
|
||||
loadTokens?: LoadTokensFunc;
|
||||
limitHassInstance?: boolean;
|
||||
};
|
||||
export declare const genClientId: () => string;
|
||||
export declare const genExpires: (expires_in: number) => number;
|
||||
export declare class Auth {
|
||||
private _saveTokens?;
|
||||
data: AuthData;
|
||||
constructor(data: AuthData, saveTokens?: SaveTokensFunc);
|
||||
get wsUrl(): string;
|
||||
get accessToken(): string;
|
||||
get expired(): boolean;
|
||||
/**
|
||||
* Refresh the access token.
|
||||
*/
|
||||
refreshAccessToken(): Promise<void>;
|
||||
/**
|
||||
* Revoke the refresh & access tokens.
|
||||
*/
|
||||
revoke(): Promise<void>;
|
||||
}
|
||||
export declare function createLongLivedTokenAuth(hassUrl: string, access_token: string): Auth;
|
||||
export declare function getAuth(options?: getAuthOptions): Promise<Auth>;
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { parseQuery } from "./util.js";
|
||||
import { ERR_HASS_HOST_REQUIRED, ERR_INVALID_AUTH, ERR_INVALID_AUTH_CALLBACK, ERR_INVALID_HTTPS_TO_HTTP, } from "./errors.js";
|
||||
export const genClientId = () => `${location.protocol}//${location.host}/`;
|
||||
export 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));
|
||||
}
|
||||
export 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
export function createLongLivedTokenAuth(hassUrl, access_token) {
|
||||
return new Auth({
|
||||
hassUrl,
|
||||
clientId: null,
|
||||
expires: Date.now() + 1e11,
|
||||
refresh_token: "",
|
||||
access_token,
|
||||
expires_in: 1e11,
|
||||
});
|
||||
}
|
||||
export 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(() => { });
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Store } from "./store.js";
|
||||
import { Connection } from "./connection.js";
|
||||
import { UnsubscribeFunc } from "./types.js";
|
||||
export type Collection<State> = {
|
||||
state: State;
|
||||
refresh(): Promise<void>;
|
||||
subscribe(subscriber: (state: State) => void): UnsubscribeFunc;
|
||||
};
|
||||
/**
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export declare const getCollection: <State>(conn: Connection, key: string, fetchCollection: ((conn: Connection) => Promise<State>) | undefined, subscribeUpdates?: (conn: Connection, store: Store<State>) => Promise<UnsubscribeFunc>, options?: {
|
||||
unsubGrace: boolean;
|
||||
}) => Collection<State>;
|
||||
export declare const createCollection: <State>(key: string, fetchCollection: (conn: Connection) => Promise<State>, subscribeUpdates: ((conn: Connection, store: Store<State>) => Promise<UnsubscribeFunc>) | undefined, conn: Connection, onChange: (state: State) => void) => UnsubscribeFunc;
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { createStore } from "./store.js";
|
||||
// Time to wait to unsubscribe from updates after last subscriber unsubscribes
|
||||
const UNSUB_GRACE_PERIOD = 5000; // 5 seconds
|
||||
const DEBUG = false;
|
||||
/**
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export 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) {
|
||||
if (DEBUG) {
|
||||
console.log(`Prevented unsubscribe for ${key}`);
|
||||
}
|
||||
clearTimeout(unsubTimer);
|
||||
unsubTimer = undefined;
|
||||
return;
|
||||
}
|
||||
if (DEBUG) {
|
||||
console.log(`Subscribing to ${key}`);
|
||||
}
|
||||
if (subscribeUpdates) {
|
||||
unsubProm = subscribeUpdates(conn, store);
|
||||
}
|
||||
if (fetchCollection) {
|
||||
// Fetch when connection re-established.
|
||||
conn.addEventListener("ready", refreshSwallow);
|
||||
refreshSwallow();
|
||||
}
|
||||
conn.addEventListener("disconnected", handleDisconnect);
|
||||
};
|
||||
const teardownUpdateSubscription = () => {
|
||||
if (DEBUG) {
|
||||
console.log(`Unsubscribing from ${key}`);
|
||||
}
|
||||
unsubTimer = undefined;
|
||||
// Unsubscribe from changes
|
||||
if (unsubProm)
|
||||
unsubProm.then((unsub) => {
|
||||
unsub();
|
||||
});
|
||||
store.clearState();
|
||||
conn.removeEventListener("ready", refresh);
|
||||
conn.removeEventListener("disconnected", handleDisconnect);
|
||||
};
|
||||
const scheduleTeardownUpdateSubscription = () => {
|
||||
if (DEBUG) {
|
||||
console.log(`Scheduling unsubscribing from ${key}`);
|
||||
}
|
||||
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 (DEBUG) {
|
||||
console.log(`New subscriber for ${key}. Active subscribers: ${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 (DEBUG) {
|
||||
console.log(`Unsubscribe for ${key}. Active subscribers: ${active}`);
|
||||
}
|
||||
if (!active) {
|
||||
options.unsubGrace
|
||||
? scheduleTeardownUpdateSubscription()
|
||||
: teardownUpdateSubscription();
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
// @ts-ignore
|
||||
return conn[key];
|
||||
};
|
||||
// Legacy name. It gets a collection and subscribes.
|
||||
export const createCollection = (key, fetchCollection, subscribeUpdates, conn, onChange) => getCollection(conn, key, fetchCollection, subscribeUpdates).subscribe(onChange);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { Connection } from "./connection.js";
|
||||
import { HassEntity, HassServices, HassConfig, HassUser, HassServiceTarget } from "./types.js";
|
||||
export declare const getStates: (connection: Connection) => Promise<HassEntity[]>;
|
||||
export declare const getServices: (connection: Connection) => Promise<HassServices>;
|
||||
export declare const getConfig: (connection: Connection) => Promise<HassConfig>;
|
||||
export declare const getUser: (connection: Connection) => Promise<HassUser>;
|
||||
export declare const callService: (connection: Connection, domain: string, service: string, serviceData?: object, target?: HassServiceTarget, returnResponse?: boolean) => Promise<unknown>;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import * as messages from "./messages.js";
|
||||
export const getStates = (connection) => connection.sendMessagePromise(messages.states());
|
||||
export const getServices = (connection) => connection.sendMessagePromise(messages.services());
|
||||
export const getConfig = (connection) => connection.sendMessagePromise(messages.config());
|
||||
export const getUser = (connection) => connection.sendMessagePromise(messages.user());
|
||||
export const callService = (connection, domain, service, serviceData, target, returnResponse) => connection.sendMessagePromise(messages.callService(domain, service, serviceData, target, returnResponse));
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { HassConfig, UnsubscribeFunc } from "./types.js";
|
||||
import { Connection } from "./connection.js";
|
||||
export declare const configColl: (conn: Connection) => import("./collection.js").Collection<HassConfig>;
|
||||
export declare const subscribeConfig: (conn: Connection, onChange: (state: HassConfig) => void) => UnsubscribeFunc;
|
||||
export declare const STATE_NOT_RUNNING = "NOT_RUNNING";
|
||||
export declare const STATE_STARTING = "STARTING";
|
||||
export declare const STATE_RUNNING = "RUNNING";
|
||||
export declare const STATE_STOPPING = "STOPPING";
|
||||
export declare const STATE_FINAL_WRITE = "FINAL_WRITE";
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { getCollection } from "./collection.js";
|
||||
import { getConfig } from "./commands.js";
|
||||
function processComponentLoaded(state, event) {
|
||||
if (state === undefined)
|
||||
return null;
|
||||
return {
|
||||
components: state.components.concat(event.data.component),
|
||||
};
|
||||
}
|
||||
const fetchConfig = (conn) => getConfig(conn);
|
||||
const subscribeUpdates = (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()));
|
||||
export const configColl = (conn) => getCollection(conn, "_cnf", fetchConfig, subscribeUpdates);
|
||||
export const subscribeConfig = (conn, onChange) => configColl(conn).subscribe(onChange);
|
||||
export const STATE_NOT_RUNNING = "NOT_RUNNING";
|
||||
export const STATE_STARTING = "STARTING";
|
||||
export const STATE_RUNNING = "RUNNING";
|
||||
export const STATE_STOPPING = "STOPPING";
|
||||
export const STATE_FINAL_WRITE = "FINAL_WRITE";
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { ERR_CONNECTION_LOST } from "./errors.js";
|
||||
import { MessageBase } from "./types.js";
|
||||
import { HaWebSocket } from "./socket.js";
|
||||
import type { Auth } from "./auth.js";
|
||||
export type ConnectionOptions = {
|
||||
setupRetry: number;
|
||||
auth?: Auth;
|
||||
createSocket: (options: ConnectionOptions) => Promise<HaWebSocket>;
|
||||
};
|
||||
export type ConnectionEventListener = (conn: Connection, eventData?: any) => void;
|
||||
type Events = "ready" | "disconnected" | "reconnect-error";
|
||||
type SubscriptionUnsubscribe = () => Promise<void>;
|
||||
interface SubscribeEventCommmandInFlight<T> {
|
||||
resolve: (result?: any) => void;
|
||||
reject: (err: any) => void;
|
||||
callback: (ev: T) => void;
|
||||
subscribe: (() => Promise<SubscriptionUnsubscribe>) | undefined;
|
||||
unsubscribe: SubscriptionUnsubscribe;
|
||||
}
|
||||
type CommandWithAnswerInFlight = {
|
||||
resolve: (result?: any) => void;
|
||||
reject: (err: any) => void;
|
||||
};
|
||||
type CommandInFlight = SubscribeEventCommmandInFlight<any> | CommandWithAnswerInFlight;
|
||||
export declare class Connection {
|
||||
options: ConnectionOptions;
|
||||
commandId: number;
|
||||
commands: Map<number, CommandInFlight>;
|
||||
eventListeners: Map<string, ConnectionEventListener[]>;
|
||||
closeRequested: boolean;
|
||||
suspendReconnectPromise?: Promise<void>;
|
||||
oldSubscriptions?: Map<number, CommandInFlight>;
|
||||
_queuedMessages?: Array<{
|
||||
resolve: (value?: unknown) => unknown;
|
||||
reject?: (err: typeof ERR_CONNECTION_LOST) => unknown;
|
||||
}>;
|
||||
socket?: HaWebSocket;
|
||||
/**
|
||||
* Version string of the Home Assistant instance. Set to version of last connection while reconnecting.
|
||||
*/
|
||||
haVersion: string;
|
||||
constructor(socket: HaWebSocket, options: ConnectionOptions);
|
||||
get connected(): boolean;
|
||||
private _setSocket;
|
||||
addEventListener(eventType: Events, callback: ConnectionEventListener): void;
|
||||
removeEventListener(eventType: Events, callback: ConnectionEventListener): void;
|
||||
fireEvent(eventType: Events, eventData?: any): void;
|
||||
suspendReconnectUntil(suspendPromise: Promise<void>): void;
|
||||
suspend(): void;
|
||||
/**
|
||||
* Reconnect the websocket connection.
|
||||
* @param force discard old socket instead of gracefully closing it.
|
||||
*/
|
||||
reconnect(force?: boolean): void;
|
||||
close(): void;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
subscribeEvents<EventType>(callback: (ev: EventType) => void, eventType?: string): Promise<SubscriptionUnsubscribe>;
|
||||
ping(): Promise<unknown>;
|
||||
sendMessage(message: MessageBase, commandId?: number): void;
|
||||
sendMessagePromise<Result>(message: MessageBase): Promise<Result>;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
subscribeMessage<Result>(callback: (result: Result) => void, subscribeMessage: MessageBase, options?: {
|
||||
resubscribe?: boolean;
|
||||
preCheck?: () => boolean | Promise<boolean>;
|
||||
}): Promise<SubscriptionUnsubscribe>;
|
||||
private _handleMessage;
|
||||
private _handleClose;
|
||||
private _genCmdId;
|
||||
}
|
||||
export {};
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Connection that wraps a socket and provides an interface to interact with
|
||||
* the Home Assistant websocket API.
|
||||
*/
|
||||
import * as messages from "./messages.js";
|
||||
import { ERR_INVALID_AUTH, ERR_CONNECTION_LOST } from "./errors.js";
|
||||
const DEBUG = false;
|
||||
export class Connection {
|
||||
constructor(socket, options) {
|
||||
this._handleMessage = (event) => {
|
||||
let messageGroup = JSON.parse(event.data);
|
||||
if (!Array.isArray(messageGroup)) {
|
||||
messageGroup = [messageGroup];
|
||||
}
|
||||
messageGroup.forEach((message) => {
|
||||
if (DEBUG) {
|
||||
console.log("Received", 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(messages.unsubscribeEvents(message.id)).catch((err) => {
|
||||
if (DEBUG) {
|
||||
console.warn(` Error unsubsribing from unknown subscription ${message.id}`, 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;
|
||||
default:
|
||||
if (DEBUG) {
|
||||
console.warn("Unhandled message", message);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
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(messages.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;
|
||||
}
|
||||
if (DEBUG) {
|
||||
console.log("Trying to reconnect");
|
||||
}
|
||||
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, messages.subscribeEvents(eventType));
|
||||
}
|
||||
ping() {
|
||||
return this.sendMessagePromise(messages.ping());
|
||||
}
|
||||
sendMessage(message, commandId) {
|
||||
if (!this.connected) {
|
||||
throw ERR_CONNECTION_LOST;
|
||||
}
|
||||
if (DEBUG) {
|
||||
console.log("Sending", message);
|
||||
}
|
||||
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(messages.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;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { HassEntities, UnsubscribeFunc } from "./types.js";
|
||||
import { Connection } from "./connection.js";
|
||||
export declare const entitiesColl: (conn: Connection) => import("./collection.js").Collection<HassEntities>;
|
||||
export declare const subscribeEntities: (conn: Connection, onChange: (state: HassEntities) => void) => UnsubscribeFunc;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { getCollection } from "./collection.js";
|
||||
import { getStates } from "./commands.js";
|
||||
import { atLeastHaVersion } from "./util.js";
|
||||
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");
|
||||
export const entitiesColl = (conn) => atLeastHaVersion(conn.haVersion, 2022, 4, 0)
|
||||
? getCollection(conn, "_ent", undefined, subscribeUpdates)
|
||||
: getCollection(conn, "_ent", legacyFetchEntities, legacySubscribeUpdates);
|
||||
export const subscribeEntities = (conn, onChange) => entitiesColl(conn).subscribe(onChange);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export declare const ERR_CANNOT_CONNECT = 1;
|
||||
export declare const ERR_INVALID_AUTH = 2;
|
||||
export declare const ERR_CONNECTION_LOST = 3;
|
||||
export declare const ERR_HASS_HOST_REQUIRED = 4;
|
||||
export declare const ERR_INVALID_HTTPS_TO_HTTP = 5;
|
||||
export declare const ERR_INVALID_AUTH_CALLBACK = 6;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const ERR_CANNOT_CONNECT = 1;
|
||||
export const ERR_INVALID_AUTH = 2;
|
||||
export const ERR_CONNECTION_LOST = 3;
|
||||
export const ERR_HASS_HOST_REQUIRED = 4;
|
||||
export const ERR_INVALID_HTTPS_TO_HTTP = 5;
|
||||
export const ERR_INVALID_AUTH_CALLBACK = 6;
|
||||
+1124
File diff suppressed because it is too large.
Load diff
+1130
File diff suppressed because it is too large.
Load diff
+13
@@ -0,0 +1,13 @@
|
||||
import { Connection, ConnectionOptions } from "./connection.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./collection.js";
|
||||
export * from "./connection.js";
|
||||
export * from "./config.js";
|
||||
export * from "./services.js";
|
||||
export * from "./entities.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./socket.js";
|
||||
export * from "./types.js";
|
||||
export * from "./commands.js";
|
||||
export * from "./store.js";
|
||||
export declare function createConnection(options?: Partial<ConnectionOptions>): Promise<Connection>;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// JS extensions in imports allow tsc output to be consumed by browsers.
|
||||
import { createSocket } from "./socket.js";
|
||||
import { Connection } from "./connection.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./collection.js";
|
||||
export * from "./connection.js";
|
||||
export * from "./config.js";
|
||||
export * from "./services.js";
|
||||
export * from "./entities.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./socket.js";
|
||||
export * from "./types.js";
|
||||
export * from "./commands.js";
|
||||
export * from "./store.js";
|
||||
export 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;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { Error, HassServiceTarget } from "./types.js";
|
||||
export declare function auth(accessToken: string): {
|
||||
type: string;
|
||||
access_token: string;
|
||||
};
|
||||
export declare function supportedFeatures(): {
|
||||
type: string;
|
||||
id: number;
|
||||
features: {
|
||||
coalesce_messages: number;
|
||||
};
|
||||
};
|
||||
export declare function states(): {
|
||||
type: string;
|
||||
};
|
||||
export declare function config(): {
|
||||
type: string;
|
||||
};
|
||||
export declare function services(): {
|
||||
type: string;
|
||||
};
|
||||
export declare function user(): {
|
||||
type: string;
|
||||
};
|
||||
type ServiceCallMessage = {
|
||||
type: "call_service";
|
||||
domain: string;
|
||||
service: string;
|
||||
service_data?: object;
|
||||
target?: HassServiceTarget;
|
||||
return_response?: boolean;
|
||||
};
|
||||
export declare function callService(domain: string, service: string, serviceData?: object, target?: HassServiceTarget, returnResponse?: boolean): ServiceCallMessage;
|
||||
type SubscribeEventMessage = {
|
||||
type: "subscribe_events";
|
||||
event_type?: string;
|
||||
};
|
||||
export declare function subscribeEvents(eventType?: string): SubscribeEventMessage;
|
||||
export declare function unsubscribeEvents(subscription: number): {
|
||||
type: string;
|
||||
subscription: number;
|
||||
};
|
||||
export declare function ping(): {
|
||||
type: string;
|
||||
};
|
||||
export declare function error(code: Error, message: string): {
|
||||
type: string;
|
||||
success: boolean;
|
||||
error: {
|
||||
code: Error;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
export {};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
export function auth(accessToken) {
|
||||
return {
|
||||
type: "auth",
|
||||
access_token: accessToken,
|
||||
};
|
||||
}
|
||||
export function supportedFeatures() {
|
||||
return {
|
||||
type: "supported_features",
|
||||
id: 1, // Always the first message after auth
|
||||
features: { coalesce_messages: 1 },
|
||||
};
|
||||
}
|
||||
export function states() {
|
||||
return {
|
||||
type: "get_states",
|
||||
};
|
||||
}
|
||||
export function config() {
|
||||
return {
|
||||
type: "get_config",
|
||||
};
|
||||
}
|
||||
export function services() {
|
||||
return {
|
||||
type: "get_services",
|
||||
};
|
||||
}
|
||||
export function user() {
|
||||
return {
|
||||
type: "auth/current_user",
|
||||
};
|
||||
}
|
||||
export function callService(domain, service, serviceData, target, returnResponse) {
|
||||
const message = {
|
||||
type: "call_service",
|
||||
domain,
|
||||
service,
|
||||
target,
|
||||
return_response: returnResponse,
|
||||
};
|
||||
if (serviceData) {
|
||||
message.service_data = serviceData;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
export function subscribeEvents(eventType) {
|
||||
const message = {
|
||||
type: "subscribe_events",
|
||||
};
|
||||
if (eventType) {
|
||||
message.event_type = eventType;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
export function unsubscribeEvents(subscription) {
|
||||
return {
|
||||
type: "unsubscribe_events",
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
export function ping() {
|
||||
return {
|
||||
type: "ping",
|
||||
};
|
||||
}
|
||||
export function error(code, message) {
|
||||
return {
|
||||
type: "result",
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { HassServices, UnsubscribeFunc } from "./types.js";
|
||||
import { Connection } from "./connection.js";
|
||||
export declare const servicesColl: (conn: Connection) => import("./collection.js").Collection<HassServices>;
|
||||
export declare const subscribeServices: (conn: Connection, onChange: (state: HassServices) => void) => UnsubscribeFunc;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { getCollection } from "./collection.js";
|
||||
import { getServices } from "./commands.js";
|
||||
import { debounce } from "./util.js";
|
||||
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 = (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()));
|
||||
export const servicesColl = (conn) => getCollection(conn, "_srv", fetchServices, subscribeUpdates);
|
||||
export const subscribeServices = (conn, onChange) => servicesColl(conn).subscribe(onChange);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import type { ConnectionOptions } from "./connection.js";
|
||||
export declare const MSG_TYPE_AUTH_REQUIRED = "auth_required";
|
||||
export declare const MSG_TYPE_AUTH_INVALID = "auth_invalid";
|
||||
export declare const MSG_TYPE_AUTH_OK = "auth_ok";
|
||||
export interface HaWebSocket extends WebSocket {
|
||||
haVersion: string;
|
||||
}
|
||||
export declare function createSocket(options: ConnectionOptions): Promise<HaWebSocket>;
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Create a web socket connection with a Home Assistant instance.
|
||||
*/
|
||||
import { ERR_INVALID_AUTH, ERR_CANNOT_CONNECT, ERR_HASS_HOST_REQUIRED, } from "./errors.js";
|
||||
import * as messages from "./messages.js";
|
||||
import { atLeastHaVersion } from "./util.js";
|
||||
const DEBUG = false;
|
||||
export const MSG_TYPE_AUTH_REQUIRED = "auth_required";
|
||||
export const MSG_TYPE_AUTH_INVALID = "auth_invalid";
|
||||
export const MSG_TYPE_AUTH_OK = "auth_ok";
|
||||
export function createSocket(options) {
|
||||
if (!options.auth) {
|
||||
throw ERR_HASS_HOST_REQUIRED;
|
||||
}
|
||||
const auth = options.auth;
|
||||
// Start refreshing expired tokens even before the WS connection is open.
|
||||
// We know that we will need auth anyway.
|
||||
let authRefreshTask = auth.expired
|
||||
? auth.refreshAccessToken().then(() => {
|
||||
authRefreshTask = undefined;
|
||||
}, () => {
|
||||
authRefreshTask = undefined;
|
||||
})
|
||||
: undefined;
|
||||
// Convert from http:// -> ws://, https:// -> wss://
|
||||
const url = auth.wsUrl;
|
||||
if (DEBUG) {
|
||||
console.log("[Auth phase] Initializing", url);
|
||||
}
|
||||
function connect(triesLeft, promResolve, promReject) {
|
||||
if (DEBUG) {
|
||||
console.log("[Auth Phase] New connection", url);
|
||||
}
|
||||
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.expired) {
|
||||
await (authRefreshTask ? authRefreshTask : auth.refreshAccessToken());
|
||||
}
|
||||
socket.send(JSON.stringify(messages.auth(auth.accessToken)));
|
||||
}
|
||||
catch (err) {
|
||||
// Refresh token failed
|
||||
invalidAuth = err === ERR_INVALID_AUTH;
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
const handleMessage = async (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (DEBUG) {
|
||||
console.log("[Auth phase] Received", message);
|
||||
}
|
||||
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(messages.supportedFeatures()));
|
||||
}
|
||||
promResolve(socket);
|
||||
break;
|
||||
default:
|
||||
if (DEBUG) {
|
||||
// We already send response to this message when socket opens
|
||||
if (message.type !== MSG_TYPE_AUTH_REQUIRED) {
|
||||
console.warn("[Auth phase] Unhandled message", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
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));
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { UnsubscribeFunc } from "./types.js";
|
||||
type Listener<State> = (state: State) => void;
|
||||
type Action<State> = (state: State, ...args: any[]) => Partial<State> | Promise<Partial<State>> | null;
|
||||
type BoundAction<State> = (...args: any[]) => void;
|
||||
export type Store<State> = {
|
||||
state: State | undefined;
|
||||
action(action: Action<State>): BoundAction<State>;
|
||||
setState(update: Partial<State>, overwrite?: boolean): void;
|
||||
clearState(): void;
|
||||
subscribe(listener: Listener<State>): UnsubscribeFunc;
|
||||
};
|
||||
export declare const createStore: <State>(state?: State) => Store<State>;
|
||||
export {};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
export 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,
|
||||
};
|
||||
};
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
export type Error = 1 | 2 | 3 | 4;
|
||||
export type UnsubscribeFunc = () => void;
|
||||
export type MessageBase = {
|
||||
id?: number;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
export type Context = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
parent_id: string | null;
|
||||
};
|
||||
export type HassEventBase = {
|
||||
origin: string;
|
||||
time_fired: string;
|
||||
context: Context;
|
||||
};
|
||||
export type HassEvent = HassEventBase & {
|
||||
event_type: string;
|
||||
data: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
export type StateChangedEvent = HassEventBase & {
|
||||
event_type: "state_changed";
|
||||
data: {
|
||||
entity_id: string;
|
||||
new_state: HassEntity | null;
|
||||
old_state: HassEntity | null;
|
||||
};
|
||||
};
|
||||
export type HassConfig = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
elevation: number;
|
||||
radius: number;
|
||||
unit_system: {
|
||||
length: string;
|
||||
mass: string;
|
||||
volume: string;
|
||||
temperature: string;
|
||||
pressure: string;
|
||||
wind_speed: string;
|
||||
accumulated_precipitation: string;
|
||||
};
|
||||
location_name: string;
|
||||
time_zone: string;
|
||||
components: string[];
|
||||
config_dir: string;
|
||||
allowlist_external_dirs: string[];
|
||||
allowlist_external_urls: string[];
|
||||
version: string;
|
||||
config_source: string;
|
||||
recovery_mode: boolean;
|
||||
safe_mode: boolean;
|
||||
state: "NOT_RUNNING" | "STARTING" | "RUNNING" | "STOPPING" | "FINAL_WRITE";
|
||||
external_url: string | null;
|
||||
internal_url: string | null;
|
||||
currency: string;
|
||||
country: string | null;
|
||||
language: string;
|
||||
};
|
||||
export type HassEntityBase = {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
last_changed: string;
|
||||
last_updated: string;
|
||||
attributes: HassEntityAttributeBase;
|
||||
context: Context;
|
||||
};
|
||||
export type HassEntityAttributeBase = {
|
||||
friendly_name?: string;
|
||||
unit_of_measurement?: string;
|
||||
icon?: string;
|
||||
entity_picture?: string;
|
||||
supported_features?: number;
|
||||
hidden?: boolean;
|
||||
assumed_state?: boolean;
|
||||
device_class?: string;
|
||||
state_class?: string;
|
||||
restored?: boolean;
|
||||
};
|
||||
export type HassEntity = HassEntityBase & {
|
||||
attributes: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
export type HassEntities = {
|
||||
[entity_id: string]: HassEntity;
|
||||
};
|
||||
export type HassService = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
description_placeholders?: {
|
||||
[placeholder: string]: string;
|
||||
};
|
||||
target?: {} | null;
|
||||
fields: {
|
||||
[field_name: string]: {
|
||||
example?: string | boolean | number;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
advanced?: boolean;
|
||||
selector?: {};
|
||||
filter?: {
|
||||
supported_features?: number[];
|
||||
attribute?: Record<string, any[]>;
|
||||
};
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
response?: {
|
||||
optional: boolean;
|
||||
};
|
||||
};
|
||||
export type HassDomainServices = {
|
||||
[service_name: string]: HassService;
|
||||
};
|
||||
export type HassServices = {
|
||||
[domain: string]: HassDomainServices;
|
||||
};
|
||||
export type HassUser = {
|
||||
id: string;
|
||||
is_admin: boolean;
|
||||
is_owner: boolean;
|
||||
name: string;
|
||||
};
|
||||
export type HassServiceTarget = {
|
||||
entity_id?: string | string[];
|
||||
device_id?: string | string[];
|
||||
area_id?: string | string[];
|
||||
floor_id?: string | string[];
|
||||
label_id?: string | string[];
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// This file has no imports on purpose
|
||||
// So it can easily be consumed by other TS projects
|
||||
export {};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export declare function parseQuery<T>(queryString: string): T;
|
||||
export declare const debounce: <T extends (...args: any[]) => unknown>(func: T, wait: number, immediate?: boolean) => T;
|
||||
export declare const atLeastHaVersion: (version: string, major: number, minor: number, patch?: number) => boolean;
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
export 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
|
||||
export 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
export 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));
|
||||
};
|
||||
Reference in new issue
Block a user