Files
2025-07-31 20:26:36 +02:00

433 lines
13 KiB
TypeScript

import assert from "node:assert";
import {exec} from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import equals from "fast-deep-equal/es6";
import humanizeDuration from "humanize-duration";
import type * as zhc from "zigbee-herdsman-converters";
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from "../types/api";
import data from "./data";
const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;
export const DEFAULT_BIND_GROUP_ID = 901;
function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? "0" : "") + norm;
}
// construct a local ISO8601 string (instead of UTC-based)
// Example:
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
// - ISO8601 (local) = 2019-03-01T16:32:45.941+0100 (for timezone GMT+1)
function toLocalISOString(date: Date): string {
const tzOffset = -date.getTimezoneOffset();
const plusOrMinus = tzOffset >= 0 ? "+" : "-";
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${plusOrMinus}${pad(tzOffset / 60)}:${pad(tzOffset % 60)}`;
}
function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1);
}
export async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
const packageJSON = (await import("../../package.json", {with: {type: "json"}})).default;
const version = packageJSON.version;
let commitHash: string | undefined;
if (!includeCommitHash) {
return {version, commitHash};
}
return await new Promise((resolve) => {
exec("git rev-parse --short=8 HEAD", (error, stdout) => {
commitHash = stdout.trim();
if (error || commitHash === "") {
try {
commitHash = fs.readFileSync(path.join(__dirname, "..", "..", "dist", ".hash"), "utf-8");
} catch {
commitHash = "unknown";
}
}
resolve({commitHash, version});
});
});
}
async function getDependencyVersion(depend: string): Promise<{version: string}> {
const packageJSON = (await import(`${depend}/package.json`, {with: {type: "json"}})).default;
return {version: packageJSON.version};
}
function formatDate(time: number, type: "ISO_8601" | "ISO_8601_local" | "epoch" | "relative"): string | number {
switch (type) {
case "ISO_8601":
// ISO8601 (UTC) = 2019-03-01T15:32:45.941Z
return new Date(time).toISOString();
case "ISO_8601_local":
// ISO8601 (local) = 2019-03-01T16:32:45.941+01:00 (for timezone GMT+1)
return toLocalISOString(new Date(time));
case "epoch":
return time;
default:
// relative
return `${humanizeDuration(Date.now() - time, {language: "en", largest: 2, round: true})} ago`;
}
}
function objectIsEmpty(object: object): boolean {
// much faster than checking `Object.keys(object).length`
for (const _k in object) return false;
return true;
}
function objectHasProperties(object: {[s: string]: unknown}, properties: string[]): boolean {
for (const property of properties) {
if (object[property] === undefined) {
return false;
}
}
return true;
}
function equalsPartial(object: KeyValue, expected: KeyValue): boolean {
for (const [key, value] of Object.entries(expected)) {
if (!equals(object[key], value)) {
return false;
}
}
return true;
}
function getObjectProperty<T>(object: KeyValue, key: string, defaultValue: NoInfer<T>): T {
return object && object[key] !== undefined ? object[key] : defaultValue;
}
function getResponse<T extends Zigbee2MQTTResponseEndpoints>(
request: KeyValue | string,
data: Zigbee2MQTTAPI[T],
error?: string,
): Zigbee2MQTTResponse<T> {
if (error !== undefined) {
const response: Zigbee2MQTTResponse<T> = {
data: {}, // always return an empty `data` payload on error
status: "error",
error: error,
};
if (typeof request === "object" && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
}
const response: Zigbee2MQTTResponse<T> = {
data, // valid from error check
status: "ok",
};
if (typeof request === "object" && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
}
function parseJSON(value: string, fallback: string): KeyValue | string {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
/**
* Delete all keys from passed object that have null/undefined values.
*
* @param {KeyValue} obj Object to process (in-place)
* @param {string[]} [ignoreKeys] Recursively ignore these keys in the object (keep null/undefined values).
*/
function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []): void {
for (const key of Object.keys(obj)) {
if (ignoreKeys.includes(key)) {
continue;
}
const value = obj[key];
if (value == null) {
delete obj[key];
} else if (typeof value === "object") {
removeNullPropertiesFromObject(value, ignoreKeys);
}
}
}
function toNetworkAddressHex(value: number): string {
const hex = value.toString(16);
return `0x${"0".repeat(4 - hex.length)}${hex}`;
}
function charRange(start: string, stop: string): number[] {
const result = [];
for (let idx = start.charCodeAt(0), end = stop.charCodeAt(0); idx <= end; ++idx) {
result.push(idx);
}
return result;
}
const controlCharacters = [...charRange("\u0000", "\u001F"), ...charRange("\u007f", "\u009F"), ...charRange("\ufdd0", "\ufdef")];
function containsControlCharacter(str: string): boolean {
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
if (controlCharacters.includes(ch) || [0xfffe, 0xffff].includes(ch & 0xffff)) {
return true;
}
}
return false;
}
function getAllFiles(path_: string): string[] {
const result = [];
for (const item of fs.readdirSync(path_, {withFileTypes: true})) {
if (item.isSymbolicLink()) {
continue;
}
const fileName = path.join(path_, item.name);
if (fs.lstatSync(fileName).isFile()) {
result.push(fileName);
} else {
result.push(...getAllFiles(fileName));
}
}
return result;
}
function validateFriendlyName(name: string, throwFirstError = false): string[] {
const errors = [];
if (name.length === 0) errors.push("friendly_name must be at least 1 char long");
if (name.endsWith("/") || name.startsWith("/")) errors.push("friendly_name is not allowed to end or start with /");
if (containsControlCharacter(name)) errors.push("friendly_name is not allowed to contain control char");
if (name.match(/.*\/\d*$/)) errors.push(`Friendly name cannot end with a "/DIGIT" ('${name}')`);
if (name.includes("#") || name.includes("+")) {
errors.push(`MQTT wildcard (+ and #) not allowed in friendly_name ('${name}')`);
}
if (throwFirstError && errors.length) {
throw new Error(errors[0]);
}
return errors;
}
function sleep(seconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
function sanitizeImageParameter(parameter: string): string {
return parameter.replace(/\?|&|[^a-z\d\- _./:]/gi, "-");
}
function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settings): boolean {
if (entity.isDevice() && entity.options.disabled) {
return false;
}
if (entity.isGroup()) {
for (const memberDevice of entity.membersDevices()) {
if (!isAvailabilityEnabledForEntity(memberDevice, settings)) {
return false;
}
}
return true;
}
if (entity.options.availability != null) {
return !!entity.options.availability;
}
return settings.availability.enabled;
}
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
return obj?.constructor.name.toLowerCase() === "endpoint";
}
function flatten<Type>(arr: Type[][]): Type[] {
return ([] as Type[]).concat(...arr);
}
function arrayUnique<Type>(arr: Type[]): Type[] {
return [...new Set(arr)];
}
function isZHGroup(obj: unknown): obj is zh.Group {
return obj?.constructor.name.toLowerCase() === "group";
}
export const hours = (hours: number): number => 1000 * 60 * 60 * hours;
export const minutes = (minutes: number): number => 1000 * 60 * minutes;
export const seconds = (seconds: number): number => 1000 * seconds;
async function publishLastSeen(
data: eventdata.LastSeenChanged,
settings: Settings,
allowMessageEmitted: boolean,
publishEntityState: PublishEntityState,
): Promise<void> {
/**
* Prevent 2 MQTT publishes when 1 message event is received;
* - In case reason == messageEmitted, receive.ts will only call this when it did not publish a
* message based on the received zigbee message. In this case allowMessageEmitted has to be true.
* - In case reason !== messageEmitted, controller.ts will call this based on the zigbee-herdsman
* lastSeenChanged event.
*/
const allow = data.reason !== "messageEmitted" || (data.reason === "messageEmitted" && allowMessageEmitted);
if (settings.advanced.last_seen && settings.advanced.last_seen !== "disable" && allow) {
await publishEntityState(data.device, {}, "lastSeenChanged");
}
}
function filterProperties(filter: string[] | undefined, data: KeyValue): void {
if (filter) {
for (const property of Object.keys(data)) {
if (filter.find((p) => property.match(`^${p}$`))) {
delete data[property];
}
}
}
}
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
return expose?.type === "numeric";
}
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
assert(expose?.type === "enum");
}
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
assert(expose?.type === "numeric");
}
export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary {
assert(expose?.type === "binary");
}
export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum {
return expose?.type === "enum";
}
export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary {
return expose?.type === "binary";
}
export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
return expose.type === "light";
}
export function assertString(value: unknown, property: string): asserts value is string {
if (typeof value !== "string") {
throw new Error(`${property} is not a string, got ${typeof value} (${value})`);
}
}
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
const scenes: {[id: number]: Zigbee2MQTTScene} = {};
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
const groupID = isZHEndpoint(entity) ? 0 : entity.groupID;
for (const endpoint of endpoints) {
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
const split = key.split("_");
const sceneID = Number.parseInt(split[0], 10);
const sceneGroupID = Number.parseInt(split[1], 10);
if (sceneGroupID === groupID) {
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
}
}
}
return Object.values(scenes);
}
function deviceNotCoordinator(device: zh.Device): boolean {
return device.type !== "Coordinator";
}
function matchBase64File(value: string | undefined): {extension: string; data: string} | false {
if (value !== undefined) {
const match = value.match(BASE64_IMAGE_REGEX);
if (match) {
assert(match.groups?.extension && match.groups?.data);
return {extension: match.groups.extension, data: match.groups.data};
}
}
return false;
}
function saveBase64DeviceIcon(base64Match: {extension: string; data: string}): string {
const md5Hash = crypto.createHash("md5").update(base64Match.data).digest("hex");
const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`;
const file = path.join(data.getPath(), fileSettings);
fs.mkdirSync(path.dirname(file), {recursive: true});
fs.writeFileSync(file, base64Match.data, {encoding: "base64"});
return fileSettings;
}
/* v8 ignore next */
const noop = (): void => {};
export default {
matchBase64File,
saveBase64DeviceIcon,
capitalize,
getZigbee2MQTTVersion,
getDependencyVersion,
formatDate,
objectIsEmpty,
objectHasProperties,
equalsPartial,
getObjectProperty,
getResponse,
parseJSON,
removeNullPropertiesFromObject,
toNetworkAddressHex,
isZHEndpoint,
isZHGroup,
hours,
minutes,
seconds,
validateFriendlyName,
sleep,
sanitizeImageParameter,
isAvailabilityEnabledForEntity,
publishLastSeen,
getAllFiles,
filterProperties,
flatten,
arrayUnique,
getScenes,
deviceNotCoordinator,
noop,
};