chore: Implement prettier (#23153)

* chore: Implement prettier

* Run prettier

* fix lint

* process feedback

* process feedback
This commit is contained in:
Koen Kanters 2024-06-24 20:58:47 +02:00 committed by GitHub
parent 8780ab2792
commit 30227a13ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 10687 additions and 5360 deletions

View File

@ -5,21 +5,19 @@ module.exports = {
'es6': true, 'es6': true,
'node': true, 'node': true,
}, },
'extends': ['eslint:recommended', 'google', 'plugin:jest/recommended', 'plugin:jest/style'], 'extends': ['eslint:recommended', 'plugin:jest/recommended', 'plugin:jest/style', 'prettier'],
'parserOptions': { 'parserOptions': {
'ecmaVersion': 2018, 'ecmaVersion': 2018,
'sourceType': 'module', 'sourceType': 'module',
}, },
'rules': { 'rules': {
'require-jsdoc': 'off', 'require-jsdoc': 'off',
'indent': ['error', 4],
'max-len': ['error', {'code': 150}],
'no-prototype-builtins': 'off', 'no-prototype-builtins': 'off',
'linebreak-style': ['error', (process.platform === 'win32' ? 'windows' : 'unix')], // https://stackoverflow.com/q/39114446/2771889
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
}, },
'plugins': [ 'plugins': [
'jest', 'jest',
'perfectionist',
], ],
'overrides': [{ 'overrides': [{
files: ['*.ts'], files: ['*.ts'],
@ -35,14 +33,46 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'error', '@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/semi': ['error'],
'array-bracket-spacing': ['error', 'never'],
'indent': ['error', 4],
'max-len': ['error', {'code': 150}],
'no-return-await': 'error',
'object-curly-spacing': ['error', 'never'],
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'no-return-await': 'error',
"perfectionist/sort-imports": [
"error",
{
"groups": [
"type",
[
"builtin",
"external"
],
"internal-type",
"internal",
[
"parent-type",
"sibling-type",
"index-type"
],
[
"parent",
"sibling",
"index"
],
"object",
"unknown"
],
"custom-groups": {
"value": {},
"type": {}
},
"newlines-between": "always",
"internal-pattern": [
"~/**"
],
"type": "natural",
"order": "asc",
"ignore-case": false
}
],
}, },
}], }],
}; };

View File

@ -26,10 +26,12 @@ jobs:
run: npm ci run: npm ci
- name: Build - name: Build
run: npm run build run: npm run build
- name: Lint
run: |
npm run pretty:check
npm run eslint
- name: Test - name: Test
run: npm run test-with-coverage run: npm run test-with-coverage
- name: Lint
run: npm run eslint
- name: Docker login - name: Docker login
if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push' if: (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
run: echo ${{ secrets.DOCKER_KEY }} | docker login -u koenkk --password-stdin run: echo ${{ secrets.DOCKER_KEY }} | docker login -u koenkk --password-stdin

9
.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 150,
"bracketSpacing": false,
"endOfLine": "lf",
"tabWidth": 4
}

View File

@ -1,46 +1,67 @@
import MQTT from './mqtt'; import assert from 'assert';
import Zigbee from './zigbee'; import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import {setLogger as zhSetLogger} from 'zigbee-herdsman';
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
import EventBus from './eventBus'; import EventBus from './eventBus';
import ExtensionAvailability from './extension/availability';
import ExtensionBind from './extension/bind';
import ExtensionBridge from './extension/bridge';
import ExtensionConfigure from './extension/configure';
import ExtensionExternalConverters from './extension/externalConverters';
import ExtensionExternalExtension from './extension/externalExtension';
// Extensions
import ExtensionFrontend from './extension/frontend';
import ExtensionGroups from './extension/groups';
import ExtensionHomeAssistant from './extension/homeassistant';
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
import ExtensionReport from './extension/legacy/report';
import ExtensionSoftReset from './extension/legacy/softReset';
import ExtensionNetworkMap from './extension/networkMap';
import ExtensionOnEvent from './extension/onEvent';
import ExtensionOTAUpdate from './extension/otaUpdate';
import ExtensionPublish from './extension/publish';
import ExtensionReceive from './extension/receive';
import MQTT from './mqtt';
import State from './state'; import State from './state';
import logger from './util/logger'; import logger from './util/logger';
import * as settings from './util/settings'; import * as settings from './util/settings';
import utils from './util/utils'; import utils from './util/utils';
import stringify from 'json-stable-stringify-without-jsonify'; import Zigbee from './zigbee';
import assert from 'assert';
import bind from 'bind-decorator';
import {setLogger as zhcSetLogger} from 'zigbee-herdsman-converters';
import {setLogger as zhSetLogger} from 'zigbee-herdsman';
// Extensions
import ExtensionFrontend from './extension/frontend';
import ExtensionPublish from './extension/publish';
import ExtensionReceive from './extension/receive';
import ExtensionNetworkMap from './extension/networkMap';
import ExtensionSoftReset from './extension/legacy/softReset';
import ExtensionHomeAssistant from './extension/homeassistant';
import ExtensionConfigure from './extension/configure';
import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMembership';
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
import ExtensionBridge from './extension/bridge';
import ExtensionGroups from './extension/groups';
import ExtensionAvailability from './extension/availability';
import ExtensionBind from './extension/bind';
import ExtensionReport from './extension/legacy/report';
import ExtensionOnEvent from './extension/onEvent';
import ExtensionOTAUpdate from './extension/otaUpdate';
import ExtensionExternalConverters from './extension/externalConverters';
import ExtensionExternalExtension from './extension/externalExtension';
const AllExtensions = [ const AllExtensions = [
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant, ExtensionPublish,
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups, ExtensionReceive,
ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate, ExtensionNetworkMap,
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension, ExtensionAvailability, ExtensionSoftReset,
ExtensionHomeAssistant,
ExtensionConfigure,
ExtensionDeviceGroupMembership,
ExtensionBridgeLegacy,
ExtensionBridge,
ExtensionGroups,
ExtensionBind,
ExtensionReport,
ExtensionOnEvent,
ExtensionOTAUpdate,
ExtensionExternalConverters,
ExtensionFrontend,
ExtensionExternalExtension,
ExtensionAvailability,
]; ];
type ExtensionArgs = [Zigbee, MQTT, State, PublishEntityState, EventBus, type ExtensionArgs = [
enableDisableExtension: (enable: boolean, name: string) => Promise<void>, restartCallback: () => Promise<void>, Zigbee,
addExtension: (extension: Extension) => Promise<void>]; MQTT,
State,
PublishEntityState,
EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let sdNotify: any = null; let sdNotify: any = null;
@ -72,8 +93,16 @@ export class Controller {
this.exitCallback = exitCallback; this.exitCallback = exitCallback;
// Initialize extensions. // Initialize extensions.
this.extensionArgs = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, this.extensionArgs = [
this.enableDisableExtension, this.restartCallback, this.addExtension]; this.zigbee,
this.mqtt,
this.state,
this.publishEntityState,
this.eventBus,
this.enableDisableExtension,
this.restartCallback,
this.addExtension,
];
this.extensions = [ this.extensions = [
new ExtensionOnEvent(...this.extensionArgs), new ExtensionOnEvent(...this.extensionArgs),
@ -111,7 +140,7 @@ export class Controller {
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected); this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
} catch (error) { } catch (error) {
logger.error('Failed to start zigbee'); logger.error('Failed to start zigbee');
logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions'); /* eslint-disable-line max-len */ logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions');
logger.error('Exiting...'); logger.error('Exiting...');
logger.error(error.stack); logger.error(error.stack);
return this.exit(1); return this.exit(1);
@ -130,9 +159,9 @@ export class Controller {
const devices = this.zigbee.devices(false); const devices = this.zigbee.devices(false);
logger.info(`Currently ${devices.length} devices are joined:`); logger.info(`Currently ${devices.length} devices are joined:`);
for (const device of devices) { for (const device of devices) {
const model = device.isSupported ? const model = device.isSupported
`${device.definition.model} - ${device.definition.vendor} ${device.definition.description}` : ? `${device.definition.model} - ${device.definition.vendor} ${device.definition.description}`
'Not supported'; : 'Not supported';
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`); logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
} }
@ -170,8 +199,7 @@ export class Controller {
} }
} }
this.eventBus.onLastSeenChanged(this, this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
(data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
logger.info(`Zigbee2MQTT started!`); logger.info(`Zigbee2MQTT started!`);
@ -237,8 +265,7 @@ export class Controller {
await this.stop(); await this.stop();
} }
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, @bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
stateChangeReason?: StateChangeReason): Promise<void> {
let message = {...payload}; let message = {...payload};
// Update state cache with new state. // Update state cache with new state.
@ -261,12 +288,18 @@ export class Controller {
if (entity.isDevice() && settings.get().mqtt.include_device_information) { if (entity.isDevice() && settings.get().mqtt.include_device_information) {
message.device = { message.device = {
friendlyName: entity.name, model: entity.definition?.model, friendlyName: entity.name,
ieeeAddr: entity.ieeeAddr, networkAddress: entity.zh.networkAddress, type: entity.zh.type, model: entity.definition?.model,
ieeeAddr: entity.ieeeAddr,
networkAddress: entity.zh.networkAddress,
type: entity.zh.type,
manufacturerID: entity.zh.manufacturerID, manufacturerID: entity.zh.manufacturerID,
powerSource: entity.zh.powerSource, applicationVersion: entity.zh.applicationVersion, powerSource: entity.zh.powerSource,
stackVersion: entity.zh.stackVersion, zclVersion: entity.zh.zclVersion, applicationVersion: entity.zh.applicationVersion,
hardwareVersion: entity.zh.hardwareVersion, dateCode: entity.zh.dateCode, stackVersion: entity.zh.stackVersion,
zclVersion: entity.zh.zclVersion,
hardwareVersion: entity.zh.hardwareVersion,
dateCode: entity.zh.dateCode,
softwareBuildID: entity.zh.softwareBuildID, softwareBuildID: entity.zh.softwareBuildID,
// Manufacturer name can contain \u0000, remove this. // Manufacturer name can contain \u0000, remove this.
// https://github.com/home-assistant/core/issues/85691 // https://github.com/home-assistant/core/issues/85691

View File

@ -1,11 +1,12 @@
import events from 'events'; import events from 'events';
import logger from './util/logger'; import logger from './util/logger';
// eslint-disable-next-line // eslint-disable-next-line
type ListenerKey = object; type ListenerKey = object;
export default class EventBus { export default class EventBus {
private callbacksByExtension: { [s: string]: { event: string, callback: (...args: unknown[]) => void }[] } = {}; private callbacksByExtension: {[s: string]: {event: string; callback: (...args: unknown[]) => void}[]} = {};
private emitter = new events.EventEmitter(); private emitter = new events.EventEmitter();
constructor() { constructor() {
@ -57,8 +58,7 @@ export default class EventBus {
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void { public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
this.emitter.emit('deviceNetworkAddressChanged', data); this.emitter.emit('deviceNetworkAddressChanged', data);
} }
public onDeviceNetworkAddressChanged( public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
this.on('deviceNetworkAddressChanged', callback, key); this.on('deviceNetworkAddressChanged', callback, key);
} }
@ -167,7 +167,7 @@ export default class EventBus {
this.on('stateChange', callback, key); this.on('stateChange', callback, key);
} }
private on(event: string, callback: (...args: unknown[]) => (Promise<void> | void), key: ListenerKey): void { private on(event: string, callback: (...args: unknown[]) => Promise<void> | void, key: ListenerKey): void {
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = []; if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
const wrappedCallback = async (...args: unknown[]): Promise<void> => { const wrappedCallback = async (...args: unknown[]): Promise<void> => {
try { try {
@ -182,7 +182,6 @@ export default class EventBus {
} }
public removeListeners(key: ListenerKey): void { public removeListeners(key: ListenerKey): void {
this.callbacksByExtension[key.constructor.name]?.forEach( this.callbacksByExtension[key.constructor.name]?.forEach((e) => this.emitter.removeListener(e.event, e.callback));
(e) => this.emitter.removeListener(e.event, e.callback));
} }
} }

View File

@ -1,12 +1,13 @@
import Extension from './extension';
import logger from '../util/logger';
import utils from '../util/utils';
import * as settings from '../util/settings';
import debounce from 'debounce';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import debounce from 'debounce';
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
const RETRIEVE_ON_RECONNECT: readonly {keys: string[], condition?: (state: KeyValue) => boolean}[] = [ import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
{keys: ['state']}, {keys: ['state']},
{keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'}, {keys: ['brightness'], condition: (state: KeyValue): boolean => state.state === 'ON'},
{keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'}, {keys: ['color', 'color_temp'], condition: (state: KeyValue): boolean => state.state === 'ON'},
@ -40,8 +41,9 @@ export default class Availability extends Extension {
} }
private isAvailable(entity: Device | Group): boolean { private isAvailable(entity: Device | Group): boolean {
return entity.isDevice() ? (Date.now() - entity.zh.lastSeen) < this.getTimeout(entity) : return entity.isDevice()
entity.membersDevices().length === 0 || entity.membersDevices().some((d) => this.availabilityCache[d.ieeeAddr]); ? Date.now() - entity.zh.lastSeen < this.getTimeout(entity)
: entity.membersDevices().length === 0 || entity.membersDevices().some((d) => this.availabilityCache[d.ieeeAddr]);
} }
private resetTimer(device: Device): void { private resetTimer(device: Device): void {
@ -80,7 +82,7 @@ export default class Availability extends Extension {
for (let i = 1; i <= attempts; i++) { for (let i = 1; i <= attempts; i++) {
try { try {
// Enable recovery if device is marked as available and first ping fails. // Enable recovery if device is marked as available and first ping fails.
await device.zh.ping(!available || (i !== 2)); await device.zh.ping(!available || i !== 2);
pingedSuccessfully = true; pingedSuccessfully = true;
@ -152,7 +154,7 @@ export default class Availability extends Extension {
} }
} }
private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish=false, skipGroups=false): Promise<void> { private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise<void> {
if (logLastSeen && entity.isDevice()) { if (logLastSeen && entity.isDevice()) {
const ago = Date.now() - entity.zh.lastSeen; const ago = Date.now() - entity.zh.lastSeen;
if (this.isActiveDevice(entity)) { if (this.isActiveDevice(entity)) {
@ -226,8 +228,12 @@ export default class Availability extends Extension {
const options: KeyValue = device.options; const options: KeyValue = device.options;
const state = this.state.get(device); const state = this.state.get(device);
const meta: zhc.Tz.Meta = { const meta: zhc.Tz.Meta = {
message: this.state.get(device), mapped: device.definition, endpoint_name: null, message: this.state.get(device),
options, state, device: device.zh, mapped: device.definition,
endpoint_name: null,
options,
state,
device: device.zh,
}; };
try { try {

View File

@ -1,28 +1,38 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import utils from '../util/utils';
import Extension from './extension';
import stringify from 'json-stable-stringify-without-jsonify';
import debounce from 'debounce';
import {Zcl} from 'zigbee-herdsman';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
import {Zcl} from 'zigbee-herdsman';
import {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import Device from '../model/device'; import Device from '../model/device';
import Group from '../model/group'; import Group from '../model/group';
import {ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype'; import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
const LEGACY_API = settings.get().advanced.legacy_api; const LEGACY_API = settings.get().advanced.legacy_api;
const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`); const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`);
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`); const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`);
const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [ const ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
'genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering', 'hvacThermostat', 'msIlluminanceMeasurement', 'genScenes',
'msTemperatureMeasurement', 'msRelativeHumidity', 'msSoilMoisture', 'msCO2', 'genOnOff',
'genLevelCtrl',
'lightingColorCtrl',
'closuresWindowCovering',
'hvacThermostat',
'msIlluminanceMeasurement',
'msTemperatureMeasurement',
'msRelativeHumidity',
'msSoilMoisture',
'msCO2',
]; ];
// See zigbee-herdsman-converters // See zigbee-herdsman-converters
const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'}; const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'};
const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1}; const DEFAULT_REPORT_CONFIG = {minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1};
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => { const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) { if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') == null) {
await endpoint.read('lightingColorCtrl', ['colorCapabilities']); await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
} }
@ -30,47 +40,53 @@ const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemper
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number; const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
return { return {
colorTemperature: (value & 1 << 4) > 0, colorTemperature: (value & (1 << 4)) > 0,
colorXY: (value & 1 << 3) > 0, colorXY: (value & (1 << 3)) > 0,
}; };
}; };
const REPORT_CLUSTERS: Readonly<Partial<Record<ClusterName, Readonly<{ const REPORT_CLUSTERS: Readonly<
attribute: string; Partial<
minimumReportInterval: number; Record<
maximumReportInterval: number; ClusterName,
reportableChange: number; Readonly<{
condition?: (endpoint: zh.Endpoint) => Promise<boolean>; attribute: string;
}>[]>>> = { minimumReportInterval: number;
'genOnOff': [ maximumReportInterval: number;
{attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}, reportableChange: number;
], condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
'genLevelCtrl': [ }>[]
{attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG}, >
], >
'lightingColorCtrl': [ > = {
genOnOff: [{attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG}],
lightingColorCtrl: [
{ {
attribute: 'colorTemperature', ...DEFAULT_REPORT_CONFIG, attribute: 'colorTemperature',
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature, condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
}, },
{ {
attribute: 'currentX', ...DEFAULT_REPORT_CONFIG, attribute: 'currentX',
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY, condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
}, },
{ {
attribute: 'currentY', ...DEFAULT_REPORT_CONFIG, attribute: 'currentY',
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY, condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
}, },
], ],
'closuresWindowCovering': [ closuresWindowCovering: [
{attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG}, {attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG},
{attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG}, {attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG},
], ],
}; };
type PollOnMessage = { type PollOnMessage = {
cluster: Readonly<Partial<Record<ClusterName, {type: string, data: KeyValue}[]>>>; cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
read: Readonly<{cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>; read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
manufacturerIDs: readonly number[]; manufacturerIDs: readonly number[];
manufacturerNames: readonly string[]; manufacturerNames: readonly string[];
}[]; }[];
@ -92,9 +108,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{type: 'commandMove', data: {}}, {type: 'commandMove', data: {}},
{type: 'commandMoveToLevelWithOnOff', data: {}}, {type: 'commandMoveToLevelWithOnOff', data: {}},
], ],
genScenes: [ genScenes: [{type: 'commandRecall', data: {}}],
{type: 'commandRecall', data: {}},
],
}, },
// Read the following attributes // Read the following attributes
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']}, read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
@ -107,10 +121,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO, Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO, Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
], ],
manufacturerNames: [ manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
'GLEDOPTO',
'Trust International B.V.\u0000',
],
}, },
{ {
cluster: { cluster: {
@ -126,9 +137,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{type: 'commandOffWithEffect', data: {}}, {type: 'commandOffWithEffect', data: {}},
{type: 'commandToggle', data: {}}, {type: 'commandToggle', data: {}},
], ],
genScenes: [ genScenes: [{type: 'commandRecall', data: {}}],
{type: 'commandRecall', data: {}},
],
manuSpecificPhilips: [ manuSpecificPhilips: [
{type: 'commandHueNotification', data: {button: 1}}, {type: 'commandHueNotification', data: {button: 1}},
{type: 'commandHueNotification', data: {button: 4}}, {type: 'commandHueNotification', data: {button: 4}},
@ -143,16 +152,11 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO, Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO, Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
], ],
manufacturerNames: [ manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
'GLEDOPTO',
'Trust International B.V.\u0000',
],
}, },
{ {
cluster: { cluster: {
genScenes: [ genScenes: [{type: 'commandRecall', data: {}}],
{type: 'commandRecall', data: {}},
],
}, },
read: { read: {
cluster: 'lightingColorCtrl', cluster: 'lightingColorCtrl',
@ -184,15 +188,16 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO, Zcl.ManufacturerCode.TELINK_MICRO,
// Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors // Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors
], ],
manufacturerNames: [ manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
'GLEDOPTO',
'Trust International B.V.\u0000',
],
}, },
]; ];
interface ParsedMQTTMessage { interface ParsedMQTTMessage {
type: 'bind' | 'unbind', sourceKey: string, targetKey: string, clusters: string[], skipDisableReporting: boolean type: 'bind' | 'unbind';
sourceKey: string;
targetKey: string;
clusters: string[];
skipDisableReporting: boolean;
} }
export default class Bind extends Extension { export default class Bind extends Extension {
@ -258,8 +263,8 @@ export default class Bind extends Extension {
const attemptedClusters = []; const attemptedClusters = [];
const bindSource: zh.Endpoint = parsedSource.endpoint; const bindSource: zh.Endpoint = parsedSource.endpoint;
const bindTarget: number | zh.Group | zh.Endpoint = (target instanceof Device) ? parsedTarget.endpoint : const bindTarget: number | zh.Group | zh.Endpoint =
((target instanceof Group) ? target.zh : Number(target.ID)); target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
// Find which clusters are supported by both the source and target. // Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters. // Groups are assumed to support all clusters.
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES; const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
@ -270,8 +275,9 @@ export default class Bind extends Extension {
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator'; const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator';
if (!anyClusterValid && utils.isEndpoint(bindTarget)) { if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
matchingClusters = ((bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) || matchingClusters =
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster))); (bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster));
} }
const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster); const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster);
@ -332,7 +338,7 @@ export default class Bind extends Extension {
if (successfulClusters.length !== 0) { if (successfulClusters.length !== 0) {
if (type === 'bind') { if (type === 'bind') {
await this.setupReporting(bindSource.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === bindTarget)); await this.setupReporting(bindSource.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === bindTarget));
} else if ((typeof bindTarget !== 'number') && !skipDisableReporting) { } else if (typeof bindTarget !== 'number' && !skipDisableReporting) {
await this.disableUnnecessaryReportings(bindTarget); await this.disableUnnecessaryReportings(bindTarget);
} }
} }
@ -368,7 +374,8 @@ export default class Bind extends Extension {
} }
await this.setupReporting(bindsToGroup); await this.setupReporting(bindsToGroup);
} else { // action === remove/remove_all } else {
// action === remove/remove_all
if (!data.skipDisableReporting) { if (!data.skipDisableReporting) {
await this.disableUnnecessaryReportings(data.endpoint); await this.disableUnnecessaryReportings(data.endpoint);
} }
@ -411,7 +418,7 @@ export default class Bind extends Extension {
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]) { for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]) {
/* istanbul ignore else */ /* istanbul ignore else */
if (!c.condition || await c.condition(endpoint)) { if (!c.condition || (await c.condition(endpoint))) {
const i = {...c}; const i = {...c};
delete i.condition; delete i.condition;
@ -458,7 +465,7 @@ export default class Bind extends Extension {
for (const b of endpoint.binds) { for (const b of endpoint.binds) {
/* istanbul ignore else */ /* istanbul ignore else */
if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && (b.cluster.name in REPORT_CLUSTERS)) { if (b.target === coordinator && !requiredClusters.includes(b.cluster.name) && b.cluster.name in REPORT_CLUSTERS) {
boundClusters.push(b.cluster.name); boundClusters.push(b.cluster.name);
} }
} }
@ -471,11 +478,11 @@ export default class Bind extends Extension {
for (const item of REPORT_CLUSTERS[cluster as ClusterName]) { for (const item of REPORT_CLUSTERS[cluster as ClusterName]) {
/* istanbul ignore else */ /* istanbul ignore else */
if (!item.condition || await item.condition(endpoint)) { if (!item.condition || (await item.condition(endpoint))) {
const i = {...item}; const i = {...item};
delete i.condition; delete i.condition;
items.push({...i, maximumReportInterval: 0xFFFF}); items.push({...i, maximumReportInterval: 0xffff});
} }
} }
@ -499,8 +506,8 @@ export default class Bind extends Extension {
* When dimming the bulb via the dimmer switch the state is therefore not reported. * When dimming the bulb via the dimmer switch the state is therefore not reported.
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound). * When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
*/ */
const polls = POLL_ON_MESSAGE.filter( const polls = POLL_ON_MESSAGE.filter((p) =>
(p) => p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)), p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
); );
if (polls.length) { if (polls.length) {
@ -526,9 +533,11 @@ export default class Bind extends Extension {
for (const endpoint of toPoll) { for (const endpoint of toPoll) {
for (const poll of polls) { for (const poll of polls) {
if ((!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) && if (
!poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) || (!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) &&
!endpoint.supportsInputCluster(poll.read.cluster)) { !poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) ||
!endpoint.supportsInputCluster(poll.read.cluster)
) {
continue; continue;
} }

View File

@ -1,31 +1,37 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import logger from '../util/logger';
import utils from '../util/utils';
import * as settings from '../util/settings';
import Transport from 'winston-transport';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import fs from 'fs';
import stringify from 'json-stable-stringify-without-jsonify'; import stringify from 'json-stable-stringify-without-jsonify';
import JSZip from 'jszip';
import objectAssignDeep from 'object-assign-deep'; import objectAssignDeep from 'object-assign-deep';
import Extension from './extension'; import winston from 'winston';
import Transport from 'winston-transport';
import {Clusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/cluster';
import {CustomClusters, ClusterDefinition, ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import * as zhc from 'zigbee-herdsman-converters';
import Device from '../model/device'; import Device from '../model/device';
import Group from '../model/group'; import Group from '../model/group';
import data from '../util/data'; import data from '../util/data';
import JSZip from 'jszip'; import logger from '../util/logger';
import fs from 'fs'; import * as settings from '../util/settings';
import * as zhc from 'zigbee-herdsman-converters'; import utils from '../util/utils';
import {CustomClusters, ClusterDefinition, ClusterName} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype'; import Extension from './extension';
import {Clusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/cluster';
import winston from 'winston';
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`); const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
type DefinitionPayload = { type DefinitionPayload = {
model: string, vendor: string, description: string, exposes: zhc.Expose[], supports_ota: model: string;
boolean, icon: string, options: zhc.Expose[], vendor: string;
description: string;
exposes: zhc.Expose[];
supports_ota: boolean;
icon: string;
options: zhc.Expose[];
}; };
export default class Bridge extends Extension { export default class Bridge extends Extension {
private zigbee2mqttVersion: {commitHash: string, version: string}; private zigbee2mqttVersion: {commitHash: string; version: string};
private zigbeeHerdsmanVersion: {version: string}; private zigbeeHerdsmanVersion: {version: string};
private zigbeeHerdsmanConvertersVersion: {version: string}; private zigbeeHerdsmanConvertersVersion: {version: string};
private coordinatorVersion: zh.CoordinatorVersion; private coordinatorVersion: zh.CoordinatorVersion;
@ -47,16 +53,16 @@ export default class Bridge extends Extension {
'group/options': this.groupOptions, 'group/options': this.groupOptions,
'group/remove': this.groupRemove, 'group/remove': this.groupRemove,
'group/rename': this.groupRename, 'group/rename': this.groupRename,
'permit_join': this.permitJoin, permit_join: this.permitJoin,
'restart': this.restart, restart: this.restart,
'backup': this.backup, backup: this.backup,
'touchlink/factory_reset': this.touchlinkFactoryReset, 'touchlink/factory_reset': this.touchlinkFactoryReset,
'touchlink/identify': this.touchlinkIdentify, 'touchlink/identify': this.touchlinkIdentify,
'install_code/add': this.installCodeAdd, 'install_code/add': this.installCodeAdd,
'touchlink/scan': this.touchlinkScan, 'touchlink/scan': this.touchlinkScan,
'health_check': this.healthCheck, health_check: this.healthCheck,
'coordinator_check': this.coordinatorCheck, coordinator_check: this.coordinatorCheck,
'options': this.bridgeOptions, options: this.bridgeOptions,
// Below are deprecated // Below are deprecated
'config/last_seen': this.configLastSeen, 'config/last_seen': this.configLastSeen,
'config/homeassistant': this.configHomeAssistant, 'config/homeassistant': this.configHomeAssistant,
@ -78,7 +84,7 @@ export default class Bridge extends Extension {
if (debugToMQTTFrontend) { if (debugToMQTTFrontend) {
class DebugEventTransport extends Transport { class DebugEventTransport extends Transport {
log(info: {message: string, level: string, namespace: string}, next: () => void): void { log(info: {message: string; level: string; namespace: string}, next: () => void): void {
bridgeLogging(info.message, info.level, info.namespace); bridgeLogging(info.message, info.level, info.namespace);
next(); next();
} }
@ -87,7 +93,7 @@ export default class Bridge extends Extension {
this.logTransport = new DebugEventTransport(); this.logTransport = new DebugEventTransport();
} else { } else {
class EventTransport extends Transport { class EventTransport extends Transport {
log(info: {message: string, level: string, namespace: string}, next: () => void): void { log(info: {message: string; level: string; namespace: string}, next: () => void): void {
if (info.level !== 'debug') { if (info.level !== 'debug') {
bridgeLogging(info.message, info.level, info.namespace); bridgeLogging(info.message, info.level, info.namespace);
} }
@ -107,9 +113,7 @@ export default class Bridge extends Extension {
this.eventBus.onEntityRenamed(this, () => this.publishInfo()); this.eventBus.onEntityRenamed(this, () => this.publishInfo());
this.eventBus.onGroupMembersChanged(this, () => this.publishGroups()); this.eventBus.onGroupMembersChanged(this, () => this.publishGroups());
this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.publishInfo() && this.publishDefinitions());
this.publishInfo() &&
this.publishDefinitions());
this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo()); this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo());
this.eventBus.onScenesChanged(this, async () => { this.eventBus.onScenesChanged(this, async () => {
await this.publishDevices(); await this.publishDevices();
@ -132,8 +136,7 @@ export default class Bridge extends Extension {
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices()); this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices());
this.eventBus.onDeviceInterview(this, async (data) => { this.eventBus.onDeviceInterview(this, async (data) => {
await this.publishDevices(); await this.publishDevices();
const payload: KeyValue = const payload: KeyValue = {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
{friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
if (data.status === 'successful') { if (data.status === 'successful') {
payload.supported = data.device.isSupported; payload.supported = data.device.isSupported;
payload.definition = this.getDefinitionPayload(data.device); payload.definition = this.getDefinitionPayload(data.device);
@ -274,7 +277,9 @@ export default class Bridge extends Extension {
@bind async backup(message: string | KeyValue): Promise<MQTTResponse> { @bind async backup(message: string | KeyValue): Promise<MQTTResponse> {
await this.zigbee.backup(); await this.zigbee.backup();
const dataPath = data.getPath(); const dataPath = data.getPath();
const files = utils.getAllFiles(dataPath).map((f) => [f, f.substring(dataPath.length + 1)]) const files = utils
.getAllFiles(dataPath)
.map((f) => [f, f.substring(dataPath.length + 1)])
.filter((f) => !f[1].startsWith('log')); .filter((f) => !f[1].startsWith('log'));
const zip = new JSZip(); const zip = new JSZip();
files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0]))); files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0])));
@ -321,7 +326,7 @@ export default class Bridge extends Extension {
} }
await this.zigbee.permitJoin(value, device, time); await this.zigbee.permitJoin(value, device, time);
const response: {value: boolean, device?: string, time?: number} = {value}; const response: {value: boolean; device?: string; time?: number} = {value};
if (device && typeof message === 'object') response.device = message.device; if (device && typeof message === 'object') response.device = message.device;
if (time && typeof message === 'object') response.time = message.time; if (time && typeof message === 'object') response.time = message.time;
return utils.getResponse(message, response, null); return utils.getResponse(message, response, null);
@ -380,8 +385,7 @@ export default class Bridge extends Extension {
} }
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> { @bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') || if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') || !message.hasOwnProperty('channel')) {
!message.hasOwnProperty('channel')) {
throw new Error('Invalid payload'); throw new Error('Invalid payload');
} }
@ -392,9 +396,8 @@ export default class Bridge extends Extension {
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> { @bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
let result = false; let result = false;
const payload: {ieee_address?: string, channel?: number} = {}; const payload: {ieee_address?: string; channel?: number} = {};
if (typeof message === 'object' && message.hasOwnProperty('ieee_address') && if (typeof message === 'object' && message.hasOwnProperty('ieee_address') && message.hasOwnProperty('channel')) {
message.hasOwnProperty('channel')) {
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`); logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel); result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel);
payload.ieee_address = message.ieee_address; payload.ieee_address = message.ieee_address;
@ -445,7 +448,11 @@ export default class Bridge extends Extension {
} }
const cleanup = (o: KeyValue): KeyValue => { const cleanup = (o: KeyValue): KeyValue => {
delete o.friendlyName; delete o.friendly_name; delete o.ID; delete o.type; delete o.devices; delete o.friendlyName;
delete o.friendly_name;
delete o.ID;
delete o.type;
delete o.devices;
return o; return o;
}; };
@ -460,17 +467,19 @@ export default class Bridge extends Extension {
logger.info(`Changed config for ${entityType} ${ID}`); logger.info(`Changed config for ${entityType} ${ID}`);
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity}); this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
return utils.getResponse( return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired}, null);
message,
{from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired},
null,
);
} }
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> { @bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('cluster') || if (
!message.hasOwnProperty('maximum_report_interval') || !message.hasOwnProperty('minimum_report_interval') || typeof message !== 'object' ||
!message.hasOwnProperty('reportable_change') || !message.hasOwnProperty('attribute')) { !message.hasOwnProperty('id') ||
!message.hasOwnProperty('cluster') ||
!message.hasOwnProperty('maximum_report_interval') ||
!message.hasOwnProperty('minimum_report_interval') ||
!message.hasOwnProperty('reportable_change') ||
!message.hasOwnProperty('attribute')
) {
throw new Error(`Invalid payload`); throw new Error(`Invalid payload`);
} }
@ -485,20 +494,35 @@ export default class Bridge extends Extension {
const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint(); const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
await endpoint.bind(message.cluster, coordinatorEndpoint); await endpoint.bind(message.cluster, coordinatorEndpoint);
await endpoint.configureReporting(message.cluster, [{ await endpoint.configureReporting(
attribute: message.attribute, minimumReportInterval: message.minimum_report_interval, message.cluster,
maximumReportInterval: message.maximum_report_interval, reportableChange: message.reportable_change, [
}], message.options); {
attribute: message.attribute,
minimumReportInterval: message.minimum_report_interval,
maximumReportInterval: message.maximum_report_interval,
reportableChange: message.reportable_change,
},
],
message.options,
);
await this.publishDevices(); await this.publishDevices();
logger.info(`Configured reporting for '${message.id}', '${message.cluster}.${message.attribute}'`); logger.info(`Configured reporting for '${message.id}', '${message.cluster}.${message.attribute}'`);
return utils.getResponse(message, { return utils.getResponse(
id: message.id, cluster: message.cluster, maximum_report_interval: message.maximum_report_interval, message,
minimum_report_interval: message.minimum_report_interval, reportable_change: message.reportable_change, {
attribute: message.attribute, id: message.id,
}, null); cluster: message.cluster,
maximum_report_interval: message.maximum_report_interval,
minimum_report_interval: message.minimum_report_interval,
reportable_change: message.reportable_change,
attribute: message.attribute,
},
null,
);
} }
@bind async deviceInterview(message: string | KeyValue): Promise<MQTTResponse> { @bind async deviceInterview(message: string | KeyValue): Promise<MQTTResponse> {
@ -537,8 +561,7 @@ export default class Bridge extends Extension {
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> { async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true; const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) || if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) || !message.hasOwnProperty('to')) {
!message.hasOwnProperty('to')) {
throw new Error(`Invalid payload`); throw new Error(`Invalid payload`);
} }
@ -548,8 +571,7 @@ export default class Bridge extends Extension {
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from; const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
const to = message.to; const to = message.to;
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ? const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ? message.homeassistant_rename : false;
message.homeassistant_rename : false;
const entity = this.getEntity(entityType, from); const entity = this.getEntity(entityType, from);
const oldFriendlyName = entity.options.friendly_name; const oldFriendlyName = entity.options.friendly_name;
@ -570,11 +592,7 @@ export default class Bridge extends Extension {
// Republish entity state // Republish entity state
await this.publishEntityState(entity, {}); await this.publishEntityState(entity, {});
return utils.getResponse( return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename}, null);
message,
{from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename},
null,
);
} }
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> { async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
@ -650,9 +668,7 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {id: ID, force: force}, null); return utils.getResponse(message, {id: ID, force: force}, null);
} }
} catch (error) { } catch (error) {
throw new Error( throw new Error(`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`);
`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`,
);
} }
} }
@ -687,16 +703,21 @@ export default class Bridge extends Extension {
config_schema: settings.schema, config_schema: settings.schema,
}; };
await this.mqtt.publish( await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
'bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
} }
async publishDevices(): Promise<void> { async publishDevices(): Promise<void> {
interface Data { interface Data {
bindings: {cluster: string, target: {type: string, endpoint?: number, ieee_address?: string, id?: number}}[] bindings: {cluster: string; target: {type: string; endpoint?: number; ieee_address?: string; id?: number}}[];
configured_reportings: {cluster: string, attribute: string | number, configured_reportings: {
minimum_report_interval: number, maximum_report_interval: number, reportable_change: number}[], cluster: string;
clusters: {input: string[], output: string[]}, scenes: Scene[] attribute: string | number;
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
}[];
clusters: {input: string[]; output: string[]};
scenes: Scene[];
} }
const devices = this.zigbee.devices().map((device) => { const devices = this.zigbee.devices().map((device) => {
@ -713,9 +734,9 @@ export default class Bridge extends Extension {
}; };
for (const bind of endpoint.binds) { for (const bind of endpoint.binds) {
const target = utils.isEndpoint(bind.target) ? const target = utils.isEndpoint(bind.target)
{type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID} : ? {type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID}
{type: 'group', id: bind.target.groupID}; : {type: 'group', id: bind.target.groupID};
data.bindings.push({cluster: bind.cluster.name, target}); data.bindings.push({cluster: bind.cluster.name, target});
} }
@ -752,8 +773,7 @@ export default class Bridge extends Extension {
}; };
}); });
await this.mqtt.publish('bridge/devices', stringify(devices), await this.mqtt.publish('bridge/devices', stringify(devices), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
{retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
} }
async publishGroups(): Promise<void> { async publishGroups(): Promise<void> {
@ -768,14 +788,13 @@ export default class Bridge extends Extension {
}), }),
}; };
}); });
await this.mqtt.publish( await this.mqtt.publish('bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
'bridge/groups', stringify(groups), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
} }
async publishDefinitions(): Promise<void> { async publishDefinitions(): Promise<void> {
interface ClusterDefinitionPayload { interface ClusterDefinitionPayload {
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>, clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
custom_clusters: {[key: string] : CustomClusters} custom_clusters: {[key: string]: CustomClusters};
} }
const data: ClusterDefinitionPayload = { const data: ClusterDefinitionPayload = {
@ -789,8 +808,7 @@ export default class Bridge extends Extension {
} }
} }
await this.mqtt.publish('bridge/definitions', stringify(data), await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
{retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
} }
getDefinitionPayload(device: Device): DefinitionPayload { getDefinitionPayload(device: Device): DefinitionPayload {

View File

@ -1,11 +1,12 @@
import * as settings from '../util/settings'; import bind from 'bind-decorator';
import utils from '../util/utils';
import logger from '../util/logger';
import stringify from 'json-stable-stringify-without-jsonify'; import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
import Extension from './extension';
import bind from 'bind-decorator';
import Device from '../model/device'; import Device from '../model/device';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
/** /**
* This extension calls the zigbee-herdsman-converters definition configure() method * This extension calls the zigbee-herdsman-converters definition configure() method
@ -89,8 +90,12 @@ export default class Configure extends Extension {
this.eventBus.onReconfigure(this, this.onReconfigure); this.eventBus.onReconfigure(this, this.onReconfigure);
} }
private async configure(device: Device, event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message', private async configure(
force=false, throwError=false): Promise<void> { device: Device,
event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
force = false,
throwError = false,
): Promise<void> {
if (!force) { if (!force) {
if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) { if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) {
return; return;

View File

@ -20,9 +20,16 @@ abstract class Extension {
* @param {restartCallback} restartCallback Restart Zigbee2MQTT * @param {restartCallback} restartCallback Restart Zigbee2MQTT
* @param {addExtension} addExtension Add an extension * @param {addExtension} addExtension Add an extension
*/ */
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState, constructor(
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>, zigbee: Zigbee,
restartCallback: () => Promise<void>, addExtension: (extension: Extension) => Promise<void>) { mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
this.zigbee = zigbee; this.zigbee = zigbee;
this.mqtt = mqtt; this.mqtt = mqtt;
this.state = state; this.state = state;

View File

@ -1,13 +1,21 @@
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
import logger from '../util/logger';
import * as settings from '../util/settings'; import * as settings from '../util/settings';
import {loadExternalConverter} from '../util/utils'; import {loadExternalConverter} from '../util/utils';
import Extension from './extension'; import Extension from './extension';
import logger from '../util/logger';
export default class ExternalConverters extends Extension { export default class ExternalConverters extends Extension {
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState, constructor(
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>, zigbee: Zigbee,
restartCallback: () => Promise<void>, addExtension: (extension: Extension) => Promise<void>) { mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension); super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
for (const file of settings.get().external_converters) { for (const file of settings.get().external_converters) {
@ -21,10 +29,11 @@ export default class ExternalConverters extends Extension {
logger.error(`Failed to load external converter file '${file}' (${error.message})`); logger.error(`Failed to load external converter file '${file}' (${error.message})`);
logger.error( logger.error(
`Probably there is a syntax error in the file or the external converter is not ` + `Probably there is a syntax error in the file or the external converter is not ` +
`compatible with the current Zigbee2MQTT version`); `compatible with the current Zigbee2MQTT version`,
);
logger.error( logger.error(
`Note that external converters are not meant for long term usage, it's meant for local ` + `Note that external converters are not meant for long term usage, it's meant for local ` +
`testing after which a pull request should be created to add out-of-the-box support for the device`, `testing after which a pull request should be created to add out-of-the-box support for the device`,
); );
} }
} }

View File

@ -1,11 +1,12 @@
import bind from 'bind-decorator';
import fs from 'fs';
import stringify from 'json-stable-stringify-without-jsonify';
import path from 'path';
import * as settings from '../util/settings'; import * as settings from '../util/settings';
import utils from '../util/utils'; import utils from '../util/utils';
import fs from 'fs';
import data from './../util/data'; import data from './../util/data';
import path from 'path';
import logger from './../util/logger'; import logger from './../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from 'bind-decorator';
import Extension from './extension'; import Extension from './extension';
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`); const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
@ -15,7 +16,7 @@ export default class ExternalExtension extends Extension {
override async start(): Promise<void> { override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage); this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.requestLookup = {'save': this.saveExtension, 'remove': this.removeExtension}; this.requestLookup = {save: this.saveExtension, remove: this.removeExtension};
await this.loadUserDefinedExtensions(); await this.loadUserDefinedExtensions();
await this.publishExtensions(); await this.publishExtensions();
} }
@ -24,13 +25,16 @@ export default class ExternalExtension extends Extension {
return data.joinPath('extension'); return data.joinPath('extension');
} }
private getListOfUserDefinedExtensions(): {name: string, code: string}[] { private getListOfUserDefinedExtensions(): {name: string; code: string}[] {
const basePath = this.getExtensionsBasePath(); const basePath = this.getExtensionsBasePath();
if (fs.existsSync(basePath)) { if (fs.existsSync(basePath)) {
return fs.readdirSync(basePath).filter((f) => f.endsWith('.js')).map((fileName) => { return fs
const extensionFilePath = path.join(basePath, fileName); .readdirSync(basePath)
return {'name': fileName, 'code': fs.readFileSync(extensionFilePath, 'utf-8')}; .filter((f) => f.endsWith('.js'))
}); .map((fileName) => {
const extensionFilePath = path.join(basePath, fileName);
return {name: fileName, code: fs.readFileSync(extensionFilePath, 'utf-8')};
});
} else { } else {
return []; return [];
} }
@ -88,8 +92,7 @@ export default class ExternalExtension extends Extension {
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> { @bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
await this.enableDisableExtension(false, ConstructorClass.name); await this.enableDisableExtension(false, ConstructorClass.name);
// @ts-ignore // @ts-ignore
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
this.eventBus, settings, logger));
} }
private async loadUserDefinedExtensions(): Promise<void> { private async loadUserDefinedExtensions(): Promise<void> {
@ -100,9 +103,15 @@ export default class ExternalExtension extends Extension {
private async publishExtensions(): Promise<void> { private async publishExtensions(): Promise<void> {
const extensions = this.getListOfUserDefinedExtensions(); const extensions = this.getListOfUserDefinedExtensions();
await this.mqtt.publish('bridge/extensions', stringify(extensions), { await this.mqtt.publish(
retain: true, 'bridge/extensions',
qos: 0, stringify(extensions),
}, settings.get().mqtt.base_topic, true); {
retain: true,
qos: 0,
},
settings.get().mqtt.base_topic,
true,
);
} }
} }

View File

@ -1,18 +1,19 @@
import http from 'http'; import bind from 'bind-decorator';
import https from 'https';
import gzipStatic, {RequestHandler} from 'connect-gzip-static'; import gzipStatic, {RequestHandler} from 'connect-gzip-static';
import finalhandler from 'finalhandler'; import finalhandler from 'finalhandler';
import logger from '../util/logger'; import fs from 'fs';
import frontend from 'zigbee2mqtt-frontend'; import http from 'http';
import WebSocket from 'ws'; import https from 'https';
import stringify from 'json-stable-stringify-without-jsonify';
import net from 'net'; import net from 'net';
import url from 'url'; import url from 'url';
import fs from 'fs'; import WebSocket from 'ws';
import frontend from 'zigbee2mqtt-frontend';
import logger from '../util/logger';
import * as settings from '../util/settings'; import * as settings from '../util/settings';
import utils from '../util/utils'; import utils from '../util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import Extension from './extension'; import Extension from './extension';
import bind from 'bind-decorator';
/** /**
* This extension servers the frontend * This extension servers the frontend
@ -30,17 +31,24 @@ export default class Frontend extends Extension {
private fileServer: RequestHandler; private fileServer: RequestHandler;
private wss: WebSocket.Server = null; private wss: WebSocket.Server = null;
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState, constructor(
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>, zigbee: Zigbee,
restartCallback: () => Promise<void>, addExtension: (extension: Extension) => Promise<void>) { mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension); super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage); this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
} }
private isHttpsConfigured():boolean { private isHttpsConfigured(): boolean {
if (this.sslCert && this.sslKey) { if (this.sslCert && this.sslKey) {
if (!fs.existsSync(this.sslCert) || !fs.existsSync(this.sslKey)) { if (!fs.existsSync(this.sslCert) || !fs.existsSync(this.sslKey)) {
logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`); /* eslint-disable-line max-len */ logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`);
return false; return false;
} }
return true; return true;
@ -48,12 +56,12 @@ export default class Frontend extends Extension {
return false; return false;
} }
override async start(): Promise<void> { override async start(): Promise<void> {
if (this.isHttpsConfigured()) { if (this.isHttpsConfigured()) {
const serverOptions = { const serverOptions = {
key: fs.readFileSync(this.sslKey), key: fs.readFileSync(this.sslKey),
cert: fs.readFileSync(this.sslCert)}; cert: fs.readFileSync(this.sslCert),
};
this.server = https.createServer(serverOptions, this.onRequest); this.server = https.createServer(serverOptions, this.onRequest);
} else { } else {
this.server = http.createServer(this.onRequest); this.server = http.createServer(this.onRequest);

View File

@ -1,31 +1,42 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import utils from '../util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import equals from 'fast-deep-equal/es6';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import Extension from './extension'; import equals from 'fast-deep-equal/es6';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters';
import Device from '../model/device'; import Device from '../model/device';
import Group from '../model/group'; import Group from '../model/group';
import * as zhc from 'zigbee-herdsman-converters'; import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`); const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`); const LEGACY_TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`);
const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`); const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`);
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = { const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
'state': () => true, state: () => true,
'brightness': (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'brightness')), brightness: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'brightness')),
'color_temp': (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_temp')), color_temp: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_temp')),
'color': (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')), color: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
'color_mode': (value, exposes) => exposes.some((e) => e.type === 'light' && color_mode: (value, exposes) =>
((e.features.some((f) => f.name === `color_${value}`)) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp')))), exposes.some(
(e) =>
e.type === 'light' &&
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
),
}; };
interface ParsedMQTTMessage { interface ParsedMQTTMessage {
type: 'remove' | 'add' | 'remove_all', resolvedEntityGroup: Group, resolvedEntityDevice: Device, type: 'remove' | 'add' | 'remove_all';
error: string, groupKey: string, deviceKey: string, triggeredViaLegacyApi: boolean, resolvedEntityGroup: Group;
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint, resolvedEntityDevice: Device;
error: string;
groupKey: string;
deviceKey: string;
triggeredViaLegacyApi: boolean;
skipDisableReporting: boolean;
resolvedEntityEndpoint: zh.Endpoint;
} }
export default class Groups extends Extension { export default class Groups extends Extension {
@ -42,8 +53,13 @@ export default class Groups extends Extension {
const settingsGroups = settings.getGroups(); const settingsGroups = settings.getGroups();
const zigbeeGroups = this.zigbee.groups(); const zigbeeGroups = this.zigbee.groups();
const addRemoveFromGroup = async (action: 'add' | 'remove', deviceName: string, groupName: string | number, const addRemoveFromGroup = async (
endpoint: zh.Endpoint, group: Group): Promise<void> => { action: 'add' | 'remove',
deviceName: string,
groupName: string | number,
endpoint: zh.Endpoint,
group: Group,
): Promise<void> => {
try { try {
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`); logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
@ -119,7 +135,8 @@ export default class Groups extends Extension {
let endpointName: string = null; let endpointName: string = null;
const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : []; const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : [];
for (let [prop, value] of Object.entries(data.update)) { for (let prop of Object.keys(data.update)) {
const value = data.update[prop];
const endpointNameMatch = endpointNames.find((n) => prop.endsWith(`_${n}`)); const endpointNameMatch = endpointNames.find((n) => prop.endsWith(`_${n}`));
if (endpointNameMatch) { if (endpointNameMatch) {
@ -140,8 +157,11 @@ export default class Groups extends Extension {
if (entity instanceof Device) { if (entity instanceof Device) {
for (const group of groups) { for (const group of groups) {
if (group.zh.hasMember(entity.endpoint(endpointName)) && !equals(this.lastOptimisticState[group.ID], payload) && if (
this.shouldPublishPayloadForGroup(group, payload)) { group.zh.hasMember(entity.endpoint(endpointName)) &&
!equals(this.lastOptimisticState[group.ID], payload) &&
this.shouldPublishPayloadForGroup(group, payload)
) {
this.lastOptimisticState[group.ID] = payload; this.lastOptimisticState[group.ID] = payload;
await this.publishEntityState(group, payload, reason); await this.publishEntityState(group, payload, reason);
@ -197,7 +217,7 @@ export default class Groups extends Extension {
} }
private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean { private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean {
return ((group.options.off_state === 'last_member_state') || (!payload || payload.state !== 'OFF') || this.areAllMembersOff(group)); return group.options.off_state === 'last_member_state' || !payload || payload.state !== 'OFF' || this.areAllMembersOff(group);
} }
private areAllMembersOff(group: Group): boolean { private areAllMembersOff(group: Group): boolean {
@ -263,7 +283,7 @@ export default class Groups extends Extension {
/* istanbul ignore else */ /* istanbul ignore else */
if (settings.get().advanced.legacy_api) { if (settings.get().advanced.legacy_api) {
const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: 'entity doesn\'t exists'}; const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: "entity doesn't exists"};
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message})); await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
} }
@ -309,8 +329,15 @@ export default class Groups extends Extension {
} }
return { return {
resolvedEntityGroup, resolvedEntityDevice, type, error, groupKey, deviceKey, resolvedEntityGroup,
triggeredViaLegacyApi, skipDisableReporting, resolvedEntityEndpoint, resolvedEntityDevice,
type,
error,
groupKey,
deviceKey,
triggeredViaLegacyApi,
skipDisableReporting,
resolvedEntityEndpoint,
}; };
} }
@ -321,10 +348,17 @@ export default class Groups extends Extension {
return; return;
} }
let { const {
resolvedEntityGroup, resolvedEntityDevice, type, error, triggeredViaLegacyApi, resolvedEntityGroup,
groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint, resolvedEntityDevice,
type,
triggeredViaLegacyApi,
groupKey,
deviceKey,
skipDisableReporting,
resolvedEntityEndpoint,
} = parsed; } = parsed;
let error = parsed.error;
let changedGroups: Group[] = []; let changedGroups: Group[] = [];
if (!error) { if (!error) {
@ -369,7 +403,8 @@ export default class Groups extends Extension {
await this.mqtt.publish('bridge/log', stringify({type: `device_group_remove`, message})); await this.mqtt.publish('bridge/log', stringify({type: `device_group_remove`, message}));
} }
} else { // remove_all } else {
// remove_all
logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`); logger.info(`Removing '${resolvedEntityDevice.name}' from all groups`);
changedGroups = this.zigbee.groups().filter((g) => g.zh.members.includes(resolvedEntityEndpoint)); changedGroups = this.zigbee.groups().filter((g) => g.zh.members.includes(resolvedEntityEndpoint));
await resolvedEntityEndpoint.removeFromAllGroups(); await resolvedEntityEndpoint.removeFromAllGroups();

View File

@ -1,16 +1,25 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import utils, {isNumericExposeFeature, isBinaryExposeFeature, isEnumExposeFeature} from '../util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import assert from 'assert'; import assert from 'assert';
import Extension from './extension';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
interface MockProperty {property: string, value: KeyValue | string} import logger from '../util/logger';
import * as settings from '../util/settings';
import utils, {isNumericExposeFeature, isBinaryExposeFeature, isEnumExposeFeature} from '../util/utils';
import Extension from './extension';
interface MockProperty {
property: string;
value: KeyValue | string;
}
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
interface DiscoveryEntry {mockProperties: MockProperty[], type: string, object_id: string, discovery_payload: KeyValue} interface DiscoveryEntry {
mockProperties: MockProperty[];
type: string;
object_id: string;
discovery_payload: KeyValue;
}
const sensorClick: DiscoveryEntry = { const sensorClick: DiscoveryEntry = {
type: 'sensor', type: 'sensor',
@ -24,10 +33,10 @@ const sensorClick: DiscoveryEntry = {
}; };
interface Discovered { interface Discovered {
mockProperties: Set<MockProperty>, mockProperties: Set<MockProperty>;
messages: {[s: string]: {payload: string, published: boolean}}, messages: {[s: string]: {payload: string; published: boolean}};
triggers: Set<string>, triggers: Set<string>;
discovered: boolean, discovered: boolean;
} }
const ACCESS_STATE = 0b001; const ACCESS_STATE = 0b001;
@ -37,11 +46,36 @@ const defaultStatusTopic = 'homeassistant/status';
const legacyMapping = [ const legacyMapping = [
{ {
models: ['WXKG01LM', 'HS1EB/HS1EB-E', 'ICZB-KPD14S', 'TERNCY-SD01', 'TERNCY-PP01', 'ICZB-KPD18S', models: [
'E1766', 'ZWallRemote0', 'ptvo.switch', '2AJZ4KPKEY', 'ZGRC-KEY-013', 'HGZB-02S', 'HGZB-045', 'WXKG01LM',
'HGZB-1S', 'AV2010/34', 'IM6001-BTP01', 'WXKG11LM', 'WXKG03LM', 'WXKG02LM_rev1', 'WXKG02LM_rev2', 'HS1EB/HS1EB-E',
'QBKG04LM', 'QBKG03LM', 'QBKG11LM', 'QBKG21LM', 'QBKG22LM', 'WXKG12LM', 'QBKG12LM', 'ICZB-KPD14S',
'E1743'], 'TERNCY-SD01',
'TERNCY-PP01',
'ICZB-KPD18S',
'E1766',
'ZWallRemote0',
'ptvo.switch',
'2AJZ4KPKEY',
'ZGRC-KEY-013',
'HGZB-02S',
'HGZB-045',
'HGZB-1S',
'AV2010/34',
'IM6001-BTP01',
'WXKG11LM',
'WXKG03LM',
'WXKG02LM_rev1',
'WXKG02LM_rev2',
'QBKG04LM',
'QBKG03LM',
'QBKG11LM',
'QBKG21LM',
'QBKG22LM',
'WXKG12LM',
'QBKG12LM',
'E1743',
],
discovery: sensorClick, discovery: sensorClick,
}, },
{ {
@ -78,16 +112,26 @@ class Bridge {
private discoveryEntries: DiscoveryEntry[]; private discoveryEntries: DiscoveryEntry[];
readonly options: { readonly options: {
ID?: string, ID?: string;
homeassistant?: KeyValue, homeassistant?: KeyValue;
}; };
/* eslint-disable brace-style */ /* eslint-disable brace-style */
get ID(): string {return this.coordinatorIeeeAddress;} get ID(): string {
get name(): string {return 'bridge';} return this.coordinatorIeeeAddress;
get hardwareVersion(): string {return this.coordinatorType;} }
get firmwareVersion(): string {return this.coordinatorFirmwareVersion;} get name(): string {
get configs(): DiscoveryEntry[] {return this.discoveryEntries;} return 'bridge';
}
get hardwareVersion(): string {
return this.coordinatorType;
}
get firmwareVersion(): string {
return this.coordinatorFirmwareVersion;
}
get configs(): DiscoveryEntry[] {
return this.discoveryEntries;
}
constructor(ieeeAdress: string, version: zh.CoordinatorVersion, discovery: DiscoveryEntry[]) { constructor(ieeeAdress: string, version: zh.CoordinatorVersion, discovery: DiscoveryEntry[]) {
this.coordinatorIeeeAddress = ieeeAdress; this.coordinatorIeeeAddress = ieeeAdress;
@ -104,8 +148,12 @@ class Bridge {
}; };
} }
isDevice(): this is Device {return false;} isDevice(): this is Device {
isGroup(): this is Group {return false;} return false;
}
isGroup(): this is Group {
return false;
}
/* eslint-enable brace-style */ /* eslint-enable brace-style */
} }
@ -120,13 +168,20 @@ export default class HomeAssistant extends Extension {
private statusTopic = settings.get().homeassistant.status_topic; private statusTopic = settings.get().homeassistant.status_topic;
private entityAttributes = settings.get().homeassistant.legacy_entity_attributes; private entityAttributes = settings.get().homeassistant.legacy_entity_attributes;
private zigbee2MQTTVersion: string; private zigbee2MQTTVersion: string;
private discoveryOrigin: {name: string, sw: string, url: string}; private discoveryOrigin: {name: string; sw: string; url: string};
private bridge: Bridge; private bridge: Bridge;
private bridgeIdentifier: string; private bridgeIdentifier: string;
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState, constructor(
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>, zigbee: Zigbee,
restartCallback: () => Promise<void>, addExtension: (extension: Extension) => Promise<void>) { mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension); super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
if (settings.get().advanced.output === 'attribute') { if (settings.get().advanced.output === 'attribute') {
throw new Error('Home Assistant integration is not possible with attribute output!'); throw new Error('Home Assistant integration is not possible with attribute output!');
@ -193,19 +248,21 @@ export default class HomeAssistant extends Extension {
return this.discovered[ID]; return this.discovered[ID];
} }
private exposeToConfig(exposes: zhc.Expose[], entityType: 'device' | 'group', private exposeToConfig(
allExposes: zhc.Expose[], definition?: zhc.Definition): DiscoveryEntry[] { exposes: zhc.Expose[],
entityType: 'device' | 'group',
allExposes: zhc.Expose[],
definition?: zhc.Definition,
): DiscoveryEntry[] {
// For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features // For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features
// to use for a bulb (e.g. color_xy/color_temp) // to use for a bulb (e.g. color_xy/color_temp)
assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed'); assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed');
const firstExpose = exposes[0]; const firstExpose = exposes[0];
assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type), assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`);
`Unsupported expose type ${firstExpose.type} for group`);
const discoveryEntries: DiscoveryEntry[] = []; const discoveryEntries: DiscoveryEntry[] = [];
const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined; const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined;
const getProperty = (feature: zhc.Feature): string => entityType === 'group' ? const getProperty = (feature: zhc.Feature): string => (entityType === 'group' ? featurePropertyWithoutEndpoint(feature) : feature.property);
featurePropertyWithoutEndpoint(feature) : feature.property;
/* istanbul ignore else */ /* istanbul ignore else */
if (firstExpose.type === 'light') { if (firstExpose.type === 'light') {
@ -216,9 +273,10 @@ export default class HomeAssistant extends Extension {
const state = firstExpose.features.find((f) => f.name === 'state'); const state = firstExpose.features.find((f) => f.name === 'state');
// Prefer HS over XY when at least one of the lights in the group prefers HS over XY. // Prefer HS over XY when at least one of the lights in the group prefers HS over XY.
// A light prefers HS over XY when HS is earlier in the feature array than HS. // A light prefers HS over XY when HS is earlier in the feature array than HS.
const preferHS = exposes.map((e) => [e.features.findIndex((ee) => ee.name === 'color_xy'), const preferHS =
e.features.findIndex((ee) => ee.name === 'color_hs')]) exposes
.filter((d) => d[0] !== -1 && d[1] !== -1 && d[1] < d[0]).length !== 0; .map((e) => [e.features.findIndex((ee) => ee.name === 'color_xy'), e.features.findIndex((ee) => ee.name === 'color_hs')])
.filter((d) => d[0] !== -1 && d[1] !== -1 && d[1] < d[0]).length !== 0;
const discoveryEntry: DiscoveryEntry = { const discoveryEntry: DiscoveryEntry = {
type: 'light', type: 'light',
@ -246,16 +304,24 @@ export default class HomeAssistant extends Extension {
} }
if (hasColorTemp) { if (hasColorTemp) {
const colorTemps = exposes.map((expose) => expose.features.find((e) => e.name === 'color_temp')) const colorTemps = exposes
.filter((e) => e).filter(isNumericExposeFeature); .map((expose) => expose.features.find((e) => e.name === 'color_temp'))
.filter((e) => e)
.filter(isNumericExposeFeature);
const max = Math.min(...colorTemps.map((e) => e.value_max)); const max = Math.min(...colorTemps.map((e) => e.value_max));
const min = Math.max(...colorTemps.map((e) => e.value_min)); const min = Math.max(...colorTemps.map((e) => e.value_min));
discoveryEntry.discovery_payload.max_mireds = max; discoveryEntry.discovery_payload.max_mireds = max;
discoveryEntry.discovery_payload.min_mireds = min; discoveryEntry.discovery_payload.min_mireds = min;
} }
const effects = utils.arrayUnique(utils.flatten( const effects = utils.arrayUnique(
allExposes.filter(isEnumExposeFeature).filter((e) => e.name === 'effect').map((e) => e.values))); utils.flatten(
allExposes
.filter(isEnumExposeFeature)
.filter((e) => e.name === 'effect')
.map((e) => e.values),
),
);
if (effects.length) { if (effects.length) {
discoveryEntry.discovery_payload.effect = true; discoveryEntry.discovery_payload.effect = true;
discoveryEntry.discovery_payload.effect_list = effects; discoveryEntry.discovery_payload.effect_list = effects;
@ -295,8 +361,7 @@ export default class HomeAssistant extends Extension {
discoveryEntries.push(discoveryEntry); discoveryEntries.push(discoveryEntry);
} else if (firstExpose.type === 'climate') { } else if (firstExpose.type === 'climate') {
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint']; const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
const setpoint = firstExpose.features.filter(isNumericExposeFeature) const setpoint = firstExpose.features.filter(isNumericExposeFeature).find((f) => setpointProperties.includes(f.name));
.find((f) => setpointProperties.includes(f.name));
assert(setpoint, 'No setpoint found'); assert(setpoint, 'No setpoint found');
const temperature = firstExpose.features.find((f) => f.name === 'local_temperature'); const temperature = firstExpose.features.find((f) => f.name === 'local_temperature');
assert(temperature, 'No temperature found'); assert(temperature, 'No temperature found');
@ -339,45 +404,39 @@ export default class HomeAssistant extends Extension {
if (state) { if (state) {
discoveryEntry.mockProperties.push({property: state.property, value: null}); discoveryEntry.mockProperties.push({property: state.property, value: null});
discoveryEntry.discovery_payload.action_topic = true; discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template = `{% set values = ` + discoveryEntry.discovery_payload.action_template =
`{None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'}` + `{% set values = ` +
` %}{{ values[value_json.${state.property}] }}`; `{None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
} }
const coolingSetpoint = firstExpose.features.find((f) => f.name === 'occupied_cooling_setpoint'); const coolingSetpoint = firstExpose.features.find((f) => f.name === 'occupied_cooling_setpoint');
if (coolingSetpoint) { if (coolingSetpoint) {
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name; discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_low_state_template = discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json.${setpoint.property} }}`;
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_topic = true; discoveryEntry.discovery_payload.temperature_low_state_topic = true;
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name; discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
discoveryEntry.discovery_payload.temperature_high_state_template = discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json.${coolingSetpoint.property} }}`;
`{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_topic = true; discoveryEntry.discovery_payload.temperature_high_state_topic = true;
} else { } else {
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name; discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_state_template = discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json.${setpoint.property} }}`;
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_topic = true; discoveryEntry.discovery_payload.temperature_state_topic = true;
} }
const fanMode = firstExpose.features.filter(isEnumExposeFeature) const fanMode = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'fan_mode');
.find((f) => f.name === 'fan_mode');
if (fanMode) { if (fanMode) {
discoveryEntry.discovery_payload.fan_modes = fanMode.values; discoveryEntry.discovery_payload.fan_modes = fanMode.values;
discoveryEntry.discovery_payload.fan_mode_command_topic = true; discoveryEntry.discovery_payload.fan_mode_command_topic = true;
discoveryEntry.discovery_payload.fan_mode_state_template = discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json.${fanMode.property} }}`;
`{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_topic = true; discoveryEntry.discovery_payload.fan_mode_state_topic = true;
} }
const swingMode = firstExpose.features.filter(isEnumExposeFeature) const swingMode = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'swing_mode');
.find((f) => f.name === 'swing_mode');
if (swingMode) { if (swingMode) {
discoveryEntry.discovery_payload.swing_modes = swingMode.values; discoveryEntry.discovery_payload.swing_modes = swingMode.values;
discoveryEntry.discovery_payload.swing_mode_command_topic = true; discoveryEntry.discovery_payload.swing_mode_command_topic = true;
discoveryEntry.discovery_payload.swing_mode_state_template = discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json.${swingMode.property} }}`;
`{{ value_json.${swingMode.property} }}`;
discoveryEntry.discovery_payload.swing_mode_state_topic = true; discoveryEntry.discovery_payload.swing_mode_state_topic = true;
} }
@ -385,13 +444,11 @@ export default class HomeAssistant extends Extension {
if (preset) { if (preset) {
discoveryEntry.discovery_payload.preset_modes = preset.values; discoveryEntry.discovery_payload.preset_modes = preset.values;
discoveryEntry.discovery_payload.preset_mode_command_topic = 'preset'; discoveryEntry.discovery_payload.preset_mode_command_topic = 'preset';
discoveryEntry.discovery_payload.preset_mode_value_template = discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${preset.property} }}`;
`{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.preset_mode_state_topic = true; discoveryEntry.discovery_payload.preset_mode_state_topic = true;
} }
const tempCalibration = firstExpose.features.filter(isNumericExposeFeature) const tempCalibration = firstExpose.features.filter(isNumericExposeFeature).find((f) => f.name === 'local_temperature_calibration');
.find((f) => f.name === 'local_temperature_calibration');
if (tempCalibration) { if (tempCalibration) {
const discoveryEntry: DiscoveryEntry = { const discoveryEntry: DiscoveryEntry = {
type: 'number', type: 'number',
@ -418,8 +475,7 @@ export default class HomeAssistant extends Extension {
discoveryEntries.push(discoveryEntry); discoveryEntries.push(discoveryEntry);
} }
const piHeatingDemand = firstExpose.features.filter(isNumericExposeFeature) const piHeatingDemand = firstExpose.features.filter(isNumericExposeFeature).find((f) => f.name === 'pi_heating_demand');
.find((f) => f.name === 'pi_heating_demand');
if (piHeatingDemand) { if (piHeatingDemand) {
const discoveryEntry: DiscoveryEntry = { const discoveryEntry: DiscoveryEntry = {
type: 'sensor', type: 'sensor',
@ -480,13 +536,13 @@ export default class HomeAssistant extends Extension {
discoveryEntries.push(discoveryEntry); discoveryEntries.push(discoveryEntry);
} else if (firstExpose.type === 'cover') { } else if (firstExpose.type === 'cover') {
const state = exposes.find((expose) => expose.features.find((e) => e.name === 'state')) const state = exposes.find((expose) => expose.features.find((e) => e.name === 'state'))?.features.find((f) => f.name === 'state');
?.features.find((f) => f.name === 'state'); const position = exposes
const position = exposes.find((expose) => expose.features.find((e) => e.name === 'position')) .find((expose) => expose.features.find((e) => e.name === 'position'))
?.features.find((f) => f.name === 'position'); ?.features.find((f) => f.name === 'position');
const tilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt')) const tilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt'))?.features.find((f) => f.name === 'tilt');
?.features.find((f) => f.name === 'tilt'); const motorState = allExposes
const motorState = allExposes?.filter(isEnumExposeFeature) ?.filter(isEnumExposeFeature)
.find((e) => ['motor_state', 'moving'].includes(e.name) && e.access === ACCESS_STATE); .find((e) => ['motor_state', 'moving'].includes(e.name) && e.access === ACCESS_STATE);
const running = allExposes?.find((e) => e.type === 'binary' && e.name === 'running'); const running = allExposes?.find((e) => e.type === 'binary' && e.name === 'running');
@ -506,7 +562,8 @@ export default class HomeAssistant extends Extension {
// If curtains have `running` property, use this in discovery. // If curtains have `running` property, use this in discovery.
// The movement direction is calculated (assumed) in this case. // The movement direction is calculated (assumed) in this case.
if (running) { if (running) {
discoveryEntry.discovery_payload.value_template = `{% if "${running.property}" in value_json ` + discoveryEntry.discovery_payload.value_template =
`{% if "${running.property}" in value_json ` +
`and value_json.${running.property} %} {% if value_json.${position.property} > 0 %} closing ` + `and value_json.${running.property} %} {% if value_json.${position.property} > 0 %} closing ` +
`{% else %} opening {% endif %} {% else %} stopped {% endif %}`; `{% else %} opening {% endif %} {% else %} stopped {% endif %}`;
} }
@ -526,7 +583,8 @@ export default class HomeAssistant extends Extension {
discoveryEntry.discovery_payload.state_opening = openingState; discoveryEntry.discovery_payload.state_opening = openingState;
discoveryEntry.discovery_payload.state_closing = closingState; discoveryEntry.discovery_payload.state_closing = closingState;
discoveryEntry.discovery_payload.state_stopped = stoppedState; discoveryEntry.discovery_payload.state_stopped = stoppedState;
discoveryEntry.discovery_payload.value_template = `{% if "${motorState.property}" in value_json ` + discoveryEntry.discovery_payload.value_template =
`{% if "${motorState.property}" in value_json ` +
`and value_json.${motorState.property} %} {{ value_json.${motorState.property} }} {% else %} ` + `and value_json.${motorState.property} %} {{ value_json.${motorState.property} }} {% else %} ` +
`${stoppedState} {% endif %}`; `${stoppedState} {% endif %}`;
} }
@ -534,9 +592,8 @@ export default class HomeAssistant extends Extension {
// If curtains do not have `running`, `motor_state` or `moving` properties. // If curtains do not have `running`, `motor_state` or `moving` properties.
if (!discoveryEntry.discovery_payload.value_template) { if (!discoveryEntry.discovery_payload.value_template) {
discoveryEntry.discovery_payload.value_template = (discoveryEntry.discovery_payload.value_template = `{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`),
`{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`, (discoveryEntry.discovery_payload.state_open = 'OPEN');
discoveryEntry.discovery_payload.state_open = 'OPEN';
discoveryEntry.discovery_payload.state_closed = 'CLOSE'; discoveryEntry.discovery_payload.state_closed = 'CLOSE';
discoveryEntry.discovery_payload.state_stopped = 'STOP'; discoveryEntry.discovery_payload.state_stopped = 'STOP';
} }
@ -546,7 +603,8 @@ export default class HomeAssistant extends Extension {
} }
if (position) { if (position) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload, discoveryEntry.discovery_payload = {
...discoveryEntry.discovery_payload,
position_template: `{{ value_json.${featurePropertyWithoutEndpoint(position)} }}`, position_template: `{{ value_json.${featurePropertyWithoutEndpoint(position)} }}`,
set_position_template: `{ "${getProperty(position)}": {{ position }} }`, set_position_template: `{ "${getProperty(position)}": {{ position }} }`,
set_position_topic: true, set_position_topic: true,
@ -555,7 +613,8 @@ export default class HomeAssistant extends Extension {
} }
if (tilt) { if (tilt) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload, discoveryEntry.discovery_payload = {
...discoveryEntry.discovery_payload,
tilt_command_topic: true, tilt_command_topic: true,
tilt_status_topic: true, tilt_status_topic: true,
tilt_status_template: `{{ value_json.${featurePropertyWithoutEndpoint(tilt)} }}`, tilt_status_template: `{{ value_json.${featurePropertyWithoutEndpoint(tilt)} }}`,
@ -592,8 +651,9 @@ export default class HomeAssistant extends Extension {
// presets "on", "auto" and "smart" to cover the remaining modes in // presets "on", "auto" and "smart" to cover the remaining modes in
// ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is // ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is
// always a valid speed. // always a valid speed.
let speeds = ['off'].concat(['low', 'medium', 'high', '1', '2', '3', '4', '5', let speeds = ['off'].concat(
'6', '7', '8', '9'].filter((s) => speed.values.includes(s))); ['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => speed.values.includes(s)),
);
let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s)); let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s));
if (['99432'].includes(definition.model)) { if (['99432'].includes(definition.model)) {
@ -613,24 +673,21 @@ export default class HomeAssistant extends Extension {
discoveryEntry.discovery_payload.percentage_state_topic = true; discoveryEntry.discovery_payload.percentage_state_topic = true;
discoveryEntry.discovery_payload.percentage_command_topic = true; discoveryEntry.discovery_payload.percentage_command_topic = true;
discoveryEntry.discovery_payload.percentage_value_template = discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`;
`{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`; discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`;
discoveryEntry.discovery_payload.percentage_command_template =
`{{ {${percentCommands}}[value] | default('') }}`;
discoveryEntry.discovery_payload.speed_range_min = 1; discoveryEntry.discovery_payload.speed_range_min = 1;
discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1; discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1;
assert(presets.length !== 0); assert(presets.length !== 0);
discoveryEntry.discovery_payload.preset_mode_state_topic = true; discoveryEntry.discovery_payload.preset_mode_state_topic = true;
discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode'; discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode';
discoveryEntry.discovery_payload.preset_mode_value_template = discoveryEntry.discovery_payload.preset_mode_value_template =
`{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}]` + `{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}]` + ` else 'None' | default('None') }}`;
` else 'None' | default('None') }}`;
discoveryEntry.discovery_payload.preset_modes = presets; discoveryEntry.discovery_payload.preset_modes = presets;
} }
discoveryEntries.push(discoveryEntry); discoveryEntries.push(discoveryEntry);
} else if (isBinaryExposeFeature(firstExpose)) { } else if (isBinaryExposeFeature(firstExpose)) {
const lookup: {[s: string]: KeyValue}= { const lookup: {[s: string]: KeyValue} = {
activity_led_indicator: {icon: 'mdi:led-on'}, activity_led_indicator: {icon: 'mdi:led-on'},
auto_off: {icon: 'mdi:flash-auto'}, auto_off: {icon: 'mdi:flash-auto'},
battery_low: {entity_category: 'diagnostic', device_class: 'battery'}, battery_low: {entity_category: 'diagnostic', device_class: 'battery'},
@ -699,14 +756,13 @@ export default class HomeAssistant extends Extension {
const discoveryEntry: DiscoveryEntry = { const discoveryEntry: DiscoveryEntry = {
type: 'switch', type: 'switch',
mockProperties: [{property: firstExpose.property, value: null}], mockProperties: [{property: firstExpose.property, value: null}],
object_id: endpoint ? object_id: endpoint ? `switch_${firstExpose.name}_${endpoint}` : `switch_${firstExpose.name}`,
`switch_${firstExpose.name}_${endpoint}` :
`switch_${firstExpose.name}`,
discovery_payload: { discovery_payload: {
name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label,
value_template: typeof firstExpose.value_on === 'boolean' ? value_template:
`{% if value_json.${firstExpose.property} %} true {% else %} false {% endif %}` : typeof firstExpose.value_on === 'boolean'
`{{ value_json.${firstExpose.property} }}`, ? `{% if value_json.${firstExpose.property} %} true {% else %} false {% endif %}`
: `{{ value_json.${firstExpose.property} }}`,
payload_on: firstExpose.value_on.toString(), payload_on: firstExpose.value_on.toString(),
payload_off: firstExpose.value_off.toString(), payload_off: firstExpose.value_off.toString(),
command_topic: true, command_topic: true,
@ -735,15 +791,12 @@ export default class HomeAssistant extends Extension {
} }
} else if (isNumericExposeFeature(firstExpose)) { } else if (isNumericExposeFeature(firstExpose)) {
const lookup: {[s: string]: KeyValue} = { const lookup: {[s: string]: KeyValue} = {
ac_frequency: {device_class: 'frequency', enabled_by_default: false, entity_category: 'diagnostic', ac_frequency: {device_class: 'frequency', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'},
state_class: 'measurement'},
action_duration: {icon: 'mdi:timer', device_class: 'duration'}, action_duration: {icon: 'mdi:timer', device_class: 'duration'},
alarm_humidity_max: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-plus'}, alarm_humidity_max: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-plus'},
alarm_humidity_min: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-minus'}, alarm_humidity_min: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-minus'},
alarm_temperature_max: {device_class: 'temperature', entity_category: 'config', alarm_temperature_max: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-high'},
icon: 'mdi:thermometer-high'}, alarm_temperature_min: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-low'},
alarm_temperature_min: {device_class: 'temperature', entity_category: 'config',
icon: 'mdi:thermometer-low'},
angle: {icon: 'angle-acute'}, angle: {icon: 'angle-acute'},
angle_axis: {icon: 'angle-acute'}, angle_axis: {icon: 'angle-acute'},
aqi: {device_class: 'aqi', state_class: 'measurement'}, aqi: {device_class: 'aqi', state_class: 'measurement'},
@ -756,8 +809,7 @@ export default class HomeAssistant extends Extension {
ballast_physical_minimum_level: {entity_category: 'diagnostic'}, ballast_physical_minimum_level: {entity_category: 'diagnostic'},
battery: {device_class: 'battery', entity_category: 'diagnostic', state_class: 'measurement'}, battery: {device_class: 'battery', entity_category: 'diagnostic', state_class: 'measurement'},
battery2: {device_class: 'battery', entity_category: 'diagnostic', state_class: 'measurement'}, battery2: {device_class: 'battery', entity_category: 'diagnostic', state_class: 'measurement'},
battery_voltage: {device_class: 'voltage', entity_category: 'diagnostic', state_class: 'measurement', battery_voltage: {device_class: 'voltage', entity_category: 'diagnostic', state_class: 'measurement', enabled_by_default: true},
enabled_by_default: true},
boost_heating_countdown: {device_class: 'duration'}, boost_heating_countdown: {device_class: 'duration'},
boost_heating_countdown_time_set: {entity_category: 'config', icon: 'mdi:timer'}, boost_heating_countdown_time_set: {entity_category: 'config', icon: 'mdi:timer'},
boost_time: {entity_category: 'config', icon: 'mdi:timer'}, boost_time: {entity_category: 'config', icon: 'mdi:timer'},
@ -766,7 +818,9 @@ export default class HomeAssistant extends Extension {
co2: {device_class: 'carbon_dioxide', state_class: 'measurement'}, co2: {device_class: 'carbon_dioxide', state_class: 'measurement'},
comfort_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, comfort_temperature: {entity_category: 'config', icon: 'mdi:thermometer'},
cpu_temperature: { cpu_temperature: {
device_class: 'temperature', entity_category: 'diagnostic', state_class: 'measurement', device_class: 'temperature',
entity_category: 'diagnostic',
state_class: 'measurement',
}, },
cube_side: {icon: 'mdi:cube'}, cube_side: {icon: 'mdi:cube'},
current: { current: {
@ -790,7 +844,9 @@ export default class HomeAssistant extends Extension {
deadzone_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, deadzone_temperature: {entity_category: 'config', icon: 'mdi:thermometer'},
detection_interval: {icon: 'mdi:timer'}, detection_interval: {icon: 'mdi:timer'},
device_temperature: { device_temperature: {
device_class: 'temperature', entity_category: 'diagnostic', state_class: 'measurement', device_class: 'temperature',
entity_category: 'diagnostic',
state_class: 'measurement',
}, },
duration: {entity_category: 'config', icon: 'mdi:timer'}, duration: {entity_category: 'config', icon: 'mdi:timer'},
eco2: {device_class: 'carbon_dioxide', state_class: 'measurement'}, eco2: {device_class: 'carbon_dioxide', state_class: 'measurement'},
@ -832,18 +888,21 @@ export default class HomeAssistant extends Extension {
people: {state_class: 'measurement', icon: 'mdi:account-multiple'}, people: {state_class: 'measurement', icon: 'mdi:account-multiple'},
position: {icon: 'mdi:valve', state_class: 'measurement'}, position: {icon: 'mdi:valve', state_class: 'measurement'},
power: {device_class: 'power', entity_category: 'diagnostic', state_class: 'measurement'}, power: {device_class: 'power', entity_category: 'diagnostic', state_class: 'measurement'},
power_factor: {device_class: 'power_factor', enabled_by_default: false, power_factor: {device_class: 'power_factor', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'},
entity_category: 'diagnostic', state_class: 'measurement'},
power_outage_count: {icon: 'mdi:counter', enabled_by_default: false}, power_outage_count: {icon: 'mdi:counter', enabled_by_default: false},
precision: {entity_category: 'config', icon: 'mdi:decimal-comma-increase'}, precision: {entity_category: 'config', icon: 'mdi:decimal-comma-increase'},
pressure: {device_class: 'atmospheric_pressure', state_class: 'measurement'}, pressure: {device_class: 'atmospheric_pressure', state_class: 'measurement'},
presence_timeout: {entity_category: 'config', icon: 'mdi:timer'}, presence_timeout: {entity_category: 'config', icon: 'mdi:timer'},
reporting_time: {entity_category: 'config', icon: 'mdi:clock-time-one-outline'}, reporting_time: {entity_category: 'config', icon: 'mdi:clock-time-one-outline'},
requested_brightness_level: { requested_brightness_level: {
enabled_by_default: false, entity_category: 'diagnostic', icon: 'mdi:brightness-5', enabled_by_default: false,
entity_category: 'diagnostic',
icon: 'mdi:brightness-5',
}, },
requested_brightness_percent: { requested_brightness_percent: {
enabled_by_default: false, entity_category: 'diagnostic', icon: 'mdi:brightness-5', enabled_by_default: false,
entity_category: 'diagnostic',
icon: 'mdi:brightness-5',
}, },
smoke_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'}, smoke_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
soil_moisture: {device_class: 'moisture', state_class: 'measurement'}, soil_moisture: {device_class: 'moisture', state_class: 'measurement'},
@ -892,7 +951,6 @@ export default class HomeAssistant extends Extension {
Object.assign(extraAttrs, {device_class: 'energy', state_class: 'total_increasing'}); Object.assign(extraAttrs, {device_class: 'energy', state_class: 'total_increasing'});
} }
const allowsSet = firstExpose.access & ACCESS_SET; const allowsSet = firstExpose.access & ACCESS_SET;
let key = firstExpose.name; let key = firstExpose.name;
@ -918,8 +976,7 @@ export default class HomeAssistant extends Extension {
// When a device_class is set, unit_of_measurement must be set, otherwise warnings are generated. // When a device_class is set, unit_of_measurement must be set, otherwise warnings are generated.
// https://github.com/Koenkk/zigbee2mqtt/issues/15958#issuecomment-1377483202 // https://github.com/Koenkk/zigbee2mqtt/issues/15958#issuecomment-1377483202
if (discoveryEntry.discovery_payload.device_class && if (discoveryEntry.discovery_payload.device_class && !discoveryEntry.discovery_payload.unit_of_measurement) {
!discoveryEntry.discovery_payload.unit_of_measurement) {
delete discoveryEntry.discovery_payload.device_class; delete discoveryEntry.discovery_payload.device_class;
} }
@ -1008,8 +1065,7 @@ export default class HomeAssistant extends Extension {
week: {entity_category: 'config', icon: 'mdi:calendar-clock'}, week: {entity_category: 'config', icon: 'mdi:calendar-clock'},
}; };
const valueTemplate = firstExpose.access & ACCESS_STATE ? const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json.${firstExpose.property} }}` : undefined;
`{{ value_json.${firstExpose.property} }}` : undefined;
if (firstExpose.access & ACCESS_STATE) { if (firstExpose.access & ACCESS_STATE) {
discoveryEntries.push({ discoveryEntries.push({
@ -1078,8 +1134,7 @@ export default class HomeAssistant extends Extension {
color_options: {icon: 'mdi:palette'}, color_options: {icon: 'mdi:palette'},
level_config: {entity_category: 'diagnostic'}, level_config: {entity_category: 'diagnostic'},
programming_mode: {icon: 'mdi:calendar-clock'}, programming_mode: {icon: 'mdi:calendar-clock'},
program: {value_template: `{{ value_json.${firstExpose.property}|default('',True) ` + program: {value_template: `{{ value_json.${firstExpose.property}|default('',True) ` + `| truncate(254, True, '', 0) }}`},
`| truncate(254, True, '', 0) }}`},
schedule_settings: {icon: 'mdi:calendar-clock'}, schedule_settings: {icon: 'mdi:calendar-clock'},
}; };
if (firstExpose.access & ACCESS_STATE) { if (firstExpose.access & ACCESS_STATE) {
@ -1119,9 +1174,9 @@ export default class HomeAssistant extends Extension {
// Exposes with category 'config' or 'diagnostic' are always added to the respective category. // Exposes with category 'config' or 'diagnostic' are always added to the respective category.
// This takes precedence over definitions in this file. // This takes precedence over definitions in this file.
if (firstExpose.category === 'config') { if (firstExpose.category === 'config') {
discoveryEntries.forEach((d) => d.discovery_payload.entity_category = 'config'); discoveryEntries.forEach((d) => (d.discovery_payload.entity_category = 'config'));
} else if (firstExpose.category === 'diagnostic') { } else if (firstExpose.category === 'diagnostic') {
discoveryEntries.forEach((d) => d.discovery_payload.entity_category = 'diagnostic'); discoveryEntries.forEach((d) => (d.discovery_payload.entity_category = 'diagnostic'));
} }
discoveryEntries.forEach((d) => { discoveryEntries.forEach((d) => {
@ -1273,12 +1328,15 @@ export default class HomeAssistant extends Extension {
// @ts-ignore // @ts-ignore
configs.push(entity.definition.homeassistant); configs.push(entity.definition.homeassistant);
} }
} else if (isGroup) { // group } else if (isGroup) {
// group
const exposesByType: {[s: string]: zhc.Expose[]} = {}; const exposesByType: {[s: string]: zhc.Expose[]} = {};
const allExposes: zhc.Expose[] = []; const allExposes: zhc.Expose[] = [];
entity.zh.members.map((e) => this.zigbee.resolveEntity(e.getDevice()) as Device) entity.zh.members
.filter((d) => d.definition).forEach((device) => { .map((e) => this.zigbee.resolveEntity(e.getDevice()) as Device)
.filter((d) => d.definition)
.forEach((device) => {
const exposes = device.exposes(); const exposes = device.exposes();
allExposes.push(...exposes); allExposes.push(...exposes);
for (const expose of exposes.filter((e) => groupSupportedTypes.includes(e.type))) { for (const expose of exposes.filter((e) => groupSupportedTypes.includes(e.type))) {
@ -1295,8 +1353,7 @@ export default class HomeAssistant extends Extension {
} }
}); });
configs = [].concat(...Object.values(exposesByType) configs = [].concat(...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, 'group', allExposes)));
.map((exposes) => this.exposeToConfig(exposes, 'group', allExposes)));
} else { } else {
// Discover bridge config. // Discover bridge config.
configs.push(...entity.configs); configs.push(...entity.configs);
@ -1370,8 +1427,7 @@ export default class HomeAssistant extends Extension {
value_template: `{{ value_json['update']['installed_version'] }}`, value_template: `{{ value_json['update']['installed_version'] }}`,
latest_version_template: `{{ value_json['update']['latest_version'] }}`, latest_version_template: `{{ value_json['update']['latest_version'] }}`,
json_attributes_topic: `${settings.get().mqtt.base_topic}/${entity.name}`, // state topic json_attributes_topic: `${settings.get().mqtt.base_topic}/${entity.name}`, // state topic
json_attributes_template: json_attributes_template: `{"in_progress": {{ iif(value_json['update']['state'] == 'updating', 'true', 'false') }} }`,
`{"in_progress": {{ iif(value_json['update']['state'] == 'updating', 'true', 'false') }} }`,
}, },
}; };
configs.push(updateSensor); configs.push(updateSensor);
@ -1431,8 +1487,10 @@ export default class HomeAssistant extends Extension {
if (isGroup && entity.zh.members.length === 0) { if (isGroup && entity.zh.members.length === 0) {
return; return;
} else if (isDevice && (!entity.definition || entity.zh.interviewing || } else if (
(entity.options.hasOwnProperty('homeassistant') && !entity.options.homeassistant))) { isDevice &&
(!entity.definition || entity.zh.interviewing || (entity.options.hasOwnProperty('homeassistant') && !entity.options.homeassistant))
) {
return; return;
} }
@ -1467,7 +1525,7 @@ export default class HomeAssistant extends Extension {
payload.tilt_status_topic = stateTopic; payload.tilt_status_topic = stateTopic;
} }
if (this.entityAttributes && (isDevice || isGroup) ) { if (this.entityAttributes && (isDevice || isGroup)) {
payload.json_attributes_topic = stateTopic; payload.json_attributes_topic = stateTopic;
} }
@ -1494,23 +1552,24 @@ export default class HomeAssistant extends Extension {
payload.origin = this.discoveryOrigin; payload.origin = this.discoveryOrigin;
// Availability payload (can be disabled by setting `payload.availability = false`). // Availability payload (can be disabled by setting `payload.availability = false`).
if (! payload.hasOwnProperty('availability') || payload.availability) { if (!payload.hasOwnProperty('availability') || payload.availability) {
payload.availability = [{topic: `${settings.get().mqtt.base_topic}/bridge/state`}]; payload.availability = [{topic: `${settings.get().mqtt.base_topic}/bridge/state`}];
if (isDevice||isGroup) { if (isDevice || isGroup) {
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) { if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
payload.availability_mode = 'all'; payload.availability_mode = 'all';
payload.availability.push({topic: `${baseTopic}/availability`}); payload.availability.push({topic: `${baseTopic}/availability`});
} }
} else { // Bridge availability is different. } else {
// Bridge availability is different.
payload.availability_mode = 'all'; payload.availability_mode = 'all';
} }
if (isDevice && entity.options.disabled) { if (isDevice && entity.options.disabled) {
// Mark disabled device always as unavailable // Mark disabled device always as unavailable
payload.availability.forEach((a: KeyValue) => a.value_template = '{{ "offline" }}'); payload.availability.forEach((a: KeyValue) => (a.value_template = '{{ "offline" }}'));
} else if (!settings.get().advanced.legacy_availability_payload) { } else if (!settings.get().advanced.legacy_availability_payload) {
payload.availability.forEach((a: KeyValue) => a.value_template = '{{ value_json.state }}'); payload.availability.forEach((a: KeyValue) => (a.value_template = '{{ value_json.state }}'));
} }
} else { } else {
delete payload.availability; delete payload.availability;
@ -1559,18 +1618,15 @@ export default class HomeAssistant extends Extension {
} }
if (payload.temperature_command_topic) { if (payload.temperature_command_topic) {
payload.temperature_command_topic = payload.temperature_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_command_topic}`;
`${baseTopic}/${commandTopicPrefix}set/${payload.temperature_command_topic}`;
} }
if (payload.temperature_low_command_topic) { if (payload.temperature_low_command_topic) {
payload.temperature_low_command_topic = payload.temperature_low_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_low_command_topic}`;
`${baseTopic}/${commandTopicPrefix}set/${payload.temperature_low_command_topic}`;
} }
if (payload.temperature_high_command_topic) { if (payload.temperature_high_command_topic) {
payload.temperature_high_command_topic = payload.temperature_high_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_high_command_topic}`;
`${baseTopic}/${commandTopicPrefix}set/${payload.temperature_high_command_topic}`;
} }
if (payload.fan_mode_state_topic) { if (payload.fan_mode_state_topic) {
@ -1606,8 +1662,7 @@ export default class HomeAssistant extends Extension {
} }
if (payload.preset_mode_command_topic) { if (payload.preset_mode_command_topic) {
payload.preset_mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/` + payload.preset_mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/` + payload.preset_mode_command_topic;
payload.preset_mode_command_topic;
} }
if (payload.action_topic) { if (payload.action_topic) {
@ -1622,8 +1677,7 @@ export default class HomeAssistant extends Extension {
return; return;
} else if (ignoreName && key === 'name') { } else if (ignoreName && key === 'name') {
return; return;
} else if (['number', 'string', 'boolean'].includes(typeof obj[key]) || } else if (['number', 'string', 'boolean'].includes(typeof obj[key]) || Array.isArray(obj[key])) {
Array.isArray(obj[key])) {
payload[key] = obj[key]; payload[key] = obj[key];
} else if (obj[key] === null) { } else if (obj[key] === null) {
delete payload[key]; delete payload[key];
@ -1680,8 +1734,7 @@ export default class HomeAssistant extends Extension {
return; return;
} }
if (!isDeviceAutomation && if (!isDeviceAutomation && (!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
(!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
return; return;
} }
} catch (e) { } catch (e) {
@ -1691,7 +1744,7 @@ export default class HomeAssistant extends Extension {
// Group discovery topic uses "ENCODEDBASETOPIC_GROUPID", device use ieeeAddr // Group discovery topic uses "ENCODEDBASETOPIC_GROUPID", device use ieeeAddr
const ID = discoveryMatch[2].includes('_') ? discoveryMatch[2].split('_')[1] : discoveryMatch[2]; const ID = discoveryMatch[2].includes('_') ? discoveryMatch[2].split('_')[1] : discoveryMatch[2];
const entity = ID === this.bridge.ID ? this.bridge : this.zigbee.resolveEntity(ID); const entity = ID === this.bridge.ID ? this.bridge : this.zigbee.resolveEntity(ID);
let clear = !entity || entity.isDevice() && !entity.definition; let clear = !entity || (entity.isDevice() && !entity.definition);
// Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml // Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml
if (entity) { if (entity) {
@ -1716,8 +1769,7 @@ export default class HomeAssistant extends Extension {
} else { } else {
this.getDiscovered(entity).messages[topic] = {payload: stringify(message), published: true}; this.getDiscovered(entity).messages[topic] = {payload: stringify(message), published: true};
} }
} else if ((data.topic === this.statusTopic || data.topic === defaultStatusTopic) && } else if ((data.topic === this.statusTopic || data.topic === defaultStatusTopic) && data.message.toLowerCase() === 'online') {
data.message.toLowerCase() === 'online') {
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
// Publish all device states. // Publish all device states.
for (const entity of [...this.zigbee.devices(false), ...this.zigbee.groups()]) { for (const entity of [...this.zigbee.devices(false), ...this.zigbee.groups()]) {
@ -1762,8 +1814,7 @@ export default class HomeAssistant extends Extension {
} }
private getDevicePayload(entity: Device | Group | Bridge): KeyValue { private getDevicePayload(entity: Device | Group | Bridge): KeyValue {
const identifierPostfix = entity.isGroup() ? const identifierPostfix = entity.isGroup() ? `zigbee2mqtt_${this.getEncodedBaseTopic()}` : 'zigbee2mqtt';
`zigbee2mqtt_${this.getEncodedBaseTopic()}` : 'zigbee2mqtt';
// Allow device name to be overridden by homeassistant config // Allow device name to be overridden by homeassistant config
let deviceName = entity.name; let deviceName = entity.name;
@ -1830,7 +1881,11 @@ export default class HomeAssistant extends Extension {
} }
private getEncodedBaseTopic(): string { private getEncodedBaseTopic(): string {
return settings.get().mqtt.base_topic.split('').map((s) => s.charCodeAt(0).toString()).join(''); return settings
.get()
.mqtt.base_topic.split('')
.map((s) => s.charCodeAt(0).toString())
.join('');
} }
private getDiscoveryTopic(config: DiscoveryEntry, entity: Device | Group | Bridge): string { private getDiscoveryTopic(config: DiscoveryEntry, entity: Device | Group | Bridge): string {
@ -1838,11 +1893,12 @@ export default class HomeAssistant extends Extension {
return `${config.type}/${key}/${config.object_id}/config`; return `${config.type}/${key}/${config.object_id}/config`;
} }
private async publishDeviceTriggerDiscover(device: Device, key: string, value: string, force=false): Promise<void> { private async publishDeviceTriggerDiscover(device: Device, key: string, value: string, force = false): Promise<void> {
const haConfig = device.options.homeassistant; const haConfig = device.options.homeassistant;
if (device.options.hasOwnProperty('homeassistant') && (haConfig == null || if (
(haConfig.hasOwnProperty('device_automation') && typeof haConfig === 'object' && device.options.hasOwnProperty('homeassistant') &&
haConfig.device_automation == null))) { (haConfig == null || (haConfig.hasOwnProperty('device_automation') && typeof haConfig === 'object' && haConfig.device_automation == null))
) {
return; return;
} }
@ -1944,8 +2000,7 @@ export default class HomeAssistant extends Extension {
state_topic_postfix: 'info', state_topic_postfix: 'info',
value_template: '{{ value_json.log_level | lower }}', value_template: '{{ value_json.log_level | lower }}',
command_topic: `${baseTopic}/request/options`, command_topic: `${baseTopic}/request/options`,
command_template: command_template: '{"options": {"advanced": {"log_level": "{{ value }}" } } }',
'{"options": {"advanced": {"log_level": "{{ value }}" } } }',
options: settings.LOG_LEVELS, options: settings.LOG_LEVELS,
}, },
}, },
@ -1962,7 +2017,6 @@ export default class HomeAssistant extends Extension {
state_topic_postfix: 'info', state_topic_postfix: 'info',
value_template: '{{ value_json.version }}', value_template: '{{ value_json.version }}',
}, },
}, },
{ {
type: 'sensor', type: 'sensor',
@ -1977,7 +2031,6 @@ export default class HomeAssistant extends Extension {
state_topic_postfix: 'info', state_topic_postfix: 'info',
value_template: '{{ value_json.coordinator.meta.revision }}', value_template: '{{ value_json.coordinator.meta.revision }}',
}, },
}, },
{ {
type: 'sensor', type: 'sensor',
@ -1989,7 +2042,7 @@ export default class HomeAssistant extends Extension {
enabled_by_default: false, enabled_by_default: false,
state_topic: true, state_topic: true,
state_topic_postfix: 'response/networkmap', state_topic_postfix: 'response/networkmap',
value_template: '{{ now().strftime(\'%Y-%m-%d %H:%M:%S\') }}', value_template: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}",
json_attributes_topic: `${baseTopic}/response/networkmap`, json_attributes_topic: `${baseTopic}/response/networkmap`,
json_attributes_template: '{{ value_json.data.value | tojson }}', json_attributes_template: '{{ value_json.data.value | tojson }}',
}, },
@ -2005,8 +2058,7 @@ export default class HomeAssistant extends Extension {
entity_category: 'diagnostic', entity_category: 'diagnostic',
state_topic: true, state_topic: true,
state_topic_postfix: 'info', state_topic_postfix: 'info',
value_template: value_template: '{{ iif(value_json.permit_join_timeout is defined, value_json.permit_join_timeout, None) }}',
'{{ iif(value_json.permit_join_timeout is defined, value_json.permit_join_timeout, None) }}',
}, },
}, },

View File

@ -1,13 +1,13 @@
import * as settings from '../../util/settings';
import logger from '../../util/logger';
import utils from '../../util/utils';
import assert from 'assert'; import assert from 'assert';
import Extension from '../extension';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
const configRegex = import logger from '../../util/logger';
new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`); import * as settings from '../../util/settings';
import utils from '../../util/utils';
import Extension from '../extension';
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
export default class BridgeLegacy extends Extension { export default class BridgeLegacy extends Extension {
private lastJoinedDeviceName: string = null; private lastJoinedDeviceName: string = null;
@ -15,24 +15,24 @@ export default class BridgeLegacy extends Extension {
override async start(): Promise<void> { override async start(): Promise<void> {
this.supportedOptions = { this.supportedOptions = {
'permit_join': this.permitJoin, permit_join: this.permitJoin,
'last_seen': this.lastSeen, last_seen: this.lastSeen,
'elapsed': this.elapsed, elapsed: this.elapsed,
'reset': this.reset, reset: this.reset,
'log_level': this.logLevel, log_level: this.logLevel,
'devices': this.devices, devices: this.devices,
'groups': this.groups, groups: this.groups,
'devices/get': this.devices, 'devices/get': this.devices,
'rename': this.rename, rename: this.rename,
'rename_last': this.renameLast, rename_last: this.renameLast,
'remove': this.remove, remove: this.remove,
'force_remove': this.forceRemove, force_remove: this.forceRemove,
'ban': this.ban, ban: this.ban,
'device_options': this.deviceOptions, device_options: this.deviceOptions,
'add_group': this.addGroup, add_group: this.addGroup,
'remove_group': this.removeGroup, remove_group: this.removeGroup,
'force_remove_group': this.removeGroup, force_remove_group: this.removeGroup,
'whitelist': this.whitelist, whitelist: this.whitelist,
'touchlink/factory_reset': this.touchlinkFactoryReset, 'touchlink/factory_reset': this.touchlinkFactoryReset,
}; };
@ -51,10 +51,7 @@ export default class BridgeLegacy extends Extension {
assert(entity, `Entity '${message}' does not exist`); assert(entity, `Entity '${message}' does not exist`);
settings.addDeviceToPasslist(entity.ID.toString()); settings.addDeviceToPasslist(entity.ID.toString());
logger.info(`Whitelisted '${entity.friendly_name}'`); logger.info(`Whitelisted '${entity.friendly_name}'`);
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}));
'bridge/log',
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}),
);
} catch (error) { } catch (error) {
logger.error(`Failed to whitelist '${message}' '${error}'`); logger.error(`Failed to whitelist '${message}' '${error}'`);
} }
@ -162,9 +159,7 @@ export default class BridgeLegacy extends Extension {
}); });
if (topic.split('/').pop() == 'get') { if (topic.split('/').pop() == 'get') {
await this.mqtt.publish( await this.mqtt.publish(`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false);
`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false,
);
} else { } else {
await this.mqtt.publish('bridge/log', stringify({type: 'devices', message: devices})); await this.mqtt.publish('bridge/log', stringify({type: 'devices', message: devices}));
} }
@ -179,8 +174,7 @@ export default class BridgeLegacy extends Extension {
} }
@bind async rename(topic: string, message: string): Promise<void> { @bind async rename(topic: string, message: string): Promise<void> {
const invalid = const invalid = `Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
`Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
let json = null; let json = null;
try { try {
@ -218,10 +212,7 @@ export default class BridgeLegacy extends Extension {
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity}); this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
} }
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}));
'bridge/log',
stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}),
);
} catch (error) { } catch (error) {
logger.error(`Failed to rename - ${from} to ${to}`); logger.error(`Failed to rename - ${from} to ${to}`);
} }
@ -377,40 +368,25 @@ export default class BridgeLegacy extends Extension {
} }
if (type === 'deviceJoined') { if (type === 'deviceJoined') {
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
'bridge/log',
stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}),
);
} else if (type === 'deviceInterview') { } else if (type === 'deviceInterview') {
if (data.status === 'successful') { if (data.status === 'successful') {
if (resolvedEntity.isSupported) { if (resolvedEntity.isSupported) {
const {vendor, description, model} = resolvedEntity.definition; const {vendor, description, model} = resolvedEntity.definition;
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true}; const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
'bridge/log',
stringify({type: `pairing`, message: 'interview_successful', meta: log}),
);
} else { } else {
const meta = {friendly_name: resolvedEntity.name, supported: false}; const meta = {friendly_name: resolvedEntity.name, supported: false};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
'bridge/log',
stringify({type: `pairing`, message: 'interview_successful', meta}),
);
} }
} else if (data.status === 'failed') { } else if (data.status === 'failed') {
const meta = {friendly_name: resolvedEntity.name}; const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
'bridge/log',
stringify({type: `pairing`, message: 'interview_failed', meta}),
);
} else { } else {
/* istanbul ignore else */ /* istanbul ignore else */
if (data.status === 'started') { if (data.status === 'started') {
const meta = {friendly_name: resolvedEntity.name}; const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
'bridge/log',
stringify({type: `pairing`, message: 'interview_started', meta}),
);
} }
} }
} else if (type === 'deviceAnnounce') { } else if (type === 'deviceAnnounce') {
@ -421,34 +397,22 @@ export default class BridgeLegacy extends Extension {
if (type === 'deviceLeave') { if (type === 'deviceLeave') {
const name = data.ieeeAddr; const name = data.ieeeAddr;
const meta = {friendly_name: name}; const meta = {friendly_name: name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `device_removed`, message: 'left_network', meta}));
'bridge/log',
stringify({type: `device_removed`, message: 'left_network', meta}),
);
} }
} }
} }
@bind async touchlinkFactoryReset(): Promise<void> { @bind async touchlinkFactoryReset(): Promise<void> {
logger.info('Starting touchlink factory reset...'); logger.info('Starting touchlink factory reset...');
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_started', meta: {status: 'started'}}));
'bridge/log',
stringify({type: `touchlink`, message: 'reset_started', meta: {status: 'started'}}),
);
const result = await this.zigbee.touchlinkFactoryResetFirst(); const result = await this.zigbee.touchlinkFactoryResetFirst();
if (result) { if (result) {
logger.info('Successfully factory reset device through Touchlink'); logger.info('Successfully factory reset device through Touchlink');
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}));
'bridge/log',
stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}),
);
} else { } else {
logger.warning('Failed to factory reset device through Touchlink'); logger.warning('Failed to factory reset device through Touchlink');
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}));
'bridge/log',
stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}),
);
} }
} }
} }

View File

@ -1,9 +1,10 @@
/* istanbul ignore file */ /* istanbul ignore file */
import * as settings from '../../util/settings';
import logger from '../../util/logger';
import Extension from '../extension';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import Device from '../../model/device'; import Device from '../../model/device';
import logger from '../../util/logger';
import * as settings from '../../util/settings';
import Extension from '../extension';
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`); const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/device/(.+)/get_group_membership$`);
@ -31,16 +32,14 @@ export default class DeviceGroupMembership extends Extension {
return; return;
} }
const response = await endpoint.command( const response = await endpoint.command(`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {});
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {},
);
if (!response) { if (!response) {
logger.warning(`Couldn't get group membership of ${device.ieeeAddr}`); logger.warning(`Couldn't get group membership of ${device.ieeeAddr}`);
return; return;
} }
let {grouplist, capacity} = response; let {grouplist} = response;
grouplist = grouplist.map((gid: string) => { grouplist = grouplist.map((gid: string) => {
const g = settings.getGroup(gid); const g = settings.getGroup(gid);
@ -49,13 +48,13 @@ export default class DeviceGroupMembership extends Extension {
const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`; const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`;
let msgCapacity; let msgCapacity;
if (capacity === 254) { if (response.capacity === 254) {
msgCapacity = 'it can be a part of at least 1 more group'; msgCapacity = 'it can be a part of at least 1 more group';
} else { } else {
msgCapacity = `its remaining group capacity is ${capacity === 255 ? 'unknown' : capacity}`; msgCapacity = `its remaining group capacity is ${response.capacity === 255 ? 'unknown' : response.capacity}`;
} }
logger.info(`${msgGroupList} and ${msgCapacity}`); logger.info(`${msgGroupList} and ${msgCapacity}`);
await this.publishEntityState(device, {group_list: grouplist, group_capacity: capacity}); await this.publishEntityState(device, {group_list: grouplist, group_capacity: response.capacity});
} }
} }

View File

@ -1,10 +1,13 @@
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
import logger from '../../util/logger'; import logger from '../../util/logger';
import * as settings from '../../util/settings'; import * as settings from '../../util/settings';
import Extension from '../extension'; import Extension from '../extension';
const defaultConfiguration = { const defaultConfiguration = {
minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 1, minimumReportInterval: 3,
maximumReportInterval: 300,
reportableChange: 1,
}; };
const ZNLDP12LM = zhc.definitions.find((d) => d.model === 'ZNLDP12LM'); const ZNLDP12LM = zhc.definitions.find((d) => d.model === 'ZNLDP12LM');
@ -19,43 +22,47 @@ const devicesNotSupportingReporting = [
const reportKey = 1; const reportKey = 1;
const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean, colorXY: boolean}> => { const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemperature: boolean; colorXY: boolean}> => {
if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) { if (endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') === undefined) {
await endpoint.read('lightingColorCtrl', ['colorCapabilities']); await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
} }
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number; const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
return { return {
colorTemperature: (value & 1<<4) > 0, colorTemperature: (value & (1 << 4)) > 0,
colorXY: (value & 1<<3) > 0, colorXY: (value & (1 << 3)) > 0,
}; };
}; };
const clusters: {[s: string]: const clusters: {
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number [s: string]: {
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} = attribute: string;
{ minimumReportInterval: number;
'genOnOff': [ maximumReportInterval: number;
{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0}, reportableChange: number;
], condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
'genLevelCtrl': [ }[];
{attribute: 'currentLevel', ...defaultConfiguration}, } = {
], genOnOff: [{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0}],
'lightingColorCtrl': [ genLevelCtrl: [{attribute: 'currentLevel', ...defaultConfiguration}],
lightingColorCtrl: [
{ {
attribute: 'colorTemperature', ...defaultConfiguration, attribute: 'colorTemperature',
...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature, condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
}, },
{ {
attribute: 'currentX', ...defaultConfiguration, attribute: 'currentX',
...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY, condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
}, },
{ {
attribute: 'currentY', ...defaultConfiguration, attribute: 'currentY',
...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY, condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
}, },
], ],
'closuresWindowCovering': [ closuresWindowCovering: [
{attribute: 'currentPositionLiftPercentage', ...defaultConfiguration}, {attribute: 'currentPositionLiftPercentage', ...defaultConfiguration},
{attribute: 'currentPositionTiltPercentage', ...defaultConfiguration}, {attribute: 'currentPositionTiltPercentage', ...defaultConfiguration},
], ],
@ -86,28 +93,25 @@ export default class Report extends Extension {
try { try {
for (const ep of device.zh.endpoints) { for (const ep of device.zh.endpoints) {
for (const [cluster, configuration] of Object.entries(clusters)) { for (const [cluster, configuration] of Object.entries(clusters)) {
if (ep.supportsInputCluster(cluster) && if (ep.supportsInputCluster(cluster) && !this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
!this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`); logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
const items = []; const items = [];
for (const entry of configuration) { for (const entry of configuration) {
if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) { if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) {
const toAdd = {...entry}; const toAdd = {...entry};
if (!this.enabled) toAdd.maximumReportInterval = 0xFFFF; if (!this.enabled) toAdd.maximumReportInterval = 0xffff;
items.push(toAdd); items.push(toAdd);
delete items[items.length - 1].condition; delete items[items.length - 1].condition;
} }
} }
this.enabled ? this.enabled
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint()) : ? await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint())
await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint()); : await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
await ep.configureReporting(cluster, items); await ep.configureReporting(cluster, items);
logger.info( logger.info(`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`,
);
} }
} }
} }
@ -121,9 +125,7 @@ export default class Report extends Extension {
this.eventBus.emitDevicesChanged(); this.eventBus.emitDevicesChanged();
} catch (error) { } catch (error) {
logger.error( logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`);
`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`,
);
this.failed.add(device.ieeeAddr); this.failed.add(device.ieeeAddr);
} }
@ -143,26 +145,26 @@ export default class Report extends Extension {
// else reconfigure is done in zigbee-herdsman-converters ikea.js/bulbOnEvent // else reconfigure is done in zigbee-herdsman-converters ikea.js/bulbOnEvent
// configuredReportings are saved since Zigbee2MQTT 1.17.0 // configuredReportings are saved since Zigbee2MQTT 1.17.0
// https://github.com/Koenkk/zigbee2mqtt/issues/966 // https://github.com/Koenkk/zigbee2mqtt/issues/966
if (this.enabled && messageType === 'deviceAnnounce' && device.isIkeaTradfri() && if (
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length === this.enabled &&
device.zh.endpoints.length) { messageType === 'deviceAnnounce' &&
device.isIkeaTradfri() &&
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length === device.zh.endpoints.length
) {
return true; return true;
} }
// These do not support reproting. // These do not support reproting.
// https://github.com/Koenkk/zigbee-herdsman/issues/110 // https://github.com/Koenkk/zigbee-herdsman/issues/110
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000']; const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
if (device.zh.manufacturerName === 'Philips' && if (device.zh.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
if (device.zh.interviewing === true) return false; if (device.zh.interviewing === true) return false;
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false; if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
// Gledopto devices don't support reporting. // Gledopto devices don't support reporting.
if (devicesNotSupportingReporting.includes(device.definition) || if (devicesNotSupportingReporting.includes(device.definition) || device.definition.vendor === 'Gledopto') return false;
device.definition.vendor === 'Gledopto') return false;
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') && if (this.enabled && device.zh.meta.hasOwnProperty('reporting') && device.zh.meta.reporting === reportKey) {
device.zh.meta.reporting === reportKey) {
return false; return false;
} }

View File

@ -1,7 +1,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
import logger from '../../util/logger';
// DEPRECATED // DEPRECATED
import * as settings from '../../util/settings'; import * as settings from '../../util/settings';
import logger from '../../util/logger';
import utils from '../../util/utils'; import utils from '../../util/utils';
import Extension from '../extension'; import Extension from '../extension';

View File

@ -1,22 +1,37 @@
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import logger from '../util/logger';
import * as settings from '../util/settings'; import * as settings from '../util/settings';
import utils from '../util/utils'; import utils from '../util/utils';
import logger from '../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import Extension from './extension'; import Extension from './extension';
import bind from 'bind-decorator';
interface Link { interface Link {
source: {ieeeAddr: string, networkAddress: number}, target: {ieeeAddr: string, networkAddress: number}, source: {ieeeAddr: string; networkAddress: number};
linkquality: number, depth: number, routes: zh.RoutingTableEntry[], target: {ieeeAddr: string; networkAddress: number};
sourceIeeeAddr: string, targetIeeeAddr: string, sourceNwkAddr: number, lqi: number, relationship: number, linkquality: number;
depth: number;
routes: zh.RoutingTableEntry[];
sourceIeeeAddr: string;
targetIeeeAddr: string;
sourceNwkAddr: number;
lqi: number;
relationship: number;
} }
interface Topology { interface Topology {
nodes: { nodes: {
ieeeAddr: string, friendlyName: string, type: string, networkAddress: number, manufacturerName: string, ieeeAddr: string;
modelID: string, failed: string[], lastSeen: number, friendlyName: string;
definition: {model: string, vendor: string, supports: string, description: string}}[], type: string;
links: Link[], networkAddress: number;
manufacturerName: string;
modelID: string;
failed: string[];
lastSeen: number;
definition: {model: string; vendor: string; supports: string; description: string};
}[];
links: Link[];
} }
/** /**
@ -32,17 +47,16 @@ export default class NetworkMap extends Extension {
override async start(): Promise<void> { override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage); this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.supportedFormats = { this.supportedFormats = {
'raw': this.raw, raw: this.raw,
'graphviz': this.graphviz, graphviz: this.graphviz,
'plantuml': this.plantuml, plantuml: this.plantuml,
}; };
} }
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
/* istanbul ignore else */ /* istanbul ignore else */
if (this.legacyApi) { if (this.legacyApi) {
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) && if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) && this.supportedFormats.hasOwnProperty(data.message)) {
this.supportedFormats.hasOwnProperty(data.message)) {
const includeRoutes = data.topic === this.legacyTopicRoutes; const includeRoutes = data.topic === this.legacyTopicRoutes;
const topology = await this.networkScan(includeRoutes); const topology = await this.networkScan(includeRoutes);
let converted = this.supportedFormats[data.message](topology); let converted = this.supportedFormats[data.message](topology);
@ -62,15 +76,9 @@ export default class NetworkMap extends Extension {
const routes = typeof message === 'object' && message.routes; const routes = typeof message === 'object' && message.routes;
const topology = await this.networkScan(routes); const topology = await this.networkScan(routes);
const value = this.supportedFormats[type](topology); const value = this.supportedFormats[type](topology);
await this.mqtt.publish( await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value}, null)));
'bridge/response/networkmap',
stringify(utils.getResponse(message, {routes, type, value}, null)),
);
} catch (error) { } catch (error) {
await this.mqtt.publish( await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, error.message)));
'bridge/response/networkmap',
stringify(utils.getResponse(message, {}, error.message)),
);
} }
} }
} }
@ -94,7 +102,7 @@ export default class NetworkMap extends Extension {
// Add the device short network address, ieeaddr and scan note (if any) // Add the device short network address, ieeaddr and scan note (if any)
labels.push( labels.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` + `${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
((node.failed && node.failed.length) ? `failed: ${node.failed.join(',')}` : ''), (node.failed && node.failed.length ? `failed: ${node.failed.join(',')}` : ''),
); );
// Add the device model // Add the device model
@ -113,35 +121,31 @@ export default class NetworkMap extends Extension {
// Shape the record according to device type // Shape the record according to device type
if (node.type == 'Coordinator') { if (node.type == 'Coordinator') {
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` + style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` + `fontcolor="${colors.font.coordinator}"`;
`fontcolor="${colors.font.coordinator}"`;
} else if (node.type == 'Router') { } else if (node.type == 'Router') {
style = `style="rounded, filled", fillcolor="${colors.fill.router}", ` + style = `style="rounded, filled", fillcolor="${colors.fill.router}", ` + `fontcolor="${colors.font.router}"`;
`fontcolor="${colors.font.router}"`;
} else { } else {
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", ` + style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", ` + `fontcolor="${colors.font.enddevice}"`;
`fontcolor="${colors.font.enddevice}"`;
} }
// Add the device with its labels to the graph as a node. // Add the device with its labels to the graph as a node.
text += ` "${node.ieeeAddr}" [`+style+`, label="{${labels.join('|')}}"];\n`; text += ` "${node.ieeeAddr}" [${style}, label="{${labels.join('|')}}"];\n`;
/** /**
* Add an edge between the device and its child to the graph * Add an edge between the device and its child to the graph
* NOTE: There are situations where a device is NOT in the topology, this can be e.g. * NOTE: There are situations where a device is NOT in the topology, this can be e.g.
* due to not responded to the lqi scan. In that case we do not add an edge for this device. * due to not responded to the lqi scan. In that case we do not add an edge for this device.
*/ */
topology.links.filter((e) => (e.source.ieeeAddr === node.ieeeAddr)).forEach((e) => { topology.links
const lineStyle = (node.type=='EndDevice') ? 'penwidth=1, ' : .filter((e) => e.source.ieeeAddr === node.ieeeAddr)
(!e.routes.length) ? 'penwidth=0.5, ' : 'penwidth=2, '; .forEach((e) => {
const lineWeight = (!e.routes.length) ? `weight=0, color="${colors.line.inactive}", ` : const lineStyle = node.type == 'EndDevice' ? 'penwidth=1, ' : !e.routes.length ? 'penwidth=0.5, ' : 'penwidth=2, ';
`weight=1, color="${colors.line.active}", `; const lineWeight = !e.routes.length ? `weight=0, color="${colors.line.inactive}", ` : `weight=1, color="${colors.line.active}", `;
const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress)); const textRoutes = e.routes.map((r) => utils.toNetworkAddressHex(r.destinationAddress));
const lineLabels = (!e.routes.length) ? `label="${e.linkquality}"` : const lineLabels = !e.routes.length ? `label="${e.linkquality}"` : `label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
`label="${e.linkquality} (routes: ${textRoutes.join(',')})"`; text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`; text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`; });
});
}); });
text += '}'; text += '}';
@ -156,36 +160,38 @@ export default class NetworkMap extends Extension {
text.push(``); text.push(``);
text.push('@startuml'); text.push('@startuml');
topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName)).forEach((node) => { topology.nodes
// Add friendly name .sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))
text.push(`card ${node.ieeeAddr} [`); .forEach((node) => {
text.push(`${node.friendlyName}`); // Add friendly name
text.push(`---`); text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
// Add the device short network address, ieeaddr and scan note (if any)
text.push(
`${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
((node.failed && node.failed.length) ? ` failed: ${node.failed.join(',')}` : ''),
);
// Add the device model
if (node.type !== 'Coordinator') {
text.push(`---`); text.push(`---`);
const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
}
// Add the device last_seen timestamp // Add the device short network address, ieeaddr and scan note (if any)
let lastSeen = 'unknown'; text.push(
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen; `${node.ieeeAddr} (${utils.toNetworkAddressHex(node.networkAddress)})` +
if (date) { (node.failed && node.failed.length ? ` failed: ${node.failed.join(',')}` : ''),
lastSeen = utils.formatDate(date, 'relative') as string; );
}
text.push(`---`); // Add the device model
text.push(lastSeen); if (node.type !== 'Coordinator') {
text.push(`]`); text.push(`---`);
text.push(``); const definition = (this.zigbee.resolveEntity(node.ieeeAddr) as Device).definition;
}); text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
}
// Add the device last_seen timestamp
let lastSeen = 'unknown';
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, 'relative') as string;
}
text.push(`---`);
text.push(lastSeen);
text.push(`]`);
text.push(``);
});
/** /**
* Add edges between the devices * Add edges between the devices
@ -254,19 +260,30 @@ export default class NetworkMap extends Extension {
const topology: Topology = {nodes: [], links: []}; const topology: Topology = {nodes: [], links: []};
// Add nodes // Add nodes
for (const device of devices) { for (const device of devices) {
const definition = device.definition ? { const definition = device.definition
model: device.definition.model, ? {
vendor: device.definition.vendor, model: device.definition.model,
description: device.definition.description, vendor: device.definition.vendor,
supports: Array.from(new Set((device.exposes()).map((e) => { description: device.definition.description,
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`; supports: Array.from(
}))).join(', '), new Set(
} : null; device.exposes().map((e) => {
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`;
}),
),
).join(', '),
}
: null;
topology.nodes.push({ topology.nodes.push({
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.zh.type, ieeeAddr: device.ieeeAddr,
networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName, friendlyName: device.name,
modelID: device.zh.modelID, failed: failed.get(device), lastSeen: device.zh.lastSeen, type: device.zh.type,
networkAddress: device.zh.networkAddress,
manufacturerName: device.zh.manufacturerName,
modelID: device.zh.modelID,
failed: failed.get(device),
lastSeen: device.zh.lastSeen,
definition, definition,
}); });
} }
@ -289,17 +306,20 @@ export default class NetworkMap extends Extension {
const link: Link = { const link: Link = {
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress}, source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress}, target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [], linkquality: neighbor.linkquality,
depth: neighbor.depth,
routes: [],
// DEPRECATED: // DEPRECATED:
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr, sourceIeeeAddr: neighbor.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress, lqi: neighbor.linkquality, targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress,
lqi: neighbor.linkquality,
relationship: neighbor.relationship, relationship: neighbor.relationship,
}; };
const routingTable = routingTables.get(device); const routingTable = routingTables.get(device);
if (routingTable) { if (routingTable) {
link.routes = routingTable.table link.routes = routingTable.table.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
} }
topology.links.push(link); topology.links.push(link);

View File

@ -1,4 +1,5 @@
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
import Extension from './extension'; import Extension from './extension';
/** /**
@ -11,21 +12,17 @@ export default class OnEvent extends Extension {
} }
this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data))); this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data)));
this.eventBus.onDeviceJoined(this, this.eventBus.onDeviceJoined(this, (data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
(data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data))); this.eventBus.onDeviceInterview(this, (data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
this.eventBus.onDeviceInterview(this, this.eventBus.onDeviceAnnounce(this, (data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
(data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data))); this.eventBus.onDeviceNetworkAddressChanged(this, (data) =>
this.eventBus.onDeviceAnnounce(this, this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)),
(data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data))); );
this.eventBus.onDeviceNetworkAddressChanged(this, this.eventBus.onEntityOptionsChanged(this, async (data) => {
(data) => this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data))); if (data.entity.isDevice()) {
this.eventBus.onEntityOptionsChanged(this, await this.callOnEvent(data.entity, 'deviceOptionsChanged', data).then(() => this.eventBus.emitDevicesChanged());
async (data) => { }
if (data.entity.isDevice()) { });
await this.callOnEvent(data.entity, 'deviceOptionsChanged', data)
.then(() => this.eventBus.emitDevicesChanged());
}
});
} }
private convertData(data: KeyValue): KeyValue { private convertData(data: KeyValue): KeyValue {

View File

@ -1,15 +1,16 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import utils from '../util/utils';
import Extension from './extension';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import path from 'path';
import * as URI from 'uri-js';
import {Zcl} from 'zigbee-herdsman';
import * as zhc from 'zigbee-herdsman-converters';
import Device from '../model/device'; import Device from '../model/device';
import dataDir from '../util/data'; import dataDir from '../util/data';
import * as URI from 'uri-js'; import logger from '../util/logger';
import path from 'path'; import * as settings from '../util/settings';
import * as zhc from 'zigbee-herdsman-converters'; import utils from '../util/utils';
import {Zcl} from 'zigbee-herdsman'; import Extension from './extension';
function isValidUrl(url: string): boolean { function isValidUrl(url: string): boolean {
let parsed; let parsed;
@ -24,17 +25,19 @@ function isValidUrl(url: string): boolean {
type UpdateState = 'updating' | 'idle' | 'available'; type UpdateState = 'updating' | 'idle' | 'available';
interface UpdatePayload { interface UpdatePayload {
update_available?: boolean update_available?: boolean;
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
update: { update: {
progress?: number, remaining?: number, state: UpdateState, progress?: number;
installed_version: number | null, latest_version: number | null remaining?: number;
} state: UpdateState;
installed_version: number | null;
latest_version: number | null;
};
} }
const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`); const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`);
const topicRegex = const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
export default class OTAUpdate extends Extension { export default class OTAUpdate extends Extension {
private inProgress = new Set(); private inProgress = new Set();
@ -79,8 +82,7 @@ export default class OTAUpdate extends Extension {
} }
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> { @bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition || if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) return;
this.inProgress.has(data.device.ieeeAddr)) return;
logger.debug(`Device '${data.device.name}' requested OTA`); logger.debug(`Device '${data.device.name}' requested OTA`);
const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check; const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check;
@ -90,8 +92,9 @@ export default class OTAUpdate extends Extension {
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update // with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day). // each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60; const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr) ? const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr)
(Date.now() - this.lastChecked[data.device.ieeeAddr]) > updateCheckInterval : true; ? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
: true;
if (!check) return; if (!check) return;
this.lastChecked[data.device.ieeeAddr] = Date.now(); this.lastChecked[data.device.ieeeAddr] = Date.now();
@ -113,23 +116,24 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */ /* istanbul ignore else */
if (settings.get().advanced.legacy_api) { if (settings.get().advanced.legacy_api) {
const meta = {status: 'available', device: data.device.name}; const meta = {status: 'available', device: data.device.name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
'bridge/log',
stringify({type: `ota_update`, message, meta}),
);
} }
} }
} }
// Respond to stop the client from requesting OTAs // Respond to stop the client from requesting OTAs
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint; const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint;
await endpoint.commandResponse('genOta', 'queryNextImageResponse', await endpoint.commandResponse(
{status: Zcl.Status.NO_IMAGE_AVAILABLE}, undefined, data.meta.zclTransactionSequenceNumber); 'genOta',
'queryNextImageResponse',
{status: Zcl.Status.NO_IMAGE_AVAILABLE},
undefined,
data.meta.zclTransactionSequenceNumber,
);
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`); logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
} }
private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'): private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'): Promise<{softwareBuildID: string; dateCode: string}> {
Promise<{softwareBuildID: string, dateCode: string}> {
try { try {
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic')); const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy}); const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy});
@ -139,16 +143,20 @@ export default class OTAUpdate extends Extension {
} }
} }
private getEntityPublishPayload(device: Device, state: zhc.OtaUpdateAvailableResult | UpdateState, private getEntityPublishPayload(
progress: number=null, remaining: number=null): UpdatePayload { device: Device,
state: zhc.OtaUpdateAvailableResult | UpdateState,
progress: number = null,
remaining: number = null,
): UpdatePayload {
const deviceUpdateState = this.state.get(device).update; const deviceUpdateState = this.state.get(device).update;
const payload: UpdatePayload = {update: { const payload: UpdatePayload = {
state: typeof state === 'string' ? state : (state.available ? 'available' : 'idle'), update: {
installed_version: typeof state === 'string' ? state: typeof state === 'string' ? state : state.available ? 'available' : 'idle',
deviceUpdateState?.installed_version : state.currentFileVersion, installed_version: typeof state === 'string' ? deviceUpdateState?.installed_version : state.currentFileVersion,
latest_version: typeof state === 'string' ? latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
deviceUpdateState?.latest_version : state.otaFileVersion, },
}}; };
if (progress !== null) payload.update.progress = progress; if (progress !== null) payload.update.progress = progress;
if (remaining !== null) payload.update.remaining = Math.round(remaining); if (remaining !== null) payload.update.remaining = Math.round(remaining);
@ -169,7 +177,7 @@ export default class OTAUpdate extends Extension {
const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string; const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string;
const device = this.zigbee.resolveEntity(ID); const device = this.zigbee.resolveEntity(ID);
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1); const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
const responseData: {id: string, updateAvailable?: boolean, from?: string, to?: string}= {id: ID}; const responseData: {id: string; updateAvailable?: boolean; from?: string; to?: string} = {id: ID};
let error = null; let error = null;
let errorStack = null; let errorStack = null;
@ -181,10 +189,7 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */ /* istanbul ignore else */
if (settings.get().advanced.legacy_api) { if (settings.get().advanced.legacy_api) {
const meta = {status: `not_supported`, device: device.name}; const meta = {status: `not_supported`, device: device.name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
'bridge/log',
stringify({type: `ota_update`, message: error, meta}),
);
} }
} else if (this.inProgress.has(device.ieeeAddr)) { } else if (this.inProgress.has(device.ieeeAddr)) {
error = `Update or check for update already in progress for '${device.name}'`; error = `Update or check for update already in progress for '${device.name}'`;
@ -198,10 +203,7 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */ /* istanbul ignore else */
if (settings.get().advanced.legacy_api) { if (settings.get().advanced.legacy_api) {
const meta = {status: `checking_if_available`, device: device.name}; const meta = {status: `checking_if_available`, device: device.name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
} }
try { try {
@ -212,11 +214,10 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */ /* istanbul ignore else */
if (settings.get().advanced.legacy_api) { if (settings.get().advanced.legacy_api) {
const meta = { const meta = {
status: availableResult.available ? 'available' : 'not_available', device: device.name}; status: availableResult.available ? 'available' : 'not_available',
await this.mqtt.publish( device: device.name,
'bridge/log', };
stringify({type: `ota_update`, message: msg, meta}), await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
);
} }
const payload = this.getEntityPublishPayload(device, availableResult); const payload = this.getEntityPublishPayload(device, availableResult);
@ -230,23 +231,18 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */ /* istanbul ignore else */
if (settings.get().advanced.legacy_api) { if (settings.get().advanced.legacy_api) {
const meta = {status: `check_failed`, device: device.name}; const meta = {status: `check_failed`, device: device.name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
'bridge/log',
stringify({type: `ota_update`, message: error, meta}),
);
} }
} }
} else { // type === 'update' } else {
// type === 'update'
const msg = `Updating '${device.name}' to latest firmware`; const msg = `Updating '${device.name}' to latest firmware`;
logger.info(msg); logger.info(msg);
/* istanbul ignore else */ /* istanbul ignore else */
if (settings.get().advanced.legacy_api) { if (settings.get().advanced.legacy_api) {
const meta = {status: `update_in_progress`, device: device.name}; const meta = {status: `update_in_progress`, device: device.name};
await this.mqtt.publish( await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
} }
try { try {
@ -272,8 +268,11 @@ export default class OTAUpdate extends Extension {
const fileVersion = await device.definition.ota.updateToLatest(device.zh, onProgress); const fileVersion = await device.definition.ota.updateToLatest(device.zh, onProgress);
logger.info(`Finished update of '${device.name}'`); logger.info(`Finished update of '${device.name}'`);
this.removeProgressAndRemainingFromState(device); this.removeProgressAndRemainingFromState(device);
const payload = this.getEntityPublishPayload(device, const payload = this.getEntityPublishPayload(device, {
{available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}); available: false,
currentFileVersion: fileVersion,
otaFileVersion: fileVersion,
});
await this.publishEntityState(device, payload); await this.publishEntityState(device, payload);
const to = await this.readSoftwareBuildIDAndDateCode(device); const to = await this.readSoftwareBuildIDAndDateCode(device);
const [fromS, toS] = [stringify(from_), stringify(to)]; const [fromS, toS] = [stringify(from_), stringify(to)];

View File

@ -1,14 +1,14 @@
import bind from 'bind-decorator';
import * as settings from '../util/settings'; import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
import * as philips from 'zigbee-herdsman-converters/lib/philips'; import * as philips from 'zigbee-herdsman-converters/lib/philips';
import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger'; import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils'; import utils from '../util/utils';
import Extension from './extension'; import Extension from './extension';
import stringify from 'json-stable-stringify-without-jsonify';
import Group from '../model/group';
import Device from '../model/device';
import bind from 'bind-decorator';
let topicGetSetRegex: RegExp; let topicGetSetRegex: RegExp;
// Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`. // Used by `publish.test.js` to reload regex when changing `mqtt.base_topic`.
@ -37,7 +37,12 @@ const defaultGroupConverters = [
zhc.toZigbee.light_hue_saturation_step, zhc.toZigbee.light_hue_saturation_step,
]; ];
interface ParsedTopic {ID: string, endpoint: string, attribute: string, type: 'get' | 'set'} interface ParsedTopic {
ID: string;
endpoint: string;
attribute: string;
type: 'get' | 'set';
}
export default class Publish extends Extension { export default class Publish extends Extension {
async start(): Promise<void> { async start(): Promise<void> {
@ -92,8 +97,14 @@ export default class Publish extends Extension {
} }
} }
legacyRetrieveState(re: Device | Group, converter: zhc.Tz.Converter, result: zhc.Tz.ConvertSetResult, legacyRetrieveState(
target: zh.Endpoint | zh.Group, key: string, meta: zhc.Tz.Meta): void { re: Device | Group,
converter: zhc.Tz.Converter,
result: zhc.Tz.ConvertSetResult,
target: zh.Endpoint | zh.Group,
key: string,
meta: zhc.Tz.Meta,
): void {
// It's possible for devices to get out of sync when writing an attribute that's not reportable. // It's possible for devices to get out of sync when writing an attribute that's not reportable.
// So here we re-read the value after a specified timeout, this timeout could for example be the // So here we re-read the value after a specified timeout, this timeout could for example be the
// transition time of a color change or for forcing a state read for devices that don't // transition time of a color change or for forcing a state read for devices that don't
@ -102,9 +113,7 @@ export default class Publish extends Extension {
// ever issue a read here, as we assume the device will properly report changes. // ever issue a read here, as we assume the device will properly report changes.
// Only do this when the retrieve_state option is enabled for this device. // Only do this when the retrieve_state option is enabled for this device.
// retrieve_state == deprecated // retrieve_state == deprecated
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') && if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') && re.options.retrieve_state) {
re.options.retrieve_state
) {
setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime); setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime);
} }
} }
@ -148,9 +157,12 @@ export default class Publish extends Extension {
const device = re instanceof Device ? re.zh : null; const device = re instanceof Device ? re.zh : null;
const entitySettings = re.options; const entitySettings = re.options;
const entityState = this.state.get(re); const entityState = this.state.get(re);
const membersState = re instanceof Group ? const membersState =
Object.fromEntries(re.zh.members.map((e) => [e.getDevice().ieeeAddr, re instanceof Group
this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))])) : null; ? Object.fromEntries(
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))]),
)
: null;
let converters: zhc.Tz.Converter[]; let converters: zhc.Tz.Converter[];
{ {
if (Array.isArray(definition)) { if (Array.isArray(definition)) {
@ -199,7 +211,9 @@ export default class Publish extends Extension {
const endpointNames = re instanceof Device ? re.getEndpointNames() : []; const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`); const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`);
for (let [key, value] of entries) { for (const entry of entries) {
let key = entry[0];
const value = entry[1];
let endpointName = parsedTopic.endpoint; let endpointName = parsedTopic.endpoint;
let localTarget = target; let localTarget = target;
let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID; let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID;
@ -215,8 +229,7 @@ export default class Publish extends Extension {
if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = []; if (!usedConverters.hasOwnProperty(endpointOrGroupID)) usedConverters[endpointOrGroupID] = [];
/* istanbul ignore next */ /* istanbul ignore next */
const converter = converters.find((c) => const converter = converters.find((c) => c.key.includes(key) && (!c.endpoint || c.endpoint == endpointName));
c.key.includes(key) && (!c.endpoint || c.endpoint == endpointName));
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) { if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
// Use a converter for set only once // Use a converter for set only once
@ -230,16 +243,21 @@ export default class Publish extends Extension {
} }
// If the endpoint_name name is a number, try to map it to a friendlyName // If the endpoint_name name is a number, try to map it to a friendlyName
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) && if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) && re.endpointName(localTarget)) {
re.endpointName(localTarget)) {
endpointName = re.endpointName(localTarget); endpointName = re.endpointName(localTarget);
} }
// Converter didn't return a result, skip // Converter didn't return a result, skip
const entitySettingsKeyValue: KeyValue = entitySettings; const entitySettingsKeyValue: KeyValue = entitySettings;
const meta = { const meta = {
endpoint_name: endpointName, options: entitySettingsKeyValue, endpoint_name: endpointName,
message: {...message}, logger, device, state: entityState, membersState, mapped: definition, options: entitySettingsKeyValue,
message: {...message},
logger,
device,
state: entityState,
membersState,
mapped: definition,
}; };
// Strip endpoint name from meta.message properties. // Strip endpoint name from meta.message properties.
@ -289,8 +307,7 @@ export default class Publish extends Extension {
continue; continue;
} }
} catch (error) { } catch (error) {
const message = const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
`Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
logger.error(message); logger.error(message);
logger.debug(error.stack); logger.debug(error.stack);
await this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}}); await this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
@ -305,8 +322,7 @@ export default class Publish extends Extension {
} }
} }
const scenesChanged = Object.values(usedConverters) const scenesChanged = Object.values(usedConverters).some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
.some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
if (scenesChanged) { if (scenesChanged) {
this.eventBus.emitScenesChanged({entity: re}); this.eventBus.emitScenesChanged({entity: re});
} }

View File

@ -1,17 +1,18 @@
import * as settings from '../util/settings';
import logger from '../util/logger';
import debounce from 'debounce';
import Extension from './extension';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import utils from '../util/utils'; import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; }; import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
type DebounceFunction = (() => void) & {clear(): void} & {flush(): void};
export default class Receive extends Extension { export default class Receive extends Extension {
private elapsed: {[s: string]: number} = {}; private elapsed: {[s: string]: number} = {};
private debouncers: {[s: string]: {payload: KeyValue, publish: DebounceFunction }} = {}; private debouncers: {[s: string]: {payload: KeyValue; publish: DebounceFunction}} = {};
async start(): Promise<void> { async start(): Promise<void> {
this.eventBus.onPublishEntityState(this, this.onPublishEntityState); this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
@ -24,8 +25,12 @@ export default class Receive extends Extension {
* In case that e.g. the state is currently held back by a debounce and a new state is published * In case that e.g. the state is currently held back by a debounce and a new state is published
* remove it from the to be send debounced message. * remove it from the to be send debounced message.
*/ */
if (data.entity.isDevice() && this.debouncers[data.entity.ieeeAddr] && if (
data.stateChangeReason !== 'publishDebounce' && data.stateChangeReason !== 'lastSeenChanged') { data.entity.isDevice() &&
this.debouncers[data.entity.ieeeAddr] &&
data.stateChangeReason !== 'publishDebounce' &&
data.stateChangeReason !== 'lastSeenChanged'
) {
for (const key of Object.keys(data.payload)) { for (const key of Object.keys(data.payload)) {
delete this.debouncers[data.entity.ieeeAddr].payload[key]; delete this.debouncers[data.entity.ieeeAddr].payload[key];
} }
@ -91,8 +96,7 @@ export default class Receive extends Extension {
if (!data.device) return; if (!data.device) return;
if (!this.shouldProcess(data)) { if (!this.shouldProcess(data)) {
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
settings.get(), true, this.publishEntityState);
return; return;
} }
@ -104,10 +108,11 @@ export default class Receive extends Extension {
// Check if there is an available converter, genOta messages are not interesting. // Check if there is an available converter, genOta messages are not interesting.
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl']; const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) { if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
logger.debug(`No converter available for '${data.device.definition.model}' with ` + logger.debug(
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`); `No converter available for '${data.device.definition.model}' with ` +
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, `cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`,
settings.get(), true, this.publishEntityState); );
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
return; return;
} }
@ -131,8 +136,7 @@ export default class Receive extends Extension {
// Check if we have to debounce // Check if we have to debounce
if (data.device.options.debounce) { if (data.device.options.debounce) {
this.publishDebounce(data.device, payload, data.device.options.debounce, this.publishDebounce(data.device, payload, data.device.options.debounce, data.device.options.debounce_ignore);
data.device.options.debounce_ignore);
} else { } else {
await this.publishEntityState(data.device, payload); await this.publishEntityState(data.device, payload);
} }
@ -143,15 +147,13 @@ export default class Receive extends Extension {
this.eventBus.emitExposesChanged({device: data.device}); this.eventBus.emitExposesChanged({device: data.device});
}; };
const meta = {device: data.device.zh, logger, state: this.state.get(data.device), const meta = {device: data.device.zh, logger, state: this.state.get(data.device), deviceExposesChanged: deviceExposesChanged};
deviceExposesChanged: deviceExposesChanged};
let payload: KeyValue = {}; let payload: KeyValue = {};
for (const converter of converters) { for (const converter of converters) {
try { try {
const convertData = {...data, device: data.device.zh}; const convertData = {...data, device: data.device.zh};
const options: KeyValue = data.device.options; const options: KeyValue = data.device.options;
const converted = await converter.convert( const converted = await converter.convert(data.device.definition, convertData, publish, options, meta);
data.device.definition, convertData, publish, options, meta);
if (converted) { if (converted) {
payload = {...payload, ...converted}; payload = {...payload, ...converted};
} }
@ -164,8 +166,7 @@ export default class Receive extends Extension {
if (Object.keys(payload).length) { if (Object.keys(payload).length) {
await publish(payload); await publish(payload);
} else { } else {
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
settings.get(), true, this.publishEntityState);
} }
} }
} }

View File

@ -1,16 +1,23 @@
/* eslint-disable brace-style */ /* eslint-disable brace-style */
import * as settings from '../util/settings';
import * as zhc from 'zigbee-herdsman-converters';
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype'; import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
export default class Device { export default class Device {
public zh: zh.Device; public zh: zh.Device;
public definition: zhc.Definition; public definition: zhc.Definition;
private _definitionModelID: string; private _definitionModelID: string;
get ieeeAddr(): string {return this.zh.ieeeAddr;} get ieeeAddr(): string {
get ID(): string {return this.zh.ieeeAddr;} return this.zh.ieeeAddr;
get options(): DeviceOptions {return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};} }
get ID(): string {
return this.zh.ieeeAddr;
}
get options(): DeviceOptions {
return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};
}
get name(): string { get name(): string {
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr; return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr;
} }
@ -86,9 +93,15 @@ export default class Device {
return Object.keys(this.definition?.endpoint?.(this.zh) ?? {}).filter((name) => name !== 'default'); return Object.keys(this.definition?.endpoint?.(this.zh) ?? {}).filter((name) => name !== 'default');
} }
isIkeaTradfri(): boolean {return this.zh.manufacturerID === 4476;} isIkeaTradfri(): boolean {
return this.zh.manufacturerID === 4476;
}
isDevice(): this is Device {return true;} isDevice(): this is Device {
return true;
}
/* istanbul ignore next */ /* istanbul ignore next */
isGroup(): this is Group {return false;} isGroup(): this is Group {
return false;
}
} }

View File

@ -1,14 +1,21 @@
/* eslint-disable brace-style */ /* eslint-disable brace-style */
import * as settings from '../util/settings';
import * as zhc from 'zigbee-herdsman-converters'; import * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
export default class Group { export default class Group {
public zh: zh.Group; public zh: zh.Group;
private resolveDevice: (ieeeAddr: string) => Device; private resolveDevice: (ieeeAddr: string) => Device;
get ID(): number {return this.zh.groupID;} get ID(): number {
get options(): GroupOptions {return {...settings.getGroup(this.ID)};} return this.zh.groupID;
get name(): string {return this.options?.friendly_name || this.ID.toString();} }
get options(): GroupOptions {
return {...settings.getGroup(this.ID)};
}
get name(): string {
return this.options?.friendly_name || this.ID.toString();
}
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) { constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) {
this.zh = group; this.zh = group;
@ -24,9 +31,15 @@ export default class Group {
} }
membersDefinitions(): zhc.Definition[] { membersDefinitions(): zhc.Definition[] {
return this.membersDevices().map((d) => d.definition).filter((d) => d); return this.membersDevices()
.map((d) => d.definition)
.filter((d) => d);
} }
isDevice(): this is Device {return false;} isDevice(): this is Device {
isGroup(): this is Group {return true;} return false;
}
isGroup(): this is Group {
return true;
}
} }

View File

@ -1,10 +1,12 @@
import type {QoS} from 'mqtt-packet';
import bind from 'bind-decorator';
import fs from 'fs';
import * as mqtt from 'mqtt'; import * as mqtt from 'mqtt';
import logger from './util/logger'; import logger from './util/logger';
import * as settings from './util/settings'; import * as settings from './util/settings';
import utils from './util/utils'; import utils from './util/utils';
import fs from 'fs';
import bind from 'bind-decorator';
import type {QoS} from 'mqtt-packet';
const NS = 'z2m:mqtt'; const NS = 'z2m:mqtt';
@ -15,8 +17,9 @@ export default class MQTT {
private eventBus: EventBus; private eventBus: EventBus;
private initialConnect = true; private initialConnect = true;
private republishRetainedTimer: NodeJS.Timeout; private republishRetainedTimer: NodeJS.Timeout;
private retainedMessages: {[s: string]: {payload: string, options: MQTTOptions, private retainedMessages: {
skipLog: boolean, skipReceive: boolean, topic: string, base: string}} = {}; [s: string]: {payload: string; options: MQTTOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
} = {};
constructor(eventBus: EventBus) { constructor(eventBus: EventBus) {
this.eventBus = eventBus; this.eventBus = eventBus;
@ -155,9 +158,15 @@ export default class MQTT {
return this.client && !this.client.reconnecting; return this.client && !this.client.reconnecting;
} }
async publish(topic: string, payload: string, options: MQTTOptions={}, base=settings.get().mqtt.base_topic, skipLog=false, skipReceive=true) async publish(
: Promise<void> { topic: string,
const defaultOptions: {qos: QoS, retain: boolean} = {qos: 0, retain: false}; payload: string,
options: MQTTOptions = {},
base = settings.get().mqtt.base_topic,
skipLog = false,
skipReceive = true,
): Promise<void> {
const defaultOptions: {qos: QoS; retain: boolean} = {qos: 0, retain: false};
topic = `${base}/${topic}`; topic = `${base}/${topic}`;
if (skipReceive) { if (skipReceive) {

View File

@ -1,16 +1,33 @@
import logger from './util/logger';
import data from './util/data';
import * as settings from './util/settings';
import utils from './util/utils';
import fs from 'fs'; import fs from 'fs';
import objectAssignDeep from 'object-assign-deep'; import objectAssignDeep from 'object-assign-deep';
import data from './util/data';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
const saveInterval = 1000 * 60 * 5; // 5 minutes const saveInterval = 1000 * 60 * 5; // 5 minutes
const dontCacheProperties = [ const dontCacheProperties = [
'action', 'action_.*', 'button', 'button_left', 'button_right', 'click', 'forgotten', 'keyerror', 'action',
'step_size', 'transition_time', 'group_list', 'group_capacity', 'no_occupancy_since', 'action_.*',
'step_mode', 'transition_time', 'duration', 'elapsed', 'from_side', 'to_side', 'button',
'button_left',
'button_right',
'click',
'forgotten',
'keyerror',
'step_size',
'transition_time',
'group_list',
'group_capacity',
'no_occupancy_since',
'step_mode',
'transition_time',
'duration',
'elapsed',
'from_side',
'to_side',
]; ];
class State { class State {
@ -18,7 +35,10 @@ class State {
private file = data.joinPath('state.json'); private file = data.joinPath('state.json');
private timer: NodeJS.Timeout = null; private timer: NodeJS.Timeout = null;
constructor(private readonly eventBus: EventBus, private readonly zigbee: Zigbee) { constructor(
private readonly eventBus: EventBus,
private readonly zigbee: Zigbee,
) {
this.eventBus = eventBus; this.eventBus = eventBus;
this.zigbee = zigbee; this.zigbee = zigbee;
} }
@ -75,7 +95,7 @@ class State {
return this.state[entity.ID] || {}; return this.state[entity.ID] || {};
} }
set(entity: Group | Device, update: KeyValue, reason: string=null): KeyValue { set(entity: Group | Device, update: KeyValue, reason: string = null): KeyValue {
const fromState = this.state[entity.ID] || {}; const fromState = this.state[entity.ID] || {};
const toState = objectAssignDeep({}, fromState, update); const toState = objectAssignDeep({}, fromState, update);
const newCache = {...toState}; const newCache = {...toState};

357
lib/types/types.d.ts vendored
View File

@ -1,11 +1,12 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import {LogLevel} from 'lib/util/settings'; import type TypeEventBus from 'lib/eventBus';
import type { import type TypeExtension from 'lib/extension/extension';
Device as ZHDevice, import type TypeDevice from 'lib/model/device';
Group as ZHGroup, import type TypeGroup from 'lib/model/group';
Endpoint as ZHEndpoint, import type TypeMQTT from 'lib/mqtt';
} from 'zigbee-herdsman/dist/controller/model'; import type TypeState from 'lib/state';
import type TypeZigbee from 'lib/zigbee';
import type {QoS} from 'mqtt-packet';
import type { import type {
NetworkParameters as ZHNetworkParameters, NetworkParameters as ZHNetworkParameters,
CoordinatorVersion as ZHCoordinatorVersion, CoordinatorVersion as ZHCoordinatorVersion,
@ -13,25 +14,12 @@ import type {
RoutingTable as ZHRoutingTable, RoutingTable as ZHRoutingTable,
RoutingTableEntry as ZHRoutingTableEntry, RoutingTableEntry as ZHRoutingTableEntry,
} from 'zigbee-herdsman/dist/adapter/tstype'; } from 'zigbee-herdsman/dist/adapter/tstype';
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
import type { import type {Device as ZHDevice, Group as ZHGroup, Endpoint as ZHEndpoint} from 'zigbee-herdsman/dist/controller/model';
Cluster as ZHCluster, import type {Cluster as ZHCluster, FrameControl as ZHFrameControl} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
FrameControl as ZHFrameControl,
} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import type * as zhc from 'zigbee-herdsman-converters'; import type * as zhc from 'zigbee-herdsman-converters';
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events'; import {LogLevel} from 'lib/util/settings';
import type TypeEventBus from 'lib/eventBus';
import type TypeMQTT from 'lib/mqtt';
import type TypeState from 'lib/state';
import type TypeZigbee from 'lib/zigbee';
import type TypeDevice from 'lib/model/device';
import type TypeGroup from 'lib/model/group';
import type TypeExtension from 'lib/extension/extension';
import type {QoS} from 'mqtt-packet';
declare global { declare global {
// Define some class types as global // Define some class types as global
@ -46,15 +34,25 @@ declare global {
// Types // Types
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExternalDefinition = zhc.Definition & {homeassistant: any}; type ExternalDefinition = zhc.Definition & {homeassistant: any};
interface MQTTResponse {data: KeyValue, status: 'error' | 'ok', error?: string, transaction?: string} interface MQTTResponse {
interface MQTTOptions {qos?: QoS, retain?: boolean, properties?: {messageExpiryInterval: number}} data: KeyValue;
type Scene = {id: number, name: string}; status: 'error' | 'ok';
error?: string;
transaction?: string;
}
interface MQTTOptions {
qos?: QoS;
retain?: boolean;
properties?: {messageExpiryInterval: number};
}
type Scene = {id: number; name: string};
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached'; type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached';
type PublishEntityState = (entity: Device | Group, payload: KeyValue, type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
stateChangeReason?: StateChangeReason) => Promise<void>; type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>;}; interface KeyValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
interface KeyValue {[s: string]: any} [s: string]: any;
}
// zigbee-herdsman // zigbee-herdsman
namespace zh { namespace zh {
@ -74,27 +72,32 @@ declare global {
} }
namespace eventdata { namespace eventdata {
type EntityRenamed = { entity: Device | Group, homeAssisantRename: boolean, from: string, to: string }; type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
type DeviceRemoved = { ieeeAddr: string, name: string }; type DeviceRemoved = {ieeeAddr: string; name: string};
type MQTTMessage = { topic: string, message: string }; type MQTTMessage = {topic: string; message: string};
type MQTTMessagePublished = { topic: string, payload: string, options: {retain: boolean, qos: number} }; type MQTTMessagePublished = {topic: string; payload: string; options: {retain: boolean; qos: number}};
type StateChange = { type StateChange = {
entity: Device | Group, from: KeyValue, to: KeyValue, reason: string | null, update: KeyValue }; entity: Device | Group;
from: KeyValue;
to: KeyValue;
reason: string | null;
update: KeyValue;
};
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload; type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
type LastSeenChanged = { device: Device, type LastSeenChanged = {
reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted'; }; device: Device;
type DeviceNetworkAddressChanged = { device: Device }; reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted';
type DeviceAnnounce = { device: Device }; };
type DeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' }; type DeviceNetworkAddressChanged = {device: Device};
type DeviceJoined = { device: Device }; type DeviceAnnounce = {device: Device};
type EntityOptionsChanged = { entity: Device | Group, from: KeyValue, to: KeyValue }; type DeviceInterview = {device: Device; status: 'started' | 'successful' | 'failed'};
type ExposesChanged = { device: Device }; type DeviceJoined = {device: Device};
type Reconfigure = { device: Device }; type EntityOptionsChanged = {entity: Device | Group; from: KeyValue; to: KeyValue};
type DeviceLeave = { ieeeAddr: string, name: string }; type ExposesChanged = {device: Device};
type GroupMembersChanged = {group: Group, action: 'remove' | 'add' | 'remove_all', type Reconfigure = {device: Device};
endpoint: zh.Endpoint, skipDisableReporting: boolean }; type DeviceLeave = {ieeeAddr: string; name: string};
type PublishEntityState = {entity: Group | Device, message: KeyValue, stateChangeReason: StateChangeReason, type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean};
payload: KeyValue}; type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason: StateChangeReason; payload: KeyValue};
type DeviceMessage = { type DeviceMessage = {
type: ZHEvents.MessagePayloadType; type: ZHEvents.MessagePayloadType;
device: Device; device: Device;
@ -103,157 +106,157 @@ declare global {
groupID: number; groupID: number;
cluster: string | number; cluster: string | number;
data: KeyValue | Array<string | number>; data: KeyValue | Array<string | number>;
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl;}; meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
}; };
type ScenesChanged = { entity: Device | Group }; type ScenesChanged = {entity: Device | Group};
} }
// Settings // Settings
// eslint-disable camelcase // eslint-disable camelcase
interface Settings { interface Settings {
homeassistant?: { homeassistant?: {
discovery_topic: string, discovery_topic: string;
status_topic: string, status_topic: string;
legacy_entity_attributes: boolean, legacy_entity_attributes: boolean;
legacy_triggers: boolean, legacy_triggers: boolean;
}, };
permit_join?: boolean, permit_join?: boolean;
availability?: { availability?: {
active: {timeout: number}, active: {timeout: number};
passive: {timeout: number} passive: {timeout: number};
}, };
external_converters: string[], external_converters: string[];
mqtt: { mqtt: {
base_topic: string, base_topic: string;
include_device_information: boolean, include_device_information: boolean;
force_disable_retain: boolean force_disable_retain: boolean;
version?: 3 | 4 | 5, version?: 3 | 4 | 5;
user?: string, user?: string;
password?: string, password?: string;
server: string, server: string;
ca?: string, ca?: string;
keepalive?: number, keepalive?: number;
key?: string, key?: string;
cert?: string, cert?: string;
client_id?: string, client_id?: string;
reject_unauthorized?: boolean, reject_unauthorized?: boolean;
}, };
serial: { serial: {
disable_led: boolean, disable_led: boolean;
port?: string, port?: string;
adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember', adapter?: 'deconz' | 'zstack' | 'ezsp' | 'zigate' | 'ember';
baudrate?: number, baudrate?: number;
rtscts?: boolean, rtscts?: boolean;
}, };
passlist: string[], passlist: string[];
blocklist: string[], blocklist: string[];
map_options: { map_options: {
graphviz: { graphviz: {
colors: { colors: {
fill: { fill: {
enddevice: string, enddevice: string;
coordinator: string, coordinator: string;
router: string, router: string;
}, };
font: { font: {
coordinator: string, coordinator: string;
router: string, router: string;
enddevice: string, enddevice: string;
}, };
line: { line: {
active: string, active: string;
inactive: string, inactive: string;
}, };
}, };
}, };
}, };
ota: { ota: {
update_check_interval: number, update_check_interval: number;
disable_automatic_update_check: boolean, disable_automatic_update_check: boolean;
zigbee_ota_override_index_location?: string, zigbee_ota_override_index_location?: string;
ikea_ota_use_test_url?: boolean, ikea_ota_use_test_url?: boolean;
}, };
frontend?: { frontend?: {
auth_token?: string, auth_token?: string;
host?: string, host?: string;
port?: number, port?: number;
url?: string, url?: string;
ssl_cert?: string, ssl_cert?: string;
ssl_key?: string, ssl_key?: string;
}, };
devices?: {[s: string]: DeviceOptions}, devices?: {[s: string]: DeviceOptions};
groups?: {[s: string]: GroupOptions}, groups?: {[s: string]: GroupOptions};
device_options: KeyValue, device_options: KeyValue;
advanced: { advanced: {
legacy_api: boolean, legacy_api: boolean;
legacy_availability_payload: boolean, legacy_availability_payload: boolean;
log_rotation: boolean, log_rotation: boolean;
log_symlink_current: boolean, log_symlink_current: boolean;
log_output: ('console' | 'file' | 'syslog')[], log_output: ('console' | 'file' | 'syslog')[];
log_directory: string, log_directory: string;
log_file: string, log_file: string;
log_level: LogLevel, log_level: LogLevel;
log_namespaced_levels: Record<string, LogLevel>, log_namespaced_levels: Record<string, LogLevel>;
log_syslog: KeyValue, log_syslog: KeyValue;
log_debug_to_mqtt_frontend: boolean, log_debug_to_mqtt_frontend: boolean;
log_debug_namespace_ignore: string, log_debug_namespace_ignore: string;
pan_id: number | 'GENERATE', pan_id: number | 'GENERATE';
ext_pan_id: number[] | 'GENERATE', ext_pan_id: number[] | 'GENERATE';
channel: number, channel: number;
adapter_concurrent: number | null, adapter_concurrent: number | null;
adapter_delay: number | null, adapter_delay: number | null;
cache_state: boolean, cache_state: boolean;
cache_state_persistent: boolean, cache_state_persistent: boolean;
cache_state_send_on_startup: boolean, cache_state_send_on_startup: boolean;
last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch', last_seen: 'disable' | 'ISO_8601' | 'ISO_8601_local' | 'epoch';
elapsed: boolean, elapsed: boolean;
network_key: number[] | 'GENERATE', network_key: number[] | 'GENERATE';
timestamp_format: string, timestamp_format: string;
output: 'json' | 'attribute' | 'attribute_and_json', output: 'json' | 'attribute' | 'attribute_and_json';
transmit_power?: number, transmit_power?: number;
// Everything below is deprecated // Everything below is deprecated
availability_timeout?: number, availability_timeout?: number;
availability_blocklist?: string[], availability_blocklist?: string[];
availability_passlist?: string[], availability_passlist?: string[];
availability_blacklist?: string[], availability_blacklist?: string[];
availability_whitelist?: string[], availability_whitelist?: string[];
soft_reset_timeout: number, soft_reset_timeout: number;
report: boolean, report: boolean;
}, };
} }
interface DeviceOptions { interface DeviceOptions {
ID?: string, ID?: string;
disabled?: boolean, disabled?: boolean;
retention?: number, retention?: number;
availability?: boolean | {timeout: number}, availability?: boolean | {timeout: number};
optimistic?: boolean, optimistic?: boolean;
retrieve_state?: boolean, retrieve_state?: boolean;
debounce?: number, debounce?: number;
debounce_ignore?: string[], debounce_ignore?: string[];
filtered_attributes?: string[], filtered_attributes?: string[];
filtered_cache?: string[], filtered_cache?: string[];
filtered_optimistic?: string[], filtered_optimistic?: string[];
icon?: string, icon?: string;
homeassistant?: KeyValue, homeassistant?: KeyValue;
legacy?: boolean, legacy?: boolean;
friendly_name: string, friendly_name: string;
description?: string, description?: string;
qos?: 0 | 1 | 2, qos?: 0 | 1 | 2;
} }
interface GroupOptions { interface GroupOptions {
devices?: string[], devices?: string[];
ID?: number, ID?: number;
optimistic?: boolean, optimistic?: boolean;
off_state?: 'all_members_off' | 'last_member_state' off_state?: 'all_members_off' | 'last_member_state';
filtered_attributes?: string[], filtered_attributes?: string[];
filtered_cache?: string[], filtered_cache?: string[];
filtered_optimistic?: string[], filtered_optimistic?: string[];
retrieve_state?: boolean, retrieve_state?: boolean;
homeassistant?: KeyValue, homeassistant?: KeyValue;
friendly_name: string, friendly_name: string;
description?: string, description?: string;
qos?: 0 | 1 | 2, qos?: 0 | 1 | 2;
} }
} }

View File

@ -1,11 +1,12 @@
import winston from 'winston'; import assert from 'assert';
import moment from 'moment';
import * as settings from './settings';
import path from 'path';
import fs from 'fs'; import fs from 'fs';
import fx from 'mkdir-recursive'; import fx from 'mkdir-recursive';
import moment from 'moment';
import path from 'path';
import {rimrafSync} from 'rimraf'; import {rimrafSync} from 'rimraf';
import assert from 'assert'; import winston from 'winston';
import * as settings from './settings';
const NAMESPACE_SEPARATOR = ':'; const NAMESPACE_SEPARATOR = ':';
@ -30,19 +31,13 @@ class Logger {
this.namespacedLevels = settings.get().advanced.log_namespaced_levels; this.namespacedLevels = settings.get().advanced.log_namespaced_levels;
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels); this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
assert( assert(settings.LOG_LEVELS.includes(this.level), `'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(', ')}'`);
settings.LOG_LEVELS.includes(this.level),
`'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(', ')}'`,
);
const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format); const timestampFormat = (): string => moment().format(settings.get().advanced.timestamp_format);
this.logger = winston.createLogger({ this.logger = winston.createLogger({
level: 'debug', level: 'debug',
format: winston.format.combine( format: winston.format.combine(winston.format.errors({stack: true}), winston.format.timestamp({format: timestampFormat})),
winston.format.errors({stack: true}),
winston.format.timestamp({format: timestampFormat}),
),
levels: winston.config.syslog.levels, levels: winston.config.syslog.levels,
}); });
@ -51,16 +46,20 @@ class Logger {
let logging = `Logging to console${consoleSilenced ? ' (silenced)' : ''}`; let logging = `Logging to console${consoleSilenced ? ' (silenced)' : ''}`;
// Setup default console logger // Setup default console logger
this.logger.add(new winston.transports.Console({ this.logger.add(
silent: consoleSilenced, new winston.transports.Console({
// winston.config.syslog.levels sets 'warning' as 'red' silent: consoleSilenced,
format: winston.format.combine( // winston.config.syslog.levels sets 'warning' as 'red'
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}), format: winston.format.combine(
winston.format.printf(/* istanbul ignore next */(info) => { winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
return `[${info.timestamp}] ${info.level}: \t${info.message}`; winston.format.printf(
}), /* istanbul ignore next */ (info) => {
), return `[${info.timestamp}] ${info.level}: \t${info.message}`;
})); },
),
),
}),
);
if (this.output.includes('file')) { if (this.output.includes('file')) {
logging += `, file (filename: ${logFilename})`; logging += `, file (filename: ${logFilename})`;
@ -79,13 +78,14 @@ class Logger {
} }
// Add file logger when enabled // Add file logger when enabled
// eslint-disable-next-line max-len
// NOTE: the initiation of the logger even when not added as transport tries to create the logging directory // NOTE: the initiation of the logger even when not added as transport tries to create the logging directory
const transportFileOptions: winston.transports.FileTransportOptions = { const transportFileOptions: winston.transports.FileTransportOptions = {
filename: path.join(this.directory, logFilename), filename: path.join(this.directory, logFilename),
format: winston.format.printf(/* istanbul ignore next */(info) => { format: winston.format.printf(
return `[${info.timestamp}] ${info.level}: \t${info.message}`; /* istanbul ignore next */ (info) => {
}), return `[${info.timestamp}] ${info.level}: \t${info.message}`;
},
),
}; };
if (settings.get().advanced.log_rotation) { if (settings.get().advanced.log_rotation) {
@ -135,7 +135,7 @@ class Logger {
} }
public getDebugNamespaceIgnore(): string { public getDebugNamespaceIgnore(): string {
return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1)/* remove slashes */ ?? ''; return this.debugNamespaceIgnoreRegex?.toString().slice(1, -1) /* remove slashes */ ?? '';
} }
public setDebugNamespaceIgnore(value: string): void { public setDebugNamespaceIgnore(value: string): void {
@ -164,14 +164,14 @@ class Logger {
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels); this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
} }
private cacheNamespacedLevel(namespace: string) : string { private cacheNamespacedLevel(namespace: string): string {
let cached = namespace; let cached = namespace;
while (this.cachedNamespacedLevels[namespace] == undefined) { while (this.cachedNamespacedLevels[namespace] == undefined) {
const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR); const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR);
if (sep === -1) { if (sep === -1) {
return this.cachedNamespacedLevels[namespace] = this.level; return (this.cachedNamespacedLevels[namespace] = this.level);
} }
cached = cached.slice(0, sep); cached = cached.slice(0, sep);

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
import data from './data'; import Ajv, {ValidateFunction} from 'ajv';
import utils from './utils';
import objectAssignDeep from 'object-assign-deep'; import objectAssignDeep from 'object-assign-deep';
import path from 'path'; import path from 'path';
import yaml from './yaml';
import Ajv, {ValidateFunction} from 'ajv'; import data from './data';
import schemaJson from './settings.schema.json'; import schemaJson from './settings.schema.json';
import utils from './utils';
import yaml from './yaml';
export let schema = schemaJson; export let schema = schemaJson;
// @ts-ignore // @ts-ignore
schema = {}; schema = {};
@ -28,18 +29,19 @@ objectAssignDeep(schema, schemaJson);
/** NOTE: by order of priority, lower index is lower level (more important) */ /** NOTE: by order of priority, lower index is lower level (more important) */
export const LOG_LEVELS: readonly string[] = ['error', 'warning', 'info', 'debug'] as const; export const LOG_LEVELS: readonly string[] = ['error', 'warning', 'info', 'debug'] as const;
export type LogLevel = typeof LOG_LEVELS[number]; export type LogLevel = (typeof LOG_LEVELS)[number];
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697 // DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml'); const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml');
const NULLABLE_SETTINGS = ['homeassistant']; const NULLABLE_SETTINGS = ['homeassistant'];
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson); const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson);
const ajvRestartRequired = new Ajv({allErrors: true}) const ajvRestartRequired = new Ajv({allErrors: true}).addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
const ajvRestartRequiredDeviceOptions = new Ajv({allErrors: true}) const ajvRestartRequiredDeviceOptions = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson.definitions.device); .addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
.compile(schemaJson.definitions.device);
const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true}) const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson.definitions.group); .addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
.compile(schemaJson.definitions.group);
const defaults: RecursivePartial<Settings> = { const defaults: RecursivePartial<Settings> = {
permit_join: false, permit_join: false,
external_converters: [], external_converters: [],
@ -92,7 +94,7 @@ const defaults: RecursivePartial<Settings> = {
log_debug_to_mqtt_frontend: false, log_debug_to_mqtt_frontend: false,
log_debug_namespace_ignore: '', log_debug_namespace_ignore: '',
pan_id: 0x1a62, pan_id: 0x1a62,
ext_pan_id: [0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD], ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
channel: 11, channel: 11,
adapter_concurrent: null, adapter_concurrent: null,
adapter_delay: null, adapter_delay: null,
@ -129,12 +131,15 @@ function loadSettingsWithDefaults(): void {
} }
if (_settingsWithDefaults.homeassistant) { if (_settingsWithDefaults.homeassistant) {
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status', const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status', legacy_entity_attributes: true, legacy_triggers: true};
legacy_entity_attributes: true, legacy_triggers: true};
const sLegacy = {}; const sLegacy = {};
if (_settingsWithDefaults.advanced) { if (_settingsWithDefaults.advanced) {
for (const key of ['homeassistant_legacy_triggers', 'homeassistant_discovery_topic', for (const key of [
'homeassistant_legacy_entity_attributes', 'homeassistant_status_topic']) { 'homeassistant_legacy_triggers',
'homeassistant_discovery_topic',
'homeassistant_legacy_entity_attributes',
'homeassistant_status_topic',
]) {
// @ts-ignore // @ts-ignore
if (_settingsWithDefaults.advanced[key] !== undefined) { if (_settingsWithDefaults.advanced[key] !== undefined) {
// @ts-ignore // @ts-ignore
@ -202,7 +207,7 @@ function loadSettingsWithDefaults(): void {
_settingsWithDefaults.whitelist && _settingsWithDefaults.passlist.push(..._settingsWithDefaults.whitelist); _settingsWithDefaults.whitelist && _settingsWithDefaults.passlist.push(..._settingsWithDefaults.whitelist);
} }
function parseValueRef(text: string): {filename: string, key: string} | null { function parseValueRef(text: string): {filename: string; key: string} | null {
const match = /!(.*) (.*)/g.exec(text); const match = /!(.*) (.*)/g.exec(text);
if (match) { if (match) {
let filename = match[1]; let filename = match[1];
@ -248,7 +253,8 @@ function write(): void {
// If an array, only write to first file and only devices which are not in the other files. // If an array, only write to first file and only devices which are not in the other files.
if (Array.isArray(actual[type])) { if (Array.isArray(actual[type])) {
actual[type].filter((f: string, i: number) => i !== 0) actual[type]
.filter((f: string, i: number) => i !== 0)
.map((f: string) => yaml.readIfExists(data.joinPath(f), {})) .map((f: string) => yaml.readIfExists(data.joinPath(f), {}))
.map((c: KeyValue) => Object.keys(c)) .map((c: KeyValue) => Object.keys(c))
// @ts-ignore // @ts-ignore
@ -274,10 +280,7 @@ export function validate(): string[] {
getInternalSettings(); getInternalSettings();
} catch (error) { } catch (error) {
if (error.name === 'YAMLException') { if (error.name === 'YAMLException') {
return [ return [`Your YAML file: '${error.file}' is invalid ` + `(use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
`Your YAML file: '${error.file}' is invalid ` +
`(use https://jsonformatter.org/yaml-validator to find and fix the issue)`,
];
} }
return [error.message]; return [error.message];
@ -288,18 +291,30 @@ export function validate(): string[] {
} }
const errors = []; const errors = [];
if (_settings.advanced && _settings.advanced.network_key && typeof _settings.advanced.network_key === 'string' && if (
_settings.advanced.network_key !== 'GENERATE') { _settings.advanced &&
_settings.advanced.network_key &&
typeof _settings.advanced.network_key === 'string' &&
_settings.advanced.network_key !== 'GENERATE'
) {
errors.push(`advanced.network_key: should be array or 'GENERATE' (is '${_settings.advanced.network_key}')`); errors.push(`advanced.network_key: should be array or 'GENERATE' (is '${_settings.advanced.network_key}')`);
} }
if (_settings.advanced && _settings.advanced.pan_id && typeof _settings.advanced.pan_id === 'string' && if (
_settings.advanced.pan_id !== 'GENERATE') { _settings.advanced &&
_settings.advanced.pan_id &&
typeof _settings.advanced.pan_id === 'string' &&
_settings.advanced.pan_id !== 'GENERATE'
) {
errors.push(`advanced.pan_id: should be number or 'GENERATE' (is '${_settings.advanced.pan_id}')`); errors.push(`advanced.pan_id: should be number or 'GENERATE' (is '${_settings.advanced.pan_id}')`);
} }
if (_settings.advanced && _settings.advanced.ext_pan_id && typeof _settings.advanced.ext_pan_id === 'string' && if (
_settings.advanced.ext_pan_id !== 'GENERATE') { _settings.advanced &&
_settings.advanced.ext_pan_id &&
typeof _settings.advanced.ext_pan_id === 'string' &&
_settings.advanced.ext_pan_id !== 'GENERATE'
) {
errors.push(`advanced.ext_pan_id: should be array or 'GENERATE' (is '${_settings.advanced.ext_pan_id}')`); errors.push(`advanced.ext_pan_id: should be array or 'GENERATE' (is '${_settings.advanced.ext_pan_id}')`);
} }
@ -404,7 +419,7 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
if (key !== 'properties' && obj[key]) { if (key !== 'properties' && obj[key]) {
const type = (obj[key].type || 'object').toString(); const type = (obj[key].type || 'object').toString();
const envPart = path.reduce((acc, val) => `${acc}${val}_`, ''); const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
const envVariableName = (`ZIGBEE2MQTT_CONFIG_${envPart}${key}`).toUpperCase(); const envVariableName = `ZIGBEE2MQTT_CONFIG_${envPart}${key}`.toUpperCase();
if (process.env[envVariableName]) { if (process.env[envVariableName]) {
const setting = path.reduce((acc, val) => { const setting = path.reduce((acc, val) => {
/* eslint-disable-line */ // @ts-ignore /* eslint-disable-line */ // @ts-ignore
@ -498,8 +513,7 @@ export function apply(settings: Record<string, unknown>): boolean {
write(); write();
ajvRestartRequired(settings); ajvRestartRequired(settings);
const restartRequired = ajvRestartRequired.errors && const restartRequired = ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
!!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
return restartRequired; return restartRequired;
} }
@ -607,8 +621,7 @@ export function removeDevice(IDorName: string): void {
// Remove device from groups // Remove device from groups
if (settings.groups) { if (settings.groups) {
const regex = const regex = new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`);
new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`);
for (const group of Object.values(settings.groups).filter((g) => g.devices)) { for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
group.devices = group.devices.filter((device) => !device.match(regex)); group.devices = group.devices.filter((device) => !device.match(regex));
} }
@ -701,7 +714,7 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
validator = ajvRestartRequiredDeviceOptions; validator = ajvRestartRequiredDeviceOptions;
} else if (getGroup(IDorName)) { } else if (getGroup(IDorName)) {
objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions); objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions);
utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID], NULLABLE_SETTINGS ); utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID], NULLABLE_SETTINGS);
validator = ajvRestartRequiredGroupOptions; validator = ajvRestartRequiredGroupOptions;
} else { } else {
throw new Error(`Device or group '${IDorName}' does not exist`); throw new Error(`Device or group '${IDorName}' does not exist`);

View File

@ -1,11 +1,13 @@
import equals from 'fast-deep-equal/es6';
import humanizeDuration from 'humanize-duration';
import data from './data';
import vm from 'vm';
import fs from 'fs';
import path from 'path';
import type * as zhc from 'zigbee-herdsman-converters'; import type * as zhc from 'zigbee-herdsman-converters';
import equals from 'fast-deep-equal/es6';
import fs from 'fs';
import humanizeDuration from 'humanize-duration';
import path from 'path';
import vm from 'vm';
import data from './data';
// construct a local ISO8601 string (instead of UTC-based) // construct a local ISO8601 string (instead of UTC-based)
// Example: // Example:
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000 // - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
@ -18,21 +20,30 @@ function toLocalISOString(date: Date): string {
return (norm < 10 ? '0' : '') + norm; return (norm < 10 ? '0' : '') + norm;
}; };
return date.getFullYear() + return (
'-' + pad(date.getMonth() + 1) + date.getFullYear() +
'-' + pad(date.getDate()) + '-' +
'T' + pad(date.getHours()) + pad(date.getMonth() + 1) +
':' + pad(date.getMinutes()) + '-' +
':' + pad(date.getSeconds()) + pad(date.getDate()) +
plusOrMinus + pad(tzOffset / 60) + 'T' +
':' + pad(tzOffset % 60); pad(date.getHours()) +
':' +
pad(date.getMinutes()) +
':' +
pad(date.getSeconds()) +
plusOrMinus +
pad(tzOffset / 60) +
':' +
pad(tzOffset % 60)
);
} }
function capitalize(s: string): string { function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1); return s[0].toUpperCase() + s.slice(1);
} }
async function getZigbee2MQTTVersion(includeCommitHash=true): Promise<{commitHash: string, version: string}> { async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash: string; version: string}> {
const git = await import('git-last-commit'); const git = await import('git-last-commit');
const packageJSON = await import('../..' + '/package.json'); const packageJSON = await import('../..' + '/package.json');
@ -75,7 +86,8 @@ function formatDate(time: number, type: 'ISO_8601' | 'ISO_8601_local' | 'epoch'
if (type === 'ISO_8601') return new Date(time).toISOString(); if (type === 'ISO_8601') return new Date(time).toISOString();
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time)); else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
else if (type === 'epoch') return time; else if (type === 'epoch') return time;
else { // relative else {
// relative
return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago'; return humanizeDuration(Date.now() - time, {language: 'en', largest: 2, round: true}) + ' ago';
} }
} }
@ -168,7 +180,7 @@ export function* loadExternalConverter(moduleName: string): Generator<ExternalDe
* @param {KeyValue} obj Object to process (in-place) * @param {KeyValue} obj Object to process (in-place)
* @param {string[]} [ignoreKeys] Recursively ignore these keys in the object (keep null/undefined values). * @param {string[]} [ignoreKeys] Recursively ignore these keys in the object (keep null/undefined values).
*/ */
function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = [] ): void { function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []): void {
for (const key of Object.keys(obj)) { for (const key of Object.keys(obj)) {
if (ignoreKeys.includes(key)) continue; if (ignoreKeys.includes(key)) continue;
const value = obj[key]; const value = obj[key];
@ -198,28 +210,27 @@ function toSnakeCase(value: string | KeyValue): any {
} }
return value; return value;
} else { } else {
return value.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase()).replace(/^_/, '').replace('_i_d', '_id'); return value
.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase())
.replace(/^_/, '')
.replace('_i_d', '_id');
} }
} }
function charRange(start: string, stop: string): number[] { function charRange(start: string, stop: string): number[] {
const result = []; const result = [];
for (let idx=start.charCodeAt(0), end=stop.charCodeAt(0); idx <=end; ++idx) { for (let idx = start.charCodeAt(0), end = stop.charCodeAt(0); idx <= end; ++idx) {
result.push(idx); result.push(idx);
} }
return result; return result;
} }
const controlCharacters = [ const controlCharacters = [...charRange('\u0000', '\u001F'), ...charRange('\u007f', '\u009F'), ...charRange('\ufdd0', '\ufdef')];
...charRange('\u0000', '\u001F'),
...charRange('\u007f', '\u009F'),
...charRange('\ufdd0', '\ufdef'),
];
function containsControlCharacter(str: string): boolean { function containsControlCharacter(str: string): boolean {
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i); const ch = str.charCodeAt(i);
if (controlCharacters.includes(ch) || [0xFFFE, 0xFFFF].includes(ch & 0xFFFF)) { if (controlCharacters.includes(ch) || [0xfffe, 0xffff].includes(ch & 0xffff)) {
return true; return true;
} }
} }
@ -239,7 +250,7 @@ function getAllFiles(path_: string): string[] {
return result; return result;
} }
function validateFriendlyName(name: string, throwFirstError=false): string[] { function validateFriendlyName(name: string, throwFirstError = false): string[] {
const errors = []; const errors = [];
if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`); if (name.length === 0) errors.push(`friendly_name must be at least 1 char long`);
@ -264,7 +275,7 @@ function sleep(seconds: number): Promise<void> {
function sanitizeImageParameter(parameter: string): string { function sanitizeImageParameter(parameter: string): string {
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi]; const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
let sanitized = parameter; let sanitized = parameter;
replaceByDash.forEach((r) => sanitized = sanitized.replace(r, '-')); replaceByDash.forEach((r) => (sanitized = sanitized.replace(r, '-')));
return sanitized; return sanitized;
} }
@ -325,8 +336,12 @@ const hours = (hours: number): number => 1000 * 60 * 60 * hours;
const minutes = (minutes: number): number => 1000 * 60 * minutes; const minutes = (minutes: number): number => 1000 * 60 * minutes;
const seconds = (seconds: number): number => 1000 * seconds; const seconds = (seconds: number): number => 1000 * seconds;
async function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, allowMessageEmitted: boolean, async function publishLastSeen(
publishEntityState: PublishEntityState): Promise<void> { data: eventdata.LastSeenChanged,
settings: Settings,
allowMessageEmitted: boolean,
publishEntityState: PublishEntityState,
): Promise<void> {
/** /**
* Prevent 2 MQTT publishes when 1 message event is received; * 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 * - In case reason == messageEmitted, receive.ts will only call this when it did not publish a
@ -382,10 +397,34 @@ function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
} }
export default { export default {
capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties, capitalize,
equalsPartial, getObjectProperty, getResponse, parseJSON, loadModuleFromText, loadModuleFromFile, getZigbee2MQTTVersion,
removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase, getDependencyVersion,
isEndpoint, isZHGroup, hours, minutes, seconds, validateFriendlyName, sleep, formatDate,
sanitizeImageParameter, isAvailabilityEnabledForEntity, publishLastSeen, availabilityPayload, objectHasProperties,
getAllFiles, filterProperties, flatten, arrayUnique, getScenes, equalsPartial,
getObjectProperty,
getResponse,
parseJSON,
loadModuleFromText,
loadModuleFromFile,
removeNullPropertiesFromObject,
toNetworkAddressHex,
toSnakeCase,
isEndpoint,
isZHGroup,
hours,
minutes,
seconds,
validateFriendlyName,
sleep,
sanitizeImageParameter,
isAvailabilityEnabledForEntity,
publishLastSeen,
availabilityPayload,
getAllFiles,
filterProperties,
flatten,
arrayUnique,
getScenes,
}; };

View File

@ -1,11 +1,11 @@
import yaml from 'js-yaml';
import fs from 'fs';
import equals from 'fast-deep-equal/es6'; import equals from 'fast-deep-equal/es6';
import fs from 'fs';
import yaml from 'js-yaml';
function read(file: string): KeyValue { function read(file: string): KeyValue {
try { try {
const result = yaml.load(fs.readFileSync(file, 'utf8')); const result = yaml.load(fs.readFileSync(file, 'utf8'));
return result as KeyValue ?? {}; return (result as KeyValue) ?? {};
} catch (error) { } catch (error) {
if (error.name === 'YAMLException') { if (error.name === 'YAMLException') {
error.file = file; error.file = file;

View File

@ -1,14 +1,15 @@
import {Controller} from 'zigbee-herdsman';
import logger from './util/logger';
import * as settings from './util/settings';
import data from './util/data';
import utils from './util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import Device from './model/device';
import Group from './model/group';
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
import bind from 'bind-decorator'; import bind from 'bind-decorator';
import {randomInt} from 'crypto'; import {randomInt} from 'crypto';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from 'zigbee-herdsman';
import * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
import Device from './model/device';
import Group from './model/group';
import data from './util/data';
import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`); const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`);
@ -27,13 +28,14 @@ export default class Zigbee {
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`); logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
const herdsmanSettings = { const herdsmanSettings = {
network: { network: {
panID: settings.get().advanced.pan_id === 'GENERATE' ? panID: settings.get().advanced.pan_id === 'GENERATE' ? this.generatePanID() : (settings.get().advanced.pan_id as number),
this.generatePanID() : settings.get().advanced.pan_id as number, extendedPanID:
extendedPanID: settings.get().advanced.ext_pan_id === 'GENERATE' ? settings.get().advanced.ext_pan_id === 'GENERATE' ? this.generateExtPanID() : (settings.get().advanced.ext_pan_id as number[]),
this.generateExtPanID() : settings.get().advanced.ext_pan_id as number[],
channelList: [settings.get().advanced.channel], channelList: [settings.get().advanced.channel],
networkKey: settings.get().advanced.network_key === 'GENERATE' ? networkKey:
this.generateNetworkKey() : settings.get().advanced.network_key as number[], settings.get().advanced.network_key === 'GENERATE'
? this.generateNetworkKey()
: (settings.get().advanced.network_key as number[]),
}, },
databasePath: data.joinPath('database.db'), databasePath: data.joinPath('database.db'),
databaseBackupPath: data.joinPath('database.db.backup'), databaseBackupPath: data.joinPath('database.db.backup'),
@ -108,10 +110,12 @@ export default class Zigbee {
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => { this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
const device = this.resolveDevice(data.device.ieeeAddr); const device = this.resolveDevice(data.device.ieeeAddr);
await device.resolveDefinition(); await device.resolveDefinition();
logger.debug(`Received Zigbee message from '${device.name}', type '${data.type}', ` + logger.debug(
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` + `Received Zigbee message from '${device.name}', type '${data.type}', ` +
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) + `cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``)); (data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) +
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``),
);
if (device.zh.type === 'Coordinator') return; if (device.zh.type === 'Coordinator') return;
this.eventBus.emitDeviceMessage({...data, device}); this.eventBus.emitDeviceMessage({...data, device});
}); });
@ -161,14 +165,16 @@ export default class Zigbee {
const {vendor, description, model} = data.device.definition; const {vendor, description, model} = data.device.definition;
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`); logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
} else { } else {
logger.warning(`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` + logger.warning(
`'${data.device.zh.manufacturerName}' is NOT supported, ` + `Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
// eslint-disable-next-line max-len `'${data.device.zh.manufacturerName}' is NOT supported, ` +
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`); `please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
);
} }
} else if (data.status === 'failed') { } else if (data.status === 'failed') {
logger.error(`Failed to interview '${name}', device has not successfully been paired`); logger.error(`Failed to interview '${name}', device has not successfully been paired`);
} else { // data.status === 'started' } else {
// data.status === 'started'
logger.info(`Starting interview of '${name}'`); logger.info(`Starting interview of '${name}'`);
} }
} }
@ -186,7 +192,7 @@ export default class Zigbee {
} }
private generatePanID(): number { private generatePanID(): number {
const panID = randomInt(1, 0xFFFF - 1); const panID = randomInt(1, 0xffff - 1);
settings.set(['advanced', 'pan_id'], panID); settings.set(['advanced', 'pan_id'], panID);
return panID; return panID;
} }
@ -230,7 +236,7 @@ export default class Zigbee {
return this.herdsman.getPermitJoinTimeout(); return this.herdsman.getPermitJoinTimeout();
} }
async permitJoin(permit: boolean, device?: Device, time: number=undefined): Promise<void> { async permitJoin(permit: boolean, device?: Device, time: number = undefined): Promise<void> {
if (permit) { if (permit) {
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`); logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
} else { } else {
@ -284,8 +290,7 @@ export default class Zigbee {
} }
} }
resolveEntityAndEndpoint(ID: string) resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group; endpointID: string; endpoint: zh.Endpoint} {
: {ID: string, entity: Device | Group, endpointID: string, endpoint: zh.Endpoint} {
// This function matches the following entity formats: // This function matches the following entity formats:
// device_name (just device name) // device_name (just device name)
// device_name/ep_name (device name and endpoint numeric ID or name) // device_name/ep_name (device name and endpoint numeric ID or name)
@ -324,8 +329,9 @@ export default class Zigbee {
return this.herdsman.getGroups().map((g) => this.resolveGroup(g.groupID)); return this.herdsman.getGroups().map((g) => this.resolveGroup(g.groupID));
} }
devices(includeCoordinator=true): Device[] { devices(includeCoordinator = true): Device[] {
return this.herdsman.getDevices() return this.herdsman
.getDevices()
.map((d) => this.resolveDevice(d.ieeeAddr)) .map((d) => this.resolveDevice(d.ieeeAddr))
.filter((d) => includeCoordinator || d.zh.type !== 'Coordinator'); .filter((d) => includeCoordinator || d.zh.type !== 'Coordinator');
} }
@ -371,7 +377,7 @@ export default class Zigbee {
await this.herdsman.touchlinkIdentify(ieeeAddr, channel); await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
} }
async touchlinkScan(): Promise<{ieeeAddr: string, channel: number}[]> { async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
return this.herdsman.touchlinkScan(); return this.herdsman.touchlinkScan();
} }

71
package-lock.json generated
View File

@ -56,9 +56,11 @@
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^7.13.1",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-google": "^0.14.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-perfectionist": "^2.11.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.3.2",
"tmp": "^0.2.3", "tmp": "^0.2.3",
"typescript": "^5.5.2" "typescript": "^5.5.2"
}, },
@ -4671,16 +4673,16 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint-config-google": { "node_modules/eslint-config-prettier": {
"version": "0.14.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true, "dev": true,
"engines": { "bin": {
"node": ">=0.10.0" "eslint-config-prettier": "bin/cli.js"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=5.16.0" "eslint": ">=7.0.0"
} }
}, },
"node_modules/eslint-plugin-jest": { "node_modules/eslint-plugin-jest": {
@ -4708,6 +4710,38 @@
} }
} }
}, },
"node_modules/eslint-plugin-perfectionist": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-2.11.0.tgz",
"integrity": "sha512-XrtBtiu5rbQv88gl+1e2RQud9te9luYNvKIgM9emttQ2zutHPzY/AQUucwxscDKV4qlTkvLTxjOFvxqeDpPorw==",
"dev": true,
"dependencies": {
"@typescript-eslint/utils": "^6.13.0 || ^7.0.0",
"minimatch": "^9.0.3",
"natural-compare-lite": "^1.4.0"
},
"peerDependencies": {
"astro-eslint-parser": "^1.0.2",
"eslint": ">=8.0.0",
"svelte": ">=3.0.0",
"svelte-eslint-parser": "^0.37.0",
"vue-eslint-parser": ">=9.0.0"
},
"peerDependenciesMeta": {
"astro-eslint-parser": {
"optional": true
},
"svelte": {
"optional": true
},
"svelte-eslint-parser": {
"optional": true
},
"vue-eslint-parser": {
"optional": true
}
}
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "7.2.2", "version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@ -7939,6 +7973,12 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "dev": true
}, },
"node_modules/natural-compare-lite": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true
},
"node_modules/node-addon-api": { "node_modules/node-addon-api": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz",
@ -8283,6 +8323,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",

View File

@ -23,6 +23,8 @@
"build": "tsc && node index.js writehash", "build": "tsc && node index.js writehash",
"build-watch": "tsc --watch", "build-watch": "tsc --watch",
"eslint": "eslint lib/ --max-warnings=0", "eslint": "eslint lib/ --max-warnings=0",
"pretty:write": "prettier --write lib test",
"pretty:check": "prettier --check lib test",
"start": "node index.js", "start": "node index.js",
"test-with-coverage": "jest test --silent --maxWorkers=50% --coverage", "test-with-coverage": "jest test --silent --maxWorkers=50% --coverage",
"test": "jest test --silent --maxWorkers=50%", "test": "jest test --silent --maxWorkers=50%",
@ -79,9 +81,11 @@
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^7.13.1",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-google": "^0.14.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-perfectionist": "^2.11.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.3.2",
"tmp": "^0.2.3", "tmp": "^0.2.3",
"typescript": "^5.5.2" "typescript": "^5.5.2"
}, },

View File

@ -1,4 +1,3 @@
/* eslint max-len: 0 */
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const process = require('process'); const process = require('process');

View File

@ -1,11 +1,11 @@
class Example { class Example {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) { constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
this.mqtt = mqtt; this.mqtt = mqtt;
this.mqtt.publish('example/extension', 'call from constructor') this.mqtt.publish('example/extension', 'call from constructor');
} }
start() { start() {
this.mqtt.publish('example/extension', 'test') this.mqtt.publish('example/extension', 'test');
} }
} }

View File

@ -9,25 +9,28 @@ const homeassistantSwitch = {
}, },
}; };
const mockDevices = [{ const mockDevices = [
mock: 1, {
model: 'external_converters_device_1', mock: 1,
homeassistant: [homeassistantSwitch], model: 'external_converters_device_1',
zigbeeModel: ['external_converter_device_1'], homeassistant: [homeassistantSwitch],
vendor: 'external_1', zigbeeModel: ['external_converter_device_1'],
description: 'external_1', vendor: 'external_1',
fromZigbee: [], description: 'external_1',
toZigbee: [], fromZigbee: [],
exposes: [], toZigbee: [],
}, { exposes: [],
mock: 2, },
model: 'external_converters_device_2', {
zigbeeModel: ['external_converter_device_2'], mock: 2,
vendor: 'external_2', model: 'external_converters_device_2',
description: 'external_2', zigbeeModel: ['external_converter_device_2'],
fromZigbee: [], vendor: 'external_2',
toZigbee: [], description: 'external_2',
exposes: [], fromZigbee: [],
}]; toZigbee: [],
exposes: [],
},
];
module.exports = mockDevices; module.exports = mockDevices;

View File

@ -9,4 +9,4 @@ const mockDevice = {
exposes: [], exposes: [],
}; };
module.exports = mockDevice; module.exports = mockDevice;

View File

@ -13,7 +13,8 @@ import stringify from 'json-stable-stringify-without-jsonify';
const mocks = [MQTT.publish, logger.warning, logger.info]; const mocks = [MQTT.publish, logger.warning, logger.info];
const devices = zigbeeHerdsman.devices; const devices = zigbeeHerdsman.devices;
zigbeeHerdsman.returnDevices.push( zigbeeHerdsman.returnDevices.push(
...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr]) ...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr],
);
describe('Availability', () => { describe('Availability', () => {
let controller; let controller;
@ -21,12 +22,12 @@ describe('Availability', () => {
let resetExtension = async () => { let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Availability'); await controller.enableDisableExtension(false, 'Availability');
await controller.enableDisableExtension(true, 'Availability'); await controller.enableDisableExtension(true, 'Availability');
} };
const setTimeAndAdvanceTimers = async (value) => { const setTimeAndAdvanceTimers = async (value) => {
jest.setSystemTime(Date.now() + value); jest.setSystemTime(Date.now() + value);
await jest.advanceTimersByTimeAsync(value); await jest.advanceTimersByTimeAsync(value);
} };
beforeAll(async () => { beforeAll(async () => {
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {}); jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
@ -44,24 +45,28 @@ describe('Availability', () => {
settings.reRead(); settings.reRead();
settings.set(['availability'], true); settings.set(['availability'], true);
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false); settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1)); Object.values(devices).forEach((d) => (d.lastSeen = utils.minutes(1)));
mocks.forEach((m) => m.mockClear()); mocks.forEach((m) => m.mockClear());
await resetExtension(); await resetExtension();
Object.values(devices).forEach((d) => d.ping.mockClear()); Object.values(devices).forEach((d) => d.ping.mockClear());
}); });
afterEach(async () => { afterEach(async () => {});
})
afterAll(async () => { afterAll(async () => {
await controller.stop(); await controller.stop();
jest.useRealTimers(); jest.useRealTimers();
}) });
it('Should publish availability on startup for device where it is enabled for', async () => { it('Should publish availability on startup for device where it is enabled for', async () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'online', {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability', 'online', {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).not.toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color_2/availability',
'online',
{retain: true, qos: 1},
expect.any(Function),
);
}); });
it('Should ping on startup for enabled and unavailable devices', async () => { it('Should ping on startup for enabled and unavailable devices', async () => {
@ -71,8 +76,8 @@ describe('Availability', () => {
await resetExtension(); await resetExtension();
await setTimeAndAdvanceTimers(utils.minutes(1)); await setTimeAndAdvanceTimers(utils.minutes(1));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);// enabled/unavailable expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); // enabled/unavailable
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);// enabled/available expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // enabled/available
}); });
it('Should not ping on startup for available or disabled devices', async () => { it('Should not ping on startup for available or disabled devices', async () => {
@ -83,8 +88,8 @@ describe('Availability', () => {
await resetExtension(); await resetExtension();
await setTimeAndAdvanceTimers(utils.minutes(1)); await setTimeAndAdvanceTimers(utils.minutes(1));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);// enabled/available expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); // enabled/available
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);// disabled/unavailable expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // disabled/unavailable
}); });
it('Should publish offline for active device when not seen for 10 minutes', async () => { it('Should publish offline for active device when not seen for 10 minutes', async () => {
@ -134,7 +139,9 @@ describe('Availability', () => {
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color}); await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color});
devices.bulb_color.ping.mockImplementationOnce(() => {throw new Error('failed')}); devices.bulb_color.ping.mockImplementationOnce(() => {
throw new Error('failed');
});
await setTimeAndAdvanceTimers(utils.minutes(15)); await setTimeAndAdvanceTimers(utils.minutes(15));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
@ -218,7 +225,7 @@ describe('Availability', () => {
await setTimeAndAdvanceTimers(utils.minutes(9)); await setTimeAndAdvanceTimers(utils.minutes(9));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: "bulb_color"})); MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb_color'}));
await flushPromises(); await flushPromises();
await setTimeAndAdvanceTimers(utils.minutes(3)); await setTimeAndAdvanceTimers(utils.minutes(3));
@ -249,7 +256,7 @@ describe('Availability', () => {
//@ts-expect-error private //@ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr); const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
//@ts-expect-error private //@ts-expect-error private
controller.state.set(device, {state: 'OFF'}) controller.state.set(device, {state: 'OFF'});
const endpoint = devices.bulb_color.getEndpoint(1); const endpoint = devices.bulb_color.getEndpoint(1);
endpoint.read.mockClear(); endpoint.read.mockClear();
@ -269,7 +276,9 @@ describe('Availability', () => {
endpoint.read.mockClear(); endpoint.read.mockClear();
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color}); await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
await flushPromises(); await flushPromises();
endpoint.read.mockImplementationOnce(() => {throw new Error('')}); endpoint.read.mockImplementationOnce(() => {
throw new Error('');
});
await setTimeAndAdvanceTimers(utils.seconds(3)); await setTimeAndAdvanceTimers(utils.seconds(3));
expect(endpoint.read).toHaveBeenCalledTimes(1); expect(endpoint.read).toHaveBeenCalledTimes(1);
}); });
@ -293,7 +302,12 @@ describe('Availability', () => {
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await setTimeAndAdvanceTimers(utils.hours(26)); await setTimeAndAdvanceTimers(utils.hours(26));
expect(devices.remote.ping).toHaveBeenCalledTimes(0); expect(devices.remote.ping).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', stringify({state: 'offline'}), {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/remote/availability',
stringify({state: 'offline'}),
{retain: true, qos: 1},
expect.any(Function),
);
}); });
it('Deprecated - should allow to block via advanced.availability_blocklist', async () => { it('Deprecated - should allow to block via advanced.availability_blocklist', async () => {
@ -332,15 +346,30 @@ describe('Availability', () => {
await resetExtension(); await resetExtension();
devices.bulb_color_2.ping.mockClear(); devices.bulb_color_2.ping.mockClear();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', 'online', {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_tradfri_remote/availability',
'online',
{retain: true, qos: 1},
expect.any(Function),
);
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(12)); await setTimeAndAdvanceTimers(utils.minutes(12));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', 'offline', {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_tradfri_remote/availability',
'offline',
{retain: true, qos: 1},
expect.any(Function),
);
MQTT.publish.mockClear(); MQTT.publish.mockClear();
devices.bulb_color_2.lastSeen = Date.now(); devices.bulb_color_2.lastSeen = Date.now();
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_tradfri_remote/availability', 'online', {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_tradfri_remote/availability',
'online',
{retain: true, qos: 1},
expect.any(Function),
);
}); });
it('Should clear the ping queue on stop', async () => { it('Should clear the ping queue on stop', async () => {
@ -358,7 +387,7 @@ describe('Availability', () => {
expect(availability.pingQueue).toEqual([]); expect(availability.pingQueue).toEqual([]);
// Validate the stop-interrupt implicitly by checking that it prevents further function invocations // Validate the stop-interrupt implicitly by checking that it prevents further function invocations
expect(publishAvailabilitySpy).not.toHaveBeenCalled(); expect(publishAvailabilitySpy).not.toHaveBeenCalled();
devices.bulb_color.ping = jest.fn();// ensure reset devices.bulb_color.ping = jest.fn(); // ensure reset
}); });
it('Should prevent instance restart', async () => { it('Should prevent instance restart', async () => {

View File

@ -6,7 +6,7 @@ const settings = require('../lib/util/settings');
const Controller = require('../lib/controller'); const Controller = require('../lib/controller');
const flushPromises = require('./lib/flushPromises'); const flushPromises = require('./lib/flushPromises');
const stringify = require('json-stable-stringify-without-jsonify'); const stringify = require('json-stable-stringify-without-jsonify');
jest.mock('debounce', () => jest.fn(fn => fn)); jest.mock('debounce', () => jest.fn((fn) => fn));
const debounce = require('debounce'); const debounce = require('debounce');
describe('Bind', () => { describe('Bind', () => {
@ -21,12 +21,12 @@ describe('Bind', () => {
endpoint.bind.mockClear(); endpoint.bind.mockClear();
endpoint.unbind.mockClear(); endpoint.unbind.mockClear();
} }
} };
let resetExtension = async () => { let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Bind'); await controller.enableDisableExtension(false, 'Bind');
await controller.enableDisableExtension(true, 'Bind'); await controller.enableDisableExtension(true, 'Bind');
} };
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -49,7 +49,7 @@ describe('Bind', () => {
afterAll(async () => { afterAll(async () => {
jest.useRealTimers(); jest.useRealTimers();
}) });
it('Should bind to device and configure reporting', async () => { it('Should bind to device and configure reporting', async () => {
const device = zigbeeHerdsman.devices.remote; const device = zigbeeHerdsman.devices.remote;
@ -61,29 +61,44 @@ describe('Bind', () => {
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768]; device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
const originalTargetBinds = target.binds; const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}]; target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined); target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined);
mockClear(device); mockClear(device);
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")}); target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: "1234", from: 'remote', to: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
await flushPromises(); await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]); expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4); expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(target.configureReporting).toHaveBeenCalledTimes(3); expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]); expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]); {attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1}]); ]);
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}), stringify({
{retain: false, qos: 0}, expect.any(Function) transaction: '1234',
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
expect.any(Function),
); );
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
// Teardown // Teardown
target.binds = originalTargetBinds; target.binds = originalTargetBinds;
@ -102,35 +117,48 @@ describe('Bind', () => {
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768]; device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
const originalTargetInputClusters = target.inputClusters; const originalTargetInputClusters = target.inputClusters;
target.inputClusters = [...originalTargetInputClusters]; target.inputClusters = [...originalTargetInputClusters];
target.inputClusters.splice(originalTargetInputClusters.indexOf(8), 1);// remove genLevelCtrl target.inputClusters.splice(originalTargetInputClusters.indexOf(8), 1); // remove genLevelCtrl
const originalTargetOutputClusters = target.outputClusters; const originalTargetOutputClusters = target.outputClusters;
target.outputClusters = [...target.outputClusters, 8]; target.outputClusters = [...target.outputClusters, 8];
const originalTargetBinds = target.binds; const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}]; target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined); target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined);
mockClear(device); mockClear(device);
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")}); target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: "1234", from: 'remote', to: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
await flushPromises(); await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]); expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4); expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(target.configureReporting).toHaveBeenCalledTimes(2); expect(target.configureReporting).toHaveBeenCalledTimes(2);
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]); expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]); // expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1}]); expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl","lightingColorCtrl"],"failed":[]},"status":"ok"}), stringify({
{retain: false, qos: 0}, expect.any(Function) transaction: '1234',
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
expect.any(Function),
); );
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
// Teardown // Teardown
target.binds = originalTargetBinds; target.binds = originalTargetBinds;
@ -150,9 +178,11 @@ describe('Bind', () => {
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768]; device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
const originalTargetBinds = target.binds; const originalTargetBinds = target.binds;
target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}]; target.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}];
target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined); target.getClusterAttributeValue.mockImplementationOnce((cluster, value) => undefined);
mockClear(device); mockClear(device);
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")}); target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
const originalTargetCR = target.configuredReportings; const originalTargetCR = target.configuredReportings;
target.configuredReportings = [ target.configuredReportings = [
{ {
@ -161,28 +191,39 @@ describe('Bind', () => {
minimumReportInterval: 0, minimumReportInterval: 0,
maximumReportInterval: 3600, maximumReportInterval: 3600,
reportableChange: 0, reportableChange: 0,
} },
]; ];
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: "1234", from: 'remote', to: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({transaction: '1234', from: 'remote', to: 'bulb_color'}));
await flushPromises(); await flushPromises();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]); expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4); expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(target.configureReporting).toHaveBeenCalledTimes(2); expect(target.configureReporting).toHaveBeenCalledTimes(2);
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]); expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
// expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]); // expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":3600,"reportableChange":1}]); expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: 1},
]);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}), stringify({
{retain: false, qos: 0}, expect.any(Function) transaction: '1234',
data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
expect.any(Function),
); );
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
// Teardown // Teardown
target.configuredReportings = originalTargetCR; target.configuredReportings = originalTargetCR;
@ -195,14 +236,15 @@ describe('Bind', () => {
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
mockClear(device); mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ["genOnOff"]})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color', clusters: ['genOnOff']}));
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1); expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genOnOff"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -216,8 +258,9 @@ describe('Bind', () => {
expect(endpoint.bind).toHaveBeenCalledTimes(0); expect(endpoint.bind).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"button","clusters":[],"failed":[]},"status":"error","error":"Nothing to bind"}), stringify({data: {from: 'remote', to: 'button', clusters: [], failed: []}, status: 'error', error: 'Nothing to bind'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -226,14 +269,16 @@ describe('Bind', () => {
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
// setup // setup
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")}); target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
const originalRemoteBinds = device.getEndpoint(1).binds; const originalRemoteBinds = device.getEndpoint(1).binds;
device.getEndpoint(1).binds = []; device.getEndpoint(1).binds = [];
const originalTargetBinds = target.binds; const originalTargetBinds = target.binds;
target.binds = [ target.binds = [
{cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
{cluster: {name: 'lightingColorCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)} {cluster: {name: 'lightingColorCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
]; ];
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
@ -243,20 +288,29 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
await flushPromises(); await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
// Disable reporting // Disable reporting
expect(target.configureReporting).toHaveBeenCalledTimes(3); expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 0, "reportableChange": 0}]); expect(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
expect(target.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 5, "reportableChange": 1}]); {attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
expect(target.configureReporting).toHaveBeenCalledWith("lightingColorCtrl",[{"attribute":"colorTemperature","minimumReportInterval":5,"maximumReportInterval":0xFFFF,"reportableChange":1},{"attribute":"currentX","minimumReportInterval":5,"maximumReportInterval":0xFFFF,"reportableChange":1},{"attribute":"currentY","minimumReportInterval":5,"maximumReportInterval":0xFFFF,"reportableChange":1}]); ]);
expect(target.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: 'currentX', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
{attribute: 'currentY', minimumReportInterval: 5, maximumReportInterval: 0xffff, reportableChange: 1},
]);
expect(zigbeeHerdsman.devices.bulb_color.meta.configured).toBe(332242049); expect(zigbeeHerdsman.devices.bulb_color.meta.configured).toBe(332242049);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind', 'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
// Teardown // Teardown
@ -273,13 +327,14 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
await flushPromises(); await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind', 'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"Coordinator","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -294,16 +349,21 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'group_1'}));
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3); expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2); expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]); expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]); {attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"group_1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
// Should configure reproting for device added to group // Should configure reproting for device added to group
@ -311,8 +371,12 @@ describe('Bind', () => {
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb'); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb');
await flushPromises(); await flushPromises();
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2); expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]); expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]); {attribute: 'onOff', maximumReportInterval: 3600, minimumReportInterval: 0, reportableChange: 0},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 3600, minimumReportInterval: 5, reportableChange: 1},
]);
}); });
it('Should unbind from group', async () => { it('Should unbind from group', async () => {
@ -326,13 +390,14 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
await flushPromises(); await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind', 'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"group_1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -347,7 +412,10 @@ describe('Bind', () => {
const originalBinds = endpoint.binds; const originalBinds = endpoint.binds;
endpoint.binds = []; endpoint.binds = [];
target1Member.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}]; target1Member.binds = [
{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
{cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
];
target1Member.configureReporting.mockClear(); target1Member.configureReporting.mockClear();
mockClear(device); mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true})); MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: true}));
@ -369,7 +437,10 @@ describe('Bind', () => {
const originalBinds = endpoint.binds; const originalBinds = endpoint.binds;
endpoint.binds = []; endpoint.binds = [];
target1Member.binds = [{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}, {cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)}]; target1Member.binds = [
{cluster: {name: 'genLevelCtrl'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
{cluster: {name: 'genOnOff'}, target: zigbeeHerdsman.devices.coordinator.getEndpoint(1)},
];
target1Member.configureReporting.mockClear(); target1Member.configureReporting.mockClear();
mockClear(device); mockClear(device);
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false})); MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1', skip_disable_reporting: false}));
@ -377,8 +448,12 @@ describe('Bind', () => {
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
// with skip_disable_reporting set, we expect it to reconfigure reporting // with skip_disable_reporting set, we expect it to reconfigure reporting
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2); expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 65535, "minimumReportInterval": 5, "reportableChange": 1}]) expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 65535, "minimumReportInterval": 0, "reportableChange": 0}]) {attribute: 'currentLevel', maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
]);
endpoint.binds = originalBinds; endpoint.binds = originalBinds;
}); });
@ -390,13 +465,14 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3); expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -405,14 +481,21 @@ describe('Bind', () => {
const device = zigbeeHerdsman.devices.remote; const device = zigbeeHerdsman.devices.remote;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
mockClear(device); mockClear(device);
endpoint.bind.mockImplementation(() => {throw new Error('failed')}); endpoint.bind.mockImplementation(() => {
throw new Error('failed');
});
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: 'bulb_color'}));
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3); expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":[],"failed":["genScenes","genOnOff","genLevelCtrl"]},"status":"error","error":"Failed to bind"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {from: 'remote', to: 'bulb_color', clusters: [], failed: ['genScenes', 'genOnOff', 'genLevelCtrl']},
status: 'error',
error: 'Failed to bind',
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -424,11 +507,12 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch_double/right'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch_double/right'}));
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1); expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote/ep2","to":"wall_switch_double/right","clusters":["genOnOff"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote/ep2', to: 'wall_switch_double/right', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -440,11 +524,12 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'temperature_sensor', to: 'heating_actuator'}));
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1); expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("msTemperatureMeasurement", target); expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"temperature_sensor","to":"heating_actuator","clusters":["msTemperatureMeasurement"],"failed":[]},"status":"ok"}), stringify({data: {from: 'temperature_sensor', to: 'heating_actuator', clusters: ['msTemperatureMeasurement'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -456,11 +541,12 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch'})); MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote/ep2', to: 'wall_switch'}));
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1); expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote/ep2","to":"wall_switch","clusters":["genOnOff"],"failed":[]},"status":"ok"}), stringify({data: {from: 'remote/ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -472,13 +558,17 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: target})); MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: target}));
await flushPromises(); await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901); expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901); expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind', 'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"default_bind_group","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {from: 'remote', to: 'default_bind_group', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []},
status: 'ok',
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -491,8 +581,13 @@ describe('Bind', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote_not_existing","to":"bulb_color"},"status":"error","error":"Source device 'remote_not_existing' does not exist"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {from: 'remote_not_existing', to: 'bulb_color'},
status: 'error',
error: "Source device 'remote_not_existing' does not exist",
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -505,8 +600,13 @@ describe('Bind', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote/not_existing_endpoint","to":"bulb_color"},"status":"error","error":"Source device 'remote' does not have endpoint 'not_existing_endpoint'"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {from: 'remote/not_existing_endpoint', to: 'bulb_color'},
status: 'error',
error: "Source device 'remote' does not have endpoint 'not_existing_endpoint'",
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -519,8 +619,13 @@ describe('Bind', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color_not_existing"},"status":"error","error":"Target device or group 'bulb_color_not_existing' does not exist"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {from: 'remote', to: 'bulb_color_not_existing'},
status: 'error',
error: "Target device or group 'bulb_color_not_existing' does not exist",
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -533,8 +638,13 @@ describe('Bind', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind', 'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color/not_existing_endpoint"},"status":"error","error":"Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {from: 'remote', to: 'bulb_color/not_existing_endpoint'},
status: 'error',
error: "Target device 'bulb_color' does not have endpoint 'not_existing_endpoint'",
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -546,15 +656,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color'); MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color');
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3); expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'}}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'},
});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'}}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'},
});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'}}); expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'},
});
}); });
it('Legacy api: Should log error when there is nothing to bind', async () => { it('Legacy api: Should log error when there is nothing to bind', async () => {
@ -576,15 +695,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'bulb_color'); MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'bulb_color');
await flushPromises(); await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'}}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'},
});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'}}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'bulb_color', cluster: 'genOnOff'},
});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'}}); expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'},
});
}); });
it('Legacy api: Should unbind coordinator', async () => { it('Legacy api: Should unbind coordinator', async () => {
@ -596,15 +724,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'Coordinator'); MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'Coordinator');
await flushPromises(); await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genScenes'}}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'Coordinator', cluster: 'genScenes'},
});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genOnOff'}}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'Coordinator', cluster: 'genOnOff'},
});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genLevelCtrl'}}); expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'Coordinator', cluster: 'genLevelCtrl'},
});
}); });
it('Legacy api: Should bind to groups', async () => { it('Legacy api: Should bind to groups', async () => {
@ -615,15 +752,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'group_1'); MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'group_1');
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3); expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genScenes'}}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'group_1', cluster: 'genScenes'},
});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'}}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'},
});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'}}); expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'},
});
}); });
it('Legacy api: Should bind to group by number', async () => { it('Legacy api: Should bind to group by number', async () => {
@ -634,15 +780,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', '1'); MQTT.events.message('zigbee2mqtt/bridge/bind/remote', '1');
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3); expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genScenes'}}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'group_1', cluster: 'genScenes'},
});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'}}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'group_1', cluster: 'genOnOff'},
});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'}}); expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
type: 'device_bind',
message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'},
});
}); });
it('Legacy api: Should log when bind fails', async () => { it('Legacy api: Should log when bind fails', async () => {
@ -650,7 +805,9 @@ describe('Bind', () => {
const device = zigbeeHerdsman.devices.remote; const device = zigbeeHerdsman.devices.remote;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
mockClear(device); mockClear(device);
endpoint.bind.mockImplementationOnce(() => {throw new Error('failed')}); endpoint.bind.mockImplementationOnce(() => {
throw new Error('failed');
});
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color'); MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color');
await flushPromises(); await flushPromises();
expect(logger.error).toHaveBeenCalledWith("Failed to bind cluster 'genScenes' from 'remote' to 'bulb_color' (Error: failed)"); expect(logger.error).toHaveBeenCalledWith("Failed to bind cluster 'genScenes' from 'remote' to 'bulb_color' (Error: failed)");
@ -665,7 +822,7 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch_double/right'); MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch_double/right');
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1); expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
}); });
it('Legacy api: Should bind to default endpoint returned by endpoints()', async () => { it('Legacy api: Should bind to default endpoint returned by endpoints()', async () => {
@ -676,7 +833,7 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch'); MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch');
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1); expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
}); });
it('Legacy api: Should unbind from default_bind_group', async () => { it('Legacy api: Should unbind from default_bind_group', async () => {
@ -687,53 +844,92 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', target); MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', target);
await flushPromises(); await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3); expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901); expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901); expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'default_bind_group', cluster: 'genScenes'}}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'default_bind_group', cluster: 'genScenes'},
});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'default_bind_group', cluster: 'genOnOff'}}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'default_bind_group', cluster: 'genOnOff'},
});
expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[2][0]).toStrictEqual('zigbee2mqtt/bridge/log');
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'default_bind_group', cluster: 'genLevelCtrl'}}); expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({
type: 'device_unbind',
message: {from: 'remote', to: 'default_bind_group', cluster: 'genLevelCtrl'},
});
}); });
it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => { it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => {
const remote = zigbeeHerdsman.devices.remote; const remote = zigbeeHerdsman.devices.remote;
const data = {"button":3,"unknown1":3145728,"type":2,"unknown2":0,"time":1}; const data = {button: 3, unknown1: 3145728, type: 2, unknown2: 0, time: 1};
const payload = {data, cluster: 'manuSpecificPhilips', device: remote, endpoint: remote.getEndpoint(2), type: 'commandHueNotification', linkquality: 10, groupID: 0}; const payload = {
data,
cluster: 'manuSpecificPhilips',
device: remote,
endpoint: remote.getEndpoint(2),
type: 'commandHueNotification',
linkquality: 10,
groupID: 0,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(debounce).toHaveBeenCalledTimes(1); expect(debounce).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.devices.bulb_color.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]); expect(zigbeeHerdsman.devices.bulb_color.getEndpoint(1).read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
}); });
it('Should poll bounded Hue bulb when receiving message from scene controller', async () => { it('Should poll bounded Hue bulb when receiving message from scene controller', async () => {
const remote = zigbeeHerdsman.devices.bj_scene_switch; const remote = zigbeeHerdsman.devices.bj_scene_switch;
const data = {"action": "recall_2_row_1"}; const data = {action: 'recall_2_row_1'};
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockImplementationOnce(() => {throw new Error('failed')}); zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockImplementationOnce(() => {
const payload = {data, cluster: 'genScenes', device: remote, endpoint: remote.getEndpoint(10), type: 'commandRecall', linkquality: 10, groupID: 0}; throw new Error('failed');
});
const payload = {
data,
cluster: 'genScenes',
device: remote,
endpoint: remote.getEndpoint(10),
type: 'commandRecall',
linkquality: 10,
groupID: 0,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
// Calls to three clusters are expected in this case // Calls to three clusters are expected in this case
expect(debounce).toHaveBeenCalledTimes(3); expect(debounce).toHaveBeenCalledTimes(3);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genOnOff", ["onOff"]); expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genOnOff', ['onOff']);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]); expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("lightingColorCtrl", ["currentX", "currentY", "colorTemperature"]); expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('lightingColorCtrl', [
'currentX',
'currentY',
'colorTemperature',
]);
}); });
it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => { it('Should poll grouped Hue bulb when receiving message from TRADFRI remote', async () => {
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear(); zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear();
zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read.mockClear(); zigbeeHerdsman.devices.bulb_2.getEndpoint(1).read.mockClear();
const remote = zigbeeHerdsman.devices.tradfri_remote; const remote = zigbeeHerdsman.devices.tradfri_remote;
const data = {"stepmode":0,"stepsize":43,"transtime":5}; const data = {stepmode: 0, stepsize: 43, transtime: 5};
const payload = {data, cluster: 'genLevelCtrl', device: remote, endpoint: remote.getEndpoint(1), type: 'commandStepWithOnOff', linkquality: 10, groupID: 15071}; const payload = {
data,
cluster: 'genLevelCtrl',
device: remote,
endpoint: remote.getEndpoint(1),
type: 'commandStepWithOnOff',
linkquality: 10,
groupID: 15071,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(debounce).toHaveBeenCalledTimes(2); expect(debounce).toHaveBeenCalledTimes(2);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(2); expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(2);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]); expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genOnOff", ["onOff"]); expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genOnOff', ['onOff']);
// Should also only debounce once // Should also only debounce once
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);

File diff suppressed because one or more lines are too long

View File

@ -23,29 +23,29 @@ describe('Configure', () => {
const endpoint2 = device.getEndpoint(2); const endpoint2 = device.getEndpoint(2);
expect(endpoint2.write).toHaveBeenCalledTimes(1); expect(endpoint2.write).toHaveBeenCalledTimes(1);
expect(endpoint2.write).toHaveBeenCalledWith("genBasic", {"49": {"type": 25, "value": 11}}, {"disableDefaultResponse": true, "manufacturerCode": 4107}); expect(endpoint2.write).toHaveBeenCalledWith('genBasic', {49: {type: 25, value: 11}}, {disableDefaultResponse: true, manufacturerCode: 4107});
expect(device.meta.configured).toBe(332242049); expect(device.meta.configured).toBe(332242049);
} };
const expectBulbConfigured = () => { const expectBulbConfigured = () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint1 = device.getEndpoint(1); const endpoint1 = device.getEndpoint(1);
expect(endpoint1.read).toHaveBeenCalledTimes(2); expect(endpoint1.read).toHaveBeenCalledTimes(2);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']); expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorTempPhysicalMin', 'colorTempPhysicalMax' ]); expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorTempPhysicalMin', 'colorTempPhysicalMax']);
} };
const expectBulbNotConfigured = () => { const expectBulbNotConfigured = () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint1 = device.getEndpoint(1); const endpoint1 = device.getEndpoint(1);
expect(endpoint1.read).toHaveBeenCalledTimes(0); expect(endpoint1.read).toHaveBeenCalledTimes(0);
} };
const expectRemoteNotConfigured = () => { const expectRemoteNotConfigured = () => {
const device = zigbeeHerdsman.devices.remote; const device = zigbeeHerdsman.devices.remote;
const endpoint1 = device.getEndpoint(1); const endpoint1 = device.getEndpoint(1);
expect(endpoint1.bind).toHaveBeenCalledTimes(0); expect(endpoint1.bind).toHaveBeenCalledTimes(0);
} };
const mockClear = (device) => { const mockClear = (device) => {
for (const endpoint of device.endpoints) { for (const endpoint of device.endpoints) {
@ -54,12 +54,12 @@ describe('Configure', () => {
endpoint.configureReporting.mockClear(); endpoint.configureReporting.mockClear();
endpoint.bind.mockClear(); endpoint.bind.mockClear();
} }
} };
let resetExtension = async () => { let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Configure'); await controller.enableDisableExtension(false, 'Configure');
await controller.enableDisableExtension(true, 'Configure'); await controller.enableDisableExtension(true, 'Configure');
} };
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -80,7 +80,7 @@ describe('Configure', () => {
afterAll(async () => { afterAll(async () => {
jest.useRealTimers(); jest.useRealTimers();
}) });
it('Should configure Router on startup', async () => { it('Should configure Router on startup', async () => {
expectBulbConfigured(); expectBulbConfigured();
@ -149,39 +149,45 @@ describe('Configure', () => {
expectRemoteConfigured(); expectRemoteConfigured();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure', 'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "remote"},"status":"ok"}), stringify({data: {id: 'remote'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Fail to configure via MQTT when device does not exist', async () => { it('Fail to configure via MQTT when device does not exist', async () => {
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "not_existing_device"})); await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'not_existing_device'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure', 'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "not_existing_device"},"status":"error","error": "Device 'not_existing_device' does not exist"}), stringify({data: {id: 'not_existing_device'}, status: 'error', error: "Device 'not_existing_device' does not exist"}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Fail to configure via MQTT when configure fails', async () => { it('Fail to configure via MQTT when configure fails', async () => {
zigbeeHerdsman.devices.remote.getEndpoint(1).bind.mockImplementationOnce(async () => {throw new Error('Bind timeout after 10s')}); zigbeeHerdsman.devices.remote.getEndpoint(1).bind.mockImplementationOnce(async () => {
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "remote"})); throw new Error('Bind timeout after 10s');
});
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'remote'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure', 'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "remote"},"status":"error","error": "Failed to configure (Bind timeout after 10s)"}), stringify({data: {id: 'remote'}, status: 'error', error: 'Failed to configure (Bind timeout after 10s)'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Fail to configure via MQTT when device has no configure', async () => { it('Fail to configure via MQTT when device has no configure', async () => {
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "0x0017882104a44559", transaction: 20})); await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: '0x0017882104a44559', transaction: 20}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure', 'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "0x0017882104a44559"},"status":"error","error": "Device 'TS0601_thermostat' cannot be configured","transaction":20}), stringify({data: {id: '0x0017882104a44559'}, status: 'error', error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -202,7 +208,7 @@ describe('Configure', () => {
it('Legacy api: Should skip reconfigure when device does not require this', async () => { it('Legacy api: Should skip reconfigure when device does not require this', async () => {
await MQTT.events.message('zigbee2mqtt/bridge/configure', '0x0017882104a44559'); await MQTT.events.message('zigbee2mqtt/bridge/configure', '0x0017882104a44559');
await flushPromises(); await flushPromises();
expect(logger.warning).toHaveBeenCalledWith(`Skipping configure of 'TS0601_thermostat', device does not require this.`) expect(logger.warning).toHaveBeenCalledWith(`Skipping configure of 'TS0601_thermostat', device does not require this.`);
}); });
it('Should not configure when interview not completed', async () => { it('Should not configure when interview not completed', async () => {
@ -237,19 +243,29 @@ describe('Configure', () => {
delete device.meta.configured; delete device.meta.configured;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
mockClear(device); mockClear(device);
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')}); endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device}); await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises(); await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')}); endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device}); await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises(); await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')}); endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device}); await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises(); await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')}); endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device}); await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises(); await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')}); endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device}); await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises(); await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3); expect(endpoint.bind).toHaveBeenCalledTimes(3);

View File

@ -1,4 +1,4 @@
process.env.NOTIFY_SOCKET = "mocked"; process.env.NOTIFY_SOCKET = 'mocked';
const data = require('./stub/data'); const data = require('./stub/data');
const logger = require('./stub/logger'); const logger = require('./stub/logger');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman'); const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
@ -10,24 +10,36 @@ const stringify = require('json-stable-stringify-without-jsonify');
const flushPromises = require('./lib/flushPromises'); const flushPromises = require('./lib/flushPromises');
const tmp = require('tmp'); const tmp = require('tmp');
const mocksClear = [ const mocksClear = [
zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug, zigbeeHerdsman.permitJoin,
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork, MQTT.end,
zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error, zigbeeHerdsman.stop,
logger.debug,
MQTT.publish,
MQTT.connect,
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork,
logger.error,
]; ];
const fs = require('fs'); const fs = require('fs');
const LOG_MQTT_NS = 'z2m:mqtt'; const LOG_MQTT_NS = 'z2m:mqtt';
jest.mock('sd-notify', () => { jest.mock(
return { 'sd-notify',
watchdogInterval: () => {return 3000;}, () => {
startWatchdogMode: (interval) => {}, return {
stopWatchdogMode: () => {}, watchdogInterval: () => {
ready: () => {}, return 3000;
stopping: () => {}, },
}; startWatchdogMode: (interval) => {},
}, {virtual: true}); stopWatchdogMode: () => {},
ready: () => {},
stopping: () => {},
};
},
{virtual: true},
);
describe('Controller', () => { describe('Controller', () => {
let controller; let controller;
@ -51,27 +63,51 @@ describe('Controller', () => {
afterAll(async () => { afterAll(async () => {
jest.useRealTimers(); jest.useRealTimers();
}) });
it('Start controller', async () => { it('Start controller', async () => {
await controller.start(); await controller.start();
expect(zigbeeHerdsman.constructor).toHaveBeenCalledWith({"network":{"panID":6754,"extendedPanID":[221,221,221,221,221,221,221,221],"channelList":[11],"networkKey":[1,3,5,7,9,11,13,15,0,2,4,6,8,10,12,13]},"databasePath":path.join(data.mockDir, "database.db"), "databaseBackupPath":path.join(data.mockDir, "database.db.backup"),"backupPath":path.join(data.mockDir, "coordinator_backup.json"),"acceptJoiningDeviceHandler": expect.any(Function),adapter: {concurrent: null, delay: null, disableLED: false}, "serialPort":{"baudRate":undefined,"rtscts":undefined,"path":"/dev/dummy"}}); expect(zigbeeHerdsman.constructor).toHaveBeenCalledWith({
network: {
panID: 6754,
extendedPanID: [221, 221, 221, 221, 221, 221, 221, 221],
channelList: [11],
networkKey: [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13],
},
databasePath: path.join(data.mockDir, 'database.db'),
databaseBackupPath: path.join(data.mockDir, 'database.db.backup'),
backupPath: path.join(data.mockDir, 'coordinator_backup.json'),
acceptJoiningDeviceHandler: expect.any(Function),
adapter: {concurrent: null, delay: null, disableLED: false},
serialPort: {baudRate: undefined, rtscts: undefined, path: '/dev/dummy'},
});
expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.setTransmitPower).toHaveBeenCalledTimes(0); expect(zigbeeHerdsman.setTransmitPower).toHaveBeenCalledTimes(0);
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true, undefined, undefined); expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true, undefined, undefined);
expect(logger.info).toHaveBeenCalledWith(`Currently ${Object.values(zigbeeHerdsman.devices).length - 1} devices are joined:`) expect(logger.info).toHaveBeenCalledWith(`Currently ${Object.values(zigbeeHerdsman.devices).length - 1} devices are joined:`);
expect(logger.info).toHaveBeenCalledWith('bulb (0x000b57fffec6a5b2): LED1545G12 - IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (Router)'); expect(logger.info).toHaveBeenCalledWith(
'bulb (0x000b57fffec6a5b2): LED1545G12 - IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (Router)',
);
expect(logger.info).toHaveBeenCalledWith('remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)'); expect(logger.info).toHaveBeenCalledWith('remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)');
expect(logger.info).toHaveBeenCalledWith('0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)'); expect(logger.info).toHaveBeenCalledWith('0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)');
expect(MQTT.connect).toHaveBeenCalledTimes(1); expect(MQTT.connect).toHaveBeenCalledTimes(1);
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", {"will": {"payload": Buffer.from("offline"), "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1}}); expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}),{ retain: true, qos: 0 }, expect.any(Function)); will: {payload: Buffer.from('offline'), retain: true, topic: 'zigbee2mqtt/bridge/state', qos: 1},
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({"brightness":255}), { retain: true, qos: 0 }, expect.any(Function)); });
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
{retain: true, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({brightness: 255}), {retain: true, qos: 0}, expect.any(Function));
}); });
it('Start controller when permit join fails', async () => { it('Start controller when permit join fails', async () => {
zigbeeHerdsman.permitJoin.mockImplementationOnce(() => {throw new Error("failed!")}); zigbeeHerdsman.permitJoin.mockImplementationOnce(() => {
throw new Error('failed!');
});
await controller.start(); await controller.start();
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
expect(MQTT.connect).toHaveBeenCalledTimes(1); expect(MQTT.connect).toHaveBeenCalledTimes(1);
@ -79,29 +115,31 @@ describe('Controller', () => {
it('Start controller with specific MQTT settings', async () => { it('Start controller with specific MQTT settings', async () => {
const ca = tmp.fileSync().name; const ca = tmp.fileSync().name;
fs.writeFileSync(ca, "ca"); fs.writeFileSync(ca, 'ca');
const key = tmp.fileSync().name; const key = tmp.fileSync().name;
fs.writeFileSync(key, "key"); fs.writeFileSync(key, 'key');
const cert = tmp.fileSync().name; const cert = tmp.fileSync().name;
fs.writeFileSync(cert, "cert"); fs.writeFileSync(cert, 'cert');
const configuration = { const configuration = {
base_topic: "zigbee2mqtt", base_topic: 'zigbee2mqtt',
server: "mqtt://localhost", server: 'mqtt://localhost',
keepalive: 30, keepalive: 30,
ca, cert, key, ca,
cert,
key,
password: 'pass', password: 'pass',
user: 'user1', user: 'user1',
client_id: 'my_client_id', client_id: 'my_client_id',
reject_unauthorized: false, reject_unauthorized: false,
version: 5, version: 5,
} };
settings.set(['mqtt'], configuration) settings.set(['mqtt'], configuration);
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
expect(MQTT.connect).toHaveBeenCalledTimes(1); expect(MQTT.connect).toHaveBeenCalledTimes(1);
const expected = { const expected = {
"will": {"payload": Buffer.from("offline"), "retain": true, "topic": "zigbee2mqtt/bridge/state", "qos": 1}, will: {payload: Buffer.from('offline'), retain: true, topic: 'zigbee2mqtt/bridge/state', qos: 1},
keepalive: 30, keepalive: 30,
ca: Buffer.from([99, 97]), ca: Buffer.from([99, 97]),
key: Buffer.from([107, 101, 121]), key: Buffer.from([107, 101, 121]),
@ -111,9 +149,8 @@ describe('Controller', () => {
clientId: 'my_client_id', clientId: 'my_client_id',
rejectUnauthorized: false, rejectUnauthorized: false,
protocolVersion: 5, protocolVersion: 5,
};
} expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', expected);
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", expected);
}); });
it('Should generate network_key, pan_id and ext_pan_id when set to GENERATE', async () => { it('Should generate network_key, pan_id and ext_pan_id when set to GENERATE', async () => {
@ -134,9 +171,14 @@ describe('Controller', () => {
data.writeDefaultState(); data.writeDefaultState();
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/remote", stringify({"brightness":255}), {"qos": 0, "retain": true}, expect.any(Function)); 'zigbee2mqtt/bulb',
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":'ON'}), {"qos": 0, "retain":false}, expect.any(Function)); stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
{qos: 0, retain: true},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote', stringify({brightness: 255}), {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {qos: 0, retain: false}, expect.any(Function));
}); });
it('Start controller should not publish cached states when disabled', async () => { it('Start controller should not publish cached states when disabled', async () => {
@ -144,8 +186,8 @@ describe('Controller', () => {
data.writeDefaultState(); data.writeDefaultState();
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
const publishedTopics = MQTT.publish.mock.calls.map(m => m[0]); const publishedTopics = MQTT.publish.mock.calls.map((m) => m[0]);
expect(publishedTopics).toEqual(expect.not.arrayContaining(["zigbee2mqtt/bulb", "zigbee2mqtt/remote"])); expect(publishedTopics).toEqual(expect.not.arrayContaining(['zigbee2mqtt/bulb', 'zigbee2mqtt/remote']));
}); });
it('Start controller should not publish cached states when cache_state is false', async () => { it('Start controller should not publish cached states when cache_state is false', async () => {
@ -153,8 +195,13 @@ describe('Controller', () => {
data.writeDefaultState(); data.writeDefaultState();
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
expect(MQTT.publish).not.toHaveBeenCalledWith("zigbee2mqtt/bulb", `{"state":"ON","brightness":50,"color_temp":370,"linkquality":99}`, {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).not.toHaveBeenCalledWith(
expect(MQTT.publish).not.toHaveBeenCalledWith("zigbee2mqtt/remote", `{"brightness":255}`, {"qos": 0, "retain": true}, expect.any(Function)); 'zigbee2mqtt/bulb',
`{"state":"ON","brightness":50,"color_temp":370,"linkquality":99}`,
{qos: 0, retain: true},
expect.any(Function),
);
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/remote', `{"brightness":255}`, {qos: 0, retain: true}, expect.any(Function));
}); });
it('Log when MQTT client is unavailable', async () => { it('Log when MQTT client is unavailable', async () => {
@ -163,7 +210,7 @@ describe('Controller', () => {
logger.error.mockClear(); logger.error.mockClear();
controller.mqtt.client.reconnecting = true; controller.mqtt.client.reconnecting = true;
jest.advanceTimersByTime(11 * 1000); jest.advanceTimersByTime(11 * 1000);
expect(logger.error).toHaveBeenCalledWith("Not connected to MQTT server!"); expect(logger.error).toHaveBeenCalledWith('Not connected to MQTT server!');
controller.mqtt.client.reconnecting = false; controller.mqtt.client.reconnecting = false;
}); });
@ -173,11 +220,19 @@ describe('Controller', () => {
logger.error.mockClear(); logger.error.mockClear();
controller.mqtt.client.reconnecting = true; controller.mqtt.client.reconnecting = true;
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 50, color_temp: 370, color: {r: 100, g: 50, b: 10}, dummy: {1: 'yes', 2: 'no'}}); await controller.publishEntityState(device, {
state: 'ON',
brightness: 50,
color_temp: 370,
color: {r: 100, g: 50, b: 10},
dummy: {1: 'yes', 2: 'no'},
});
await flushPromises(); await flushPromises();
expect(logger.error).toHaveBeenCalledTimes(2); expect(logger.error).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenCalledWith("Not connected to MQTT server!"); expect(logger.error).toHaveBeenCalledWith('Not connected to MQTT server!');
expect(logger.error).toHaveBeenCalledWith("Cannot send message: topic: 'zigbee2mqtt/bulb', payload: '{\"brightness\":50,\"color\":{\"b\":10,\"g\":50,\"r\":100},\"color_temp\":370,\"dummy\":{\"1\":\"yes\",\"2\":\"no\"},\"linkquality\":99,\"state\":\"ON\"}"); expect(logger.error).toHaveBeenCalledWith(
'Cannot send message: topic: \'zigbee2mqtt/bulb\', payload: \'{"brightness":50,"color":{"b":10,"g":50,"r":100},"color_temp":370,"dummy":{"1":"yes","2":"no"},"linkquality":99,"state":"ON"}',
);
controller.mqtt.client.reconnecting = false; controller.mqtt.client.reconnecting = false;
}); });
@ -190,7 +245,9 @@ describe('Controller', () => {
it('Should remove device not on passlist on startup', async () => { it('Should remove device not on passlist on startup', async () => {
settings.set(['passlist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]); settings.set(['passlist'], [zigbeeHerdsman.devices.bulb_color.ieeeAddr]);
zigbeeHerdsman.devices.bulb.removeFromNetwork.mockImplementationOnce(() => {throw new Error("dummy")}); zigbeeHerdsman.devices.bulb.removeFromNetwork.mockImplementationOnce(() => {
throw new Error('dummy');
});
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(0); expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(0);
@ -206,7 +263,9 @@ describe('Controller', () => {
}); });
it('Start controller fails', async () => { it('Start controller fails', async () => {
zigbeeHerdsman.start.mockImplementationOnce(() => {throw new Error('failed')}); zigbeeHerdsman.start.mockImplementationOnce(() => {
throw new Error('failed');
});
await controller.start(); await controller.start();
expect(mockExit).toHaveBeenCalledTimes(1); expect(mockExit).toHaveBeenCalledTimes(1);
}); });
@ -247,7 +306,9 @@ describe('Controller', () => {
}); });
it('Start controller and stop', async () => { it('Start controller and stop', async () => {
zigbeeHerdsman.stop.mockImplementationOnce(() => {throw new Error('failed')}) zigbeeHerdsman.stop.mockImplementationOnce(() => {
throw new Error('failed');
});
await controller.start(); await controller.start();
await controller.stop(); await controller.stop();
expect(MQTT.end).toHaveBeenCalledTimes(1); expect(MQTT.end).toHaveBeenCalledTimes(1);
@ -257,7 +318,9 @@ describe('Controller', () => {
}); });
it('Start controller adapter disconnects', async () => { it('Start controller adapter disconnects', async () => {
zigbeeHerdsman.stop.mockImplementationOnce(() => {throw new Error('failed')}) zigbeeHerdsman.stop.mockImplementationOnce(() => {
throw new Error('failed');
});
await controller.start(); await controller.start();
await zigbeeHerdsman.events.adapterDisconnected(); await zigbeeHerdsman.events.adapterDisconnected();
await flushPromises(); await flushPromises();
@ -271,14 +334,14 @@ describe('Controller', () => {
await controller.start(); await controller.start();
logger.debug.mockClear(); logger.debug.mockClear();
await MQTT.events.message('dummytopic', 'dummymessage'); await MQTT.events.message('dummytopic', 'dummymessage');
expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'dummytopic' with data 'dummymessage'", LOG_MQTT_NS) expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'dummytopic' with data 'dummymessage'", LOG_MQTT_NS);
}); });
it('Skip MQTT messages on topic we published to', async () => { it('Skip MQTT messages on topic we published to', async () => {
await controller.start(); await controller.start();
logger.debug.mockClear(); logger.debug.mockClear();
await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped'); await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'zigbee2mqtt/skip-this-topic' with data 'skipped'", LOG_MQTT_NS) expect(logger.debug).toHaveBeenCalledWith("Received MQTT message on 'zigbee2mqtt/skip-this-topic' with data 'skipped'", LOG_MQTT_NS);
logger.debug.mockClear(); logger.debug.mockClear();
await controller.mqtt.publish('skip-this-topic', '', {}); await controller.mqtt.publish('skip-this-topic', '', {});
await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped'); await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
@ -288,19 +351,38 @@ describe('Controller', () => {
it('On zigbee event message', async () => { it('On zigbee event message', async () => {
await controller.start(); await controller.start();
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, cluster: 'genBasic', data: {modelId: device.modelID}}; const payload = {
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
cluster: 'genBasic',
data: {modelId: device.modelID},
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1`); expect(logger.debug).toHaveBeenCalledWith(
`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1`,
);
}); });
it('On zigbee event message with group ID', async () => { it('On zigbee event message with group ID', async () => {
await controller.start(); await controller.start();
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, groupID: 0, cluster: 'genBasic', data: {modelId: device.modelID}}; const payload = {
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
groupID: 0,
cluster: 'genBasic',
data: {modelId: device.modelID},
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1 with groupID 0`); expect(logger.debug).toHaveBeenCalledWith(
`Received Zigbee message from 'bulb', type 'attributeReport', cluster 'genBasic', data '{"modelId":"TRADFRI bulb E27 WS opal 980lm"}' from endpoint 1 with groupID 0`,
);
}); });
it('Should add entities which are missing from configuration but are in database to configuration', async () => { it('Should add entities which are missing from configuration but are in database to configuration', async () => {
@ -315,7 +397,12 @@ describe('Controller', () => {
const payload = {device}; const payload = {device};
await zigbeeHerdsman.events.deviceJoined(payload); await zigbeeHerdsman.events.deviceJoined(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_connected","message":{"friendly_name":"bulb"}}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_connected', message: {friendly_name: 'bulb'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('acceptJoiningDeviceHandler reject device on blocklist', async () => { it('acceptJoiningDeviceHandler reject device on blocklist', async () => {
@ -373,7 +460,12 @@ describe('Controller', () => {
zigbeeHerdsman.events.deviceJoined(payload); zigbeeHerdsman.events.deviceJoined(payload);
zigbeeHerdsman.events.deviceJoined(payload); zigbeeHerdsman.events.deviceJoined(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_connected","message":{"friendly_name":"bulb"}}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_connected', message: {friendly_name: 'bulb'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('On zigbee deviceInterview started', async () => { it('On zigbee deviceInterview started', async () => {
@ -382,7 +474,12 @@ describe('Controller', () => {
const payload = {device, status: 'started'}; const payload = {device, status: 'started'};
await zigbeeHerdsman.events.deviceInterview(payload); await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_started","meta":{"friendly_name":"bulb"}}), { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'pairing', message: 'interview_started', meta: {friendly_name: 'bulb'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('On zigbee deviceInterview failed', async () => { it('On zigbee deviceInterview failed', async () => {
@ -391,7 +488,12 @@ describe('Controller', () => {
const payload = {device, status: 'failed'}; const payload = {device, status: 'failed'};
await zigbeeHerdsman.events.deviceInterview(payload); await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_failed","meta":{"friendly_name":"bulb"}}), { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'pairing', message: 'interview_failed', meta: {friendly_name: 'bulb'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('On zigbee deviceInterview successful supported', async () => { it('On zigbee deviceInterview successful supported', async () => {
@ -400,7 +502,22 @@ describe('Controller', () => {
const payload = {device, status: 'successful'}; const payload = {device, status: 'successful'};
await zigbeeHerdsman.events.deviceInterview(payload); await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_successful","meta":{"friendly_name":"bulb","model":"LED1545G12","vendor":"IKEA","description":"TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm","supported":true}}), { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({
type: 'pairing',
message: 'interview_successful',
meta: {
friendly_name: 'bulb',
model: 'LED1545G12',
vendor: 'IKEA',
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
supported: true,
},
}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('On zigbee deviceInterview successful not supported', async () => { it('On zigbee deviceInterview successful not supported', async () => {
@ -409,7 +526,12 @@ describe('Controller', () => {
const payload = {device, status: 'successful'}; const payload = {device, status: 'successful'};
await zigbeeHerdsman.events.deviceInterview(payload); await zigbeeHerdsman.events.deviceInterview(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"pairing","message":"interview_successful","meta":{"friendly_name":"0x0017880104e45518","supported":false}}), { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'pairing', message: 'interview_successful', meta: {friendly_name: '0x0017880104e45518', supported: false}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('On zigbee event device announce', async () => { it('On zigbee event device announce', async () => {
@ -419,19 +541,29 @@ describe('Controller', () => {
await zigbeeHerdsman.events.deviceAnnounce(payload); await zigbeeHerdsman.events.deviceAnnounce(payload);
await flushPromises(); await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Device 'bulb' announced itself`); expect(logger.debug).toHaveBeenCalledWith(`Device 'bulb' announced itself`);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"device_announced","message":"announce","meta":{"friendly_name":"bulb"}}), { retain: false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_announced', message: 'announce', meta: {friendly_name: 'bulb'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('On zigbee event device leave (removed from database and settings)', async () => { it('On zigbee event device leave (removed from database and settings)', async () => {
await controller.start(); await controller.start();
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae'); zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
settings.set(['devices'], {}) settings.set(['devices'], {});
MQTT.publish.mockClear(); MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const payload = {ieeeAddr: device.ieeeAddr}; const payload = {ieeeAddr: device.ieeeAddr};
await zigbeeHerdsman.events.deviceLeave(payload); await zigbeeHerdsman.events.deviceLeave(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"device_removed","message":"left_network","meta":{"friendly_name":"0x000b57fffec6a5b2"}}), { retain: false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_removed', message: 'left_network', meta: {friendly_name: '0x000b57fffec6a5b2'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('On zigbee event device leave (removed from database and NOT settings)', async () => { it('On zigbee event device leave (removed from database and NOT settings)', async () => {
@ -442,7 +574,12 @@ describe('Controller', () => {
const payload = {ieeeAddr: device.ieeeAddr}; const payload = {ieeeAddr: device.ieeeAddr};
await zigbeeHerdsman.events.deviceLeave(payload); await zigbeeHerdsman.events.deviceLeave(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/log', stringify({"type":"device_removed","message":"left_network","meta":{"friendly_name":"0x000b57fffec6a5b2"}}), { retain: false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_removed', message: 'left_network', meta: {friendly_name: '0x000b57fffec6a5b2'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Publish entity state attribute output', async () => { it('Publish entity state attribute output', async () => {
@ -450,16 +587,24 @@ describe('Controller', () => {
settings.set(['experimental', 'output'], 'attribute'); settings.set(['experimental', 'output'], 'attribute');
MQTT.publish.mockClear(); MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {dummy: {1: 'yes', 2: 'no'}, color: {r: 100, g: 50, b: 10}, state: 'ON', test: undefined, test1: null, color_temp: 370, brightness: 50}); await controller.publishEntityState(device, {
dummy: {1: 'yes', 2: 'no'},
color: {r: 100, g: 50, b: 10},
state: 'ON',
test: undefined,
test1: null,
color_temp: 370,
brightness: 50,
});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "50", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '50', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/color_temp", "370", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/color_temp', '370', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/color", '100,50,10', {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/color', '100,50,10', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/dummy-1", "yes", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/dummy-1', 'yes', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/dummy-2", "no", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/dummy-2', 'no', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/test1", '', {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/test1', '', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/test", '', {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/test', '', {qos: 0, retain: true}, expect.any(Function));
}); });
it('Publish entity state attribute_json output', async () => { it('Publish entity state attribute_json output', async () => {
@ -470,14 +615,18 @@ describe('Controller', () => {
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99}); await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(5); expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/color_temp", "370", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/color_temp', '370', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/linkquality", "99", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/linkquality', '99', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 200, color_temp: 370, linkquality: 99}),
{qos: 0, retain: true},
expect.any(Function),
);
}); });
it('Publish entity state attribute_json output filtered', async () => { it('Publish entity state attribute_json output filtered', async () => {
await controller.start(); await controller.start();
settings.set(['experimental', 'output'], 'attribute_and_json'); settings.set(['experimental', 'output'], 'attribute_and_json');
@ -487,9 +636,14 @@ describe('Controller', () => {
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99}); await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3); expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 200}),
{qos: 0, retain: true},
expect.any(Function),
);
}); });
it('Publish entity state attribute_json output filtered (device_options)', async () => { it('Publish entity state attribute_json output filtered (device_options)', async () => {
@ -501,9 +655,14 @@ describe('Controller', () => {
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99}); await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3); expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 200}),
{qos: 0, retain: true},
expect.any(Function),
);
}); });
it('Publish entity state attribute_json output filtered cache', async () => { it('Publish entity state attribute_json output filtered cache', async () => {
@ -513,17 +672,22 @@ describe('Controller', () => {
MQTT.publish.mockClear(); MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":50,"color_temp":370,"linkquality":99,"state":"ON"}); expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87}); await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises(); await flushPromises();
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":200,"color_temp":370,"state":"ON"}); expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
expect(MQTT.publish).toHaveBeenCalledTimes(5); expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/linkquality", "87", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/linkquality', '87', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200,"color_temp":370,"linkquality":87}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 200, color_temp: 370, linkquality: 87}),
{qos: 0, retain: true},
expect.any(Function),
);
}); });
it('Publish entity state attribute_json output filtered cache (device_options)', async () => { it('Publish entity state attribute_json output filtered cache (device_options)', async () => {
@ -533,17 +697,22 @@ describe('Controller', () => {
MQTT.publish.mockClear(); MQTT.publish.mockClear();
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":50,"color_temp":370,"linkquality":99,"state":"ON"}); expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87}); await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises(); await flushPromises();
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":200,"color_temp":370,"state":"ON"}); expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
expect(MQTT.publish).toHaveBeenCalledTimes(5); expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/brightness", "200", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb/linkquality", "87", {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb/linkquality', '87', {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON","brightness":200,"color_temp":370,"linkquality":87}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 200, color_temp: 370, linkquality: 87}),
{qos: 0, retain: true},
expect.any(Function),
);
}); });
it('Publish entity state with device information', async () => { it('Publish entity state with device information', async () => {
@ -553,13 +722,52 @@ describe('Controller', () => {
let device = controller.zigbee.resolveEntity('bulb'); let device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'}); await controller.publishEntityState(device, {state: 'ON'});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"device":{"friendlyName":"bulb","model":"LED1545G12","ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369,"type":"Router","manufacturerID":4476,"powerSource":"Mains (single phase)","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({
state: 'ON',
brightness: 50,
color_temp: 370,
linkquality: 99,
device: {
friendlyName: 'bulb',
model: 'LED1545G12',
ieeeAddr: '0x000b57fffec6a5b2',
networkAddress: 40369,
type: 'Router',
manufacturerID: 4476,
powerSource: 'Mains (single phase)',
dateCode: null,
softwareBuildID: null,
},
}),
{qos: 0, retain: true},
expect.any(Function),
);
// Unsupported device should have model "unknown" // Unsupported device should have model "unknown"
device = controller.zigbee.resolveEntity('unsupported2'); device = controller.zigbee.resolveEntity('unsupported2');
await controller.publishEntityState(device, {state: 'ON'}); await controller.publishEntityState(device, {state: 'ON'});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/unsupported2', stringify({"state":"ON","device":{"friendlyName":"unsupported2","model":"notSupportedModelID","ieeeAddr":"0x0017880104e45529","networkAddress":6536,"type":"EndDevice","manufacturerID":0,"powerSource":"Battery","dateCode":null, "softwareBuildID": null}}), {"qos": 0, "retain": false}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/unsupported2',
stringify({
state: 'ON',
device: {
friendlyName: 'unsupported2',
model: 'notSupportedModelID',
ieeeAddr: '0x0017880104e45529',
networkAddress: 6536,
type: 'EndDevice',
manufacturerID: 0,
powerSource: 'Battery',
dateCode: null,
softwareBuildID: null,
},
}),
{qos: 0, retain: false},
expect.any(Function),
);
}); });
it('Should publish entity state without retain', async () => { it('Should publish entity state without retain', async () => {
@ -569,7 +777,12 @@ describe('Controller', () => {
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'}); await controller.publishEntityState(device, {state: 'ON'});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": false}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
{qos: 0, retain: false},
expect.any(Function),
);
}); });
it('Should publish entity state with retain', async () => { it('Should publish entity state with retain', async () => {
@ -579,7 +792,12 @@ describe('Controller', () => {
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'}); await controller.publishEntityState(device, {state: 'ON'});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
{qos: 0, retain: true},
expect.any(Function),
);
}); });
it('Should publish entity state with expiring retention', async () => { it('Should publish entity state with expiring retention', async () => {
@ -591,7 +809,12 @@ describe('Controller', () => {
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'}); await controller.publishEntityState(device, {state: 'ON'});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99}), {"qos": 0, "retain": true, "properties": {messageExpiryInterval: 37}}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 50, color_temp: 370, linkquality: 99}),
{qos: 0, retain: true, properties: {messageExpiryInterval: 37}},
expect.any(Function),
);
}); });
it('Publish entity state no empty messages', async () => { it('Publish entity state no empty messages', async () => {
@ -614,8 +837,13 @@ describe('Controller', () => {
await controller.publishEntityState(device, {brightness: 200}); await controller.publishEntityState(device, {brightness: 200});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON"}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({state: 'ON'}), {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON", brightness: 200}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({state: 'ON', brightness: 200}),
{qos: 0, retain: true},
expect.any(Function),
);
await controller.stop(); await controller.stop();
expect(data.stateExists()).toBeFalsy(); expect(data.stateExists()).toBeFalsy();
}); });
@ -624,7 +852,7 @@ describe('Controller', () => {
data.removeState(); data.removeState();
await controller.start(); await controller.start();
logger.error.mockClear(); logger.error.mockClear();
controller.state.file = "/"; controller.state.file = '/';
await controller.state.save(); await controller.state.save();
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/Failed to write state to \'\/\'/)); expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/Failed to write state to \'\/\'/));
}); });
@ -639,8 +867,8 @@ describe('Controller', () => {
await controller.publishEntityState(device, {brightness: 200}); await controller.publishEntityState(device, {brightness: 200});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"ON"}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({state: 'ON'}), {qos: 0, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"brightness":200}), {"qos": 0, "retain": true}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({brightness: 200}), {qos: 0, retain: true}, expect.any(Function));
}); });
it('Should start when state is corrupted', async () => { it('Should start when state is corrupted', async () => {
@ -656,18 +884,18 @@ describe('Controller', () => {
await flushPromises(); await flushPromises();
expect(MQTT.connect).toHaveBeenCalledTimes(1); expect(MQTT.connect).toHaveBeenCalledTimes(1);
const expected = { const expected = {
"will": { "payload": Buffer.from("offline"), "retain": false, "topic": "zigbee2mqtt/bridge/state", "qos": 1 }, will: {payload: Buffer.from('offline'), retain: false, topic: 'zigbee2mqtt/bridge/state', qos: 1},
} };
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", expected); expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', expected);
}); });
it('Should republish retained messages on MQTT reconnect', async () => { it('Should republish retained messages on MQTT reconnect', async () => {
await controller.start(); await controller.start();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events['connect'](); MQTT.events['connect']();
await jest.advanceTimersByTimeAsync(2500);// before any startup configure triggers await jest.advanceTimersByTimeAsync(2500); // before any startup configure triggers
expect(MQTT.publish).toHaveBeenCalledTimes(14); expect(MQTT.publish).toHaveBeenCalledTimes(14);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
}); });
it('Should not republish retained messages on MQTT reconnect when retained message are sent', async () => { it('Should not republish retained messages on MQTT reconnect when retained message are sent', async () => {
@ -676,19 +904,19 @@ describe('Controller', () => {
MQTT.events['connect'](); MQTT.events['connect']();
await flushPromises(); await flushPromises();
await MQTT.events.message('zigbee2mqtt/bridge/info', 'dummy'); await MQTT.events.message('zigbee2mqtt/bridge/info', 'dummy');
await jest.advanceTimersByTimeAsync(2500);// before any startup configure triggers await jest.advanceTimersByTimeAsync(2500); // before any startup configure triggers
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/state', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/state', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
}); });
it('Should prevent any message being published with retain flag when force_disable_retain is set', async () => { it('Should prevent any message being published with retain flag when force_disable_retain is set', async () => {
settings.set(['mqtt', 'force_disable_retain'], true); settings.set(['mqtt', 'force_disable_retain'], true);
await controller.mqtt.connect() await controller.mqtt.connect();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await controller.mqtt.publish('fo', 'bar', { retain: true }) await controller.mqtt.publish('fo', 'bar', {retain: true});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/fo', 'bar', { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/fo', 'bar', {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should disable legacy options on new network start', async () => { it('Should disable legacy options on new network start', async () => {
@ -711,7 +939,11 @@ describe('Controller', () => {
await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'deviceAnnounce'}); await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'deviceAnnounce'});
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/remote', stringify({"brightness":255,"last_seen":1000}), { qos: 0, retain: true }, expect.any(Function)); 'zigbee2mqtt/remote',
stringify({brightness: 255, last_seen: 1000}),
{qos: 0, retain: true},
expect.any(Function),
);
}); });
it('Should not publish last seen changes when reason is messageEmitted', async () => { it('Should not publish last seen changes when reason is messageEmitted', async () => {
@ -728,16 +960,25 @@ describe('Controller', () => {
// https://github.com/Koenkk/zigbee2mqtt/issues/9218 // https://github.com/Koenkk/zigbee2mqtt/issues/9218
await controller.start(); await controller.start();
const device = zigbeeHerdsman.devices.coordinator; const device = zigbeeHerdsman.devices.coordinator;
const payload = {device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10, cluster: 'genBasic', data: {modelId: device.modelID}}; const payload = {
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
cluster: 'genBasic',
data: {modelId: device.modelID},
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(`Received Zigbee message from 'Coordinator', type 'attributeReport', cluster 'genBasic', data '{"modelId":null}' from endpoint 1, ignoring since it is from coordinator`); expect(logger.debug).toHaveBeenCalledWith(
`Received Zigbee message from 'Coordinator', type 'attributeReport', cluster 'genBasic', data '{"modelId":null}' from endpoint 1, ignoring since it is from coordinator`,
);
}); });
it('Should remove state of removed device when stopped', async () => { it('Should remove state of removed device when stopped', async () => {
await controller.start(); await controller.start();
const device = controller.zigbee.resolveEntity('bulb'); const device = controller.zigbee.resolveEntity('bulb');
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({"brightness":50,"color_temp":370,"linkquality":99,"state":"ON"}); expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
device.zh.isDeleted = true; device.zh.isDeleted = true;
await controller.stop(); await controller.stop();
expect(controller.state.state[device.ieeeAddr]).toStrictEqual(undefined); expect(controller.state.state[device.ieeeAddr]).toStrictEqual(undefined);
@ -745,7 +986,9 @@ describe('Controller', () => {
it('EventBus should handle errors', async () => { it('EventBus should handle errors', async () => {
const eventbus = controller.eventBus; const eventbus = controller.eventBus;
const callback = jest.fn().mockImplementation(async () => {throw new Error('Whoops!')}); const callback = jest.fn().mockImplementation(async () => {
throw new Error('Whoops!');
});
eventbus.onStateChange('test', callback); eventbus.onStateChange('test', callback);
eventbus.emitStateChange({}); eventbus.emitStateChange({});
await flushPromises(); await flushPromises();

View File

@ -12,31 +12,48 @@ const fs = require('fs');
zigbeeHerdsmanConverters.addDefinition = jest.fn(); zigbeeHerdsmanConverters.addDefinition = jest.fn();
const mocksClear = [zigbeeHerdsmanConverters.addDefinition, zigbeeHerdsman.permitJoin, const mocksClear = [
mockExit, MQTT.end, zigbeeHerdsman.stop, logger.debug, zigbeeHerdsmanConverters.addDefinition,
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork, zigbeeHerdsman.permitJoin,
zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error, mockExit,
MQTT.end,
zigbeeHerdsman.stop,
logger.debug,
MQTT.publish,
MQTT.connect,
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork,
logger.error,
]; ];
jest.mock( jest.mock(
'mock-external-converter-module', () => { 'mock-external-converter-module',
() => {
return { return {
mock: true mock: true,
}; };
}, { },
virtual: true {
}); virtual: true,
},
);
jest.mock( jest.mock(
'mock-multiple-external-converter-module', () => { 'mock-multiple-external-converter-module',
return [{ () => {
mock: 1 return [
}, { {
mock: 2 mock: 1,
}]; },
}, { {
virtual: true mock: 2,
}); },
];
},
{
virtual: true,
},
);
describe('Loads external converters', () => { describe('Loads external converters', () => {
let controller; let controller;
@ -44,7 +61,7 @@ describe('Loads external converters', () => {
let resetExtension = async () => { let resetExtension = async () => {
await controller.enableDisableExtension(false, 'ExternalConverters'); await controller.enableDisableExtension(false, 'ExternalConverters');
await controller.enableDisableExtension(true, 'ExternalConverters'); await controller.enableDisableExtension(true, 'ExternalConverters');
} };
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -83,12 +100,15 @@ describe('Loads external converters', () => {
description: 'external', description: 'external',
fromZigbee: [], fromZigbee: [],
toZigbee: [], toZigbee: [],
exposes: [] exposes: [],
}); });
}); });
it('Loads multiple external converters', async () => { it('Loads multiple external converters', async () => {
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'), path.join(data.mockDir, 'mock-external-converter-multiple.js')); fs.copyFileSync(
path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'),
path.join(data.mockDir, 'mock-external-converter-multiple.js'),
);
settings.set(['external_converters'], ['mock-external-converter-multiple.js']); settings.set(['external_converters'], ['mock-external-converter-multiple.js']);
await resetExtension(); await resetExtension();
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2); expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2);
@ -100,7 +120,7 @@ describe('Loads external converters', () => {
description: 'external_1', description: 'external_1',
fromZigbee: [], fromZigbee: [],
toZigbee: [], toZigbee: [],
exposes: [] exposes: [],
}); });
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, { expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, {
mock: 2, mock: 2,
@ -110,7 +130,7 @@ describe('Loads external converters', () => {
description: 'external_2', description: 'external_2',
fromZigbee: [], fromZigbee: [],
toZigbee: [], toZigbee: [],
exposes: [] exposes: [],
}); });
}); });
@ -119,7 +139,7 @@ describe('Loads external converters', () => {
await resetExtension(); await resetExtension();
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(1); expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledWith({ expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledWith({
mock: true mock: true,
}); });
}); });
@ -128,18 +148,20 @@ describe('Loads external converters', () => {
await resetExtension(); await resetExtension();
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2); expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2);
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(1, { expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(1, {
mock: 1 mock: 1,
}); });
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, { expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, {
mock: 2 mock: 2,
}); });
}); });
it('Loads external converters with error', async () => { it('Loads external converters with error', async () => {
fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js')); fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
settings.set(['external_converters'], ['mock-external-converter.js']); settings.set(['external_converters'], ['mock-external-converter.js']);
zigbeeHerdsmanConverters.addDefinition.mockImplementationOnce(() => {throw new Error('Invalid definition!')}); zigbeeHerdsmanConverters.addDefinition.mockImplementationOnce(() => {
throw new Error('Invalid definition!');
});
await resetExtension(); await resetExtension();
expect(logger.error).toHaveBeenCalledWith(`Failed to load external converter file 'mock-external-converter.js' (Invalid definition!)`); expect(logger.error).toHaveBeenCalledWith(`Failed to load external converter file 'mock-external-converter.js' (Invalid definition!)`);
}); });
}); });

View File

@ -9,9 +9,15 @@ const Controller = require('../lib/controller');
const stringify = require('json-stable-stringify-without-jsonify'); const stringify = require('json-stable-stringify-without-jsonify');
const flushPromises = require('./lib/flushPromises'); const flushPromises = require('./lib/flushPromises');
const mocksClear = [ const mocksClear = [
zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug, zigbeeHerdsman.permitJoin,
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork, MQTT.end,
zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error, zigbeeHerdsman.stop,
logger.debug,
MQTT.publish,
MQTT.connect,
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork,
logger.error,
]; ];
const fs = require('fs'); const fs = require('fs');
@ -30,7 +36,7 @@ describe('User extensions', () => {
settings.reRead(); settings.reRead();
mocksClear.forEach((m) => m.mockClear()); mocksClear.forEach((m) => m.mockClear());
}); });
afterAll(async () => { afterAll(async () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
@ -46,18 +52,23 @@ describe('User extensions', () => {
afterEach(() => { afterEach(() => {
const extensionPath = path.join(data.mockDir, 'extension'); const extensionPath = path.join(data.mockDir, 'extension');
rimrafSync(extensionPath); rimrafSync(extensionPath);
}) });
it('Load user extension', async () => { it('Load user extension', async () => {
const extensionPath = path.join(data.mockDir, 'extension'); const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8'); const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8');
fs.mkdirSync(extensionPath); fs.mkdirSync(extensionPath);
fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), path.join(extensionPath, 'exampleExtension.js')) fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), path.join(extensionPath, 'exampleExtension.js'));
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([{"name": "exampleExtension.js", "code": extensionCode}]), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
{retain: true, qos: 0},
expect.any(Function),
);
}); });
it('Load user extension from api call', async () => { it('Load user extension from api call', async () => {
@ -67,53 +78,72 @@ describe('User extensions', () => {
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/extension/save', stringify({"name": "foo.js", "code": extensionCode})); MQTT.events.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([{"name": "foo.js", "code": extensionCode}]), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', { retain: false, qos: 0 }, expect.any(Function)); 'zigbee2mqtt/bridge/extensions',
stringify([{name: 'foo.js', code: extensionCode}]),
{retain: true, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/example/extension',
'call from constructor',
{retain: false, qos: 0},
expect.any(Function),
);
expect(mkdirSyncSpy).toHaveBeenCalledWith(extensionPath); expect(mkdirSyncSpy).toHaveBeenCalledWith(extensionPath);
}); });
it('Do not load corrupted extensions', async () => { it('Do not load corrupted extensions', async () => {
const extensionPath = path.join(data.mockDir, 'extension'); const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = "definetly not a correct javascript code"; const extensionCode = 'definetly not a correct javascript code';
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/extension/save', stringify({"name": "foo.js", "code": extensionCode})); MQTT.events.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/extension/save', expect.any(String), { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]); 'zigbee2mqtt/bridge/response/extension/save',
expect(payload).toEqual( expect.any(String),
expect.objectContaining({"data":{},"status":"error"}) {retain: false, qos: 0},
expect.any(Function),
); );
expect(payload.error).toMatch("Unexpected identifier"); const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
expect(payload).toEqual(expect.objectContaining({data: {}, status: 'error'}));
expect(payload.error).toMatch('Unexpected identifier');
}); });
it('Removes user extension', async () => { it('Removes user extension', async () => {
const extensionPath = path.join(data.mockDir, 'extension'); const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8'); const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8');
fs.mkdirSync(extensionPath); fs.mkdirSync(extensionPath);
const extensionFilePath = path.join(extensionPath, 'exampleExtension.js') const extensionFilePath = path.join(extensionPath, 'exampleExtension.js');
fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), extensionFilePath) fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), extensionFilePath);
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', { retain: false, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([{"name": "exampleExtension.js", "code": extensionCode}]), { retain: true, qos: 0 }, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
{retain: true, qos: 0},
expect.any(Function),
);
MQTT.events.message('zigbee2mqtt/bridge/request/extension/remove', stringify({"name": "exampleExtension.js"})); MQTT.events.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'exampleExtension.js'}));
await flushPromises(); await flushPromises();
expect(unlinkSyncSpy).toHaveBeenCalledWith(extensionFilePath); expect(unlinkSyncSpy).toHaveBeenCalledWith(extensionFilePath);
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/extension/remove', stringify({"name": "non existing.js"})); MQTT.events.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'non existing.js'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove', 'zigbee2mqtt/bridge/response/extension/remove',
stringify({"data":{},"status":"error","error":"Extension non existing.js doesn't exists"}), stringify({data: {}, status: 'error', error: "Extension non existing.js doesn't exists"}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
}); });

View File

@ -7,13 +7,15 @@ const Controller = require('../lib/controller');
const stringify = require('json-stable-stringify-without-jsonify'); const stringify = require('json-stable-stringify-without-jsonify');
const flushPromises = require('./lib/flushPromises'); const flushPromises = require('./lib/flushPromises');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman'); const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
const path = require("path"); const path = require('path');
jest.spyOn(process, 'exit').mockImplementation(() => {}); jest.spyOn(process, 'exit').mockImplementation(() => {});
const mockHTTP = { const mockHTTP = {
implementation: { implementation: {
listen: jest.fn(), listen: jest.fn(),
on: (event, handler) => {mockHTTP.events[event] = handler}, on: (event, handler) => {
mockHTTP.events[event] = handler;
},
close: jest.fn().mockImplementation((cb) => cb()), close: jest.fn().mockImplementation((cb) => cb()),
}, },
variables: {}, variables: {},
@ -23,7 +25,9 @@ const mockHTTP = {
const mockHTTPS = { const mockHTTPS = {
implementation: { implementation: {
listen: jest.fn(), listen: jest.fn(),
on: (event, handler) => {mockHTTPS.events[event] = handler}, on: (event, handler) => {
mockHTTPS.events[event] = handler;
},
close: jest.fn().mockImplementation((cb) => cb()), close: jest.fn().mockImplementation((cb) => cb()),
}, },
variables: {}, variables: {},
@ -37,9 +41,11 @@ const mockWSocket = {
const mockWS = { const mockWS = {
implementation: { implementation: {
clients: [], clients: [],
on: (event, handler) => {mockWS.events[event] = handler}, on: (event, handler) => {
mockWS.events[event] = handler;
},
handleUpgrade: jest.fn().mockImplementation((request, socket, head, cb) => { handleUpgrade: jest.fn().mockImplementation((request, socket, head, cb) => {
cb(mockWSocket) cb(mockWSocket);
}), }),
emit: jest.fn(), emit: jest.fn(),
close: jest.fn(), close: jest.fn(),
@ -70,11 +76,11 @@ jest.mock('https', () => ({
Agent: jest.fn(), Agent: jest.fn(),
})); }));
jest.mock("connect-gzip-static", () => jest.mock('connect-gzip-static', () =>
jest.fn().mockImplementation((path) => { jest.fn().mockImplementation((path) => {
mockNodeStatic.variables.path = path mockNodeStatic.variables.path = path;
return mockNodeStatic.implementation return mockNodeStatic.implementation;
}) }),
); );
jest.mock('zigbee2mqtt-frontend', () => ({ jest.mock('zigbee2mqtt-frontend', () => ({
@ -100,7 +106,7 @@ describe('Frontend', () => {
data.writeDefaultConfiguration(); data.writeDefaultConfiguration();
data.writeDefaultState(); data.writeDefaultState();
settings.reRead(); settings.reRead();
settings.set(['frontend'], {port: 8081, host: "127.0.0.1"}); settings.set(['frontend'], {port: 8081, host: '127.0.0.1'});
settings.set(['homeassistant'], true); settings.set(['homeassistant'], true);
zigbeeHerdsman.devices.bulb.linkquality = 10; zigbeeHerdsman.devices.bulb.linkquality = 10;
}); });
@ -109,15 +115,15 @@ describe('Frontend', () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
afterEach(async() => { afterEach(async () => {
delete zigbeeHerdsman.devices.bulb.linkquality; delete zigbeeHerdsman.devices.bulb.linkquality;
}); });
it('Start/stop', async () => { it('Start/stop', async () => {
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
expect(mockNodeStatic.variables.path).toBe("my/dummy/path"); expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
const mockWSClient = { const mockWSClient = {
implementation: { implementation: {
terminate: jest.fn(), terminate: jest.fn(),
@ -140,7 +146,7 @@ describe('Frontend', () => {
settings.set(['frontend'], {port: 8081}); settings.set(['frontend'], {port: 8081});
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
expect(mockNodeStatic.variables.path).toBe("my/dummy/path"); expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081);
const mockWSClient = { const mockWSClient = {
implementation: { implementation: {
@ -161,11 +167,11 @@ describe('Frontend', () => {
}); });
it('Start/stop unix socket', async () => { it('Start/stop unix socket', async () => {
settings.set(['frontend'], {host: "/tmp/zigbee2mqtt.sock"}); settings.set(['frontend'], {host: '/tmp/zigbee2mqtt.sock'});
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
expect(mockNodeStatic.variables.path).toBe("my/dummy/path"); expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith("/tmp/zigbee2mqtt.sock"); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith('/tmp/zigbee2mqtt.sock');
const mockWSClient = { const mockWSClient = {
implementation: { implementation: {
terminate: jest.fn(), terminate: jest.fn(),
@ -184,42 +190,39 @@ describe('Frontend', () => {
mockHTTPS.implementation.listen.mockClear(); mockHTTPS.implementation.listen.mockClear();
}); });
it('Start/stop HTTPS valid', async () => { it('Start/stop HTTPS valid', async () => {
settings.set(['frontend','ssl_cert'], path.join(__dirname,'assets','certs','dummy.crt')); settings.set(['frontend', 'ssl_cert'], path.join(__dirname, 'assets', 'certs', 'dummy.crt'));
settings.set(['frontend','ssl_key'], path.join(__dirname,'assets','certs','dummy.key')); settings.set(['frontend', 'ssl_key'], path.join(__dirname, 'assets', 'certs', 'dummy.key'));
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTPS.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
await controller.stop(); await controller.stop();
mockHTTP.implementation.listen.mockClear(); mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear(); mockHTTPS.implementation.listen.mockClear();
}); });
it('Start/stop HTTPS invalid : missing config', async () => { it('Start/stop HTTPS invalid : missing config', async () => {
settings.set(['frontend','ssl_cert'], path.join(__dirname,'assets','certs','dummy.crt')); settings.set(['frontend', 'ssl_cert'], path.join(__dirname, 'assets', 'certs', 'dummy.crt'));
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
await controller.stop(); await controller.stop();
mockHTTP.implementation.listen.mockClear(); mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear(); mockHTTPS.implementation.listen.mockClear();
}); });
it('Start/stop HTTPS invalid : missing file', async () => { it('Start/stop HTTPS invalid : missing file', async () => {
settings.set(['frontend','ssl_cert'], 'filesNotExists.crt'); settings.set(['frontend', 'ssl_cert'], 'filesNotExists.crt');
settings.set(['frontend','ssl_key'], path.join(__dirname,'assets','certs','dummy.key')); settings.set(['frontend', 'ssl_key'], path.join(__dirname, 'assets', 'certs', 'dummy.key'));
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
await controller.stop(); await controller.stop();
mockHTTP.implementation.listen.mockClear(); mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear(); mockHTTPS.implementation.listen.mockClear();
}); });
it('Websocket interaction', async () => { it('Websocket interaction', async () => {
@ -229,7 +232,9 @@ describe('Frontend', () => {
// Connect // Connect
const mockWSClient = { const mockWSClient = {
implementation: { implementation: {
on: (event, handler) => {mockWSClient.events[event] = handler}, on: (event, handler) => {
mockWSClient.events[event] = handler;
},
send: jest.fn(), send: jest.fn(),
readyState: 'open', readyState: 'open',
}, },
@ -239,22 +244,28 @@ describe('Frontend', () => {
await mockWS.events.connection(mockWSClient.implementation); await mockWS.events.connection(mockWSClient.implementation);
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bridge/state', payload: 'online'})); expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bridge/state', payload: 'online'}));
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic:"remote", payload:{brightness:255}})); expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'remote', payload: {brightness: 255}}));
// Message // Message
MQTT.publish.mockClear(); MQTT.publish.mockClear();
mockWSClient.implementation.send.mockClear(); mockWSClient.implementation.send.mockClear();
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false) mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color', 'zigbee2mqtt/bulb_color',
stringify({state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: -1, latest_version: -1}}), stringify({
{ retain: false, qos: 0 }, state: 'ON',
expect.any(Function) power_on_behavior: null,
linkquality: null,
update_available: null,
update: {state: null, installed_version: -1, latest_version: -1},
}),
{retain: false, qos: 0},
expect.any(Function),
); );
mockWSClient.events.message(undefined, false); mockWSClient.events.message(undefined, false);
mockWSClient.events.message("", false); mockWSClient.events.message('', false);
mockWSClient.events.message(null, false); mockWSClient.events.message(null, false);
await flushPromises(); await flushPromises();
@ -264,12 +275,23 @@ describe('Frontend', () => {
// Received message on socket // Received message on socket
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1); expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1);
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: -1, latest_version: -1}}})); expect(mockWSClient.implementation.send).toHaveBeenCalledWith(
stringify({
topic: 'bulb_color',
payload: {
state: 'ON',
power_on_behavior: null,
linkquality: null,
update_available: null,
update: {state: null, installed_version: -1, latest_version: -1},
},
}),
);
// Shouldnt set when not ready // Shouldnt set when not ready
mockWSClient.implementation.send.mockClear(); mockWSClient.implementation.send.mockClear();
mockWSClient.implementation.readyState = 'close'; mockWSClient.implementation.readyState = 'close';
mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false) mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}}), false);
expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(0); expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(0);
// Send last seen on connect // Send last seen on connect
@ -278,7 +300,9 @@ describe('Frontend', () => {
settings.set(['advanced'], {last_seen: 'ISO_8601'}); settings.set(['advanced'], {last_seen: 'ISO_8601'});
mockWS.implementation.clients.push(mockWSClient.implementation); mockWS.implementation.clients.push(mockWSClient.implementation);
await mockWS.events.connection(mockWSClient.implementation); await mockWS.events.connection(mockWSClient.implementation);
expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic:"remote", payload:{brightness:255, last_seen: "1970-01-01T00:00:01.000Z"}})); expect(mockWSClient.implementation.send).toHaveBeenCalledWith(
stringify({topic: 'remote', payload: {brightness: 255, last_seen: '1970-01-01T00:00:01.000Z'}}),
);
}); });
it('onReques/onUpgrade', async () => { it('onReques/onUpgrade', async () => {
@ -290,9 +314,9 @@ describe('Frontend', () => {
mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3); mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1); expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
expect(mockSocket.destroy).toHaveBeenCalledTimes(0); expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({"url": "http://localhost:8080/api"}, mockSocket, 3, expect.any(Function)); expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({url: 'http://localhost:8080/api'}, mockSocket, 3, expect.any(Function));
mockWS.implementation.handleUpgrade.mock.calls[0][3](99); mockWS.implementation.handleUpgrade.mock.calls[0][3](99);
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', 99, {"url": "http://localhost:8080/api"}); expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'});
mockHTTP.variables.onRequest(1, 2); mockHTTP.variables.onRequest(1, 2);
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1); expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
@ -303,11 +327,11 @@ describe('Frontend', () => {
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
}); });
it('Authentification', async () => { it('Authentification', async () => {
const authToken = 'sample-secure-token' const authToken = 'sample-secure-token';
settings.set(['frontend'], {auth_token: authToken}); settings.set(['frontend'], {auth_token: authToken});
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
@ -317,8 +341,8 @@ describe('Frontend', () => {
mockHTTP.events.upgrade({url: '/api'}, mockSocket, mockWSocket); mockHTTP.events.upgrade({url: '/api'}, mockSocket, mockWSocket);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1); expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
expect(mockSocket.destroy).toHaveBeenCalledTimes(0); expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({"url": "/api"}, mockSocket, mockWSocket, expect.any(Function)); expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({url: '/api'}, mockSocket, mockWSocket, expect.any(Function));
expect(mockWSocket.close).toHaveBeenCalledWith(4401, "Unauthorized"); expect(mockWSocket.close).toHaveBeenCalledWith(4401, 'Unauthorized');
mockWSocket.close.mockClear(); mockWSocket.close.mockClear();
mockWS.implementation.emit.mockClear(); mockWS.implementation.emit.mockClear();
@ -332,6 +356,5 @@ describe('Frontend', () => {
expect(mockWSocket.close).toHaveBeenCalledTimes(0); expect(mockWSocket.close).toHaveBeenCalledTimes(0);
mockWS.implementation.handleUpgrade.mock.calls[0][3](mockWSocket); mockWS.implementation.handleUpgrade.mock.calls[0][3](mockWSocket);
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url}); expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
}); });
}); });

View File

@ -22,7 +22,7 @@ describe('Groups', () => {
let resetExtension = async () => { let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Groups'); await controller.enableDisableExtension(false, 'Groups');
await controller.enableDisableExtension(true, 'Groups'); await controller.enableDisableExtension(true, 'Groups');
} };
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -36,22 +36,22 @@ describe('Groups', () => {
}); });
beforeEach(() => { beforeEach(() => {
Object.values(zigbeeHerdsman.groups).forEach((g) => g.members = []); Object.values(zigbeeHerdsman.groups).forEach((g) => (g.members = []));
data.writeDefaultConfiguration(); data.writeDefaultConfiguration();
settings.reRead(); settings.reRead();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
zigbeeHerdsman.groups.gledopto_group.command.mockClear(); zigbeeHerdsman.groups.gledopto_group.command.mockClear();
zigbeeHerdsmanConverters.toZigbee.__clearStore__(); zigbeeHerdsmanConverters.toZigbee.__clearStore__();
controller.state.state = {}; controller.state.state = {};
}) });
it('Apply group updates add', async () => { it('Apply group updates add', async () => {
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['bulb', 'bulb_color']}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: ['bulb', 'bulb_color']}});
zigbeeHerdsman.groups.group_1.members.push(zigbeeHerdsman.devices.bulb.getEndpoint(1)) zigbeeHerdsman.groups.group_1.members.push(zigbeeHerdsman.devices.bulb.getEndpoint(1));
await resetExtension(); await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([ expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([
zigbeeHerdsman.devices.bulb.getEndpoint(1), zigbeeHerdsman.devices.bulb.getEndpoint(1),
zigbeeHerdsman.devices.bulb_color.getEndpoint(1) zigbeeHerdsman.devices.bulb_color.getEndpoint(1),
]); ]);
}); });
@ -59,17 +59,19 @@ describe('Groups', () => {
const endpoint = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const endpoint = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false,}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false}});
await resetExtension(); await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
}); });
it('Apply group updates remove handle fail', async () => { it('Apply group updates remove handle fail', async () => {
const endpoint = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const endpoint = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
endpoint.removeFromGroup.mockImplementationOnce(() => {throw new Error("failed!")}); endpoint.removeFromGroup.mockImplementationOnce(() => {
throw new Error('failed!');
});
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false,}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false}});
logger.error.mockClear(); logger.error.mockClear();
await resetExtension(); await resetExtension();
expect(logger.error).toHaveBeenCalledWith(`Failed to remove 'bulb_color' from 'group_1'`); expect(logger.error).toHaveBeenCalledWith(`Failed to remove 'bulb_color' from 'group_1'`);
@ -81,28 +83,28 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'3': {friendly_name: 'group_3', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {3: {friendly_name: 'group_3', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
}); });
it('Add non standard endpoint to group with name', async () => { it('Add non standard endpoint to group with name', async () => {
const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM; const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM;
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['0x0017880104e45542/right']}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: ['0x0017880104e45542/right']}});
await resetExtension(); await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(3)]); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(3)]);
}); });
it('Add non standard endpoint to group with number', async () => { it('Add non standard endpoint to group with number', async () => {
const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM; const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM;
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['wall_switch_double/2']}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: ['wall_switch_double/2']}});
await resetExtension(); await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(2)]); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(2)]);
}); });
it('Shouldnt crash on non-existing devices', async () => { it('Shouldnt crash on non-existing devices', async () => {
logger.error.mockClear(); logger.error.mockClear();
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['not_existing_bla']}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: ['not_existing_bla']}});
await resetExtension(); await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
expect(logger.error).toHaveBeenCalledWith("Cannot find 'not_existing_bla' of group 'group_1'"); expect(logger.error).toHaveBeenCalledWith("Cannot find 'not_existing_bla' of group 'group_1'");
@ -110,11 +112,11 @@ describe('Groups', () => {
it('Should resolve device friendly names', async () => { it('Should resolve device friendly names', async () => {
settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'friendly_name'], 'bulb_friendly_name'); settings.set(['devices', zigbeeHerdsman.devices.bulb.ieeeAddr, 'friendly_name'], 'bulb_friendly_name');
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['bulb_friendly_name', 'bulb_color']}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: ['bulb_friendly_name', 'bulb_color']}});
await resetExtension(); await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([ expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([
zigbeeHerdsman.devices.bulb.getEndpoint(1), zigbeeHerdsman.devices.bulb.getEndpoint(1),
zigbeeHerdsman.devices.bulb_color.getEndpoint(1) zigbeeHerdsman.devices.bulb_color.getEndpoint(1),
]); ]);
}); });
@ -122,28 +124,38 @@ describe('Groups', () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: []}});
expect(group.members.length).toBe(0); expect(group.members.length).toBe(0);
await resetExtension(); await resetExtension();
MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb_color'); MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb_color');
await flushPromises(); await flushPromises();
expect(group.members).toStrictEqual([endpoint]); expect(group.members).toStrictEqual([endpoint]);
expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/1`]); expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/1`]);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_group_add","message":{"friendly_name":"bulb_color","group":"group_1"}}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_group_add', message: {friendly_name: 'bulb_color', group: 'group_1'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Legacy api: Add to group with slashes via MQTT', async () => { it('Legacy api: Add to group with slashes via MQTT', async () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups["group/with/slashes"]; const group = zigbeeHerdsman.groups['group/with/slashes'];
settings.set(['groups'], {'99': {friendly_name: 'group/with/slashes', retain: false, devices: []}}); settings.set(['groups'], {99: {friendly_name: 'group/with/slashes', retain: false, devices: []}});
expect(group.members.length).toBe(0); expect(group.members.length).toBe(0);
await resetExtension(); await resetExtension();
MQTT.events.message('zigbee2mqtt/bridge/group/group/with/slashes/add', 'bulb_color'); MQTT.events.message('zigbee2mqtt/bridge/group/group/with/slashes/add', 'bulb_color');
await flushPromises(); await flushPromises();
expect(group.members).toStrictEqual([endpoint]); expect(group.members).toStrictEqual([endpoint]);
expect(settings.getGroup('group/with/slashes').devices).toStrictEqual([`${device.ieeeAddr}/1`]); expect(settings.getGroup('group/with/slashes').devices).toStrictEqual([`${device.ieeeAddr}/1`]);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_group_add","message":{"friendly_name":"bulb_color","group":"group/with/slashes"}}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_group_add', message: {friendly_name: 'bulb_color', group: 'group/with/slashes'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Legacy api: Add to group via MQTT with postfix', async () => { it('Legacy api: Add to group via MQTT with postfix', async () => {
@ -177,13 +189,18 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color'); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color');
await flushPromises(); await flushPromises();
expect(group.members).toStrictEqual([]); expect(group.members).toStrictEqual([]);
expect(settings.getGroup('group_1').devices).toStrictEqual([]); expect(settings.getGroup('group_1').devices).toStrictEqual([]);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_group_remove","message":{"friendly_name":"bulb_color","group":"group_1"}}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_group_remove', message: {friendly_name: 'bulb_color', group: 'group_1'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Legacy api: Remove from group via MQTT when in zigbee but not in settings', async () => { it('Legacy api: Remove from group via MQTT when in zigbee but not in settings', async () => {
@ -191,7 +208,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['dummy']}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: ['dummy']}});
await resetExtension(); await resetExtension();
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color'); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color');
await flushPromises(); await flushPromises();
@ -204,7 +221,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}});
await resetExtension(); await resetExtension();
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/3'); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/3');
await flushPromises(); await flushPromises();
@ -217,7 +234,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}});
await resetExtension(); await resetExtension();
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'wall_switch_double/3'); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'wall_switch_double/3');
await flushPromises(); await flushPromises();
@ -230,7 +247,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
await resetExtension(); await resetExtension();
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/right'); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/right');
await flushPromises(); await flushPromises();
@ -243,13 +260,18 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
await resetExtension(); await resetExtension();
await MQTT.events.message('zigbee2mqtt/bridge/group/remove_all', '0x0017880104e45542/right'); await MQTT.events.message('zigbee2mqtt/bridge/group/remove_all', '0x0017880104e45542/right');
await flushPromises(); await flushPromises();
expect(group.members).toStrictEqual([]); expect(group.members).toStrictEqual([]);
expect(settings.getGroup('group_1').devices).toStrictEqual([]); expect(settings.getGroup('group_1').devices).toStrictEqual([]);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bridge/log", stringify({"type":"device_group_remove_all","message":{"friendly_name":"wall_switch_double"}}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_group_remove_all', message: {friendly_name: 'wall_switch_double'}}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Remove from group all deprecated', async () => { it('Remove from group all deprecated', async () => {
@ -257,7 +279,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
await resetExtension(); await resetExtension();
await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove_all', '0x0017880104e45542/right'); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove_all', '0x0017880104e45542/right');
await flushPromises(); await flushPromises();
@ -294,7 +316,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
@ -303,8 +325,8 @@ describe('Groups', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should not republish identical optimistic group states', async () => { it('Should not republish identical optimistic group states', async () => {
@ -314,16 +336,55 @@ describe('Groups', () => {
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device1, endpoint: device1.getEndpoint(1), type: 'attributeReport', linkquality: 10}); await zigbeeHerdsman.events.message({
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device2, endpoint: device2.getEndpoint(1), type: 'attributeReport', linkquality: 10}); data: {onOff: 1},
cluster: 'genOnOff',
device: device1,
endpoint: device1.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await zigbeeHerdsman.events.message({
data: {onOff: 1},
cluster: 'genOnOff',
device: device2,
endpoint: device2.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(6); expect(MQTT.publish).toHaveBeenCalledTimes(6);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); 'zigbee2mqtt/group_tradfri_remote',
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); stringify({state: 'ON'}),
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_with_tradfri", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); {retain: false, qos: 0},
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/ha_discovery_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect.any(Function),
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/switch_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); );
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_2', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color_2',
stringify({state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_with_tradfri',
stringify({state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/ha_discovery_group',
stringify({state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/switch_group',
stringify({state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Should publish state change of all members when a group changes its state', async () => { it('Should publish state change of all members when a group changes its state', async () => {
@ -331,15 +392,15 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should not publish state change when group changes state and device is disabled', async () => { it('Should not publish state change when group changes state and device is disabled', async () => {
@ -348,14 +409,14 @@ describe('Groups', () => {
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['devices', device.ieeeAddr, 'disabled'], true); settings.set(['devices', device.ieeeAddr, 'disabled'], true);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should publish state change for group when members state change', async () => { it('Should publish state change for group when members state change', async () => {
@ -364,29 +425,29 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'})); await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'OFF'})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'OFF'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'})); await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should publish state of device with endpoint name', async () => { it('Should publish state of device with endpoint name', async () => {
@ -397,10 +458,20 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/gledopto_group/set', stringify({state: 'ON'})); await MQTT.events.message('zigbee2mqtt/gledopto_group/set', stringify({state: 'ON'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/GLEDOPTO_2ID", stringify({"state_cct":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/gledopto_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); 'zigbee2mqtt/GLEDOPTO_2ID',
stringify({state_cct: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/gledopto_group',
stringify({state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(group.command).toHaveBeenCalledTimes(1); expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith("genOnOff", "on", {}, {}); expect(group.command).toHaveBeenCalledWith('genOnOff', 'on', {}, {});
}); });
it('Should publish state of group when specific state of specific endpoint is changed', async () => { it('Should publish state of group when specific state of specific endpoint is changed', async () => {
@ -411,8 +482,18 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/GLEDOPTO_2ID/set', stringify({state_cct: 'ON'})); await MQTT.events.message('zigbee2mqtt/GLEDOPTO_2ID/set', stringify({state_cct: 'ON'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/GLEDOPTO_2ID", stringify({"state_cct":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/gledopto_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); 'zigbee2mqtt/GLEDOPTO_2ID',
stringify({state_cct: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/gledopto_group',
stringify({state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(group.command).toHaveBeenCalledTimes(0); expect(group.command).toHaveBeenCalledTimes(0);
}); });
@ -421,15 +502,20 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, filtered_attributes: ['brightness'], devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, filtered_attributes: ['brightness'], devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON', brightness: 100})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON', brightness: 100}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"ON","brightness":100}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); 'zigbee2mqtt/bulb_color',
stringify({state: 'ON', brightness: 100}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Shouldnt publish group state change when a group is not optimistic', async () => { it('Shouldnt publish group state change when a group is not optimistic', async () => {
@ -437,7 +523,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [device.ieeeAddr], optimistic: false, retain: false}}); settings.set(['groups'], {1: {friendly_name: 'group_1', devices: [device.ieeeAddr], optimistic: false, retain: false}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
@ -446,7 +532,7 @@ describe('Groups', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should publish state change of another group with shared device when a group changes its state', async () => { it('Should publish state change of another group with shared device when a group changes its state', async () => {
@ -455,9 +541,9 @@ describe('Groups', () => {
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], { settings.set(['groups'], {
'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}, 1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]},
'2': {friendly_name: 'group_2', retain: false, devices: [device.ieeeAddr]}, 2: {friendly_name: 'group_2', retain: false, devices: [device.ieeeAddr]},
'3': {friendly_name: 'group_3', retain: false, devices: []} 3: {friendly_name: 'group_3', retain: false, devices: []},
}); });
await resetExtension(); await resetExtension();
@ -465,9 +551,9 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3); expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_2', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should not publish state change off if any lights within are still on when changed via device', async () => { it('Should not publish state change off if any lights within are still on when changed via device', async () => {
@ -479,7 +565,7 @@ describe('Groups', () => {
group.members.push(endpoint_1); group.members.push(endpoint_1);
group.members.push(endpoint_2); group.members.push(endpoint_2);
settings.set(['groups'], { settings.set(['groups'], {
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false} 1: {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false},
}); });
await resetExtension(); await resetExtension();
@ -490,7 +576,7 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'})); await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({state:"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should publish state change off if any lights within are still on when changed via device when off_state: last_member_state is used', async () => { it('Should publish state change off if any lights within are still on when changed via device when off_state: last_member_state is used', async () => {
@ -502,7 +588,7 @@ describe('Groups', () => {
group.members.push(endpoint_1); group.members.push(endpoint_1);
group.members.push(endpoint_2); group.members.push(endpoint_2);
settings.set(['groups'], { settings.set(['groups'], {
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false, off_state: 'last_member_state'} 1: {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false, off_state: 'last_member_state'},
}); });
await resetExtension(); await resetExtension();
@ -513,8 +599,20 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'})); await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenNthCalledWith(1, "zigbee2mqtt/group_1", stringify({state:"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenNthCalledWith(
expect(MQTT.publish).toHaveBeenNthCalledWith(2, "zigbee2mqtt/bulb_color", stringify({state:"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); 1,
'zigbee2mqtt/group_1',
stringify({state: 'OFF'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenNthCalledWith(
2,
'zigbee2mqtt/bulb_color',
stringify({state: 'OFF'}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Should not publish state change off if any lights within are still on when changed via shared group', async () => { it('Should not publish state change off if any lights within are still on when changed via shared group', async () => {
@ -526,8 +624,8 @@ describe('Groups', () => {
group.members.push(endpoint_1); group.members.push(endpoint_1);
group.members.push(endpoint_2); group.members.push(endpoint_2);
settings.set(['groups'], { settings.set(['groups'], {
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false}, 1: {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false},
'2': {friendly_name: 'group_2', retain: false, devices: [device_1.ieeeAddr]}, 2: {friendly_name: 'group_2', retain: false, devices: [device_1.ieeeAddr]},
}); });
await resetExtension(); await resetExtension();
@ -538,8 +636,8 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/group_2/set', stringify({state: 'OFF'})); await MQTT.events.message('zigbee2mqtt/group_2/set', stringify({state: 'OFF'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_2", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_2', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should publish state change off if all lights within turn off', async () => { it('Should publish state change off if all lights within turn off', async () => {
@ -551,7 +649,7 @@ describe('Groups', () => {
group.members.push(endpoint_1); group.members.push(endpoint_1);
group.members.push(endpoint_2); group.members.push(endpoint_2);
settings.set(['groups'], { settings.set(['groups'], {
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false} 1: {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false},
}); });
await resetExtension(); await resetExtension();
@ -563,9 +661,9 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/bulb/set', stringify({state: 'OFF'})); await MQTT.events.message('zigbee2mqtt/bulb/set', stringify({state: 'OFF'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3); expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"state":"OFF"}), {"retain": true, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', stringify({state: 'OFF'}), {retain: true, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Should only update group state with changed properties', async () => { it('Should only update group state with changed properties', async () => {
@ -577,7 +675,7 @@ describe('Groups', () => {
group.members.push(endpoint_1); group.members.push(endpoint_1);
group.members.push(endpoint_2); group.members.push(endpoint_2);
settings.set(['groups'], { settings.set(['groups'], {
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false} 1: {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false},
}); });
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
@ -590,9 +688,24 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 300})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 300}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3); expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color_mode": "color_temp","color_temp":300,"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode": "color_temp","color_temp":300,"state":"ON"}), {"retain": true, qos: 0}, expect.any(Function)); 'zigbee2mqtt/bulb_color',
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color_mode": "color_temp","color_temp":300,"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function)); stringify({color_mode: 'color_temp', color_temp: 300, state: 'OFF'}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({color_mode: 'color_temp', color_temp: 300, state: 'ON'}),
{retain: true, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_1',
stringify({color_mode: 'color_temp', color_temp: 300, state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Should publish state change off even when missing current state', async () => { it('Should publish state change off even when missing current state', async () => {
@ -604,7 +717,7 @@ describe('Groups', () => {
group.members.push(endpoint_1); group.members.push(endpoint_1);
group.members.push(endpoint_2); group.members.push(endpoint_2);
settings.set(['groups'], { settings.set(['groups'], {
'1': {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false} 1: {friendly_name: 'group_1', devices: [device_1.ieeeAddr, device_2.ieeeAddr], retain: false},
}); });
await resetExtension(); await resetExtension();
@ -617,27 +730,28 @@ describe('Groups', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"state":"OFF"}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
}); });
it('Add to group via MQTT', async () => { it('Add to group via MQTT', async () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: []}});
expect(group.members.length).toBe(0); expect(group.members.length).toBe(0);
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({transaction: "123", group: 'group_1', device: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({transaction: '123', group: 'group_1', device: 'bulb_color'}));
await flushPromises(); await flushPromises();
expect(group.members).toStrictEqual([endpoint]); expect(group.members).toStrictEqual([endpoint]);
expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/1`]); expect(settings.getGroup('group_1').devices).toStrictEqual([`${device.ieeeAddr}/1`]);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add', 'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"bulb_color","group":"group_1"},"transaction": "123", "status":"ok"}), stringify({data: {device: 'bulb_color', group: 'group_1'}, transaction: '123', status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -645,10 +759,12 @@ describe('Groups', () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: []}});
expect(group.members.length).toBe(0); expect(group.members.length).toBe(0);
await resetExtension(); await resetExtension();
endpoint.addToGroup.mockImplementationOnce(() => {throw new Error('timeout')}); endpoint.addToGroup.mockImplementationOnce(() => {
throw new Error('timeout');
});
await flushPromises(); await flushPromises();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb_color'}));
@ -658,16 +774,17 @@ describe('Groups', () => {
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add', 'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"error","error":"Failed to add from group (timeout)"}), stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'error', error: 'Failed to add from group (timeout)'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Add to group with slashes via MQTT', async () => { it('Add to group with slashes via MQTT', async () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups["group/with/slashes"]; const group = zigbeeHerdsman.groups['group/with/slashes'];
settings.set(['groups'], {'99': {friendly_name: 'group/with/slashes', retain: false, devices: []}}); settings.set(['groups'], {99: {friendly_name: 'group/with/slashes', retain: false, devices: []}});
expect(group.members.length).toBe(0); expect(group.members.length).toBe(0);
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
@ -678,8 +795,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add', 'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"bulb_color","group":"group/with/slashes"},"status":"ok"}), stringify({data: {device: 'bulb_color', group: 'group/with/slashes'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -697,8 +815,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add', 'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"wall_switch_double/right","group":"group_1"},"status":"ok"}), stringify({data: {device: 'wall_switch_double/right', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -718,8 +837,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add', 'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"wall_switch_double/right","group":"group_1"},"status":"ok"}), stringify({data: {device: 'wall_switch_double/right', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -728,7 +848,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color'}));
@ -738,8 +858,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove', 'zigbee2mqtt/bridge/response/group/members/remove',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}), stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -748,18 +869,22 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color', skip_disable_reporting: true})); MQTT.events.message(
'zigbee2mqtt/bridge/request/group/members/remove',
stringify({group: 'group_1', device: 'bulb_color', skip_disable_reporting: true}),
);
await flushPromises(); await flushPromises();
expect(group.members).toStrictEqual([]); expect(group.members).toStrictEqual([]);
expect(settings.getGroup('group_1').devices).toStrictEqual([]); expect(settings.getGroup('group_1').devices).toStrictEqual([]);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove', 'zigbee2mqtt/bridge/response/group/members/remove',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}), stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -768,7 +893,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['dummy']}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: ['dummy']}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'bulb_color'}));
@ -778,8 +903,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove', 'zigbee2mqtt/bridge/response/group/members/remove',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}), stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -788,7 +914,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/3'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/3'}));
@ -798,8 +924,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove', 'zigbee2mqtt/bridge/response/group/members/remove',
stringify({"data":{"device":"0x0017880104e45542/3","group":"group_1"},"status":"ok"}), stringify({data: {device: '0x0017880104e45542/3', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -808,7 +935,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'wall_switch_double/3'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: 'wall_switch_double/3'}));
@ -818,8 +945,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove', 'zigbee2mqtt/bridge/response/group/members/remove',
stringify({"data":{"device":"wall_switch_double/3","group":"group_1"},"status":"ok"}), stringify({data: {device: 'wall_switch_double/3', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -828,7 +956,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/right'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove', stringify({group: 'group_1', device: '0x0017880104e45542/right'}));
@ -838,8 +966,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove', 'zigbee2mqtt/bridge/response/group/members/remove',
stringify({"data":{"device":"0x0017880104e45542/right","group":"group_1"},"status":"ok"}), stringify({data: {device: '0x0017880104e45542/right', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -848,7 +977,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint); group.members.push(endpoint);
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove_all', stringify({device: '0x0017880104e45542/right'})); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/remove_all', stringify({device: '0x0017880104e45542/right'}));
@ -858,8 +987,9 @@ describe('Groups', () => {
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove_all', 'zigbee2mqtt/bridge/response/group/members/remove_all',
stringify({"data":{"device":"0x0017880104e45542/right"},"status":"ok"}), stringify({data: {device: '0x0017880104e45542/right'}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -872,8 +1002,13 @@ describe('Groups', () => {
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/remove', 'zigbee2mqtt/bridge/response/group/members/remove',
stringify({"data":{"device":"bulb_color","group":"group_1_not_existing"},"status":"error","error":"Group 'group_1_not_existing' does not exist"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {device: 'bulb_color', group: 'group_1_not_existing'},
status: 'error',
error: "Group 'group_1_not_existing' does not exist",
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -886,8 +1021,13 @@ describe('Groups', () => {
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add', 'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"bulb_color_not_existing","group":"group_1"},"status":"error","error":"Device 'bulb_color_not_existing' does not exist"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {device: 'bulb_color_not_existing', group: 'group_1'},
status: 'error',
error: "Device 'bulb_color_not_existing' does not exist",
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -895,13 +1035,21 @@ describe('Groups', () => {
await resetExtension(); await resetExtension();
logger.error.mockClear(); logger.error.mockClear();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'group_1', device: 'bulb_color/not_existing_endpoint'})); MQTT.events.message(
'zigbee2mqtt/bridge/request/group/members/add',
stringify({group: 'group_1', device: 'bulb_color/not_existing_endpoint'}),
);
await flushPromises(); await flushPromises();
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add', 'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"bulb_color/not_existing_endpoint","group":"group_1"},"status":"error","error":"Device 'bulb_color' does not have endpoint 'not_existing_endpoint'"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {device: 'bulb_color/not_existing_endpoint', group: 'group_1'},
status: 'error',
error: "Device 'bulb_color' does not have endpoint 'not_existing_endpoint'",
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -911,23 +1059,53 @@ describe('Groups', () => {
const group = zigbeeHerdsman.groups.group_1; const group = zigbeeHerdsman.groups.group_1;
group.members.push(bulbColor.getEndpoint(1)); group.members.push(bulbColor.getEndpoint(1));
group.members.push(bulbColorTemp.getEndpoint(1)); group.members.push(bulbColorTemp.getEndpoint(1));
settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [bulbColor.ieeeAddr, bulbColorTemp.ieeeAddr]}}); settings.set(['groups'], {1: {friendly_name: 'group_1', retain: false, devices: [bulbColor.ieeeAddr, bulbColorTemp.ieeeAddr]}});
await resetExtension(); await resetExtension();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 50})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 50}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3); expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": false, qos: 0}, expect.any(Function)); 'zigbee2mqtt/bulb_color',
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode":"color_temp","color_temp":50}), {"retain": true, qos: 0}, expect.any(Function)); stringify({color_mode: 'color_temp', color_temp: 50}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_1',
stringify({color_mode: 'color_temp', color_temp: 50}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({color_mode: 'color_temp', color_temp: 50}),
{retain: true, qos: 0},
expect.any(Function),
);
MQTT.publish.mockClear(); MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color: {x: 0.5, y: 0.3}})); await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color: {x: 0.5, y: 0.3}}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3); expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color", stringify({"color":{"x":0.5,"y":0.3},"color_mode":"xy","color_temp":548}), {"retain": false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({"color":{"x":0.5,"y":0.3},"color_mode":"xy","color_temp":548}), {"retain": false, qos: 0}, expect.any(Function)); 'zigbee2mqtt/bulb_color',
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({"color_mode":"color_temp","color_temp":548}), {"retain": true, qos: 0}, expect.any(Function)); stringify({color: {x: 0.5, y: 0.3}, color_mode: 'xy', color_temp: 548}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_1',
stringify({color: {x: 0.5, y: 0.3}, color_mode: 'xy', color_temp: 548}),
{retain: false, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({color_mode: 'color_temp', color_temp: 548}),
{retain: true, qos: 0},
expect.any(Function),
);
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,6 @@ const settings = require('../../lib/util/settings');
const Controller = require('../../lib/controller'); const Controller = require('../../lib/controller');
const flushPromises = require('../lib/flushPromises'); const flushPromises = require('../lib/flushPromises');
describe('Bridge legacy', () => { describe('Bridge legacy', () => {
let controller; let controller;
let version; let version;
@ -19,7 +18,7 @@ describe('Bridge legacy', () => {
version = await require('../../lib/util/utils').default.getZigbee2MQTTVersion(); version = await require('../../lib/util/utils').default.getZigbee2MQTTVersion();
controller = new Controller(jest.fn(), jest.fn()); controller = new Controller(jest.fn(), jest.fn());
await controller.start(); await controller.start();
}) });
beforeEach(() => { beforeEach(() => {
data.writeDefaultConfiguration(); data.writeDefaultConfiguration();
@ -35,9 +34,16 @@ describe('Bridge legacy', () => {
it('Should publish bridge configuration on startup', async () => { it('Should publish bridge configuration on startup', async () => {
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/config', 'zigbee2mqtt/bridge/config',
stringify({"version":version.version,"commit":version.commitHash,"coordinator":{"type":"z-Stack","meta":{"version":1, "revision": 20190425}},"network":{"panID":5674,"extendedPanID":[0,11,22],"channel":15},"log_level":'info',"permit_join":false}), stringify({
{ retain: true, qos: 0 }, version: version.version,
expect.any(Function) commit: version.commitHash,
coordinator: {type: 'z-Stack', meta: {version: 1, revision: 20190425}},
network: {panID: 5674, extendedPanID: [0, 11, 22], channel: 15},
log_level: 'info',
permit_join: false,
}),
{retain: true, qos: 0},
expect.any(Function),
); );
}); });
@ -62,23 +68,23 @@ describe('Bridge legacy', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: "device_whitelisted", "message": {friendly_name: "bulb_color"}}), stringify({type: 'device_whitelisted', message: {friendly_name: 'bulb_color'}}),
{ retain: false, qos: 0 }, {retain: false, qos: 0},
expect.any(Function) expect.any(Function),
); );
MQTT.publish.mockClear() MQTT.publish.mockClear();
expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr]); expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr]);
MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb'); MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb');
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: "device_whitelisted", "message": {friendly_name: "bulb"}}), stringify({type: 'device_whitelisted', message: {friendly_name: 'bulb'}}),
{ retain: false, qos: 0 }, {retain: false, qos: 0},
expect.any(Function) expect.any(Function),
); );
MQTT.publish.mockClear() MQTT.publish.mockClear();
expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr, bulb.ieeeAddr]); expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr, bulb.ieeeAddr]);
MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb'); MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb');
await flushPromises(); await flushPromises();
@ -88,39 +94,48 @@ describe('Bridge legacy', () => {
it('Should allow changing device options', async () => { it('Should allow changing device options', async () => {
const bulb_color = zigbeeHerdsman.devices.bulb_color; const bulb_color = zigbeeHerdsman.devices.bulb_color;
expect(settings.getDevice('bulb_color')).toStrictEqual( expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false}
);
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {retain: true}})); MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {retain: true}}));
await flushPromises(); await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual( expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
);
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', optionswrong: {retain: true}})); MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', optionswrong: {retain: true}}));
await flushPromises(); await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual( expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
);
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', "{friendly_name: 'bulb_color'malformed: {retain: true}}"); MQTT.events.message('zigbee2mqtt/bridge/config/device_options', "{friendly_name: 'bulb_color'malformed: {retain: true}}");
await flushPromises(); await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual( expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
);
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {random_setting: true}})); MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {random_setting: true}}));
await flushPromises(); await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual( expect(settings.getDevice('bulb_color')).toStrictEqual({
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true} ID: '0x000b57fffec6a5b3',
friendly_name: 'bulb_color',
random_setting: true,
retain: true,
});
MQTT.events.message(
'zigbee2mqtt/bridge/config/device_options',
stringify({friendly_name: 'bulb_color', options: {options: {random_1: true}}}),
); );
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {options: {random_1: true}}}));
await flushPromises(); await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual( expect(settings.getDevice('bulb_color')).toStrictEqual({
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true}} ID: '0x000b57fffec6a5b3',
friendly_name: 'bulb_color',
random_setting: true,
retain: true,
options: {random_1: true},
});
MQTT.events.message(
'zigbee2mqtt/bridge/config/device_options',
stringify({friendly_name: 'bulb_color', options: {options: {random_2: false}}}),
); );
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', options: {options: {random_2: false}}}));
await flushPromises(); await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual( expect(settings.getDevice('bulb_color')).toStrictEqual({
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true, random_2: false}} ID: '0x000b57fffec6a5b3',
); friendly_name: 'bulb_color',
random_setting: true,
retain: true,
options: {random_1: true, random_2: false},
});
}); });
it('Should allow permit join', async () => { it('Should allow permit join', async () => {
@ -142,7 +157,9 @@ describe('Bridge legacy', () => {
await flushPromises(); await flushPromises();
expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.reset).toHaveBeenCalledWith('soft'); expect(zigbeeHerdsman.reset).toHaveBeenCalledWith('soft');
zigbeeHerdsman.reset.mockImplementationOnce(() => {throw new Error('')}); zigbeeHerdsman.reset.mockImplementationOnce(() => {
throw new Error('');
});
MQTT.events.message('zigbee2mqtt/bridge/config/reset', ''); MQTT.events.message('zigbee2mqtt/bridge/config/reset', '');
await flushPromises(); await flushPromises();
expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(2); expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(2);
@ -182,8 +199,30 @@ describe('Bridge legacy', () => {
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/config/devices'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/config/devices');
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]); const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
expect(payload.length).toStrictEqual(Object.values(zigbeeHerdsman.devices).length); expect(payload.length).toStrictEqual(Object.values(zigbeeHerdsman.devices).length);
expect(payload[1]).toStrictEqual({"ieeeAddr": "0x00124b00120144ae", "type": "Coordinator", "dateCode": "20190425", "friendly_name": "Coordinator", networkAddress: 0, softwareBuildID: "z-Stack", lastSeen: 100}); expect(payload[1]).toStrictEqual({
expect(payload[2]).toStrictEqual({"dateCode": null, "friendly_name": "bulb", "ieeeAddr": "0x000b57fffec6a5b2", "lastSeen": 1000, "manufacturerID": 4476, "model": "LED1545G12", "modelID": "TRADFRI bulb E27 WS opal 980lm", "networkAddress": 40369, "powerSource": "Mains (single phase)", "softwareBuildID": null, "type": "Router", "description": "TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm", "vendor": "IKEA"}); ieeeAddr: '0x00124b00120144ae',
type: 'Coordinator',
dateCode: '20190425',
friendly_name: 'Coordinator',
networkAddress: 0,
softwareBuildID: 'z-Stack',
lastSeen: 100,
});
expect(payload[2]).toStrictEqual({
dateCode: null,
friendly_name: 'bulb',
ieeeAddr: '0x000b57fffec6a5b2',
lastSeen: 1000,
manufacturerID: 4476,
model: 'LED1545G12',
modelID: 'TRADFRI bulb E27 WS opal 980lm',
networkAddress: 40369,
powerSource: 'Mains (single phase)',
softwareBuildID: null,
type: 'Router',
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
vendor: 'IKEA',
});
Date.now = now; Date.now = now;
}); });
@ -193,13 +232,25 @@ describe('Bridge legacy', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]); const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
expect(payload).toStrictEqual({"message":[{"ID":1,"devices":[],"friendly_name":"group_1","retain":false},{"ID":2,"devices":[],"friendly_name":"group_2","retain":false},{"ID":9,"devices":["bulb_color_2","bulb_2","wall_switch_double/right"],"friendly_name":"ha_discovery_group"},{"ID":11,"devices":["bulb_2"],"friendly_name":"group_with_tradfri","retain":false},{"ID":12,"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","retain":false},{"ID":14,"devices":["power_plug","bulb_2"],"friendly_name":"switch_group","retain":false},{"ID":21,"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"},{"ID":15071,"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","retain":false}],"type":"groups"}); expect(payload).toStrictEqual({
message: [
{ID: 1, devices: [], friendly_name: 'group_1', retain: false},
{ID: 2, devices: [], friendly_name: 'group_2', retain: false},
{ID: 9, devices: ['bulb_color_2', 'bulb_2', 'wall_switch_double/right'], friendly_name: 'ha_discovery_group'},
{ID: 11, devices: ['bulb_2'], friendly_name: 'group_with_tradfri', retain: false},
{ID: 12, devices: ['TS0601_thermostat'], friendly_name: 'thermostat_group', retain: false},
{ID: 14, devices: ['power_plug', 'bulb_2'], friendly_name: 'switch_group', retain: false},
{ID: 21, devices: ['GLEDOPTO_2ID/cct'], friendly_name: 'gledopto_group'},
{ID: 15071, devices: ['bulb_color_2', 'bulb_2'], friendly_name: 'group_tradfri_remote', retain: false},
],
type: 'groups',
});
}); });
it('Should allow rename devices', async () => { it('Should allow rename devices', async () => {
const bulb_color2 = {"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color2", "retain": false}; const bulb_color2 = {ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color2', retain: false};
MQTT.publish.mockClear(); MQTT.publish.mockClear();
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false}); expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'})); MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'}));
await flushPromises(); await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual(null); expect(settings.getDevice('bulb_color')).toStrictEqual(null);
@ -208,7 +259,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'device_renamed', message: {from: 'bulb_color', to: 'bulb_color2'}}), stringify({type: 'device_renamed', message: {from: 'bulb_color', to: 'bulb_color2'}}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color2', newmalformed: 'bulb_color3'})); MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color2', newmalformed: 'bulb_color3'}));
@ -226,15 +277,15 @@ describe('Bridge legacy', () => {
it('Should allow rename groups', async () => { it('Should allow rename groups', async () => {
MQTT.publish.mockClear(); MQTT.publish.mockClear();
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], "friendly_name": "group_1", retain: false}); expect(settings.getGroup(1)).toStrictEqual({ID: 1, devices: [], friendly_name: 'group_1', retain: false});
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'group_1', new: 'group_1_renamed'})); MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'group_1', new: 'group_1_renamed'}));
await flushPromises(); await flushPromises();
expect(settings.getGroup(1)).toStrictEqual({"ID": 1, devices: [], "friendly_name": "group_1_renamed", retain: false}); expect(settings.getGroup(1)).toStrictEqual({ID: 1, devices: [], friendly_name: 'group_1_renamed', retain: false});
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'group_renamed', message: {from: 'group_1', to: 'group_1_renamed'}}), stringify({type: 'group_renamed', message: {from: 'group_1', to: 'group_1_renamed'}}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
}); });
@ -251,7 +302,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'device_renamed', message: {from: 'bulb', to: 'bulb_new_name'}}), stringify({type: 'device_renamed', message: {from: 'bulb', to: 'bulb_new_name'}}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
}); });
@ -273,9 +324,9 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'group_added', message: 'new_group'}), stringify({type: 'group_added', message: 'new_group'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendly_name": "new_group", devices: []}); expect(settings.getGroup('new_group')).toStrictEqual({ID: 3, friendly_name: 'new_group', devices: []});
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3); expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
}); });
@ -284,14 +335,14 @@ describe('Bridge legacy', () => {
zigbeeHerdsman.createGroup.mockClear(); zigbeeHerdsman.createGroup.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group"}'); MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group"}');
await flushPromises(); await flushPromises();
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 3, "friendly_name": "new_group", devices: []}); expect(settings.getGroup('new_group')).toStrictEqual({ID: 3, friendly_name: 'new_group', devices: []});
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3); expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(3);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'group_added', message: 'new_group'}), stringify({type: 'group_added', message: 'new_group'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
}); });
@ -299,14 +350,14 @@ describe('Bridge legacy', () => {
zigbeeHerdsman.createGroup.mockClear(); zigbeeHerdsman.createGroup.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group", "id": 42}'); MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group", "id": 42}');
await flushPromises(); await flushPromises();
expect(settings.getGroup('new_group')).toStrictEqual({"ID": 42, "friendly_name": "new_group", devices: []}); expect(settings.getGroup('new_group')).toStrictEqual({ID: 42, friendly_name: 'new_group', devices: []});
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42); expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'group_added', message: 'new_group'}), stringify({type: 'group_added', message: 'new_group'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
}); });
@ -314,9 +365,9 @@ describe('Bridge legacy', () => {
zigbeeHerdsman.createGroup.mockClear(); zigbeeHerdsman.createGroup.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"id": 42}'); MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"id": 42}');
await flushPromises(); await flushPromises();
expect(settings.getGroup('group_42')).toStrictEqual({"ID": 42, "friendly_name": "group_42", devices: []}); expect(settings.getGroup('group_42')).toStrictEqual({ID: 42, friendly_name: 'group_42', devices: []});
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.createGroup).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42) expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42);
}); });
it('Should allow to remove groups', async () => { it('Should allow to remove groups', async () => {
@ -329,7 +380,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'group_removed', message: 'group_1'}), stringify({type: 'group_removed', message: 'group_1'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
}); });
@ -343,7 +394,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'group_removed', message: 'group_1'}), stringify({type: 'group_removed', message: 'group_1'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
}); });
@ -378,7 +429,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'device_removed', message: 'bulb_color'}), stringify({type: 'device_removed', message: 'bulb_color'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
expect(controller.state.state).toStrictEqual({}); expect(controller.state.state).toStrictEqual({});
expect(settings.get().blocklist.length).toBe(0); expect(settings.get().blocklist.length).toBe(0);
@ -400,7 +451,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'device_force_removed', message: 'bulb_color'}), stringify({type: 'device_force_removed', message: 'bulb_color'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
expect(controller.state.state).toStrictEqual({}); expect(controller.state.state).toStrictEqual({});
expect(settings.get().blocklist.length).toBe(0); expect(settings.get().blocklist.length).toBe(0);
@ -421,7 +472,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log', 'zigbee2mqtt/bridge/log',
stringify({type: 'device_banned', message: 'bulb_color'}), stringify({type: 'device_banned', message: 'bulb_color'}),
{qos: 0, retain: false}, {qos: 0, retain: false},
expect.any(Function) expect.any(Function),
); );
expect(settings.get().blocklist).toStrictEqual(['0x000b57fffec6a5b3']); expect(settings.get().blocklist).toStrictEqual(['0x000b57fffec6a5b3']);
}); });
@ -436,28 +487,32 @@ describe('Bridge legacy', () => {
it('Should handle when remove fails', async () => { it('Should handle when remove fails', async () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
device.removeFromNetwork.mockClear(); device.removeFromNetwork.mockClear();
device.removeFromNetwork.mockImplementationOnce(() => {throw new Error('')}) device.removeFromNetwork.mockImplementationOnce(() => {
throw new Error('');
});
await flushPromises(); await flushPromises();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'bulb_color'); MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'bulb_color');
await flushPromises(); await flushPromises();
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false}) expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
}); });
it('Should handle when ban fails', async () => { it('Should handle when ban fails', async () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
device.removeFromNetwork.mockClear(); device.removeFromNetwork.mockClear();
device.removeFromNetwork.mockImplementationOnce(() => {throw new Error('')}) device.removeFromNetwork.mockImplementationOnce(() => {
throw new Error('');
});
await flushPromises(); await flushPromises();
MQTT.publish.mockClear(); MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/ban', 'bulb_color'); MQTT.events.message('zigbee2mqtt/bridge/config/ban', 'bulb_color');
await flushPromises(); await flushPromises();
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(settings.getDevice('bulb_color')).toStrictEqual({"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": false}) expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
}); });

View File

@ -32,12 +32,22 @@ describe('Report', () => {
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint); expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3); expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 300, "minimumReportInterval": 0, "reportableChange": 0}]); expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}]); {attribute: 'onOff', maximumReportInterval: 300, minimumReportInterval: 0, reportableChange: 0},
]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
]);
if (colorXY) { if (colorXY) {
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [{"attribute": "colorTemperature", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}, {"attribute": "currentX", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}, {"attribute": "currentY", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}]); expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
{attribute: 'currentX', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
{attribute: 'currentY', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
]);
} else { } else {
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [{"attribute": "colorTemperature", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}]); expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
]);
} }
} }
@ -51,12 +61,22 @@ describe('Report', () => {
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint); expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
expect(endpoint.unbind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint); expect(endpoint.unbind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3); expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 0, "reportableChange": 0}]); expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 3, "reportableChange": 1}]); {attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
]);
if (colorXY) { if (colorXY) {
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [{"attribute": "colorTemperature", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 3, "reportableChange": 1}, {"attribute": "currentX", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 3, "reportableChange": 1}, {"attribute": "currentY", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 3, "reportableChange": 1}]); expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
{attribute: 'currentX', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
{attribute: 'currentY', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
]);
} else { } else {
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [{"attribute": "colorTemperature", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 3, "reportableChange": 1}]); expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
]);
} }
} }
@ -68,7 +88,7 @@ describe('Report', () => {
endpoint.bind.mockClear(); endpoint.bind.mockClear();
endpoint.unbind.mockClear(); endpoint.unbind.mockClear();
} }
} };
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -130,7 +150,7 @@ describe('Report', () => {
device.save.mockClear(); device.save.mockClear();
mockClear(device); mockClear(device);
delete device.meta.reporting; delete device.meta.reporting;
const data = {onOff: 1} const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
@ -155,7 +175,9 @@ describe('Report', () => {
it('Should not mark as configured when reporting setup fails', async () => { it('Should not mark as configured when reporting setup fails', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
endpoint.bind.mockImplementationOnce(async () => {throw new Error('failed')}); endpoint.bind.mockImplementationOnce(async () => {
throw new Error('failed');
});
delete device.meta.reporting; delete device.meta.reporting;
mockClear(device); mockClear(device);
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
@ -183,7 +205,7 @@ describe('Report', () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
mockClear(device); mockClear(device);
const data = {onOff: 1} const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
@ -233,7 +255,9 @@ describe('Report', () => {
it('Should not configure reporting again when it already failed once', async () => { it('Should not configure reporting again when it already failed once', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
endpoint.bind.mockImplementationOnce(async () => {throw new Error('failed')}); endpoint.bind.mockImplementationOnce(async () => {
throw new Error('failed');
});
delete device.meta.reporting; delete device.meta.reporting;
mockClear(device); mockClear(device);
const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
@ -281,8 +305,10 @@ describe('Report', () => {
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint); expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', coordinatorEndpoint);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint); expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint); expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']) expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [{"attribute": "colorTemperature", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}]); expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
]);
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3); expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
endpoint.configuredReportings = configuredReportings; endpoint.configuredReportings = configuredReportings;
}); });

View File

@ -30,8 +30,7 @@ describe('Logger', () => {
consoleWriteSpy.mockClear(); consoleWriteSpy.mockClear();
}); });
afterEach(async () => { afterEach(async () => {});
});
it('Create log directory', () => { it('Create log directory', () => {
const dirs = fs.readdirSync(dir.name); const dirs = fs.readdirSync(dir.name);
@ -50,7 +49,7 @@ describe('Logger', () => {
expect(fs.readdirSync(dir.name).length).toBe(21); expect(fs.readdirSync(dir.name).length).toBe(21);
logger.cleanup(); logger.cleanup();
expect(fs.readdirSync(dir.name).length).toBe(10); expect(fs.readdirSync(dir.name).length).toBe(10);
}) });
it('Should not cleanup when there is no timestamp set', () => { it('Should not cleanup when there is no timestamp set', () => {
for (let i = 30; i < 40; i++) { for (let i = 30; i < 40; i++) {
@ -61,7 +60,7 @@ describe('Logger', () => {
expect(fs.readdirSync(dir.name).length).toBe(21); expect(fs.readdirSync(dir.name).length).toBe(21);
logger.cleanup(); logger.cleanup();
expect(fs.readdirSync(dir.name).length).toBe(21); expect(fs.readdirSync(dir.name).length).toBe(21);
}) });
it('Set and get log level', () => { it('Set and get log level', () => {
logger.setLevel('debug'); logger.setLevel('debug');
@ -82,8 +81,7 @@ describe('Logger', () => {
it('Add/remove transport', () => { it('Add/remove transport', () => {
class DummyTransport extends Transport { class DummyTransport extends Transport {
log(info, callback) { log(info, callback) {}
}
} }
expect(logger.winston.transports.length).toBe(2); expect(logger.winston.transports.length).toBe(2);
@ -143,7 +141,7 @@ describe('Logger', () => {
it('Should allow to symlink logs to current directory', () => { it('Should allow to symlink logs to current directory', () => {
settings.set(['advanced', 'log_symlink_current'], true); settings.set(['advanced', 'log_symlink_current'], true);
logger.init(); logger.init();
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy() expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy();
jest.resetModules(); jest.resetModules();
}); });
@ -192,51 +190,51 @@ describe('Logger', () => {
it('Logs Error object', () => { it('Logs Error object', () => {
const logSpy = jest.spyOn(logger.winston, 'log'); const logSpy = jest.spyOn(logger.winston, 'log');
logger.error(new Error('msg'));// test for stack=true logger.error(new Error('msg')); // test for stack=true
expect(logSpy).toHaveBeenLastCalledWith('error', `z2m: ${new Error('msg')}`); expect(logSpy).toHaveBeenLastCalledWith('error', `z2m: ${new Error('msg')}`);
expect(consoleWriteSpy).toHaveBeenCalledTimes(1); expect(consoleWriteSpy).toHaveBeenCalledTimes(1);
}) });
it.each([ it.each([
[ [
'^zhc:legacy:fz:(tuya|moes)', '^zhc:legacy:fz:(tuya|moes)',
new RegExp(/^zhc:legacy:fz:(tuya|moes)/), new RegExp(/^zhc:legacy:fz:(tuya|moes)/),
[ [
{ ns: 'zhc:legacy:fz:tuya_device12', match: true }, {ns: 'zhc:legacy:fz:tuya_device12', match: true},
{ ns: 'zhc:legacy:fz:moes_dimmer', match: true }, {ns: 'zhc:legacy:fz:moes_dimmer', match: true},
{ ns: 'zhc:legacy:fz:not_moes', match: false }, {ns: 'zhc:legacy:fz:not_moes', match: false},
{ ns: 'zhc:legacy:fz', match: false }, {ns: 'zhc:legacy:fz', match: false},
{ ns: 'zhc:legacy:fz:', match: false }, {ns: 'zhc:legacy:fz:', match: false},
{ ns: '1zhc:legacy:fz:tuya_device12', match: false }, {ns: '1zhc:legacy:fz:tuya_device12', match: false},
] ],
], ],
[ [
'^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller', '^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller',
new RegExp(/^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller/), new RegExp(/^zhc:legacy:fz:(tuya|moes)|^zh:ember:uart:|^zh:controller/),
[ [
{ ns: 'zh:ember:uart:ash', match: true }, {ns: 'zh:ember:uart:ash', match: true},
{ ns: 'zh:ember:uart', match: false }, {ns: 'zh:ember:uart', match: false},
{ ns: 'zh:controller', match: true }, {ns: 'zh:controller', match: true},
{ ns: 'zh:controller:', match: true }, {ns: 'zh:controller:', match: true},
{ ns: 'azh:controller:', match: false }, {ns: 'azh:controller:', match: false},
] ],
], ],
[ [
'', '',
undefined, undefined,
[ [
{ ns: 'zhc:legacy:fz:tuya_device12', match: false }, {ns: 'zhc:legacy:fz:tuya_device12', match: false},
{ ns: 'zhc:legacy:fz:moes_dimmer', match: false }, {ns: 'zhc:legacy:fz:moes_dimmer', match: false},
{ ns: 'zhc:legacy:fz:not_moes', match: false }, {ns: 'zhc:legacy:fz:not_moes', match: false},
{ ns: 'zhc:legacy:fz', match: false }, {ns: 'zhc:legacy:fz', match: false},
{ ns: 'zhc:legacy:fz:', match: false }, {ns: 'zhc:legacy:fz:', match: false},
{ ns: '1zhc:legacy:fz:tuya_device12', match: false }, {ns: '1zhc:legacy:fz:tuya_device12', match: false},
{ ns: 'zh:ember:uart:ash', match: false }, {ns: 'zh:ember:uart:ash', match: false},
{ ns: 'zh:ember:uart', match: false }, {ns: 'zh:ember:uart', match: false},
{ ns: 'zh:controller', match: false }, {ns: 'zh:controller', match: false},
{ ns: 'zh:controller:', match: false }, {ns: 'zh:controller:', match: false},
{ ns: 'azh:controller:', match: false }, {ns: 'azh:controller:', match: false},
] ],
], ],
])('Sets namespace ignore for debug level %s', (ignore, expected, tests) => { ])('Sets namespace ignore for debug level %s', (ignore, expected, tests) => {
logger.setLevel('debug'); logger.setLevel('debug');
@ -264,7 +262,7 @@ describe('Logger', () => {
}); });
logger.init(); logger.init();
logger.setLevel('debug'); logger.setLevel('debug');
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": 'warning'}); expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'warning'});
expect(logger.getLevel()).toStrictEqual('debug'); expect(logger.getLevel()).toStrictEqual('debug');
const logSpy = jest.spyOn(logger.winston, 'log'); const logSpy = jest.spyOn(logger.winston, 'log');
@ -283,9 +281,9 @@ describe('Logger', () => {
it('Logs with namespaced levels or default - lower', () => { it('Logs with namespaced levels or default - lower', () => {
expect(logger.getNamespacedLevels()).toStrictEqual({}); expect(logger.getNamespacedLevels()).toStrictEqual({});
logger.setNamespacedLevels({'z2m:mqtt': 'info'}) logger.setNamespacedLevels({'z2m:mqtt': 'info'});
logger.setLevel('warning'); logger.setLevel('warning');
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": 'info'}); expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'info'});
expect(logger.getLevel()).toStrictEqual('warning'); expect(logger.getLevel()).toStrictEqual('warning');
const logSpy = jest.spyOn(logger.winston, 'log'); const logSpy = jest.spyOn(logger.winston, 'log');
@ -332,7 +330,7 @@ describe('Logger', () => {
expect(consoleWriteSpy).toHaveBeenCalledTimes(3); expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
logger.setLevel('info'); logger.setLevel('info');
expect(logger.cachedNamespacedLevels).toStrictEqual(cachedNSLevels = Object.assign({}, nsLevels)); expect(logger.cachedNamespacedLevels).toStrictEqual((cachedNSLevels = Object.assign({}, nsLevels)));
logger.info(`unconfigured namespace info should now pass after default level change and cache reset`, 'z2m:mqtt'); logger.info(`unconfigured namespace info should now pass after default level change and cache reset`, 'z2m:mqtt');
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'})); expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(4); expect(consoleWriteSpy).toHaveBeenCalledTimes(4);
@ -340,8 +338,8 @@ describe('Logger', () => {
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'})); expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer:another:sub:ns': 'error'}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(5); expect(consoleWriteSpy).toHaveBeenCalledTimes(5);
logger.setNamespacedLevels({'zh:zstack': 'warning'}) logger.setNamespacedLevels({'zh:zstack': 'warning'});
expect(logger.cachedNamespacedLevels).toStrictEqual(cachedNSLevels = {'zh:zstack': 'warning'}); expect(logger.cachedNamespacedLevels).toStrictEqual((cachedNSLevels = {'zh:zstack': 'warning'}));
logger.error(`error logged`, 'zh:zstack:unpi:writer'); logger.error(`error logged`, 'zh:zstack:unpi:writer');
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer': 'warning'})); expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer': 'warning'}));
expect(consoleWriteSpy).toHaveBeenCalledTimes(6); expect(consoleWriteSpy).toHaveBeenCalledTimes(6);

View File

@ -12,7 +12,7 @@ zigbeeHerdsman.returnDevices.push(bulb_color.ieeeAddr);
zigbeeHerdsman.returnDevices.push(WXKG02LM_rev1.ieeeAddr); zigbeeHerdsman.returnDevices.push(WXKG02LM_rev1.ieeeAddr);
zigbeeHerdsman.returnDevices.push(CC2530_ROUTER.ieeeAddr); zigbeeHerdsman.returnDevices.push(CC2530_ROUTER.ieeeAddr);
zigbeeHerdsman.returnDevices.push(unsupported_router.ieeeAddr); zigbeeHerdsman.returnDevices.push(unsupported_router.ieeeAddr);
zigbeeHerdsman.returnDevices.push(external_converter_device.ieeeAddr) zigbeeHerdsman.returnDevices.push(external_converter_device.ieeeAddr);
const MQTT = require('./stub/mqtt'); const MQTT = require('./stub/mqtt');
const settings = require('../lib/util/settings'); const settings = require('../lib/util/settings');
const Controller = require('../lib/controller'); const Controller = require('../lib/controller');
@ -25,7 +25,7 @@ describe('Networkmap', () => {
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
Date.now = jest.fn() Date.now = jest.fn();
Date.now.mockReturnValue(10000); Date.now.mockReturnValue(10000);
data.writeDefaultConfiguration(); data.writeDefaultConfiguration();
settings.reRead(); settings.reRead();
@ -66,32 +66,62 @@ describe('Networkmap', () => {
* | -> CC2530_ROUTER -> WXKG02LM_rev1 * | -> CC2530_ROUTER -> WXKG02LM_rev1
* *
*/ */
coordinator.lqi = () => {return {neighbors: [ coordinator.lqi = () => {
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 2, depth: 1, linkquality: 120}, return {
{ieeeAddr: bulb.ieeeAddr, networkAddress: bulb.networkAddress, relationship: 2, depth: 1, linkquality: 92}, neighbors: [
{ieeeAddr: external_converter_device.ieeeAddr, networkAddress: external_converter_device.networkAddress, relationship: 2, depth: 1, linkquality: 92} {ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 2, depth: 1, linkquality: 120},
]}}; {ieeeAddr: bulb.ieeeAddr, networkAddress: bulb.networkAddress, relationship: 2, depth: 1, linkquality: 92},
coordinator.routingTable = () => {return {table: [ {
{destinationAddress: CC2530_ROUTER.networkAddress, status: 'ACTIVE', nextHop: bulb.networkAddress}, ieeeAddr: external_converter_device.ieeeAddr,
]}}; networkAddress: external_converter_device.networkAddress,
relationship: 2,
depth: 1,
linkquality: 92,
},
],
};
};
coordinator.routingTable = () => {
return {table: [{destinationAddress: CC2530_ROUTER.networkAddress, status: 'ACTIVE', nextHop: bulb.networkAddress}]};
};
bulb.lqi = () => {return {neighbors: [ bulb.lqi = () => {
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 1, depth: 2, linkquality: 110}, return {
{ieeeAddr: CC2530_ROUTER.ieeeAddr, networkAddress: CC2530_ROUTER.networkAddress, relationship: 1, depth: 2, linkquality: 100} neighbors: [
]}}; {ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 1, depth: 2, linkquality: 110},
bulb.routingTable = () => {return {table: []}}; {ieeeAddr: CC2530_ROUTER.ieeeAddr, networkAddress: CC2530_ROUTER.networkAddress, relationship: 1, depth: 2, linkquality: 100},
],
};
};
bulb.routingTable = () => {
return {table: []};
};
bulb_color.lqi = () => {return {neighbors: []}} bulb_color.lqi = () => {
bulb_color.routingTable = () => {return {table: []}}; return {neighbors: []};
};
bulb_color.routingTable = () => {
return {table: []};
};
CC2530_ROUTER.lqi = () => {return {neighbors: [ CC2530_ROUTER.lqi = () => {
{ieeeAddr: '0x0000000000000000', networkAddress: WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130}, return {
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 4, depth: 2, linkquality: 130}, neighbors: [
]}}; {ieeeAddr: '0x0000000000000000', networkAddress: WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
CC2530_ROUTER.routingTable = () => {return {table: []}}; {ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 4, depth: 2, linkquality: 130},
],
};
};
CC2530_ROUTER.routingTable = () => {
return {table: []};
};
unsupported_router.lqi = () => {throw new Error('failed')}; unsupported_router.lqi = () => {
unsupported_router.routingTable = () => {throw new Error('failed')}; throw new Error('failed');
};
unsupported_router.routingTable = () => {
throw new Error('failed');
};
} }
it('Output raw networkmap legacy api', async () => { it('Output raw networkmap legacy api', async () => {
@ -102,7 +132,175 @@ describe('Networkmap', () => {
let call = MQTT.publish.mock.calls[0]; let call = MQTT.publish.mock.calls[0];
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/networkmap/raw'); expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/networkmap/raw');
const expected = {"links":[{"depth":1,"linkquality":120,"lqi":120,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[{"destinationAddress":6540,"nextHop":40369,"status":"ACTIVE"}],"source":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"sourceIeeeAddr":"0x000b57fffec6a5b2","sourceNwkAddr":40369,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x0017880104e45511","networkAddress":1114},"sourceIeeeAddr":"0x0017880104e45511","sourceNwkAddr":1114,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":2,"linkquality":110,"lqi":110,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":100,"lqi":100,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"sourceIeeeAddr":"0x0017880104e45559","sourceNwkAddr":6540,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":130,"lqi":130,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45521","networkAddress":6538},"sourceIeeeAddr":"0x0017880104e45521","sourceNwkAddr":6538,"target":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"targetIeeeAddr":"0x0017880104e45559"}],"nodes":[{"definition":null,"failed":[],"friendlyName":"Coordinator","ieeeAddr":"0x00124b00120144ae","lastSeen":1000,"modelID":null,"networkAddress":0,"type":"Coordinator"},{"definition":{"description":"TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm","model":"LED1545G12","supports":"light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, color_options, identify, linkquality","vendor":"IKEA"},"failed":[],"friendlyName":"bulb","ieeeAddr":"0x000b57fffec6a5b2","lastSeen":1000,"modelID":"TRADFRI bulb E27 WS opal 980lm","networkAddress":40369,"type":"Router"},{"definition":{"description":"Hue Go","model":"7146060PH","supports":"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality","vendor":"Philips"},"failed":[],"friendlyName":"bulb_color","ieeeAddr":"0x000b57fffec6a5b3","lastSeen":1000,"modelID":"LLC020","networkAddress":40399,"type":"Router"},{"definition":{"description":"Wireless remote switch (double rocker), 2016 model","model":"WXKG02LM_rev1","supports":"battery, voltage, power_outage_count, action, linkquality","vendor":"Aqara"},"friendlyName":"button_double_key","ieeeAddr":"0x0017880104e45521","lastSeen":1000,"modelID":"lumi.sensor_86sw2.es1","networkAddress":6538,"type":"EndDevice"},{"definition":{"description":"Automatically generated definition","model":"notSupportedModelID","supports":"action, linkquality","vendor":"Boef"},"failed":["lqi","routingTable"],"friendlyName":"0x0017880104e45525","ieeeAddr":"0x0017880104e45525","lastSeen":1000,"manufacturerName":"Boef","modelID":"notSupportedModelID","networkAddress":6536,"type":"Router"},{"definition":{"description":"CC2530 router","model":"CC2530.ROUTER","supports":"led, linkquality","vendor":"Custom devices (DiY)"},"failed":[],"friendlyName":"cc2530_router","ieeeAddr":"0x0017880104e45559","lastSeen":1000,"modelID":"lumi.router","networkAddress":6540,"type":"Router"},{"definition":{"description":"external","model":"external_converter_device","supports":"linkquality","vendor":"external"},"friendlyName":"0x0017880104e45511","ieeeAddr":"0x0017880104e45511","lastSeen":1000,"modelID":"external_converter_device","networkAddress":1114,"type":"EndDevice"}]}; const expected = {
links: [
{
depth: 1,
linkquality: 120,
lqi: 120,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
sourceIeeeAddr: '0x000b57fffec6a5b2',
sourceNwkAddr: 40369,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
sourceIeeeAddr: '0x0017880104e45511',
sourceNwkAddr: 1114,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 2,
linkquality: 110,
lqi: 110,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
targetIeeeAddr: '0x000b57fffec6a5b2',
},
{
depth: 2,
linkquality: 100,
lqi: 100,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
sourceIeeeAddr: '0x0017880104e45559',
sourceNwkAddr: 6540,
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
targetIeeeAddr: '0x000b57fffec6a5b2',
},
{
depth: 2,
linkquality: 130,
lqi: 130,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
sourceIeeeAddr: '0x0017880104e45521',
sourceNwkAddr: 6538,
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
targetIeeeAddr: '0x0017880104e45559',
},
],
nodes: [
{
definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
lastSeen: 1000,
modelID: null,
networkAddress: 0,
type: 'Coordinator',
},
{
definition: {
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
model: 'LED1545G12',
supports:
'light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, color_options, identify, linkquality',
vendor: 'IKEA',
},
failed: [],
friendlyName: 'bulb',
ieeeAddr: '0x000b57fffec6a5b2',
lastSeen: 1000,
modelID: 'TRADFRI bulb E27 WS opal 980lm',
networkAddress: 40369,
type: 'Router',
},
{
definition: {
description: 'Hue Go',
model: '7146060PH',
supports:
'light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality',
vendor: 'Philips',
},
failed: [],
friendlyName: 'bulb_color',
ieeeAddr: '0x000b57fffec6a5b3',
lastSeen: 1000,
modelID: 'LLC020',
networkAddress: 40399,
type: 'Router',
},
{
definition: {
description: 'Wireless remote switch (double rocker), 2016 model',
model: 'WXKG02LM_rev1',
supports: 'battery, voltage, power_outage_count, action, linkquality',
vendor: 'Aqara',
},
friendlyName: 'button_double_key',
ieeeAddr: '0x0017880104e45521',
lastSeen: 1000,
modelID: 'lumi.sensor_86sw2.es1',
networkAddress: 6538,
type: 'EndDevice',
},
{
definition: {
description: 'Automatically generated definition',
model: 'notSupportedModelID',
supports: 'action, linkquality',
vendor: 'Boef',
},
failed: ['lqi', 'routingTable'],
friendlyName: '0x0017880104e45525',
ieeeAddr: '0x0017880104e45525',
lastSeen: 1000,
manufacturerName: 'Boef',
modelID: 'notSupportedModelID',
networkAddress: 6536,
type: 'Router',
},
{
definition: {description: 'CC2530 router', model: 'CC2530.ROUTER', supports: 'led, linkquality', vendor: 'Custom devices (DiY)'},
failed: [],
friendlyName: 'cc2530_router',
ieeeAddr: '0x0017880104e45559',
lastSeen: 1000,
modelID: 'lumi.router',
networkAddress: 6540,
type: 'Router',
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
lastSeen: 1000,
modelID: 'external_converter_device',
networkAddress: 1114,
type: 'EndDevice',
},
],
};
expect(JSON.parse(call[1])).toStrictEqual(expected); expect(JSON.parse(call[1])).toStrictEqual(expected);
/** /**
@ -122,7 +320,7 @@ describe('Networkmap', () => {
} }
}); });
expected.links.forEach((l) => l.routes = []) expected.links.forEach((l) => (l.routes = []));
expect(JSON.parse(call[1])).toStrictEqual(expected); expect(JSON.parse(call[1])).toStrictEqual(expected);
}); });
@ -131,7 +329,7 @@ describe('Networkmap', () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
device.lastSeen = null; device.lastSeen = null;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const data = {modelID: 'test'} const data = {modelID: 'test'};
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'graphviz'); MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'graphviz');
@ -170,7 +368,7 @@ describe('Networkmap', () => {
const device = zigbeeHerdsman.devices.bulb_color; const device = zigbeeHerdsman.devices.bulb_color;
device.lastSeen = null; device.lastSeen = null;
const endpoint = device.getEndpoint(1); const endpoint = device.getEndpoint(1);
const data = {modelID: 'test'} const data = {modelID: 'test'};
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'plantuml'); MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'plantuml');
@ -276,7 +474,187 @@ describe('Networkmap', () => {
let call = MQTT.publish.mock.calls[0]; let call = MQTT.publish.mock.calls[0];
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap'); expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
const expected = {"data":{"routes":true,"type":"raw","value":{"links":[{"depth":1,"linkquality":120,"lqi":120,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[{"destinationAddress":6540,"nextHop":40369,"status":"ACTIVE"}],"source":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"sourceIeeeAddr":"0x000b57fffec6a5b2","sourceNwkAddr":40369,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x0017880104e45511","networkAddress":1114},"sourceIeeeAddr":"0x0017880104e45511","sourceNwkAddr":1114,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":2,"linkquality":110,"lqi":110,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":100,"lqi":100,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"sourceIeeeAddr":"0x0017880104e45559","sourceNwkAddr":6540,"target":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"targetIeeeAddr":"0x000b57fffec6a5b2"},{"depth":2,"linkquality":130,"lqi":130,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45521","networkAddress":6538},"sourceIeeeAddr":"0x0017880104e45521","sourceNwkAddr":6538,"target":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"targetIeeeAddr":"0x0017880104e45559"}],"nodes":[{"definition":null,"failed":[],"friendlyName":"Coordinator","ieeeAddr":"0x00124b00120144ae","lastSeen":1000,"modelID":null,"networkAddress":0,"type":"Coordinator"},{"definition":{"description":"TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm","model":"LED1545G12","supports":"light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, color_options, identify, linkquality","vendor":"IKEA"},"failed":[],"friendlyName":"bulb","ieeeAddr":"0x000b57fffec6a5b2","lastSeen":1000,"modelID":"TRADFRI bulb E27 WS opal 980lm","networkAddress":40369,"type":"Router"},{"definition":{"description":"Hue Go","model":"7146060PH","supports":"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality","vendor":"Philips"},"failed":[],"friendlyName":"bulb_color","ieeeAddr":"0x000b57fffec6a5b3","lastSeen":1000,"modelID":"LLC020","networkAddress":40399,"type":"Router"},{"definition":{"description":"Wireless remote switch (double rocker), 2016 model","model":"WXKG02LM_rev1","supports":"battery, voltage, power_outage_count, action, linkquality","vendor":"Aqara"},"friendlyName":"button_double_key","ieeeAddr":"0x0017880104e45521","lastSeen":1000,"modelID":"lumi.sensor_86sw2.es1","networkAddress":6538,"type":"EndDevice"},{"definition":{"description":"Automatically generated definition","model":"notSupportedModelID","supports":"action, linkquality","vendor":"Boef"},"failed":["lqi","routingTable"],"friendlyName":"0x0017880104e45525","ieeeAddr":"0x0017880104e45525","lastSeen":1000,"manufacturerName":"Boef","modelID":"notSupportedModelID","networkAddress":6536,"type":"Router"},{"definition":{"description":"CC2530 router","model":"CC2530.ROUTER","supports":"led, linkquality","vendor":"Custom devices (DiY)"},"failed":[],"friendlyName":"cc2530_router","ieeeAddr":"0x0017880104e45559","lastSeen":1000,"modelID":"lumi.router","networkAddress":6540,"type":"Router"},{"definition":{"description":"external","model":"external_converter_device","supports":"linkquality","vendor":"external"},"friendlyName":"0x0017880104e45511","ieeeAddr":"0x0017880104e45511","lastSeen":1000,"modelID":"external_converter_device","networkAddress":1114,"type":"EndDevice"}]}},"status":"ok"}; const expected = {
data: {
routes: true,
type: 'raw',
value: {
links: [
{
depth: 1,
linkquality: 120,
lqi: 120,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
sourceIeeeAddr: '0x000b57fffec6a5b2',
sourceNwkAddr: 40369,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
sourceIeeeAddr: '0x0017880104e45511',
sourceNwkAddr: 1114,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 2,
linkquality: 110,
lqi: 110,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
targetIeeeAddr: '0x000b57fffec6a5b2',
},
{
depth: 2,
linkquality: 100,
lqi: 100,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
sourceIeeeAddr: '0x0017880104e45559',
sourceNwkAddr: 6540,
target: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
targetIeeeAddr: '0x000b57fffec6a5b2',
},
{
depth: 2,
linkquality: 130,
lqi: 130,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
sourceIeeeAddr: '0x0017880104e45521',
sourceNwkAddr: 6538,
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
targetIeeeAddr: '0x0017880104e45559',
},
],
nodes: [
{
definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
lastSeen: 1000,
modelID: null,
networkAddress: 0,
type: 'Coordinator',
},
{
definition: {
description: 'TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm',
model: 'LED1545G12',
supports:
'light (state, brightness, color_temp, color_temp_startup), effect, power_on_behavior, color_options, identify, linkquality',
vendor: 'IKEA',
},
failed: [],
friendlyName: 'bulb',
ieeeAddr: '0x000b57fffec6a5b2',
lastSeen: 1000,
modelID: 'TRADFRI bulb E27 WS opal 980lm',
networkAddress: 40369,
type: 'Router',
},
{
definition: {
description: 'Hue Go',
model: '7146060PH',
supports:
'light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality',
vendor: 'Philips',
},
failed: [],
friendlyName: 'bulb_color',
ieeeAddr: '0x000b57fffec6a5b3',
lastSeen: 1000,
modelID: 'LLC020',
networkAddress: 40399,
type: 'Router',
},
{
definition: {
description: 'Wireless remote switch (double rocker), 2016 model',
model: 'WXKG02LM_rev1',
supports: 'battery, voltage, power_outage_count, action, linkquality',
vendor: 'Aqara',
},
friendlyName: 'button_double_key',
ieeeAddr: '0x0017880104e45521',
lastSeen: 1000,
modelID: 'lumi.sensor_86sw2.es1',
networkAddress: 6538,
type: 'EndDevice',
},
{
definition: {
description: 'Automatically generated definition',
model: 'notSupportedModelID',
supports: 'action, linkquality',
vendor: 'Boef',
},
failed: ['lqi', 'routingTable'],
friendlyName: '0x0017880104e45525',
ieeeAddr: '0x0017880104e45525',
lastSeen: 1000,
manufacturerName: 'Boef',
modelID: 'notSupportedModelID',
networkAddress: 6536,
type: 'Router',
},
{
definition: {
description: 'CC2530 router',
model: 'CC2530.ROUTER',
supports: 'led, linkquality',
vendor: 'Custom devices (DiY)',
},
failed: [],
friendlyName: 'cc2530_router',
ieeeAddr: '0x0017880104e45559',
lastSeen: 1000,
modelID: 'lumi.router',
networkAddress: 6540,
type: 'Router',
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
lastSeen: 1000,
modelID: 'external_converter_device',
networkAddress: 1114,
type: 'EndDevice',
},
],
},
},
status: 'ok',
};
const actual = JSON.parse(call[1]); const actual = JSON.parse(call[1]);
expect(actual).toStrictEqual(expected); expect(actual).toStrictEqual(expected);
}); });
@ -288,8 +666,9 @@ describe('Networkmap', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/networkmap', 'zigbee2mqtt/bridge/response/networkmap',
stringify({"data":{},"status":"error","error":"Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}), stringify({data: {}, status: 'error', error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -303,7 +682,147 @@ describe('Networkmap', () => {
let call = MQTT.publish.mock.calls[0]; let call = MQTT.publish.mock.calls[0];
expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap'); expect(call[0]).toStrictEqual('zigbee2mqtt/bridge/response/networkmap');
const expected = {"data":{"routes":true,"type":"raw","value":{"links":[{"depth":1,"linkquality":120,"lqi":120,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x000b57fffec6a5b3","networkAddress":40399},"sourceIeeeAddr":"0x000b57fffec6a5b3","sourceNwkAddr":40399,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[{"destinationAddress":6540,"nextHop":40369,"status":"ACTIVE"}],"source":{"ieeeAddr":"0x000b57fffec6a5b2","networkAddress":40369},"sourceIeeeAddr":"0x000b57fffec6a5b2","sourceNwkAddr":40369,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":1,"linkquality":92,"lqi":92,"relationship":2,"routes":[],"source":{"ieeeAddr":"0x0017880104e45511","networkAddress":1114},"sourceIeeeAddr":"0x0017880104e45511","sourceNwkAddr":1114,"target":{"ieeeAddr":"0x00124b00120144ae","networkAddress":0},"targetIeeeAddr":"0x00124b00120144ae"},{"depth":2,"linkquality":130,"lqi":130,"relationship":1,"routes":[],"source":{"ieeeAddr":"0x0017880104e45521","networkAddress":6538},"sourceIeeeAddr":"0x0017880104e45521","sourceNwkAddr":6538,"target":{"ieeeAddr":"0x0017880104e45559","networkAddress":6540},"targetIeeeAddr":"0x0017880104e45559"}],"nodes":[{"definition":null,"failed":[],"friendlyName":"Coordinator","ieeeAddr":"0x00124b00120144ae","lastSeen":1000,"modelID":null,"networkAddress":0,"type":"Coordinator"},{"definition":{"description":"Hue Go","model":"7146060PH","supports":"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality","vendor":"Philips"},"failed":[],"friendlyName":"bulb_color","ieeeAddr":"0x000b57fffec6a5b3","lastSeen":1000,"modelID":"LLC020","networkAddress":40399,"type":"Router"},{"definition":{"description":"Wireless remote switch (double rocker), 2016 model","model":"WXKG02LM_rev1","supports":"battery, voltage, power_outage_count, action, linkquality","vendor":"Aqara"},"friendlyName":"button_double_key","ieeeAddr":"0x0017880104e45521","lastSeen":1000,"modelID":"lumi.sensor_86sw2.es1","networkAddress":6538,"type":"EndDevice"},{"definition":{"description":"Automatically generated definition","model":"notSupportedModelID","supports":"action, linkquality","vendor":"Boef"},"failed":["lqi","routingTable"],"friendlyName":"0x0017880104e45525","ieeeAddr":"0x0017880104e45525","lastSeen":1000,"manufacturerName":"Boef","modelID":"notSupportedModelID","networkAddress":6536,"type":"Router"},{"definition":{"description":"CC2530 router","model":"CC2530.ROUTER","supports":"led, linkquality","vendor":"Custom devices (DiY)"},"failed":[],"friendlyName":"cc2530_router","ieeeAddr":"0x0017880104e45559","lastSeen":1000,"modelID":"lumi.router","networkAddress":6540,"type":"Router"},{"definition":{"description":"external","model":"external_converter_device","supports":"linkquality","vendor":"external"},"friendlyName":"0x0017880104e45511","ieeeAddr":"0x0017880104e45511","lastSeen":1000,"modelID":"external_converter_device","networkAddress":1114,"type":"EndDevice"}]}},"status":"ok"} const expected = {
data: {
routes: true,
type: 'raw',
value: {
links: [
{
depth: 1,
linkquality: 120,
lqi: 120,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x000b57fffec6a5b3', networkAddress: 40399},
sourceIeeeAddr: '0x000b57fffec6a5b3',
sourceNwkAddr: 40399,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [{destinationAddress: 6540, nextHop: 40369, status: 'ACTIVE'}],
source: {ieeeAddr: '0x000b57fffec6a5b2', networkAddress: 40369},
sourceIeeeAddr: '0x000b57fffec6a5b2',
sourceNwkAddr: 40369,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 1,
linkquality: 92,
lqi: 92,
relationship: 2,
routes: [],
source: {ieeeAddr: '0x0017880104e45511', networkAddress: 1114},
sourceIeeeAddr: '0x0017880104e45511',
sourceNwkAddr: 1114,
target: {ieeeAddr: '0x00124b00120144ae', networkAddress: 0},
targetIeeeAddr: '0x00124b00120144ae',
},
{
depth: 2,
linkquality: 130,
lqi: 130,
relationship: 1,
routes: [],
source: {ieeeAddr: '0x0017880104e45521', networkAddress: 6538},
sourceIeeeAddr: '0x0017880104e45521',
sourceNwkAddr: 6538,
target: {ieeeAddr: '0x0017880104e45559', networkAddress: 6540},
targetIeeeAddr: '0x0017880104e45559',
},
],
nodes: [
{
definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
lastSeen: 1000,
modelID: null,
networkAddress: 0,
type: 'Coordinator',
},
{
definition: {
description: 'Hue Go',
model: '7146060PH',
supports:
'light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, linkquality',
vendor: 'Philips',
},
failed: [],
friendlyName: 'bulb_color',
ieeeAddr: '0x000b57fffec6a5b3',
lastSeen: 1000,
modelID: 'LLC020',
networkAddress: 40399,
type: 'Router',
},
{
definition: {
description: 'Wireless remote switch (double rocker), 2016 model',
model: 'WXKG02LM_rev1',
supports: 'battery, voltage, power_outage_count, action, linkquality',
vendor: 'Aqara',
},
friendlyName: 'button_double_key',
ieeeAddr: '0x0017880104e45521',
lastSeen: 1000,
modelID: 'lumi.sensor_86sw2.es1',
networkAddress: 6538,
type: 'EndDevice',
},
{
definition: {
description: 'Automatically generated definition',
model: 'notSupportedModelID',
supports: 'action, linkquality',
vendor: 'Boef',
},
failed: ['lqi', 'routingTable'],
friendlyName: '0x0017880104e45525',
ieeeAddr: '0x0017880104e45525',
lastSeen: 1000,
manufacturerName: 'Boef',
modelID: 'notSupportedModelID',
networkAddress: 6536,
type: 'Router',
},
{
definition: {
description: 'CC2530 router',
model: 'CC2530.ROUTER',
supports: 'led, linkquality',
vendor: 'Custom devices (DiY)',
},
failed: [],
friendlyName: 'cc2530_router',
ieeeAddr: '0x0017880104e45559',
lastSeen: 1000,
modelID: 'lumi.router',
networkAddress: 6540,
type: 'Router',
},
{
definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'},
friendlyName: '0x0017880104e45511',
ieeeAddr: '0x0017880104e45511',
lastSeen: 1000,
modelID: 'external_converter_device',
networkAddress: 1114,
type: 'EndDevice',
},
],
},
},
status: 'ok',
};
const actual = JSON.parse(call[1]); const actual = JSON.parse(call[1]);
expect(actual).toStrictEqual(expected); expect(actual).toStrictEqual(expected);
}); });

View File

@ -44,8 +44,8 @@ describe('On event', () => {
it('Should call with start event', async () => { it('Should call with start event', async () => {
expect(mockOnEvent).toHaveBeenCalledTimes(1); expect(mockOnEvent).toHaveBeenCalledTimes(1);
const call = mockOnEvent.mock.calls[0]; const call = mockOnEvent.mock.calls[0];
expect(call[0]).toBe('start') expect(call[0]).toBe('start');
expect(call[1]).toStrictEqual({}) expect(call[1]).toStrictEqual({});
expect(call[2]).toBe(device); expect(call[2]).toBe(device);
expect(call[3]).toStrictEqual(settings.getDevice(device.ieeeAddr)); expect(call[3]).toStrictEqual(settings.getDevice(device.ieeeAddr));
expect(call[4]).toStrictEqual({}); expect(call[4]).toStrictEqual({});
@ -57,8 +57,8 @@ describe('On event', () => {
await flushPromises(); await flushPromises();
expect(mockOnEvent).toHaveBeenCalledTimes(1); expect(mockOnEvent).toHaveBeenCalledTimes(1);
const call = mockOnEvent.mock.calls[0]; const call = mockOnEvent.mock.calls[0];
expect(call[0]).toBe('stop') expect(call[0]).toBe('stop');
expect(call[1]).toStrictEqual({}) expect(call[1]).toStrictEqual({});
expect(call[2]).toBe(device); expect(call[2]).toBe(device);
}); });

View File

@ -14,19 +14,18 @@ const zigbeeOTA = require('zigbee-herdsman-converters/lib/ota/zigbeeOTA');
const spyUseIndexOverride = jest.spyOn(zigbeeOTA, 'useIndexOverride'); const spyUseIndexOverride = jest.spyOn(zigbeeOTA, 'useIndexOverride');
describe('OTA update', () => { describe('OTA update', () => {
let controller; let controller;
let resetExtension = async () => { let resetExtension = async () => {
await controller.enableDisableExtension(false, 'OTAUpdate'); await controller.enableDisableExtension(false, 'OTAUpdate');
await controller.enableDisableExtension(true, 'OTAUpdate'); await controller.enableDisableExtension(true, 'OTAUpdate');
} };
const mockClear = (mapped) => { const mockClear = (mapped) => {
mapped.ota.updateToLatest = jest.fn(); mapped.ota.updateToLatest = jest.fn();
mapped.ota.isUpdateAvailable = jest.fn(); mapped.ota.isUpdateAvailable = jest.fn();
} };
beforeAll(async () => { beforeAll(async () => {
data.writeDefaultConfiguration(); data.writeDefaultConfiguration();
@ -66,9 +65,9 @@ describe('OTA update', () => {
let count = 0; let count = 0;
endpoint.read.mockImplementation(() => { endpoint.read.mockImplementation(() => {
count++; count++;
return {swBuildId: count, dateCode: '2019010' + count} return {swBuildId: count, dateCode: '2019010' + count};
}); });
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
logger.info.mockClear(); logger.info.mockClear();
device.save.mockClear(); device.save.mockClear();
@ -87,164 +86,189 @@ describe('OTA update', () => {
expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 0.00%`); expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 0.00%`);
expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`); expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`);
expect(logger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`); expect(logger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`);
expect(logger.info).toHaveBeenCalledWith(`Device 'bulb' was updated from '{"dateCode":"20190101","softwareBuildID":1}' to '{"dateCode":"20190102","softwareBuildID":2}'`); expect(logger.info).toHaveBeenCalledWith(
`Device 'bulb' was updated from '{"dateCode":"20190101","softwareBuildID":1}' to '{"dateCode":"20190102","softwareBuildID":2}'`,
);
expect(device.save).toHaveBeenCalledTimes(2); expect(device.save).toHaveBeenCalledTimes(2);
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {'sendPolicy': 'immediate'}); expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: 'immediate'});
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {'sendPolicy': undefined}); expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: undefined});
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb', 'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"updating","progress":0}}), stringify({update_available: false, update: {state: 'updating', progress: 0}}),
{retain: true, qos: 0}, expect.any(Function) {retain: true, qos: 0},
expect.any(Function),
); );
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb', 'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"updating","progress":10,"remaining":3600}}), stringify({update_available: false, update: {state: 'updating', progress: 10, remaining: 3600}}),
{retain: true, qos: 0}, expect.any(Function) {retain: true, qos: 0},
expect.any(Function),
); );
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb', 'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"idle","installed_version":90,"latest_version":90}}), stringify({update_available: false, update: {state: 'idle', installed_version: 90, latest_version: 90}}),
{retain: true, qos: 0}, expect.any(Function) {retain: true, qos: 0},
expect.any(Function),
); );
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update', 'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({"data":{"from":{"date_code":"20190101","software_build_id":1},"id":"bulb","to":{"date_code":"20190102","software_build_id":2}},"status":"ok"}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {from: {date_code: '20190101', software_build_id: 1}, id: 'bulb', to: {date_code: '20190102', software_build_id: 2}},
); status: 'ok',
expect(MQTT.publish).toHaveBeenCalledWith( }),
'zigbee2mqtt/bridge/devices', {retain: false, qos: 0},
expect.any(String), expect.any(Function),
{ retain: true, qos: 0 },
expect.any(Function)
); );
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
}); });
it('Should handle when OTA update fails', async () => { it('Should handle when OTA update fails', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.endpoints[0]; const endpoint = device.endpoints[0];
endpoint.read.mockImplementation(() => {return {swBuildId: 1, dateCode: '2019010'}}); endpoint.read.mockImplementation(() => {
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) return {swBuildId: 1, dateCode: '2019010'};
});
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
device.save.mockClear(); device.save.mockClear();
mapped.ota.updateToLatest.mockImplementationOnce((a, onUpdate) => { mapped.ota.updateToLatest.mockImplementationOnce((a, onUpdate) => {
throw new Error('Update failed'); throw new Error('Update failed');
}); });
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', stringify({id: "bulb"})); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', stringify({id: 'bulb'}));
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb', 'zigbee2mqtt/bulb',
stringify({"update_available":true,"update":{"state":"available"}}), stringify({update_available: true, update: {state: 'available'}}),
{retain: true, qos: 0}, expect.any(Function) {retain: true, qos: 0},
expect.any(Function),
); );
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update', 'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({"data":{"id": "bulb"},"status":"error","error":"Update of 'bulb' failed (Update failed)"}), stringify({data: {id: 'bulb'}, status: 'error', error: "Update of 'bulb' failed (Update failed)"}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Should be able to check if OTA update is available', async () => { it('Should be able to check if OTA update is available', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10}); mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10});
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises(); await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0); expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check', 'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "bulb","updateAvailable":false},"status":"ok"}), stringify({data: {id: 'bulb', updateAvailable: false}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
MQTT.publish.mockClear(); MQTT.publish.mockClear();
mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12}); mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises(); await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(2); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(2);
expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0); expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check', 'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "bulb","updateAvailable":true},"status":"ok"}), stringify({data: {id: 'bulb', updateAvailable: true}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Should handle if OTA update check fails', async () => { it('Should handle if OTA update check fails', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {throw new Error('RF signals disturbed because of dogs barking')}); mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {
throw new Error('RF signals disturbed because of dogs barking');
});
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises(); await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0); expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check', 'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "bulb"},"status":"error","error": `Failed to check if update available for 'bulb' (RF signals disturbed because of dogs barking)`}), stringify({
{retain: false, qos: 0}, expect.any(Function) data: {id: 'bulb'},
status: 'error',
error: `Failed to check if update available for 'bulb' (RF signals disturbed because of dogs barking)`,
}),
{retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Should fail when device does not exist', async () => { it('Should fail when device does not exist', async () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "not_existing_deviceooo"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'not_existing_deviceooo');
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check', 'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "not_existing_deviceooo"},"status":"error","error": `Device 'not_existing_deviceooo' does not exist`}), stringify({data: {id: 'not_existing_deviceooo'}, status: 'error', error: `Device 'not_existing_deviceooo' does not exist`}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Should not check for OTA when device does not support it', async () => { it('Should not check for OTA when device does not support it', async () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "dimmer_wall_switch"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'dimmer_wall_switch');
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check', 'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "dimmer_wall_switch"},"status":"error","error": `Device 'dimmer_wall_switch' does not support OTA updates`}), stringify({data: {id: 'dimmer_wall_switch'}, status: 'error', error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Should refuse to check/update when already in progress', async () => { it('Should refuse to check/update when already in progress', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
mapped.ota.isUpdateAvailable.mockImplementationOnce(() => { mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {
return new Promise((resolve, reject) => {setTimeout(() => resolve(), 99999)}) return new Promise((resolve, reject) => {
setTimeout(() => resolve(), 99999);
});
}); });
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises(); await flushPromises();
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises(); await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check', 'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "bulb"},"status":"error","error": `Update or check for update already in progress for 'bulb'`}), stringify({data: {id: 'bulb'}, status: 'error', error: `Update or check for update already in progress for 'bulb'`}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
it('Shouldnt crash when read modelID before/after OTA update fails', async () => { it('Shouldnt crash when read modelID before/after OTA update fails', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.endpoints[0]; const endpoint = device.endpoints[0];
endpoint.read.mockImplementation(() => {throw new Error('Failed!')}); endpoint.read.mockImplementation(() => {
throw new Error('Failed!');
});
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', "bulb"); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', 'bulb');
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update', 'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({"data":{"id":"bulb","from":null,"to":null},"status":"ok"}), stringify({data: {id: 'bulb', from: null, to: null}, status: 'ok'}),
{retain: false, qos: 0}, expect.any(Function) {retain: false, qos: 0},
expect.any(Function),
); );
}); });
@ -252,18 +276,26 @@ describe('OTA update', () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
device.endpoints[0].commandResponse.mockClear(); device.endpoints[0].commandResponse.mockClear();
const data = {imageType: 12382}; const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12}); mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12});
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10, meta: {zclTransactionSequenceNumber: 10}}; const payload = {
data,
cluster: 'genOta',
device,
endpoint: device.getEndpoint(1),
type: 'commandQueryNextImageRequest',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
logger.info.mockClear(); logger.info.mockClear();
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, {"imageType": 12382}); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, {imageType: 12382});
expect(logger.info).toHaveBeenCalledWith(`Update available for 'bulb'`); expect(logger.info).toHaveBeenCalledWith(`Update available for 'bulb'`);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1); expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 0x98}, undefined, 10); expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
// Should not request again when device asks again after a short time // Should not request again when device asks again after a short time
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
@ -277,8 +309,9 @@ describe('OTA update', () => {
expect(logger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`); expect(logger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb', 'zigbee2mqtt/bulb',
stringify({"update_available":true,"update":{"state":"available","installed_version":10,"latest_version":12}}), stringify({update_available: true, update: {state: 'available', installed_version: 10, latest_version: 12}}),
{retain: true, qos: 0}, expect.any(Function) {retain: true, qos: 0},
expect.any(Function),
); );
}); });
@ -286,21 +319,32 @@ describe('OTA update', () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
device.endpoints[0].commandResponse.mockClear(); device.endpoints[0].commandResponse.mockClear();
const data = {imageType: 12382}; const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {throw new Error('Nothing to find here')}) mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10, meta: {zclTransactionSequenceNumber: 10}}; throw new Error('Nothing to find here');
});
const payload = {
data,
cluster: 'genOta',
device,
endpoint: device.getEndpoint(1),
type: 'commandQueryNextImageRequest',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
logger.info.mockClear(); logger.info.mockClear();
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, {"imageType": 12382}); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, {imageType: 12382});
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1); expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 0x98}, undefined, 10); expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb', 'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"idle"}}), stringify({update_available: false, update: {state: 'idle'}}),
{retain: true, qos: 0}, expect.any(Function) {retain: true, qos: 0},
expect.any(Function),
); );
}); });
@ -308,21 +352,30 @@ describe('OTA update', () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
device.endpoints[0].commandResponse.mockClear(); device.endpoints[0].commandResponse.mockClear();
const data = {imageType: 12382}; const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 13, otaFileVersion: 13}); mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 13, otaFileVersion: 13});
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10, meta: {zclTransactionSequenceNumber: 10}}; const payload = {
data,
cluster: 'genOta',
device,
endpoint: device.getEndpoint(1),
type: 'commandQueryNextImageRequest',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
logger.info.mockClear(); logger.info.mockClear();
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, {"imageType": 12382}); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, {imageType: 12382});
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1); expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 0x98}, undefined, 10); expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb', 'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"idle","installed_version": 13, "latest_version": 13}}), stringify({update_available: false, update: {state: 'idle', installed_version: 13, latest_version: 13}}),
{retain: true, qos: 0}, expect.any(Function) {retain: true, qos: 0},
expect.any(Function),
); );
}); });
@ -330,10 +383,18 @@ describe('OTA update', () => {
settings.set(['ota', 'disable_automatic_update_check'], true); settings.set(['ota', 'disable_automatic_update_check'], true);
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const data = {imageType: 12382}; const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 13}); mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 13});
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10, meta: {zclTransactionSequenceNumber: 10}}; const payload = {
data,
cluster: 'genOta',
device,
endpoint: device.getEndpoint(1),
type: 'commandQueryNextImageRequest',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
logger.info.mockClear(); logger.info.mockClear();
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
@ -343,22 +404,38 @@ describe('OTA update', () => {
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA', async () => { it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA', async () => {
const device = zigbeeHerdsman.devices.HGZB04D; const device = zigbeeHerdsman.devices.HGZB04D;
const data = {imageType: 12382}; const data = {imageType: 12382};
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10, meta: {zclTransactionSequenceNumber: 10}}; const payload = {
data,
cluster: 'genOta',
device,
endpoint: device.getEndpoint(1),
type: 'commandQueryNextImageRequest',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1); expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 152}, undefined, 10); expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 152}, undefined, 10);
}); });
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA and device has no OTA endpoint to standard endpoint', async () => { it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA and device has no OTA endpoint to standard endpoint', async () => {
const device = zigbeeHerdsman.devices.SV01; const device = zigbeeHerdsman.devices.SV01;
const data = {imageType: 12382}; const data = {imageType: 12382};
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10, meta: {zclTransactionSequenceNumber: 10}}; const payload = {
data,
cluster: 'genOta',
device,
endpoint: device.getEndpoint(1),
type: 'commandQueryNextImageRequest',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 10},
};
logger.error.mockClear(); logger.error.mockClear();
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1); expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 152}, undefined, 10); expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 152}, undefined, 10);
}); });
it('Legacy api: Should OTA update a device', async () => { it('Legacy api: Should OTA update a device', async () => {
@ -367,9 +444,9 @@ describe('OTA update', () => {
let count = 0; let count = 0;
endpoint.read.mockImplementation(() => { endpoint.read.mockImplementation(() => {
count++; count++;
return {swBuildId: count, dateCode: '2019010' + count} return {swBuildId: count, dateCode: '2019010' + count};
}); });
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
logger.info.mockClear(); logger.info.mockClear();
logger.error.mockClear(); logger.error.mockClear();
@ -389,18 +466,22 @@ describe('OTA update', () => {
expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 0.00%`); expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 0.00%`);
expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`); expect(logger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`);
expect(logger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`); expect(logger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`);
expect(logger.info).toHaveBeenCalledWith(`Device 'bulb' was updated from '{"dateCode":"20190101","softwareBuildID":1}' to '{"dateCode":"20190102","softwareBuildID":2}'`); expect(logger.info).toHaveBeenCalledWith(
`Device 'bulb' was updated from '{"dateCode":"20190101","softwareBuildID":1}' to '{"dateCode":"20190102","softwareBuildID":2}'`,
);
expect(logger.error).toHaveBeenCalledTimes(0); expect(logger.error).toHaveBeenCalledTimes(0);
expect(device.save).toHaveBeenCalledTimes(2); expect(device.save).toHaveBeenCalledTimes(2);
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {'sendPolicy': 'immediate'}); expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: 'immediate'});
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {'sendPolicy': undefined}); expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: undefined});
}); });
it('Legacy api: Should handle when OTA update fails', async () => { it('Legacy api: Should handle when OTA update fails', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.endpoints[0]; const endpoint = device.endpoints[0];
endpoint.read.mockImplementation(() => {return {swBuildId: 1, dateCode: '2019010'}}); endpoint.read.mockImplementation(() => {
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) return {swBuildId: 1, dateCode: '2019010'};
});
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
logger.info.mockClear(); logger.info.mockClear();
logger.error.mockClear(); logger.error.mockClear();
@ -417,7 +498,7 @@ describe('OTA update', () => {
it('Legacy api: Should be able to check if OTA update is available', async () => { it('Legacy api: Should be able to check if OTA update is available', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
logger.info.mockClear(); logger.info.mockClear();
@ -439,10 +520,12 @@ describe('OTA update', () => {
it('Legacy api: Should handle if OTA update check fails', async () => { it('Legacy api: Should handle if OTA update check fails', async () => {
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
logger.error.mockClear(); logger.error.mockClear();
mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {throw new Error('RF signals disturbed because of dogs barking')}); mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {
throw new Error('RF signals disturbed because of dogs barking');
});
MQTT.events.message('zigbee2mqtt/bridge/ota_update/check', 'bulb'); MQTT.events.message('zigbee2mqtt/bridge/ota_update/check', 'bulb');
await flushPromises(); await flushPromises();
@ -462,12 +545,12 @@ describe('OTA update', () => {
const endpoint = device.endpoints[0]; const endpoint = device.endpoints[0];
let count = 0; let count = 0;
endpoint.read.mockImplementation(() => { endpoint.read.mockImplementation(() => {
if (count === 1) throw new Error('Failed!') if (count === 1) throw new Error('Failed!');
count++; count++;
return {swBuildId: 1, dateCode: '2019010'} return {swBuildId: 1, dateCode: '2019010'};
}); });
const mapped = await zigbeeHerdsmanConverters.findByDevice(device) const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped); mockClear(mapped);
logger.info.mockClear(); logger.info.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/ota_update/update', 'bulb'); MQTT.events.message('zigbee2mqtt/bridge/ota_update/update', 'bulb');
@ -480,7 +563,7 @@ describe('OTA update', () => {
await resetExtension(); await resetExtension();
expect(spyUseIndexOverride).toHaveBeenCalledWith(path.join(data.mockDir, 'local.index.json')); expect(spyUseIndexOverride).toHaveBeenCalledWith(path.join(data.mockDir, 'local.index.json'));
spyUseIndexOverride.mockClear(); spyUseIndexOverride.mockClear();
settings.set(['ota', 'zigbee_ota_override_index_location'], 'http://my.site/index.json'); settings.set(['ota', 'zigbee_ota_override_index_location'], 'http://my.site/index.json');
await resetExtension(); await resetExtension();
expect(spyUseIndexOverride).toHaveBeenCalledWith('http://my.site/index.json'); expect(spyUseIndexOverride).toHaveBeenCalledWith('http://my.site/index.json');
@ -489,7 +572,7 @@ describe('OTA update', () => {
it('Clear update state on startup', async () => { it('Clear update state on startup', async () => {
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr); const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color.ieeeAddr);
controller.state.set(device, {update: {progress: 100, remaining: 10, state: 'updating'}}) controller.state.set(device, {update: {progress: 100, remaining: 10, state: 'updating'}});
await resetExtension(); await resetExtension();
expect(controller.state.get(device)).toStrictEqual({update: {state: 'available'}}); expect(controller.state.get(device)).toStrictEqual({update: {state: 'available'}});
}); });

File diff suppressed because it is too large Load Diff

View File

@ -38,72 +38,108 @@ describe('Receive', () => {
it('Should handle a zigbee message', async () => { it('Should handle a zigbee message', async () => {
const device = zigbeeHerdsman.devices.WXKG11LM; const device = zigbeeHerdsman.devices.WXKG11LM;
device.linkquality = 10; device.linkquality = 10;
const data = {onOff: 1} const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/button', stringify({action: 'single', click: 'single', linkquality: 10}), {retain: false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/button',
stringify({action: 'single', click: 'single', linkquality: 10}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Should handle a zigbee message which uses ep (left)', async () => { it('Should handle a zigbee message which uses ep (left)', async () => {
const device = zigbeeHerdsman.devices.WXKG02LM_rev1; const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
const data = {onOff: 1} const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'left', action: 'single_left'}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'left', action: 'single_left'});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should handle a zigbee message which uses ep (right)', async () => { it('Should handle a zigbee message which uses ep (right)', async () => {
const device = zigbeeHerdsman.devices.WXKG02LM_rev1; const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
const data = {onOff: 1} const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(2), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(2), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'right', action: 'single_right'}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'right', action: 'single_right'});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should handle a zigbee message with default precision', async () => { it('Should handle a zigbee message with default precision', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
const data = {measuredValue: -85} const data = {measuredValue: -85};
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.85}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.85});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
}); });
it('Should allow to invert cover', async () => { it('Should allow to invert cover', async () => {
const device = zigbeeHerdsman.devices.J1; const device = zigbeeHerdsman.devices.J1;
// Non-inverted (open = 100, close = 0) // Non-inverted (open = 100, close = 0)
await zigbeeHerdsman.events.message({data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80}, cluster: 'closuresWindowCovering', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}); await zigbeeHerdsman.events.message({
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
cluster: 'closuresWindowCovering',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 10, tilt: 20, state: 'OPEN'}), {retain: false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/J1_cover',
stringify({position: 10, tilt: 20, state: 'OPEN'}),
{retain: false, qos: 0},
expect.any(Function),
);
// Inverted // Inverted
MQTT.publish.mockClear(); MQTT.publish.mockClear();
settings.set(['devices', device.ieeeAddr, 'invert_cover'], true); settings.set(['devices', device.ieeeAddr, 'invert_cover'], true);
await zigbeeHerdsman.events.message({data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80}, cluster: 'closuresWindowCovering', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}); await zigbeeHerdsman.events.message({
data: {currentPositionLiftPercentage: 90, currentPositionTiltPercentage: 80},
cluster: 'closuresWindowCovering',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/J1_cover', stringify({position: 90, tilt: 80, state: 'OPEN'}), {retain: false, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/J1_cover',
stringify({position: 90, tilt: 80, state: 'OPEN'}),
{retain: false, qos: 0},
expect.any(Function),
);
}); });
it('Should allow to disable the legacy integration', async () => { it('Should allow to disable the legacy integration', async () => {
const device = zigbeeHerdsman.devices.WXKG11LM; const device = zigbeeHerdsman.devices.WXKG11LM;
settings.set(['devices', device.ieeeAddr, 'legacy'], false); settings.set(['devices', device.ieeeAddr, 'legacy'], false);
const data = {onOff: 1} const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
@ -114,14 +150,35 @@ describe('Receive', () => {
it('Should debounce messages', async () => { it('Should debounce messages', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1); settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
const data1 = {measuredValue: 8} const data1 = {measuredValue: 8};
const payload1 = {data: data1, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload1); await zigbeeHerdsman.events.message(payload1);
const data2 = {measuredValue: 1} const data2 = {measuredValue: 1};
const payload2 = {data: data2, cluster: 'msRelativeHumidity', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload2 = {
data: data2,
cluster: 'msRelativeHumidity',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload2); await zigbeeHerdsman.events.message(payload2);
const data3 = {measuredValue: 2} const data3 = {measuredValue: 2};
const payload3 = {data: data3, cluster: 'msPressureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload3 = {
data: data3,
cluster: 'msPressureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload3); await zigbeeHerdsman.events.message(payload3);
await flushPromises(); await flushPromises();
jest.advanceTimersByTime(50); jest.advanceTimersByTime(50);
@ -131,7 +188,7 @@ describe('Receive', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
}); });
it('Should debounce and retain messages when set via device_options', async () => { it('Should debounce and retain messages when set via device_options', async () => {
@ -139,14 +196,35 @@ describe('Receive', () => {
settings.set(['device_options', 'debounce'], 0.1); settings.set(['device_options', 'debounce'], 0.1);
settings.set(['device_options', 'retain'], true); settings.set(['device_options', 'retain'], true);
delete settings.get().devices['0x0017880104e45522']['retain']; delete settings.get().devices['0x0017880104e45522']['retain'];
const data1 = {measuredValue: 8} const data1 = {measuredValue: 8};
const payload1 = {data: data1, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload1); await zigbeeHerdsman.events.message(payload1);
const data2 = {measuredValue: 1} const data2 = {measuredValue: 1};
const payload2 = {data: data2, cluster: 'msRelativeHumidity', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload2 = {
data: data2,
cluster: 'msRelativeHumidity',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload2); await zigbeeHerdsman.events.message(payload2);
const data3 = {measuredValue: 2} const data3 = {measuredValue: 2};
const payload3 = {data: data3, cluster: 'msPressureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload3 = {
data: data3,
cluster: 'msPressureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload3); await zigbeeHerdsman.events.message(payload3);
await flushPromises(); await flushPromises();
jest.advanceTimersByTime(50); jest.advanceTimersByTime(50);
@ -156,20 +234,48 @@ describe('Receive', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": true}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: true});
}); });
it('Should debounce messages only with the same payload values for provided debounce_ignore keys', async () => { it('Should debounce messages only with the same payload values for provided debounce_ignore keys', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1); settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
settings.set(['devices', device.ieeeAddr, 'debounce_ignore'], ['temperature']); settings.set(['devices', device.ieeeAddr, 'debounce_ignore'], ['temperature']);
const tempMsg = {data: {measuredValue: 8}, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 13}; const tempMsg = {
data: {measuredValue: 8},
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 13,
};
await zigbeeHerdsman.events.message(tempMsg); await zigbeeHerdsman.events.message(tempMsg);
const pressureMsg = {data: {measuredValue: 2}, cluster: 'msPressureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 13}; const pressureMsg = {
data: {measuredValue: 2},
cluster: 'msPressureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 13,
};
await zigbeeHerdsman.events.message(pressureMsg); await zigbeeHerdsman.events.message(pressureMsg);
const tempMsg2 = {data: {measuredValue: 7}, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 13}; const tempMsg2 = {
data: {measuredValue: 7},
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 13,
};
await zigbeeHerdsman.events.message(tempMsg2); await zigbeeHerdsman.events.message(tempMsg2);
const humidityMsg = {data: {measuredValue: 3}, cluster: 'msRelativeHumidity', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 13}; const humidityMsg = {
data: {measuredValue: 3},
cluster: 'msRelativeHumidity',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 13,
};
await zigbeeHerdsman.events.message(humidityMsg); await zigbeeHerdsman.events.message(humidityMsg);
await flushPromises(); await flushPromises();
jest.advanceTimersByTime(50); jest.advanceTimersByTime(50);
@ -182,16 +288,36 @@ describe('Receive', () => {
}); });
it('Should NOT publish old messages from State cache during debouncing', async () => { it('Should NOT publish old messages from State cache during debouncing', async () => {
// Summary: // Summary:
// First send multiple measurements to device that is debouncing. Make sure only one message is sent out to MQTT. This also ensures first message is cached to "State". // First send multiple measurements to device that is debouncing. Make sure only one message is sent out to MQTT. This also ensures first message is cached to "State".
// Then send another measurement to that same device and trigger asynchronous event to push data from Cache. Newest value should be sent out. // Then send another measurement to that same device and trigger asynchronous event to push data from Cache. Newest value should be sent out.
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1); settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
await zigbeeHerdsman.events.message({data: {measuredValue: 8}, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}); await zigbeeHerdsman.events.message({
await zigbeeHerdsman.events.message( {data: {measuredValue: 1}, cluster: 'msRelativeHumidity', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10} ); data: {measuredValue: 8},
await zigbeeHerdsman.events.message( {data: {measuredValue: 2}, cluster: 'msPressureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10} ); cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await zigbeeHerdsman.events.message({
data: {measuredValue: 1},
cluster: 'msRelativeHumidity',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await zigbeeHerdsman.events.message({
data: {measuredValue: 2},
cluster: 'msPressureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await flushPromises(); await flushPromises();
jest.advanceTimersByTime(50); jest.advanceTimersByTime(50);
// Test that measurements are combined(=debounced) // Test that measurements are combined(=debounced)
@ -205,11 +331,18 @@ describe('Receive', () => {
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
// Send another Zigbee message... // Send another Zigbee message...
await zigbeeHerdsman.events.message({data: {measuredValue: 9}, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}); await zigbeeHerdsman.events.message({
data: {measuredValue: 9},
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
const realDevice = controller.zigbee.resolveEntity(device); const realDevice = controller.zigbee.resolveEntity(device);
// Trigger asynchronous event while device is "debouncing" to trigger Message to be sent out from State cache. // Trigger asynchronous event while device is "debouncing" to trigger Message to be sent out from State cache.
await controller.publishEntityState( realDevice, {} ); await controller.publishEntityState(realDevice, {});
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await flushPromises(); await flushPromises();
@ -220,13 +353,20 @@ describe('Receive', () => {
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
// Test that messages after debouncing contains NEW measurement and not old. // Test that messages after debouncing contains NEW measurement and not old.
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2}); expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
}); });
it('Shouldnt republish old state', async () => { it('Shouldnt republish old state', async () => {
// https://github.com/Koenkk/zigbee2mqtt/issues/3572 // https://github.com/Koenkk/zigbee2mqtt/issues/3572
const device = zigbeeHerdsman.devices.bulb; const device = zigbeeHerdsman.devices.bulb;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1); settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
await zigbeeHerdsman.events.message({data: {onOff: 0}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}); await zigbeeHerdsman.events.message({
data: {onOff: 0},
cluster: 'genOnOff',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
});
await MQTT.events.message('zigbee2mqtt/bulb/set', stringify({state: 'ON'})); await MQTT.events.message('zigbee2mqtt/bulb/set', stringify({state: 'ON'}));
await flushPromises(); await flushPromises();
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
@ -238,83 +378,118 @@ describe('Receive', () => {
it('Should handle a zigbee message with 1 precision', async () => { it('Should handle a zigbee message with 1 precision', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 1); settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 1);
const data = {measuredValue: -85} const data = {measuredValue: -85};
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.8}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
}); });
it('Should handle a zigbee message with 0 precision', async () => { it('Should handle a zigbee message with 0 precision', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0); settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
const data = {measuredValue: -85} const data = {measuredValue: -85};
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -1}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -1});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
}); });
it('Should handle a zigbee message with 1 precision when set via device_options', async () => { it('Should handle a zigbee message with 1 precision when set via device_options', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['device_options', 'temperature_precision'], 1); settings.set(['device_options', 'temperature_precision'], 1);
const data = {measuredValue: -85} const data = {measuredValue: -85};
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.8}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.8});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
}); });
it('Should handle a zigbee message with 2 precision when overrides device_options', async () => { it('Should handle a zigbee message with 2 precision when overrides device_options', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM; const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['device_options', 'temperature_precision'], 1); settings.set(['device_options', 'temperature_precision'], 1);
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0); settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
const data = {measuredValue: -85} const data = {measuredValue: -85};
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -1}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -1});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
}); });
it('Should handle a zigbee message with voltage 2990', async () => { it('Should handle a zigbee message with voltage 2990', async () => {
const device = zigbeeHerdsman.devices.WXKG02LM_rev1; const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
const data = {'65281': {'1': 2990}} const data = {65281: {1: 2990}};
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({battery: 93, voltage: 2990}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({battery: 93, voltage: 2990});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should publish 1 message when converted twice', async () => { it('Should publish 1 message when converted twice', async () => {
const device = zigbeeHerdsman.devices.RTCGQ11LM; const device = zigbeeHerdsman.devices.RTCGQ11LM;
const data = {'65281': {'1': 3045, '3': 19, '5': 35, '6': [0, 3], '11': 381, '100': 0}} const data = {65281: {1: 3045, 3: 19, 5: 35, 6: [0, 3], 11: 381, 100: 0}};
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/occupancy_sensor'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/occupancy_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({'battery': 100, 'illuminance': 381, "illuminance_lux": 381, 'voltage': 3045, 'device_temperature': 19, 'power_outage_count': 34}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); battery: 100,
illuminance: 381,
illuminance_lux: 381,
voltage: 3045,
device_temperature: 19,
power_outage_count: 34,
});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should publish 1 message when converted twice', async () => { it('Should publish 1 message when converted twice', async () => {
const device = zigbeeHerdsman.devices.RTCGQ11LM; const device = zigbeeHerdsman.devices.RTCGQ11LM;
const data = {'9999': {'1': 3045}}; const data = {9999: {1: 3045}};
const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'genBasic', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
@ -330,8 +505,8 @@ describe('Receive', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('number') expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('number');
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should publish last_seen ISO_8601', async () => { it('Should publish last_seen ISO_8601', async () => {
@ -343,8 +518,8 @@ describe('Receive', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('string') expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('string');
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should publish last_seen ISO_8601_local', async () => { it('Should publish last_seen ISO_8601_local', async () => {
@ -356,8 +531,8 @@ describe('Receive', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/button_double_key');
expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('string') expect(typeof JSON.parse(MQTT.publish.mock.calls[0][1]).last_seen).toBe('string');
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should handle messages from Xiaomi router devices', async () => { it('Should handle messages from Xiaomi router devices', async () => {
@ -367,19 +542,13 @@ describe('Receive', () => {
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/power_plug', stringify({state: 'ON'}), {retain: false, qos: 0}, expect.any(Function));
'zigbee2mqtt/power_plug',
stringify({state: 'ON'}),
{ retain: false, qos: 0 },
expect.any(Function)
);
expect(MQTT.publish).toHaveBeenCalledWith( expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/switch_group', 'zigbee2mqtt/switch_group',
stringify({'state': 'ON'}), stringify({state: 'ON'}),
{ retain: false, qos: 0 }, {retain: false, qos: 0},
expect.any(Function) expect.any(Function),
); );
}); });
it('Should not handle messages from coordinator', async () => { it('Should not handle messages from coordinator', async () => {
@ -405,13 +574,21 @@ describe('Receive', () => {
it('Should handle a command', async () => { it('Should handle a command', async () => {
const device = zigbeeHerdsman.devices.E1743; const device = zigbeeHerdsman.devices.E1743;
const data = {}; const data = {};
const payload = {data, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff', linkquality: 10, meta: {zclTransactionSequenceNumber: 1}}; const payload = {
data,
cluster: 'genLevelCtrl',
device,
endpoint: device.getEndpoint(1),
type: 'commandStopWithOnOff',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 1},
};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({'click': 'brightness_stop', action: 'brightness_stop'}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'brightness_stop', action: 'brightness_stop'});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
}); });
it('Should add elapsed', async () => { it('Should add elapsed', async () => {
@ -419,7 +596,7 @@ describe('Receive', () => {
const device = zigbeeHerdsman.devices.E1743; const device = zigbeeHerdsman.devices.E1743;
const payload = {data: {}, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff'}; const payload = {data: {}, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff'};
const oldNow = Date.now; const oldNow = Date.now;
Date.now = jest.fn() Date.now = jest.fn();
Date.now.mockReturnValue(new Date(150)); Date.now.mockReturnValue(new Date(150));
await zigbeeHerdsman.events.message({...payload, meta: {zclTransactionSequenceNumber: 2}}); await zigbeeHerdsman.events.message({...payload, meta: {zclTransactionSequenceNumber: 2}});
await flushPromises(); await flushPromises();
@ -428,12 +605,12 @@ describe('Receive', () => {
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2); expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({'click': 'brightness_stop', action: 'brightness_stop'}); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({click: 'brightness_stop', action: 'brightness_stop'});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/ikea_onoff'); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/ikea_onoff');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toMatchObject({'click': 'brightness_stop', action: 'brightness_stop'}); expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toMatchObject({click: 'brightness_stop', action: 'brightness_stop'});
expect(JSON.parse(MQTT.publish.mock.calls[1][1]).elapsed).toBe(50); expect(JSON.parse(MQTT.publish.mock.calls[1][1]).elapsed).toBe(50);
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false}); expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({qos: 0, retain: false});
Date.now = oldNow; Date.now = oldNow;
}); });
@ -444,16 +621,26 @@ describe('Receive', () => {
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(0); expect(MQTT.publish).toHaveBeenCalledTimes(0);
expect(logger.debug).toHaveBeenCalledWith('No converter available for \'ZNCZ02LM\' with cluster \'genBinaryOutput\' and type \'attributeReport\' and data \'{"inactiveText":"hello"}\''); expect(logger.debug).toHaveBeenCalledWith(
"No converter available for 'ZNCZ02LM' with cluster 'genBinaryOutput' and type 'attributeReport' and data '{\"inactiveText\":\"hello\"}'",
);
}); });
it('Should report correct energy and power values for different versions of SP600', async () => { it('Should report correct energy and power values for different versions of SP600', async () => {
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/915, OLD and NEW use different date code // https://github.com/Koenkk/zigbee-herdsman-converters/issues/915, OLD and NEW use different date code
// divisor of OLD is not correct and therefore underreports by factor 10. // divisor of OLD is not correct and therefore underreports by factor 10.
const data = {instantaneousDemand:496,currentSummDelivered:[0,6648]} const data = {instantaneousDemand: 496, currentSummDelivered: [0, 6648]};
const SP600_NEW = zigbeeHerdsman.devices.SP600_NEW; const SP600_NEW = zigbeeHerdsman.devices.SP600_NEW;
await zigbeeHerdsman.events.message({data, cluster: 'seMetering', device: SP600_NEW, endpoint: SP600_NEW.getEndpoint(1), type: 'attributeReport', linkquality: 10, meta: {zclTransactionSequenceNumber: 1}}); await zigbeeHerdsman.events.message({
data,
cluster: 'seMetering',
device: SP600_NEW,
endpoint: SP600_NEW.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 1},
});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_NEW'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_NEW');
@ -461,7 +648,15 @@ describe('Receive', () => {
MQTT.publish.mockClear(); MQTT.publish.mockClear();
const SP600_OLD = zigbeeHerdsman.devices.SP600_OLD; const SP600_OLD = zigbeeHerdsman.devices.SP600_OLD;
await zigbeeHerdsman.events.message({data, cluster: 'seMetering', device: SP600_OLD, endpoint: SP600_OLD.getEndpoint(1), type: 'attributeReport', linkquality: 10, meta: {zclTransactionSequenceNumber: 2}}); await zigbeeHerdsman.events.message({
data,
cluster: 'seMetering',
device: SP600_OLD,
endpoint: SP600_OLD.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
meta: {zclTransactionSequenceNumber: 2},
});
await flushPromises(); await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_OLD'); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_OLD');
@ -470,9 +665,9 @@ describe('Receive', () => {
it('Should emit DevicesChanged event when a converter announces changed exposes', async () => { it('Should emit DevicesChanged event when a converter announces changed exposes', async () => {
const device = zigbeeHerdsman.devices['BMCT-SLZ']; const device = zigbeeHerdsman.devices['BMCT-SLZ'];
const data = {deviceMode: 0} const data = {deviceMode: 0};
const payload = {data, cluster: 'boschSpecific', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; const payload = {data, cluster: 'boschSpecific', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload); await zigbeeHerdsman.events.message(payload);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/bridge/devices"); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices');
}); });
}); });

View File

@ -19,8 +19,8 @@ const minimalConfig = {
}; };
describe('Settings', () => { describe('Settings', () => {
const write = (file, json, reread=true) => { const write = (file, json, reread = true) => {
fs.writeFileSync(file, yaml.dump(json)) fs.writeFileSync(file, yaml.dump(json));
if (reread) { if (reread) {
settings.reRead(); settings.reRead();
} }
@ -28,14 +28,14 @@ describe('Settings', () => {
const read = (file) => yaml.load(fs.readFileSync(file, 'utf8')); const read = (file) => yaml.load(fs.readFileSync(file, 'utf8'));
const remove = (file) => { const remove = (file) => {
if (fs.existsSync(file)) fs.unlinkSync(file); if (fs.existsSync(file)) fs.unlinkSync(file);
} };
const clearEnvironmentVariables = () => { const clearEnvironmentVariables = () => {
Object.keys(process.env).forEach((key) => { Object.keys(process.env).forEach((key) => {
if(key.indexOf('ZIGBEE2MQTT_CONFIG_') >= 0) { if (key.indexOf('ZIGBEE2MQTT_CONFIG_') >= 0) {
delete process.env[key]; delete process.env[key];
} }
}); });
} };
beforeEach(() => { beforeEach(() => {
remove(configurationFile); remove(configurationFile);
@ -69,7 +69,8 @@ describe('Settings', () => {
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_SOFT_RESET_TIMEOUT'] = 1; process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_SOFT_RESET_TIMEOUT'] = 1;
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_OUTPUT'] = 'attribute_and_json'; process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_OUTPUT'] = 'attribute_and_json';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_OUTPUT'] = '["console"]'; process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_LOG_OUTPUT'] = '["console"]';
process.env['ZIGBEE2MQTT_CONFIG_MAP_OPTIONS_GRAPHVIZ_COLORS_FILL'] = '{"enddevice": "#ff0000", "coordinator": "#00ff00", "router": "#0000ff"}'; process.env['ZIGBEE2MQTT_CONFIG_MAP_OPTIONS_GRAPHVIZ_COLORS_FILL'] =
'{"enddevice": "#ff0000", "coordinator": "#00ff00", "router": "#0000ff"}';
process.env['ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC'] = 'testtopic'; process.env['ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC'] = 'testtopic';
process.env['ZIGBEE2MQTT_CONFIG_MQTT_SERVER'] = 'testserver'; process.env['ZIGBEE2MQTT_CONFIG_MQTT_SERVER'] = 'testserver';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY'] = 'GENERATE'; process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY'] = 'GENERATE';
@ -97,7 +98,7 @@ describe('Settings', () => {
expected.groups = {}; expected.groups = {};
expected.serial.disable_led = true; expected.serial.disable_led = true;
expected.advanced.soft_reset_timeout = 1; expected.advanced.soft_reset_timeout = 1;
expected.advanced.log_output = ["console"]; expected.advanced.log_output = ['console'];
expected.advanced.output = 'attribute_and_json'; expected.advanced.output = 'attribute_and_json';
expected.map_options.graphviz.colors.fill = {enddevice: '#ff0000', coordinator: '#00ff00', router: '#0000ff'}; expected.map_options.graphviz.colors.fill = {enddevice: '#ff0000', coordinator: '#00ff00', router: '#0000ff'};
expected.mqtt.base_topic = 'testtopic'; expected.mqtt.base_topic = 'testtopic';
@ -153,7 +154,7 @@ describe('Settings', () => {
const device = settings.getDevice('0x12345678'); const device = settings.getDevice('0x12345678');
const expected = { const expected = {
ID: "0x12345678", ID: '0x12345678',
friendly_name: '0x12345678', friendly_name: '0x12345678',
retain: false, retain: false,
}; };
@ -175,14 +176,14 @@ describe('Settings', () => {
password: '!secret password', password: '!secret password',
}, },
advanced: { advanced: {
network_key: '!secret network_key' network_key: '!secret network_key',
} },
}; };
const contentSecret = { const contentSecret = {
username: 'mysecretusername', username: 'mysecretusername',
password: 'mysecretpassword', password: 'mysecretpassword',
network_key: [1,2,3], network_key: [1, 2, 3],
}; };
write(secretFile, contentSecret, false); write(secretFile, contentSecret, false);
@ -192,22 +193,22 @@ describe('Settings', () => {
base_topic: 'zigbee2mqtt', base_topic: 'zigbee2mqtt',
include_device_information: false, include_device_information: false,
force_disable_retain: false, force_disable_retain: false,
password: "mysecretpassword", password: 'mysecretpassword',
server: "my.mqtt.server", server: 'my.mqtt.server',
user: "mysecretusername", user: 'mysecretusername',
}; };
expect(settings.get().mqtt).toStrictEqual(expected); expect(settings.get().mqtt).toStrictEqual(expected);
expect(settings.get().advanced.network_key).toStrictEqual([1,2,3]); expect(settings.get().advanced.network_key).toStrictEqual([1, 2, 3]);
settings.testing.write(); settings.testing.write();
expect(read(configurationFile)).toStrictEqual(contentConfiguration); expect(read(configurationFile)).toStrictEqual(contentConfiguration);
expect(read(secretFile)).toStrictEqual(contentSecret); expect(read(secretFile)).toStrictEqual(contentSecret);
settings.set(['mqtt', 'user'], 'test123'); settings.set(['mqtt', 'user'], 'test123');
settings.set(['advanced', 'network_key'], [1,2,3, 4]); settings.set(['advanced', 'network_key'], [1, 2, 3, 4]);
expect(read(configurationFile)).toStrictEqual(contentConfiguration); expect(read(configurationFile)).toStrictEqual(contentConfiguration);
expect(read(secretFile)).toStrictEqual({...contentSecret, username: 'test123', network_key: [1,2,3,4]}); expect(read(secretFile)).toStrictEqual({...contentSecret, username: 'test123', network_key: [1, 2, 3, 4]});
}); });
it('Should read ALL secrets form a separate file', () => { it('Should read ALL secrets form a separate file', () => {
@ -219,14 +220,14 @@ describe('Settings', () => {
}, },
advanced: { advanced: {
network_key: '!secret network_key', network_key: '!secret network_key',
} },
}; };
const contentSecret = { const contentSecret = {
server: 'my.mqtt.server', server: 'my.mqtt.server',
username: 'mysecretusername', username: 'mysecretusername',
password: 'mysecretpassword', password: 'mysecretpassword',
network_key: [1,2,3], network_key: [1, 2, 3],
}; };
write(secretFile, contentSecret, false); write(secretFile, contentSecret, false);
@ -236,13 +237,13 @@ describe('Settings', () => {
base_topic: 'zigbee2mqtt', base_topic: 'zigbee2mqtt',
include_device_information: false, include_device_information: false,
force_disable_retain: false, force_disable_retain: false,
password: "mysecretpassword", password: 'mysecretpassword',
server: "my.mqtt.server", server: 'my.mqtt.server',
user: "mysecretusername", user: 'mysecretusername',
}; };
expect(settings.get().mqtt).toStrictEqual(expected); expect(settings.get().mqtt).toStrictEqual(expected);
expect(settings.get().advanced.network_key).toStrictEqual([1,2,3]); expect(settings.get().advanced.network_key).toStrictEqual([1, 2, 3]);
settings.testing.write(); settings.testing.write();
expect(read(configurationFile)).toStrictEqual(contentConfiguration); expect(read(configurationFile)).toStrictEqual(contentConfiguration);
@ -269,7 +270,7 @@ describe('Settings', () => {
write(devicesFile, contentDevices); write(devicesFile, contentDevices);
const device = settings.getDevice('0x12345678'); const device = settings.getDevice('0x12345678');
const expected = { const expected = {
ID: "0x12345678", ID: '0x12345678',
friendly_name: '0x12345678', friendly_name: '0x12345678',
retain: false, retain: false,
}; };
@ -279,7 +280,7 @@ describe('Settings', () => {
it('Should read devices form 2 separate files', () => { it('Should read devices form 2 separate files', () => {
const contentConfiguration = { const contentConfiguration = {
devices: ['devices.yaml', 'devices2.yaml'] devices: ['devices.yaml', 'devices2.yaml'],
}; };
const contentDevices = { const contentDevices = {
@ -343,7 +344,7 @@ describe('Settings', () => {
const contentDevices = { const contentDevices = {
'0x12345678': { '0x12345678': {
friendly_name: '0x12345678', friendly_name: '0x12345678',
retain: false, retain: false,
}, },
}; };
@ -358,9 +359,9 @@ describe('Settings', () => {
const expected = { const expected = {
'0x12345678': { '0x12345678': {
friendly_name: '0x12345678', friendly_name: '0x12345678',
retain: false, retain: false,
}, },
'0x1234': { '0x1234': {
friendly_name: '0x1234', friendly_name: '0x1234',
}, },
}; };
@ -373,13 +374,13 @@ describe('Settings', () => {
extractFromMultipleDeviceConfigs({ extractFromMultipleDeviceConfigs({
'0x87654321': { '0x87654321': {
friendly_name: '0x87654321', friendly_name: '0x87654321',
retain: false, retain: false,
}, },
}); });
}); });
it('Should add devices for first file when using 2 separates file and the second file is empty', () => { it('Should add devices for first file when using 2 separates file and the second file is empty', () => {
extractFromMultipleDeviceConfigs(null) extractFromMultipleDeviceConfigs(null);
}); });
it('Should add devices to a separate file if devices.yaml doesnt exist', () => { it('Should add devices to a separate file if devices.yaml doesnt exist', () => {
@ -400,8 +401,7 @@ describe('Settings', () => {
}; };
expect(read(devicesFile)).toStrictEqual(expected); expect(read(devicesFile)).toStrictEqual(expected);
} });
);
it('Should add and remove devices to a separate file if devices.yaml doesnt exist', () => { it('Should add and remove devices to a separate file if devices.yaml doesnt exist', () => {
const contentConfiguration = { const contentConfiguration = {
@ -417,13 +417,12 @@ describe('Settings', () => {
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'}); expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
expect(read(devicesFile)).toStrictEqual({}); expect(read(devicesFile)).toStrictEqual({});
} });
);
it('Should read groups', () => { it('Should read groups', () => {
const content = { const content = {
groups: { groups: {
'1': { 1: {
friendly_name: '123', friendly_name: '123',
}, },
}, },
@ -447,7 +446,7 @@ describe('Settings', () => {
}; };
const contentGroups = { const contentGroups = {
'1': { 1: {
friendly_name: '123', friendly_name: '123',
}, },
}; };
@ -472,7 +471,7 @@ describe('Settings', () => {
}; };
const contentGroups = { const contentGroups = {
'1': { 1: {
friendly_name: '123', friendly_name: '123',
devices: [], devices: [],
}, },
@ -523,7 +522,7 @@ describe('Settings', () => {
const added = settings.addGroup('test123'); const added = settings.addGroup('test123');
const expected = { const expected = {
'1': { 1: {
friendly_name: 'test123', friendly_name: 'test123',
}, },
}; };
@ -536,7 +535,7 @@ describe('Settings', () => {
const added = settings.addGroup('test123', 123); const added = settings.addGroup('test123', 123);
const expected = { const expected = {
'123': { 123: {
friendly_name: 'test123', friendly_name: 'test123',
}, },
}; };
@ -560,7 +559,7 @@ describe('Settings', () => {
settings.addGroup('test123'); settings.addGroup('test123');
}).toThrow(new Error("friendly_name 'test123' is already in use")); }).toThrow(new Error("friendly_name 'test123' is already in use"));
const expected = { const expected = {
'1': { 1: {
friendly_name: 'test123', friendly_name: 'test123',
}, },
}; };
@ -576,7 +575,7 @@ describe('Settings', () => {
settings.addGroup('test_id_123', 123); settings.addGroup('test_id_123', 123);
}).toThrow(new Error("Group ID '123' is already in use")); }).toThrow(new Error("Group ID '123' is already in use"));
const expected = { const expected = {
'123': { 123: {
friendly_name: 'test123', friendly_name: 'test123',
}, },
}; };
@ -590,14 +589,14 @@ describe('Settings', () => {
'0x123': { '0x123': {
friendly_name: 'bulb', friendly_name: 'bulb',
retain: true, retain: true,
} },
} },
}); });
settings.addGroup('test123'); settings.addGroup('test123');
settings.addDeviceToGroup('test123', ['0x123']); settings.addDeviceToGroup('test123', ['0x123']);
const expected = { const expected = {
'1': { 1: {
friendly_name: 'test123', friendly_name: 'test123',
devices: ['0x123'], devices: ['0x123'],
}, },
@ -612,19 +611,19 @@ describe('Settings', () => {
'0x123': { '0x123': {
friendly_name: 'bulb', friendly_name: 'bulb',
retain: true, retain: true,
} },
}, },
groups: { groups: {
'1': { 1: {
friendly_name: 'test123', friendly_name: 'test123',
devices: ['0x123'], devices: ['0x123'],
} },
} },
}); });
settings.removeDeviceFromGroup('test123', ['0x123']); settings.removeDeviceFromGroup('test123', ['0x123']);
const expected = { const expected = {
'1': { 1: {
friendly_name: 'test123', friendly_name: 'test123',
devices: [], devices: [],
}, },
@ -639,18 +638,18 @@ describe('Settings', () => {
'0x123': { '0x123': {
friendly_name: 'bulb', friendly_name: 'bulb',
retain: true, retain: true,
} },
}, },
groups: { groups: {
'1': { 1: {
friendly_name: 'test123', friendly_name: 'test123',
} },
} },
}); });
settings.removeDeviceFromGroup('test123', ['0x123']); settings.removeDeviceFromGroup('test123', ['0x123']);
const expected = { const expected = {
'1': { 1: {
friendly_name: 'test123', friendly_name: 'test123',
}, },
}; };
@ -664,12 +663,12 @@ describe('Settings', () => {
'0x123': { '0x123': {
friendly_name: 'bulb', friendly_name: 'bulb',
retain: true, retain: true,
} },
}, },
}); });
expect(() => { expect(() => {
settings.removeDeviceFromGroup('test123', 'bulb') settings.removeDeviceFromGroup('test123', 'bulb');
}).toThrow(new Error("Group 'test123' does not exist")); }).toThrow(new Error("Group 'test123' does not exist"));
}); });
@ -679,12 +678,12 @@ describe('Settings', () => {
'0x123': { '0x123': {
friendly_name: 'bulb', friendly_name: 'bulb',
retain: true, retain: true,
} },
}, },
}); });
expect(() => { expect(() => {
settings.addDevice('0x123') settings.addDevice('0x123');
}).toThrow(new Error("Device '0x123' already exists")); }).toThrow(new Error("Device '0x123' already exists"));
}); });
@ -735,7 +734,6 @@ describe('Settings', () => {
expect(settings.validate()).toEqual([]); expect(settings.validate()).toEqual([]);
}); });
it('Should not allow retention configuration without MQTT v5', () => { it('Should not allow retention configuration without MQTT v5', () => {
write(configurationFile, { write(configurationFile, {
...minimalConfig, ...minimalConfig,
@ -782,10 +780,13 @@ describe('Settings', () => {
}); });
it('Should throw error when yaml file is invalid', () => { it('Should throw error when yaml file is invalid', () => {
fs.writeFileSync(configurationFile, ` fs.writeFileSync(
configurationFile,
`
good: 9 good: 9
\t wrong \t wrong
`) `,
);
settings.testing.clear(); settings.testing.clear();
const error = `Your YAML file: '${configurationFile}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`; const error = `Your YAML file: '${configurationFile}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`;
@ -813,7 +814,7 @@ describe('Settings', () => {
write(configurationFile, { write(configurationFile, {
...minimalConfig, ...minimalConfig,
devices: {'0x0017880104e45519': {friendly_name: 'myname', retain: false}}, devices: {'0x0017880104e45519': {friendly_name: 'myname', retain: false}},
groups: {'1': {friendly_name: 'myname', retain: false}}, groups: {1: {friendly_name: 'myname', retain: false}},
}); });
settings.reRead(); settings.reRead();
@ -886,7 +887,7 @@ describe('Settings', () => {
write(configurationFile, { write(configurationFile, {
devices: { devices: {
'0x0017880104e45519': {friendly_name: 'myname', retain: false}, '0x0017880104e45519': {friendly_name: 'myname', retain: false},
'0x0017880104e45511': {friendly_name: 'myname1', retain: false} '0x0017880104e45511': {friendly_name: 'myname1', retain: false},
}, },
}); });
@ -901,7 +902,7 @@ describe('Settings', () => {
write(configurationFile, { write(configurationFile, {
devices: { devices: {
'0x0017880104e45519': {friendly_name: 'myname', retain: false}, '0x0017880104e45519': {friendly_name: 'myname', retain: false},
'0x0017880104e45511': {friendly_name: 'myname1', retain: false} '0x0017880104e45511': {friendly_name: 'myname1', retain: false},
}, },
}); });
@ -925,48 +926,48 @@ describe('Settings', () => {
it('Should keep homeassistant null property on device setting change', () => { it('Should keep homeassistant null property on device setting change', () => {
write(configurationFile, { write(configurationFile, {
devices: { devices: {
'0x12345678': { '0x12345678': {
friendly_name: 'custom discovery', friendly_name: 'custom discovery',
homeassistant: { homeassistant: {
entityXYZ: { entityXYZ: {
entity_category: null, entity_category: null,
} },
} },
} },
} },
}); });
settings.changeEntityOptions('0x12345678',{disabled: true}); settings.changeEntityOptions('0x12345678', {disabled: true});
const actual = read(configurationFile); const actual = read(configurationFile);
const expected = { const expected = {
devices: { devices: {
'0x12345678': { '0x12345678': {
friendly_name: 'custom discovery', friendly_name: 'custom discovery',
disabled: true, disabled: true,
homeassistant: { homeassistant: {
entityXYZ: { entityXYZ: {
entity_category: null, entity_category: null,
} },
} },
},
}, },
}
}; };
expect(actual).toStrictEqual(expected); expect(actual).toStrictEqual(expected);
}); });
it('Should keep homeassistant null properties on apply', async () => { it('Should keep homeassistant null properties on apply', async () => {
write(configurationFile, { write(configurationFile, {
device_options: { device_options: {
homeassistant: {temperature: null}, homeassistant: {temperature: null},
}, },
devices: { devices: {
'0x1234567812345678': { '0x1234567812345678': {
friendly_name: 'custom discovery', friendly_name: 'custom discovery',
homeassistant: {humidity: null}, homeassistant: {humidity: null},
} },
} },
}); });
settings.reRead(); settings.reRead();
settings.apply({permit_join: false}); settings.apply({permit_join: false});
expect(settings.get().device_options.homeassistant).toStrictEqual({temperature: null}); expect(settings.get().device_options.homeassistant).toStrictEqual({temperature: null});
@ -974,86 +975,76 @@ describe('Settings', () => {
}); });
it('Frontend config', () => { it('Frontend config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, frontend: true});
frontend: true,
});
settings.reRead(); settings.reRead();
expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: false}) expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: false});
}); });
it('Baudrate config', () => { it('Baudrate config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, advanced: {baudrate: 20}});
advanced: {baudrate: 20},
});
settings.reRead(); settings.reRead();
expect(settings.get().serial.baudrate).toStrictEqual(20) expect(settings.get().serial.baudrate).toStrictEqual(20);
}); });
it('ikea_ota_use_test_url config', () => { it('ikea_ota_use_test_url config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, advanced: {ikea_ota_use_test_url: true}});
advanced: {ikea_ota_use_test_url: true},
});
settings.reRead(); settings.reRead();
expect(settings.get().ota.ikea_ota_use_test_url).toStrictEqual(true) expect(settings.get().ota.ikea_ota_use_test_url).toStrictEqual(true);
}); });
it('transmit_power config', () => { it('transmit_power config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, experimental: {transmit_power: 1337}});
experimental: {transmit_power: 1337},
});
settings.reRead(); settings.reRead();
expect(settings.get().advanced.transmit_power).toStrictEqual(1337) expect(settings.get().advanced.transmit_power).toStrictEqual(1337);
}); });
it('output config', () => { it('output config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, experimental: {output: 'json'}});
experimental: {output: 'json'},
});
settings.reRead(); settings.reRead();
expect(settings.get().advanced.output).toStrictEqual('json') expect(settings.get().advanced.output).toStrictEqual('json');
}); });
it('Baudrartsctste config', () => { it('Baudrartsctste config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, advanced: {rtscts: true}});
advanced: {rtscts: true},
});
settings.reRead(); settings.reRead();
expect(settings.get().serial.rtscts).toStrictEqual(true) expect(settings.get().serial.rtscts).toStrictEqual(true);
}); });
it('Deprecated: Home Assistant config', () => { it('Deprecated: Home Assistant config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {
...minimalConfig,
homeassistant: {discovery_topic: 'new'}, homeassistant: {discovery_topic: 'new'},
advanced: {homeassistant_discovery_topic: 'old', homeassistant_status_topic: 'olds'}, advanced: {homeassistant_discovery_topic: 'old', homeassistant_status_topic: 'olds'},
}); });
settings.reRead(); settings.reRead();
expect(settings.get().homeassistant).toStrictEqual({discovery_topic: 'new', legacy_entity_attributes: true, legacy_triggers: true, status_topic: 'olds'}) expect(settings.get().homeassistant).toStrictEqual({
discovery_topic: 'new',
legacy_entity_attributes: true,
legacy_triggers: true,
status_topic: 'olds',
});
}); });
it('Deprecated: ban/whitelist config', () => { it('Deprecated: ban/whitelist config', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, ban: ['ban'], whitelist: ['whitelist'], passlist: ['passlist'], blocklist: ['blocklist']});
ban: ['ban'], whitelist: ['whitelist'], passlist: ['passlist'], blocklist: ['blocklist']
});
settings.reRead(); settings.reRead();
expect(settings.get().blocklist).toStrictEqual(['blocklist', 'ban']) expect(settings.get().blocklist).toStrictEqual(['blocklist', 'ban']);
expect(settings.get().passlist).toStrictEqual(['passlist', 'whitelist']) expect(settings.get().passlist).toStrictEqual(['passlist', 'whitelist']);
}); });
it('Deprecated: warn log level', () => { it('Deprecated: warn log level', () => {
write(configurationFile, {...minimalConfig, write(configurationFile, {...minimalConfig, advanced: {log_level: 'warn'}});
advanced: {log_level: 'warn'}
});
settings.reRead(); settings.reRead();
expect(settings.get().advanced.log_level).toStrictEqual('warning') expect(settings.get().advanced.log_level).toStrictEqual('warning');
}) });
}); });

View File

@ -12,121 +12,121 @@ function writeDefaultConfiguration() {
homeassistant: false, homeassistant: false,
permit_join: true, permit_join: true,
mqtt: { mqtt: {
base_topic: "zigbee2mqtt", base_topic: 'zigbee2mqtt',
server: "mqtt://localhost", server: 'mqtt://localhost',
}, },
serial: { serial: {
"port": "/dev/dummy", port: '/dev/dummy',
}, },
devices: { devices: {
"0x000b57fffec6a5b2": { '0x000b57fffec6a5b2': {
retain: true, retain: true,
friendly_name: "bulb", friendly_name: 'bulb',
description: "this is my bulb", description: 'this is my bulb',
}, },
"0x0017880104e45517": { '0x0017880104e45517': {
retain: true, retain: true,
friendly_name: "remote" friendly_name: 'remote',
}, },
"0x0017880104e45520": { '0x0017880104e45520': {
retain: false, retain: false,
friendly_name: "button" friendly_name: 'button',
}, },
"0x0017880104e45521": { '0x0017880104e45521': {
retain: false, retain: false,
friendly_name: "button_double_key" friendly_name: 'button_double_key',
}, },
"0x0017880104e45522": { '0x0017880104e45522': {
qos: 1, qos: 1,
retain: false, retain: false,
friendly_name: "weather_sensor" friendly_name: 'weather_sensor',
}, },
"0x0017880104e45523": { '0x0017880104e45523': {
retain: false, retain: false,
friendly_name: "occupancy_sensor" friendly_name: 'occupancy_sensor',
}, },
"0x0017880104e45524": { '0x0017880104e45524': {
retain: false, retain: false,
friendly_name: "power_plug" friendly_name: 'power_plug',
}, },
"0x0017880104e45530": { '0x0017880104e45530': {
retain: false, retain: false,
friendly_name: "button_double_key_interviewing" friendly_name: 'button_double_key_interviewing',
}, },
"0x0017880104e45540": { '0x0017880104e45540': {
friendly_name: "ikea_onoff" friendly_name: 'ikea_onoff',
}, },
'0x000b57fffec6a5b7': { '0x000b57fffec6a5b7': {
retain: false, retain: false,
friendly_name: "bulb_2" friendly_name: 'bulb_2',
}, },
"0x000b57fffec6a5b3": { '0x000b57fffec6a5b3': {
retain: false, retain: false,
friendly_name: "bulb_color" friendly_name: 'bulb_color',
}, },
'0x000b57fffec6a5b4': { '0x000b57fffec6a5b4': {
retain: false, retain: false,
friendly_name: "bulb_color_2" friendly_name: 'bulb_color_2',
}, },
"0x0017880104e45541": { '0x0017880104e45541': {
retain: false, retain: false,
friendly_name: "wall_switch" friendly_name: 'wall_switch',
}, },
"0x0017880104e45542": { '0x0017880104e45542': {
retain: false, retain: false,
friendly_name: "wall_switch_double" friendly_name: 'wall_switch_double',
}, },
"0x0017880104e45543": { '0x0017880104e45543': {
retain: false, retain: false,
friendly_name: "led_controller_1" friendly_name: 'led_controller_1',
}, },
"0x0017880104e45544": { '0x0017880104e45544': {
retain: false, retain: false,
friendly_name: "led_controller_2" friendly_name: 'led_controller_2',
}, },
'0x0017880104e45545': { '0x0017880104e45545': {
retain: false, retain: false,
friendly_name: "dimmer_wall_switch" friendly_name: 'dimmer_wall_switch',
}, },
'0x0017880104e45547': { '0x0017880104e45547': {
retain: false, retain: false,
friendly_name: "curtain" friendly_name: 'curtain',
}, },
'0x0017880104e45548': { '0x0017880104e45548': {
retain: false, retain: false,
friendly_name: 'fan' friendly_name: 'fan',
}, },
'0x0017880104e45549': { '0x0017880104e45549': {
retain: false, retain: false,
friendly_name: 'siren' friendly_name: 'siren',
}, },
'0x0017880104e45529': { '0x0017880104e45529': {
retain: false, retain: false,
friendly_name: 'unsupported2' friendly_name: 'unsupported2',
}, },
'0x0017880104e45550': { '0x0017880104e45550': {
retain: false, retain: false,
friendly_name: 'thermostat' friendly_name: 'thermostat',
}, },
'0x0017880104e45551': { '0x0017880104e45551': {
retain: false, retain: false,
friendly_name: 'smart vent' friendly_name: 'smart vent',
}, },
'0x0017880104e45552': { '0x0017880104e45552': {
retain: false, retain: false,
friendly_name: 'j1' friendly_name: 'j1',
}, },
'0x0017880104e45553': { '0x0017880104e45553': {
retain: false, retain: false,
friendly_name: 'bulb_enddevice' friendly_name: 'bulb_enddevice',
}, },
'0x0017880104e45559': { '0x0017880104e45559': {
retain: false, retain: false,
friendly_name: 'cc2530_router' friendly_name: 'cc2530_router',
}, },
'0x0017880104e45560': { '0x0017880104e45560': {
retain: false, retain: false,
friendly_name: 'livolo' friendly_name: 'livolo',
}, },
'0x90fd9ffffe4b64ae': { '0x90fd9ffffe4b64ae': {
retain: false, retain: false,
@ -151,7 +151,7 @@ function writeDefaultConfiguration() {
friendly_name: 'GL-S-007ZS', friendly_name: 'GL-S-007ZS',
}, },
'0x0017880104e43559': { '0x0017880104e43559': {
friendly_name: 'U202DST600ZB' friendly_name: 'U202DST600ZB',
}, },
'0xf4ce368a38be56a1': { '0xf4ce368a38be56a1': {
retain: false, retain: false,
@ -196,44 +196,44 @@ function writeDefaultConfiguration() {
}, },
'0x0017880104e45562': { '0x0017880104e45562': {
friendly_name: 'heating_actuator', friendly_name: 'heating_actuator',
} },
}, },
groups: { groups: {
'1': { 1: {
friendly_name: 'group_1', friendly_name: 'group_1',
retain: false, retain: false,
}, },
'2': { 2: {
friendly_name: 'group_2', friendly_name: 'group_2',
retain: false, retain: false,
}, },
'15071': { 15071: {
friendly_name: 'group_tradfri_remote', friendly_name: 'group_tradfri_remote',
retain: false, retain: false,
devices: ['bulb_color_2', 'bulb_2'] devices: ['bulb_color_2', 'bulb_2'],
}, },
'11': { 11: {
friendly_name: 'group_with_tradfri', friendly_name: 'group_with_tradfri',
retain: false, retain: false,
devices: ['bulb_2'] devices: ['bulb_2'],
}, },
'12': { 12: {
friendly_name: 'thermostat_group', friendly_name: 'thermostat_group',
retain: false, retain: false,
devices: ['TS0601_thermostat'], devices: ['TS0601_thermostat'],
}, },
'14': { 14: {
friendly_name: 'switch_group', friendly_name: 'switch_group',
retain: false, retain: false,
devices: ['power_plug', 'bulb_2'], devices: ['power_plug', 'bulb_2'],
}, },
'21': { 21: {
friendly_name: 'gledopto_group', friendly_name: 'gledopto_group',
devices: ['GLEDOPTO_2ID/cct'], devices: ['GLEDOPTO_2ID/cct'],
}, },
'9': { 9: {
friendly_name: 'ha_discovery_group', friendly_name: 'ha_discovery_group',
devices: ['bulb_color_2', 'bulb_2', 'wall_switch_double/right'] devices: ['bulb_color_2', 'bulb_2', 'wall_switch_double/right'],
}, },
}, },
external_converters: [], external_converters: [],
@ -257,19 +257,19 @@ function stateExists() {
} }
const defaultState = { const defaultState = {
"0x000b57fffec6a5b2": { '0x000b57fffec6a5b2': {
"state": "ON", state: 'ON',
"brightness": 50, brightness: 50,
"color_temp": 370, color_temp: 370,
"linkquality": 99, linkquality: 99,
}, },
"0x0017880104e45517": { '0x0017880104e45517': {
"brightness": 255 brightness: 255,
}, },
"1": { 1: {
'state': 'ON', state: 'ON',
} },
} };
function getDefaultState() { function getDefaultState() {
return defaultState; return defaultState;

View File

@ -11,7 +11,7 @@ const callTransports = (level, message, namespace) => {
transport.log({level, message, namespace}, () => {}); transport.log({level, message, namespace}, () => {});
} }
} }
} };
const mock = { const mock = {
init: jest.fn(), init: jest.fn(),
@ -26,16 +26,24 @@ const mock = {
removeTransport: (transport) => { removeTransport: (transport) => {
transports = transports.filter((t) => t !== transport); transports = transports.filter((t) => t !== transport);
}, },
setLevel: (newLevel) => {level = newLevel}, setLevel: (newLevel) => {
level = newLevel;
},
getLevel: () => level, getLevel: () => level,
setNamespacedLevels: (nsLevels) => {namespacedLevels = nsLevels}, setNamespacedLevels: (nsLevels) => {
namespacedLevels = nsLevels;
},
getNamespacedLevels: () => namespacedLevels, getNamespacedLevels: () => namespacedLevels,
setDebugNamespaceIgnore: (newIgnore) => {debugNamespaceIgnore = newIgnore}, setDebugNamespaceIgnore: (newIgnore) => {
debugNamespaceIgnore = newIgnore;
},
getDebugNamespaceIgnore: () => debugNamespaceIgnore, getDebugNamespaceIgnore: () => debugNamespaceIgnore,
setTransportsEnabled: (value) => {transportsEnabled = value}, setTransportsEnabled: (value) => {
transportsEnabled = value;
},
end: jest.fn(), end: jest.fn(),
}; };
jest.mock('../../lib/util/logger', () => (mock)); jest.mock('../../lib/util/logger', () => mock);
module.exports = {...mock}; module.exports = {...mock};

View File

@ -7,13 +7,13 @@ const mock = {
unsubscribe: jest.fn(), unsubscribe: jest.fn(),
reconnecting: false, reconnecting: false,
on: jest.fn(), on: jest.fn(),
stream: {setMaxListeners: jest.fn()} stream: {setMaxListeners: jest.fn()},
}; };
const mockConnect = jest.fn().mockReturnValue(mock); const mockConnect = jest.fn().mockReturnValue(mock);
jest.mock('mqtt', () => { jest.mock('mqtt', () => {
return {connect: mockConnect}; return {connect: mockConnect};
}); });
const restoreOnMock = () => { const restoreOnMock = () => {
@ -22,12 +22,16 @@ const restoreOnMock = () => {
handler(); handler();
} }
events[type] = handler events[type] = handler;
}); });
} };
restoreOnMock(); restoreOnMock();
module.exports = { module.exports = {
events, ...mock, connect: mockConnect, mock, restoreOnMock events,
}; ...mock,
connect: mockConnect,
mock,
restoreOnMock,
};

View File

@ -19,20 +19,20 @@ class Group {
} }
const clusters = { const clusters = {
'genBasic': 0, genBasic: 0,
'genOta': 25, genOta: 25,
'genScenes': 5, genScenes: 5,
'genOnOff': 6, genOnOff: 6,
'genLevelCtrl': 8, genLevelCtrl: 8,
'lightingColorCtrl': 768, lightingColorCtrl: 768,
'closuresWindowCovering': 258, closuresWindowCovering: 258,
'hvacThermostat': 513, hvacThermostat: 513,
'msIlluminanceMeasurement': 1024, msIlluminanceMeasurement: 1024,
'msTemperatureMeasurement': 1026, msTemperatureMeasurement: 1026,
'msRelativeHumidity': 1029, msRelativeHumidity: 1029,
'msSoilMoisture': 1032, msSoilMoisture: 1032,
'msCO2': 1037 msCO2: 1037,
} };
const custom_clusters = { const custom_clusters = {
custom_1: { custom_1: {
@ -42,14 +42,25 @@ const custom_clusters = {
attribute_0: {ID: 0, type: 49}, attribute_0: {ID: 0, type: 49},
}, },
commands: { commands: {
command_0: { ID: 0, response: 0, parameters: [{name: 'reset', type: 40}], }, command_0: {ID: 0, response: 0, parameters: [{name: 'reset', type: 40}]},
}, },
commandsResponse: {}, commandsResponse: {},
}, },
} };
class Endpoint { class Endpoint {
constructor(ID, inputClusters, outputClusters, deviceIeeeAddress, binds=[], clusterValues={}, configuredReportings=[], profileID=null, deviceID=null, meta={}) { constructor(
ID,
inputClusters,
outputClusters,
deviceIeeeAddress,
binds = [],
clusterValues = {},
configuredReportings = [],
profileID = null,
deviceID = null,
meta = {},
) {
this.deviceIeeeAddress = deviceIeeeAddress; this.deviceIeeeAddress = deviceIeeeAddress;
this.clusterValues = clusterValues; this.clusterValues = clusterValues;
this.ID = ID; this.ID = ID;
@ -68,32 +79,38 @@ class Endpoint {
this.profileID = profileID; this.profileID = profileID;
this.deviceID = deviceID; this.deviceID = deviceID;
this.configuredReportings = configuredReportings; this.configuredReportings = configuredReportings;
this.getInputClusters = () => inputClusters.map((c) => { this.getInputClusters = () =>
return {ID: c, name: getKeyByValue(clusters, c)}; inputClusters
}).filter((c) => c.name); .map((c) => {
return {ID: c, name: getKeyByValue(clusters, c)};
})
.filter((c) => c.name);
this.getOutputClusters = () => outputClusters.map((c) => { this.getOutputClusters = () =>
return {ID: c, name: getKeyByValue(clusters, c)}; outputClusters
}).filter((c) => c.name); .map((c) => {
return {ID: c, name: getKeyByValue(clusters, c)};
})
.filter((c) => c.name);
this.supportsInputCluster = (cluster) => { this.supportsInputCluster = (cluster) => {
assert(clusters[cluster] !== undefined, `Undefined '${cluster}'`); assert(clusters[cluster] !== undefined, `Undefined '${cluster}'`);
return this.inputClusters.includes(clusters[cluster]); return this.inputClusters.includes(clusters[cluster]);
} };
this.supportsOutputCluster = (cluster) => { this.supportsOutputCluster = (cluster) => {
assert(clusters[cluster], `Undefined '${cluster}'`); assert(clusters[cluster], `Undefined '${cluster}'`);
return this.outputClusters.includes(clusters[cluster]); return this.outputClusters.includes(clusters[cluster]);
} };
this.addToGroup = jest.fn(); this.addToGroup = jest.fn();
this.addToGroup.mockImplementation((group) => { this.addToGroup.mockImplementation((group) => {
if (!group.members.includes(this)) group.members.push(this); if (!group.members.includes(this)) group.members.push(this);
}) });
this.getDevice = () => { this.getDevice = () => {
return Object.values(devices).find(d => d.ieeeAddr === deviceIeeeAddress); return Object.values(devices).find((d) => d.ieeeAddr === deviceIeeeAddress);
} };
this.removeFromGroup = jest.fn(); this.removeFromGroup = jest.fn();
this.removeFromGroup.mockImplementation((group) => { this.removeFromGroup.mockImplementation((group) => {
@ -104,8 +121,8 @@ class Endpoint {
}); });
this.removeFromAllGroups = () => { this.removeFromAllGroups = () => {
Object.values(groups).forEach((g) => this.removeFromGroup(g)) Object.values(groups).forEach((g) => this.removeFromGroup(g));
} };
this.getClusterAttributeValue = jest.fn(); this.getClusterAttributeValue = jest.fn();
this.getClusterAttributeValue.mockImplementation((cluster, value) => { this.getClusterAttributeValue.mockImplementation((cluster, value) => {
@ -116,7 +133,21 @@ class Endpoint {
} }
class Device { class Device {
constructor(type, ieeeAddr, networkAddress, manufacturerID, endpoints, interviewCompleted, powerSource = null, modelID = null, interviewing=false, manufacturerName, dateCode= null, softwareBuildID=null, customClusters = {}) { constructor(
type,
ieeeAddr,
networkAddress,
manufacturerID,
endpoints,
interviewCompleted,
powerSource = null,
modelID = null,
interviewing = false,
manufacturerName,
dateCode = null,
softwareBuildID = null,
customClusters = {},
) {
this.type = type; this.type = type;
this.ieeeAddr = ieeeAddr; this.ieeeAddr = ieeeAddr;
this.dateCode = dateCode; this.dateCode = dateCode;
@ -147,88 +178,624 @@ class Device {
const returnDevices = []; const returnDevices = [];
const bulb_color = new Device('Router', '0x000b57fffec6a5b3', 40399, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b3', [], {lightingColorCtrl: {colorCapabilities: 254}})], true, "Mains (single phase)", "LLC020"); const bulb_color = new Device(
const bulb_color_2 = new Device('Router', '0x000b57fffec6a5b4', 401292, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b4', [], {lightingColorCtrl: {colorCapabilities: 254}}, [], null, null, {'scenes': {'1_0': {name: 'Chill scene', state: {state: 'ON'}}, '4_9': {state: {state: 'OFF'}}}})], true, "Mains (single phase)", "LLC020", false, 'Philips', '2019.09', '5.127.1.26581'); 'Router',
const bulb_2 = new Device('Router', '0x000b57fffec6a5b7', 40369, 4476, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b7', [], {lightingColorCtrl: {colorCapabilities: 17}})], true, "Mains (single phase)", "TRADFRI bulb E27 WS opal 980lm"); '0x000b57fffec6a5b3',
const TS0601_thermostat = new Device('EndDevice', '0x0017882104a44559', 6544,4151, [new Endpoint(1, [], [], '0x0017882104a44559')], true, "Mains (single phase)", 'kud7u2l'); 40399,
const TS0601_switch = new Device('EndDevice', '0x0017882104a44560', 6544,4151, [new Endpoint(1, [], [], '0x0017882104a44560')], true, "Mains (single phase)", 'kjintbl'); 4107,
const TS0601_cover_switch = new Device('EndDevice', '0x0017882104a44562', 6544,4151, [new Endpoint(1, [], [], '0x0017882104a44562')], true, "Mains (single phase)", 'TS0601', false, '_TZE200_5nldle7w'); [
const ZNCZ02LM = new Device('Router', '0x0017880104e45524', 6540,4151, [new Endpoint(1, [0, 6], [], '0x0017880104e45524')], true, "Mains (single phase)", "lumi.plug"); new Endpoint(1, [0, 3, 4, 5, 6, 8, 768, 2821, 4096], [5, 25, 32, 4096], '0x000b57fffec6a5b3', [], {
const GLEDOPTO_2ID = new Device('Router', '0x0017880104e45724', 6540,4151, [new Endpoint(11, [0,3,4,5,6,8,768], [], '0x0017880104e45724', [], {}, [], 49246, 528), new Endpoint(12, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 260, 258), new Endpoint(13, [4096], [4096], '0x0017880104e45724', [], {}, [], 49246, 57694), new Endpoint(15, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 49246, 256)], true, "Mains (single phase)", 'GL-C-007', false, 'GLEDOPTO'); lightingColorCtrl: {colorCapabilities: 254},
const QBKG03LM = new Device('Router', '0x0017880104e45542', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45542'), new Endpoint(2, [0, 6], [], '0x0017880104e45542'), new Endpoint(3, [0, 6], [], '0x0017880104e45542')], true, "Mains (single phase)", 'lumi.ctrl_neutral2'); }),
const zigfred_plus = new Device('Router', '0xf4ce368a38be56a1', 6589, 0x129C, [new Endpoint(5, [0, 3, 4, 5, 6, 8, 0x0300, 0xFC42], [0xFC42], '0xf4ce368a38be56a1'), new Endpoint(7, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'), new Endpoint(8, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'), new Endpoint(9, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'), new Endpoint(10, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'), new Endpoint(11, [0, 3, 4, 5, 0x0102], [], '0xf4ce368a38be56a1'), new Endpoint(12, [0, 3, 4, 5, 0x0102], [], '0xf4ce368a38be56a1')], true, "Mains (single phase)", 'zigfred plus', false, 'Siglis'); ],
true,
'Mains (single phase)',
'LLC020',
);
const bulb_color_2 = new Device(
'Router',
'0x000b57fffec6a5b4',
401292,
4107,
[
new Endpoint(
1,
[0, 3, 4, 5, 6, 8, 768, 2821, 4096],
[5, 25, 32, 4096],
'0x000b57fffec6a5b4',
[],
{lightingColorCtrl: {colorCapabilities: 254}},
[],
null,
null,
{scenes: {'1_0': {name: 'Chill scene', state: {state: 'ON'}}, '4_9': {state: {state: 'OFF'}}}},
),
],
true,
'Mains (single phase)',
'LLC020',
false,
'Philips',
'2019.09',
'5.127.1.26581',
);
const bulb_2 = new Device(
'Router',
'0x000b57fffec6a5b7',
40369,
4476,
[new Endpoint(1, [0, 3, 4, 5, 6, 8, 768, 2821, 4096], [5, 25, 32, 4096], '0x000b57fffec6a5b7', [], {lightingColorCtrl: {colorCapabilities: 17}})],
true,
'Mains (single phase)',
'TRADFRI bulb E27 WS opal 980lm',
);
const TS0601_thermostat = new Device(
'EndDevice',
'0x0017882104a44559',
6544,
4151,
[new Endpoint(1, [], [], '0x0017882104a44559')],
true,
'Mains (single phase)',
'kud7u2l',
);
const TS0601_switch = new Device(
'EndDevice',
'0x0017882104a44560',
6544,
4151,
[new Endpoint(1, [], [], '0x0017882104a44560')],
true,
'Mains (single phase)',
'kjintbl',
);
const TS0601_cover_switch = new Device(
'EndDevice',
'0x0017882104a44562',
6544,
4151,
[new Endpoint(1, [], [], '0x0017882104a44562')],
true,
'Mains (single phase)',
'TS0601',
false,
'_TZE200_5nldle7w',
);
const ZNCZ02LM = new Device(
'Router',
'0x0017880104e45524',
6540,
4151,
[new Endpoint(1, [0, 6], [], '0x0017880104e45524')],
true,
'Mains (single phase)',
'lumi.plug',
);
const GLEDOPTO_2ID = new Device(
'Router',
'0x0017880104e45724',
6540,
4151,
[
new Endpoint(11, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 49246, 528),
new Endpoint(12, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 260, 258),
new Endpoint(13, [4096], [4096], '0x0017880104e45724', [], {}, [], 49246, 57694),
new Endpoint(15, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 49246, 256),
],
true,
'Mains (single phase)',
'GL-C-007',
false,
'GLEDOPTO',
);
const QBKG03LM = new Device(
'Router',
'0x0017880104e45542',
6540,
4151,
[
new Endpoint(1, [0], [], '0x0017880104e45542'),
new Endpoint(2, [0, 6], [], '0x0017880104e45542'),
new Endpoint(3, [0, 6], [], '0x0017880104e45542'),
],
true,
'Mains (single phase)',
'lumi.ctrl_neutral2',
);
const zigfred_plus = new Device(
'Router',
'0xf4ce368a38be56a1',
6589,
0x129c,
[
new Endpoint(5, [0, 3, 4, 5, 6, 8, 0x0300, 0xfc42], [0xfc42], '0xf4ce368a38be56a1'),
new Endpoint(7, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'),
new Endpoint(8, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'),
new Endpoint(9, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'),
new Endpoint(10, [0, 3, 4, 5, 6, 8], [], '0xf4ce368a38be56a1'),
new Endpoint(11, [0, 3, 4, 5, 0x0102], [], '0xf4ce368a38be56a1'),
new Endpoint(12, [0, 3, 4, 5, 0x0102], [], '0xf4ce368a38be56a1'),
],
true,
'Mains (single phase)',
'zigfred plus',
false,
'Siglis',
);
const groups = { const groups = {
'group_1': new Group(1, []), group_1: new Group(1, []),
'group_tradfri_remote': new Group(15071, [bulb_color_2.endpoints[0], bulb_2.endpoints[0]]), group_tradfri_remote: new Group(15071, [bulb_color_2.endpoints[0], bulb_2.endpoints[0]]),
'group/with/slashes': new Group(99, []), 'group/with/slashes': new Group(99, []),
'group_with_tradfri': new Group(11, [bulb_2.endpoints[0]]), group_with_tradfri: new Group(11, [bulb_2.endpoints[0]]),
'thermostat_group': new Group(12, [TS0601_thermostat.endpoints[0]]), thermostat_group: new Group(12, [TS0601_thermostat.endpoints[0]]),
'group_with_switch': new Group(14, [ZNCZ02LM.endpoints[0], bulb_2.endpoints[0]]), group_with_switch: new Group(14, [ZNCZ02LM.endpoints[0], bulb_2.endpoints[0]]),
'gledopto_group': new Group(21, [GLEDOPTO_2ID.endpoints[3]]), gledopto_group: new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
'default_bind_group': new Group(901, []), default_bind_group: new Group(901, []),
'ha_discovery_group': new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[1]]), ha_discovery_group: new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[1]]),
} };
const devices = { const devices = {
'coordinator': new Device('Coordinator', '0x00124b00120144ae', 0, 0, [new Endpoint(1, [], [], '0x00124b00120144ae')], false), coordinator: new Device('Coordinator', '0x00124b00120144ae', 0, 0, [new Endpoint(1, [], [], '0x00124b00120144ae')], false),
'bulb': new Device('Router', '0x000b57fffec6a5b2', 40369, 4476, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b2', [], {lightingColorCtrl: {colorCapabilities: 17}}, [{cluster: {name: 'genOnOff'}, attribute: {name: 'onOff'}, minimumReportInterval: 1, maximumReportInterval: 10, reportableChange: 20}])], true, "Mains (single phase)", "TRADFRI bulb E27 WS opal 980lm"), bulb: new Device(
'bulb_color': bulb_color, 'Router',
'bulb_2': bulb_2, '0x000b57fffec6a5b2',
'bulb_color_2': bulb_color_2, 40369,
'remote': new Device('EndDevice', '0x0017880104e45517', 6535, 4107, [new Endpoint(1, [0], [0,3,4,6,8,5], '0x0017880104e45517', [{target: bulb_color.endpoints[0], cluster: {ID: 8, name: 'genLevelCtrl'}}, {target: bulb_color.endpoints[0], cluster: {ID: 6, name: 'genOnOff'}}, {target: bulb_color.endpoints[0], cluster: {ID: 768, name: 'lightingColorCtrl'}}, {target: groups.group_1, cluster: {ID: 6, name: 'genOnOff'}}, {target: groups.group_1, cluster: {ID: 6, name: 'genLevelCtrl'}}]), new Endpoint(2, [0,1,3,15,64512], [25, 6])], true, "Battery", "RWL021"), 4476,
'unsupported': new Device('EndDevice', '0x0017880104e45518', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Battery", "notSupportedModelID", false, "notSupportedMfg"), [
'unsupported2': new Device('EndDevice', '0x0017880104e45529', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Battery", "notSupportedModelID"), new Endpoint(
'interviewing': new Device('EndDevice', '0x0017880104e45530', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Battery", undefined, true), 1,
'notInSettings': new Device('EndDevice', '0x0017880104e45519', 6537, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Battery", "lumi.sensor_switch.aq2"), [0, 3, 4, 5, 6, 8, 768, 2821, 4096],
'WXKG11LM': new Device('EndDevice', '0x0017880104e45520', 6537,4151, [new Endpoint(1, [0], [0,3,4,6,8,5], '0x0017880104e45520', [], {}, [{cluster: {name: 'genOnOff'}, attribute: {name: undefined, ID: 1337}, minimumReportInterval: 1, maximumReportInterval: 10, reportableChange: 20}])], true, "Battery", "lumi.sensor_switch.aq2"), [5, 25, 32, 4096],
'WXKG02LM_rev1': new Device('EndDevice', '0x0017880104e45521', 6538,4151, [new Endpoint(1, [0], []), new Endpoint(2, [0], [])], true, "Battery", "lumi.sensor_86sw2.es1"), '0x000b57fffec6a5b2',
'WSDCGQ11LM': new Device('EndDevice', '0x0017880104e45522', 6539,4151, [new Endpoint(1, [0], [])], true, "Battery", "lumi.weather"), [],
'RTCGQ11LM': new Device('EndDevice', '0x0017880104e45523', 6540,4151, [new Endpoint(1, [0], [])], true, "Battery", "lumi.sensor_motion.aq2"), {lightingColorCtrl: {colorCapabilities: 17}},
'ZNCZ02LM': ZNCZ02LM, [
'E1743': new Device('Router', '0x0017880104e45540', 6540,4476, [new Endpoint(1, [0], [])], true, "Mains (single phase)", 'TRADFRI on/off switch'), {
'QBKG04LM': new Device('Router', '0x0017880104e45541', 6549,4151, [new Endpoint(1, [0], [25]), new Endpoint(2, [0, 6], [])], true, "Mains (single phase)", 'lumi.ctrl_neutral1'), cluster: {name: 'genOnOff'},
'QBKG03LM':QBKG03LM, attribute: {name: 'onOff'},
'GLEDOPTO1112': new Device('Router', '0x0017880104e45543', 6540, 4151, [new Endpoint(11, [0], [], '0x0017880104e45543'), new Endpoint(13, [0], [], '0x0017880104e45543')], true, "Mains (single phase)", 'GL-C-008'), minimumReportInterval: 1,
'GLEDOPTO111213': new Device('Router', '0x0017880104e45544', 6540,4151, [new Endpoint(11, [0], []), new Endpoint(13, [0], []), new Endpoint(12, [0], [])], true, "Mains (single phase)", 'GL-C-008'), maximumReportInterval: 10,
'GLEDOPTO_2ID': GLEDOPTO_2ID, reportableChange: 20,
'HGZB04D': new Device('Router', '0x0017880104e45545', 6540,4151, [new Endpoint(1, [0], [25], '0x0017880104e45545')], true, "Mains (single phase)", 'FB56+ZSC05HG1.0'), },
'ZNCLDJ11LM': new Device('Router', '0x0017880104e45547', 6540,4151, [new Endpoint(1, [0], []), new Endpoint(2, [0], [])], true, "Mains (single phase)", 'lumi.curtain'), ],
'HAMPTON99432': new Device('Router', '0x0017880104e45548', 6540,4151, [new Endpoint(1, [0], []), new Endpoint(2, [0], [])], true, "Mains (single phase)", 'HDC52EastwindFan'), ),
'HS2WD': new Device('Router', '0x0017880104e45549', 6540,4151, [new Endpoint(1, [0], [])], true, "Mains (single phase)", 'WarningDevice'), ],
'1TST_EU': new Device('Router', '0x0017880104e45550', 6540,4151, [new Endpoint(1, [0], [])], true, "Mains (single phase)", 'Thermostat'), true,
'SV01': new Device('Router', '0x0017880104e45551', 6540,4151, [new Endpoint(1, [0], [])], true, "Mains (single phase)", 'SV01-410-MP-1.0'), 'Mains (single phase)',
'J1': new Device('Router', '0x0017880104e45552', 6540,4151, [new Endpoint(1, [0], [])], true, "Mains (single phase)", 'J1 (5502)'), 'TRADFRI bulb E27 WS opal 980lm',
'E11_G13': new Device('EndDevice', '0x0017880104e45553', 6540,4151, [new Endpoint(1, [0, 6], [])], true, "Mains (single phase)", 'E11-G13'), ),
'nomodel': new Device('Router', '0x0017880104e45535', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Mains (single phase)", undefined, true), bulb_color: bulb_color,
'unsupported_router': new Device('Router', '0x0017880104e45525', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Mains (single phase)", "notSupportedModelID", false, "Boef"), bulb_2: bulb_2,
'CC2530_ROUTER': new Device('Router', '0x0017880104e45559', 6540,4151, [new Endpoint(1, [0, 6], [])], true, "Mains (single phase)", 'lumi.router'), bulb_color_2: bulb_color_2,
'LIVOLO': new Device('Router', '0x0017880104e45560', 6541,4152, [new Endpoint(6, [0, 6], [])], true, "Mains (single phase)", 'TI0001 '), remote: new Device(
'tradfri_remote': new Device('EndDevice', '0x90fd9ffffe4b64ae', 33906, 4476, [new Endpoint(1, [0], [0,3,4,6,8,5], '0x90fd9ffffe4b64ae')], true, "Battery", "TRADFRI remote control"), 'EndDevice',
'roller_shutter': new Device('EndDevice', '0x90fd9ffffe4b64af', 33906, 4476, [new Endpoint(1, [0], [0,3,4,6,8,5], '0x90fd9ffffe4b64af')], true, "Battery", "SCM-R_00.00.03.15TC"), '0x0017880104e45517',
'ZNLDP12LM': new Device('Router', '0x90fd9ffffe4b64ax', 33901, 4476, [new Endpoint(1, [0,4,3,5,10,258,13,19,6,1,1030,8,768,1027,1029,1026], [0,3,4,6,8,5], '0x90fd9ffffe4b64ax', [], {lightingColorCtrl: {colorCapabilities: 254}})], true, "Mains (single phase)", "lumi.light.aqcn02"), 6535,
'SP600_OLD': new Device('Router', '0x90fd9ffffe4b64aa', 33901, 4476, [new Endpoint(1, [0,4,3,5,10,258,13,19,6,1,1030,8,768,1027,1029,1026], [0,3,4,6,8,5], '0x90fd9ffffe4b64aa', [], {seMetering: {"multiplier":1,"divisor":10000}})], true, "Mains (single phase)", "SP600", false, 'Salus', '20160120'), 4107,
'SP600_NEW': new Device('Router', '0x90fd9ffffe4b64ab', 33901, 4476, [new Endpoint(1, [0,4,3,5,10,258,13,19,6,1,1030,8,768,1027,1029,1026], [0,3,4,6,8,5], '0x90fd9ffffe4b64aa', [], {seMetering: {"multiplier":1,"divisor":10000}})], true, "Mains (single phase)", "SP600", false, 'Salus', '20170220'), [
'MKS-CM-W5': new Device('Router', '0x90fd9ffffe4b64ac', 33901, 4476, [new Endpoint(1, [0,4,3,5,10,258,13,19,6,1,1030,8,768,1027,1029,1026], [0,3,4,6,8,5], '0x90fd9ffffe4b64aa', [], {})], true, "Mains (single phase)", "qnazj70", false), new Endpoint(1, [0], [0, 3, 4, 6, 8, 5], '0x0017880104e45517', [
'GL-S-007ZS': new Device('Router', '0x0017880104e45526', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45526')], true, "Mains (single phase)", 'GL-S-007ZS'), {target: bulb_color.endpoints[0], cluster: {ID: 8, name: 'genLevelCtrl'}},
'U202DST600ZB': new Device('Router', '0x0017880104e43559', 6540,4151, [new Endpoint(10, [0, 6], [], '0x0017880104e43559'), new Endpoint(11, [0, 6], [], '0x0017880104e43559')], true, "Mains (single phase)", 'U202DST600ZB'), {target: bulb_color.endpoints[0], cluster: {ID: 6, name: 'genOnOff'}},
'zigfred_plus': zigfred_plus, {target: bulb_color.endpoints[0], cluster: {ID: 768, name: 'lightingColorCtrl'}},
'3157100': new Device('Router', '0x0017880104e44559', 6542,4151, [new Endpoint(1, [], [], '0x0017880104e44559')], true, "Mains (single phase)", '3157100', false, 'Centralite'), {target: groups.group_1, cluster: {ID: 6, name: 'genOnOff'}},
'J1': new Device('Router', '0x0017880104a44559', 6543,4151, [new Endpoint(1, [], [], '0x0017880104a44559')], true, "Mains (single phase)", 'J1 (5502)'), {target: groups.group_1, cluster: {ID: 6, name: 'genLevelCtrl'}},
'TS0601_thermostat': TS0601_thermostat, ]),
'TS0601_switch': TS0601_switch, new Endpoint(2, [0, 1, 3, 15, 64512], [25, 6]),
'TS0601_cover_switch': TS0601_cover_switch, ],
'external_converter_device': new Device('EndDevice', '0x0017880104e45511', 1114, 'external', [new Endpoint(1, [], [], '0x0017880104e45511')], false, null, 'external_converter_device' ), true,
'QS_Zigbee_D02_TRIAC_2C_LN':new Device('Router', '0x0017882194e45543', 6549,4151, [new Endpoint(1, [0], [], '0x0017882194e45543'), new Endpoint(2, [0, 6], [], '0x0017882194e45543')], true, "Mains (single phase)", 'TS110F', false, '_TYZB01_v8gtiaed'), 'Battery',
'unknown': new Device('Router', '0x0017980134e45545', 6540,4151, [], true, "Mains (single phase)"), 'RWL021',
'temperature_sensor': new Device('EndDevice', '0x0017880104e45561', 6544,4151, [new Endpoint(1, [0,3,4,1026], [])], true, "Battery", "temperature.sensor"), ),
'heating_actuator': new Device('Router', '0x0017880104e45562', 6545,4151, [new Endpoint(1, [0,3,4,513], [1026])], true, "Mains (single phase)", "heating.actuator"), unsupported: new Device(
'bj_scene_switch': new Device('EndDevice', '0xd85def11a1002caa', 50117, 4398, [new Endpoint(10, [0,4096], [3,4,5,6,8,25,768,4096], '0xd85def11a1002caa', [{target: bulb_color_2.endpoints[0], cluster: {ID: 8, name: 'genLevelCtrl'}}, {target: bulb_color_2.endpoints[0], cluster: {ID: 6, name: 'genOnOff'}}, {target: bulb_color_2.endpoints[0], cluster: {ID: 768, name: 'lightingColorCtrl'}},]), new Endpoint(11, [0,4096], [3,4,5,6,8,25,768,4096], '0xd85def11a1002caa')], true, 'Battery', 'RB01', false, 'Busch-Jaeger', '20161222', '1.2.0'), 'EndDevice',
'GW003-AS-IN-TE-FC': new Device('Router', '0x0017548104a44669', 6545,4699, [new Endpoint(1, [3], [0,3,513,514], '0x0017548104a44669')], true, "Mains (single phase)", 'Adapter Zigbee FUJITSU'), '0x0017880104e45518',
'BMCT-SLZ': new Device('Router', '0x18fc26000000cafe', 6546,4617, [new Endpoint(1, [0,3,4,5,258,1794,2820,2821,64672], [10,25], '0x18fc26000000cafe')], true, "Mains (single phase)", 'RBSH-MMS-ZB-EU'), 6536,
'BMCT_SLZ': new Device('Router', '0x0026decafe000473', 6546,4617, [new Endpoint(1, [0,3,4,5,258,1794,2820,2821,64672], [10,25], '0x0026decafe000473')], true, "Mains (single phase)", 'RBSH-MMS-ZB-EU', false, null, null, null, custom_clusters), 0,
'bulb_custom_cluster': new Device('Router', '0x000b57fffec6a5c2', 40369, 4476, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5c2')], true, "Mains (single phase)", "TRADFRI bulb E27 WS opal 980lm", false, null, null, null, custom_clusters), [new Endpoint(1, [0], [0, 3, 4, 6, 8, 5])],
} true,
'Battery',
'notSupportedModelID',
false,
'notSupportedMfg',
),
unsupported2: new Device(
'EndDevice',
'0x0017880104e45529',
6536,
0,
[new Endpoint(1, [0], [0, 3, 4, 6, 8, 5])],
true,
'Battery',
'notSupportedModelID',
),
interviewing: new Device(
'EndDevice',
'0x0017880104e45530',
6536,
0,
[new Endpoint(1, [0], [0, 3, 4, 6, 8, 5])],
true,
'Battery',
undefined,
true,
),
notInSettings: new Device(
'EndDevice',
'0x0017880104e45519',
6537,
0,
[new Endpoint(1, [0], [0, 3, 4, 6, 8, 5])],
true,
'Battery',
'lumi.sensor_switch.aq2',
),
WXKG11LM: new Device(
'EndDevice',
'0x0017880104e45520',
6537,
4151,
[
new Endpoint(1, [0], [0, 3, 4, 6, 8, 5], '0x0017880104e45520', [], {}, [
{
cluster: {name: 'genOnOff'},
attribute: {name: undefined, ID: 1337},
minimumReportInterval: 1,
maximumReportInterval: 10,
reportableChange: 20,
},
]),
],
true,
'Battery',
'lumi.sensor_switch.aq2',
),
WXKG02LM_rev1: new Device(
'EndDevice',
'0x0017880104e45521',
6538,
4151,
[new Endpoint(1, [0], []), new Endpoint(2, [0], [])],
true,
'Battery',
'lumi.sensor_86sw2.es1',
),
WSDCGQ11LM: new Device('EndDevice', '0x0017880104e45522', 6539, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.weather'),
RTCGQ11LM: new Device('EndDevice', '0x0017880104e45523', 6540, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.sensor_motion.aq2'),
ZNCZ02LM: ZNCZ02LM,
E1743: new Device('Router', '0x0017880104e45540', 6540, 4476, [new Endpoint(1, [0], [])], true, 'Mains (single phase)', 'TRADFRI on/off switch'),
QBKG04LM: new Device(
'Router',
'0x0017880104e45541',
6549,
4151,
[new Endpoint(1, [0], [25]), new Endpoint(2, [0, 6], [])],
true,
'Mains (single phase)',
'lumi.ctrl_neutral1',
),
QBKG03LM: QBKG03LM,
GLEDOPTO1112: new Device(
'Router',
'0x0017880104e45543',
6540,
4151,
[new Endpoint(11, [0], [], '0x0017880104e45543'), new Endpoint(13, [0], [], '0x0017880104e45543')],
true,
'Mains (single phase)',
'GL-C-008',
),
GLEDOPTO111213: new Device(
'Router',
'0x0017880104e45544',
6540,
4151,
[new Endpoint(11, [0], []), new Endpoint(13, [0], []), new Endpoint(12, [0], [])],
true,
'Mains (single phase)',
'GL-C-008',
),
GLEDOPTO_2ID: GLEDOPTO_2ID,
HGZB04D: new Device(
'Router',
'0x0017880104e45545',
6540,
4151,
[new Endpoint(1, [0], [25], '0x0017880104e45545')],
true,
'Mains (single phase)',
'FB56+ZSC05HG1.0',
),
ZNCLDJ11LM: new Device(
'Router',
'0x0017880104e45547',
6540,
4151,
[new Endpoint(1, [0], []), new Endpoint(2, [0], [])],
true,
'Mains (single phase)',
'lumi.curtain',
),
HAMPTON99432: new Device(
'Router',
'0x0017880104e45548',
6540,
4151,
[new Endpoint(1, [0], []), new Endpoint(2, [0], [])],
true,
'Mains (single phase)',
'HDC52EastwindFan',
),
HS2WD: new Device('Router', '0x0017880104e45549', 6540, 4151, [new Endpoint(1, [0], [])], true, 'Mains (single phase)', 'WarningDevice'),
'1TST_EU': new Device('Router', '0x0017880104e45550', 6540, 4151, [new Endpoint(1, [0], [])], true, 'Mains (single phase)', 'Thermostat'),
SV01: new Device('Router', '0x0017880104e45551', 6540, 4151, [new Endpoint(1, [0], [])], true, 'Mains (single phase)', 'SV01-410-MP-1.0'),
J1: new Device('Router', '0x0017880104e45552', 6540, 4151, [new Endpoint(1, [0], [])], true, 'Mains (single phase)', 'J1 (5502)'),
E11_G13: new Device('EndDevice', '0x0017880104e45553', 6540, 4151, [new Endpoint(1, [0, 6], [])], true, 'Mains (single phase)', 'E11-G13'),
nomodel: new Device(
'Router',
'0x0017880104e45535',
6536,
0,
[new Endpoint(1, [0], [0, 3, 4, 6, 8, 5])],
true,
'Mains (single phase)',
undefined,
true,
),
unsupported_router: new Device(
'Router',
'0x0017880104e45525',
6536,
0,
[new Endpoint(1, [0], [0, 3, 4, 6, 8, 5])],
true,
'Mains (single phase)',
'notSupportedModelID',
false,
'Boef',
),
CC2530_ROUTER: new Device('Router', '0x0017880104e45559', 6540, 4151, [new Endpoint(1, [0, 6], [])], true, 'Mains (single phase)', 'lumi.router'),
LIVOLO: new Device('Router', '0x0017880104e45560', 6541, 4152, [new Endpoint(6, [0, 6], [])], true, 'Mains (single phase)', 'TI0001 '),
tradfri_remote: new Device(
'EndDevice',
'0x90fd9ffffe4b64ae',
33906,
4476,
[new Endpoint(1, [0], [0, 3, 4, 6, 8, 5], '0x90fd9ffffe4b64ae')],
true,
'Battery',
'TRADFRI remote control',
),
roller_shutter: new Device(
'EndDevice',
'0x90fd9ffffe4b64af',
33906,
4476,
[new Endpoint(1, [0], [0, 3, 4, 6, 8, 5], '0x90fd9ffffe4b64af')],
true,
'Battery',
'SCM-R_00.00.03.15TC',
),
ZNLDP12LM: new Device(
'Router',
'0x90fd9ffffe4b64ax',
33901,
4476,
[
new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], '0x90fd9ffffe4b64ax', [], {
lightingColorCtrl: {colorCapabilities: 254},
}),
],
true,
'Mains (single phase)',
'lumi.light.aqcn02',
),
SP600_OLD: new Device(
'Router',
'0x90fd9ffffe4b64aa',
33901,
4476,
[
new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], '0x90fd9ffffe4b64aa', [], {
seMetering: {multiplier: 1, divisor: 10000},
}),
],
true,
'Mains (single phase)',
'SP600',
false,
'Salus',
'20160120',
),
SP600_NEW: new Device(
'Router',
'0x90fd9ffffe4b64ab',
33901,
4476,
[
new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], '0x90fd9ffffe4b64aa', [], {
seMetering: {multiplier: 1, divisor: 10000},
}),
],
true,
'Mains (single phase)',
'SP600',
false,
'Salus',
'20170220',
),
'MKS-CM-W5': new Device(
'Router',
'0x90fd9ffffe4b64ac',
33901,
4476,
[new Endpoint(1, [0, 4, 3, 5, 10, 258, 13, 19, 6, 1, 1030, 8, 768, 1027, 1029, 1026], [0, 3, 4, 6, 8, 5], '0x90fd9ffffe4b64aa', [], {})],
true,
'Mains (single phase)',
'qnazj70',
false,
),
'GL-S-007ZS': new Device(
'Router',
'0x0017880104e45526',
6540,
4151,
[new Endpoint(1, [0], [], '0x0017880104e45526')],
true,
'Mains (single phase)',
'GL-S-007ZS',
),
U202DST600ZB: new Device(
'Router',
'0x0017880104e43559',
6540,
4151,
[new Endpoint(10, [0, 6], [], '0x0017880104e43559'), new Endpoint(11, [0, 6], [], '0x0017880104e43559')],
true,
'Mains (single phase)',
'U202DST600ZB',
),
zigfred_plus: zigfred_plus,
3157100: new Device(
'Router',
'0x0017880104e44559',
6542,
4151,
[new Endpoint(1, [], [], '0x0017880104e44559')],
true,
'Mains (single phase)',
'3157100',
false,
'Centralite',
),
J1: new Device(
'Router',
'0x0017880104a44559',
6543,
4151,
[new Endpoint(1, [], [], '0x0017880104a44559')],
true,
'Mains (single phase)',
'J1 (5502)',
),
TS0601_thermostat: TS0601_thermostat,
TS0601_switch: TS0601_switch,
TS0601_cover_switch: TS0601_cover_switch,
external_converter_device: new Device(
'EndDevice',
'0x0017880104e45511',
1114,
'external',
[new Endpoint(1, [], [], '0x0017880104e45511')],
false,
null,
'external_converter_device',
),
QS_Zigbee_D02_TRIAC_2C_LN: new Device(
'Router',
'0x0017882194e45543',
6549,
4151,
[new Endpoint(1, [0], [], '0x0017882194e45543'), new Endpoint(2, [0, 6], [], '0x0017882194e45543')],
true,
'Mains (single phase)',
'TS110F',
false,
'_TYZB01_v8gtiaed',
),
unknown: new Device('Router', '0x0017980134e45545', 6540, 4151, [], true, 'Mains (single phase)'),
temperature_sensor: new Device(
'EndDevice',
'0x0017880104e45561',
6544,
4151,
[new Endpoint(1, [0, 3, 4, 1026], [])],
true,
'Battery',
'temperature.sensor',
),
heating_actuator: new Device(
'Router',
'0x0017880104e45562',
6545,
4151,
[new Endpoint(1, [0, 3, 4, 513], [1026])],
true,
'Mains (single phase)',
'heating.actuator',
),
bj_scene_switch: new Device(
'EndDevice',
'0xd85def11a1002caa',
50117,
4398,
[
new Endpoint(10, [0, 4096], [3, 4, 5, 6, 8, 25, 768, 4096], '0xd85def11a1002caa', [
{target: bulb_color_2.endpoints[0], cluster: {ID: 8, name: 'genLevelCtrl'}},
{target: bulb_color_2.endpoints[0], cluster: {ID: 6, name: 'genOnOff'}},
{target: bulb_color_2.endpoints[0], cluster: {ID: 768, name: 'lightingColorCtrl'}},
]),
new Endpoint(11, [0, 4096], [3, 4, 5, 6, 8, 25, 768, 4096], '0xd85def11a1002caa'),
],
true,
'Battery',
'RB01',
false,
'Busch-Jaeger',
'20161222',
'1.2.0',
),
'GW003-AS-IN-TE-FC': new Device(
'Router',
'0x0017548104a44669',
6545,
4699,
[new Endpoint(1, [3], [0, 3, 513, 514], '0x0017548104a44669')],
true,
'Mains (single phase)',
'Adapter Zigbee FUJITSU',
),
'BMCT-SLZ': new Device(
'Router',
'0x18fc26000000cafe',
6546,
4617,
[new Endpoint(1, [0, 3, 4, 5, 258, 1794, 2820, 2821, 64672], [10, 25], '0x18fc26000000cafe')],
true,
'Mains (single phase)',
'RBSH-MMS-ZB-EU',
),
BMCT_SLZ: new Device(
'Router',
'0x0026decafe000473',
6546,
4617,
[new Endpoint(1, [0, 3, 4, 5, 258, 1794, 2820, 2821, 64672], [10, 25], '0x0026decafe000473')],
true,
'Mains (single phase)',
'RBSH-MMS-ZB-EU',
false,
null,
null,
null,
custom_clusters,
),
bulb_custom_cluster: new Device(
'Router',
'0x000b57fffec6a5c2',
40369,
4476,
[new Endpoint(1, [0, 3, 4, 5, 6, 8, 768, 2821, 4096], [5, 25, 32, 4096], '0x000b57fffec6a5c2')],
true,
'Mains (single phase)',
'TRADFRI bulb E27 WS opal 980lm',
false,
null,
null,
null,
custom_clusters,
),
};
const mock = { const mock = {
setTransmitPower: jest.fn(), setTransmitPower: jest.fn(),
@ -252,13 +819,19 @@ const mock = {
return Object.values(devices).filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr)); return Object.values(devices).filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr));
}), }),
getDevicesByType: jest.fn().mockImplementation((type) => { getDevicesByType: jest.fn().mockImplementation((type) => {
return Object.values(devices).filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr)).filter((d) => d.type === type); return Object.values(devices)
.filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr))
.filter((d) => d.type === type);
}), }),
getDeviceByIeeeAddr: jest.fn().mockImplementation((ieeeAddr) => { getDeviceByIeeeAddr: jest.fn().mockImplementation((ieeeAddr) => {
return Object.values(devices).filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr)).find((d) => d.ieeeAddr === ieeeAddr); return Object.values(devices)
.filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr))
.find((d) => d.ieeeAddr === ieeeAddr);
}), }),
getDeviceByNetworkAddress: jest.fn().mockImplementation((networkAddress) => { getDeviceByNetworkAddress: jest.fn().mockImplementation((networkAddress) => {
return Object.values(devices).filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr)).find((d) => d.networkAddress === networkAddress); return Object.values(devices)
.filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr))
.find((d) => d.networkAddress === networkAddress);
}), }),
getGroups: jest.fn().mockImplementation((query) => { getGroups: jest.fn().mockImplementation((query) => {
return Object.values(groups); return Object.values(groups);
@ -271,9 +844,9 @@ const mock = {
reset: jest.fn(), reset: jest.fn(),
createGroup: jest.fn().mockImplementation((groupID) => { createGroup: jest.fn().mockImplementation((groupID) => {
const group = new Group(groupID, []); const group = new Group(groupID, []);
groups[`group_${groupID}`] = group groups[`group_${groupID}`] = group;
return group; return group;
}) }),
}; };
const mockConstructor = jest.fn().mockImplementation(() => mock); const mockConstructor = jest.fn().mockImplementation(() => mock);
@ -284,5 +857,11 @@ jest.mock('zigbee-herdsman', () => ({
})); }));
module.exports = { module.exports = {
events, ...mock, constructor: mockConstructor, devices, groups, returnDevices, custom_clusters events,
...mock,
constructor: mockConstructor,
devices,
groups,
returnDevices,
custom_clusters,
}; };

View File

@ -7,25 +7,25 @@ describe('Utils', () => {
it('Object has properties', () => { it('Object has properties', () => {
expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b'])).toBeTruthy(); expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b'])).toBeTruthy();
expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b', 'd'])).toBeFalsy(); expect(utils.objectHasProperties({a: 1, b: 2, c: 3}, ['a', 'b', 'd'])).toBeFalsy();
}) });
it('git last commit', async () => { it('git last commit', async () => {
let mockReturnValue = []; let mockReturnValue = [];
jest.mock('git-last-commit', () => ({ jest.mock('git-last-commit', () => ({
getLastCommit: (cb) => cb(mockReturnValue[0], mockReturnValue[1]) getLastCommit: (cb) => cb(mockReturnValue[0], mockReturnValue[1]),
})); }));
mockReturnValue = [false, {shortHash: '123'}] mockReturnValue = [false, {shortHash: '123'}];
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({"commitHash": "123", "version": version}); expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: '123', version: version});
mockReturnValue = [true, null] mockReturnValue = [true, null];
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({"commitHash": expect.any(String), "version": version}); expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: expect.any(String), version: version});
}) });
it('Check dependency version', async () => { it('Check dependency version', async () => {
expect(await utils.getDependencyVersion('zigbee-herdsman')).toStrictEqual({"version": versionHerdsman}); expect(await utils.getDependencyVersion('zigbee-herdsman')).toStrictEqual({version: versionHerdsman});
expect(await utils.getDependencyVersion('zigbee-herdsman-converters')).toStrictEqual({"version": versionHerdsmanConverters}); expect(await utils.getDependencyVersion('zigbee-herdsman-converters')).toStrictEqual({version: versionHerdsmanConverters});
}) });
it('To local iso string', async () => { it('To local iso string', async () => {
var date = new Date('August 19, 1975 23:15:30 UTC+00:00'); var date = new Date('August 19, 1975 23:15:30 UTC+00:00');
@ -35,7 +35,7 @@ describe('Utils', () => {
Date.prototype.getTimezoneOffset = () => -60; Date.prototype.getTimezoneOffset = () => -60;
expect(utils.formatDate(date, 'ISO_8601_local').endsWith('+01:00')).toBeTruthy(); expect(utils.formatDate(date, 'ISO_8601_local').endsWith('+01:00')).toBeTruthy();
Date.prototype.getTimezoneOffset = getTimezoneOffset; Date.prototype.getTimezoneOffset = getTimezoneOffset;
}) });
it('Removes null properties from object', () => { it('Removes null properties from object', () => {
const obj1 = { const obj1 = {
ab: 0, ab: 0,