mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-06-25 14:42:03 +00:00
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import type * as zhc from 'zigbee-herdsman-converters';
|
|
|
|
import assert from 'assert';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import equals from 'fast-deep-equal/es6';
|
|
import humanizeDuration from 'humanize-duration';
|
|
|
|
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);
|
|
}
|
|
|
|
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
|
|
const git = await import('git-last-commit');
|
|
const packageJSON = await import('../..' + '/package.json');
|
|
|
|
if (!includeCommitHash) {
|
|
return {version: packageJSON.version, commitHash: undefined};
|
|
}
|
|
|
|
return await new Promise((resolve) => {
|
|
const version = packageJSON.version;
|
|
|
|
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
|
|
let commitHash = undefined;
|
|
|
|
if (err) {
|
|
try {
|
|
commitHash = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', '.hash'), 'utf-8');
|
|
} catch {
|
|
/* istanbul ignore next */
|
|
commitHash = 'unknown';
|
|
}
|
|
} else {
|
|
commitHash = commit.shortHash;
|
|
}
|
|
|
|
commitHash = commitHash.trim();
|
|
resolve({commitHash, version});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function getDependencyVersion(depend: string): Promise<{version: string}> {
|
|
const packageJsonPath = require.resolve(`${depend}/package.json`);
|
|
const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
|
|
return {version};
|
|
}
|
|
|
|
function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch' | 'relative'): string | number {
|
|
if (type === 'ISO_8601') return new Date(time).toISOString();
|
|
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
|
|
else if (type === 'epoch') return time;
|
|
else {
|
|
// 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(request: KeyValue | string, data: KeyValue, error?: string): MQTTResponse {
|
|
// On `error`, always return an empty `data` payload.
|
|
const response: MQTTResponse = {data: error ? {} : data, status: error ? 'error' : 'ok'};
|
|
|
|
if (error) {
|
|
response.error = error;
|
|
}
|
|
|
|
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 toSnakeCaseObject(value: KeyValue): KeyValue {
|
|
value = {...value};
|
|
for (const key of Object.keys(value)) {
|
|
const keySnakeCase = toSnakeCaseString(key);
|
|
assert(typeof keySnakeCase === 'string');
|
|
if (key !== keySnakeCase) {
|
|
value[keySnakeCase] = value[key];
|
|
delete value[key];
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function toSnakeCaseString(value: string): string {
|
|
return value
|
|
.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase())
|
|
.replace(/^_/, '')
|
|
.replace('_i_d', '_id');
|
|
}
|
|
|
|
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 (let item of fs.readdirSync(path_)) {
|
|
item = path.join(path_, item);
|
|
if (fs.lstatSync(item).isFile()) {
|
|
result.push(item);
|
|
} else {
|
|
result.push(...getAllFiles(item));
|
|
}
|
|
}
|
|
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 {
|
|
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
|
|
let sanitized = parameter;
|
|
replaceByDash.forEach((r) => (sanitized = sanitized.replace(r, '-')));
|
|
return sanitized;
|
|
}
|
|
|
|
function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settings): boolean {
|
|
if (entity.isDevice() && entity.options.disabled) {
|
|
return false;
|
|
}
|
|
|
|
if (entity.isGroup()) {
|
|
return !entity.membersDevices().some((d) => !isAvailabilityEnabledForEntity(d, settings));
|
|
}
|
|
|
|
if (entity.options.availability != null) {
|
|
return !!entity.options.availability;
|
|
}
|
|
|
|
if (!settings.availability) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
|
|
const minutes = (minutes: number): number => 1000 * 60 * minutes;
|
|
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';
|
|
}
|
|
|
|
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
|
|
const scenes: {[id: number]: Scene} = {};
|
|
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 = parseInt(split[0], 10);
|
|
const sceneGroupID = 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';
|
|
}
|
|
|
|
/* istanbul ignore next */
|
|
const noop = (): void => {};
|
|
|
|
export default {
|
|
capitalize,
|
|
getZigbee2MQTTVersion,
|
|
getDependencyVersion,
|
|
formatDate,
|
|
objectIsEmpty,
|
|
objectHasProperties,
|
|
equalsPartial,
|
|
getObjectProperty,
|
|
getResponse,
|
|
parseJSON,
|
|
removeNullPropertiesFromObject,
|
|
toNetworkAddressHex,
|
|
toSnakeCaseString,
|
|
toSnakeCaseObject,
|
|
isZHEndpoint,
|
|
isZHGroup,
|
|
hours,
|
|
minutes,
|
|
seconds,
|
|
validateFriendlyName,
|
|
sleep,
|
|
sanitizeImageParameter,
|
|
isAvailabilityEnabledForEntity,
|
|
publishLastSeen,
|
|
getAllFiles,
|
|
filterProperties,
|
|
flatten,
|
|
arrayUnique,
|
|
getScenes,
|
|
deviceNotCoordinator,
|
|
noop,
|
|
};
|