UNPKG

@hakit/core

Version:

A collection of React hooks and helpers for Home Assistant to easily communicate with the Home Assistant WebSocket API.

374 lines (363 loc) 13.1 kB
#!/usr/bin/env node // scripts/sync-user-types/cli.ts import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; // scripts/sync-user-types/connection.ts import { createConnection, createLongLivedTokenAuth, getServices, getStates } from "home-assistant-js-websocket"; import WebSocket from "ws"; var MSG_TYPE_AUTH_REQUIRED = "auth_required"; var MSG_TYPE_AUTH_INVALID = "auth_invalid"; var MSG_TYPE_AUTH_OK = "auth_ok"; var ERR_CANNOT_CONNECT = 1; var ERR_INVALID_AUTH = 2; function createSocket(auth) { const url2 = auth.wsUrl; console.info( "[Auth phase] Initializing WebSocket connection to Home Assistant", url2 ); function connect2(triesLeft, promResolve, promReject) { console.info( `[Auth Phase] Connecting to Home Assistant... Tries left: ${triesLeft}`, url2 ); const socket = new WebSocket(url2, { rejectUnauthorized: false }); let invalidAuth = false; const closeMessage = (ev) => { let errorMessage2; if (ev && ev.code && ev.code !== 1e3) { errorMessage2 = `WebSocket connection to Home Assistant closed with code ${ev.code} and reason ${ev.reason}`; } closeOrError(errorMessage2); }; const errorMessage = (ev) => { socket.removeEventListener("close", closeMessage); let errMessage = "Disconnected from Home Assistant with a WebSocket error"; if (ev.message) { errMessage += ` with message: ${ev.message}`; } closeOrError(errMessage); }; const closeOrError = (errorText) => { if (errorText) { console.info( `WebSocket Connection to Home Assistant closed with an error: ${errorText}` ); } if (invalidAuth) { promReject(ERR_INVALID_AUTH); return; } if (triesLeft === 0) { promReject(ERR_CANNOT_CONNECT); return; } const newTries = triesLeft === -1 ? -1 : triesLeft - 1; setTimeout(() => connect2(newTries, promResolve, promReject), 1e3); }; const handleOpen = async () => { try { if (auth.expired) { await auth.refreshAccessToken(); } socket.send( JSON.stringify({ type: "auth", access_token: auth.accessToken }) ); } catch (err) { invalidAuth = err === ERR_INVALID_AUTH; socket.close(); } }; const handleMessage = (event) => { const message = JSON.parse(event.data); console.info( `[Auth phase] Received a message of type ${message.type}`, 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", errorMessage); socket.haVersion = message.ha_version; promResolve(socket); break; default: if (message.type !== MSG_TYPE_AUTH_REQUIRED) { console.info("[Auth phase] Unhandled message", message); } } }; socket.addEventListener("open", handleOpen); socket.addEventListener("message", handleMessage); socket.addEventListener("close", closeMessage); socket.addEventListener("error", errorMessage); } return new Promise((resolve, reject) => connect2(3, resolve, reject)); } async function connect(url2, token2) { try { const auth = createLongLivedTokenAuth(url2, token2); const connection = await createConnection({ auth, // @ts-expect-error - no way to fix this without providing an override for the types // as the websocket definition is different createSocket: () => createSocket(auth) }); const services = await getServices(connection); const states = await getStates(connection); connection.close(); return { services, states }; } catch (err) { console.error("err", err); throw new Error("Failed to connect to Home Assistant"); } } // scripts/sync-user-types/action-generator.ts import _ from "lodash"; // scripts/sync-user-types/constants.ts var DEFAULT_FILENAME = "supported-types.d.ts"; var REMAPPED_TYPES = { hs_color: `[number, number]`, rgb_color: `[number, number, number]`, rgbw_color: `[number, number, number, number]`, rgbww_color: `[number, number, number, number, number]`, group_members: `string[]`, media_content_id: `string | number`, color_temp_kelvin: `number`, white: "boolean", color_temp: `number`, xy_color: `[number, number]` }; // scripts/sync-user-types/action-generator.ts var resolveSelectorType = (selector) => { if (!selector) return "object"; const keys = Object.keys(selector); if (keys.includes("number")) return "number"; if (keys.includes("object")) return "object"; if (keys.includes("duration")) return `{ hours?: number; days?: number; minutes?: number; seconds?: number; }`; const stringTypes = [ "text", "entity", "datetime", "time", "date", "addon", "backup_location", "icon", "conversation_agent", "device", "theme" ]; const isStringType = stringTypes.some((type) => keys.includes(type)); if (isStringType) return "string"; if (keys.includes("boolean")) return "boolean"; if (keys.includes("select")) { const options = selector?.select?.options; if (!_.isArray(options) || options.length === 0) return "unknown"; return `${options.map((option) => `'${typeof option === "string" ? option : option.value}'`).join(" | ")}`; } return "unknown"; }; function sanitizeString(str) { return `${str}`.replace(/"/g, "'").replace(/[\n\r]+/g, " "); } var generateActionTypes = (input, { domainWhitelist: domainWhitelist2 = [], domainBlacklist: domainBlacklist2 = [], serviceWhitelist: serviceWhitelist2 = [], serviceBlacklist: serviceBlacklist2 = [] }) => { const interfaces = Object.entries(input).map(([domain, actions]) => { const camelDomain = _.camelCase(domain); if (domainBlacklist2.length > 0 && (domainBlacklist2.includes(camelDomain) || domainBlacklist2.includes(domain))) return ""; if (domainWhitelist2.length > 0 && (!domainWhitelist2.includes(camelDomain) || !domainWhitelist2.includes(domain))) return ""; const domainActions = Object.entries(actions).map(([action, { fields, description }]) => { const camelAction = _.camelCase(action); if (serviceBlacklist2.length > 0 && (serviceBlacklist2.includes(camelAction) || serviceBlacklist2.includes(action))) return ""; if (serviceWhitelist2.length > 0 && (!serviceWhitelist2.includes(camelAction) || !serviceWhitelist2.includes(action))) return ""; function processFields(fields2) { return Object.entries(fields2).map(([field, { selector, example, description: description2, ...rest }]) => { const required = rest.required ?? false; const remapByDomainActionField = `${domain}.${action}.${field}`; const remapByActionField = `${action}.${field}`; const remapByField = field; const domainActionFieldOverride = remapByDomainActionField in REMAPPED_TYPES ? REMAPPED_TYPES[remapByDomainActionField] : void 0; const actionFieldOverride = remapByActionField in REMAPPED_TYPES ? REMAPPED_TYPES[remapByActionField] : void 0; const fieldOverride = remapByField in REMAPPED_TYPES ? REMAPPED_TYPES[remapByField] : void 0; const _selector = selector; const overrides = domainActionFieldOverride || actionFieldOverride || fieldOverride; const type = typeof overrides === "string" ? overrides : resolveSelectorType(_selector); let constraints = ""; if (_.isObject(selector)) { const ignoredKeys = ["select", "entity", "theme", "constant", "text", "device"]; constraints = Object.entries(_selector || {}).filter(([_key, value]) => _key && _.isObject(value) && !ignoredKeys.includes(_key)).map(([key, value]) => ` ${key}: ${Object.entries(value || {}).map(([key2, value2]) => `${key2}: ${value2}`).join(", ")}`).join(", "); constraints = constraints ? ` @constraints ${constraints}` : ""; } const exampleUsage = example ? ` @example ${example}` : ""; const isAdvancedFields = field === "advanced_fields"; const comment = `${description2 ?? ""}${exampleUsage ?? ""}${constraints}`; return isAdvancedFields && "fields" in rest ? processFields(rest.fields).join("\n") : `//${comment ? sanitizeString(` ${comment}`) : ""} ${field}${required ? "" : "?"}: ${type};`; }); } const data = processFields(fields); const actionData = `${Object.keys(fields).length === 0 ? "object" : `{${data.join("\n")}}`}`; return `// ${sanitizeString(description)} ${camelAction}: ServiceFunction<object, T, ${actionData}>; `; }).join(""); const result = `${camelDomain}: { ${domainActions} } `; return result; }); return interfaces.join(""); }; // scripts/sync-user-types/entity-generator.ts var generateEntityType = (input) => { return input.map((e) => `'${e.entity_id}'`).join(" | "); }; // scripts/sync-user-types/index.ts import { writeFileSync } from "fs"; import { format } from "prettier"; async function typeSync({ url: url2, token: token2, outDir: _outDir, filename: filename2 = DEFAULT_FILENAME, domainWhitelist: domainWhitelist2 = [], domainBlacklist: domainBlacklist2 = [], serviceWhitelist: serviceWhitelist2 = [], serviceBlacklist: serviceBlacklist2 = [], custom = true, prettier }) { if (!url2 || !token2) { throw new Error("Missing url or token arguments"); } const warning = ` // this is an auto generated file, do not change this manually `; const { states, services } = await connect(url2, token2); const serviceInterfaces = await generateActionTypes(services, { domainWhitelist: domainWhitelist2, domainBlacklist: domainBlacklist2, serviceWhitelist: serviceWhitelist2, serviceBlacklist: serviceBlacklist2 }); const output = custom ? ` ${warning} import { ServiceFunction, ServiceFunctionTypes } from "@hakit/core"; declare module '@hakit/core' { export interface CustomSupportedServices<T extends ServiceFunctionTypes = "target"> { ${serviceInterfaces} } export interface CustomEntityNameContainer { names: ${generateEntityType(states)}; } } ` : ` ${warning} import type { ServiceFunctionTypes, ServiceFunction } from "./"; export interface DefaultServices<T extends ServiceFunctionTypes = "target"> { ${serviceInterfaces} } `; const outDir2 = _outDir || process.cwd(); const formatted = prettier?.disable ? output : await format(output, { parser: "typescript", ...prettier?.options }); writeFileSync(`${outDir2}/${filename2}`, formatted); console.info(`Succesfully generated types: ${outDir2}/${filename2} `); console.info(`IMPORTANT: Don't forget to add the "${filename2}" file to your tsconfig.app.json include array `); } // scripts/sync-user-types/cli.ts var argv = yargs(hideBin(process.argv)).option("url", { alias: "u", type: "string", requiresArg: true, description: "Homeassistant url" }).option("token", { alias: "t", requiresArg: true, type: "string", description: "Long lived access token from the bottom of your home assistant profile page" }).option("outDir", { alias: "o", requiresArg: false, type: "string", description: "Where the files should be written to, defaults to current working directory" }).option("filename", { alias: "n", requiresArg: false, type: "string", default: DEFAULT_FILENAME, description: "The filename for the generated file" }).option("serviceWhitelist", { alias: "sw", requiresArg: false, type: "array", description: "A whitelist of services to generate types for" }).option("serviceBlacklist", { alias: "sb", requiresArg: false, type: "array", description: "A blacklist of services to generate types for" }).option("domainWhitelist", { alias: "dw", requiresArg: false, type: "array", description: "A whitelist of domain to generate types for" }).option("domainBlacklist", { alias: "db", requiresArg: false, type: "array", description: "A blacklist of domain to generate types for" }).help().parseSync(); var { url, token, domainBlacklist = [], domainWhitelist = [], serviceBlacklist = [], serviceWhitelist = [], outDir, filename } = argv; async function main() { try { await typeSync({ url, token, serviceWhitelist, serviceBlacklist, domainWhitelist, domainBlacklist, outDir, filename }); } catch (e) { if (e instanceof Error) { console.info(e.message); } else { console.info("Error: ", e); } process.exit(1); } process.exit(0); } main();