Init Commit

This commit is contained in:
matt committed 2026-03-04 22:25:13 -08:00
commit 60188b0574
850 files changed
+570948

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### Terms and Conditions for use, reproduction, and distribution
#### 1. Definitions
“License” shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
“Licensor” shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
“Legal Entity” shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, “control” means **(i)** the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
outstanding shares, or **(iii)** beneficial ownership of such entity.
“You” (or “Your”) shall mean an individual or Legal Entity exercising
permissions granted by this License.
“Source” form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
“Object” form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
“Work” shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
“Derivative Works” shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
“Contribution” shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as “Not a Contribution.”
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
#### 2. Grant of Copyright License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.
#### 3. Grant of Patent License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
such license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by combination
of their Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this License
for that Work shall terminate as of the date such litigation is filed.
#### 4. Redistribution
You may reproduce and distribute copies of the Work or Derivative Works thereof
in any medium, with or without modifications, and in Source or Object form,
provided that You meet the following conditions:
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
this License; and
* **(b)** You must cause any modified files to carry prominent notices stating that You
changed the files; and
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source form
of the Work, excluding those notices that do not pertain to any part of the
Derivative Works; and
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
#### 5. Submission of Contributions
Unless You explicitly state otherwise, any Contribution intentionally submitted
for inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the terms of
any separate license agreement you may have executed with Licensor regarding
such Contributions.
#### 6. Trademarks
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
#### 7. Disclaimer of Warranty
Unless required by applicable law or agreed to in writing, Licensor provides the
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
including, without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise of
permissions under this License.
#### 8. Limitation of Liability
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License or
out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
any and all other commercial damages or losses), even if such Contributor has
been advised of the possibility of such damages.
#### 9. Accepting Warranty or Additional Liability
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
other liability obligations and/or rights consistent with this License. However,
in accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if You
agree to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
_END OF TERMS AND CONDITIONS_
### APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets `[]` replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same “printed page” as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+472
View File
@@ -0,0 +1,472 @@
# :aerial_tramway: JavaScript websocket client for Home Assistant
This is a websocket client written in JavaScript that allows retrieving authentication tokens and communicate with the Home Assistant websocket API. It can be used to integrate Home Assistant into your apps. It has 0 dependencies.
## Trying it out
Check [the demo](https://home-assistant.github.io/home-assistant-js-websocket/) ([source](https://github.com/home-assistant/home-assistant-js-websocket/blob/master/index.html)).
Clone this repository, then go to home-assistant-js-websocket folder and run the following commands:
```bash
yarn install
yarn build
npx http-server -o
# A browser will open, navigate to example.html
```
## Installation
```bash
# With npm
npm i home-assistant-js-websocket
# With yarn
yarn add home-assistant-js-websocket
```
## Usage
To initialize a connection, you need an authentication token for the instance that you want to connect to. This library implements the necessary steps to guide the user to authenticate your website with their Home Assistant instance and give you a token. All you need from the user is the url of their instance.
```js
// Example connect code
import {
getAuth,
createConnection,
subscribeEntities,
ERR_HASS_HOST_REQUIRED,
} from "home-assistant-js-websocket";
async function connect() {
let auth;
try {
// Try to pick up authentication after user logs in
auth = await getAuth();
} catch (err) {
if (err === ERR_HASS_HOST_REQUIRED) {
const hassUrl = prompt(
"What host to connect to?",
"http://localhost:8123",
);
// Redirect user to log in on their instance
auth = await getAuth({ hassUrl });
} else {
alert(`Unknown error: ${err}`);
return;
}
}
const connection = await createConnection({ auth });
subscribeEntities(connection, (ent) => console.log(ent));
}
connect();
```
### `getAuth()`
Use this method to get authentication from a server via OAuth2. This method will handle redirecting to an instance and fetching the token after the user successful logs in.
You can pass options using the syntax:
```js
getAuth({ hassUrl: "http://localhost:8123" });
```
| Option | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| hassUrl | The url where the Home Assistant instance can be reached. This option is needed so we know where to redirect the user for authentication. Once redirected back, it is not needed to pass this option in. |
| clientId | Client ID to use. Client IDs for Home Assistant is the url of your application. Defaults to domain of current page. Pass `null` if you are making requests on behalf of a system user. |
| redirectUrl | The url to redirect back to when the user has logged in. Defaults to current page. |
| saveTokens | Function to store an object containing the token information. |
| loadTokens | Function that returns a promise that resolves to previously stored token information object or undefined if no info available. |
| authCode | If you have an auth code received via other means, you can pass it in and it will be used to fetch tokens instead of going through the OAuth2 flow. |
| limitHassInstance | If set to true, allow only authentication credentials for the passed in `hassUrl` and `clientId`. Defaults to false. |
In certain instances `getAuth` will raise an error. These errors can be imported from the package:
```js
// When bundling your application
import {
ERR_HASS_HOST_REQUIRED,
ERR_INVALID_AUTH,
} from "home-assistant-js-websocket";
// When using the UMD build
HAWS.ERR_HASS_HOST_REQUIRED;
```
| Error | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ERR_HASS_HOST_REQUIRED` | You need to pass in `hassUrl` to `getAuth` to continue getting auth. This option is not needed when the user is redirected back after successfully logging in. |
| `ERR_INVALID_AUTH` | This error will be raised if the url contains an authorization code that is no longer valid. |
| `ERR_INVALID_HTTPS_TO_HTTP` | This error is raised if your code is being run from a secure context (hosted via https) and you're trying to fetch tokens from a Home Assistant instance via a non secure context (http). This is called [mixed active content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content#Mixed_active_content) and the browser forbids this. |
| `ERR_INVALID_AUTH_CALLBACK` | This error is raised if only credentials for the specified Home Assistant instance are allowed and the client ID or hassURL in the auth callback state do not match the expected ones. |
| Other errors | Unknown error! |
### `createConnection()`
You need to either provide `auth` or `createSocket` as options to createConnection:
```js
createConnection({ auth });
```
| Option | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| auth | Auth object to use to create a connection. |
| createSocket | Override the createSocket method with your own. `(options) => Promise<WebSocket>`. Needs to return a connection that is already authenticated. |
| setupRetry | Number of times to retry initial connection when it fails. Set to -1 for infinite retries. Default is 0 (no retries) |
Currently the following error codes can be raised by createConnection:
| Error | Description |
| ------------------ | --------------------------------------------------------- |
| ERR_CANNOT_CONNECT | If the client was unable to connect to the websocket API. |
| ERR_INVALID_AUTH | If the supplied authentication was invalid. |
You can import them into your code as follows:
```javascript
import {
ERR_CANNOT_CONNECT,
ERR_INVALID_AUTH,
} from "home-assistant-js-websocket";
```
### Automatic reconnecting
The connection object will automatically try to reconnect to the server when the connection gets lost. On reconnect, it will automatically resubscribe the event listeners.
#### Suspend reconnection
If you don't want to automatically try to reconnect to the server when the connection is lost, you can pass a promise to wait for. The connection will try to reconnect after the promise is resolved.
```javascript
connection.suspendReconnectUntil(
new Promise((resolve) => {
// When you want to try to reconnect again, resolve the promise.
resolve();
}),
);
```
When the suspend promise resolves until the connection is re-established, all messages being send will be delayed until the connection is established. If the first reconnect fails, the queued messages will be rejected.
#### Suspend connection
You can also actively close the connection and wait for a promise to resolve to reconnect again. This promise can be passed either with `suspendReconnectUntil` or with the `suspend` command itself.
If you don't provide a promise with either of these functions, an error will be thrown.
```javascript
connection.suspendReconnectUntil(
new Promise((resolve) => {
// When you want to try to reconnect again, resolve the promise.
resolve();
}),
);
connection.suspend();
```
or
```javascript
connection.suspend(
new Promise((resolve) => {
// When you want to try to reconnect again, resolve the promise.
resolve();
}),
);
```
#### Close connection
You can also close the connection, without any reconnect, with `connection.close()`.
#### Events
The `Connection` object implements three events related to the reconnecting logic.
| Event | Data | Description |
| --------------- | ---------- | -------------------------------------------------------------------------------------------------------- |
| ready | - | Fired when authentication is successful and the connection is ready to take commands. |
| disconnected | - | Fired when the connection is lost. |
| reconnect-error | Error code | Fired when we encounter a fatal error when trying to reconnect. Currently limited to `ERR_INVALID_AUTH`. |
You can attach and remove listeners as follows:
```javascript
function eventHandler(connection, data) {
console.log("Connection has been established again");
}
conn.addEventListener("ready", eventHandler);
conn.removeEventListener("ready", eventHandler);
```
### Entities
You can subscribe to the entities of Home Assistant. Your callback will be called when the entities are first loaded and on every change to the state of any of the entities after that. The callback will be called with a single object that contains the entities keyed by entity_id.
The function `subscribeEntities` will return an unsubscribe function.
```javascript
import { subscribeEntities } from "home-assistant-js-websocket";
// conn is the connection from earlier.
subscribeEntities(conn, (entities) => console.log("New entities!", entities));
```
You can also import the collection:
```javascript
import { entitiesColl } from "home-assistant-js-websocket";
// conn is the connection from earlier.
const coll = entitiesColl(conn);
console.log(coll.state);
await coll.refresh();
coll.subscribe((entities) => console.log(entities));
```
### Config
You can subscribe to the config of Home Assistant. Config can change when either a component gets loaded.
The function `subscribeConfig` will return an unsubscribe function.
```javascript
import { subscribeConfig } from "home-assistant-js-websocket";
// conn is the connection from earlier.
subscribeConfig(conn, (config) => console.log("New config!", config));
```
You can also import the collection:
```javascript
import { configColl } from "home-assistant-js-websocket";
// conn is the connection from earlier.
const coll = configColl(connection);
console.log(coll.state);
await coll.refresh();
coll.subscribe((config) => console.log(config));
```
### Services
You can subscribe to the available services of Home Assistant. Services can change when a new service gets registered or removed.
The function `subscribeServices` will return an unsubscribe function.
```javascript
import { subscribeServices } from "home-assistant-js-websocket";
// conn is the connection from earlier.
subscribeServices(conn, (services) => console.log("New services!", services));
```
You can also import the collection:
```javascript
import { servicesColl } from "home-assistant-js-websocket";
// conn is the connection from earlier.
const coll = servicesColl(connection);
console.log(coll.state);
await coll.refresh();
coll.subscribe((services) => console.log(services));
```
### Collections
Besides entities, config and services you might want to create your own collections. A collection has the following features:
- Fetch a full data set on initial creation and on reconnect
- Subscribe to events to keep collection up to date
- Share subscription between multiple listeners
- Unsubscribe when no listeners
- Manually trigger a refresh
```typescript
// Will only initialize one collection per connection.
getCollection<State>(
conn: Connection,
key: string,
fetchCollection: (conn: Connection) => Promise<State>,
subscribeUpdates: (
conn: Connection,
store: Store<State>
) => Promise<UnsubscribeFunc>,
): Collection<State>
// Returns object with following type
class Collection<State> {
state: State;
async refresh(): Promise<void>;
subscribe(subscriber: (state: State) => void): UnsubscribeFunc;
}
```
- `conn` is the connection to subscribe to.
- `key` a unique key for the collection
- `fetchCollection` needs to return a Promsise that resolves to the full state
- `subscribeUpdates` needs to subscribe to the updates and update the store. Returns a promise that resolves to an unsubscribe function.
#### Collection Example
```javascript
import { getCollection } from "home-assistant-js-websocket";
function panelRegistered(state, event) {
// Returning null means no change.
if (state === undefined) return null;
// This will be merged with the existing state.
return {
panels: state.panels.concat(event.data.panel),
};
}
const fetchPanels = (conn) => conn.sendMessagePromise({ type: "get_panels" });
const subscribeUpdates = (conn, store) =>
conn.subscribeEvents(store.action(panelRegistered), "panel_registered");
const panelsColl = getCollection(conn, "_pnl", fetchPanels, subscribeUpdates);
// Now use collection
console.log(panelsColl.state);
await panelsColl.refresh();
panelsColl.subscribe((panels) => console.log("New panels!", panels));
```
Collections are useful to define if data is needed for initial data load. You can create a collection and have code on your page call it before you start rendering the UI. By the time UI is loaded, the data will be available to use.
## Connection API Reference
A connection object is obtained by calling [`createConnection()`](#createconnection).
##### `conn.haVersion`
String containing the current version of Home Assistant. Examples:
- `0.107.0`
- `0.107.0b1`
- `0.107.0.dev0`
- `2021.3.0`
##### `conn.subscribeEvents(eventCallback[, eventType])`
Subscribe to all or specific events on the Home Assistant bus. Calls `eventCallback` for each event that gets received.
Returns a promise that will resolve to a function that will cancel the subscription once called.
Subscription will be automatically re-established after a reconnect.
Uses `conn.subscribeMessage` under the hood.
##### `conn.addEventListener(eventType, listener)`
Listen for events on the connection. [See docs.](#automatic-reconnecting)
##### `conn.sendMessagePromise(message)`
Send a message to the server. Returns a promise that resolves or rejects based on the result of the server. Special case rejection is `ERR_CONNECTION_LOST` if the connection is lost while the command is in progress.
##### `conn.subscribeMessage(callback, subscribeMessage[, options])`
Call an endpoint in Home Assistant that creates a subscription. Calls `callback` for each item that gets received.
Returns a promise that will resolve to a function that will cancel the subscription once called.
Subscription will be automatically re-established after a reconnect unless `options.resubscribe` is false.
| Option | Description |
| ----------- | ----------------------------------------------- |
| resubscribe | Re-established a subscription after a reconnect |
## Auth API Reference
An instance of Auth is returned from the `getAuth` method. It has the following properties:
- `wsUrl`: the websocket url of the instance
- `accessToken`: the access token
- `expired`: boolean that indicates if the access token has expired
##### `auth.refreshAccessToken()`
Fetches a new access token from the server.
##### `auth.revoke()`
Makes a request to the server to revoke the refresh and all related access token. Returns a promise that resolves when the request is finished.
**Note:** If you support storing and retrieving tokens, the returned auth object might load tokens from your cache that are no longer valid. If this happens, the promise returned by `createConnection` will reject with `ERR_INVALID_AUTH`. If that happens, clear your tokens with `storeTokens(null`) and call `getAuth` again. This will pick up the auth flow without relying on stored tokens.
## Error Reference
| Error constant | Error number |
| ------------------------- | ------------ |
| ERR_CANNOT_CONNECT | 1 |
| ERR_INVALID_AUTH | 2 |
| ERR_CONNECTION_LOST | 3 |
| ERR_HASS_HOST_REQUIRED | 4 |
| ERR_INVALID_HTTPS_TO_HTTP | 5 |
## Other methods
The library also contains a few helper method that you can use to ineract with the API.
- `getUser(connection) -> Promise<HassUser>`
- `callService(connection, domain, service, serviceData?, target?, returnResponse?) -> Promise` (Support for `target` was added in Home Assistant Core 2021.3)
The following are also available, but it's recommended that you use the subscribe methods documented above.
- `getStates(connection) -> Promise<HassEntity[]>`
- `getServices(connection) -> Promise<HassEntity[]>`
- `getConfig(connection) -> Promise<HassEntity[]>`
## Using this with long-lived access tokens
If you are in a browser, you should prefer to use the `getAuth()` flow. This will use the more secure refresh/access token pair. If that is not possible, you can ask the user to create a long-lived access token.
You will need to create your own auth object if you want to use this library with a long-lived access token.
```js
import {
Auth,
createConnection,
subscribeEntities,
createLongLivedTokenAuth,
} from "home-assistant-js-websocket";
(async () => {
const auth = createLongLivedTokenAuth(
"http://localhost:8123",
"YOUR ACCESS TOKEN",
);
const connection = await createConnection({ auth });
subscribeEntities(connection, (entities) => console.log(entities));
})();
```
## Using this in NodeJS
NodeJS does not have a WebSocket client built-in, but there are some good ones on NPM. We recommend ws. The easiest way to enable WebSocket is to polyfill it into the global namespace.
Look at https://github.com/keesschollaart81/vscode-home-assistant/blob/master/src/language-service/src/home-assistant/socket.ts as an example using ws.
If using TypeScript, you will need to install `@types/ws` as well.
```js
globalThis.WebSocket = require("ws");
```
or in TypeScript:
```ts
const wnd = globalThis;
wnd.WebSocket = require("ws");
```
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,3 @@
// This file has no imports on purpose
// So it can easily be consumed by other TS projects
export {};
+3
View File
@@ -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
View File
@@ -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));
};
+60
View File
@@ -0,0 +1,60 @@
{
"name": "home-assistant-js-websocket",
"type": "module",
"sideEffects": false,
"version": "9.6.0",
"description": "Home Assistant websocket client",
"source": "lib/index.ts",
"types": "dist/index.d.ts",
"main": "dist/haws.umd.js",
"module": "dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/haws.cjs",
"default": "./dist/haws.umd.js"
},
"./haws.cjs": "./dist/haws.cjs",
"./haws.umd.js": "./dist/haws.umd.js",
"./dist/*": "./dist/*"
},
"repository": {
"url": "https://github.com/home-assistant/home-assistant-js-websocket.git",
"type": "git"
},
"scripts": {
"watch": "tsc --watch",
"build": "tsc && rollup -c",
"test": "tsc && prettier --check . && mocha --loader=ts-node/esm",
"format": "prettier --write .",
"prepublishOnly": "rm -rf dist && yarn build && yarn test"
},
"author": "Paulus Schoutsen <paulus@paulusschoutsen.nl>",
"license": "Apache-2.0",
"devDependencies": {
"@types/assert": "^1.4.7",
"@types/mocha": "^10.0.9",
"assert": "^2.0.0",
"husky": "^4.2.5",
"lint-staged": "^16.0.0",
"mocha": "^11.1.0",
"prettier": "^3.0.0",
"rollup": "^4.3.0",
"ts-node": "^10.9.2",
"typescript": "^5.2.2"
},
"files": [
"dist"
],
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,js,json,css,md}": [
"prettier --write"
]
},
"packageManager": "yarn@4.7.0"
}