mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-06-22 21:21:45 +00:00
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
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<string, NodeJS.Timeout>();
|
|
/** Mapped by IEEE address or Group ID */
|
|
private readonly lastPublishedAvailabilities = new Map<string | number, boolean>();
|
|
/** Mapped by IEEE address */
|
|
private readonly pingBackoffs = new Map<string, number>();
|
|
/** IEEE addresses, waiting for last seen changes to take them out of "availability sleep" */
|
|
private readonly backoffPausedDevices = new Set<string>();
|
|
/** Mapped by IEEE address */
|
|
private readonly retrieveStateDebouncers = new Map<string, () => 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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)?.();
|
|
}
|
|
}
|