Last active
May 16, 2020 15:04
-
-
Save balloob/2d07dd9b5fdd34af0141a666ee157393 to your computer and use it in GitHub Desktop.
Home Assistant Websocket Deno
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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