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,
'node': true,
},
'extends': ['eslint:recommended', 'google', 'plugin:jest/recommended', 'plugin:jest/style'],
'extends': ['eslint:recommended', 'plugin:jest/recommended', 'plugin:jest/style', 'prettier'],
'parserOptions': {
'ecmaVersion': 2018,
'sourceType': 'module',
},
'rules': {
'require-jsdoc': 'off',
'indent': ['error', 4],
'max-len': ['error', {'code': 150}],
'no-prototype-builtins': 'off',
'linebreak-style': ['error', (process.platform === 'win32' ? 'windows' : 'unix')], // https://stackoverflow.com/q/39114446/2771889
'@typescript-eslint/no-floating-promises': 'error',
},
'plugins': [
'jest',
'perfectionist',
],
'overrides': [{
files: ['*.ts'],
@ -35,14 +33,46 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/no-empty-function': 'off',
'@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-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
- name: Build
run: npm run build
- name: Lint
run: |
npm run pretty:check
npm run eslint
- name: Test
run: npm run test-with-coverage
- name: Lint
run: npm run eslint
- name: Docker login
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

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 Zigbee from './zigbee';
import assert from 'assert';
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 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 logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
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';
import Zigbee from './zigbee';
const AllExtensions = [
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension, ExtensionAvailability,
ExtensionPublish,
ExtensionReceive,
ExtensionNetworkMap,
ExtensionSoftReset,
ExtensionHomeAssistant,
ExtensionConfigure,
ExtensionDeviceGroupMembership,
ExtensionBridgeLegacy,
ExtensionBridge,
ExtensionGroups,
ExtensionBind,
ExtensionReport,
ExtensionOnEvent,
ExtensionOTAUpdate,
ExtensionExternalConverters,
ExtensionFrontend,
ExtensionExternalExtension,
ExtensionAvailability,
];
type ExtensionArgs = [Zigbee, MQTT, State, PublishEntityState, EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>, restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>];
type ExtensionArgs = [
Zigbee,
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
let sdNotify: any = null;
@ -72,8 +93,16 @@ export class Controller {
this.exitCallback = exitCallback;
// Initialize extensions.
this.extensionArgs = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
this.enableDisableExtension, this.restartCallback, this.addExtension];
this.extensionArgs = [
this.zigbee,
this.mqtt,
this.state,
this.publishEntityState,
this.eventBus,
this.enableDisableExtension,
this.restartCallback,
this.addExtension,
];
this.extensions = [
new ExtensionOnEvent(...this.extensionArgs),
@ -111,7 +140,7 @@ export class Controller {
this.eventBus.onAdapterDisconnected(this, this.onZigbeeAdapterDisconnected);
} catch (error) {
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(error.stack);
return this.exit(1);
@ -130,9 +159,9 @@ export class Controller {
const devices = this.zigbee.devices(false);
logger.info(`Currently ${devices.length} devices are joined:`);
for (const device of devices) {
const model = device.isSupported ?
`${device.definition.model} - ${device.definition.vendor} ${device.definition.description}` :
'Not supported';
const model = device.isSupported
? `${device.definition.model} - ${device.definition.vendor} ${device.definition.description}`
: 'Not supported';
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
}
@ -170,8 +199,7 @@ export class Controller {
}
}
this.eventBus.onLastSeenChanged(this,
(data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
this.eventBus.onLastSeenChanged(this, (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState));
logger.info(`Zigbee2MQTT started!`);
@ -237,8 +265,7 @@ export class Controller {
await this.stop();
}
@bind async publishEntityState(entity: Group | Device, payload: KeyValue,
stateChangeReason?: StateChangeReason): Promise<void> {
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
let message = {...payload};
// Update state cache with new state.
@ -261,12 +288,18 @@ export class Controller {
if (entity.isDevice() && settings.get().mqtt.include_device_information) {
message.device = {
friendlyName: entity.name, model: entity.definition?.model,
ieeeAddr: entity.ieeeAddr, networkAddress: entity.zh.networkAddress, type: entity.zh.type,
friendlyName: entity.name,
model: entity.definition?.model,
ieeeAddr: entity.ieeeAddr,
networkAddress: entity.zh.networkAddress,
type: entity.zh.type,
manufacturerID: entity.zh.manufacturerID,
powerSource: entity.zh.powerSource, applicationVersion: entity.zh.applicationVersion,
stackVersion: entity.zh.stackVersion, zclVersion: entity.zh.zclVersion,
hardwareVersion: entity.zh.hardwareVersion, dateCode: entity.zh.dateCode,
powerSource: entity.zh.powerSource,
applicationVersion: entity.zh.applicationVersion,
stackVersion: entity.zh.stackVersion,
zclVersion: entity.zh.zclVersion,
hardwareVersion: entity.zh.hardwareVersion,
dateCode: entity.zh.dateCode,
softwareBuildID: entity.zh.softwareBuildID,
// Manufacturer name can contain \u0000, remove this.
// https://github.com/home-assistant/core/issues/85691

View File

@ -1,11 +1,12 @@
import events from 'events';
import logger from './util/logger';
// eslint-disable-next-line
type ListenerKey = object;
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();
constructor() {
@ -57,8 +58,7 @@ export default class EventBus {
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
this.emitter.emit('deviceNetworkAddressChanged', data);
}
public onDeviceNetworkAddressChanged(
key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
this.on('deviceNetworkAddressChanged', callback, key);
}
@ -167,7 +167,7 @@ export default class EventBus {
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] = [];
const wrappedCallback = async (...args: unknown[]): Promise<void> => {
try {
@ -182,7 +182,6 @@ export default class EventBus {
}
public removeListeners(key: ListenerKey): void {
this.callbacksByExtension[key.constructor.name]?.forEach(
(e) => this.emitter.removeListener(e.event, e.callback));
this.callbacksByExtension[key.constructor.name]?.forEach((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 debounce from 'debounce';
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: ['brightness'], 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 {
return entity.isDevice() ? (Date.now() - entity.zh.lastSeen) < this.getTimeout(entity) :
entity.membersDevices().length === 0 || entity.membersDevices().some((d) => this.availabilityCache[d.ieeeAddr]);
return entity.isDevice()
? 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 {
@ -80,7 +82,7 @@ export default class Availability extends Extension {
for (let i = 1; i <= attempts; i++) {
try {
// 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;
@ -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()) {
const ago = Date.now() - entity.zh.lastSeen;
if (this.isActiveDevice(entity)) {
@ -226,8 +228,12 @@ export default class Availability extends Extension {
const options: KeyValue = device.options;
const state = this.state.get(device);
const meta: zhc.Tz.Meta = {
message: this.state.get(device), mapped: device.definition, endpoint_name: null,
options, state, device: device.zh,
message: this.state.get(device),
mapped: device.definition,
endpoint_name: null,
options,
state,
device: device.zh,
};
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 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 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_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 ALL_CLUSTER_CANDIDATES: readonly ClusterName[] = [
'genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering', 'hvacThermostat', 'msIlluminanceMeasurement',
'msTemperatureMeasurement', 'msRelativeHumidity', 'msSoilMoisture', 'msCO2',
'genScenes',
'genOnOff',
'genLevelCtrl',
'lightingColorCtrl',
'closuresWindowCovering',
'hvacThermostat',
'msIlluminanceMeasurement',
'msTemperatureMeasurement',
'msRelativeHumidity',
'msSoilMoisture',
'msCO2',
];
// See zigbee-herdsman-converters
const DEFAULT_BIND_GROUP = {type: 'group_number', ID: 901, name: 'default_bind_group'};
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) {
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;
return {
colorTemperature: (value & 1 << 4) > 0,
colorXY: (value & 1 << 3) > 0,
colorTemperature: (value & (1 << 4)) > 0,
colorXY: (value & (1 << 3)) > 0,
};
};
const REPORT_CLUSTERS: Readonly<Partial<Record<ClusterName, Readonly<{
attribute: string;
minimumReportInterval: number;
maximumReportInterval: number;
reportableChange: number;
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
}>[]>>> = {
'genOnOff': [
{attribute: 'onOff', ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0},
],
'genLevelCtrl': [
{attribute: 'currentLevel', ...DEFAULT_REPORT_CONFIG},
],
'lightingColorCtrl': [
const REPORT_CLUSTERS: Readonly<
Partial<
Record<
ClusterName,
Readonly<{
attribute: string;
minimumReportInterval: number;
maximumReportInterval: number;
reportableChange: number;
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
}>[]
>
>
> = {
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,
},
{
attribute: 'currentX', ...DEFAULT_REPORT_CONFIG,
attribute: 'currentX',
...DEFAULT_REPORT_CONFIG,
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,
},
],
'closuresWindowCovering': [
closuresWindowCovering: [
{attribute: 'currentPositionLiftPercentage', ...DEFAULT_REPORT_CONFIG},
{attribute: 'currentPositionTiltPercentage', ...DEFAULT_REPORT_CONFIG},
],
};
type PollOnMessage = {
cluster: Readonly<Partial<Record<ClusterName, {type: string, data: KeyValue}[]>>>;
read: Readonly<{cluster: string, attributes: string[], attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
manufacturerIDs: readonly number[];
manufacturerNames: readonly string[];
}[];
@ -92,9 +108,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{type: 'commandMove', data: {}},
{type: 'commandMoveToLevelWithOnOff', data: {}},
],
genScenes: [
{type: 'commandRecall', data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
},
// Read the following attributes
read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']},
@ -107,10 +121,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
],
manufacturerNames: [
'GLEDOPTO',
'Trust International B.V.\u0000',
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
},
{
cluster: {
@ -126,9 +137,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{type: 'commandOffWithEffect', data: {}},
{type: 'commandToggle', data: {}},
],
genScenes: [
{type: 'commandRecall', data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
manuSpecificPhilips: [
{type: 'commandHueNotification', data: {button: 1}},
{type: 'commandHueNotification', data: {button: 4}},
@ -143,16 +152,11 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO,
Zcl.ManufacturerCode.BUSCH_JAEGER_ELEKTRO,
],
manufacturerNames: [
'GLEDOPTO',
'Trust International B.V.\u0000',
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
},
{
cluster: {
genScenes: [
{type: 'commandRecall', data: {}},
],
genScenes: [{type: 'commandRecall', data: {}}],
},
read: {
cluster: 'lightingColorCtrl',
@ -184,15 +188,16 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
Zcl.ManufacturerCode.TELINK_MICRO,
// Note: ManufacturerCode.BUSCH_JAEGER is left out intentionally here as their devices don't support colors
],
manufacturerNames: [
'GLEDOPTO',
'Trust International B.V.\u0000',
],
manufacturerNames: ['GLEDOPTO', 'Trust International B.V.\u0000'],
},
];
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 {
@ -258,8 +263,8 @@ export default class Bind extends Extension {
const attemptedClusters = [];
const bindSource: zh.Endpoint = parsedSource.endpoint;
const bindTarget: number | zh.Group | zh.Endpoint = (target instanceof Device) ? parsedTarget.endpoint :
((target instanceof Group) ? target.zh : Number(target.ID));
const bindTarget: number | zh.Group | zh.Endpoint =
target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
// Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters.
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';
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
matchingClusters = ((bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster)));
matchingClusters =
(bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
(bindSource.supportsInputCluster(cluster) && bindTarget.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 (type === 'bind') {
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);
}
}
@ -368,7 +374,8 @@ export default class Bind extends Extension {
}
await this.setupReporting(bindsToGroup);
} else { // action === remove/remove_all
} else {
// action === remove/remove_all
if (!data.skipDisableReporting) {
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]) {
/* istanbul ignore else */
if (!c.condition || await c.condition(endpoint)) {
if (!c.condition || (await c.condition(endpoint))) {
const i = {...c};
delete i.condition;
@ -458,7 +465,7 @@ export default class Bind extends Extension {
for (const b of endpoint.binds) {
/* 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);
}
}
@ -471,11 +478,11 @@ export default class Bind extends Extension {
for (const item of REPORT_CLUSTERS[cluster as ClusterName]) {
/* istanbul ignore else */
if (!item.condition || await item.condition(endpoint)) {
if (!item.condition || (await item.condition(endpoint))) {
const i = {...item};
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 we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
*/
const polls = POLL_ON_MESSAGE.filter(
(p) => p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
const polls = POLL_ON_MESSAGE.filter((p) =>
p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
);
if (polls.length) {
@ -526,9 +533,11 @@ export default class Bind extends Extension {
for (const endpoint of toPoll) {
for (const poll of polls) {
if ((!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) &&
!poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) ||
!endpoint.supportsInputCluster(poll.read.cluster)) {
if (
(!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) &&
!poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) ||
!endpoint.supportsInputCluster(poll.read.cluster)
) {
continue;
}

View File

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

View File

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

View File

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

View File

@ -1,13 +1,21 @@
import * as zhc from 'zigbee-herdsman-converters';
import logger from '../util/logger';
import * as settings from '../util/settings';
import {loadExternalConverter} from '../util/utils';
import Extension from './extension';
import logger from '../util/logger';
export default class ExternalConverters extends Extension {
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>, addExtension: (extension: Extension) => Promise<void>) {
constructor(
zigbee: Zigbee,
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);
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(
`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(
`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 utils from '../util/utils';
import fs from 'fs';
import data from './../util/data';
import path from 'path';
import logger from './../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from 'bind-decorator';
import Extension from './extension';
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> {
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.publishExtensions();
}
@ -24,13 +25,16 @@ export default class ExternalExtension extends Extension {
return data.joinPath('extension');
}
private getListOfUserDefinedExtensions(): {name: string, code: string}[] {
private getListOfUserDefinedExtensions(): {name: string; code: string}[] {
const basePath = this.getExtensionsBasePath();
if (fs.existsSync(basePath)) {
return fs.readdirSync(basePath).filter((f) => f.endsWith('.js')).map((fileName) => {
const extensionFilePath = path.join(basePath, fileName);
return {'name': fileName, 'code': fs.readFileSync(extensionFilePath, 'utf-8')};
});
return fs
.readdirSync(basePath)
.filter((f) => f.endsWith('.js'))
.map((fileName) => {
const extensionFilePath = path.join(basePath, fileName);
return {name: fileName, code: fs.readFileSync(extensionFilePath, 'utf-8')};
});
} else {
return [];
}
@ -88,8 +92,7 @@ export default class ExternalExtension extends Extension {
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
await this.enableDisableExtension(false, ConstructorClass.name);
// @ts-ignore
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState,
this.eventBus, settings, logger));
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
}
private async loadUserDefinedExtensions(): Promise<void> {
@ -100,9 +103,15 @@ export default class ExternalExtension extends Extension {
private async publishExtensions(): Promise<void> {
const extensions = this.getListOfUserDefinedExtensions();
await this.mqtt.publish('bridge/extensions', stringify(extensions), {
retain: true,
qos: 0,
}, settings.get().mqtt.base_topic, true);
await this.mqtt.publish(
'bridge/extensions',
stringify(extensions),
{
retain: true,
qos: 0,
},
settings.get().mqtt.base_topic,
true,
);
}
}

View File

@ -1,18 +1,19 @@
import http from 'http';
import https from 'https';
import bind from 'bind-decorator';
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
import finalhandler from 'finalhandler';
import logger from '../util/logger';
import frontend from 'zigbee2mqtt-frontend';
import WebSocket from 'ws';
import fs from 'fs';
import http from 'http';
import https from 'https';
import stringify from 'json-stable-stringify-without-jsonify';
import net from 'net';
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 utils from '../util/utils';
import stringify from 'json-stable-stringify-without-jsonify';
import Extension from './extension';
import bind from 'bind-decorator';
/**
* This extension servers the frontend
@ -30,17 +31,24 @@ export default class Frontend extends Extension {
private fileServer: RequestHandler;
private wss: WebSocket.Server = null;
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>, addExtension: (extension: Extension) => Promise<void>) {
constructor(
zigbee: Zigbee,
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);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
}
private isHttpsConfigured():boolean {
private isHttpsConfigured(): boolean {
if (this.sslCert && 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 true;
@ -48,12 +56,12 @@ export default class Frontend extends Extension {
return false;
}
override async start(): Promise<void> {
if (this.isHttpsConfigured()) {
const serverOptions = {
key: fs.readFileSync(this.sslKey),
cert: fs.readFileSync(this.sslCert)};
cert: fs.readFileSync(this.sslCert),
};
this.server = https.createServer(serverOptions, this.onRequest);
} else {
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 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 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 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 STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
'state': () => true,
'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': (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' &&
((e.features.some((f) => f.name === `color_${value}`)) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp')))),
state: () => true,
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: (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' &&
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
),
};
interface ParsedMQTTMessage {
type: 'remove' | 'add' | 'remove_all', resolvedEntityGroup: Group, resolvedEntityDevice: Device,
error: string, groupKey: string, deviceKey: string, triggeredViaLegacyApi: boolean,
skipDisableReporting: boolean, resolvedEntityEndpoint: zh.Endpoint,
type: 'remove' | 'add' | 'remove_all';
resolvedEntityGroup: Group;
resolvedEntityDevice: Device;
error: string;
groupKey: string;
deviceKey: string;
triggeredViaLegacyApi: boolean;
skipDisableReporting: boolean;
resolvedEntityEndpoint: zh.Endpoint;
}
export default class Groups extends Extension {
@ -42,8 +53,13 @@ export default class Groups extends Extension {
const settingsGroups = settings.getGroups();
const zigbeeGroups = this.zigbee.groups();
const addRemoveFromGroup = async (action: 'add' | 'remove', deviceName: string, groupName: string | number,
endpoint: zh.Endpoint, group: Group): Promise<void> => {
const addRemoveFromGroup = async (
action: 'add' | 'remove',
deviceName: string,
groupName: string | number,
endpoint: zh.Endpoint,
group: Group,
): Promise<void> => {
try {
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} '${deviceName}' to group '${groupName}'`);
@ -119,7 +135,8 @@ export default class Groups extends Extension {
let endpointName: string = null;
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}`));
if (endpointNameMatch) {
@ -140,8 +157,11 @@ export default class Groups extends Extension {
if (entity instanceof Device) {
for (const group of groups) {
if (group.zh.hasMember(entity.endpoint(endpointName)) && !equals(this.lastOptimisticState[group.ID], payload) &&
this.shouldPublishPayloadForGroup(group, payload)) {
if (
group.zh.hasMember(entity.endpoint(endpointName)) &&
!equals(this.lastOptimisticState[group.ID], payload) &&
this.shouldPublishPayloadForGroup(group, payload)
) {
this.lastOptimisticState[group.ID] = payload;
await this.publishEntityState(group, payload, reason);
@ -197,7 +217,7 @@ export default class Groups extends Extension {
}
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 {
@ -263,7 +283,7 @@ export default class Groups extends Extension {
/* istanbul ignore else */
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}));
}
@ -309,8 +329,15 @@ export default class Groups extends Extension {
}
return {
resolvedEntityGroup, resolvedEntityDevice, type, error, groupKey, deviceKey,
triggeredViaLegacyApi, skipDisableReporting, resolvedEntityEndpoint,
resolvedEntityGroup,
resolvedEntityDevice,
type,
error,
groupKey,
deviceKey,
triggeredViaLegacyApi,
skipDisableReporting,
resolvedEntityEndpoint,
};
}
@ -321,10 +348,17 @@ export default class Groups extends Extension {
return;
}
let {
resolvedEntityGroup, resolvedEntityDevice, type, error, triggeredViaLegacyApi,
groupKey, deviceKey, skipDisableReporting, resolvedEntityEndpoint,
const {
resolvedEntityGroup,
resolvedEntityDevice,
type,
triggeredViaLegacyApi,
groupKey,
deviceKey,
skipDisableReporting,
resolvedEntityEndpoint,
} = parsed;
let error = parsed.error;
let changedGroups: Group[] = [];
if (!error) {
@ -369,7 +403,8 @@ export default class Groups extends Extension {
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`);
changedGroups = this.zigbee.groups().filter((g) => g.zh.members.includes(resolvedEntityEndpoint));
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 Extension from './extension';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
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
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 = {
type: 'sensor',
@ -24,10 +33,10 @@ const sensorClick: DiscoveryEntry = {
};
interface Discovered {
mockProperties: Set<MockProperty>,
messages: {[s: string]: {payload: string, published: boolean}},
triggers: Set<string>,
discovered: boolean,
mockProperties: Set<MockProperty>;
messages: {[s: string]: {payload: string; published: boolean}};
triggers: Set<string>;
discovered: boolean;
}
const ACCESS_STATE = 0b001;
@ -37,11 +46,36 @@ const defaultStatusTopic = 'homeassistant/status';
const legacyMapping = [
{
models: ['WXKG01LM', 'HS1EB/HS1EB-E', 'ICZB-KPD14S', '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'],
models: [
'WXKG01LM',
'HS1EB/HS1EB-E',
'ICZB-KPD14S',
'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,
},
{
@ -78,16 +112,26 @@ class Bridge {
private discoveryEntries: DiscoveryEntry[];
readonly options: {
ID?: string,
homeassistant?: KeyValue,
ID?: string;
homeassistant?: KeyValue;
};
/* eslint-disable brace-style */
get ID(): string {return this.coordinatorIeeeAddress;}
get name(): string {return 'bridge';}
get hardwareVersion(): string {return this.coordinatorType;}
get firmwareVersion(): string {return this.coordinatorFirmwareVersion;}
get configs(): DiscoveryEntry[] {return this.discoveryEntries;}
get ID(): string {
return this.coordinatorIeeeAddress;
}
get name(): string {
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[]) {
this.coordinatorIeeeAddress = ieeeAdress;
@ -104,8 +148,12 @@ class Bridge {
};
}
isDevice(): this is Device {return false;}
isGroup(): this is Group {return false;}
isDevice(): this is Device {
return false;
}
isGroup(): this is Group {
return false;
}
/* eslint-enable brace-style */
}
@ -120,13 +168,20 @@ export default class HomeAssistant extends Extension {
private statusTopic = settings.get().homeassistant.status_topic;
private entityAttributes = settings.get().homeassistant.legacy_entity_attributes;
private zigbee2MQTTVersion: string;
private discoveryOrigin: {name: string, sw: string, url: string};
private discoveryOrigin: {name: string; sw: string; url: string};
private bridge: Bridge;
private bridgeIdentifier: string;
constructor(zigbee: Zigbee, mqtt: MQTT, state: State, publishEntityState: PublishEntityState,
eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>, addExtension: (extension: Extension) => Promise<void>) {
constructor(
zigbee: Zigbee,
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);
if (settings.get().advanced.output === 'attribute') {
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];
}
private exposeToConfig(exposes: zhc.Expose[], entityType: 'device' | 'group',
allExposes: zhc.Expose[], definition?: zhc.Definition): DiscoveryEntry[] {
private exposeToConfig(
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
// to use for a bulb (e.g. color_xy/color_temp)
assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed');
const firstExpose = exposes[0];
assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type),
`Unsupported expose type ${firstExpose.type} for group`);
assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`);
const discoveryEntries: DiscoveryEntry[] = [];
const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined;
const getProperty = (feature: zhc.Feature): string => entityType === 'group' ?
featurePropertyWithoutEndpoint(feature) : feature.property;
const getProperty = (feature: zhc.Feature): string => (entityType === 'group' ? featurePropertyWithoutEndpoint(feature) : feature.property);
/* istanbul ignore else */
if (firstExpose.type === 'light') {
@ -216,9 +273,10 @@ export default class HomeAssistant extends Extension {
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.
// 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'),
e.features.findIndex((ee) => ee.name === 'color_hs')])
.filter((d) => d[0] !== -1 && d[1] !== -1 && d[1] < d[0]).length !== 0;
const preferHS =
exposes
.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 = {
type: 'light',
@ -246,16 +304,24 @@ export default class HomeAssistant extends Extension {
}
if (hasColorTemp) {
const colorTemps = exposes.map((expose) => expose.features.find((e) => e.name === 'color_temp'))
.filter((e) => e).filter(isNumericExposeFeature);
const colorTemps = exposes
.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 min = Math.max(...colorTemps.map((e) => e.value_min));
discoveryEntry.discovery_payload.max_mireds = max;
discoveryEntry.discovery_payload.min_mireds = min;
}
const effects = utils.arrayUnique(utils.flatten(
allExposes.filter(isEnumExposeFeature).filter((e) => e.name === 'effect').map((e) => e.values)));
const effects = utils.arrayUnique(
utils.flatten(
allExposes
.filter(isEnumExposeFeature)
.filter((e) => e.name === 'effect')
.map((e) => e.values),
),
);
if (effects.length) {
discoveryEntry.discovery_payload.effect = true;
discoveryEntry.discovery_payload.effect_list = effects;
@ -295,8 +361,7 @@ export default class HomeAssistant extends Extension {
discoveryEntries.push(discoveryEntry);
} else if (firstExpose.type === 'climate') {
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
const setpoint = firstExpose.features.filter(isNumericExposeFeature)
.find((f) => setpointProperties.includes(f.name));
const setpoint = firstExpose.features.filter(isNumericExposeFeature).find((f) => setpointProperties.includes(f.name));
assert(setpoint, 'No setpoint found');
const temperature = firstExpose.features.find((f) => f.name === 'local_temperature');
assert(temperature, 'No temperature found');
@ -339,45 +404,39 @@ export default class HomeAssistant extends Extension {
if (state) {
discoveryEntry.mockProperties.push({property: state.property, value: null});
discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template = `{% set values = ` +
`{None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
discoveryEntry.discovery_payload.action_template =
`{% set values = ` +
`{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');
if (coolingSetpoint) {
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_low_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
discoveryEntry.discovery_payload.temperature_high_state_template =
`{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
} else {
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_topic = true;
}
const fanMode = firstExpose.features.filter(isEnumExposeFeature)
.find((f) => f.name === 'fan_mode');
const fanMode = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'fan_mode');
if (fanMode) {
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
discoveryEntry.discovery_payload.fan_mode_state_template =
`{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
}
const swingMode = firstExpose.features.filter(isEnumExposeFeature)
.find((f) => f.name === 'swing_mode');
const swingMode = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'swing_mode');
if (swingMode) {
discoveryEntry.discovery_payload.swing_modes = swingMode.values;
discoveryEntry.discovery_payload.swing_mode_command_topic = true;
discoveryEntry.discovery_payload.swing_mode_state_template =
`{{ value_json.${swingMode.property} }}`;
discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json.${swingMode.property} }}`;
discoveryEntry.discovery_payload.swing_mode_state_topic = true;
}
@ -385,13 +444,11 @@ export default class HomeAssistant extends Extension {
if (preset) {
discoveryEntry.discovery_payload.preset_modes = preset.values;
discoveryEntry.discovery_payload.preset_mode_command_topic = 'preset';
discoveryEntry.discovery_payload.preset_mode_value_template =
`{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
}
const tempCalibration = firstExpose.features.filter(isNumericExposeFeature)
.find((f) => f.name === 'local_temperature_calibration');
const tempCalibration = firstExpose.features.filter(isNumericExposeFeature).find((f) => f.name === 'local_temperature_calibration');
if (tempCalibration) {
const discoveryEntry: DiscoveryEntry = {
type: 'number',
@ -418,8 +475,7 @@ export default class HomeAssistant extends Extension {
discoveryEntries.push(discoveryEntry);
}
const piHeatingDemand = firstExpose.features.filter(isNumericExposeFeature)
.find((f) => f.name === 'pi_heating_demand');
const piHeatingDemand = firstExpose.features.filter(isNumericExposeFeature).find((f) => f.name === 'pi_heating_demand');
if (piHeatingDemand) {
const discoveryEntry: DiscoveryEntry = {
type: 'sensor',
@ -480,13 +536,13 @@ export default class HomeAssistant extends Extension {
discoveryEntries.push(discoveryEntry);
} else if (firstExpose.type === 'cover') {
const state = exposes.find((expose) => expose.features.find((e) => e.name === 'state'))
?.features.find((f) => f.name === 'state');
const position = exposes.find((expose) => expose.features.find((e) => e.name === 'position'))
const state = exposes.find((expose) => expose.features.find((e) => e.name === 'state'))?.features.find((f) => f.name === 'state');
const position = exposes
.find((expose) => expose.features.find((e) => e.name === 'position'))
?.features.find((f) => f.name === 'position');
const tilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt'))
?.features.find((f) => f.name === 'tilt');
const motorState = allExposes?.filter(isEnumExposeFeature)
const tilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt'))?.features.find((f) => f.name === 'tilt');
const motorState = allExposes
?.filter(isEnumExposeFeature)
.find((e) => ['motor_state', 'moving'].includes(e.name) && e.access === ACCESS_STATE);
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.
// The movement direction is calculated (assumed) in this case.
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 ` +
`{% 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_closing = closingState;
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 %} ` +
`${stoppedState} {% endif %}`;
}
@ -534,9 +592,8 @@ export default class HomeAssistant extends Extension {
// If curtains do not have `running`, `motor_state` or `moving` properties.
if (!discoveryEntry.discovery_payload.value_template) {
discoveryEntry.discovery_payload.value_template =
`{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`,
discoveryEntry.discovery_payload.state_open = 'OPEN';
(discoveryEntry.discovery_payload.value_template = `{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`),
(discoveryEntry.discovery_payload.state_open = 'OPEN');
discoveryEntry.discovery_payload.state_closed = 'CLOSE';
discoveryEntry.discovery_payload.state_stopped = 'STOP';
}
@ -546,7 +603,8 @@ export default class HomeAssistant extends Extension {
}
if (position) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
discoveryEntry.discovery_payload = {
...discoveryEntry.discovery_payload,
position_template: `{{ value_json.${featurePropertyWithoutEndpoint(position)} }}`,
set_position_template: `{ "${getProperty(position)}": {{ position }} }`,
set_position_topic: true,
@ -555,7 +613,8 @@ export default class HomeAssistant extends Extension {
}
if (tilt) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
discoveryEntry.discovery_payload = {
...discoveryEntry.discovery_payload,
tilt_command_topic: true,
tilt_status_topic: true,
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
// ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is
// always a valid speed.
let speeds = ['off'].concat(['low', 'medium', 'high', '1', '2', '3', '4', '5',
'6', '7', '8', '9'].filter((s) => speed.values.includes(s)));
let speeds = ['off'].concat(
['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));
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_command_topic = true;
discoveryEntry.discovery_payload.percentage_value_template =
`{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`;
discoveryEntry.discovery_payload.percentage_command_template =
`{{ {${percentCommands}}[value] | default('') }}`;
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`;
discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`;
discoveryEntry.discovery_payload.speed_range_min = 1;
discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1;
assert(presets.length !== 0);
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode';
discoveryEntry.discovery_payload.preset_mode_value_template =
`{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}]` +
` else 'None' | default('None') }}`;
`{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}]` + ` else 'None' | default('None') }}`;
discoveryEntry.discovery_payload.preset_modes = presets;
}
discoveryEntries.push(discoveryEntry);
} else if (isBinaryExposeFeature(firstExpose)) {
const lookup: {[s: string]: KeyValue}= {
const lookup: {[s: string]: KeyValue} = {
activity_led_indicator: {icon: 'mdi:led-on'},
auto_off: {icon: 'mdi:flash-auto'},
battery_low: {entity_category: 'diagnostic', device_class: 'battery'},
@ -699,14 +756,13 @@ export default class HomeAssistant extends Extension {
const discoveryEntry: DiscoveryEntry = {
type: 'switch',
mockProperties: [{property: firstExpose.property, value: null}],
object_id: endpoint ?
`switch_${firstExpose.name}_${endpoint}` :
`switch_${firstExpose.name}`,
object_id: endpoint ? `switch_${firstExpose.name}_${endpoint}` : `switch_${firstExpose.name}`,
discovery_payload: {
name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label,
value_template: typeof firstExpose.value_on === 'boolean' ?
`{% if value_json.${firstExpose.property} %} true {% else %} false {% endif %}` :
`{{ value_json.${firstExpose.property} }}`,
value_template:
typeof firstExpose.value_on === 'boolean'
? `{% if value_json.${firstExpose.property} %} true {% else %} false {% endif %}`
: `{{ value_json.${firstExpose.property} }}`,
payload_on: firstExpose.value_on.toString(),
payload_off: firstExpose.value_off.toString(),
command_topic: true,
@ -735,15 +791,12 @@ export default class HomeAssistant extends Extension {
}
} else if (isNumericExposeFeature(firstExpose)) {
const lookup: {[s: string]: KeyValue} = {
ac_frequency: {device_class: 'frequency', enabled_by_default: false, entity_category: 'diagnostic',
state_class: 'measurement'},
ac_frequency: {device_class: 'frequency', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'},
action_duration: {icon: 'mdi:timer', device_class: 'duration'},
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_temperature_max: {device_class: 'temperature', entity_category: 'config',
icon: 'mdi:thermometer-high'},
alarm_temperature_min: {device_class: 'temperature', entity_category: 'config',
icon: 'mdi:thermometer-low'},
alarm_temperature_max: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-high'},
alarm_temperature_min: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-low'},
angle: {icon: 'angle-acute'},
angle_axis: {icon: 'angle-acute'},
aqi: {device_class: 'aqi', state_class: 'measurement'},
@ -756,8 +809,7 @@ export default class HomeAssistant extends Extension {
ballast_physical_minimum_level: {entity_category: 'diagnostic'},
battery: {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',
enabled_by_default: true},
battery_voltage: {device_class: 'voltage', entity_category: 'diagnostic', state_class: 'measurement', enabled_by_default: true},
boost_heating_countdown: {device_class: 'duration'},
boost_heating_countdown_time_set: {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'},
comfort_temperature: {entity_category: 'config', icon: 'mdi:thermometer'},
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'},
current: {
@ -790,7 +844,9 @@ export default class HomeAssistant extends Extension {
deadzone_temperature: {entity_category: 'config', icon: 'mdi:thermometer'},
detection_interval: {icon: 'mdi:timer'},
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'},
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'},
position: {icon: 'mdi:valve', state_class: 'measurement'},
power: {device_class: 'power', entity_category: 'diagnostic', state_class: 'measurement'},
power_factor: {device_class: 'power_factor', enabled_by_default: false,
entity_category: 'diagnostic', state_class: 'measurement'},
power_factor: {device_class: 'power_factor', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'},
power_outage_count: {icon: 'mdi:counter', enabled_by_default: false},
precision: {entity_category: 'config', icon: 'mdi:decimal-comma-increase'},
pressure: {device_class: 'atmospheric_pressure', state_class: 'measurement'},
presence_timeout: {entity_category: 'config', icon: 'mdi:timer'},
reporting_time: {entity_category: 'config', icon: 'mdi:clock-time-one-outline'},
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: {
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'},
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'});
}
const allowsSet = firstExpose.access & ACCESS_SET;
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.
// https://github.com/Koenkk/zigbee2mqtt/issues/15958#issuecomment-1377483202
if (discoveryEntry.discovery_payload.device_class &&
!discoveryEntry.discovery_payload.unit_of_measurement) {
if (discoveryEntry.discovery_payload.device_class && !discoveryEntry.discovery_payload.unit_of_measurement) {
delete discoveryEntry.discovery_payload.device_class;
}
@ -1008,8 +1065,7 @@ export default class HomeAssistant extends Extension {
week: {entity_category: 'config', icon: 'mdi:calendar-clock'},
};
const valueTemplate = firstExpose.access & ACCESS_STATE ?
`{{ value_json.${firstExpose.property} }}` : undefined;
const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json.${firstExpose.property} }}` : undefined;
if (firstExpose.access & ACCESS_STATE) {
discoveryEntries.push({
@ -1078,8 +1134,7 @@ export default class HomeAssistant extends Extension {
color_options: {icon: 'mdi:palette'},
level_config: {entity_category: 'diagnostic'},
programming_mode: {icon: 'mdi:calendar-clock'},
program: {value_template: `{{ value_json.${firstExpose.property}|default('',True) ` +
`| truncate(254, True, '', 0) }}`},
program: {value_template: `{{ value_json.${firstExpose.property}|default('',True) ` + `| truncate(254, True, '', 0) }}`},
schedule_settings: {icon: 'mdi:calendar-clock'},
};
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.
// This takes precedence over definitions in this file.
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') {
discoveryEntries.forEach((d) => d.discovery_payload.entity_category = 'diagnostic');
discoveryEntries.forEach((d) => (d.discovery_payload.entity_category = 'diagnostic'));
}
discoveryEntries.forEach((d) => {
@ -1273,12 +1328,15 @@ export default class HomeAssistant extends Extension {
// @ts-ignore
configs.push(entity.definition.homeassistant);
}
} else if (isGroup) { // group
} else if (isGroup) {
// group
const exposesByType: {[s: string]: zhc.Expose[]} = {};
const allExposes: zhc.Expose[] = [];
entity.zh.members.map((e) => this.zigbee.resolveEntity(e.getDevice()) as Device)
.filter((d) => d.definition).forEach((device) => {
entity.zh.members
.map((e) => this.zigbee.resolveEntity(e.getDevice()) as Device)
.filter((d) => d.definition)
.forEach((device) => {
const exposes = device.exposes();
allExposes.push(...exposes);
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)
.map((exposes) => this.exposeToConfig(exposes, 'group', allExposes)));
configs = [].concat(...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, 'group', allExposes)));
} else {
// Discover bridge config.
configs.push(...entity.configs);
@ -1370,8 +1427,7 @@ export default class HomeAssistant extends Extension {
value_template: `{{ value_json['update']['installed_version'] }}`,
latest_version_template: `{{ value_json['update']['latest_version'] }}`,
json_attributes_topic: `${settings.get().mqtt.base_topic}/${entity.name}`, // state topic
json_attributes_template:
`{"in_progress": {{ iif(value_json['update']['state'] == 'updating', 'true', 'false') }} }`,
json_attributes_template: `{"in_progress": {{ iif(value_json['update']['state'] == 'updating', 'true', 'false') }} }`,
},
};
configs.push(updateSensor);
@ -1431,8 +1487,10 @@ export default class HomeAssistant extends Extension {
if (isGroup && entity.zh.members.length === 0) {
return;
} else if (isDevice && (!entity.definition || entity.zh.interviewing ||
(entity.options.hasOwnProperty('homeassistant') && !entity.options.homeassistant))) {
} else if (
isDevice &&
(!entity.definition || entity.zh.interviewing || (entity.options.hasOwnProperty('homeassistant') && !entity.options.homeassistant))
) {
return;
}
@ -1467,7 +1525,7 @@ export default class HomeAssistant extends Extension {
payload.tilt_status_topic = stateTopic;
}
if (this.entityAttributes && (isDevice || isGroup) ) {
if (this.entityAttributes && (isDevice || isGroup)) {
payload.json_attributes_topic = stateTopic;
}
@ -1494,23 +1552,24 @@ export default class HomeAssistant extends Extension {
payload.origin = this.discoveryOrigin;
// 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`}];
if (isDevice||isGroup) {
if (isDevice || isGroup) {
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
payload.availability_mode = 'all';
payload.availability.push({topic: `${baseTopic}/availability`});
}
} else { // Bridge availability is different.
} else {
// Bridge availability is different.
payload.availability_mode = 'all';
}
if (isDevice && entity.options.disabled) {
// 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) {
payload.availability.forEach((a: KeyValue) => a.value_template = '{{ value_json.state }}');
payload.availability.forEach((a: KeyValue) => (a.value_template = '{{ value_json.state }}'));
}
} else {
delete payload.availability;
@ -1559,18 +1618,15 @@ export default class HomeAssistant extends Extension {
}
if (payload.temperature_command_topic) {
payload.temperature_command_topic =
`${baseTopic}/${commandTopicPrefix}set/${payload.temperature_command_topic}`;
payload.temperature_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_command_topic}`;
}
if (payload.temperature_low_command_topic) {
payload.temperature_low_command_topic =
`${baseTopic}/${commandTopicPrefix}set/${payload.temperature_low_command_topic}`;
payload.temperature_low_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_low_command_topic}`;
}
if (payload.temperature_high_command_topic) {
payload.temperature_high_command_topic =
`${baseTopic}/${commandTopicPrefix}set/${payload.temperature_high_command_topic}`;
payload.temperature_high_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_high_command_topic}`;
}
if (payload.fan_mode_state_topic) {
@ -1606,8 +1662,7 @@ export default class HomeAssistant extends Extension {
}
if (payload.preset_mode_command_topic) {
payload.preset_mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/` +
payload.preset_mode_command_topic;
payload.preset_mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/` + payload.preset_mode_command_topic;
}
if (payload.action_topic) {
@ -1622,8 +1677,7 @@ export default class HomeAssistant extends Extension {
return;
} else if (ignoreName && key === 'name') {
return;
} else if (['number', 'string', 'boolean'].includes(typeof obj[key]) ||
Array.isArray(obj[key])) {
} else if (['number', 'string', 'boolean'].includes(typeof obj[key]) || Array.isArray(obj[key])) {
payload[key] = obj[key];
} else if (obj[key] === null) {
delete payload[key];
@ -1680,8 +1734,7 @@ export default class HomeAssistant extends Extension {
return;
}
if (!isDeviceAutomation &&
(!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
if (!isDeviceAutomation && (!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
return;
}
} catch (e) {
@ -1691,7 +1744,7 @@ export default class HomeAssistant extends Extension {
// Group discovery topic uses "ENCODEDBASETOPIC_GROUPID", device use ieeeAddr
const ID = discoveryMatch[2].includes('_') ? discoveryMatch[2].split('_')[1] : discoveryMatch[2];
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
if (entity) {
@ -1716,8 +1769,7 @@ export default class HomeAssistant extends Extension {
} else {
this.getDiscovered(entity).messages[topic] = {payload: stringify(message), published: true};
}
} else if ((data.topic === this.statusTopic || data.topic === defaultStatusTopic) &&
data.message.toLowerCase() === 'online') {
} else if ((data.topic === this.statusTopic || data.topic === defaultStatusTopic) && data.message.toLowerCase() === 'online') {
const timer = setTimeout(async () => {
// Publish all device states.
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 {
const identifierPostfix = entity.isGroup() ?
`zigbee2mqtt_${this.getEncodedBaseTopic()}` : 'zigbee2mqtt';
const identifierPostfix = entity.isGroup() ? `zigbee2mqtt_${this.getEncodedBaseTopic()}` : 'zigbee2mqtt';
// Allow device name to be overridden by homeassistant config
let deviceName = entity.name;
@ -1830,7 +1881,11 @@ export default class HomeAssistant extends Extension {
}
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 {
@ -1838,11 +1893,12 @@ export default class HomeAssistant extends Extension {
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;
if (device.options.hasOwnProperty('homeassistant') && (haConfig == null ||
(haConfig.hasOwnProperty('device_automation') && typeof haConfig === 'object' &&
haConfig.device_automation == null))) {
if (
device.options.hasOwnProperty('homeassistant') &&
(haConfig == null || (haConfig.hasOwnProperty('device_automation') && typeof haConfig === 'object' && haConfig.device_automation == null))
) {
return;
}
@ -1944,8 +2000,7 @@ export default class HomeAssistant extends Extension {
state_topic_postfix: 'info',
value_template: '{{ value_json.log_level | lower }}',
command_topic: `${baseTopic}/request/options`,
command_template:
'{"options": {"advanced": {"log_level": "{{ value }}" } } }',
command_template: '{"options": {"advanced": {"log_level": "{{ value }}" } } }',
options: settings.LOG_LEVELS,
},
},
@ -1962,7 +2017,6 @@ export default class HomeAssistant extends Extension {
state_topic_postfix: 'info',
value_template: '{{ value_json.version }}',
},
},
{
type: 'sensor',
@ -1977,7 +2031,6 @@ export default class HomeAssistant extends Extension {
state_topic_postfix: 'info',
value_template: '{{ value_json.coordinator.meta.revision }}',
},
},
{
type: 'sensor',
@ -1989,7 +2042,7 @@ export default class HomeAssistant extends Extension {
enabled_by_default: false,
state_topic: true,
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_template: '{{ value_json.data.value | tojson }}',
},
@ -2005,8 +2058,7 @@ export default class HomeAssistant extends Extension {
entity_category: 'diagnostic',
state_topic: true,
state_topic_postfix: 'info',
value_template:
'{{ iif(value_json.permit_join_timeout is defined, value_json.permit_join_timeout, None) }}',
value_template: '{{ 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 Extension from '../extension';
import stringify from 'json-stable-stringify-without-jsonify';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
const configRegex =
new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
import logger from '../../util/logger';
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 {
private lastJoinedDeviceName: string = null;
@ -15,24 +15,24 @@ export default class BridgeLegacy extends Extension {
override async start(): Promise<void> {
this.supportedOptions = {
'permit_join': this.permitJoin,
'last_seen': this.lastSeen,
'elapsed': this.elapsed,
'reset': this.reset,
'log_level': this.logLevel,
'devices': this.devices,
'groups': this.groups,
permit_join: this.permitJoin,
last_seen: this.lastSeen,
elapsed: this.elapsed,
reset: this.reset,
log_level: this.logLevel,
devices: this.devices,
groups: this.groups,
'devices/get': this.devices,
'rename': this.rename,
'rename_last': this.renameLast,
'remove': this.remove,
'force_remove': this.forceRemove,
'ban': this.ban,
'device_options': this.deviceOptions,
'add_group': this.addGroup,
'remove_group': this.removeGroup,
'force_remove_group': this.removeGroup,
'whitelist': this.whitelist,
rename: this.rename,
rename_last: this.renameLast,
remove: this.remove,
force_remove: this.forceRemove,
ban: this.ban,
device_options: this.deviceOptions,
add_group: this.addGroup,
remove_group: this.removeGroup,
force_remove_group: this.removeGroup,
whitelist: this.whitelist,
'touchlink/factory_reset': this.touchlinkFactoryReset,
};
@ -51,10 +51,7 @@ export default class BridgeLegacy extends Extension {
assert(entity, `Entity '${message}' does not exist`);
settings.addDeviceToPasslist(entity.ID.toString());
logger.info(`Whitelisted '${entity.friendly_name}'`);
await this.mqtt.publish(
'bridge/log',
stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}),
);
await this.mqtt.publish('bridge/log', stringify({type: 'device_whitelisted', message: {friendly_name: entity.friendly_name}}));
} catch (error) {
logger.error(`Failed to whitelist '${message}' '${error}'`);
}
@ -162,9 +159,7 @@ export default class BridgeLegacy extends Extension {
});
if (topic.split('/').pop() == 'get') {
await this.mqtt.publish(
`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false,
);
await this.mqtt.publish(`bridge/config/devices`, stringify(devices), {}, settings.get().mqtt.base_topic, false, false);
} else {
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> {
const invalid =
`Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
const invalid = `Invalid rename message format expected {"old": "friendly_name", "new": "new_name"} got ${message}`;
let json = null;
try {
@ -218,10 +212,7 @@ export default class BridgeLegacy extends Extension {
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
}
await this.mqtt.publish(
'bridge/log',
stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}),
);
await this.mqtt.publish('bridge/log', stringify({type: `${isGroup ? 'group' : 'device'}_renamed`, message: {from, to}}));
} catch (error) {
logger.error(`Failed to rename - ${from} to ${to}`);
}
@ -377,40 +368,25 @@ export default class BridgeLegacy extends Extension {
}
if (type === 'deviceJoined') {
await this.mqtt.publish(
'bridge/log',
stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}),
);
await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
} else if (type === 'deviceInterview') {
if (data.status === 'successful') {
if (resolvedEntity.isSupported) {
const {vendor, description, model} = resolvedEntity.definition;
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
await this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_successful', meta: log}),
);
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
} else {
const meta = {friendly_name: resolvedEntity.name, supported: false};
await this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_successful', meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
}
} else if (data.status === 'failed') {
const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_failed', meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
} else {
/* istanbul ignore else */
if (data.status === 'started') {
const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `pairing`, message: 'interview_started', meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
}
}
} else if (type === 'deviceAnnounce') {
@ -421,34 +397,22 @@ export default class BridgeLegacy extends Extension {
if (type === 'deviceLeave') {
const name = data.ieeeAddr;
const meta = {friendly_name: name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `device_removed`, message: 'left_network', meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `device_removed`, message: 'left_network', meta}));
}
}
}
@bind async touchlinkFactoryReset(): Promise<void> {
logger.info('Starting touchlink factory reset...');
await this.mqtt.publish(
'bridge/log',
stringify({type: `touchlink`, message: 'reset_started', meta: {status: 'started'}}),
);
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_started', meta: {status: 'started'}}));
const result = await this.zigbee.touchlinkFactoryResetFirst();
if (result) {
logger.info('Successfully factory reset device through Touchlink');
await this.mqtt.publish(
'bridge/log',
stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}),
);
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_success', meta: {status: 'success'}}));
} else {
logger.warning('Failed to factory reset device through Touchlink');
await this.mqtt.publish(
'bridge/log',
stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}),
);
await this.mqtt.publish('bridge/log', stringify({type: `touchlink`, message: 'reset_failed', meta: {status: 'failed'}}));
}
}
}

View File

@ -1,9 +1,10 @@
/* istanbul ignore file */
import * as settings from '../../util/settings';
import logger from '../../util/logger';
import Extension from '../extension';
import bind from 'bind-decorator';
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$`);
@ -31,16 +32,14 @@ export default class DeviceGroupMembership extends Extension {
return;
}
const response = await endpoint.command(
`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {},
);
const response = await endpoint.command(`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {});
if (!response) {
logger.warning(`Couldn't get group membership of ${device.ieeeAddr}`);
return;
}
let {grouplist, capacity} = response;
let {grouplist} = response;
grouplist = grouplist.map((gid: string) => {
const g = settings.getGroup(gid);
@ -49,13 +48,13 @@ export default class DeviceGroupMembership extends Extension {
const msgGroupList = `${device.ieeeAddr} is in groups [${grouplist}]`;
let msgCapacity;
if (capacity === 254) {
if (response.capacity === 254) {
msgCapacity = 'it can be a part of at least 1 more group';
} 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}`);
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 logger from '../../util/logger';
import * as settings from '../../util/settings';
import Extension from '../extension';
const defaultConfiguration = {
minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 1,
minimumReportInterval: 3,
maximumReportInterval: 300,
reportableChange: 1,
};
const ZNLDP12LM = zhc.definitions.find((d) => d.model === 'ZNLDP12LM');
@ -19,43 +22,47 @@ const devicesNotSupportingReporting = [
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) {
await endpoint.read('lightingColorCtrl', ['colorCapabilities']);
}
const value = endpoint.getClusterAttributeValue('lightingColorCtrl', 'colorCapabilities') as number;
return {
colorTemperature: (value & 1<<4) > 0,
colorXY: (value & 1<<3) > 0,
colorTemperature: (value & (1 << 4)) > 0,
colorXY: (value & (1 << 3)) > 0,
};
};
const clusters: {[s: string]:
{attribute: string, minimumReportInterval: number, maximumReportInterval: number, reportableChange: number
condition?: (endpoint: zh.Endpoint) => Promise<boolean>}[]} =
{
'genOnOff': [
{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0},
],
'genLevelCtrl': [
{attribute: 'currentLevel', ...defaultConfiguration},
],
'lightingColorCtrl': [
const clusters: {
[s: string]: {
attribute: string;
minimumReportInterval: number;
maximumReportInterval: number;
reportableChange: number;
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
}[];
} = {
genOnOff: [{attribute: 'onOff', ...defaultConfiguration, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: 'currentLevel', ...defaultConfiguration}],
lightingColorCtrl: [
{
attribute: 'colorTemperature', ...defaultConfiguration,
attribute: 'colorTemperature',
...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
},
{
attribute: 'currentX', ...defaultConfiguration,
attribute: 'currentX',
...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
{
attribute: 'currentY', ...defaultConfiguration,
attribute: 'currentY',
...defaultConfiguration,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
],
'closuresWindowCovering': [
closuresWindowCovering: [
{attribute: 'currentPositionLiftPercentage', ...defaultConfiguration},
{attribute: 'currentPositionTiltPercentage', ...defaultConfiguration},
],
@ -86,28 +93,25 @@ export default class Report extends Extension {
try {
for (const ep of device.zh.endpoints) {
for (const [cluster, configuration] of Object.entries(clusters)) {
if (ep.supportsInputCluster(cluster) &&
!this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
if (ep.supportsInputCluster(cluster) && !this.shouldIgnoreClusterForDevice(cluster, device.definition)) {
logger.debug(`${term1} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
const items = [];
for (const entry of configuration) {
if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) {
const toAdd = {...entry};
if (!this.enabled) toAdd.maximumReportInterval = 0xFFFF;
if (!this.enabled) toAdd.maximumReportInterval = 0xffff;
items.push(toAdd);
delete items[items.length - 1].condition;
}
}
this.enabled ?
await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint()) :
await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
this.enabled
? await ep.bind(cluster, this.zigbee.firstCoordinatorEndpoint())
: await ep.unbind(cluster, this.zigbee.firstCoordinatorEndpoint());
await ep.configureReporting(cluster, items);
logger.info(
`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`,
);
logger.info(`Successfully ${term2} reporting for '${device.ieeeAddr}' - ${ep.ID} - ${cluster}`);
}
}
}
@ -121,9 +125,7 @@ export default class Report extends Extension {
this.eventBus.emitDevicesChanged();
} catch (error) {
logger.error(
`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`,
);
logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`);
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
// configuredReportings are saved since Zigbee2MQTT 1.17.0
// https://github.com/Koenkk/zigbee2mqtt/issues/966
if (this.enabled && messageType === 'deviceAnnounce' && device.isIkeaTradfri() &&
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length ===
device.zh.endpoints.length) {
if (
this.enabled &&
messageType === 'deviceAnnounce' &&
device.isIkeaTradfri() &&
device.zh.endpoints.filter((e) => e.configuredReportings.length === 0).length === device.zh.endpoints.length
) {
return true;
}
// These do not support reproting.
// https://github.com/Koenkk/zigbee-herdsman/issues/110
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
if (device.zh.manufacturerName === 'Philips' &&
philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
if (device.zh.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
if (device.zh.interviewing === true) return false;
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
// Gledopto devices don't support reporting.
if (devicesNotSupportingReporting.includes(device.definition) ||
device.definition.vendor === 'Gledopto') return false;
if (devicesNotSupportingReporting.includes(device.definition) || device.definition.vendor === 'Gledopto') return false;
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') &&
device.zh.meta.reporting === reportKey) {
if (this.enabled && device.zh.meta.hasOwnProperty('reporting') && device.zh.meta.reporting === reportKey) {
return false;
}

View File

@ -1,7 +1,7 @@
/* istanbul ignore file */
import logger from '../../util/logger';
// DEPRECATED
import * as settings from '../../util/settings';
import logger from '../../util/logger';
import utils from '../../util/utils';
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 utils from '../util/utils';
import logger from '../util/logger';
import stringify from 'json-stable-stringify-without-jsonify';
import Extension from './extension';
import bind from 'bind-decorator';
interface Link {
source: {ieeeAddr: string, networkAddress: number}, target: {ieeeAddr: string, networkAddress: number},
linkquality: number, depth: number, routes: zh.RoutingTableEntry[],
sourceIeeeAddr: string, targetIeeeAddr: string, sourceNwkAddr: number, lqi: number, relationship: number,
source: {ieeeAddr: string; networkAddress: number};
target: {ieeeAddr: string; networkAddress: number};
linkquality: number;
depth: number;
routes: zh.RoutingTableEntry[];
sourceIeeeAddr: string;
targetIeeeAddr: string;
sourceNwkAddr: number;
lqi: number;
relationship: number;
}
interface Topology {
nodes: {
ieeeAddr: string, friendlyName: string, type: string, networkAddress: number, manufacturerName: string,
modelID: string, failed: string[], lastSeen: number,
definition: {model: string, vendor: string, supports: string, description: string}}[],
links: Link[],
ieeeAddr: string;
friendlyName: string;
type: string;
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> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.supportedFormats = {
'raw': this.raw,
'graphviz': this.graphviz,
'plantuml': this.plantuml,
raw: this.raw,
graphviz: this.graphviz,
plantuml: this.plantuml,
};
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
/* istanbul ignore else */
if (this.legacyApi) {
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) &&
this.supportedFormats.hasOwnProperty(data.message)) {
if ((data.topic === this.legacyTopic || data.topic === this.legacyTopicRoutes) && this.supportedFormats.hasOwnProperty(data.message)) {
const includeRoutes = data.topic === this.legacyTopicRoutes;
const topology = await this.networkScan(includeRoutes);
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 topology = await this.networkScan(routes);
const value = this.supportedFormats[type](topology);
await this.mqtt.publish(
'bridge/response/networkmap',
stringify(utils.getResponse(message, {routes, type, value}, null)),
);
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value}, null)));
} catch (error) {
await this.mqtt.publish(
'bridge/response/networkmap',
stringify(utils.getResponse(message, {}, error.message)),
);
await this.mqtt.publish('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)
labels.push(
`${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
@ -113,35 +121,31 @@ export default class NetworkMap extends Extension {
// Shape the record according to device type
if (node.type == 'Coordinator') {
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` +
`fontcolor="${colors.font.coordinator}"`;
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` + `fontcolor="${colors.font.coordinator}"`;
} else if (node.type == 'Router') {
style = `style="rounded, filled", fillcolor="${colors.fill.router}", ` +
`fontcolor="${colors.font.router}"`;
style = `style="rounded, filled", fillcolor="${colors.fill.router}", ` + `fontcolor="${colors.font.router}"`;
} else {
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", ` +
`fontcolor="${colors.font.enddevice}"`;
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", ` + `fontcolor="${colors.font.enddevice}"`;
}
// 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
* 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.
*/
topology.links.filter((e) => (e.source.ieeeAddr === node.ieeeAddr)).forEach((e) => {
const lineStyle = (node.type=='EndDevice') ? 'penwidth=1, ' :
(!e.routes.length) ? 'penwidth=0.5, ' : 'penwidth=2, ';
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 lineLabels = (!e.routes.length) ? `label="${e.linkquality}"` :
`label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
});
topology.links
.filter((e) => e.source.ieeeAddr === node.ieeeAddr)
.forEach((e) => {
const lineStyle = node.type == 'EndDevice' ? 'penwidth=1, ' : !e.routes.length ? 'penwidth=0.5, ' : 'penwidth=2, ';
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 lineLabels = !e.routes.length ? `label="${e.linkquality}"` : `label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
});
});
text += '}';
@ -156,36 +160,38 @@ export default class NetworkMap extends Extension {
text.push(``);
text.push('@startuml');
topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName)).forEach((node) => {
// Add friendly name
text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
text.push(`---`);
// 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') {
topology.nodes
.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))
.forEach((node) => {
// Add friendly name
text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
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 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(`---`);
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
@ -254,19 +260,30 @@ export default class NetworkMap extends Extension {
const topology: Topology = {nodes: [], links: []};
// Add nodes
for (const device of devices) {
const definition = device.definition ? {
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
supports: Array.from(new Set((device.exposes()).map((e) => {
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`;
}))).join(', '),
} : null;
const definition = device.definition
? {
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
supports: Array.from(
new Set(
device.exposes().map((e) => {
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`;
}),
),
).join(', '),
}
: null;
topology.nodes.push({
ieeeAddr: device.ieeeAddr, friendlyName: device.name, type: device.zh.type,
networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName,
modelID: device.zh.modelID, failed: failed.get(device), lastSeen: device.zh.lastSeen,
ieeeAddr: device.ieeeAddr,
friendlyName: device.name,
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,
});
}
@ -289,17 +306,20 @@ export default class NetworkMap extends Extension {
const link: Link = {
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [],
linkquality: neighbor.linkquality,
depth: neighbor.depth,
routes: [],
// DEPRECATED:
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress, lqi: neighbor.linkquality,
sourceIeeeAddr: neighbor.ieeeAddr,
targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress,
lqi: neighbor.linkquality,
relationship: neighbor.relationship,
};
const routingTable = routingTables.get(device);
if (routingTable) {
link.routes = routingTable.table
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
link.routes = routingTable.table.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
}
topology.links.push(link);

View File

@ -1,4 +1,5 @@
import * as zhc from 'zigbee-herdsman-converters';
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.onDeviceJoined(this,
(data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
this.eventBus.onDeviceInterview(this,
(data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
this.eventBus.onDeviceAnnounce(this,
(data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
this.eventBus.onDeviceNetworkAddressChanged(this,
(data) => this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)));
this.eventBus.onEntityOptionsChanged(this,
async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, 'deviceOptionsChanged', data)
.then(() => this.eventBus.emitDevicesChanged());
}
});
this.eventBus.onDeviceJoined(this, (data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data)));
this.eventBus.onDeviceInterview(this, (data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data)));
this.eventBus.onDeviceAnnounce(this, (data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data)));
this.eventBus.onDeviceNetworkAddressChanged(this, (data) =>
this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)),
);
this.eventBus.onEntityOptionsChanged(this, async (data) => {
if (data.entity.isDevice()) {
await this.callOnEvent(data.entity, 'deviceOptionsChanged', data).then(() => this.eventBus.emitDevicesChanged());
}
});
}
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 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 dataDir from '../util/data';
import * as URI from 'uri-js';
import path from 'path';
import * as zhc from 'zigbee-herdsman-converters';
import {Zcl} from 'zigbee-herdsman';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
function isValidUrl(url: string): boolean {
let parsed;
@ -24,17 +25,19 @@ function isValidUrl(url: string): boolean {
type UpdateState = 'updating' | 'idle' | 'available';
interface UpdatePayload {
update_available?: boolean
update_available?: boolean;
// eslint-disable-next-line camelcase
update: {
progress?: number, remaining?: number, state: UpdateState,
installed_version: number | null, latest_version: number | null
}
progress?: number;
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 topicRegex =
new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i');
export default class OTAUpdate extends Extension {
private inProgress = new Set();
@ -79,8 +82,7 @@ export default class OTAUpdate extends Extension {
}
@bind private async onZigbeeEvent(data: eventdata.DeviceMessage): Promise<void> {
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition ||
this.inProgress.has(data.device.ieeeAddr)) return;
if (data.type !== 'commandQueryNextImageRequest' || !data.device.definition || this.inProgress.has(data.device.ieeeAddr)) return;
logger.debug(`Device '${data.device.name}' requested OTA`);
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
// 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 check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr) ?
(Date.now() - this.lastChecked[data.device.ieeeAddr]) > updateCheckInterval : true;
const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr)
? Date.now() - this.lastChecked[data.device.ieeeAddr] > updateCheckInterval
: true;
if (!check) return;
this.lastChecked[data.device.ieeeAddr] = Date.now();
@ -113,23 +116,24 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: 'available', device: data.device.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message, meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta}));
}
}
}
// Respond to stop the client from requesting OTAs
const endpoint = data.device.zh.endpoints.find((e) => e.supportsOutputCluster('genOta')) || data.endpoint;
await endpoint.commandResponse('genOta', 'queryNextImageResponse',
{status: Zcl.Status.NO_IMAGE_AVAILABLE}, undefined, data.meta.zclTransactionSequenceNumber);
await endpoint.commandResponse(
'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'`);
}
private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'):
Promise<{softwareBuildID: string, dateCode: string}> {
private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'): Promise<{softwareBuildID: string; dateCode: string}> {
try {
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
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,
progress: number=null, remaining: number=null): UpdatePayload {
private getEntityPublishPayload(
device: Device,
state: zhc.OtaUpdateAvailableResult | UpdateState,
progress: number = null,
remaining: number = null,
): UpdatePayload {
const deviceUpdateState = this.state.get(device).update;
const payload: UpdatePayload = {update: {
state: typeof state === 'string' ? state : (state.available ? 'available' : 'idle'),
installed_version: typeof state === 'string' ?
deviceUpdateState?.installed_version : state.currentFileVersion,
latest_version: typeof state === 'string' ?
deviceUpdateState?.latest_version : state.otaFileVersion,
}};
const payload: UpdatePayload = {
update: {
state: typeof state === 'string' ? state : state.available ? 'available' : 'idle',
installed_version: typeof state === 'string' ? deviceUpdateState?.installed_version : state.currentFileVersion,
latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
},
};
if (progress !== null) payload.update.progress = progress;
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 device = this.zigbee.resolveEntity(ID);
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 errorStack = null;
@ -181,10 +189,7 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `not_supported`, device: device.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: error, meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
}
} else if (this.inProgress.has(device.ieeeAddr)) {
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 */
if (settings.get().advanced.legacy_api) {
const meta = {status: `checking_if_available`, device: device.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
}
try {
@ -212,11 +214,10 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {
status: availableResult.available ? 'available' : 'not_available', device: device.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
status: availableResult.available ? 'available' : 'not_available',
device: device.name,
};
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
}
const payload = this.getEntityPublishPayload(device, availableResult);
@ -230,23 +231,18 @@ export default class OTAUpdate extends Extension {
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `check_failed`, device: device.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: error, meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta}));
}
}
} else { // type === 'update'
} else {
// type === 'update'
const msg = `Updating '${device.name}' to latest firmware`;
logger.info(msg);
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const meta = {status: `update_in_progress`, device: device.name};
await this.mqtt.publish(
'bridge/log',
stringify({type: `ota_update`, message: msg, meta}),
);
await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta}));
}
try {
@ -272,8 +268,11 @@ export default class OTAUpdate extends Extension {
const fileVersion = await device.definition.ota.updateToLatest(device.zh, onProgress);
logger.info(`Finished update of '${device.name}'`);
this.removeProgressAndRemainingFromState(device);
const payload = this.getEntityPublishPayload(device,
{available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion});
const payload = this.getEntityPublishPayload(device, {
available: false,
currentFileVersion: fileVersion,
otaFileVersion: fileVersion,
});
await this.publishEntityState(device, payload);
const to = await this.readSoftwareBuildIDAndDateCode(device);
const [fromS, toS] = [stringify(from_), stringify(to)];

View File

@ -1,14 +1,14 @@
import * as settings from '../util/settings';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters';
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 * as settings from '../util/settings';
import utils from '../util/utils';
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;
// 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,
];
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 {
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,
target: zh.Endpoint | zh.Group, key: string, meta: zhc.Tz.Meta): void {
legacyRetrieveState(
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.
// 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
@ -102,9 +113,7 @@ export default class Publish extends Extension {
// 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.
// retrieve_state == deprecated
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') &&
re.options.retrieve_state
) {
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') && re.options.retrieve_state) {
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 entitySettings = re.options;
const entityState = this.state.get(re);
const membersState = re instanceof Group ?
Object.fromEntries(re.zh.members.map((e) => [e.getDevice().ieeeAddr,
this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))])) : null;
const membersState =
re instanceof Group
? 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[];
{
if (Array.isArray(definition)) {
@ -199,7 +211,9 @@ export default class Publish extends Extension {
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
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 localTarget = target;
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] = [];
/* istanbul ignore next */
const converter = converters.find((c) =>
c.key.includes(key) && (!c.endpoint || c.endpoint == endpointName));
const converter = converters.find((c) => c.key.includes(key) && (!c.endpoint || c.endpoint == endpointName));
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
// 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 (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) &&
re.endpointName(localTarget)) {
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) && re.endpointName(localTarget)) {
endpointName = re.endpointName(localTarget);
}
// Converter didn't return a result, skip
const entitySettingsKeyValue: KeyValue = entitySettings;
const meta = {
endpoint_name: endpointName, options: entitySettingsKeyValue,
message: {...message}, logger, device, state: entityState, membersState, mapped: definition,
endpoint_name: endpointName,
options: entitySettingsKeyValue,
message: {...message},
logger,
device,
state: entityState,
membersState,
mapped: definition,
};
// Strip endpoint name from meta.message properties.
@ -289,8 +307,7 @@ export default class Publish extends Extension {
continue;
}
} catch (error) {
const message =
`Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
logger.error(message);
logger.debug(error.stack);
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)
.some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
const scenesChanged = Object.values(usedConverters).some((cl) => cl.some((c) => c.key.some((k) => sceneConverterKeys.includes(k))));
if (scenesChanged) {
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 utils from '../util/utils';
import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
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 {
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> {
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
* remove it from the to be send debounced message.
*/
if (data.entity.isDevice() && this.debouncers[data.entity.ieeeAddr] &&
data.stateChangeReason !== 'publishDebounce' && data.stateChangeReason !== 'lastSeenChanged') {
if (
data.entity.isDevice() &&
this.debouncers[data.entity.ieeeAddr] &&
data.stateChangeReason !== 'publishDebounce' &&
data.stateChangeReason !== 'lastSeenChanged'
) {
for (const key of Object.keys(data.payload)) {
delete this.debouncers[data.entity.ieeeAddr].payload[key];
}
@ -91,8 +96,7 @@ export default class Receive extends Extension {
if (!data.device) return;
if (!this.shouldProcess(data)) {
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
settings.get(), true, this.publishEntityState);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
return;
}
@ -104,10 +108,11 @@ export default class Receive extends Extension {
// Check if there is an available converter, genOta messages are not interesting.
const ignoreClusters: (string | number)[] = ['genOta', 'genTime', 'genBasic', 'genPollCtrl'];
if (converters.length == 0 && !ignoreClusters.includes(data.cluster)) {
logger.debug(`No converter available for '${data.device.definition.model}' with ` +
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
settings.get(), true, this.publishEntityState);
logger.debug(
`No converter available for '${data.device.definition.model}' with ` +
`cluster '${data.cluster}' and type '${data.type}' and data '${stringify(data.data)}'`,
);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
return;
}
@ -131,8 +136,7 @@ export default class Receive extends Extension {
// Check if we have to debounce
if (data.device.options.debounce) {
this.publishDebounce(data.device, payload, data.device.options.debounce,
data.device.options.debounce_ignore);
this.publishDebounce(data.device, payload, data.device.options.debounce, data.device.options.debounce_ignore);
} else {
await this.publishEntityState(data.device, payload);
}
@ -143,15 +147,13 @@ export default class Receive extends Extension {
this.eventBus.emitExposesChanged({device: data.device});
};
const meta = {device: data.device.zh, logger, state: this.state.get(data.device),
deviceExposesChanged: deviceExposesChanged};
const meta = {device: data.device.zh, logger, state: this.state.get(data.device), deviceExposesChanged: deviceExposesChanged};
let payload: KeyValue = {};
for (const converter of converters) {
try {
const convertData = {...data, device: data.device.zh};
const options: KeyValue = data.device.options;
const converted = await converter.convert(
data.device.definition, convertData, publish, options, meta);
const converted = await converter.convert(data.device.definition, convertData, publish, options, meta);
if (converted) {
payload = {...payload, ...converted};
}
@ -164,8 +166,7 @@ export default class Receive extends Extension {
if (Object.keys(payload).length) {
await publish(payload);
} else {
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'},
settings.get(), true, this.publishEntityState);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
}
}
}

View File

@ -1,16 +1,23 @@
/* 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 * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
export default class Device {
public zh: zh.Device;
public definition: zhc.Definition;
private _definitionModelID: string;
get ieeeAddr(): string {return this.zh.ieeeAddr;}
get ID(): string {return this.zh.ieeeAddr;}
get options(): DeviceOptions {return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};}
get ieeeAddr(): string {
return this.zh.ieeeAddr;
}
get ID(): string {
return this.zh.ieeeAddr;
}
get options(): DeviceOptions {
return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};
}
get name(): string {
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');
}
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 */
isGroup(): this is Group {return false;}
isGroup(): this is Group {
return false;
}
}

View File

@ -1,14 +1,21 @@
/* eslint-disable brace-style */
import * as settings from '../util/settings';
import * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
export default class Group {
public zh: zh.Group;
private resolveDevice: (ieeeAddr: string) => Device;
get ID(): number {return this.zh.groupID;}
get options(): GroupOptions {return {...settings.getGroup(this.ID)};}
get name(): string {return this.options?.friendly_name || this.ID.toString();}
get ID(): number {
return this.zh.groupID;
}
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) {
this.zh = group;
@ -24,9 +31,15 @@ export default class Group {
}
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;}
isGroup(): this is Group {return true;}
isDevice(): this is Device {
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 logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
import fs from 'fs';
import bind from 'bind-decorator';
import type {QoS} from 'mqtt-packet';
const NS = 'z2m:mqtt';
@ -15,8 +17,9 @@ export default class MQTT {
private eventBus: EventBus;
private initialConnect = true;
private republishRetainedTimer: NodeJS.Timeout;
private retainedMessages: {[s: string]: {payload: string, options: MQTTOptions,
skipLog: boolean, skipReceive: boolean, topic: string, base: string}} = {};
private retainedMessages: {
[s: string]: {payload: string; options: MQTTOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
} = {};
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
@ -155,9 +158,15 @@ export default class MQTT {
return this.client && !this.client.reconnecting;
}
async publish(topic: string, 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};
async publish(
topic: string,
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}`;
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 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 dontCacheProperties = [
'action', 'action_.*', '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',
'action',
'action_.*',
'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 {
@ -18,7 +35,10 @@ class State {
private file = data.joinPath('state.json');
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.zigbee = zigbee;
}
@ -75,7 +95,7 @@ class State {
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 toState = objectAssignDeep({}, fromState, update);
const newCache = {...toState};

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

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

View File

@ -1,11 +1,12 @@
import winston from 'winston';
import moment from 'moment';
import * as settings from './settings';
import path from 'path';
import assert from 'assert';
import fs from 'fs';
import fx from 'mkdir-recursive';
import moment from 'moment';
import path from 'path';
import {rimrafSync} from 'rimraf';
import assert from 'assert';
import winston from 'winston';
import * as settings from './settings';
const NAMESPACE_SEPARATOR = ':';
@ -30,19 +31,13 @@ class Logger {
this.namespacedLevels = settings.get().advanced.log_namespaced_levels;
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
assert(
settings.LOG_LEVELS.includes(this.level),
`'${this.level}' is not valid log_level, use one of '${settings.LOG_LEVELS.join(', ')}'`,
);
assert(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);
this.logger = winston.createLogger({
level: 'debug',
format: winston.format.combine(
winston.format.errors({stack: true}),
winston.format.timestamp({format: timestampFormat}),
),
format: winston.format.combine(winston.format.errors({stack: true}), winston.format.timestamp({format: timestampFormat})),
levels: winston.config.syslog.levels,
});
@ -51,16 +46,20 @@ class Logger {
let logging = `Logging to console${consoleSilenced ? ' (silenced)' : ''}`;
// Setup default console logger
this.logger.add(new winston.transports.Console({
silent: consoleSilenced,
// winston.config.syslog.levels sets 'warning' as 'red'
format: winston.format.combine(
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
winston.format.printf(/* istanbul ignore next */(info) => {
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
}),
),
}));
this.logger.add(
new winston.transports.Console({
silent: consoleSilenced,
// winston.config.syslog.levels sets 'warning' as 'red'
format: winston.format.combine(
winston.format.colorize({colors: {debug: 'blue', info: 'green', warning: 'yellow', error: 'red'}}),
winston.format.printf(
/* istanbul ignore next */ (info) => {
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
},
),
),
}),
);
if (this.output.includes('file')) {
logging += `, file (filename: ${logFilename})`;
@ -79,13 +78,14 @@ class Logger {
}
// 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
const transportFileOptions: winston.transports.FileTransportOptions = {
filename: path.join(this.directory, logFilename),
format: winston.format.printf(/* istanbul ignore next */(info) => {
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
}),
format: winston.format.printf(
/* istanbul ignore next */ (info) => {
return `[${info.timestamp}] ${info.level}: \t${info.message}`;
},
),
};
if (settings.get().advanced.log_rotation) {
@ -135,7 +135,7 @@ class Logger {
}
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 {
@ -164,14 +164,14 @@ class Logger {
this.cachedNamespacedLevels = Object.assign({}, this.namespacedLevels);
}
private cacheNamespacedLevel(namespace: string) : string {
private cacheNamespacedLevel(namespace: string): string {
let cached = namespace;
while (this.cachedNamespacedLevels[namespace] == undefined) {
const sep = cached.lastIndexOf(NAMESPACE_SEPARATOR);
if (sep === -1) {
return this.cachedNamespacedLevels[namespace] = this.level;
return (this.cachedNamespacedLevels[namespace] = this.level);
}
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 utils from './utils';
import Ajv, {ValidateFunction} from 'ajv';
import objectAssignDeep from 'object-assign-deep';
import path from 'path';
import yaml from './yaml';
import Ajv, {ValidateFunction} from 'ajv';
import data from './data';
import schemaJson from './settings.schema.json';
import utils from './utils';
import yaml from './yaml';
export let schema = schemaJson;
// @ts-ignore
schema = {};
@ -28,18 +29,19 @@ objectAssignDeep(schema, schemaJson);
/** 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 type LogLevel = typeof LOG_LEVELS[number];
export type LogLevel = (typeof LOG_LEVELS)[number];
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml');
const NULLABLE_SETTINGS = ['homeassistant'];
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson);
const ajvRestartRequired = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
const ajvRestartRequired = new Ajv({allErrors: true}).addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson);
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})
.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> = {
permit_join: false,
external_converters: [],
@ -92,7 +94,7 @@ const defaults: RecursivePartial<Settings> = {
log_debug_to_mqtt_frontend: false,
log_debug_namespace_ignore: '',
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,
adapter_concurrent: null,
adapter_delay: null,
@ -129,12 +131,15 @@ function loadSettingsWithDefaults(): void {
}
if (_settingsWithDefaults.homeassistant) {
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status',
legacy_entity_attributes: true, legacy_triggers: true};
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status', legacy_entity_attributes: true, legacy_triggers: true};
const sLegacy = {};
if (_settingsWithDefaults.advanced) {
for (const key of ['homeassistant_legacy_triggers', 'homeassistant_discovery_topic',
'homeassistant_legacy_entity_attributes', 'homeassistant_status_topic']) {
for (const key of [
'homeassistant_legacy_triggers',
'homeassistant_discovery_topic',
'homeassistant_legacy_entity_attributes',
'homeassistant_status_topic',
]) {
// @ts-ignore
if (_settingsWithDefaults.advanced[key] !== undefined) {
// @ts-ignore
@ -202,7 +207,7 @@ function loadSettingsWithDefaults(): void {
_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);
if (match) {
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 (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((c: KeyValue) => Object.keys(c))
// @ts-ignore
@ -274,10 +280,7 @@ export function validate(): string[] {
getInternalSettings();
} catch (error) {
if (error.name === 'YAMLException') {
return [
`Your YAML file: '${error.file}' is invalid ` +
`(use https://jsonformatter.org/yaml-validator to find and fix the issue)`,
];
return [`Your YAML file: '${error.file}' is invalid ` + `(use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
}
return [error.message];
@ -288,18 +291,30 @@ export function validate(): string[] {
}
const errors = [];
if (_settings.advanced && _settings.advanced.network_key && typeof _settings.advanced.network_key === 'string' &&
_settings.advanced.network_key !== 'GENERATE') {
if (
_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}')`);
}
if (_settings.advanced && _settings.advanced.pan_id && typeof _settings.advanced.pan_id === 'string' &&
_settings.advanced.pan_id !== 'GENERATE') {
if (
_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}')`);
}
if (_settings.advanced && _settings.advanced.ext_pan_id && typeof _settings.advanced.ext_pan_id === 'string' &&
_settings.advanced.ext_pan_id !== 'GENERATE') {
if (
_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}')`);
}
@ -404,7 +419,7 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
if (key !== 'properties' && obj[key]) {
const type = (obj[key].type || 'object').toString();
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]) {
const setting = path.reduce((acc, val) => {
/* eslint-disable-line */ // @ts-ignore
@ -498,8 +513,7 @@ export function apply(settings: Record<string, unknown>): boolean {
write();
ajvRestartRequired(settings);
const restartRequired = ajvRestartRequired.errors &&
!!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
const restartRequired = ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
return restartRequired;
}
@ -607,8 +621,7 @@ export function removeDevice(IDorName: string): void {
// Remove device from groups
if (settings.groups) {
const regex =
new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`);
const regex = new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`);
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
group.devices = group.devices.filter((device) => !device.match(regex));
}
@ -701,7 +714,7 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
validator = ajvRestartRequiredDeviceOptions;
} else if (getGroup(IDorName)) {
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;
} else {
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 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)
// Example:
// - ISO8601 (UTC) = 2019-03-01T15:32:45.941+0000
@ -18,21 +20,30 @@ function toLocalISOString(date: Date): string {
return (norm < 10 ? '0' : '') + norm;
};
return date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes()) +
':' + pad(date.getSeconds()) +
plusOrMinus + pad(tzOffset / 60) +
':' + pad(tzOffset % 60);
return (
date.getFullYear() +
'-' +
pad(date.getMonth() + 1) +
'-' +
pad(date.getDate()) +
'T' +
pad(date.getHours()) +
':' +
pad(date.getMinutes()) +
':' +
pad(date.getSeconds()) +
plusOrMinus +
pad(tzOffset / 60) +
':' +
pad(tzOffset % 60)
);
}
function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1);
}
async function getZigbee2MQTTVersion(includeCommitHash=true): Promise<{commitHash: string, version: string}> {
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash: string; version: string}> {
const git = await import('git-last-commit');
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();
else if (type === 'ISO_8601_local') return toLocalISOString(new Date(time));
else if (type === 'epoch') return time;
else { // relative
else {
// relative
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 {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)) {
if (ignoreKeys.includes(key)) continue;
const value = obj[key];
@ -198,28 +210,27 @@ function toSnakeCase(value: string | KeyValue): any {
}
return value;
} 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[] {
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);
}
return result;
}
const controlCharacters = [
...charRange('\u0000', '\u001F'),
...charRange('\u007f', '\u009F'),
...charRange('\ufdd0', '\ufdef'),
];
const controlCharacters = [...charRange('\u0000', '\u001F'), ...charRange('\u007f', '\u009F'), ...charRange('\ufdd0', '\ufdef')];
function containsControlCharacter(str: string): boolean {
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
if (controlCharacters.includes(ch) || [0xFFFE, 0xFFFF].includes(ch & 0xFFFF)) {
if (controlCharacters.includes(ch) || [0xfffe, 0xffff].includes(ch & 0xffff)) {
return true;
}
}
@ -239,7 +250,7 @@ function getAllFiles(path_: string): string[] {
return result;
}
function validateFriendlyName(name: string, throwFirstError=false): string[] {
function validateFriendlyName(name: string, throwFirstError = false): string[] {
const errors = [];
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 {
const replaceByDash = [/\?/g, /&/g, /[^a-z\d\- _./:]/gi];
let sanitized = parameter;
replaceByDash.forEach((r) => sanitized = sanitized.replace(r, '-'));
replaceByDash.forEach((r) => (sanitized = sanitized.replace(r, '-')));
return sanitized;
}
@ -325,8 +336,12 @@ const hours = (hours: number): number => 1000 * 60 * 60 * hours;
const minutes = (minutes: number): number => 1000 * 60 * minutes;
const seconds = (seconds: number): number => 1000 * seconds;
async function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, allowMessageEmitted: boolean,
publishEntityState: PublishEntityState): Promise<void> {
async function publishLastSeen(
data: eventdata.LastSeenChanged,
settings: Settings,
allowMessageEmitted: boolean,
publishEntityState: PublishEntityState,
): Promise<void> {
/**
* Prevent 2 MQTT publishes when 1 message event is received;
* - In case reason == messageEmitted, receive.ts will only call this when it did not publish a
@ -382,10 +397,34 @@ function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
}
export default {
capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties,
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,
capitalize,
getZigbee2MQTTVersion,
getDependencyVersion,
formatDate,
objectHasProperties,
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 fs from 'fs';
import yaml from 'js-yaml';
function read(file: string): KeyValue {
try {
const result = yaml.load(fs.readFileSync(file, 'utf8'));
return result as KeyValue ?? {};
return (result as KeyValue) ?? {};
} catch (error) {
if (error.name === 'YAMLException') {
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 {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(`^(.+?)(?:/([^/]+))?$`);
@ -27,13 +28,14 @@ export default class Zigbee {
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
const herdsmanSettings = {
network: {
panID: settings.get().advanced.pan_id === 'GENERATE' ?
this.generatePanID() : settings.get().advanced.pan_id as number,
extendedPanID: settings.get().advanced.ext_pan_id === 'GENERATE' ?
this.generateExtPanID() : settings.get().advanced.ext_pan_id as number[],
panID: settings.get().advanced.pan_id === 'GENERATE' ? this.generatePanID() : (settings.get().advanced.pan_id as number),
extendedPanID:
settings.get().advanced.ext_pan_id === 'GENERATE' ? this.generateExtPanID() : (settings.get().advanced.ext_pan_id as number[]),
channelList: [settings.get().advanced.channel],
networkKey: settings.get().advanced.network_key === 'GENERATE' ?
this.generateNetworkKey() : settings.get().advanced.network_key as number[],
networkKey:
settings.get().advanced.network_key === 'GENERATE'
? this.generateNetworkKey()
: (settings.get().advanced.network_key as number[]),
},
databasePath: data.joinPath('database.db'),
databaseBackupPath: data.joinPath('database.db.backup'),
@ -108,10 +110,12 @@ export default class Zigbee {
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
await device.resolveDefinition();
logger.debug(`Received Zigbee message from '${device.name}', type '${data.type}', ` +
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) +
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``));
logger.debug(
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
`cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` +
(data.hasOwnProperty('groupID') ? ` with groupID ${data.groupID}` : ``) +
(device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``),
);
if (device.zh.type === 'Coordinator') return;
this.eventBus.emitDeviceMessage({...data, device});
});
@ -161,14 +165,16 @@ export default class Zigbee {
const {vendor, description, model} = data.device.definition;
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
} else {
logger.warning(`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
// eslint-disable-next-line max-len
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`);
logger.warning(
`Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` +
`'${data.device.zh.manufacturerName}' is NOT supported, ` +
`please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`,
);
}
} else if (data.status === 'failed') {
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}'`);
}
}
@ -186,7 +192,7 @@ export default class Zigbee {
}
private generatePanID(): number {
const panID = randomInt(1, 0xFFFF - 1);
const panID = randomInt(1, 0xffff - 1);
settings.set(['advanced', 'pan_id'], panID);
return panID;
}
@ -230,7 +236,7 @@ export default class Zigbee {
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) {
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
} else {
@ -284,8 +290,7 @@ export default class Zigbee {
}
}
resolveEntityAndEndpoint(ID: string)
: {ID: string, entity: Device | Group, endpointID: string, endpoint: zh.Endpoint} {
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group; endpointID: string; endpoint: zh.Endpoint} {
// This function matches the following entity formats:
// device_name (just device 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));
}
devices(includeCoordinator=true): Device[] {
return this.herdsman.getDevices()
devices(includeCoordinator = true): Device[] {
return this.herdsman
.getDevices()
.map((d) => this.resolveDevice(d.ieeeAddr))
.filter((d) => includeCoordinator || d.zh.type !== 'Coordinator');
}
@ -371,7 +377,7 @@ export default class Zigbee {
await this.herdsman.touchlinkIdentify(ieeeAddr, channel);
}
async touchlinkScan(): Promise<{ieeeAddr: string, channel: number}[]> {
async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
return this.herdsman.touchlinkScan();
}

71
package-lock.json generated
View File

@ -56,9 +56,11 @@
"@typescript-eslint/parser": "^7.13.1",
"babel-jest": "^29.7.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-perfectionist": "^2.11.0",
"jest": "^29.7.0",
"prettier": "^3.3.2",
"tmp": "^0.2.3",
"typescript": "^5.5.2"
},
@ -4671,16 +4673,16 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-config-google": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz",
"integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==",
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=5.16.0"
"eslint": ">=7.0.0"
}
},
"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": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@ -7939,6 +7973,12 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"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": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz",
@ -8283,6 +8323,21 @@
"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": {
"version": "29.7.0",
"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-watch": "tsc --watch",
"eslint": "eslint lib/ --max-warnings=0",
"pretty:write": "prettier --write lib test",
"pretty:check": "prettier --check lib test",
"start": "node index.js",
"test-with-coverage": "jest test --silent --maxWorkers=50% --coverage",
"test": "jest test --silent --maxWorkers=50%",
@ -79,9 +81,11 @@
"@typescript-eslint/parser": "^7.13.1",
"babel-jest": "^29.7.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-perfectionist": "^2.11.0",
"jest": "^29.7.0",
"prettier": "^3.3.2",
"tmp": "^0.2.3",
"typescript": "^5.5.2"
},

View File

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

View File

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

View File

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

View File

@ -9,4 +9,4 @@ const mockDevice = {
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 devices = zigbeeHerdsman.devices;
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', () => {
let controller;
@ -21,12 +22,12 @@ describe('Availability', () => {
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Availability');
await controller.enableDisableExtension(true, 'Availability');
}
};
const setTimeAndAdvanceTimers = async (value) => {
jest.setSystemTime(Date.now() + value);
await jest.advanceTimersByTimeAsync(value);
}
};
beforeAll(async () => {
jest.spyOn(utils, 'sleep').mockImplementation(async (seconds) => {});
@ -44,24 +45,28 @@ describe('Availability', () => {
settings.reRead();
settings.set(['availability'], true);
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());
await resetExtension();
Object.values(devices).forEach((d) => d.ping.mockClear());
});
afterEach(async () => {
})
afterEach(async () => {});
afterAll(async () => {
await controller.stop();
jest.useRealTimers();
})
});
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/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 () => {
@ -71,8 +76,8 @@ describe('Availability', () => {
await resetExtension();
await setTimeAndAdvanceTimers(utils.minutes(1));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);// enabled/unavailable
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);// enabled/available
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); // enabled/unavailable
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // enabled/available
});
it('Should not ping on startup for available or disabled devices', async () => {
@ -83,8 +88,8 @@ describe('Availability', () => {
await resetExtension();
await setTimeAndAdvanceTimers(utils.minutes(1));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);// enabled/available
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);// disabled/unavailable
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); // enabled/available
expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // disabled/unavailable
});
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});
devices.bulb_color.ping.mockImplementationOnce(() => {throw new Error('failed')});
devices.bulb_color.ping.mockImplementationOnce(() => {
throw new Error('failed');
});
await setTimeAndAdvanceTimers(utils.minutes(15));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
@ -218,7 +225,7 @@ describe('Availability', () => {
await setTimeAndAdvanceTimers(utils.minutes(9));
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 setTimeAndAdvanceTimers(utils.minutes(3));
@ -249,7 +256,7 @@ describe('Availability', () => {
//@ts-expect-error private
const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
//@ts-expect-error private
controller.state.set(device, {state: 'OFF'})
controller.state.set(device, {state: 'OFF'});
const endpoint = devices.bulb_color.getEndpoint(1);
endpoint.read.mockClear();
@ -269,7 +276,9 @@ describe('Availability', () => {
endpoint.read.mockClear();
await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color});
await flushPromises();
endpoint.read.mockImplementationOnce(() => {throw new Error('')});
endpoint.read.mockImplementationOnce(() => {
throw new Error('');
});
await setTimeAndAdvanceTimers(utils.seconds(3));
expect(endpoint.read).toHaveBeenCalledTimes(1);
});
@ -293,7 +302,12 @@ describe('Availability', () => {
MQTT.publish.mockClear();
await setTimeAndAdvanceTimers(utils.hours(26));
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 () => {
@ -332,15 +346,30 @@ describe('Availability', () => {
await resetExtension();
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();
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();
devices.bulb_color_2.lastSeen = Date.now();
await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2});
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 () => {
@ -358,7 +387,7 @@ describe('Availability', () => {
expect(availability.pingQueue).toEqual([]);
// Validate the stop-interrupt implicitly by checking that it prevents further function invocations
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 () => {

View File

@ -6,7 +6,7 @@ const settings = require('../lib/util/settings');
const Controller = require('../lib/controller');
const flushPromises = require('./lib/flushPromises');
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');
describe('Bind', () => {
@ -21,12 +21,12 @@ describe('Bind', () => {
endpoint.bind.mockClear();
endpoint.unbind.mockClear();
}
}
};
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Bind');
await controller.enableDisableExtension(true, 'Bind');
}
};
beforeAll(async () => {
jest.useFakeTimers();
@ -49,7 +49,7 @@ describe('Bind', () => {
afterAll(async () => {
jest.useRealTimers();
})
});
it('Should bind to device and configure reporting', async () => {
const device = zigbeeHerdsman.devices.remote;
@ -61,29 +61,44 @@ describe('Bind', () => {
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
const originalTargetBinds = target.binds;
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);
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();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]);
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
expect(target.configureReporting).toHaveBeenCalledTimes(3);
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("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('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('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(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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
target.binds = originalTargetBinds;
@ -102,35 +117,48 @@ describe('Bind', () => {
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
const originalTargetInputClusters = target.inputClusters;
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;
target.outputClusters = [...target.outputClusters, 8];
const originalTargetBinds = target.binds;
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);
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();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]);
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
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("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(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl","lightingColorCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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
target.binds = originalTargetBinds;
@ -150,9 +178,11 @@ describe('Bind', () => {
device.getEndpoint(1).outputClusters = [...device.getEndpoint(1).outputClusters, 768];
const originalTargetBinds = target.binds;
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);
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")});
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
const originalTargetCR = target.configuredReportings;
target.configuredReportings = [
{
@ -161,28 +191,39 @@ describe('Bind', () => {
minimumReportInterval: 0,
maximumReportInterval: 3600,
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();
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorCapabilities' ]);
expect(target.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.bind).toHaveBeenCalledTimes(4);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith("lightingColorCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', target);
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("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(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"transaction": "1234","data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl", "lightingColorCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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
target.configuredReportings = originalTargetCR;
@ -195,14 +236,15 @@ describe('Bind', () => {
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
const endpoint = device.getEndpoint(1);
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();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genOnOff"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -216,8 +258,9 @@ describe('Bind', () => {
expect(endpoint.bind).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"button","clusters":[],"failed":[]},"status":"error","error":"Nothing to bind"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote', to: 'button', clusters: [], failed: []}, status: 'error', error: 'Nothing to bind'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -226,14 +269,16 @@ describe('Bind', () => {
const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1);
// setup
target.configureReporting.mockImplementationOnce(() => {throw new Error("timeout")});
target.configureReporting.mockImplementationOnce(() => {
throw new Error('timeout');
});
const originalRemoteBinds = device.getEndpoint(1).binds;
device.getEndpoint(1).binds = [];
const originalTargetBinds = target.binds;
target.binds = [
{cluster: {name: 'genOnOff'}, 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);
@ -243,20 +288,29 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'bulb_color'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
// Disable reporting
expect(target.configureReporting).toHaveBeenCalledTimes(3);
expect(target.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 0, "reportableChange": 0}]);
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(target.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
]);
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(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote', to: 'bulb_color', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
// Teardown
@ -273,13 +327,14 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'Coordinator'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"Coordinator","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote', to: 'Coordinator', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{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'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{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(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"group_1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
// 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 flushPromises();
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genOnOff",[{"attribute": "onOff", "maximumReportInterval": 3600, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(target1Member.configureReporting).toHaveBeenCalledWith("genLevelCtrl",[{"attribute": "currentLevel", "maximumReportInterval": 3600, "minimumReportInterval": 5, "reportableChange": 1}]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{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 () => {
@ -326,13 +390,14 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', stringify({from: 'remote', to: 'group_1'}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"group_1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote', to: 'group_1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -347,7 +412,10 @@ describe('Bind', () => {
const originalBinds = 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();
mockClear(device);
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;
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();
mockClear(device);
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);
// with skip_disable_reporting set, we expect it to reconfigure reporting
expect(target1Member.configureReporting).toHaveBeenCalledTimes(2);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 65535, "minimumReportInterval": 5, "reportableChange": 1}])
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 65535, "minimumReportInterval": 0, "reportableChange": 0}])
expect(target1Member.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 65535, minimumReportInterval: 5, reportableChange: 1},
]);
expect(target1Member.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 65535, minimumReportInterval: 0, reportableChange: 0},
]);
endpoint.binds = originalBinds;
});
@ -390,13 +465,14 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', stringify({from: 'remote', to: '1'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote', to: '1', clusters: ['genScenes', 'genOnOff', 'genLevelCtrl'], failed: []}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -405,14 +481,21 @@ describe('Bind', () => {
const device = zigbeeHerdsman.devices.remote;
const endpoint = device.getEndpoint(1);
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'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote","to":"bulb_color","clusters":[],"failed":["genScenes","genOnOff","genLevelCtrl"]},"status":"error","error":"Failed to bind"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote/ep2","to":"wall_switch_double/right","clusters":["genOnOff"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote/ep2', to: 'wall_switch_double/right', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{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'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("msTemperatureMeasurement", target);
expect(endpoint.bind).toHaveBeenCalledWith('msTemperatureMeasurement', target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"temperature_sensor","to":"heating_actuator","clusters":["msTemperatureMeasurement"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'temperature_sensor', to: 'heating_actuator', clusters: ['msTemperatureMeasurement'], failed: []}, status: 'ok'}),
{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'}));
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/bind',
stringify({"data":{"from":"remote/ep2","to":"wall_switch","clusters":["genOnOff"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {from: 'remote/ep2', to: 'wall_switch', clusters: ['genOnOff'], failed: []}, status: 'ok'}),
{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}));
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/unbind',
stringify({"data":{"from":"remote","to":"default_bind_group","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'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"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'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'"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'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"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'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'"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
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(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(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 () => {
@ -576,15 +695,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'bulb_color');
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
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(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(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 () => {
@ -596,15 +724,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', 'Coordinator');
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', target);
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(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(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 () => {
@ -615,15 +752,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'group_1');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
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(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(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 () => {
@ -634,15 +780,24 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote', '1');
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target);
expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target);
expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target);
expect(endpoint.bind).toHaveBeenCalledWith('genOnOff', target);
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', target);
expect(endpoint.bind).toHaveBeenCalledWith('genScenes', target);
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(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(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 () => {
@ -650,7 +805,9 @@ describe('Bind', () => {
const device = zigbeeHerdsman.devices.remote;
const endpoint = device.getEndpoint(1);
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');
await flushPromises();
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');
await flushPromises();
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 () => {
@ -676,7 +833,7 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/bind/remote/ep2', 'wall_switch');
await flushPromises();
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 () => {
@ -687,53 +844,92 @@ describe('Bind', () => {
MQTT.events.message('zigbee2mqtt/bridge/unbind/remote', target);
await flushPromises();
expect(endpoint.unbind).toHaveBeenCalledTimes(3);
expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901);
expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901);
expect(endpoint.unbind).toHaveBeenCalledWith('genOnOff', 901);
expect(endpoint.unbind).toHaveBeenCalledWith('genLevelCtrl', 901);
expect(endpoint.unbind).toHaveBeenCalledWith('genScenes', 901);
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(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(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 () => {
const remote = zigbeeHerdsman.devices.remote;
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 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,
};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
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 () => {
const remote = zigbeeHerdsman.devices.bj_scene_switch;
const data = {"action": "recall_2_row_1"};
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockImplementationOnce(() => {throw new Error('failed')});
const payload = {data, cluster: 'genScenes', device: remote, endpoint: remote.getEndpoint(10), type: 'commandRecall', linkquality: 10, groupID: 0};
const data = {action: 'recall_2_row_1'};
zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockImplementationOnce(() => {
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 flushPromises();
// Calls to three clusters are expected in this case
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("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('genOnOff', ['onOff']);
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',
]);
});
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_2.getEndpoint(1).read.mockClear();
const remote = zigbeeHerdsman.devices.tradfri_remote;
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 data = {stepmode: 0, stepsize: 43, transtime: 5};
const payload = {
data,
cluster: 'genLevelCtrl',
device: remote,
endpoint: remote.getEndpoint(1),
type: 'commandStepWithOnOff',
linkquality: 10,
groupID: 15071,
};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(debounce).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("genOnOff", ["onOff"]);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith('genOnOff', ['onOff']);
// Should also only debounce once
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);
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);
}
};
const expectBulbConfigured = () => {
const device = zigbeeHerdsman.devices.bulb;
const endpoint1 = device.getEndpoint(1);
expect(endpoint1.read).toHaveBeenCalledTimes(2);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', [ 'colorTempPhysicalMin', 'colorTempPhysicalMax' ]);
}
expect(endpoint1.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorTempPhysicalMin', 'colorTempPhysicalMax']);
};
const expectBulbNotConfigured = () => {
const device = zigbeeHerdsman.devices.bulb;
const endpoint1 = device.getEndpoint(1);
expect(endpoint1.read).toHaveBeenCalledTimes(0);
}
};
const expectRemoteNotConfigured = () => {
const device = zigbeeHerdsman.devices.remote;
const endpoint1 = device.getEndpoint(1);
expect(endpoint1.bind).toHaveBeenCalledTimes(0);
}
};
const mockClear = (device) => {
for (const endpoint of device.endpoints) {
@ -54,12 +54,12 @@ describe('Configure', () => {
endpoint.configureReporting.mockClear();
endpoint.bind.mockClear();
}
}
};
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Configure');
await controller.enableDisableExtension(true, 'Configure');
}
};
beforeAll(async () => {
jest.useFakeTimers();
@ -80,7 +80,7 @@ describe('Configure', () => {
afterAll(async () => {
jest.useRealTimers();
})
});
it('Should configure Router on startup', async () => {
expectBulbConfigured();
@ -149,39 +149,45 @@ describe('Configure', () => {
expectRemoteConfigured();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "remote"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'remote'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "not_existing_device"},"status":"error","error": "Device 'not_existing_device' does not exist"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'not_existing_device'}, status: 'error', error: "Device 'not_existing_device' does not exist"}),
{retain: false, qos: 0},
expect.any(Function),
);
});
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')});
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: "remote"}));
zigbeeHerdsman.devices.remote.getEndpoint(1).bind.mockImplementationOnce(async () => {
throw new Error('Bind timeout after 10s');
});
await MQTT.events.message('zigbee2mqtt/bridge/request/device/configure', stringify({id: 'remote'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "remote"},"status":"error","error": "Failed to configure (Bind timeout after 10s)"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'remote'}, status: 'error', error: 'Failed to configure (Bind timeout after 10s)'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({"data":{"id": "0x0017882104a44559"},"status":"error","error": "Device 'TS0601_thermostat' cannot be configured","transaction":20}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: '0x0017882104a44559'}, status: 'error', error: "Device 'TS0601_thermostat' cannot be configured", transaction: 20}),
{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 () => {
await MQTT.events.message('zigbee2mqtt/bridge/configure', '0x0017882104a44559');
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 () => {
@ -237,19 +243,29 @@ describe('Configure', () => {
delete device.meta.configured;
const endpoint = device.getEndpoint(1);
mockClear(device);
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
endpoint.bind.mockImplementationOnce(async () => {
throw new Error('BLA');
});
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
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 logger = require('./stub/logger');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
@ -10,24 +10,36 @@ const stringify = require('json-stable-stringify-without-jsonify');
const flushPromises = require('./lib/flushPromises');
const tmp = require('tmp');
const mocksClear = [
zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug,
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error,
zigbeeHerdsman.permitJoin,
MQTT.end,
zigbeeHerdsman.stop,
logger.debug,
MQTT.publish,
MQTT.connect,
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork,
logger.error,
];
const fs = require('fs');
const LOG_MQTT_NS = 'z2m:mqtt';
jest.mock('sd-notify', () => {
return {
watchdogInterval: () => {return 3000;},
startWatchdogMode: (interval) => {},
stopWatchdogMode: () => {},
ready: () => {},
stopping: () => {},
};
}, {virtual: true});
jest.mock(
'sd-notify',
() => {
return {
watchdogInterval: () => {
return 3000;
},
startWatchdogMode: (interval) => {},
stopWatchdogMode: () => {},
ready: () => {},
stopping: () => {},
};
},
{virtual: true},
);
describe('Controller', () => {
let controller;
@ -51,27 +63,51 @@ describe('Controller', () => {
afterAll(async () => {
jest.useRealTimers();
})
});
it('Start controller', async () => {
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.setTransmitPower).toHaveBeenCalledTimes(0);
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
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('bulb (0x000b57fffec6a5b2): LED1545G12 - IKEA TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm (Router)');
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('remote (0x0017880104e45517): 324131092621 - Philips Hue dimmer switch (EndDevice)');
expect(logger.info).toHaveBeenCalledWith('0x0017880104e45518 (0x0017880104e45518): Not supported (EndDevice)');
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.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));
expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', {
will: {payload: Buffer.from('offline'), retain: true, topic: 'zigbee2mqtt/bridge/state', qos: 1},
});
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 () => {
zigbeeHerdsman.permitJoin.mockImplementationOnce(() => {throw new Error("failed!")});
zigbeeHerdsman.permitJoin.mockImplementationOnce(() => {
throw new Error('failed!');
});
await controller.start();
expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1);
expect(MQTT.connect).toHaveBeenCalledTimes(1);
@ -79,29 +115,31 @@ describe('Controller', () => {
it('Start controller with specific MQTT settings', async () => {
const ca = tmp.fileSync().name;
fs.writeFileSync(ca, "ca");
fs.writeFileSync(ca, 'ca');
const key = tmp.fileSync().name;
fs.writeFileSync(key, "key");
fs.writeFileSync(key, 'key');
const cert = tmp.fileSync().name;
fs.writeFileSync(cert, "cert");
fs.writeFileSync(cert, 'cert');
const configuration = {
base_topic: "zigbee2mqtt",
server: "mqtt://localhost",
base_topic: 'zigbee2mqtt',
server: 'mqtt://localhost',
keepalive: 30,
ca, cert, key,
ca,
cert,
key,
password: 'pass',
user: 'user1',
client_id: 'my_client_id',
reject_unauthorized: false,
version: 5,
}
settings.set(['mqtt'], configuration)
};
settings.set(['mqtt'], configuration);
await controller.start();
await flushPromises();
expect(MQTT.connect).toHaveBeenCalledTimes(1);
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,
ca: Buffer.from([99, 97]),
key: Buffer.from([107, 101, 121]),
@ -111,9 +149,8 @@ describe('Controller', () => {
clientId: 'my_client_id',
rejectUnauthorized: false,
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 () => {
@ -134,9 +171,14 @@ describe('Controller', () => {
data.writeDefaultState();
await controller.start();
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/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));
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/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 () => {
@ -144,8 +186,8 @@ describe('Controller', () => {
data.writeDefaultState();
await controller.start();
await flushPromises();
const publishedTopics = MQTT.publish.mock.calls.map(m => m[0]);
expect(publishedTopics).toEqual(expect.not.arrayContaining(["zigbee2mqtt/bulb", "zigbee2mqtt/remote"]));
const publishedTopics = MQTT.publish.mock.calls.map((m) => m[0]);
expect(publishedTopics).toEqual(expect.not.arrayContaining(['zigbee2mqtt/bulb', 'zigbee2mqtt/remote']));
});
it('Start controller should not publish cached states when cache_state is false', async () => {
@ -153,8 +195,13 @@ describe('Controller', () => {
data.writeDefaultState();
await controller.start();
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("zigbee2mqtt/remote", `{"brightness":255}`, {"qos": 0, "retain": true}, expect.any(Function));
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('zigbee2mqtt/remote', `{"brightness":255}`, {qos: 0, retain: true}, expect.any(Function));
});
it('Log when MQTT client is unavailable', async () => {
@ -163,7 +210,7 @@ describe('Controller', () => {
logger.error.mockClear();
controller.mqtt.client.reconnecting = true;
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;
});
@ -173,11 +220,19 @@ describe('Controller', () => {
logger.error.mockClear();
controller.mqtt.client.reconnecting = true;
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();
expect(logger.error).toHaveBeenCalledTimes(2);
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('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"}',
);
controller.mqtt.client.reconnecting = false;
});
@ -190,7 +245,9 @@ describe('Controller', () => {
it('Should remove device not on passlist on startup', async () => {
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 flushPromises();
expect(zigbeeHerdsman.devices.bulb_color.removeFromNetwork).toHaveBeenCalledTimes(0);
@ -206,7 +263,9 @@ describe('Controller', () => {
});
it('Start controller fails', async () => {
zigbeeHerdsman.start.mockImplementationOnce(() => {throw new Error('failed')});
zigbeeHerdsman.start.mockImplementationOnce(() => {
throw new Error('failed');
});
await controller.start();
expect(mockExit).toHaveBeenCalledTimes(1);
});
@ -247,7 +306,9 @@ describe('Controller', () => {
});
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.stop();
expect(MQTT.end).toHaveBeenCalledTimes(1);
@ -257,7 +318,9 @@ describe('Controller', () => {
});
it('Start controller adapter disconnects', async () => {
zigbeeHerdsman.stop.mockImplementationOnce(() => {throw new Error('failed')})
zigbeeHerdsman.stop.mockImplementationOnce(() => {
throw new Error('failed');
});
await controller.start();
await zigbeeHerdsman.events.adapterDisconnected();
await flushPromises();
@ -271,14 +334,14 @@ describe('Controller', () => {
await controller.start();
logger.debug.mockClear();
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 () => {
await controller.start();
logger.debug.mockClear();
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();
await controller.mqtt.publish('skip-this-topic', '', {});
await MQTT.events.message('zigbee2mqtt/skip-this-topic', 'skipped');
@ -288,19 +351,38 @@ describe('Controller', () => {
it('On zigbee event message', async () => {
await controller.start();
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 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 () => {
await controller.start();
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 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 () => {
@ -315,7 +397,12 @@ describe('Controller', () => {
const payload = {device};
await zigbeeHerdsman.events.deviceJoined(payload);
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 () => {
@ -373,7 +460,12 @@ describe('Controller', () => {
zigbeeHerdsman.events.deviceJoined(payload);
zigbeeHerdsman.events.deviceJoined(payload);
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 () => {
@ -382,7 +474,12 @@ describe('Controller', () => {
const payload = {device, status: 'started'};
await zigbeeHerdsman.events.deviceInterview(payload);
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 () => {
@ -391,7 +488,12 @@ describe('Controller', () => {
const payload = {device, status: 'failed'};
await zigbeeHerdsman.events.deviceInterview(payload);
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 () => {
@ -400,7 +502,22 @@ describe('Controller', () => {
const payload = {device, status: 'successful'};
await zigbeeHerdsman.events.deviceInterview(payload);
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 () => {
@ -409,7 +526,12 @@ describe('Controller', () => {
const payload = {device, status: 'successful'};
await zigbeeHerdsman.events.deviceInterview(payload);
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 () => {
@ -419,19 +541,29 @@ describe('Controller', () => {
await zigbeeHerdsman.events.deviceAnnounce(payload);
await flushPromises();
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 () => {
await controller.start();
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
settings.set(['devices'], {})
settings.set(['devices'], {});
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.bulb;
const payload = {ieeeAddr: device.ieeeAddr};
await zigbeeHerdsman.events.deviceLeave(payload);
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 () => {
@ -442,7 +574,12 @@ describe('Controller', () => {
const payload = {ieeeAddr: device.ieeeAddr};
await zigbeeHerdsman.events.deviceLeave(payload);
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 () => {
@ -450,16 +587,24 @@ describe('Controller', () => {
settings.set(['experimental', 'output'], 'attribute');
MQTT.publish.mockClear();
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();
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/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/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/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/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/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/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/test1', '', {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 () => {
@ -470,14 +615,18 @@ describe('Controller', () => {
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises();
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/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/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/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/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',
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 () => {
await controller.start();
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 flushPromises();
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/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/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',
stringify({state: 'ON', brightness: 200}),
{qos: 0, retain: true},
expect.any(Function),
);
});
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 flushPromises();
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/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/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',
stringify({state: 'ON', brightness: 200}),
{qos: 0, retain: true},
expect.any(Function),
);
});
it('Publish entity state attribute_json output filtered cache', async () => {
@ -513,17 +672,22 @@ describe('Controller', () => {
MQTT.publish.mockClear();
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 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).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/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/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/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 () => {
@ -533,17 +697,22 @@ describe('Controller', () => {
MQTT.publish.mockClear();
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 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).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/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/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/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 () => {
@ -553,13 +722,52 @@ describe('Controller', () => {
let device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
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"
device = controller.zigbee.resolveEntity('unsupported2');
await controller.publishEntityState(device, {state: 'ON'});
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 () => {
@ -569,7 +777,12 @@ describe('Controller', () => {
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
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 () => {
@ -579,7 +792,12 @@ describe('Controller', () => {
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
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 () => {
@ -591,7 +809,12 @@ describe('Controller', () => {
const device = controller.zigbee.resolveEntity('bulb');
await controller.publishEntityState(device, {state: 'ON'});
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 () => {
@ -614,8 +837,13 @@ describe('Controller', () => {
await controller.publishEntityState(device, {brightness: 200});
await flushPromises();
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", brightness: 200}), {"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),
);
await controller.stop();
expect(data.stateExists()).toBeFalsy();
});
@ -624,7 +852,7 @@ describe('Controller', () => {
data.removeState();
await controller.start();
logger.error.mockClear();
controller.state.file = "/";
controller.state.file = '/';
await controller.state.save();
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/Failed to write state to \'\/\'/));
});
@ -639,8 +867,8 @@ describe('Controller', () => {
await controller.publishEntityState(device, {brightness: 200});
await flushPromises();
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({"brightness":200}), {"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));
});
it('Should start when state is corrupted', async () => {
@ -656,18 +884,18 @@ describe('Controller', () => {
await flushPromises();
expect(MQTT.connect).toHaveBeenCalledTimes(1);
const expected = {
"will": { "payload": Buffer.from("offline"), "retain": false, "topic": "zigbee2mqtt/bridge/state", "qos": 1 },
}
expect(MQTT.connect).toHaveBeenCalledWith("mqtt://localhost", expected);
will: {payload: Buffer.from('offline'), retain: false, topic: 'zigbee2mqtt/bridge/state', qos: 1},
};
expect(MQTT.connect).toHaveBeenCalledWith('mqtt://localhost', expected);
});
it('Should republish retained messages on MQTT reconnect', async () => {
await controller.start();
MQTT.publish.mockClear();
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).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 () => {
@ -676,19 +904,19 @@ describe('Controller', () => {
MQTT.events['connect']();
await flushPromises();
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).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 () => {
settings.set(['mqtt', 'force_disable_retain'], true);
await controller.mqtt.connect()
await controller.mqtt.connect();
MQTT.publish.mockClear();
await controller.mqtt.publish('fo', 'bar', { retain: true })
await controller.mqtt.publish('fo', 'bar', {retain: true});
await flushPromises();
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 () => {
@ -711,7 +939,11 @@ describe('Controller', () => {
await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'deviceAnnounce'});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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 () => {
@ -728,16 +960,25 @@ describe('Controller', () => {
// https://github.com/Koenkk/zigbee2mqtt/issues/9218
await controller.start();
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 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 () => {
await controller.start();
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;
await controller.stop();
expect(controller.state.state[device.ieeeAddr]).toStrictEqual(undefined);
@ -745,7 +986,9 @@ describe('Controller', () => {
it('EventBus should handle errors', async () => {
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.emitStateChange({});
await flushPromises();

View File

@ -12,31 +12,48 @@ const fs = require('fs');
zigbeeHerdsmanConverters.addDefinition = jest.fn();
const mocksClear = [zigbeeHerdsmanConverters.addDefinition, zigbeeHerdsman.permitJoin,
mockExit, MQTT.end, zigbeeHerdsman.stop, logger.debug,
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error,
const mocksClear = [
zigbeeHerdsmanConverters.addDefinition,
zigbeeHerdsman.permitJoin,
mockExit,
MQTT.end,
zigbeeHerdsman.stop,
logger.debug,
MQTT.publish,
MQTT.connect,
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork,
logger.error,
];
jest.mock(
'mock-external-converter-module', () => {
'mock-external-converter-module',
() => {
return {
mock: true
mock: true,
};
}, {
virtual: true
});
},
{
virtual: true,
},
);
jest.mock(
'mock-multiple-external-converter-module', () => {
return [{
mock: 1
}, {
mock: 2
}];
}, {
virtual: true
});
'mock-multiple-external-converter-module',
() => {
return [
{
mock: 1,
},
{
mock: 2,
},
];
},
{
virtual: true,
},
);
describe('Loads external converters', () => {
let controller;
@ -44,7 +61,7 @@ describe('Loads external converters', () => {
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'ExternalConverters');
await controller.enableDisableExtension(true, 'ExternalConverters');
}
};
beforeAll(async () => {
jest.useFakeTimers();
@ -83,12 +100,15 @@ describe('Loads external converters', () => {
description: 'external',
fromZigbee: [],
toZigbee: [],
exposes: []
exposes: [],
});
});
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']);
await resetExtension();
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2);
@ -100,7 +120,7 @@ describe('Loads external converters', () => {
description: 'external_1',
fromZigbee: [],
toZigbee: [],
exposes: []
exposes: [],
});
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, {
mock: 2,
@ -110,7 +130,7 @@ describe('Loads external converters', () => {
description: 'external_2',
fromZigbee: [],
toZigbee: [],
exposes: []
exposes: [],
});
});
@ -119,7 +139,7 @@ describe('Loads external converters', () => {
await resetExtension();
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledWith({
mock: true
mock: true,
});
});
@ -128,18 +148,20 @@ describe('Loads external converters', () => {
await resetExtension();
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenCalledTimes(2);
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(1, {
mock: 1
mock: 1,
});
expect(zigbeeHerdsmanConverters.addDefinition).toHaveBeenNthCalledWith(2, {
mock: 2
mock: 2,
});
});
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'));
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();
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 flushPromises = require('./lib/flushPromises');
const mocksClear = [
zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug,
MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error,
zigbeeHerdsman.permitJoin,
MQTT.end,
zigbeeHerdsman.stop,
logger.debug,
MQTT.publish,
MQTT.connect,
zigbeeHerdsman.devices.bulb_color.removeFromNetwork,
zigbeeHerdsman.devices.bulb.removeFromNetwork,
logger.error,
];
const fs = require('fs');
@ -30,7 +36,7 @@ describe('User extensions', () => {
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
});
afterAll(async () => {
jest.useRealTimers();
});
@ -46,18 +52,23 @@ describe('User extensions', () => {
afterEach(() => {
const extensionPath = path.join(data.mockDir, 'extension');
rimrafSync(extensionPath);
})
});
it('Load user extension', async () => {
const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8');
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());
await controller.start();
await flushPromises();
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/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),
);
});
it('Load user extension from api call', async () => {
@ -67,53 +78,72 @@ describe('User extensions', () => {
await controller.start();
await flushPromises();
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();
expect(MQTT.publish).toHaveBeenCalledWith('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(MQTT.publish).toHaveBeenCalledWith(
'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);
});
it('Do not load corrupted extensions', async () => {
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());
await controller.start();
await flushPromises();
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();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/extension/save', expect.any(String), { retain: false, qos: 0 }, expect.any(Function));
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
expect(payload).toEqual(
expect.objectContaining({"data":{},"status":"error"})
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
expect.any(String),
{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 () => {
const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = fs.readFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), 'utf-8');
fs.mkdirSync(extensionPath);
const extensionFilePath = path.join(extensionPath, 'exampleExtension.js')
fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), extensionFilePath)
const extensionFilePath = path.join(extensionPath, 'exampleExtension.js');
fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), extensionFilePath);
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
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/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),
);
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();
expect(unlinkSyncSpy).toHaveBeenCalledWith(extensionFilePath);
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({"data":{},"status":"error","error":"Extension non existing.js doesn't exists"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {}, status: 'error', error: "Extension non existing.js doesn't exists"}),
{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 flushPromises = require('./lib/flushPromises');
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
const path = require("path");
const path = require('path');
jest.spyOn(process, 'exit').mockImplementation(() => {});
const mockHTTP = {
implementation: {
listen: jest.fn(),
on: (event, handler) => {mockHTTP.events[event] = handler},
on: (event, handler) => {
mockHTTP.events[event] = handler;
},
close: jest.fn().mockImplementation((cb) => cb()),
},
variables: {},
@ -23,7 +25,9 @@ const mockHTTP = {
const mockHTTPS = {
implementation: {
listen: jest.fn(),
on: (event, handler) => {mockHTTPS.events[event] = handler},
on: (event, handler) => {
mockHTTPS.events[event] = handler;
},
close: jest.fn().mockImplementation((cb) => cb()),
},
variables: {},
@ -37,9 +41,11 @@ const mockWSocket = {
const mockWS = {
implementation: {
clients: [],
on: (event, handler) => {mockWS.events[event] = handler},
on: (event, handler) => {
mockWS.events[event] = handler;
},
handleUpgrade: jest.fn().mockImplementation((request, socket, head, cb) => {
cb(mockWSocket)
cb(mockWSocket);
}),
emit: jest.fn(),
close: jest.fn(),
@ -70,11 +76,11 @@ jest.mock('https', () => ({
Agent: jest.fn(),
}));
jest.mock("connect-gzip-static", () =>
jest.mock('connect-gzip-static', () =>
jest.fn().mockImplementation((path) => {
mockNodeStatic.variables.path = path
return mockNodeStatic.implementation
})
mockNodeStatic.variables.path = path;
return mockNodeStatic.implementation;
}),
);
jest.mock('zigbee2mqtt-frontend', () => ({
@ -100,7 +106,7 @@ describe('Frontend', () => {
data.writeDefaultConfiguration();
data.writeDefaultState();
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);
zigbeeHerdsman.devices.bulb.linkquality = 10;
});
@ -109,15 +115,15 @@ describe('Frontend', () => {
jest.useRealTimers();
});
afterEach(async() => {
afterEach(async () => {
delete zigbeeHerdsman.devices.bulb.linkquality;
});
it('Start/stop', async () => {
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
expect(mockNodeStatic.variables.path).toBe("my/dummy/path");
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
const mockWSClient = {
implementation: {
terminate: jest.fn(),
@ -140,7 +146,7 @@ describe('Frontend', () => {
settings.set(['frontend'], {port: 8081});
controller = new Controller(jest.fn(), jest.fn());
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);
const mockWSClient = {
implementation: {
@ -161,11 +167,11 @@ describe('Frontend', () => {
});
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());
await controller.start();
expect(mockNodeStatic.variables.path).toBe("my/dummy/path");
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith("/tmp/zigbee2mqtt.sock");
expect(mockNodeStatic.variables.path).toBe('my/dummy/path');
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith('/tmp/zigbee2mqtt.sock');
const mockWSClient = {
implementation: {
terminate: jest.fn(),
@ -184,42 +190,39 @@ describe('Frontend', () => {
mockHTTPS.implementation.listen.mockClear();
});
it('Start/stop HTTPS valid', async () => {
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_cert'], path.join(__dirname, 'assets', 'certs', 'dummy.crt'));
settings.set(['frontend', 'ssl_key'], path.join(__dirname, 'assets', 'certs', 'dummy.key'));
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1");
expect(mockHTTPS.implementation.listen).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');
await controller.stop();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
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());
await controller.start();
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
expect(mockHTTPS.implementation.listen).not.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');
await controller.stop();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Start/stop HTTPS invalid : missing file', async () => {
settings.set(['frontend','ssl_cert'], 'filesNotExists.crt');
settings.set(['frontend','ssl_key'], path.join(__dirname,'assets','certs','dummy.key'));
settings.set(['frontend', 'ssl_cert'], 'filesNotExists.crt');
settings.set(['frontend', 'ssl_key'], path.join(__dirname, 'assets', 'certs', 'dummy.key'));
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1");
expect(mockHTTPS.implementation.listen).not.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');
await controller.stop();
mockHTTP.implementation.listen.mockClear();
mockHTTPS.implementation.listen.mockClear();
});
it('Websocket interaction', async () => {
@ -229,7 +232,9 @@ describe('Frontend', () => {
// Connect
const mockWSClient = {
implementation: {
on: (event, handler) => {mockWSClient.events[event] = handler},
on: (event, handler) => {
mockWSClient.events[event] = handler;
},
send: jest.fn(),
readyState: 'open',
},
@ -239,22 +244,28 @@ describe('Frontend', () => {
await mockWS.events.connection(mockWSClient.implementation);
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
MQTT.publish.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();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color',
stringify({state: 'ON', 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)
stringify({
state: 'ON',
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("", false);
mockWSClient.events.message('', false);
mockWSClient.events.message(null, false);
await flushPromises();
@ -264,12 +275,23 @@ describe('Frontend', () => {
// Received message on socket
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
mockWSClient.implementation.send.mockClear();
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);
// Send last seen on connect
@ -278,7 +300,9 @@ describe('Frontend', () => {
settings.set(['advanced'], {last_seen: 'ISO_8601'});
mockWS.implementation.clients.push(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 () => {
@ -290,9 +314,9 @@ describe('Frontend', () => {
mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
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);
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);
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
@ -303,11 +327,11 @@ describe('Frontend', () => {
controller = new Controller(jest.fn(), jest.fn());
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 () => {
const authToken = 'sample-secure-token'
const authToken = 'sample-secure-token';
settings.set(['frontend'], {auth_token: authToken});
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
@ -317,8 +341,8 @@ describe('Frontend', () => {
mockHTTP.events.upgrade({url: '/api'}, mockSocket, mockWSocket);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({"url": "/api"}, mockSocket, mockWSocket, expect.any(Function));
expect(mockWSocket.close).toHaveBeenCalledWith(4401, "Unauthorized");
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({url: '/api'}, mockSocket, mockWSocket, expect.any(Function));
expect(mockWSocket.close).toHaveBeenCalledWith(4401, 'Unauthorized');
mockWSocket.close.mockClear();
mockWS.implementation.emit.mockClear();
@ -332,6 +356,5 @@ describe('Frontend', () => {
expect(mockWSocket.close).toHaveBeenCalledTimes(0);
mockWS.implementation.handleUpgrade.mock.calls[0][3](mockWSocket);
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
});
});

View File

@ -22,7 +22,7 @@ describe('Groups', () => {
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'Groups');
await controller.enableDisableExtension(true, 'Groups');
}
};
beforeAll(async () => {
jest.useFakeTimers();
@ -36,22 +36,22 @@ describe('Groups', () => {
});
beforeEach(() => {
Object.values(zigbeeHerdsman.groups).forEach((g) => g.members = []);
Object.values(zigbeeHerdsman.groups).forEach((g) => (g.members = []));
data.writeDefaultConfiguration();
settings.reRead();
MQTT.publish.mockClear();
zigbeeHerdsman.groups.gledopto_group.command.mockClear();
zigbeeHerdsmanConverters.toZigbee.__clearStore__();
controller.state.state = {};
})
});
it('Apply group updates add', async () => {
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))
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));
await resetExtension();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([
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 group = zigbeeHerdsman.groups.group_1;
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();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
});
it('Apply group updates remove handle fail', async () => {
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;
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();
await resetExtension();
expect(logger.error).toHaveBeenCalledWith(`Failed to remove 'bulb_color' from 'group_1'`);
@ -81,28 +83,28 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
});
it('Add non standard endpoint to group with name', async () => {
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();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(3)]);
});
it('Add non standard endpoint to group with number', async () => {
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();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(2)]);
});
it('Shouldnt crash on non-existing devices', async () => {
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();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]);
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 () => {
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();
expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([
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 endpoint = device.getEndpoint(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);
await resetExtension();
MQTT.events.message('zigbee2mqtt/bridge/group/group_1/add', 'bulb_color');
await flushPromises();
expect(group.members).toStrictEqual([endpoint]);
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 () => {
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups["group/with/slashes"];
settings.set(['groups'], {'99': {friendly_name: 'group/with/slashes', retain: false, devices: []}});
const group = zigbeeHerdsman.groups['group/with/slashes'];
settings.set(['groups'], {99: {friendly_name: 'group/with/slashes', retain: false, devices: []}});
expect(group.members.length).toBe(0);
await resetExtension();
MQTT.events.message('zigbee2mqtt/bridge/group/group/with/slashes/add', 'bulb_color');
await flushPromises();
expect(group.members).toStrictEqual([endpoint]);
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 () => {
@ -177,13 +189,18 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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 MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color');
await flushPromises();
expect(group.members).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 () => {
@ -191,7 +208,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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 MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color');
await flushPromises();
@ -204,7 +221,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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 MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/3');
await flushPromises();
@ -217,7 +234,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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 MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'wall_switch_double/3');
await flushPromises();
@ -230,7 +247,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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 MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/right');
await flushPromises();
@ -243,13 +260,18 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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 MQTT.events.message('zigbee2mqtt/bridge/group/remove_all', '0x0017880104e45542/right');
await flushPromises();
expect(group.members).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 () => {
@ -257,7 +279,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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 MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove_all', '0x0017880104e45542/right');
await flushPromises();
@ -294,7 +316,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
@ -303,8 +325,8 @@ describe('Groups', () => {
await flushPromises();
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/group_1", 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));
});
it('Should not republish identical optimistic group states', async () => {
@ -314,16 +336,55 @@ describe('Groups', () => {
await resetExtension();
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({data: {onOff: 1}, cluster: 'genOnOff', device: device2, endpoint: device2.getEndpoint(1), type: 'attributeReport', linkquality: 10});
await zigbeeHerdsman.events.message({
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();
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("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));
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/group_tradfri_remote',
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 () => {
@ -331,15 +392,15 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
await flushPromises();
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/group_1", 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));
});
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;
group.members.push(endpoint);
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();
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
await flushPromises();
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 () => {
@ -364,29 +425,29 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'}));
await flushPromises();
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/group_1", 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));
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'OFF'}));
await flushPromises();
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/group_1", 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));
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'ON'}));
await flushPromises();
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/group_1", 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));
});
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 flushPromises();
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("zigbee2mqtt/gledopto_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'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).toHaveBeenCalledWith("genOnOff", "on", {}, {});
expect(group.command).toHaveBeenCalledWith('genOnOff', 'on', {}, {});
});
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 flushPromises();
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("zigbee2mqtt/gledopto_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'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);
});
@ -421,15 +502,20 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON', brightness: 100}));
await flushPromises();
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("zigbee2mqtt/group_1", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'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 () => {
@ -437,7 +523,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
@ -446,7 +532,7 @@ describe('Groups', () => {
await flushPromises();
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 () => {
@ -455,9 +541,9 @@ describe('Groups', () => {
const group = zigbeeHerdsman.groups.group_1;
group.members.push(endpoint);
settings.set(['groups'], {
'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]},
'2': {friendly_name: 'group_2', retain: false, devices: [device.ieeeAddr]},
'3': {friendly_name: 'group_3', retain: false, devices: []}
1: {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]},
2: {friendly_name: 'group_2', retain: false, devices: [device.ieeeAddr]},
3: {friendly_name: 'group_3', retain: false, devices: []},
});
await resetExtension();
@ -465,9 +551,9 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'}));
await flushPromises();
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/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/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_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 () => {
@ -479,7 +565,7 @@ describe('Groups', () => {
group.members.push(endpoint_1);
group.members.push(endpoint_2);
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();
@ -490,7 +576,7 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
await flushPromises();
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 () => {
@ -502,7 +588,7 @@ describe('Groups', () => {
group.members.push(endpoint_1);
group.members.push(endpoint_2);
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();
@ -513,8 +599,20 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
await flushPromises();
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(2, "zigbee2mqtt/bulb_color", stringify({state:"OFF"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenNthCalledWith(
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 () => {
@ -526,8 +624,8 @@ describe('Groups', () => {
group.members.push(endpoint_1);
group.members.push(endpoint_2);
settings.set(['groups'], {
'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]},
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]},
});
await resetExtension();
@ -538,8 +636,8 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/group_2/set', stringify({state: 'OFF'}));
await flushPromises();
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/bulb_color", 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));
});
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_2);
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();
@ -563,9 +661,9 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/bulb/set', stringify({state: 'OFF'}));
await flushPromises();
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", 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/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/group_1', stringify({state: 'OFF'}), {retain: false, qos: 0}, expect.any(Function));
});
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_2);
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();
MQTT.publish.mockClear();
@ -590,9 +688,24 @@ describe('Groups', () => {
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 300}));
await flushPromises();
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("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));
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(
'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 () => {
@ -604,7 +717,7 @@ describe('Groups', () => {
group.members.push(endpoint_1);
group.members.push(endpoint_2);
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();
@ -617,27 +730,28 @@ describe('Groups', () => {
await flushPromises();
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/group_1", 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));
});
it('Add to group via MQTT', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(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);
await resetExtension();
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();
expect(group.members).toStrictEqual([endpoint]);
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/response/group/members/add',
stringify({"data":{"device":"bulb_color","group":"group_1"},"transaction": "123", "status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'bulb_color', group: 'group_1'}, transaction: '123', status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -645,10 +759,12 @@ describe('Groups', () => {
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(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);
await resetExtension();
endpoint.addToGroup.mockImplementationOnce(() => {throw new Error('timeout')});
endpoint.addToGroup.mockImplementationOnce(() => {
throw new Error('timeout');
});
await flushPromises();
MQTT.publish.mockClear();
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).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/members/add',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"error","error":"Failed to add from group (timeout)"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'error', error: 'Failed to add from group (timeout)'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
it('Add to group with slashes via MQTT', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups["group/with/slashes"];
settings.set(['groups'], {'99': {friendly_name: 'group/with/slashes', retain: false, devices: []}});
const group = zigbeeHerdsman.groups['group/with/slashes'];
settings.set(['groups'], {99: {friendly_name: 'group/with/slashes', retain: false, devices: []}});
expect(group.members.length).toBe(0);
await resetExtension();
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/response/group/members/add',
stringify({"data":{"device":"bulb_color","group":"group/with/slashes"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'bulb_color', group: 'group/with/slashes'}, status: 'ok'}),
{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/response/group/members/add',
stringify({"data":{"device":"wall_switch_double/right","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'wall_switch_double/right', group: 'group_1'}, status: 'ok'}),
{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/response/group/members/add',
stringify({"data":{"device":"wall_switch_double/right","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'wall_switch_double/right', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -728,7 +848,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
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/response/group/members/remove',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -748,18 +869,22 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
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();
expect(group.members).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/response/group/members/remove',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -768,7 +893,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
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/response/group/members/remove',
stringify({"data":{"device":"bulb_color","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'bulb_color', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -788,7 +914,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
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/response/group/members/remove',
stringify({"data":{"device":"0x0017880104e45542/3","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: '0x0017880104e45542/3', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -808,7 +935,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
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/response/group/members/remove',
stringify({"data":{"device":"wall_switch_double/3","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: 'wall_switch_double/3', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -828,7 +956,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
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/response/group/members/remove',
stringify({"data":{"device":"0x0017880104e45542/right","group":"group_1"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: '0x0017880104e45542/right', group: 'group_1'}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -848,7 +977,7 @@ describe('Groups', () => {
const endpoint = device.getEndpoint(1);
const group = zigbeeHerdsman.groups.group_1;
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();
MQTT.publish.mockClear();
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/response/group/members/remove_all',
stringify({"data":{"device":"0x0017880104e45542/right"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {device: '0x0017880104e45542/right'}, status: 'ok'}),
{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).toHaveBeenCalledWith(
'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"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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).toHaveBeenCalledWith(
'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"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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();
logger.error.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();
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'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'"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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;
group.members.push(bulbColor.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();
MQTT.publish.mockClear();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color_temp: 50}));
await flushPromises();
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("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));
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(
'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();
await MQTT.events.message('zigbee2mqtt/group_1/set', stringify({color: {x: 0.5, y: 0.3}}));
await flushPromises();
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("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));
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(
'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 flushPromises = require('../lib/flushPromises');
describe('Bridge legacy', () => {
let controller;
let version;
@ -19,7 +18,7 @@ describe('Bridge legacy', () => {
version = await require('../../lib/util/utils').default.getZigbee2MQTTVersion();
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
})
});
beforeEach(() => {
data.writeDefaultConfiguration();
@ -35,9 +34,16 @@ describe('Bridge legacy', () => {
it('Should publish bridge configuration on startup', async () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'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}),
{ retain: true, qos: 0 },
expect.any(Function)
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,
}),
{retain: true, qos: 0},
expect.any(Function),
);
});
@ -62,23 +68,23 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: "device_whitelisted", "message": {friendly_name: "bulb_color"}}),
{ retain: false, qos: 0 },
expect.any(Function)
stringify({type: 'device_whitelisted', message: {friendly_name: 'bulb_color'}}),
{retain: false, qos: 0},
expect.any(Function),
);
MQTT.publish.mockClear()
MQTT.publish.mockClear();
expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr]);
MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb');
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: "device_whitelisted", "message": {friendly_name: "bulb"}}),
{ retain: false, qos: 0 },
expect.any(Function)
stringify({type: 'device_whitelisted', message: {friendly_name: 'bulb'}}),
{retain: false, qos: 0},
expect.any(Function),
);
MQTT.publish.mockClear()
MQTT.publish.mockClear();
expect(settings.get().passlist).toStrictEqual([bulb_color.ieeeAddr, bulb.ieeeAddr]);
MQTT.events.message('zigbee2mqtt/bridge/config/whitelist', 'bulb');
await flushPromises();
@ -88,39 +94,48 @@ describe('Bridge legacy', () => {
it('Should allow changing device options', async () => {
const bulb_color = zigbeeHerdsman.devices.bulb_color;
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/device_options', stringify({friendly_name: 'bulb_color', options: {retain: true}}));
await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual(
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
);
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', stringify({friendly_name: 'bulb_color', optionswrong: {retain: true}}));
await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual(
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
);
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: true});
MQTT.events.message('zigbee2mqtt/bridge/config/device_options', "{friendly_name: 'bulb_color'malformed: {retain: true}}");
await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual(
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "retain": true}
);
expect(settings.getDevice('bulb_color')).toStrictEqual({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}}));
await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual(
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true}
expect(settings.getDevice('bulb_color')).toStrictEqual({
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();
expect(settings.getDevice('bulb_color')).toStrictEqual(
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true}}
expect(settings.getDevice('bulb_color')).toStrictEqual({
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();
expect(settings.getDevice('bulb_color')).toStrictEqual(
{"ID": "0x000b57fffec6a5b3", "friendly_name": "bulb_color", "random_setting": true, "retain": true, options: {random_1: true, random_2: false}}
);
expect(settings.getDevice('bulb_color')).toStrictEqual({
ID: '0x000b57fffec6a5b3',
friendly_name: 'bulb_color',
random_setting: true,
retain: true,
options: {random_1: true, random_2: false},
});
});
it('Should allow permit join', async () => {
@ -142,7 +157,9 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.reset).toHaveBeenCalledWith('soft');
zigbeeHerdsman.reset.mockImplementationOnce(() => {throw new Error('')});
zigbeeHerdsman.reset.mockImplementationOnce(() => {
throw new Error('');
});
MQTT.events.message('zigbee2mqtt/bridge/config/reset', '');
await flushPromises();
expect(zigbeeHerdsman.reset).toHaveBeenCalledTimes(2);
@ -182,8 +199,30 @@ describe('Bridge legacy', () => {
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/config/devices');
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
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[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"});
expect(payload[1]).toStrictEqual({
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;
});
@ -193,13 +232,25 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
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 () => {
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();
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'}));
await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual(null);
@ -208,7 +259,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'device_renamed', message: {from: 'bulb_color', to: 'bulb_color2'}}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
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 () => {
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'}));
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(
'zigbee2mqtt/bridge/log',
stringify({type: 'group_renamed', message: {from: 'group_1', to: 'group_1_renamed'}}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
});
@ -251,7 +302,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'device_renamed', message: {from: 'bulb', to: 'bulb_new_name'}}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
});
@ -273,9 +324,9 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'group_added', message: 'new_group'}),
{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).toHaveBeenCalledWith(3);
});
@ -284,14 +335,14 @@ describe('Bridge legacy', () => {
zigbeeHerdsman.createGroup.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group"}');
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).toHaveBeenCalledWith(3);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'group_added', message: 'new_group'}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
});
@ -299,14 +350,14 @@ describe('Bridge legacy', () => {
zigbeeHerdsman.createGroup.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"friendly_name": "new_group", "id": 42}');
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).toHaveBeenCalledWith(42);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'group_added', message: 'new_group'}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
});
@ -314,9 +365,9 @@ describe('Bridge legacy', () => {
zigbeeHerdsman.createGroup.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/add_group', '{"id": 42}');
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).toHaveBeenCalledWith(42)
expect(zigbeeHerdsman.createGroup).toHaveBeenCalledWith(42);
});
it('Should allow to remove groups', async () => {
@ -329,7 +380,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'group_removed', message: 'group_1'}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
});
@ -343,7 +394,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'group_removed', message: 'group_1'}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
});
@ -378,7 +429,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'device_removed', message: 'bulb_color'}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
expect(controller.state.state).toStrictEqual({});
expect(settings.get().blocklist.length).toBe(0);
@ -400,7 +451,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'device_force_removed', message: 'bulb_color'}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
expect(controller.state.state).toStrictEqual({});
expect(settings.get().blocklist.length).toBe(0);
@ -421,7 +472,7 @@ describe('Bridge legacy', () => {
'zigbee2mqtt/bridge/log',
stringify({type: 'device_banned', message: 'bulb_color'}),
{qos: 0, retain: false},
expect.any(Function)
expect.any(Function),
);
expect(settings.get().blocklist).toStrictEqual(['0x000b57fffec6a5b3']);
});
@ -436,28 +487,32 @@ describe('Bridge legacy', () => {
it('Should handle when remove fails', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
device.removeFromNetwork.mockClear();
device.removeFromNetwork.mockImplementationOnce(() => {throw new Error('')})
device.removeFromNetwork.mockImplementationOnce(() => {
throw new Error('');
});
await flushPromises();
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'bulb_color');
await flushPromises();
expect(device.removeFromNetwork).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);
});
it('Should handle when ban fails', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
device.removeFromNetwork.mockClear();
device.removeFromNetwork.mockImplementationOnce(() => {throw new Error('')})
device.removeFromNetwork.mockImplementationOnce(() => {
throw new Error('');
});
await flushPromises();
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/config/ban', 'bulb_color');
await flushPromises();
expect(device.removeFromNetwork).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);
});

View File

@ -32,12 +32,22 @@ describe('Report', () => {
expect(endpoint.bind).toHaveBeenCalledWith('genLevelCtrl', coordinatorEndpoint);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 300, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 300, minimumReportInterval: 0, reportableChange: 0},
]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
]);
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 {
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('lightingColorCtrl', coordinatorEndpoint);
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [{"attribute": "onOff", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 0, "reportableChange": 0}]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [{"attribute": "currentLevel", "maximumReportInterval": 0xFFFF, "minimumReportInterval": 3, "reportableChange": 1}]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genOnOff', [
{attribute: 'onOff', maximumReportInterval: 0xffff, minimumReportInterval: 0, reportableChange: 0},
]);
expect(endpoint.configureReporting).toHaveBeenCalledWith('genLevelCtrl', [
{attribute: 'currentLevel', maximumReportInterval: 0xffff, minimumReportInterval: 3, reportableChange: 1},
]);
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 {
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.unbind.mockClear();
}
}
};
beforeAll(async () => {
jest.useFakeTimers();
@ -130,7 +150,7 @@ describe('Report', () => {
device.save.mockClear();
mockClear(device);
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
@ -155,7 +175,9 @@ describe('Report', () => {
it('Should not mark as configured when reporting setup fails', async () => {
const device = zigbeeHerdsman.devices.bulb;
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;
mockClear(device);
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 endpoint = device.getEndpoint(1);
mockClear(device);
const data = {onOff: 1}
const data = {onOff: 1};
const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
@ -233,7 +255,9 @@ describe('Report', () => {
it('Should not configure reporting again when it already failed once', async () => {
const device = zigbeeHerdsman.devices.bulb;
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;
mockClear(device);
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('genLevelCtrl', coordinatorEndpoint);
expect(endpoint.bind).toHaveBeenCalledWith('lightingColorCtrl', coordinatorEndpoint);
expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities'])
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [{"attribute": "colorTemperature", "maximumReportInterval": 300, "minimumReportInterval": 3, "reportableChange": 1}]);
expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl', ['colorCapabilities']);
expect(endpoint.configureReporting).toHaveBeenCalledWith('lightingColorCtrl', [
{attribute: 'colorTemperature', maximumReportInterval: 300, minimumReportInterval: 3, reportableChange: 1},
]);
expect(endpoint.configureReporting).toHaveBeenCalledTimes(3);
endpoint.configuredReportings = configuredReportings;
});

View File

@ -30,8 +30,7 @@ describe('Logger', () => {
consoleWriteSpy.mockClear();
});
afterEach(async () => {
});
afterEach(async () => {});
it('Create log directory', () => {
const dirs = fs.readdirSync(dir.name);
@ -50,7 +49,7 @@ describe('Logger', () => {
expect(fs.readdirSync(dir.name).length).toBe(21);
logger.cleanup();
expect(fs.readdirSync(dir.name).length).toBe(10);
})
});
it('Should not cleanup when there is no timestamp set', () => {
for (let i = 30; i < 40; i++) {
@ -61,7 +60,7 @@ describe('Logger', () => {
expect(fs.readdirSync(dir.name).length).toBe(21);
logger.cleanup();
expect(fs.readdirSync(dir.name).length).toBe(21);
})
});
it('Set and get log level', () => {
logger.setLevel('debug');
@ -82,8 +81,7 @@ describe('Logger', () => {
it('Add/remove transport', () => {
class DummyTransport extends Transport {
log(info, callback) {
}
log(info, callback) {}
}
expect(logger.winston.transports.length).toBe(2);
@ -143,7 +141,7 @@ describe('Logger', () => {
it('Should allow to symlink logs to current directory', () => {
settings.set(['advanced', 'log_symlink_current'], true);
logger.init();
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy()
expect(fs.readdirSync(dir.name).includes('current')).toBeTruthy();
jest.resetModules();
});
@ -192,51 +190,51 @@ describe('Logger', () => {
it('Logs Error object', () => {
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(consoleWriteSpy).toHaveBeenCalledTimes(1);
})
});
it.each([
[
'^zhc:legacy:fz:(tuya|moes)',
new RegExp(/^zhc:legacy:fz:(tuya|moes)/),
[
{ ns: 'zhc:legacy:fz:tuya_device12', match: true },
{ ns: 'zhc:legacy:fz:moes_dimmer', match: true },
{ ns: 'zhc:legacy:fz:not_moes', match: false },
{ ns: 'zhc:legacy:fz', match: false },
{ ns: 'zhc:legacy:fz:', match: false },
{ ns: '1zhc:legacy:fz:tuya_device12', match: false },
]
{ns: 'zhc:legacy:fz:tuya_device12', match: true},
{ns: 'zhc:legacy:fz:moes_dimmer', match: true},
{ns: 'zhc:legacy:fz:not_moes', match: false},
{ns: 'zhc:legacy:fz', match: false},
{ns: 'zhc:legacy:fz:', match: false},
{ns: '1zhc:legacy:fz:tuya_device12', match: false},
],
],
[
'^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', match: false },
{ ns: 'zh:controller', match: true },
{ ns: 'zh:controller:', match: true },
{ ns: 'azh:controller:', match: false },
]
{ns: 'zh:ember:uart:ash', match: true},
{ns: 'zh:ember:uart', match: false},
{ns: 'zh:controller', match: true},
{ns: 'zh:controller:', match: true},
{ns: 'azh:controller:', match: false},
],
],
[
'',
undefined,
[
{ ns: 'zhc:legacy:fz:tuya_device12', match: false },
{ ns: 'zhc:legacy:fz:moes_dimmer', match: false },
{ ns: 'zhc:legacy:fz:not_moes', match: false },
{ ns: 'zhc:legacy:fz', match: false },
{ ns: 'zhc:legacy:fz:', match: false },
{ ns: '1zhc:legacy:fz:tuya_device12', match: false },
{ ns: 'zh:ember:uart:ash', match: false },
{ ns: 'zh:ember:uart', match: false },
{ ns: 'zh:controller', match: false },
{ ns: 'zh:controller:', match: false },
{ ns: 'azh:controller:', match: false },
]
{ns: 'zhc:legacy:fz:tuya_device12', match: false},
{ns: 'zhc:legacy:fz:moes_dimmer', match: false},
{ns: 'zhc:legacy:fz:not_moes', match: false},
{ns: 'zhc:legacy:fz', match: false},
{ns: 'zhc:legacy:fz:', match: false},
{ns: '1zhc:legacy:fz:tuya_device12', match: false},
{ns: 'zh:ember:uart:ash', match: false},
{ns: 'zh:ember:uart', match: false},
{ns: 'zh:controller', match: false},
{ns: 'zh:controller:', match: false},
{ns: 'azh:controller:', match: false},
],
],
])('Sets namespace ignore for debug level %s', (ignore, expected, tests) => {
logger.setLevel('debug');
@ -264,7 +262,7 @@ describe('Logger', () => {
});
logger.init();
logger.setLevel('debug');
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": 'warning'});
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'warning'});
expect(logger.getLevel()).toStrictEqual('debug');
const logSpy = jest.spyOn(logger.winston, 'log');
@ -283,9 +281,9 @@ describe('Logger', () => {
it('Logs with namespaced levels or default - lower', () => {
expect(logger.getNamespacedLevels()).toStrictEqual({});
logger.setNamespacedLevels({'z2m:mqtt': 'info'})
logger.setNamespacedLevels({'z2m:mqtt': 'info'});
logger.setLevel('warning');
expect(logger.getNamespacedLevels()).toStrictEqual({"z2m:mqtt": 'info'});
expect(logger.getNamespacedLevels()).toStrictEqual({'z2m:mqtt': 'info'});
expect(logger.getLevel()).toStrictEqual('warning');
const logSpy = jest.spyOn(logger.winston, 'log');
@ -332,7 +330,7 @@ describe('Logger', () => {
expect(consoleWriteSpy).toHaveBeenCalledTimes(3);
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');
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'z2m:mqtt': 'info'}));
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(consoleWriteSpy).toHaveBeenCalledTimes(5);
logger.setNamespacedLevels({'zh:zstack': 'warning'})
expect(logger.cachedNamespacedLevels).toStrictEqual(cachedNSLevels = {'zh:zstack': 'warning'});
logger.setNamespacedLevels({'zh:zstack': 'warning'});
expect(logger.cachedNamespacedLevels).toStrictEqual((cachedNSLevels = {'zh:zstack': 'warning'}));
logger.error(`error logged`, 'zh:zstack:unpi:writer');
expect(logger.cachedNamespacedLevels).toStrictEqual(Object.assign(cachedNSLevels, {'zh:zstack:unpi:writer': 'warning'}));
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(CC2530_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 settings = require('../lib/util/settings');
const Controller = require('../lib/controller');
@ -25,7 +25,7 @@ describe('Networkmap', () => {
beforeAll(async () => {
jest.useFakeTimers();
Date.now = jest.fn()
Date.now = jest.fn();
Date.now.mockReturnValue(10000);
data.writeDefaultConfiguration();
settings.reRead();
@ -66,32 +66,62 @@ describe('Networkmap', () => {
* | -> CC2530_ROUTER -> WXKG02LM_rev1
*
*/
coordinator.lqi = () => {return {neighbors: [
{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},
{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},
]}};
coordinator.lqi = () => {
return {
neighbors: [
{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},
{
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: [
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 1, depth: 2, linkquality: 110},
{ieeeAddr: CC2530_ROUTER.ieeeAddr, networkAddress: CC2530_ROUTER.networkAddress, relationship: 1, depth: 2, linkquality: 100}
]}};
bulb.routingTable = () => {return {table: []}};
bulb.lqi = () => {
return {
neighbors: [
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 1, depth: 2, linkquality: 110},
{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.routingTable = () => {return {table: []}};
bulb_color.lqi = () => {
return {neighbors: []};
};
bulb_color.routingTable = () => {
return {table: []};
};
CC2530_ROUTER.lqi = () => {return {neighbors: [
{ieeeAddr: '0x0000000000000000', networkAddress: WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
{ieeeAddr: bulb_color.ieeeAddr, networkAddress: bulb_color.networkAddress, relationship: 4, depth: 2, linkquality: 130},
]}};
CC2530_ROUTER.routingTable = () => {return {table: []}};
CC2530_ROUTER.lqi = () => {
return {
neighbors: [
{ieeeAddr: '0x0000000000000000', networkAddress: WXKG02LM_rev1.networkAddress, relationship: 1, depth: 2, linkquality: 130},
{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.routingTable = () => {throw new Error('failed')};
unsupported_router.lqi = () => {
throw new Error('failed');
};
unsupported_router.routingTable = () => {
throw new Error('failed');
};
}
it('Output raw networkmap legacy api', async () => {
@ -102,7 +132,175 @@ describe('Networkmap', () => {
let call = MQTT.publish.mock.calls[0];
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);
/**
@ -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);
});
@ -131,7 +329,7 @@ describe('Networkmap', () => {
const device = zigbeeHerdsman.devices.bulb_color;
device.lastSeen = null;
const endpoint = device.getEndpoint(1);
const data = {modelID: 'test'}
const data = {modelID: 'test'};
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'graphviz');
@ -170,7 +368,7 @@ describe('Networkmap', () => {
const device = zigbeeHerdsman.devices.bulb_color;
device.lastSeen = null;
const endpoint = device.getEndpoint(1);
const data = {modelID: 'test'}
const data = {modelID: 'test'};
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
MQTT.events.message('zigbee2mqtt/bridge/networkmap/routes', 'plantuml');
@ -276,7 +474,187 @@ describe('Networkmap', () => {
let call = MQTT.publish.mock.calls[0];
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]);
expect(actual).toStrictEqual(expected);
});
@ -288,8 +666,9 @@ describe('Networkmap', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/networkmap',
stringify({"data":{},"status":"error","error":"Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {}, status: 'error', error: "Type 'not_existing' not supported, allowed are: raw,graphviz,plantuml"}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -303,7 +682,147 @@ describe('Networkmap', () => {
let call = MQTT.publish.mock.calls[0];
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]);
expect(actual).toStrictEqual(expected);
});

View File

@ -44,8 +44,8 @@ describe('On event', () => {
it('Should call with start event', async () => {
expect(mockOnEvent).toHaveBeenCalledTimes(1);
const call = mockOnEvent.mock.calls[0];
expect(call[0]).toBe('start')
expect(call[1]).toStrictEqual({})
expect(call[0]).toBe('start');
expect(call[1]).toStrictEqual({});
expect(call[2]).toBe(device);
expect(call[3]).toStrictEqual(settings.getDevice(device.ieeeAddr));
expect(call[4]).toStrictEqual({});
@ -57,8 +57,8 @@ describe('On event', () => {
await flushPromises();
expect(mockOnEvent).toHaveBeenCalledTimes(1);
const call = mockOnEvent.mock.calls[0];
expect(call[0]).toBe('stop')
expect(call[1]).toStrictEqual({})
expect(call[0]).toBe('stop');
expect(call[1]).toStrictEqual({});
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');
describe('OTA update', () => {
let controller;
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'OTAUpdate');
await controller.enableDisableExtension(true, 'OTAUpdate');
}
};
const mockClear = (mapped) => {
mapped.ota.updateToLatest = jest.fn();
mapped.ota.isUpdateAvailable = jest.fn();
}
};
beforeAll(async () => {
data.writeDefaultConfiguration();
@ -66,9 +65,9 @@ describe('OTA update', () => {
let count = 0;
endpoint.read.mockImplementation(() => {
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);
logger.info.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 10.00%, ≈ 60 minutes remaining`);
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(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: 'immediate'});
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: undefined});
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"updating","progress":0}}),
{retain: true, qos: 0}, expect.any(Function)
stringify({update_available: false, update: {state: 'updating', progress: 0}}),
{retain: true, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"updating","progress":10,"remaining":3600}}),
{retain: true, qos: 0}, expect.any(Function)
stringify({update_available: false, update: {state: 'updating', progress: 10, remaining: 3600}}),
{retain: true, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"idle","installed_version":90,"latest_version":90}}),
{retain: true, qos: 0}, expect.any(Function)
stringify({update_available: false, update: {state: 'idle', installed_version: 90, latest_version: 90}}),
{retain: true, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'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"}),
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/devices',
expect.any(String),
{ retain: true, qos: 0 },
expect.any(Function)
stringify({
data: {from: {date_code: '20190101', software_build_id: 1}, id: 'bulb', to: {date_code: '20190102', software_build_id: 2}},
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));
});
it('Should handle when OTA update fails', async () => {
const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.endpoints[0];
endpoint.read.mockImplementation(() => {return {swBuildId: 1, dateCode: '2019010'}});
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
endpoint.read.mockImplementation(() => {
return {swBuildId: 1, dateCode: '2019010'};
});
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
device.save.mockClear();
mapped.ota.updateToLatest.mockImplementationOnce((a, onUpdate) => {
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({"update_available":true,"update":{"state":"available"}}),
{retain: true, qos: 0}, expect.any(Function)
stringify({update_available: true, update: {state: 'available'}}),
{retain: true, qos: 0},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({"data":{"id": "bulb"},"status":"error","error":"Update of 'bulb' failed (Update failed)"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'bulb'}, status: 'error', error: "Update of 'bulb' failed (Update failed)"}),
{retain: false, qos: 0},
expect.any(Function),
);
});
it('Should be able to check if OTA update is available', async () => {
const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
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();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "bulb","updateAvailable":false},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'bulb', updateAvailable: false}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
MQTT.publish.mockClear();
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();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(2);
expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "bulb","updateAvailable":true},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'bulb', updateAvailable: true}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
it('Should handle if OTA update check fails', async () => {
const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
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();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith(
'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)`}),
{retain: false, qos: 0}, expect.any(Function)
stringify({
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 () => {
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "not_existing_deviceooo"},"status":"error","error": `Device 'not_existing_deviceooo' does not exist`}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'not_existing_deviceooo'}, status: 'error', error: `Device 'not_existing_deviceooo' does not exist`}),
{retain: false, qos: 0},
expect.any(Function),
);
});
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();
expect(MQTT.publish).toHaveBeenCalledWith(
'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`}),
{retain: false, qos: 0}, expect.any(Function)
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),
);
});
it('Should refuse to check/update when already in progress', async () => {
const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
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();
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb");
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', 'bulb');
await flushPromises();
expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1);
jest.runOnlyPendingTimers();
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/check',
stringify({"data":{"id": "bulb"},"status":"error","error": `Update or check for update already in progress for 'bulb'`}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'bulb'}, status: 'error', error: `Update or check for update already in progress for 'bulb'`}),
{retain: false, qos: 0},
expect.any(Function),
);
});
it('Shouldnt crash when read modelID before/after OTA update fails', async () => {
const device = zigbeeHerdsman.devices.bulb;
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);
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', "bulb");
MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', 'bulb');
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({"data":{"id":"bulb","from":null,"to":null},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
stringify({data: {id: 'bulb', from: null, to: null}, status: 'ok'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
@ -252,18 +276,26 @@ describe('OTA update', () => {
const device = zigbeeHerdsman.devices.bulb;
device.endpoints[0].commandResponse.mockClear();
const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
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();
await zigbeeHerdsman.events.message(payload);
await flushPromises();
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(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
await zigbeeHerdsman.events.message(payload);
@ -277,8 +309,9 @@ describe('OTA update', () => {
expect(logger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({"update_available":true,"update":{"state":"available","installed_version":10,"latest_version":12}}),
{retain: true, qos: 0}, expect.any(Function)
stringify({update_available: true, update: {state: 'available', installed_version: 10, latest_version: 12}}),
{retain: true, qos: 0},
expect.any(Function),
);
});
@ -286,21 +319,32 @@ describe('OTA update', () => {
const device = zigbeeHerdsman.devices.bulb;
device.endpoints[0].commandResponse.mockClear();
const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {throw new Error('Nothing to find here')})
const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10, meta: {zclTransactionSequenceNumber: 10}};
mapped.ota.isUpdateAvailable.mockImplementationOnce(() => {
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();
await zigbeeHerdsman.events.message(payload);
await flushPromises();
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).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 0x98}, undefined, 10);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"idle"}}),
{retain: true, qos: 0}, expect.any(Function)
stringify({update_available: false, update: {state: 'idle'}}),
{retain: true, qos: 0},
expect.any(Function),
);
});
@ -308,21 +352,30 @@ describe('OTA update', () => {
const device = zigbeeHerdsman.devices.bulb;
device.endpoints[0].commandResponse.mockClear();
const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
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();
await zigbeeHerdsman.events.message(payload);
await flushPromises();
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).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 0x98}, undefined, 10);
expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith('genOta', 'queryNextImageResponse', {status: 0x98}, undefined, 10);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb',
stringify({"update_available":false,"update":{"state":"idle","installed_version": 13, "latest_version": 13}}),
{retain: true, qos: 0}, expect.any(Function)
stringify({update_available: false, update: {state: 'idle', installed_version: 13, latest_version: 13}}),
{retain: true, qos: 0},
expect.any(Function),
);
});
@ -330,10 +383,18 @@ describe('OTA update', () => {
settings.set(['ota', 'disable_automatic_update_check'], true);
const device = zigbeeHerdsman.devices.bulb;
const data = {imageType: 12382};
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
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();
await zigbeeHerdsman.events.message(payload);
await flushPromises();
@ -343,22 +404,38 @@ describe('OTA update', () => {
it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA', async () => {
const device = zigbeeHerdsman.devices.HGZB04D;
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 flushPromises();
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 () => {
const device = zigbeeHerdsman.devices.SV01;
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();
await zigbeeHerdsman.events.message(payload);
await flushPromises();
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 () => {
@ -367,9 +444,9 @@ describe('OTA update', () => {
let count = 0;
endpoint.read.mockImplementation(() => {
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);
logger.info.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 10.00%, ≈ 60 minutes remaining`);
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(device.save).toHaveBeenCalledTimes(2);
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: 'immediate'});
expect(endpoint.read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: undefined});
});
it('Legacy api: Should handle when OTA update fails', async () => {
const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.endpoints[0];
endpoint.read.mockImplementation(() => {return {swBuildId: 1, dateCode: '2019010'}});
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
endpoint.read.mockImplementation(() => {
return {swBuildId: 1, dateCode: '2019010'};
});
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
logger.info.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 () => {
const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
logger.info.mockClear();
@ -439,10 +520,12 @@ describe('OTA update', () => {
it('Legacy api: Should handle if OTA update check fails', async () => {
const device = zigbeeHerdsman.devices.bulb;
const mapped = await zigbeeHerdsmanConverters.findByDevice(device)
const mapped = await zigbeeHerdsmanConverters.findByDevice(device);
mockClear(mapped);
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');
await flushPromises();
@ -462,12 +545,12 @@ describe('OTA update', () => {
const endpoint = device.endpoints[0];
let count = 0;
endpoint.read.mockImplementation(() => {
if (count === 1) throw new Error('Failed!')
if (count === 1) throw new Error('Failed!');
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);
logger.info.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/ota_update/update', 'bulb');
@ -480,7 +563,7 @@ describe('OTA update', () => {
await resetExtension();
expect(spyUseIndexOverride).toHaveBeenCalledWith(path.join(data.mockDir, 'local.index.json'));
spyUseIndexOverride.mockClear();
settings.set(['ota', 'zigbee_ota_override_index_location'], 'http://my.site/index.json');
await resetExtension();
expect(spyUseIndexOverride).toHaveBeenCalledWith('http://my.site/index.json');
@ -489,7 +572,7 @@ describe('OTA update', () => {
it('Clear update state on startup', async () => {
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();
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 () => {
const device = zigbeeHerdsman.devices.WXKG11LM;
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
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 () => {
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
const data = {measuredValue: -85}
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
const device = zigbeeHerdsman.devices.J1;
// 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();
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
MQTT.publish.mockClear();
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();
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 () => {
const device = zigbeeHerdsman.devices.WXKG11LM;
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
@ -114,14 +150,35 @@ describe('Receive', () => {
it('Should debounce messages', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
const data1 = {measuredValue: 8}
const payload1 = {data: data1, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data1 = {measuredValue: 8};
const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload1);
const data2 = {measuredValue: 1}
const payload2 = {data: data2, cluster: 'msRelativeHumidity', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data2 = {measuredValue: 1};
const payload2 = {
data: data2,
cluster: 'msRelativeHumidity',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload2);
const data3 = {measuredValue: 2}
const payload3 = {data: data3, cluster: 'msPressureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data3 = {measuredValue: 2};
const payload3 = {
data: data3,
cluster: 'msPressureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload3);
await flushPromises();
jest.advanceTimersByTime(50);
@ -131,7 +188,7 @@ describe('Receive', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
@ -139,14 +196,35 @@ describe('Receive', () => {
settings.set(['device_options', 'debounce'], 0.1);
settings.set(['device_options', 'retain'], true);
delete settings.get().devices['0x0017880104e45522']['retain'];
const data1 = {measuredValue: 8}
const payload1 = {data: data1, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data1 = {measuredValue: 8};
const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload1);
const data2 = {measuredValue: 1}
const payload2 = {data: data2, cluster: 'msRelativeHumidity', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data2 = {measuredValue: 1};
const payload2 = {
data: data2,
cluster: 'msRelativeHumidity',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload2);
const data3 = {measuredValue: 2}
const payload3 = {data: data3, cluster: 'msPressureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data3 = {measuredValue: 2};
const payload3 = {
data: data3,
cluster: 'msPressureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload3);
await flushPromises();
jest.advanceTimersByTime(50);
@ -156,20 +234,48 @@ describe('Receive', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'debounce'], 0.1);
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);
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);
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);
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 flushPromises();
jest.advanceTimersByTime(50);
@ -182,16 +288,36 @@ describe('Receive', () => {
});
it('Should NOT publish old messages from State cache during debouncing', async () => {
// 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".
// 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;
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( {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 zigbeeHerdsman.events.message({
data: {measuredValue: 8},
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();
jest.advanceTimersByTime(50);
// 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});
// 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);
// 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();
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});
// 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});
});
});
it('Shouldnt republish old state', async () => {
// https://github.com/Koenkk/zigbee2mqtt/issues/3572
const device = zigbeeHerdsman.devices.bulb;
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 flushPromises();
jest.runOnlyPendingTimers();
@ -238,83 +378,118 @@ describe('Receive', () => {
it('Should handle a zigbee message with 1 precision', async () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 1);
const data = {measuredValue: -85}
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
const data = {measuredValue: -85}
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
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 () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['device_options', 'temperature_precision'], 1);
const data = {measuredValue: -85}
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
const device = zigbeeHerdsman.devices.WSDCGQ11LM;
settings.set(['device_options', 'temperature_precision'], 1);
settings.set(['devices', device.ieeeAddr, 'temperature_precision'], 0);
const data = {measuredValue: -85}
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
const data = {measuredValue: -85};
const payload = {
data,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
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 () => {
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(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 () => {
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
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(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
});
it('Should publish 1 message when converted twice', async () => {
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};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
@ -330,8 +505,8 @@ describe('Receive', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
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});
});
it('Should publish last_seen ISO_8601', async () => {
@ -343,8 +518,8 @@ describe('Receive', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
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});
});
it('Should publish last_seen ISO_8601_local', async () => {
@ -356,8 +531,8 @@ describe('Receive', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
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});
});
it('Should handle messages from Xiaomi router devices', async () => {
@ -367,19 +542,13 @@ describe('Receive', () => {
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/power_plug',
stringify({state: 'ON'}),
{ retain: false, qos: 0 },
expect.any(Function)
);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/power_plug', 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)
stringify({state: 'ON'}),
{retain: false, qos: 0},
expect.any(Function),
);
});
it('Should not handle messages from coordinator', async () => {
@ -405,13 +574,21 @@ describe('Receive', () => {
it('Should handle a command', async () => {
const device = zigbeeHerdsman.devices.E1743;
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 flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
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});
});
it('Should add elapsed', async () => {
@ -419,7 +596,7 @@ describe('Receive', () => {
const device = zigbeeHerdsman.devices.E1743;
const payload = {data: {}, cluster: 'genLevelCtrl', device, endpoint: device.getEndpoint(1), type: 'commandStopWithOnOff'};
const oldNow = Date.now;
Date.now = jest.fn()
Date.now = jest.fn();
Date.now.mockReturnValue(new Date(150));
await zigbeeHerdsman.events.message({...payload, meta: {zclTransactionSequenceNumber: 2}});
await flushPromises();
@ -428,12 +605,12 @@ describe('Receive', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2);
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(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
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[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(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;
});
@ -444,16 +621,26 @@ describe('Receive', () => {
await zigbeeHerdsman.events.message(payload);
await flushPromises();
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 () => {
// 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.
const data = {instantaneousDemand:496,currentSummDelivered:[0,6648]}
const data = {instantaneousDemand: 496, currentSummDelivered: [0, 6648]};
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();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/SP600_NEW');
@ -461,7 +648,15 @@ describe('Receive', () => {
MQTT.publish.mockClear();
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();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
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 () => {
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};
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', () => {
const write = (file, json, reread=true) => {
fs.writeFileSync(file, yaml.dump(json))
const write = (file, json, reread = true) => {
fs.writeFileSync(file, yaml.dump(json));
if (reread) {
settings.reRead();
}
@ -28,14 +28,14 @@ describe('Settings', () => {
const read = (file) => yaml.load(fs.readFileSync(file, 'utf8'));
const remove = (file) => {
if (fs.existsSync(file)) fs.unlinkSync(file);
}
};
const clearEnvironmentVariables = () => {
Object.keys(process.env).forEach((key) => {
if(key.indexOf('ZIGBEE2MQTT_CONFIG_') >= 0) {
if (key.indexOf('ZIGBEE2MQTT_CONFIG_') >= 0) {
delete process.env[key];
}
});
}
};
beforeEach(() => {
remove(configurationFile);
@ -69,7 +69,8 @@ describe('Settings', () => {
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_SOFT_RESET_TIMEOUT'] = 1;
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_OUTPUT'] = 'attribute_and_json';
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_SERVER'] = 'testserver';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY'] = 'GENERATE';
@ -97,7 +98,7 @@ describe('Settings', () => {
expected.groups = {};
expected.serial.disable_led = true;
expected.advanced.soft_reset_timeout = 1;
expected.advanced.log_output = ["console"];
expected.advanced.log_output = ['console'];
expected.advanced.output = 'attribute_and_json';
expected.map_options.graphviz.colors.fill = {enddevice: '#ff0000', coordinator: '#00ff00', router: '#0000ff'};
expected.mqtt.base_topic = 'testtopic';
@ -153,7 +154,7 @@ describe('Settings', () => {
const device = settings.getDevice('0x12345678');
const expected = {
ID: "0x12345678",
ID: '0x12345678',
friendly_name: '0x12345678',
retain: false,
};
@ -175,14 +176,14 @@ describe('Settings', () => {
password: '!secret password',
},
advanced: {
network_key: '!secret network_key'
}
network_key: '!secret network_key',
},
};
const contentSecret = {
username: 'mysecretusername',
password: 'mysecretpassword',
network_key: [1,2,3],
network_key: [1, 2, 3],
};
write(secretFile, contentSecret, false);
@ -192,22 +193,22 @@ describe('Settings', () => {
base_topic: 'zigbee2mqtt',
include_device_information: false,
force_disable_retain: false,
password: "mysecretpassword",
server: "my.mqtt.server",
user: "mysecretusername",
password: 'mysecretpassword',
server: 'my.mqtt.server',
user: 'mysecretusername',
};
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();
expect(read(configurationFile)).toStrictEqual(contentConfiguration);
expect(read(secretFile)).toStrictEqual(contentSecret);
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(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', () => {
@ -219,14 +220,14 @@ describe('Settings', () => {
},
advanced: {
network_key: '!secret network_key',
}
},
};
const contentSecret = {
server: 'my.mqtt.server',
username: 'mysecretusername',
password: 'mysecretpassword',
network_key: [1,2,3],
network_key: [1, 2, 3],
};
write(secretFile, contentSecret, false);
@ -236,13 +237,13 @@ describe('Settings', () => {
base_topic: 'zigbee2mqtt',
include_device_information: false,
force_disable_retain: false,
password: "mysecretpassword",
server: "my.mqtt.server",
user: "mysecretusername",
password: 'mysecretpassword',
server: 'my.mqtt.server',
user: 'mysecretusername',
};
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();
expect(read(configurationFile)).toStrictEqual(contentConfiguration);
@ -269,7 +270,7 @@ describe('Settings', () => {
write(devicesFile, contentDevices);
const device = settings.getDevice('0x12345678');
const expected = {
ID: "0x12345678",
ID: '0x12345678',
friendly_name: '0x12345678',
retain: false,
};
@ -279,7 +280,7 @@ describe('Settings', () => {
it('Should read devices form 2 separate files', () => {
const contentConfiguration = {
devices: ['devices.yaml', 'devices2.yaml']
devices: ['devices.yaml', 'devices2.yaml'],
};
const contentDevices = {
@ -343,7 +344,7 @@ describe('Settings', () => {
const contentDevices = {
'0x12345678': {
friendly_name: '0x12345678',
retain: false,
retain: false,
},
};
@ -358,9 +359,9 @@ describe('Settings', () => {
const expected = {
'0x12345678': {
friendly_name: '0x12345678',
retain: false,
retain: false,
},
'0x1234': {
'0x1234': {
friendly_name: '0x1234',
},
};
@ -373,13 +374,13 @@ describe('Settings', () => {
extractFromMultipleDeviceConfigs({
'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', () => {
extractFromMultipleDeviceConfigs(null)
extractFromMultipleDeviceConfigs(null);
});
it('Should add devices to a separate file if devices.yaml doesnt exist', () => {
@ -400,8 +401,7 @@ describe('Settings', () => {
};
expect(read(devicesFile)).toStrictEqual(expected);
}
);
});
it('Should add and remove devices to a separate file if devices.yaml doesnt exist', () => {
const contentConfiguration = {
@ -417,13 +417,12 @@ describe('Settings', () => {
expect(read(configurationFile)).toStrictEqual({devices: 'devices.yaml'});
expect(read(devicesFile)).toStrictEqual({});
}
);
});
it('Should read groups', () => {
const content = {
groups: {
'1': {
1: {
friendly_name: '123',
},
},
@ -447,7 +446,7 @@ describe('Settings', () => {
};
const contentGroups = {
'1': {
1: {
friendly_name: '123',
},
};
@ -472,7 +471,7 @@ describe('Settings', () => {
};
const contentGroups = {
'1': {
1: {
friendly_name: '123',
devices: [],
},
@ -523,7 +522,7 @@ describe('Settings', () => {
const added = settings.addGroup('test123');
const expected = {
'1': {
1: {
friendly_name: 'test123',
},
};
@ -536,7 +535,7 @@ describe('Settings', () => {
const added = settings.addGroup('test123', 123);
const expected = {
'123': {
123: {
friendly_name: 'test123',
},
};
@ -560,7 +559,7 @@ describe('Settings', () => {
settings.addGroup('test123');
}).toThrow(new Error("friendly_name 'test123' is already in use"));
const expected = {
'1': {
1: {
friendly_name: 'test123',
},
};
@ -576,7 +575,7 @@ describe('Settings', () => {
settings.addGroup('test_id_123', 123);
}).toThrow(new Error("Group ID '123' is already in use"));
const expected = {
'123': {
123: {
friendly_name: 'test123',
},
};
@ -590,14 +589,14 @@ describe('Settings', () => {
'0x123': {
friendly_name: 'bulb',
retain: true,
}
}
},
},
});
settings.addGroup('test123');
settings.addDeviceToGroup('test123', ['0x123']);
const expected = {
'1': {
1: {
friendly_name: 'test123',
devices: ['0x123'],
},
@ -612,19 +611,19 @@ describe('Settings', () => {
'0x123': {
friendly_name: 'bulb',
retain: true,
}
},
},
groups: {
'1': {
1: {
friendly_name: 'test123',
devices: ['0x123'],
}
}
},
},
});
settings.removeDeviceFromGroup('test123', ['0x123']);
const expected = {
'1': {
1: {
friendly_name: 'test123',
devices: [],
},
@ -639,18 +638,18 @@ describe('Settings', () => {
'0x123': {
friendly_name: 'bulb',
retain: true,
}
},
},
groups: {
'1': {
1: {
friendly_name: 'test123',
}
}
},
},
});
settings.removeDeviceFromGroup('test123', ['0x123']);
const expected = {
'1': {
1: {
friendly_name: 'test123',
},
};
@ -664,12 +663,12 @@ describe('Settings', () => {
'0x123': {
friendly_name: 'bulb',
retain: true,
}
},
},
});
expect(() => {
settings.removeDeviceFromGroup('test123', 'bulb')
settings.removeDeviceFromGroup('test123', 'bulb');
}).toThrow(new Error("Group 'test123' does not exist"));
});
@ -679,12 +678,12 @@ describe('Settings', () => {
'0x123': {
friendly_name: 'bulb',
retain: true,
}
},
},
});
expect(() => {
settings.addDevice('0x123')
settings.addDevice('0x123');
}).toThrow(new Error("Device '0x123' already exists"));
});
@ -735,7 +734,6 @@ describe('Settings', () => {
expect(settings.validate()).toEqual([]);
});
it('Should not allow retention configuration without MQTT v5', () => {
write(configurationFile, {
...minimalConfig,
@ -782,10 +780,13 @@ describe('Settings', () => {
});
it('Should throw error when yaml file is invalid', () => {
fs.writeFileSync(configurationFile, `
fs.writeFileSync(
configurationFile,
`
good: 9
\t wrong
`)
`,
);
settings.testing.clear();
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, {
...minimalConfig,
devices: {'0x0017880104e45519': {friendly_name: 'myname', retain: false}},
groups: {'1': {friendly_name: 'myname', retain: false}},
groups: {1: {friendly_name: 'myname', retain: false}},
});
settings.reRead();
@ -886,7 +887,7 @@ describe('Settings', () => {
write(configurationFile, {
devices: {
'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, {
devices: {
'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', () => {
write(configurationFile, {
devices: {
'0x12345678': {
friendly_name: 'custom discovery',
homeassistant: {
entityXYZ: {
entity_category: null,
}
}
}
}
});
settings.changeEntityOptions('0x12345678',{disabled: true});
devices: {
'0x12345678': {
friendly_name: 'custom discovery',
homeassistant: {
entityXYZ: {
entity_category: null,
},
},
},
},
});
settings.changeEntityOptions('0x12345678', {disabled: true});
const actual = read(configurationFile);
const expected = {
devices: {
'0x12345678': {
friendly_name: 'custom discovery',
disabled: true,
homeassistant: {
entityXYZ: {
entity_category: null,
}
}
devices: {
'0x12345678': {
friendly_name: 'custom discovery',
disabled: true,
homeassistant: {
entityXYZ: {
entity_category: null,
},
},
},
},
}
};
expect(actual).toStrictEqual(expected);
});
it('Should keep homeassistant null properties on apply', async () => {
write(configurationFile, {
device_options: {
homeassistant: {temperature: null},
},
devices: {
'0x1234567812345678': {
friendly_name: 'custom discovery',
homeassistant: {humidity: null},
}
}
});
device_options: {
homeassistant: {temperature: null},
},
devices: {
'0x1234567812345678': {
friendly_name: 'custom discovery',
homeassistant: {humidity: null},
},
},
});
settings.reRead();
settings.apply({permit_join: false});
expect(settings.get().device_options.homeassistant).toStrictEqual({temperature: null});
@ -974,86 +975,76 @@ describe('Settings', () => {
});
it('Frontend config', () => {
write(configurationFile, {...minimalConfig,
frontend: true,
});
write(configurationFile, {...minimalConfig, frontend: true});
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', () => {
write(configurationFile, {...minimalConfig,
advanced: {baudrate: 20},
});
write(configurationFile, {...minimalConfig, advanced: {baudrate: 20}});
settings.reRead();
expect(settings.get().serial.baudrate).toStrictEqual(20)
expect(settings.get().serial.baudrate).toStrictEqual(20);
});
it('ikea_ota_use_test_url config', () => {
write(configurationFile, {...minimalConfig,
advanced: {ikea_ota_use_test_url: true},
});
write(configurationFile, {...minimalConfig, advanced: {ikea_ota_use_test_url: true}});
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', () => {
write(configurationFile, {...minimalConfig,
experimental: {transmit_power: 1337},
});
write(configurationFile, {...minimalConfig, experimental: {transmit_power: 1337}});
settings.reRead();
expect(settings.get().advanced.transmit_power).toStrictEqual(1337)
expect(settings.get().advanced.transmit_power).toStrictEqual(1337);
});
it('output config', () => {
write(configurationFile, {...minimalConfig,
experimental: {output: 'json'},
});
write(configurationFile, {...minimalConfig, experimental: {output: 'json'}});
settings.reRead();
expect(settings.get().advanced.output).toStrictEqual('json')
expect(settings.get().advanced.output).toStrictEqual('json');
});
it('Baudrartsctste config', () => {
write(configurationFile, {...minimalConfig,
advanced: {rtscts: true},
});
write(configurationFile, {...minimalConfig, advanced: {rtscts: true}});
settings.reRead();
expect(settings.get().serial.rtscts).toStrictEqual(true)
expect(settings.get().serial.rtscts).toStrictEqual(true);
});
it('Deprecated: Home Assistant config', () => {
write(configurationFile, {...minimalConfig,
write(configurationFile, {
...minimalConfig,
homeassistant: {discovery_topic: 'new'},
advanced: {homeassistant_discovery_topic: 'old', homeassistant_status_topic: 'olds'},
});
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', () => {
write(configurationFile, {...minimalConfig,
ban: ['ban'], whitelist: ['whitelist'], passlist: ['passlist'], blocklist: ['blocklist']
});
write(configurationFile, {...minimalConfig, ban: ['ban'], whitelist: ['whitelist'], passlist: ['passlist'], blocklist: ['blocklist']});
settings.reRead();
expect(settings.get().blocklist).toStrictEqual(['blocklist', 'ban'])
expect(settings.get().passlist).toStrictEqual(['passlist', 'whitelist'])
expect(settings.get().blocklist).toStrictEqual(['blocklist', 'ban']);
expect(settings.get().passlist).toStrictEqual(['passlist', 'whitelist']);
});
it('Deprecated: warn log level', () => {
write(configurationFile, {...minimalConfig,
advanced: {log_level: 'warn'}
});
write(configurationFile, {...minimalConfig, advanced: {log_level: 'warn'}});
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,
permit_join: true,
mqtt: {
base_topic: "zigbee2mqtt",
server: "mqtt://localhost",
base_topic: 'zigbee2mqtt',
server: 'mqtt://localhost',
},
serial: {
"port": "/dev/dummy",
port: '/dev/dummy',
},
devices: {
"0x000b57fffec6a5b2": {
'0x000b57fffec6a5b2': {
retain: true,
friendly_name: "bulb",
description: "this is my bulb",
friendly_name: 'bulb',
description: 'this is my bulb',
},
"0x0017880104e45517": {
'0x0017880104e45517': {
retain: true,
friendly_name: "remote"
friendly_name: 'remote',
},
"0x0017880104e45520": {
'0x0017880104e45520': {
retain: false,
friendly_name: "button"
friendly_name: 'button',
},
"0x0017880104e45521": {
'0x0017880104e45521': {
retain: false,
friendly_name: "button_double_key"
friendly_name: 'button_double_key',
},
"0x0017880104e45522": {
'0x0017880104e45522': {
qos: 1,
retain: false,
friendly_name: "weather_sensor"
friendly_name: 'weather_sensor',
},
"0x0017880104e45523": {
'0x0017880104e45523': {
retain: false,
friendly_name: "occupancy_sensor"
friendly_name: 'occupancy_sensor',
},
"0x0017880104e45524": {
'0x0017880104e45524': {
retain: false,
friendly_name: "power_plug"
friendly_name: 'power_plug',
},
"0x0017880104e45530": {
'0x0017880104e45530': {
retain: false,
friendly_name: "button_double_key_interviewing"
friendly_name: 'button_double_key_interviewing',
},
"0x0017880104e45540": {
friendly_name: "ikea_onoff"
'0x0017880104e45540': {
friendly_name: 'ikea_onoff',
},
'0x000b57fffec6a5b7': {
retain: false,
friendly_name: "bulb_2"
friendly_name: 'bulb_2',
},
"0x000b57fffec6a5b3": {
'0x000b57fffec6a5b3': {
retain: false,
friendly_name: "bulb_color"
friendly_name: 'bulb_color',
},
'0x000b57fffec6a5b4': {
retain: false,
friendly_name: "bulb_color_2"
friendly_name: 'bulb_color_2',
},
"0x0017880104e45541": {
'0x0017880104e45541': {
retain: false,
friendly_name: "wall_switch"
friendly_name: 'wall_switch',
},
"0x0017880104e45542": {
'0x0017880104e45542': {
retain: false,
friendly_name: "wall_switch_double"
friendly_name: 'wall_switch_double',
},
"0x0017880104e45543": {
'0x0017880104e45543': {
retain: false,
friendly_name: "led_controller_1"
friendly_name: 'led_controller_1',
},
"0x0017880104e45544": {
'0x0017880104e45544': {
retain: false,
friendly_name: "led_controller_2"
friendly_name: 'led_controller_2',
},
'0x0017880104e45545': {
retain: false,
friendly_name: "dimmer_wall_switch"
friendly_name: 'dimmer_wall_switch',
},
'0x0017880104e45547': {
retain: false,
friendly_name: "curtain"
friendly_name: 'curtain',
},
'0x0017880104e45548': {
retain: false,
friendly_name: 'fan'
friendly_name: 'fan',
},
'0x0017880104e45549': {
retain: false,
friendly_name: 'siren'
friendly_name: 'siren',
},
'0x0017880104e45529': {
retain: false,
friendly_name: 'unsupported2'
friendly_name: 'unsupported2',
},
'0x0017880104e45550': {
retain: false,
friendly_name: 'thermostat'
friendly_name: 'thermostat',
},
'0x0017880104e45551': {
retain: false,
friendly_name: 'smart vent'
friendly_name: 'smart vent',
},
'0x0017880104e45552': {
retain: false,
friendly_name: 'j1'
friendly_name: 'j1',
},
'0x0017880104e45553': {
retain: false,
friendly_name: 'bulb_enddevice'
friendly_name: 'bulb_enddevice',
},
'0x0017880104e45559': {
retain: false,
friendly_name: 'cc2530_router'
friendly_name: 'cc2530_router',
},
'0x0017880104e45560': {
retain: false,
friendly_name: 'livolo'
friendly_name: 'livolo',
},
'0x90fd9ffffe4b64ae': {
retain: false,
@ -151,7 +151,7 @@ function writeDefaultConfiguration() {
friendly_name: 'GL-S-007ZS',
},
'0x0017880104e43559': {
friendly_name: 'U202DST600ZB'
friendly_name: 'U202DST600ZB',
},
'0xf4ce368a38be56a1': {
retain: false,
@ -196,44 +196,44 @@ function writeDefaultConfiguration() {
},
'0x0017880104e45562': {
friendly_name: 'heating_actuator',
}
},
},
groups: {
'1': {
1: {
friendly_name: 'group_1',
retain: false,
},
'2': {
2: {
friendly_name: 'group_2',
retain: false,
},
'15071': {
15071: {
friendly_name: 'group_tradfri_remote',
retain: false,
devices: ['bulb_color_2', 'bulb_2']
devices: ['bulb_color_2', 'bulb_2'],
},
'11': {
11: {
friendly_name: 'group_with_tradfri',
retain: false,
devices: ['bulb_2']
devices: ['bulb_2'],
},
'12': {
12: {
friendly_name: 'thermostat_group',
retain: false,
devices: ['TS0601_thermostat'],
},
'14': {
14: {
friendly_name: 'switch_group',
retain: false,
devices: ['power_plug', 'bulb_2'],
},
'21': {
21: {
friendly_name: 'gledopto_group',
devices: ['GLEDOPTO_2ID/cct'],
},
'9': {
9: {
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: [],
@ -257,19 +257,19 @@ function stateExists() {
}
const defaultState = {
"0x000b57fffec6a5b2": {
"state": "ON",
"brightness": 50,
"color_temp": 370,
"linkquality": 99,
'0x000b57fffec6a5b2': {
state: 'ON',
brightness: 50,
color_temp: 370,
linkquality: 99,
},
"0x0017880104e45517": {
"brightness": 255
'0x0017880104e45517': {
brightness: 255,
},
"1": {
'state': 'ON',
}
}
1: {
state: 'ON',
},
};
function getDefaultState() {
return defaultState;

View File

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

View File

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

View File

@ -19,20 +19,20 @@ class Group {
}
const clusters = {
'genBasic': 0,
'genOta': 25,
'genScenes': 5,
'genOnOff': 6,
'genLevelCtrl': 8,
'lightingColorCtrl': 768,
'closuresWindowCovering': 258,
'hvacThermostat': 513,
'msIlluminanceMeasurement': 1024,
'msTemperatureMeasurement': 1026,
'msRelativeHumidity': 1029,
'msSoilMoisture': 1032,
'msCO2': 1037
}
genBasic: 0,
genOta: 25,
genScenes: 5,
genOnOff: 6,
genLevelCtrl: 8,
lightingColorCtrl: 768,
closuresWindowCovering: 258,
hvacThermostat: 513,
msIlluminanceMeasurement: 1024,
msTemperatureMeasurement: 1026,
msRelativeHumidity: 1029,
msSoilMoisture: 1032,
msCO2: 1037,
};
const custom_clusters = {
custom_1: {
@ -42,14 +42,25 @@ const custom_clusters = {
attribute_0: {ID: 0, type: 49},
},
commands: {
command_0: { ID: 0, response: 0, parameters: [{name: 'reset', type: 40}], },
command_0: {ID: 0, response: 0, parameters: [{name: 'reset', type: 40}]},
},
commandsResponse: {},
},
}
};
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.clusterValues = clusterValues;
this.ID = ID;
@ -68,32 +79,38 @@ class Endpoint {
this.profileID = profileID;
this.deviceID = deviceID;
this.configuredReportings = configuredReportings;
this.getInputClusters = () => inputClusters.map((c) => {
return {ID: c, name: getKeyByValue(clusters, c)};
}).filter((c) => c.name);
this.getInputClusters = () =>
inputClusters
.map((c) => {
return {ID: c, name: getKeyByValue(clusters, c)};
})
.filter((c) => c.name);
this.getOutputClusters = () => outputClusters.map((c) => {
return {ID: c, name: getKeyByValue(clusters, c)};
}).filter((c) => c.name);
this.getOutputClusters = () =>
outputClusters
.map((c) => {
return {ID: c, name: getKeyByValue(clusters, c)};
})
.filter((c) => c.name);
this.supportsInputCluster = (cluster) => {
assert(clusters[cluster] !== undefined, `Undefined '${cluster}'`);
return this.inputClusters.includes(clusters[cluster]);
}
};
this.supportsOutputCluster = (cluster) => {
assert(clusters[cluster], `Undefined '${cluster}'`);
return this.outputClusters.includes(clusters[cluster]);
}
};
this.addToGroup = jest.fn();
this.addToGroup.mockImplementation((group) => {
if (!group.members.includes(this)) group.members.push(this);
})
});
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.mockImplementation((group) => {
@ -104,8 +121,8 @@ class Endpoint {
});
this.removeFromAllGroups = () => {
Object.values(groups).forEach((g) => this.removeFromGroup(g))
}
Object.values(groups).forEach((g) => this.removeFromGroup(g));
};
this.getClusterAttributeValue = jest.fn();
this.getClusterAttributeValue.mockImplementation((cluster, value) => {
@ -116,7 +133,21 @@ class Endpoint {
}
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.ieeeAddr = ieeeAddr;
this.dateCode = dateCode;
@ -147,88 +178,624 @@ class Device {
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_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 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_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 = {
'group_1': new Group(1, []),
'group_tradfri_remote': new Group(15071, [bulb_color_2.endpoints[0], bulb_2.endpoints[0]]),
group_1: new Group(1, []),
group_tradfri_remote: new Group(15071, [bulb_color_2.endpoints[0], bulb_2.endpoints[0]]),
'group/with/slashes': new Group(99, []),
'group_with_tradfri': new Group(11, [bulb_2.endpoints[0]]),
'thermostat_group': new Group(12, [TS0601_thermostat.endpoints[0]]),
'group_with_switch': new Group(14, [ZNCZ02LM.endpoints[0], bulb_2.endpoints[0]]),
'gledopto_group': new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
'default_bind_group': new Group(901, []),
'ha_discovery_group': new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[1]]),
}
group_with_tradfri: new Group(11, [bulb_2.endpoints[0]]),
thermostat_group: new Group(12, [TS0601_thermostat.endpoints[0]]),
group_with_switch: new Group(14, [ZNCZ02LM.endpoints[0], bulb_2.endpoints[0]]),
gledopto_group: new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
default_bind_group: new Group(901, []),
ha_discovery_group: new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[1]]),
};
const devices = {
'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_color': bulb_color,
'bulb_2': bulb_2,
'bulb_color_2': bulb_color_2,
'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"),
'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"),
'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),
}
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_color: bulb_color,
bulb_2: bulb_2,
bulb_color_2: bulb_color_2,
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',
),
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',
),
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 = {
setTransmitPower: jest.fn(),
@ -252,13 +819,19 @@ const mock = {
return Object.values(devices).filter((d) => returnDevices.length === 0 || returnDevices.includes(d.ieeeAddr));
}),
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) => {
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) => {
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) => {
return Object.values(groups);
@ -271,9 +844,9 @@ const mock = {
reset: jest.fn(),
createGroup: jest.fn().mockImplementation((groupID) => {
const group = new Group(groupID, []);
groups[`group_${groupID}`] = group
groups[`group_${groupID}`] = group;
return group;
})
}),
};
const mockConstructor = jest.fn().mockImplementation(() => mock);
@ -284,5 +857,11 @@ jest.mock('zigbee-herdsman', () => ({
}));
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', () => {
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();
})
});
it('git last commit', async () => {
let mockReturnValue = [];
jest.mock('git-last-commit', () => ({
getLastCommit: (cb) => cb(mockReturnValue[0], mockReturnValue[1])
getLastCommit: (cb) => cb(mockReturnValue[0], mockReturnValue[1]),
}));
mockReturnValue = [false, {shortHash: '123'}]
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({"commitHash": "123", "version": version});
mockReturnValue = [false, {shortHash: '123'}];
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: '123', version: version});
mockReturnValue = [true, null]
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({"commitHash": expect.any(String), "version": version});
})
mockReturnValue = [true, null];
expect(await utils.getZigbee2MQTTVersion()).toStrictEqual({commitHash: expect.any(String), version: version});
});
it('Check dependency version', async () => {
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')).toStrictEqual({version: versionHerdsman});
expect(await utils.getDependencyVersion('zigbee-herdsman-converters')).toStrictEqual({version: versionHerdsmanConverters});
});
it('To local iso string', async () => {
var date = new Date('August 19, 1975 23:15:30 UTC+00:00');
@ -35,7 +35,7 @@ describe('Utils', () => {
Date.prototype.getTimezoneOffset = () => -60;
expect(utils.formatDate(date, 'ISO_8601_local').endsWith('+01:00')).toBeTruthy();
Date.prototype.getTimezoneOffset = getTimezoneOffset;
})
});
it('Removes null properties from object', () => {
const obj1 = {
ab: 0,