Skip to content

Instantly share code, notes, and snippets.

@balloob
Last active May 16, 2020 15:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save balloob/2d07dd9b5fdd34af0141a666ee157393 to your computer and use it in GitHub Desktop.
Save balloob/2d07dd9b5fdd34af0141a666ee157393 to your computer and use it in GitHub Desktop.
Home Assistant Websocket Deno
// Connect to Home Assistant from Deno
// Video: https://twitter.com/balloob/status/1261550082521919488?s=19
//
// Example is built-in as CLI. Try it out:
// deno run --allow-net https://raw-path-to-gist <home assistant url> <long lived access token>
//
// To use in your own code:
// import { createConnection } from https://raw-path-to-gist
// const conn = await createConnection(urlOfHomeAssistant, accessToken)
import {
createConnection as hawsCreateConnection,
subscribeEntities,
createLongLivedTokenAuth,
ConnectionOptions,
ERR_HASS_HOST_REQUIRED,
ERR_INVALID_AUTH,
ERR_CANNOT_CONNECT,
HassEntities,
} from "https://unpkg.com/home-assistant-js-websocket@5/dist/index.js";
import * as messages from "https://unpkg.com/home-assistant-js-websocket@5/dist/messages.js";
import {
WebSocket,
connectWebSocket,
isWebSocketCloseEvent,
} from "https://deno.land/std/ws/mod.ts";
interface Events {
open: {};
message: { data: string };
close: {};
error: {};
}
type EventType = keyof Events;
const DEBUG = true;
export const MSG_TYPE_AUTH_REQUIRED = "auth_required";
export const MSG_TYPE_AUTH_INVALID = "auth_invalid";
export const MSG_TYPE_AUTH_OK = "auth_ok";
class JSWebSocket {
public haVersion?: string;
private _listeners: {
[type: string]: Array<(ev: any) => void>;
} = {};
// Trying to be fancy, TS doesn't like it.
// private _listeners: {
// [E in keyof Events]?: Array<(ev: Events[E]) => unknown>;
// } = {};
private _sock?: WebSocket;
private _sockProm: Promise<WebSocket>;
constructor(public endpoint: string) {
this._sockProm = connectWebSocket(endpoint);
this._sockProm.then(
(sock) => {
this._sock = sock;
this._reader();
this.emit("open", {});
},
(err) => {
if (DEBUG) {
console.error("Failed to connect", err);
}
this.emit("error", {});
}
);
}
emit<E extends EventType>(type: E, event: Events[E]) {
const handlers = this._listeners[type];
if (!handlers) {
return;
}
for (const handler of handlers) {
handler(event);
}
}
addEventListener<E extends EventType>(
type: E,
handler: (ev: Events[E]) => unknown
) {
let handlers = this._listeners[type];
if (!handlers) {
handlers = this._listeners[type] = [];
}
handlers.push(handler);
}
removeEventListener<E extends EventType>(
type: E,
handler: (ev: Events[E]) => unknown
) {
const handlers = this._listeners[type];
if (!handlers) {
return;
}
const index = handlers.indexOf(handler);
if (index != -1) {
handlers.splice(index);
}
}
send(body: string) {
if (
DEBUG &&
// When haVersion is set, we have authenticated.
// So we don't log the auth token.
this.haVersion
) {
console.log("SENDING", body);
}
this._sock!.send(body);
}
close() {
if (DEBUG) {
console.log("Close requested");
}
this._sock!.close();
}
async _reader() {
for await (const msg of this._sock!) {
if (typeof msg === "string") {
this.emit("message", { data: msg });
} else if (isWebSocketCloseEvent(msg)) {
this.emit("close", {});
}
}
}
}
// Copy of https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/socket.ts
export function createSocket(options: ConnectionOptions): Promise<JSWebSocket> {
if (!options.auth) {
throw ERR_HASS_HOST_REQUIRED;
}
const auth = options.auth;
const url = auth.wsUrl;
if (DEBUG) {
console.log("[Auth phase] Initializing", url);
}
function connect(
triesLeft: number,
promResolve: (socket: JSWebSocket) => void,
promReject: (err: Error) => void
) {
if (DEBUG) {
console.log("[Auth Phase] New connection", url);
}
const socket = new JSWebSocket(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 () => {
socket.send(JSON.stringify(messages.auth(auth.accessToken)));
};
const handleMessage = async (event: { data: string }) => {
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;
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)
);
}
export function createConnection(endpoint: string, token: string) {
const auth = createLongLivedTokenAuth(endpoint, token);
return hawsCreateConnection({
createSocket,
auth,
});
}
if (import.meta.main) {
const url = Deno.args[0];
const token = Deno.args[1];
let conn;
try {
conn = await createConnection(url, token);
} catch (err) {
if (err == ERR_CANNOT_CONNECT) {
console.error("Unable to connect");
Deno.exit(1);
}
if (err == ERR_INVALID_AUTH) {
console.error("Invalid authentication");
Deno.exit(1);
}
console.error("Unknown error", err);
Deno.exit(1);
}
function getDomain(entId: string) {
return entId.split(".", 1)[0];
}
function findLongest(values: string[]) {
return values.reduce((prev, cur) => Math.max(prev, cur.length), 0);
}
function logEntities(entities: HassEntities) {
console.log();
console.log();
const domains = ["sensor", "light", "switch"];
const entityIds = Object.keys(entities)
.filter((entId) => domains.includes(getDomain(entId)))
.sort();
const states = entityIds.map((entId) => {
const state = entities[entId];
return "unit_of_measurement" in state.attributes
? `${state.state} ${state.attributes.unit_of_measurement}`
: state.state;
});
const longestName = findLongest(entityIds) + 5;
const longestState = findLongest(states);
console.log("State as of", new Date().toLocaleTimeString());
for (let i = 0; i < entityIds.length; i++) {
console.log(
entityIds[i].padEnd(longestName),
states[i].padStart(longestState)
);
}
}
subscribeEntities(conn, logEntities);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment