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\/(?.+);base64,(?.+)/; 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(object: KeyValue, key: string, defaultValue: NoInfer): T { return object && object[key] !== undefined ? object[key] : defaultValue; } function getResponse( request: KeyValue | string, data: Zigbee2MQTTAPI[T], error?: string, ): Zigbee2MQTTResponse { if (error !== undefined) { const response: Zigbee2MQTTResponse = { 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 = { 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 { 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(arr: Type[][]): Type[] { return ([] as Type[]).concat(...arr); } function arrayUnique(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 { /** * 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, };