Replace old availability implementation with new one (#8904)

* Update

* Updates

* More refactoringzzz

* Bindoo

* Loadz of typescripting

* Logga

* Updates

* Updates

* Updates

* Updates

* cleanup

* updates

* Fix coverage

* Fixes

* Updates

* Updates

* Replace old availability implementation with new one.
This commit is contained in:
Koen Kanters
2021-10-02 10:25:43 +02:00
committed by GitHub
parent 045ee573a0
commit 0282011255
13 changed files with 53 additions and 709 deletions
+2 -7
View File
@@ -21,7 +21,6 @@ import ExtensionDeviceGroupMembership from './extension/legacy/deviceGroupMember
import ExtensionBridgeLegacy from './extension/legacy/bridgeLegacy';
import ExtensionBridge from './extension/bridge';
import ExtensionGroups from './extension/groups';
import ExtensionAvailabilityLegacy from './extension/legacy/availability';
import ExtensionAvailability from './extension/availability';
import ExtensionBind from './extension/bind';
import ExtensionReport from './extension/legacy/report';
@@ -33,7 +32,7 @@ import ExtensionExternalExtension from './extension/externalExtension';
const AllExtensions = [
ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant,
ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups,
ExtensionAvailabilityLegacy, ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate,
ExtensionExternalConverters, ExtensionFrontend, ExtensionExternalExtension, ExtensionAvailability,
];
@@ -62,8 +61,6 @@ class Controller {
this.extensionArgs = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus,
this.enableDisableExtension, this.restartCallback, this.addExtension];
const availabilityLegacy = !settings.get().advanced.availability_timeout &&
settings.get().advanced.availability_timeout;
this.extensions = [
new ExtensionBridge(...this.extensionArgs),
new ExtensionPublish(...this.extensionArgs),
@@ -77,15 +74,13 @@ class Controller {
new ExtensionOTAUpdate(...this.extensionArgs),
new ExtensionReport(...this.extensionArgs),
new ExtensionExternalExtension(...this.extensionArgs),
new ExtensionAvailability(...this.extensionArgs),
settings.get().frontend && new ExtensionFrontend(...this.extensionArgs),
settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs),
settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs),
settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs),
/* istanbul ignore next */
settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs),
settings.get().experimental.availability_new && new ExtensionAvailability(...this.extensionArgs),
/* istanbul ignore next */
availabilityLegacy && new ExtensionAvailabilityLegacy(...this.extensionArgs),
].filter((n) => n);
}
-3
View File
@@ -5,9 +5,6 @@ import * as settings from '../util/settings';
import debounce from 'debounce';
import bind from 'bind-decorator';
// TODO
// - Enable for HA addon
// - Add to setting schema (when old availability is removed)
export default class Availability extends Extension {
private timers: {[s: string]: NodeJS.Timeout} = {};
private availabilityCache: {[s: string]: boolean} = {};
-4
View File
@@ -901,10 +901,6 @@ export default class HomeAssistant extends Extension {
/* istanbul ignore next */
if (availabilityEnabled) {
payload.availability_mode = 'all';
}
/* istanbul ignore next */
if (availabilityEnabled || settings.get().advanced.availability_timeout) {
payload.availability.push({topic: `${settings.get().mqtt.base_topic}/${entity.name}/availability`});
}
-230
View File
@@ -1,230 +0,0 @@
import logger from '../../util/logger';
import * as settings from '../../util/settings';
import utils from '../../util/utils';
// @ts-ignore
import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters';
import Extension from '../extension';
const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(.*)/availability`);
import bind from 'bind-decorator';
// Pingable end devices, some end devices should be pinged
// e.g. E11-G13 https://github.com/Koenkk/zigbee2mqtt/issues/775#issuecomment-453683846
const pingableEndDevices = [
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'E11-G13'),
zigbeeHerdsmanConverters.definitions.find((d) => d.model === 'E11-N1EA'),
zigbeeHerdsmanConverters.definitions.find((d) => d.model === '53170161'),
];
const Hours25 = 1000 * 60 * 60 * 25;
const AvailabilityLagRatio = 0.1;
function timeoutLag(timeout: number, ratio: number): number {
const lag = timeout * ratio;
return Math.floor(Math.random() * Math.floor(lag));
}
/**
* This extensions pings devices to check if they are online.
*/
export default class AvailabilityLegacy extends Extension {
// eslint-disable-next-line
private availability_timeout = settings.get().advanced.availability_timeout;
private timers: KeyValue = {};
private stateLookup: KeyValue = {};
private blocklist = settings.get().advanced.availability_blocklist
.concat(settings.get().advanced.availability_blacklist)
.map((e) => settings.getDevice(e).ID);
private passlist = settings.get().advanced.availability_passlist
.concat(settings.get().advanced.availability_whitelist)
.map((e) => settings.getDevice(e).ID);
override async start(): Promise<void> {
this.eventBus.onDeviceRemoved(this, this.onDeviceRemoved);
this.eventBus.onDeviceRenamed(this, this.onDeviceRenamed);
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data.device));
this.eventBus.onDeviceMessage(this, (data) => this.onZigbeeEvent_('dummy', data.device));
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('dummy', data.device));
/* istanbul ignore next */
this.eventBus.onDeviceNetworkAddressChanged(this, (data) => this.onZigbeeEvent_('dummy', data.device));
for (const device of this.zigbee.devices(false)) {
// Mark all devices as online on start
const ieeeAddr = device.ieeeAddr;
this.publishAvailability(device, this.stateLookup.hasOwnProperty(ieeeAddr) ?
this.stateLookup[ieeeAddr] : true, true);
if (this.inPasslistOrNotInBlocklist(device)) {
if (this.isPingable(device)) {
this.setTimerPingable(device);
} else {
this.timers[ieeeAddr] = setInterval(() => {
this.handleIntervalNotPingable(device);
}, utils.seconds(300));
}
}
}
}
@bind onDeviceRenamed(data: eventdata.DeviceRenamed): void {
this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 0});
}
/* istanbul ignore next */
@bind onDeviceRemoved(data: eventdata.DeviceRemoved): void {
this.mqtt.publish(`${data.name}/availability`, null, {retain: true, qos: 0});
delete this.stateLookup[data.ieeeAddr];
clearTimeout(this.timers[data.ieeeAddr]);
}
inPasslistOrNotInBlocklist(device: Device): boolean {
const ieeeAddr = device.ieeeAddr;
const deviceSettings = settings.getDevice(ieeeAddr);
const name = deviceSettings && deviceSettings.friendly_name;
// Passlist is not empty and device is in it, enable availability
if (this.passlist.length > 0) {
return this.passlist.includes(ieeeAddr) || (name && this.passlist.includes(name));
}
// Device is on blocklist, disable availability
if (this.blocklist.includes(ieeeAddr) || (name && this.blocklist.includes(name))) {
return false;
}
return true;
}
isPingable(device: Device): boolean {
if (pingableEndDevices.find((d) => d.hasOwnProperty('zigbeeModel') &&
d.zigbeeModel.includes(device.zh.modelID))) {
return true;
}
// Device is a mains powered router
return device.zh.type === 'Router' && device.zh.powerSource !== 'Battery';
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
// Clear topics for non-existing devices
const match = data.topic.match(topicRegex);
if (match && (!this.zigbee.resolveEntity(match[1]) ||
this.zigbee.resolveEntity(match[1]).name !== match[1])) {
this.mqtt.publish(`${match[1]}/availability`, null, {retain: true, qos: 0});
}
}
async handleIntervalPingable(device: Device): Promise<void> {
// When a device is already unavailable, log the ping failed on 'debug' instead of 'error'.
/* istanbul ignore next */
if (!device.zh) {
logger.debug(`Stop pinging '${device.ieeeAddr}', device is not known anymore`);
return;
}
const level = this.stateLookup.hasOwnProperty(device.ieeeAddr) &&
!this.stateLookup[device.ieeeAddr] ? 'debug' : 'error';
try {
await device.zh.ping();
this.publishAvailability(device, true);
logger.debug(`Successfully pinged '${device.name}'`);
} catch (error) {
this.publishAvailability(device, false);
logger[level](`Failed to ping '${device.name}'`);
} finally {
this.setTimerPingable(device);
}
}
async handleIntervalNotPingable(device: Device): Promise<void> {
/* istanbul ignore next */
if (!device.zh.lastSeen) {
return;
}
const ago = Date.now() - device.zh.lastSeen;
logger.debug(`Non-pingable device '${device.name}' was last seen '${ago / 1000}' seconds ago.`);
if (ago > Hours25) {
this.publishAvailability(device, false);
}
}
setTimerPingable(device: Device): void {
const timeout = this.availability_timeout + timeoutLag(this.availability_timeout, AvailabilityLagRatio);
clearTimeout(this.timers[device.ieeeAddr]);
this.timers[device.ieeeAddr] = setTimeout(async () => {
await this.handleIntervalPingable(device);
}, utils.seconds(timeout));
}
override async stop(): Promise<void> {
super.stop();
for (const timer of Object.values(this.timers)) {
clearTimeout(timer);
}
this.zigbee.devices(false).forEach((device) => this.publishAvailability(device, false));
}
async onReconnect(device: Device): Promise<void> {
if (device.definition) {
try {
for (const key of ['state', 'brightness', 'color', 'color_temp']) {
const converter = device.definition.toZigbee.find((tz) => tz.key.includes(key));
if (converter) {
await converter.convertGet(device.zh.endpoints[0], key, {});
}
}
} catch (error) {
logger.error(`Failed to read state of '${device.name}' after reconnect`);
}
}
}
private publishAvailability(device: Device, available: boolean, force=false): void {
const ieeeAddr = device.ieeeAddr;
if (this.stateLookup.hasOwnProperty(ieeeAddr) && !this.stateLookup[ieeeAddr] && available) {
this.onReconnect(device);
}
const topic = `${device.name}/availability`;
const payload = available ? 'online' : 'offline';
if (this.stateLookup[ieeeAddr] !== available || force) {
this.stateLookup[ieeeAddr] = available;
this.mqtt.publish(topic, payload, {retain: true, qos: 0});
}
}
onZigbeeEvent_(type: string, device: Device): Promise<void> {
/* istanbul ignore next */
if (!device) {
return;
}
if (this.inPasslistOrNotInBlocklist(device)) {
this.publishAvailability(device, true);
if (this.isPingable(device)) {
// When a zigbee message from a device is received we know the device is still alive.
// => reset the timer.
this.setTimerPingable(device);
const online = this.stateLookup.hasOwnProperty(device.ieeeAddr) && this.stateLookup[device.ieeeAddr];
if (online && type === 'deviceAnnounce' && !device.isIkeaTradfri()) {
/**
* In case the device is powered off AND on within the availability timeout,
* zigbee2qmtt does not detect the device as offline (device is still marked online).
* When a device is turned on again the state could be out of sync.
* https://github.com/Koenkk/zigbee2mqtt/issues/1383#issuecomment-489412168
* deviceAnnounce is typically send when a device comes online.
*
* This isn't needed for TRADFRI devices as they already send the state themself.
*/
this.onReconnect(device);
}
}
}
}
}
+6 -6
View File
@@ -220,7 +220,6 @@ declare global {
},
experimental: {
output: 'json' | 'attribute' | 'attribute_and_json',
availability_new?: boolean,
transmit_power?: number,
},
advanced: {
@@ -238,11 +237,6 @@ declare global {
channel: number,
adapter_concurrent: number | null,
adapter_delay: number | null,
availability_timeout: number,
availability_blocklist: string[],
availability_passlist: string[],
availability_blacklist: string[],
availability_whitelist: string[],
cache_state: boolean,
cache_state_persistent: boolean,
cache_state_send_on_startup: boolean,
@@ -258,6 +252,12 @@ declare global {
baudrate?: number,
rtscts?: boolean,
ikea_ota_use_test_url?: boolean,
// below are deprecated
availability_timeout?: number,
availability_blocklist?: string[],
availability_passlist?: string[],
availability_blacklist?: string[],
availability_whitelist?: string[],
},
ota: {
update_check_interval: number,
+40 -44
View File
@@ -26,6 +26,46 @@
},
"examples": ["DIYRuZ_FreePad.js"]
},
"availability": {
"type": ["boolean", "object"],
"title": "Availability feature",
"requiresRestart": true,
"description": "Checks wether devices are online/offline",
"default": false,
"examples": [true],
"properties": {
"active": {
"type": "object",
"title": "Active",
"requiresRestart": true,
"description": "Options for active devices (routers/mains powered)",
"properties": {
"timeout": {
"type": "number",
"title": "Timeout",
"requiresRestart": true,
"default": 10,
"description": "Time after which an active device will be marked as offline in minutes"
}
}
},
"passive": {
"type": "object",
"title": "Active",
"requiresRestart": true,
"description": "Options for passive devices (routers/mains powered)",
"properties": {
"timeout": {
"type": "number",
"title": "Timeout",
"requiresRestart": true,
"default": 1500,
"description": "Time after which an passive device will be marked as offline in minutes"
}
}
}
}
},
"mqtt": {
"type": "object",
"title": "MQTT",
@@ -365,50 +405,6 @@
"description": "Add an elapsed attribute to MQTT messages, contains milliseconds since the previous msg",
"default": false
},
"availability_timeout": {
"type": "number",
"minimum": 0,
"default": 0,
"requiresRestart": true,
"title": "Availability Timeout",
"description": "Availability timeout in seconds when enabled, devices will be checked if they are still online. Only AC powered routers are checked for availability"
},
"availability_blocklist": {
"type": "array",
"items": {
"type": "string"
},
"requiresRestart": true,
"title": "Availability Blocklist",
"description": "Prevent devices from being checked for availability"
},
"availability_passlist": {
"type": "array",
"items": {
"type": "string"
},
"requiresRestart": true,
"title": "Availability passlist",
"description": "Only enable availability check for certain devices"
},
"availability_blacklist": {
"type": "array",
"readOnly": true,
"requiresRestart": true,
"title": "Availability blacklist (deprecated, use availability_blocklist)",
"items": {
"type": "string"
}
},
"availability_whitelist": {
"type": "array",
"readOnly": true,
"requiresRestart": true,
"items": {
"type": "string"
},
"title": "Availability whitelist (deprecated, use passlist)"
},
"report": {
"type": "boolean",
"title": "Reporting",
+2 -3
View File
@@ -11,8 +11,8 @@ export const schema = schemaJson;
// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697
const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml');
const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schema);
const ajvRestartRequired = new Ajv({allErrors: true})
const ajvSetting = new Ajv({allErrors: true, allowUnionTypes: true}).addKeyword('requiresRestart').compile(schema);
const ajvRestartRequired = new Ajv({allErrors: true, allowUnionTypes: true})
.addKeyword({keyword: 'requiresRestart', validate: (schema: unknown) => !schema}).compile(schema);
const defaults: RecursivePartial<Settings> = {
@@ -75,7 +75,6 @@ const defaults: RecursivePartial<Settings> = {
adapter_delay: null,
// Availability timeout in seconds, disabled by default.
availability_timeout: 0,
availability_blocklist: [],
availability_passlist: [],
// Deprecated, use block/passlist
-3
View File
@@ -245,9 +245,6 @@ function sanitizeImageParameter(parameter: string): string {
}
function isAvailabilityEnabledForDevice(device: Device, settings: Settings): boolean {
/* istanbul ignore next */
if (!settings.experimental.availability_new) return false;
if (device.settings.hasOwnProperty('availability')) {
return !!device.settings.availability;
}
-2
View File
@@ -33,7 +33,6 @@ describe('Availability', () => {
jest.useFakeTimers('modern');
settings.reRead();
settings.set(['availability'], true);
settings.set(['experimental', 'availability_new'], true);
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
@@ -44,7 +43,6 @@ describe('Availability', () => {
data.writeDefaultConfiguration();
settings.reRead();
settings.set(['availability'], true);
settings.set(['experimental', 'availability_new'], true);
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1));
mocks.forEach((m) => m.mockClear());
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -994,7 +994,7 @@ describe('HomeAssistant extension', () => {
});
it('Should discover devices with availability', async () => {
settings.set(['advanced', 'availability_timeout'], 1)
settings.set(['availability'], true)
await resetExtension();
let payload;
@@ -1016,6 +1016,7 @@ describe('HomeAssistant extension', () => {
'model': 'Aqara temperature, humidity and pressure sensor (WSDCGQ11LM)',
'manufacturer': 'Xiaomi',
},
'availability_mode': 'all',
'availability': [{topic: 'zigbee2mqtt/bridge/state'}, {topic: 'zigbee2mqtt/weather_sensor/availability'}],
};
-403
View File
@@ -1,403 +0,0 @@
const data = require('../stub/data');
const logger = require('../stub/logger');
const stringify = require('json-stable-stringify-without-jsonify');
const zigbeeHerdsman = require('../stub/zigbeeHerdsman');
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b3');
zigbeeHerdsman.returnDevices.push('0x00124b00120144ae');
zigbeeHerdsman.returnDevices.push('0x0017880104e45553');
zigbeeHerdsman.returnDevices.push('0x0017880104e45517');
zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b2');
zigbeeHerdsman.returnDevices.push('0x0017880104e45535');
zigbeeHerdsman.returnDevices.push('0x0017880104e45521');
zigbeeHerdsman.returnDevices.push('0x0017880104e45525');
const MQTT = require('../stub/mqtt');
const settings = require('../../lib/util/settings');
const Controller = require('../../lib/controller');
const flushPromises = require('../lib/flushPromises');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const mocks = [MQTT.publish, logger.warn, logger.debug];
describe('Availability', () => {
let controller;
let extension;
let resetExtension = async () => {
await controller.enableDisableExtension(false, 'AvailabilityLegacy');
await controller.enableDisableExtension(true, 'AvailabilityLegacy');
extension = controller.extensions.find((e) => e.constructor.name === 'AvailabilityLegacy');
}
beforeAll(async () => {
jest.useFakeTimers();
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
});
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
settings.set(['advanced', 'availability_timeout'], 10);
mocks.forEach((m) => m.mockClear());
await resetExtension();
});
afterAll(async () => {
jest.useRealTimers();
})
it('Should publish availabilty on startup', async () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color/availability',
'online',
{ retain: true, qos: 0 },
expect.any(Function)
);
});
it('Should publish availabilty offline when ping fails', async () => {
MQTT.publish.mockClear();
logger.error.mockClear();
logger.debug.mockClear();
const device = zigbeeHerdsman.devices.bulb_color;
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
'zigbee2mqtt/bulb_color/availability',
'offline',
{ retain: true, qos: 0 },
expect.any(Function)
);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith("Failed to ping 'bulb_color'");
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith("Failed to ping 'bulb_color'");
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
'zigbee2mqtt/bulb_color/availability',
'offline',
{ retain: true, qos: 0 },
expect.any(Function)
);
});
it('Should publish availabilty online and query state on reconnect', async () => {
const device = zigbeeHerdsman.devices.E11_G13;
const endpoint = device.getEndpoint(1);
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
MQTT.publish.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_enddevice/availability',
'online',
{ retain: true, qos: 0 },
expect.any(Function)
);
expect(endpoint.read).toHaveBeenCalledTimes(2);
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
});
it('Should fail gracefully when quering state after reconnect fails', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1);
endpoint.read.mockClear();
endpoint.read.mockImplementationOnce(() => {throw new Error('Device timedout')});
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
logger.debug.mockClear();
jest.advanceTimersByTime(11 * 2000);
await flushPromises();
expect(endpoint.read).toHaveBeenCalledTimes(1);
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
expect(logger.error).toHaveBeenCalledWith(`Failed to read state of 'bulb_color' after reconnect`);
});
it('Shouldnt ping again when still pinging', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
device.ping.mockClear();
device.ping.mockImplementationOnce(async () => {await wait(100000000)});
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(1);
});
it('Should mark device online when receiving message while offline', async () => {
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1);
device.ping.mockImplementationOnce(() => {throw new Error('failed')});
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
MQTT.publish.mockClear();
const data = {modelID: 'test'}
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
'zigbee2mqtt/bulb_color/availability',
'online',
{ retain: true, qos: 0 },
expect.any(Function)
);
});
it('Should retrieve the state when device is turned on/off within availability timeout', async () => {
MQTT.publish.mockClear();
extension.stateLookup = {};
const payload = {device: zigbeeHerdsman.devices.bulb_color};
await zigbeeHerdsman.events.deviceJoined(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color/availability',
'online',
{ retain: true, qos: 0 },
expect.any(Function)
);
});
it('Should retrieve the state when device is turned on/off within availability timeout on deviceAnnounce', async () => {
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.bulb_color;
const endpoint = device.getEndpoint(1);
endpoint.read.mockClear();
await zigbeeHerdsman.events.deviceAnnounce({device});
await flushPromises();
expect(endpoint.read).toHaveBeenCalledTimes(2);
expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']);
expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']);
});
it('Should not retrieve the state when device is turned on/off within availability timeout on deviceJoined', async () => {
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.E11_G13;
const endpoint = device.getEndpoint(1);
endpoint.read.mockClear();
await zigbeeHerdsman.events.deviceJoined({device});
await flushPromises();
expect(endpoint.read).toHaveBeenCalledTimes(0);
});
it('Should not do anything when message has no device', async () => {
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.bulb_color;
const payload = {ieeeAddr: device.ieeeAddr};
await zigbeeHerdsman.events.deviceLeave(payload);
await flushPromises();
expect(MQTT.publish.mock.calls.find((c) => c[0].includes('availability'))).toBeUndefined();
});
it('Should not ping devices on blocklist by friendly name', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
settings.set(['advanced', 'availability_blocklist'], ['bulb_color'])
await resetExtension();
device.ping.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(0);
});
it('Should not ping devices on blacklist by friendly name', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
settings.set(['advanced', 'availability_blacklist'], ['bulb_color'])
await resetExtension();
device.ping.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(0);
});
it('Should not ping devices on blocklist by IEEE address', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
settings.set(['advanced', 'availability_blocklist'], [device.ieeeAddr]);
await resetExtension();
device.ping.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(0);
});
it('Should ping forced pingable devices', async () => {
const device = zigbeeHerdsman.devices.E11_G13;
const endpoint = device.getEndpoint(1);
const data = {modelID: 'test'}
const payload = {data, cluster: 'genOnOff', device, endpoint, type: 'readResponse', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
device.ping.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(1);
});
it('Should ping devices on passlist by friendly name if availability_passlist is set', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
settings.set(['advanced', 'availability_passlist'], ['bulb_color']);
await resetExtension();
device.ping.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(1);
});
it('Should ping devices on whitelist by friendly name if availability_whitelist is set', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
settings.set(['advanced', 'availability_whitelist'], ['bulb_color']);
await resetExtension();
device.ping.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(1);
});
it('Should ping devices on passlist by IEEE address if availability_passlist is set', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
settings.set(['advanced', 'availability_passlist'], [device.ieeeAddr]);
await resetExtension();
device.ping.mockClear();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(1);
});
it('Should not ping devices not in passlist if availability_passlist is set', async () => {
const device = zigbeeHerdsman.devices.bulb;
device.ping.mockClear();
extension.stateLookup[device.ieeeAddr] = false;
settings.set(['advanced', 'availability_passlist'], ['0x000b57fffec6a5b3'])
await resetExtension();
jest.advanceTimersByTime(11 * 1000);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(0);
MQTT.publish.mockClear();
await zigbeeHerdsman.events.deviceAnnounce({device});
});
it('Should not read when device has no modelID and reconnects', async () => {
const device = zigbeeHerdsman.devices.nomodel;
extension.stateLookup[device.ieeeAddr] = true;
const endpoint = device.getEndpoint(1);
await zigbeeHerdsman.events.deviceAnnounce({device});
await flushPromises();
expect(endpoint.read).toHaveBeenCalledTimes(0);
});
it('Should not read when device has is unsupported', async () => {
const device = zigbeeHerdsman.devices.unsupported_router;
extension.stateLookup[device.ieeeAddr] = true;
const endpoint = device.getEndpoint(1);
await zigbeeHerdsman.events.deviceAnnounce({device});
await flushPromises();
expect(endpoint.read).toHaveBeenCalledTimes(0);
});
it('Should publish availability when end device joins', async () => {
delete extension.stateLookup[zigbeeHerdsman.devices.WXKG02LM_rev1.ieeeAddr];
const device = zigbeeHerdsman.devices.WXKG02LM_rev1;
const payload = {device};
MQTT.publish.mockClear();
await zigbeeHerdsman.events.deviceJoined(payload);
await flushPromises();
expect(device.ping).toHaveBeenCalledTimes(0);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/button_double_key/availability',
'online',
{ retain: true, qos: 0 },
expect.any(Function)
);
});
it('Should mark non-pingable device as non-available when offline for longer than 24 hours', async () => {
const device = zigbeeHerdsman.devices.remote;
const defaultLastSeen = device.lastSeen;
device.lastSeen = Date.now();
MQTT.publish.mockClear();
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
device.lastSeen = device.lastSeen - (1000 * 60 * 60 * 25);
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/remote/availability',
'offline',
{ retain: true, qos: 0 },
expect.any(Function)
);
// Shouldn't do anything more when device is removed
settings.removeDevice(device.ieeeAddr);
jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours
expect(MQTT.publish).toHaveBeenCalledTimes(2);
device.lastSeen = defaultLastSeen;
});
it('Should republish existing state on MQTT connected', async () => {
const device = zigbeeHerdsman.devices.bulb_color;
MQTT.publish.mockClear();
extension.stateLookup[device.ieeeAddr] = false;
await extension.start();
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color/availability',
'offline',
{ retain: true, qos: 0 },
expect.any(Function)
);
});
it('Should clear retained availability topic when device is remove', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', 'bulb_color');
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color/availability',
null,
{retain: true, qos: 0}, expect.any(Function)
);
});
it('Should clear retained availability topic when device is renamed', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({"from": "bulb_color", "to": "bulb_color_new_name"}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bulb_color/availability',
null,
{retain: true, qos: 0}, expect.any(Function)
);
});
it('Should clear retained availability topic when device does not exist', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/not_existing_hahaha/availability', 'offline');
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/not_existing_hahaha/availability', null, {retain: true, qos: 0}, expect.any(Function));
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/0x000b57fffec6a5b3/availability', 'offline');
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/0x000b57fffec6a5b3/availability', null, {retain: true, qos: 0}, expect.any(Function));
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bulb_color/availability', 'offline');
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(0);
});
});
-2
View File
@@ -67,7 +67,6 @@ describe('Settings', () => {
process.env['ZIGBEE2MQTT_CONFIG_SERIAL_DISABLE_LED'] = 'true';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_SOFT_RESET_TIMEOUT'] = 1;
process.env['ZIGBEE2MQTT_CONFIG_EXPERIMENTAL_OUTPUT'] = 'csvtest';
process.env['ZIGBEE2MQTT_CONFIG_ADVANCED_AVAILABILITY_BLOCKLIST'] = '["0x43597f0dac781b1e", "x223b0aef2ae8d1b0"]';
process.env['ZIGBEE2MQTT_CONFIG_MAP_OPTIONS_GRAPHVIZ_COLORS_FILL'] = '{"enddevice": "#ff0000", "coordinator": "#00ff00", "router": "#0000ff"}';
process.env['ZIGBEE2MQTT_CONFIG_MQTT_BASE_TOPIC'] = 'testtopic';
@@ -79,7 +78,6 @@ describe('Settings', () => {
expected.serial.disable_led = true;
expected.advanced.soft_reset_timeout = 1;
expected.experimental.output = 'csvtest';
expected.advanced.availability_blocklist = ['0x43597f0dac781b1e', 'x223b0aef2ae8d1b0'];
expected.map_options.graphviz.colors.fill = {enddevice: '#ff0000', coordinator: '#00ff00', router: '#0000ff'};
expected.mqtt.base_topic = 'testtopic';