mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-06-21 04:31:44 +00:00
0182c4b32e
Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
433 lines
13 KiB
TypeScript
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,
|
|
};
|