diff --git a/lib/extension/availabilityNew.ts b/lib/extension/availabilityNew.ts index b580553e..529bcfd6 100644 --- a/lib/extension/availabilityNew.ts +++ b/lib/extension/availabilityNew.ts @@ -1,17 +1,17 @@ import ExtensionTS from './extensionts'; import logger from '../util/logger'; -import {sleep, isAvailabilityNewEnabledForDevice, hours, minutes, seconds} from '../util/utils'; +import {sleep, isAvailabilityEnabledForDevice, hours, minutes, seconds} from '../util/utils'; import * as settings from '../util/settings'; +import debounce from 'debounce'; // TODO -// - State retrieval -// - Honour legacy availability_timeout, availability_blocklist and availability_passlist options. // - Enable for HA addon -// - Add to setting schema +// - Add to setting schema (when old availability is removed) class AvailabilityNew extends ExtensionTS { private timers: {[s: string]: NodeJS.Timeout} = {}; private availabilityCache: {[s: string]: boolean} = {}; - private pingQueue: ResolvedEntity[] = []; + private retrieveStateDebouncers: {[s: string]: () => void} = {}; + private pingQueue: ResolvedDevice[] = []; private pingQueueExecuting = false; constructor(zigbee: TempZigbee, mqtt: TempMQTT, state: TempState, @@ -21,12 +21,12 @@ class AvailabilityNew extends ExtensionTS { logger.warn('Using experimental new availability feature'); } - private getTimeout(re: ResolvedEntity): number { - if (typeof re.settings.availability === 'object' && re.settings.availability?.timeout != null) { - return minutes(re.settings.availability.timeout); + private getTimeout(rd: ResolvedDevice): number { + if (typeof rd.settings.availability === 'object' && rd.settings.availability?.timeout != null) { + return minutes(rd.settings.availability.timeout); } - const key = this.isActiveDevice(re) ? 'active' : 'passive'; + const key = this.isActiveDevice(rd) ? 'active' : 'passive'; const availabilitySettings = settings.get().availability; if (typeof availabilitySettings === 'object' && availabilitySettings[key]?.timeout != null) { return minutes(availabilitySettings[key]?.timeout); @@ -35,37 +35,37 @@ class AvailabilityNew extends ExtensionTS { return key === 'active' ? minutes(10) : hours(25); } - private isActiveDevice(re: ResolvedEntity): boolean { - return (re.device.type === 'Router' && re.device.powerSource !== 'Battery') || - re.device.powerSource === 'Mains (single phase)'; + private isActiveDevice(rd: ResolvedDevice): boolean { + return (rd.device.type === 'Router' && rd.device.powerSource !== 'Battery') || + rd.device.powerSource === 'Mains (single phase)'; } - private isAvailable(re: ResolvedEntity): boolean { - const ago = Date.now() - re.device.lastSeen; - return ago < this.getTimeout(re); + private isAvailable(rd: ResolvedDevice): boolean { + const ago = Date.now() - rd.device.lastSeen; + return ago < this.getTimeout(rd); } - private resetTimer(re: ResolvedEntity): void { - clearTimeout(this.timers[re.device.ieeeAddr]); + private resetTimer(rd: ResolvedDevice): void { + clearTimeout(this.timers[rd.device.ieeeAddr]); // If the timer triggers, the device is not avaiable anymore otherwise resetTimer already have been called - if (this.isActiveDevice(re)) { + if (this.isActiveDevice(rd)) { // If device did not check in, ping it, if that fails it will be marked as offline - this.timers[re.device.ieeeAddr] = setTimeout( - () => this.addToPingQueue(re), this.getTimeout(re) + seconds(1)); + this.timers[rd.device.ieeeAddr] = setTimeout( + () => this.addToPingQueue(rd), this.getTimeout(rd) + seconds(1)); } else { - this.timers[re.device.ieeeAddr] = setTimeout( - () => this.publishAvailability(re, true), this.getTimeout(re) + seconds(1)); + this.timers[rd.device.ieeeAddr] = setTimeout( + () => this.publishAvailability(rd, true), this.getTimeout(rd) + seconds(1)); } } - private addToPingQueue(re: ResolvedEntity): void { - this.pingQueue.push(re); + private addToPingQueue(rd: ResolvedDevice): void { + this.pingQueue.push(rd); this.pingQueueExecuteNext(); } - private removeFromPingQueue(re: ResolvedEntity): void { - const index = this.pingQueue.findIndex((r) => r.device.ieeeAddr === re.device.ieeeAddr); + private removeFromPingQueue(rd: ResolvedDevice): void { + const index = this.pingQueue.findIndex((r) => r.device.ieeeAddr === rd.device.ieeeAddr); index != -1 && this.pingQueue.splice(index, 1); } @@ -73,29 +73,29 @@ class AvailabilityNew extends ExtensionTS { if (this.pingQueue.length === 0 || this.pingQueueExecuting) return; this.pingQueueExecuting = true; - const re = this.pingQueue[0]; + const rd = this.pingQueue[0]; let pingedSuccessfully = false; - const available = this.availabilityCache[re.device.ieeeAddr] || this.isAvailable(re); + const available = this.availabilityCache[rd.device.ieeeAddr] || this.isAvailable(rd); const attempts = available ? 2 : 1; for (let i = 0; i < attempts; i++) { try { // Enable recovery if device is marked as available and first ping fails. const disableRecovery = !(i == 1 && available); - await re.device.ping(disableRecovery); + await rd.device.ping(disableRecovery); pingedSuccessfully = true; - logger.debug(`Succesfully pinged '${re.name}' (attempt ${i + 1}/${attempts})`); + logger.debug(`Succesfully pinged '${rd.name}' (attempt ${i + 1}/${attempts})`); break; } catch (error) { - logger.error(`Failed to ping '${re.name}' (attempt ${i + 1}/${attempts}, ${error.message})`); + logger.error(`Failed to ping '${rd.name}' (attempt ${i + 1}/${attempts}, ${error.message})`); // Try again in 3 seconds. const lastAttempt = i - 1 === attempts; !lastAttempt && await sleep(3); } } - this.publishAvailability(re, !pingedSuccessfully); - this.resetTimer(re); - this.removeFromPingQueue(re); + this.publishAvailability(rd, !pingedSuccessfully); + this.resetTimer(rd); + this.removeFromPingQueue(rd); // Sleep 2 seconds before executing next ping await sleep(2); @@ -105,16 +105,16 @@ class AvailabilityNew extends ExtensionTS { override onMQTTConnected(): void { for (const device of this.zigbee.getClients()) { - const re: ResolvedEntity = this.zigbee.resolveEntity(device); - if (isAvailabilityNewEnabledForDevice(re, settings.get())) { + const rd = this.zigbee.resolveEntity(device) as ResolvedDevice; + if (isAvailabilityEnabledForDevice(rd, settings.get())) { // Publish initial availablility - this.publishAvailability(re, true); + this.publishAvailability(rd, true); - this.resetTimer(re); + this.resetTimer(rd); // If an active device is initially unavailable, ping it. - if (this.isActiveDevice(re) && !this.isAvailable(re)) { - this.addToPingQueue(re); + if (this.isActiveDevice(rd) && !this.isAvailable(rd)) { + this.addToPingQueue(rd); } } } @@ -124,44 +124,50 @@ class AvailabilityNew extends ExtensionTS { this.zigbee.on('lastSeenChanged', this.lastSeenChanged); } - override onZigbeeEvent(type: ZigbeeEventType, data: ZigbeeEventData, resolvedEntity: ResolvedEntity): void { - resolvedEntity; - + override onZigbeeEvent(type: ZigbeeEventType, data: ZigbeeEventData, re: ResolvedEntity): void { /* istanbul ignore else */ if (type === 'deviceLeave') { clearTimeout(this.timers[data.ieeeAddr]); + } else if (type === 'deviceAnnounce') { + this.retrieveState(re as ResolvedDevice); } } - private publishAvailability(re: ResolvedEntity, logLastSeen: boolean): void { + private publishAvailability(rd: ResolvedDevice, logLastSeen: boolean): void { if (logLastSeen) { - const ago = Date.now() - re.device.lastSeen; - if (this.isActiveDevice(re)) { + const ago = Date.now() - rd.device.lastSeen; + if (this.isActiveDevice(rd)) { logger.debug( - `Active device '${re.name}' was last seen '${(ago / minutes(1)).toFixed(2)}' minutes ago.`); + `Active device '${rd.name}' was last seen '${(ago / minutes(1)).toFixed(2)}' minutes ago.`); } else { - logger.debug(`Passive device '${re.name}' was last seen '${(ago / hours(1)).toFixed(2)}' hours ago.`); + logger.debug(`Passive device '${rd.name}' was last seen '${(ago / hours(1)).toFixed(2)}' hours ago.`); } } - const available = this.isAvailable(re); - if (this.availabilityCache[re.device.ieeeAddr] == available) { + const available = this.isAvailable(rd); + if (this.availabilityCache[rd.device.ieeeAddr] == available) { return; } - const topic = `${re.name}/availability`; + if (rd.device.ieeeAddr in this.availabilityCache && available && + this.availabilityCache[rd.device.ieeeAddr] === false) { + logger.debug(`Device '${rd.name}' reconnected`); + this.retrieveState(rd); + } + + const topic = `${rd.name}/availability`; const payload = available ? 'online' : 'offline'; - this.availabilityCache[re.device.ieeeAddr] = available; + this.availabilityCache[rd.device.ieeeAddr] = available; this.mqtt.publish(topic, payload, {retain: true, qos: 0}); } private lastSeenChanged(data: {device: Device}): void { - const re = this.zigbee.resolveEntity(data.device); - if (isAvailabilityNewEnabledForDevice(re, settings.get())) { + const rd = this.zigbee.resolveEntity(data.device) as ResolvedDevice; + if (isAvailabilityEnabledForDevice(rd, settings.get())) { // Remove from ping queue, not necessary anymore since we know the device is online. - this.removeFromPingQueue(re); - this.resetTimer(re); - this.publishAvailability(re, false); + this.removeFromPingQueue(rd); + this.resetTimer(rd); + this.publishAvailability(rd, false); } } @@ -170,6 +176,31 @@ class AvailabilityNew extends ExtensionTS { this.zigbee.removeListener('lastSeenChanged', this.lastSeenChanged); super.stop(); } + + private retrieveState(rd: ResolvedDevice): void { + /** + * Retrieve state of a device in a debounced manner, this function is called on a 'deviceAnnounce' which a + * device can send multiple times after each other. + */ + if (rd.definition && !rd.device.interviewing && !this.retrieveStateDebouncers[rd.device.ieeeAddr]) { + this.retrieveStateDebouncers[rd.device.ieeeAddr] = debounce(async () => { + try { + logger.debug(`Retrieving state of '${rd.name}' after reconnect`); + // Color and color temperature converters do both, only needs to be called once. + const keySet = [['state'], ['brightness'], ['color', 'color_temp']]; + for (const keys of keySet) { + const converter = rd.definition.toZigbee.find((c) => c.key.find((k) => keys.includes(k))); + await converter?.convertGet?.(rd.endpoint, keys[0], + {message: this.state.get(rd.device.ieeeAddr) || {}}); + } + } catch (error) { + logger.error(`Failed to read state of '${rd.name}' after reconnect`); + } + }, seconds(2)); + } + + this.retrieveStateDebouncers[rd.device.ieeeAddr](); + } } module.exports = AvailabilityNew; diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.js index 71f5504b..188c5ee6 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -858,7 +858,7 @@ class HomeAssistant extends Extension { payload.availability_mode = 'all'; /* istanbul ignore next */ const availabilityEnabled = settings.get().experimental.availability_new ? - utils.isAvailabilityNewEnabledForDevice(resolvedEntity, settings) : + utils.isAvailabilityEnabledForDevice(resolvedEntity, settings) : settings.get().advanced.availability_timeout; if (resolvedEntity.type === 'device' && availabilityEnabled) { payload.availability.push({topic: `${settings.get().mqtt.base_topic}/${friendlyName}/availability`}); diff --git a/lib/types/types.ts b/lib/types/types.ts index 32cefb43..6e9cb726 100644 --- a/lib/types/types.ts +++ b/lib/types/types.ts @@ -1,9 +1,9 @@ -import type {Device as ZHDevice} from 'zigbee-herdsman/dist/controller/model'; +import type {Device as ZHDevice, Endpoint} from 'zigbee-herdsman/dist/controller/model'; declare global { type Device = ZHDevice; - type ZigbeeEventType = 'deviceLeave'; + type ZigbeeEventType = 'deviceLeave' | 'deviceAnnounce'; interface ZigbeeEventData { ieeeAddr: string; @@ -124,9 +124,17 @@ declare global { interface ResolvedEntity { type: 'device' | 'group', - definition?: {model: string}, + } + + interface ResolvedDevice { + type: 'device', + definition?: { + model: string + toZigbee: {key: string[], convertGet?: (entity: Endpoint, key: string, meta: {}) => Promise}[] + }, name: string, - device?: Device, + endpoint: Endpoint, + device: Device, settings: { friendlyName: string, availability?: {timeout?: number} | boolean, @@ -146,7 +154,9 @@ declare global { publish: (topic: string, payload: string, options: {}, base?: string, skipLog?: boolean, skipReceive?: boolean) => Promise; } - interface TempState {} + interface TempState { + get: (ID: string) => {} | null; + } interface TempEventBus { removeListenersExtension: (extension: string) => void; diff --git a/lib/util/settings.ts b/lib/util/settings.ts index 70718cf6..93e04dc7 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -406,7 +406,7 @@ export function get(): Settings { return _settingsWithDefaults; } -export function set(path: string[], value: string | number): void { +export function set(path: string[], value: string | number | boolean | KeyValue): void { /* eslint-disable-next-line */ let settings: any = getInternalSettings(); diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 7ee322d9..c9f72846 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -254,8 +254,22 @@ export function sanitizeImageParameter(parameter: string): string { return sanitized; } -export function isAvailabilityNewEnabledForDevice(re: ResolvedEntity, settings: Settings): boolean { - return re.settings.hasOwnProperty('availability') ? !!re.settings.availability : !!settings.availability; +export function isAvailabilityEnabledForDevice(rd: ResolvedDevice, settings: Settings): boolean { + if (rd.settings.hasOwnProperty('availability')) { + return !!rd.settings.availability; + } + + // availability_timeout = deprecated + const enabledGlobal = settings.advanced.availability_timeout || settings.availability; + if (!enabledGlobal) return false; + + const passlist = settings.advanced.availability_passlist.concat(settings.advanced.availability_whitelist); + if (passlist.length > 0) { + return passlist.includes(rd.name) || passlist.includes(rd.device.ieeeAddr); + } + + const blocklist = settings.advanced.availability_blacklist.concat(settings.advanced.availability_blocklist); + return !blocklist.includes(rd.name) && !blocklist.includes(rd.device.ieeeAddr); } export function isXiaomiDevice(device: Device): boolean { diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d9ff0d92..1ee4294e 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1916,6 +1916,12 @@ "@babel/types": "^7.3.0" } }, + "@types/debounce": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.0.tgz", + "integrity": "sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", diff --git a/package.json b/package.json index bba947a8..37d47e2a 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@babel/core": "^7.15.0", "@babel/preset-env": "^7.15.0", "@babel/preset-typescript": "^7.15.0", + "@types/debounce": "^1.2.0", "@types/humanize-duration": "^3.25.1", "@types/jest": "^27.0.1", "@types/js-yaml": "^4.0.3", diff --git a/test/availabilityNew.test.ts b/test/availabilityNew.test.ts index fa1b4988..df794749 100644 --- a/test/availabilityNew.test.ts +++ b/test/availabilityNew.test.ts @@ -1,30 +1,25 @@ -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('0x000b57fffec6a5b4'); -zigbeeHerdsman.returnDevices.push('0x00124b00120144ae'); -zigbeeHerdsman.returnDevices.push('0x0017880104e45517'); -const MQTT = require('./stub/mqtt'); -const utils = require('../lib/util/utils'); -const settings = require('../lib/util/settings'); -const Controller = require('../lib/controller'); -const flushPromises = require('./lib/flushPromises'); +import data from './stub/data'; +import logger from './stub/logger'; +import MQTT from './stub/mqtt'; +import zigbeeHerdsman from './stub/zigbeeHerdsman'; + +import * as utils from '../lib/util/utils'; +import * as settings from '../lib/util/settings'; +import Controller from '../lib/controller'; +import flushPromises from './lib/flushPromises'; + +const devices = zigbeeHerdsman.devices; const mocks = [MQTT.publish, logger.warn, logger.debug]; -const hours = (hours) => 1000 * 60 * 60 * hours; -const minutes = (minutes) => 1000 * 60 * minutes; +zigbeeHerdsman.returnDevices.concat( + [devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote]) describe('Availability', () => { let controller; - let extension; - let devices = zigbeeHerdsman.devices; let resetExtension = async () => { await controller.enableDisableExtension(false, 'AvailabilityNew'); await controller.enableDisableExtension(true, 'AvailabilityNew'); - extension = controller.extensions.find((e) => e.constructor.name === 'AvailabilityNew'); } const advancedTime = async (value) => { @@ -34,7 +29,7 @@ describe('Availability', () => { } beforeAll(async () => { - jest.spyOn(utils, 'sleep').mockImplementation(() => {}); + jest.spyOn(utils, 'sleep').mockImplementation(async (seconds: number) => {}); jest.useFakeTimers('modern'); settings.reRead(); settings.set(['availability'], true); @@ -45,12 +40,14 @@ describe('Availability', () => { }); beforeEach(async () => { - jest.useFakeTimers('modern').setSystemTime(minutes(1)); + jest.useFakeTimers('modern').setSystemTime(utils.minutes(1)); data.writeDefaultConfiguration(); - settings.set(['devices', '0x000b57fffec6a5b4', 'availability'], false); - settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], true); + settings.reRead(); + settings.set(['availability'], true); + settings.set(['experimental', 'availability_new'], true); + settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false); // @ts-ignore - Object.values(zigbeeHerdsman.devices).forEach(d => d.lastSeen = minutes(1)); + Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1)); mocks.forEach((m) => m.mockClear()); await resetExtension(); // @ts-ignore @@ -59,44 +56,46 @@ describe('Availability', () => { afterEach(async () => { // @ts-ignore - Object.values(zigbeeHerdsman.devices).forEach(d => d.lastSeen = minutes(1)); + Object.values(devices).forEach(d => d.lastSeen = utils.minutes(1)); }) afterAll(async () => { jest.useRealTimers(); }) - it('Should publish availabilty on startup', async () => { + it('Should publish availabilty on startup for device where it is enabled for', async () => { expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'online', {retain: true, qos: 0}, expect.any(Function)); + expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bulb_color_2/availability', + 'online', {retain: true, qos: 0}, expect.any(Function)); }); it('Should publish offline for active device when not seen for 10 minutes', async () => { MQTT.publish.mockClear(); - await advancedTime(minutes(5)); + await advancedTime(utils.minutes(5)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); - await advancedTime(minutes(7)); + await advancedTime(utils.minutes(7)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 0}, expect.any(Function)); }); - it('Shouldnt do anything for a device when availability: false is set', async () => { + it('Shouldnt do anything for a device when availability: false is set for device', async () => { MQTT.publish.mockClear(); + await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction - await advancedTime(minutes(12)); - await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); + await advancedTime(utils.minutes(12)); expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); }); it('Should publish offline for passive device when not seen for 25 hours', async () => { MQTT.publish.mockClear(); - await advancedTime(hours(26)); + await advancedTime(utils.hours(26)); expect(devices.remote.ping).toHaveBeenCalledTimes(0); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'offline', {retain: true, qos: 0}, expect.any(Function)); @@ -105,15 +104,15 @@ describe('Availability', () => { it('Should reset ping timer when device last seen changes for active device', async () => { MQTT.publish.mockClear(); - await advancedTime(minutes(5)); + await advancedTime(utils.minutes(5)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color}); - await advancedTime(minutes(7)); + await advancedTime(utils.minutes(7)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); - await advancedTime(minutes(10)); + await advancedTime(utils.minutes(10)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', @@ -123,17 +122,17 @@ describe('Availability', () => { it('Should ping again when first ping fails', async () => { MQTT.publish.mockClear(); - await advancedTime(minutes(5)); + await advancedTime(utils.minutes(5)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color}); - await advancedTime(minutes(7)); + await advancedTime(utils.minutes(7)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); devices.bulb_color.ping.mockImplementationOnce(() => {throw new Error('failed')}); - devices.bulb_color.lastSeen = Date.now() + minutes(10); - await advancedTime(minutes(10)); + devices.bulb_color.lastSeen = Date.now() + utils.minutes(10); + await advancedTime(utils.minutes(10)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2); expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true); expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false); @@ -144,16 +143,16 @@ describe('Availability', () => { it('Should reset ping timer when device last seen changes for passive device', async () => { MQTT.publish.mockClear(); - await advancedTime(hours(24)); + await advancedTime(utils.hours(24)); expect(devices.remote.ping).toHaveBeenCalledTimes(0); await zigbeeHerdsman.events.lastSeenChanged({device: devices.remote}); - await advancedTime(hours(25)); + await advancedTime(utils.hours(25)); expect(devices.remote.ping).toHaveBeenCalledTimes(0); devices.remote.ping.mockImplementationOnce(() => {throw new Error('failed')}); - await advancedTime(hours(3)); + await advancedTime(utils.hours(3)); expect(devices.remote.ping).toHaveBeenCalledTimes(0); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/remote/availability', 'offline', {retain: true, qos: 0}, expect.any(Function)); @@ -162,7 +161,7 @@ describe('Availability', () => { it('Should immediately mark device as online when it lastSeen changes', async () => { MQTT.publish.mockClear(); - await advancedTime(minutes(15)); + await advancedTime(utils.minutes(15)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'offline', {retain: true, qos: 0}, expect.any(Function)); @@ -178,10 +177,10 @@ describe('Availability', () => { await resetExtension(); MQTT.publish.mockClear(); - await advancedTime(minutes(25)); + await advancedTime(utils.minutes(25)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); - await advancedTime(minutes(17)); + await advancedTime(utils.minutes(17)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); }); @@ -190,10 +189,10 @@ describe('Availability', () => { await resetExtension(); MQTT.publish.mockClear(); - await advancedTime(minutes(25)); + await advancedTime(utils.minutes(25)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); - await advancedTime(minutes(7)); + await advancedTime(utils.minutes(7)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); }); @@ -201,13 +200,78 @@ describe('Availability', () => { await resetExtension(); MQTT.publish.mockClear(); - await advancedTime(minutes(9)); + await advancedTime(utils.minutes(9)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); await zigbeeHerdsman.events.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr}); await flushPromises(); - await advancedTime(minutes(3)); + await advancedTime(utils.minutes(3)); expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); }); + + it('Should allow to be disabled', async () => { + settings.set(['availability'], false); + await resetExtension(); + + await advancedTime(utils.minutes(12)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); + }); + + it('Should retrieve device state when it reconnects', async () => { + MQTT.publish.mockClear(); + + const endpoint = devices.bulb_color.getEndpoint(1); + endpoint.read.mockClear(); + + await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color}); + await flushPromises(); + await advancedTime(utils.seconds(1)); + await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color}); + await flushPromises(); + + expect(endpoint.read).toHaveBeenCalledTimes(0); + await advancedTime(utils.seconds(2)); + + expect(endpoint.read).toHaveBeenCalledTimes(3); + expect(endpoint.read).toHaveBeenCalledWith('genOnOff', ['onOff']); + expect(endpoint.read).toHaveBeenCalledWith('genLevelCtrl', ['currentLevel']); + expect(endpoint.read).toHaveBeenCalledWith('lightingColorCtrl', + ['colorMode', 'currentX', 'currentY', 'enhancedCurrentHue', 'currentSaturation', 'colorTemperature']); + + // Should stop when one request fails + endpoint.read.mockClear(); + await zigbeeHerdsman.events.deviceAnnounce({device: devices.bulb_color}); + await flushPromises(); + endpoint.read.mockImplementationOnce(() => {throw new Error('')}); + await advancedTime(utils.seconds(3)); + expect(endpoint.read).toHaveBeenCalledTimes(1); + }); + + it('Deprecated - should allow to block via advanced.availability_blocklist', async () => { + settings.set(['advanced', 'availability_blocklist'], [devices.bulb_color.ieeeAddr]); + await resetExtension(); + + await advancedTime(utils.minutes(12)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); + }); + + it('Deprecated - should allow to pass certain devices via availability_passlist', async () => { + settings.set(['advanced', 'availability_passlist'], [devices.bulb_color_2.ieeeAddr]); + settings.changeEntityOptions(devices.bulb_color_2.ieeeAddr, {availability: null}); + await resetExtension(); + + await advancedTime(utils.minutes(12)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); + expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(1); + }); + + it('Deprecated - should allow to enable via availability_timeout', async () => { + settings.set(['availability'], false); + settings.set(['advanced', 'availability_timeout'], 60); + await resetExtension(); + + await advancedTime(utils.minutes(12)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); + }); });