import assert from "node:assert"; import bind from "bind-decorator"; import debounce from "debounce"; import type * as zhc from "zigbee-herdsman-converters"; import type {Zigbee2MQTTAPI} from "../types/api"; 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"}, ]; export default class Availability extends Extension { /** Mapped by IEEE address */ private readonly timers = new Map(); /** Mapped by IEEE address or Group ID */ private readonly lastPublishedAvailabilities = new Map(); /** Mapped by IEEE address */ private readonly pingBackoffs = new Map(); /** IEEE addresses, waiting for last seen changes to take them out of "availability sleep" */ private readonly backoffPausedDevices = new Set(); /** Mapped by IEEE address */ private readonly retrieveStateDebouncers = new Map void>(); private pingQueue: Device[] = []; private pingQueueExecuting = false; private stopped = false; private getTimeout(device: Device): number { if (typeof device.options.availability === "object" && device.options.availability?.timeout != null) { return utils.minutes(device.options.availability.timeout); } return utils.minutes(this.isActiveDevice(device) ? settings.get().availability.active.timeout : settings.get().availability.passive.timeout); } private getMaxJitter(device: Device): number { if (typeof device.options.availability === "object" && device.options.availability?.max_jitter != null) { return device.options.availability.max_jitter; } return settings.get().availability.active.max_jitter; } private getBackoff(device: Device): boolean { if (typeof device.options.availability === "object" && device.options.availability?.backoff != null) { return device.options.availability.backoff; } return settings.get().availability.active.backoff; } private getPauseOnBackoffGt(device: Device): number { if (typeof device.options.availability === "object" && device.options.availability?.pause_on_backoff_gt != null) { return device.options.availability.pause_on_backoff_gt; } return settings.get().availability.active.pause_on_backoff_gt; } private isActiveDevice(device: Device): boolean { return ( (device.zh.type === "Router" && device.zh.powerSource !== "Battery") || (device.zh.powerSource !== undefined && device.zh.powerSource !== "Unknown" && device.zh.powerSource !== "Battery") ); } private isAvailable(entity: Device | Group): boolean { if (entity.isDevice()) { const lastSeen = entity.zh.lastSeen ?? /* v8 ignore next */ 0; return Date.now() - lastSeen < this.getTimeout(entity); } for (const memberDevice of entity.membersDevices()) { if (this.lastPublishedAvailabilities.get(memberDevice.ieeeAddr) === true) { return true; } } return false; } private resetTimer(device: Device, resetBackoff = false): void { clearTimeout(this.timers.get(device.ieeeAddr)); this.removeFromPingQueue(device); // If the timer triggers, the device is not available anymore otherwise resetTimer already has been called if (this.isActiveDevice(device)) { const backoffEnabled = this.getBackoff(device); const jitter = Math.random() * this.getMaxJitter(device); let backoff = 1; if (resetBackoff) { // always cleanup even if backoff disabled (ensures proper state if changed at runtime) this.backoffPausedDevices.delete(device.ieeeAddr); this.pingBackoffs.delete(device.ieeeAddr); } else if (backoffEnabled) { backoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1; } // never paused if was reset (just deleted) or backoff disabled, might as well skip the Set lookup if (!backoffEnabled || resetBackoff || !this.backoffPausedDevices.has(device.ieeeAddr)) { // If device did not check in, ping it, if that fails it will be marked as offline this.timers.set( device.ieeeAddr, setTimeout(this.addToPingQueue.bind(this, device), (this.getTimeout(device) + utils.seconds(1) + jitter) * backoff), ); } } else { this.timers.set( device.ieeeAddr, setTimeout(this.publishAvailability.bind(this, device, true), this.getTimeout(device) + utils.seconds(1)), ); } } private clearTimer(ieeeAddress: string): void { clearTimeout(this.timers.get(ieeeAddress)); this.timers.delete(ieeeAddress); } private addToPingQueue(device: Device): void { this.pingQueue.push(device); this.pingQueueExecuteNext().catch(utils.noop); } private removeFromPingQueue(device: Device): void { const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr); if (index !== -1) { this.pingQueue.splice(index, 1); } } private async pingQueueExecuteNext(): Promise { if (this.pingQueue.length === 0 || this.pingQueueExecuting) { return; } this.pingQueueExecuting = true; const device = this.pingQueue[0]; let pingSuccess = false; const available = this.lastPublishedAvailabilities.get(device.ieeeAddr) || this.isAvailable(device); const attempts = available ? 2 : 1; 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); pingSuccess = true; logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`); break; } catch (error) { logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${(error as Error).message})`); // Try again in 3 seconds. if (i !== attempts) { await utils.sleep(3); } } } if (this.stopped) { // Exit here to avoid triggering any follow-up activity (e.g., re-queuing another ping attempt). return; } if (!pingSuccess && this.getBackoff(device)) { const currentBackoff = this.pingBackoffs.get(device.ieeeAddr) ?? 1; // setting is "greater than" but since we already did the ping, we use ">=" for comparison below (pause next) const pauseOnBackoff = this.getPauseOnBackoffGt(device); if (pauseOnBackoff > 0 && currentBackoff >= pauseOnBackoff) { this.backoffPausedDevices.add(device.ieeeAddr); } else { // results in backoffs: *1.5, *3, *6, *12... (with default timeout: 10, 15, 30, 60, 120) this.pingBackoffs.set(device.ieeeAddr, currentBackoff * (available ? 1.5 : 2)); } } await this.publishAvailability(device, !pingSuccess); this.resetTimer(device, pingSuccess); this.removeFromPingQueue(device); // Sleep 2 seconds before executing next ping await utils.sleep(2); this.pingQueueExecuting = false; await this.pingQueueExecuteNext(); } override async start(): Promise { if (this.stopped) { throw new Error("This extension cannot be restarted."); } this.eventBus.onEntityRenamed(this, async (data) => { if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) { await this.mqtt.publish(`${data.from}/availability`, "", {clientOptions: {retain: true, qos: 1}}); await this.publishAvailability(data.entity, false, true); } }); this.eventBus.onEntityRemoved(this, (data) => data.entity.isDevice() && this.clearTimer(data.entity.ID)); this.eventBus.onDeviceLeave(this, (data) => this.clearTimer(data.ieeeAddr)); this.eventBus.onDeviceAnnounce(this, (data) => this.retrieveState(data.device)); this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged); this.eventBus.onGroupMembersChanged(this, (data) => this.publishAvailability(data.group, false)); // Publish initial availability await this.publishAvailabilityForAllEntities(); // Start availability for the devices for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { if (utils.isAvailabilityEnabledForEntity(device, settings.get())) { this.resetTimer(device); // If an active device is unavailable on start, add it to the pingqueue immediately. if (this.isActiveDevice(device) && !this.isAvailable(device)) { this.addToPingQueue(device); } } } } private async publishAvailabilityForAllEntities(): Promise { for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) { if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) { await this.publishAvailability(entity, true, false, true); } } } private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise { if (logLastSeen && entity.isDevice()) { const ago = Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0); if (this.isActiveDevice(entity)) { logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`); } else { logger.debug(`Passive device '${entity.name}' was last seen '${(ago / utils.hours(1)).toFixed(2)}' hours ago.`); } } const available = this.isAvailable(entity); if (!forcePublish && this.lastPublishedAvailabilities.get(entity.ID) === available) { return; } if (entity.isDevice() && available && this.lastPublishedAvailabilities.get(entity.ieeeAddr) === false) { logger.debug(`Device '${entity.name}' reconnected`); this.retrieveState(entity); } const topic = `${entity.name}/availability`; const payload: Zigbee2MQTTAPI["{friendlyName}/availability"] = {state: available ? "online" : "offline"}; this.lastPublishedAvailabilities.set(entity.ID, available); await this.mqtt.publish(topic, JSON.stringify(payload), {clientOptions: {retain: true, qos: 1}}); if (!skipGroups && entity.isDevice()) { for (const group of this.zigbee.groupsIterator()) { if (group.hasMember(entity) && utils.isAvailabilityEnabledForEntity(group, settings.get())) { await this.publishAvailability(group, false, forcePublish); } } } } @bind private async onLastSeenChanged(data: eventdata.LastSeenChanged): Promise { if (utils.isAvailabilityEnabledForEntity(data.device, settings.get())) { // Remove from ping queue, not necessary anymore since we know the device is online. this.removeFromPingQueue(data.device); this.resetTimer(data.device, true); await this.publishAvailability(data.device, false); } } override async stop(): Promise { this.stopped = true; this.pingQueue = []; for (const [, t] of this.timers) { clearTimeout(t); } await super.stop(); } private retrieveState(device: Device): 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 (device.definition && device.interviewed && !this.retrieveStateDebouncers.get(device.ieeeAddr)) { this.retrieveStateDebouncers.set( device.ieeeAddr, debounce(async () => { logger.debug(`Retrieving state of '${device.name}' after reconnect`); // Color and color temperature converters do both, only needs to be called once. for (const item of RETRIEVE_ON_RECONNECT) { if (item.condition && this.state.get(device) && !item.condition(this.state.get(device))) { continue; } // biome-ignore lint/style/noNonNullAssertion: doesn't change once valid const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k))); const options: KeyValue = device.options; const state = this.state.get(device); const meta: zhc.Tz.Meta = { message: this.state.get(device), // biome-ignore lint/style/noNonNullAssertion: doesn't change once valid mapped: device.definition!, endpoint_name: undefined, options, state, device: device.zh, /* v8 ignore next */ publish: (payload: KeyValue) => this.publishEntityState(device, payload), }; try { const endpoint = device.endpoint(); assert(endpoint); await converter?.convertGet?.(endpoint, item.keys[0], meta); } catch (error) { logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`); } await utils.sleep(500); } }, utils.seconds(2)), ); } this.retrieveStateDebouncers.get(device.ieeeAddr)?.(); } }