@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
JavaScript
// 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();