diff --git a/lib/extension/availabilityNew.ts b/lib/extension/availabilityNew.ts index 71e01141b..631f5b5bd 100644 --- a/lib/extension/availabilityNew.ts +++ b/lib/extension/availabilityNew.ts @@ -1,14 +1,16 @@ import ExtensionTS from './extensionts'; import logger from '../util/logger'; import {sleep} from '../util/utils'; +import * as settings from '../util/settings'; const hours = (hours: number): number => 1000 * 60 * 60 * hours; const minutes = (minutes: number): number => 1000 * 60 * minutes; const seconds = (seconds: number): number => 1000 * seconds; -const ActiveTimeout = minutes(10); -const PassiveTimeout = hours(25); - +// TODO +// - State retrieval +// - Home Assistant add availability mode +// - Honour legacy availability_timeout, availability_blocklist and availability_passlist options. class AvailabilityNew extends ExtensionTS { private timers: {[s: string]: NodeJS.Timeout} = {}; private availabilityCache: {[s: string]: boolean} = {}; @@ -22,6 +24,24 @@ class AvailabilityNew extends ExtensionTS { logger.warn('Using experimental new availability feature'); } + private isEnabledForDevice(re: ResolvedEntity): boolean { + return re.settings.hasOwnProperty('availability') ? !!re.settings.availability : !!settings.get().availability; + } + + private getTimeout(re: ResolvedEntity): number { + if (typeof re.settings.availability === 'object' && re.settings.availability?.timeout != null) { + return minutes(re.settings.availability.timeout); + } + + const key = this.isActiveDevice(re) ? 'active' : 'passive'; + const availabilitySettings = settings.get().availability; + if (typeof availabilitySettings === 'object' && availabilitySettings[key]?.timeout != null) { + return minutes(availabilitySettings[key]?.timeout); + } + + 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)'; @@ -29,7 +49,7 @@ class AvailabilityNew extends ExtensionTS { private isAvailable(re: ResolvedEntity): boolean { const ago = Date.now() - re.device.lastSeen; - return this.isActiveDevice(re) ? ago < ActiveTimeout : ago < PassiveTimeout; + return ago < this.getTimeout(re); } private resetTimer(re: ResolvedEntity): void { @@ -39,10 +59,10 @@ class AvailabilityNew extends ExtensionTS { if (this.isActiveDevice(re)) { // 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), ActiveTimeout + seconds(1)); + () => this.addToPingQueue(re), this.getTimeout(re) + seconds(1)); } else { this.timers[re.device.ieeeAddr] = setTimeout( - () => this.publishAvailability(re, true), PassiveTimeout + seconds(1)); + () => this.publishAvailability(re, true), this.getTimeout(re) + seconds(1)); } } @@ -93,14 +113,17 @@ class AvailabilityNew extends ExtensionTS { override onMQTTConnected(): void { for (const device of this.zigbee.getClients()) { const re: ResolvedEntity = this.zigbee.resolveEntity(device); - this.resetTimer(re); // Publish initial availablility this.publishAvailability(re, true); - // If an active device is initially unavailable, ping it. - if (this.isActiveDevice(re) && !this.isAvailable(re)) { - this.addToPingQueue(re); + if (this.isEnabledForDevice(re)) { + this.resetTimer(re); + + // If an active device is initially unavailable, ping it. + if (this.isActiveDevice(re) && !this.isAvailable(re)) { + this.addToPingQueue(re); + } } } } @@ -110,7 +133,8 @@ class AvailabilityNew extends ExtensionTS { } private publishAvailability(re: ResolvedEntity, logLastSeen: boolean): void { - if (logLastSeen) { + const enabled = this.isEnabledForDevice(re); + if (enabled && logLastSeen) { const ago = Date.now() - re.device.lastSeen; if (this.isActiveDevice(re)) { logger.debug( @@ -120,7 +144,7 @@ class AvailabilityNew extends ExtensionTS { } } - const available = this.isAvailable(re); + const available = enabled ? this.isAvailable(re) : true; if (this.availabilityCache[re.device.ieeeAddr] == available) { return; } @@ -133,11 +157,12 @@ class AvailabilityNew extends ExtensionTS { private lastSeenChanged(data: {device: Device}): void { const re = this.zigbee.resolveEntity(data.device); - - // Remove from ping queue, not necessary anymore since we know the device is online. - this.removeFromPingQueue(re); - this.resetTimer(re); - this.publishAvailability(re, false); + if (this.isEnabledForDevice(re)) { + // Remove from ping queue, not necessary anymore since we know the device is online. + this.removeFromPingQueue(re); + this.resetTimer(re); + this.publishAvailability(re, false); + } } override stop(): void { diff --git a/lib/types/types.ts b/lib/types/types.ts index c7218f61c..f8d475249 100644 --- a/lib/types/types.ts +++ b/lib/types/types.ts @@ -16,6 +16,10 @@ declare global { blocklist: string[], whitelist: string[], ban: string[], + availability?: boolean | { + active?: {timeout?: number}, + passive?: {timeout?: number} + }, permit_join: boolean, frontend?: { auth_token?: string, @@ -117,6 +121,10 @@ declare global { definition?: {model: string}, name: string, device?: Device, + settings: { + friendlyName: string, + availability?: {timeout?: number} | boolean, + } } type lastSeenChangedHandler = (data: {device: Device}) => void; diff --git a/test/availabilityNew.test.ts b/test/availabilityNew.test.ts index fdb0d4b7b..2c693758d 100644 --- a/test/availabilityNew.test.ts +++ b/test/availabilityNew.test.ts @@ -47,6 +47,8 @@ describe('Availability', () => { beforeEach(async () => { jest.useFakeTimers('modern').setSystemTime(minutes(1)); data.writeDefaultConfiguration(); + settings.set(['devices', '0x000b57fffec6a5b4', 'availability'], false); + settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], true); // @ts-ignore Object.values(zigbeeHerdsman.devices).forEach(d => d.lastSeen = minutes(1)); mocks.forEach((m) => m.mockClear()); @@ -69,6 +71,8 @@ describe('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).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 () => { @@ -84,6 +88,14 @@ describe('Availability', () => { 'offline', {retain: true, qos: 0}, expect.any(Function)); }); + it('Shouldnt do anything for a device when availability: false is set', async () => { + MQTT.publish.mockClear(); + + await advancedTime(minutes(12)); + await zigbeeHerdsman.events.lastSeenChanged({device: devices.bulb_color_2}); + 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)); @@ -162,4 +174,28 @@ describe('Availability', () => { expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', 'online', {retain: true, qos: 0}, expect.any(Function)); }); + + it('Should allow to change availability timeout via device options', async () => { + settings.set(['devices', '0x000b57fffec6a5b3', 'availability'], {timeout: 40}); + await resetExtension(); + MQTT.publish.mockClear(); + + await advancedTime(minutes(25)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); + + await advancedTime(minutes(17)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); + }); + + it('Should allow to change availability timeout via avaiability options', async () => { + settings.set(['availability'], {active: {timeout: 30}}); + await resetExtension(); + MQTT.publish.mockClear(); + + await advancedTime(minutes(25)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); + + await advancedTime(minutes(7)); + expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); + }); });