From bcf79a7e346c19a4ec8733bf03d003672e1fbc02 Mon Sep 17 00:00:00 2001 From: Jorge Schrauwen Date: Mon, 9 Dec 2019 18:12:13 +0100 Subject: [PATCH] Device Availability: Allow battery devices to report offline/online based on device.lastSeen (#2464) * Remove execution bits for deviceReceive.js * deviceAvailability.isPingable should return true if 'availability_lastseen_timeout' is set for device * deviceAvailability.handleInterval should know how to handle lastseen_timeout setting * Allow availability_lastseen_timeout for all devices * Use a default lastseen timeout of 24h * Updates * Updates * Updates * 25 hours instead of 24. --- lib/extension/deviceAvailability.js | 79 ++++++++++++++++++----------- test/deviceAvailability.test.js | 42 +++++++++++---- 2 files changed, 79 insertions(+), 42 deletions(-) diff --git a/lib/extension/deviceAvailability.js b/lib/extension/deviceAvailability.js index 649cea16..b502c693 100644 --- a/lib/extension/deviceAvailability.js +++ b/lib/extension/deviceAvailability.js @@ -12,6 +12,8 @@ const forcedPingable = [ const toZigbeeCandidates = ['state', 'brightness', 'color', 'color_temp']; +const Hours25 = 1000 * 60 * 60 * 25; + /** * This extensions pings devices to check if they are online. */ @@ -34,7 +36,7 @@ class DeviceAvailability extends BaseExtension { }); } - isPingable(device) { + isAllowed(device) { // Whitelist is not empty and device is in it, enable availability if (this.whitelist.length > 0) { return this.whitelist.includes(device.ieeeAddr); @@ -45,29 +47,39 @@ class DeviceAvailability extends BaseExtension { return false; } + return true; + } + + isPingable(device) { // Device is on forcedPingable-list, enable availability if (forcedPingable.find((d) => d.zigbeeModel.includes(device.modelID))) { return true; } + // Device is a mains powered router const result = utils.isRouter(device) && !utils.isBatteryPowered(device); + return result; } - getAllPingableDevices() { - return this.zigbee.getClients().filter((d) => this.isPingable(d)); - } - onMQTTConnected() { - // As some devices are not checked for availability (e.g. battery powered devices) - // we mark all device as online by default. - this.zigbee.getClients().forEach((device) => this.publishAvailability(device, true)); + for (const device of this.zigbee.getClients()) { + // Mark all devices as online on start + this.publishAvailability(device, true); - // Start timers for all devices - this.getAllPingableDevices().forEach((device) => this.setTimer(device)); + if (this.isAllowed(device)) { + if (this.isPingable(device)) { + this.setTimerPingable(device); + } else { + this.timers[device.ieeeAddr] = setInterval(() => { + this.handleIntervalNotPingable(device); + }, utils.secondsToMilliseconds(300)); + } + } + } } - async handleInterval(device) { + async handleIntervalPingable(device) { // When a device is already unavailable, log the ping failed on 'debug' instead of 'error'. const entity = this.zigbee.resolveEntity(device.ieeeAddr); if (!entity) { @@ -85,17 +97,31 @@ class DeviceAvailability extends BaseExtension { this.publishAvailability(device, false); logger[level](`Failed to ping '${entity.name}'`); } finally { - this.setTimer(device); + this.setTimerPingable(device); } } - setTimer(device) { + async handleIntervalNotPingable(device) { + const ago = Date.now() - device.lastSeen; + const entity = this.zigbee.resolveEntity(device.ieeeAddr); + if (!entity || !device.lastSeen) { + return; + } + + logger.debug(`Non-pingable device '${entity.name}' was last seen '${ago / 1000}' seconds ago.`); + + if (ago > Hours25) { + this.publishAvailability(device, false); + } + } + + setTimerPingable(device) { if (this.timers[device.ieeeAddr]) { clearTimeout(this.timers[device.ieeeAddr]); } this.timers[device.ieeeAddr] = setTimeout(async () => { - await this.handleInterval(device); + await this.handleIntervalPingable(device); }, utils.secondsToMilliseconds(this.availability_timeout)); } @@ -151,37 +177,28 @@ class DeviceAvailability extends BaseExtension { return; } - if (this.isPingable(device)) { - // When a zigbee message from a device is received we know the device is still alive. - // => reset the timer. - this.setTimer(device); + if (this.isAllowed(device)) { + this.publishAvailability(data.device, true); - const online = this.state.hasOwnProperty(device.ieeeAddr) && this.state[device.ieeeAddr]; - const offline = this.state.hasOwnProperty(device.ieeeAddr) && !this.state[device.ieeeAddr]; + 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); - if (!online && !offline) { - // A new device has been connected - this.publishAvailability(device, true); - } else if (offline) { - // When a message is received and the device is marked as offline, mark it online. - this.publishAvailability(device, true); - } else { - /* istanbul ignore else */ + const online = this.state.hasOwnProperty(device.ieeeAddr) && this.state[device.ieeeAddr]; if (online && type === 'deviceAnnounce' && !utils.isIkeaTradfriDevice(device)) { /** * 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 - * endDeviceAnnce is typically send when a device comes online. + * 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); } } - } else if (type === 'deviceJoined') { - this.publishAvailability(data.device, true); } } } diff --git a/test/deviceAvailability.test.js b/test/deviceAvailability.test.js index c2c17a44..09da06dd 100644 --- a/test/deviceAvailability.test.js +++ b/test/deviceAvailability.test.js @@ -4,6 +4,8 @@ const zigbeeHerdsman = require('./stub/zigbeeHerdsman'); zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b3'); zigbeeHerdsman.returnDevices.push('0x00124b00120144ae'); zigbeeHerdsman.returnDevices.push('0x0017880104e45553'); +zigbeeHerdsman.returnDevices.push('0x0017880104e45517'); + const MQTT = require('./stub/mqtt'); const settings = require('../lib/util/settings'); const Controller = require('../lib/controller'); @@ -186,15 +188,6 @@ describe('Device availability', () => { expect(endpoint.read).toHaveBeenCalledTimes(0); }); - it('Should do nothing when receiving message from non-pingable device', async () => { - MQTT.publish.mockClear(); - const device = zigbeeHerdsman.devices.WXKG11LM; - 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(MQTT.publish).toHaveBeenCalledTimes(0); - }); - it('Should not do anything when message has no device', async () => { MQTT.publish.mockClear(); const device = zigbeeHerdsman.devices.bulb_color; @@ -248,16 +241,18 @@ describe('Device availability', () => { it('Should not ping non-whitelisted devices if availability_whitelist is set', async () => { const device = zigbeeHerdsman.devices.bulb; getExtension().state[device.ieeeAddr] = false; - settings.set(['advanced', 'availability_whitelist'], [device.ieeeAddr]) + settings.set(['advanced', 'availability_whitelist'], ['0x000b57fffec6a5b3']) await controller.stop(); await flushPromises(); controller = new Controller(); await controller.start(); await flushPromises(); - device.ping.mockClear(); jest.advanceTimersByTime(11 * 1000); await flushPromises(); expect(device.ping).toHaveBeenCalledTimes(0); + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.deviceAnnounce({device}); + expect(MQTT.publish).toHaveBeenCalledTimes(0); }); it('Should not read when device has no modelID and reconnects', async () => { @@ -305,4 +300,29 @@ describe('Device availability', () => { 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 + expect(MQTT.publish).toHaveBeenCalledTimes(0); + device.lastSeen = device.lastSeen - (1000 * 60 * 60 * 25); + jest.advanceTimersByTime(1000 * 60 * 60 * 1); // 1 hours + expect(MQTT.publish).toHaveBeenCalledTimes(1); + 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(1); + + device.lastSeen = defaultLastSeen; + }); });