feat: Availability improvements (#26811)

This commit is contained in:
Nerivec
2025-04-13 20:49:28 +02:00
committed by GitHub
parent fc31e0af0a
commit 8df5a484fc
11 changed files with 505 additions and 98 deletions
+147 -66
View File
@@ -1,3 +1,5 @@
import type * as zhc from 'zigbee-herdsman-converters';
import type {Zigbee2MQTTAPI} from '../types/api';
import assert from 'node:assert';
@@ -5,8 +7,6 @@ import assert from 'node:assert';
import bind from 'bind-decorator';
import debounce from 'debounce';
import * as zhc from 'zigbee-herdsman-converters';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
@@ -19,9 +19,16 @@ const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyVa
];
export default class Availability extends Extension {
private timers: {[s: string]: NodeJS.Timeout} = {};
private availabilityCache: {[s: string]: boolean} = {};
private retrieveStateDebouncers: {[s: string]: () => void} = {};
/** Mapped by IEEE address */
private readonly timers: Map<string, NodeJS.Timeout> = new Map();
/** Mapped by IEEE address or Group ID */
private readonly lastPublishedAvailabilities: Map<string | number, boolean> = new Map();
/** Mapped by IEEE address */
private readonly pingBackoffs: Map<string, number> = new Map();
/** IEEE addresses, waiting for last seen changes to take them out of "availability sleep" */
private readonly backoffPausedDevices: Set<string> = new Set();
/** Mapped by IEEE address */
private readonly retrieveStateDebouncers: Map<string, () => void> = new Map();
private pingQueue: Device[] = [];
private pingQueueExecuting = false;
private stopped = false;
@@ -31,37 +38,95 @@ export default class Availability extends Extension {
return utils.minutes(device.options.availability.timeout);
}
const type = this.isActiveDevice(device) ? 'active' : 'passive';
return utils.minutes(this.isActiveDevice(device) ? settings.get().availability.active.timeout : settings.get().availability.passive.timeout);
}
return utils.minutes(settings.get().availability[type].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 === 'Mains (single phase)';
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()) {
return Date.now() - (entity.zh.lastSeen ?? /* v8 ignore next */ 0) < this.getTimeout(entity);
} else {
const membersDevices = entity.membersDevices();
return membersDevices.length === 0 || membersDevices.some((d) => this.availabilityCache[d.ieeeAddr]);
const lastSeen = entity.zh.lastSeen ?? /* v8 ignore next */ 0;
return Date.now() - lastSeen < this.getTimeout(entity) + this.getMaxJitter(entity);
}
for (const memberDevice of entity.membersDevices()) {
if (this.lastPublishedAvailabilities.get(memberDevice.ieeeAddr) === true) {
return true;
}
}
return false;
}
private resetTimer(device: Device): void {
clearTimeout(this.timers[device.ieeeAddr]);
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)) {
// If device did not check in, ping it, if that fails it will be marked as offline
this.timers[device.ieeeAddr] = setTimeout(() => this.addToPingQueue(device), this.getTimeout(device) + utils.seconds(1));
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[device.ieeeAddr] = setTimeout(() => this.publishAvailability(device, true), this.getTimeout(device) + utils.seconds(1));
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);
@@ -69,7 +134,7 @@ export default class Availability extends Extension {
private removeFromPingQueue(device: Device): void {
const index = this.pingQueue.findIndex((d) => d.ieeeAddr === device.ieeeAddr);
if (index != -1) {
if (index !== -1) {
this.pingQueue.splice(index, 1);
}
}
@@ -81,8 +146,8 @@ export default class Availability extends Extension {
this.pingQueueExecuting = true;
const device = this.pingQueue[0];
let pingedSuccessfully = false;
const available = this.availabilityCache[device.ieeeAddr] || this.isAvailable(device);
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++) {
@@ -90,7 +155,7 @@ export default class Availability extends Extension {
// Enable recovery if device is marked as available and first ping fails.
await device.zh.ping(!available || i !== 2);
pingedSuccessfully = true;
pingSuccess = true;
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
break;
@@ -109,8 +174,21 @@ export default class Availability extends Extension {
return;
}
await this.publishAvailability(device, !pingedSuccessfully);
this.resetTimer(device);
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
@@ -132,12 +210,12 @@ export default class Availability extends Extension {
await this.publishAvailability(data.entity, false, true);
}
});
this.eventBus.onEntityRemoved(this, (data) => data.type == 'device' && clearTimeout(this.timers[data.id]));
this.eventBus.onDeviceLeave(this, (data) => clearTimeout(this.timers[data.ieeeAddr]));
this.eventBus.onEntityRemoved(this, (data) => data.type === 'device' && this.clearTimer(data.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();
@@ -154,7 +232,7 @@ export default class Availability extends Extension {
}
}
@bind private async publishAvailabilityForAllEntities(): Promise<void> {
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);
@@ -175,18 +253,18 @@ export default class Availability extends Extension {
const available = this.isAvailable(entity);
if (!forcePublish && this.availabilityCache[entity.ID] == available) {
if (!forcePublish && this.lastPublishedAvailabilities.get(entity.ID) === available) {
return;
}
if (entity.isDevice() && entity.ieeeAddr in this.availabilityCache && available && this.availabilityCache[entity.ieeeAddr] === false) {
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.availabilityCache[entity.ID] = available;
this.lastPublishedAvailabilities.set(entity.ID, available);
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
if (!skipGroups && entity.isDevice()) {
@@ -202,7 +280,7 @@ export default class Availability extends Extension {
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);
this.resetTimer(data.device, true);
await this.publishAvailability(data.device, false);
}
}
@@ -211,7 +289,7 @@ export default class Availability extends Extension {
this.stopped = true;
this.pingQueue = [];
for (const t of Object.values(this.timers)) {
for (const [, t] of this.timers) {
clearTimeout(t);
}
@@ -223,43 +301,46 @@ export default class Availability extends Extension {
* 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.zh.interviewing && !this.retrieveStateDebouncers[device.ieeeAddr]) {
this.retrieveStateDebouncers[device.ieeeAddr] = debounce(async () => {
logger.debug(`Retrieving state of '${device.name}' after reconnect`);
if (device.definition && !device.zh.interviewing && !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;
// 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;
}
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),
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);
}
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),
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));
}, utils.seconds(2)),
);
}
this.retrieveStateDebouncers[device.ieeeAddr]?.();
this.retrieveStateDebouncers.get(device.ieeeAddr)?.();
}
}
+10 -12
View File
@@ -592,9 +592,8 @@ export default class Bridge extends Extension {
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/remove' : 'bridge/response/group/remove'>> {
const ID = typeof message === 'object' ? message.id : message.trim();
const entity = this.getEntity(entityType, ID);
// note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee)
const friendlyName = entity.name;
const entityID = entity.ID;
let block = false;
let force = false;
let blockForceLog = '';
@@ -609,8 +608,7 @@ export default class Bridge extends Extension {
}
try {
logger.info(`Removing ${entityType} '${entity.name}'${blockForceLog}`);
const name = entity.name;
logger.info(`Removing ${entityType} '${friendlyName}'${blockForceLog}`);
if (entity instanceof Device) {
if (block) {
@@ -623,8 +621,8 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'device'});
settings.removeDevice(entityID as string);
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: 'device'});
settings.removeDevice(entity.ID as string);
} else {
if (force) {
entity.zh.removeFromDatabase();
@@ -632,12 +630,12 @@ export default class Bridge extends Extension {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'group'});
settings.removeGroup(entityID);
this.eventBus.emitEntityRemoved({id: entity.ID, name: friendlyName, type: 'group'});
settings.removeGroup(entity.ID);
}
// Remove from state
this.state.remove(entityID);
this.state.remove(entity.ID);
// Clear any retained messages
await this.mqtt.publish(friendlyName, '', {retain: true});
@@ -694,7 +692,7 @@ export default class Bridge extends Extension {
zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion,
zigbee_herdsman: this.zigbeeHerdsmanVersion,
coordinator: {
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
ieee_address: this.zigbee.firstCoordinatorEndpoint().deviceIeeeAddress,
...this.coordinatorVersion,
},
network: {
@@ -732,7 +730,7 @@ export default class Bridge extends Extension {
for (const bind of endpoint.binds) {
const target = utils.isZHEndpoint(bind.target)
? {type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID}
? {type: 'endpoint', ieee_address: bind.target.deviceIeeeAddress, endpoint: bind.target.ID}
: {type: 'group', id: bind.target.groupID};
data.bindings.push({cluster: bind.cluster.name, target});
}
@@ -780,7 +778,7 @@ export default class Bridge extends Extension {
const members = [];
for (const member of group.zh.members) {
members.push({ieee_address: member.getDevice().ieeeAddr, endpoint: member.ID});
members.push({ieee_address: member.deviceIeeeAddress, endpoint: member.ID});
}
groups.push({
+1 -3
View File
@@ -134,9 +134,7 @@ export default class Publish extends Extension {
const entityState = this.state.get(re);
const membersState =
re instanceof Group
? Object.fromEntries(
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]),
)
? Object.fromEntries(re.zh.members.map((e) => [e.deviceIeeeAddress, this.state.get(this.zigbee.resolveEntity(e.deviceIeeeAddress)!)]))
: undefined;
const converters = this.getDefinitionConverters(definition);
+4 -2
View File
@@ -26,8 +26,10 @@ export default class Group {
return !!device.zh.endpoints.find((e) => this.zh.members.includes(e));
}
membersDevices(): Device[] {
return this.zh.members.map((d) => this.resolveDevice(d.getDevice().ieeeAddr)!);
*membersDevices(): Generator<Device> {
for (const member of this.zh.members) {
yield this.resolveDevice(member.deviceIeeeAddress)!;
}
}
membersDefinitions(): zhc.Definition[] {
+15 -3
View File
@@ -48,7 +48,7 @@ declare global {
namespace eventdata {
type EntityRenamed = {entity: Device | Group; homeAssisantRename: boolean; from: string; to: string};
type EntityRemoved = {id: number | string; name: string; type: 'device' | 'group'};
type EntityRemoved = {id: string; name: string; type: 'device'} | {id: number; name: string; type: 'group'};
type MQTTMessage = {topic: string; message: string};
type MQTTMessagePublished = {topic: string; payload: string; options: {retain: boolean; qos: number}};
type StateChange = {
@@ -98,7 +98,12 @@ declare global {
};
availability: {
enabled: boolean;
active: {timeout: number};
active: {
timeout: number;
max_jitter: number;
backoff: boolean;
pause_on_backoff_gt: number;
};
passive: {timeout: number};
};
mqtt: {
@@ -200,7 +205,14 @@ declare global {
interface DeviceOptions {
disabled?: boolean;
retention?: number;
availability?: boolean | {timeout: number};
availability?:
| boolean
| {
timeout: number;
max_jitter?: number;
backoff?: boolean;
pause_on_backoff_gt?: number;
};
optimistic?: boolean;
debounce?: number;
debounce_ignore?: string[];
+20
View File
@@ -68,6 +68,26 @@
"requiresRestart": true,
"default": 10,
"description": "Time after which an active device will be marked as offline in minutes"
},
"max_jitter": {
"type": "number",
"title": "Max jitter",
"default": 30000,
"minimum": 1000,
"description": "Maximum jitter (in msec) allowed on timeout to avoid availability pings trying to trigger around the same time"
},
"backoff": {
"type": "boolean",
"title": "Enabled",
"description": "Enable timeout backoff on failed availability pings (x1.5, x3, x6, x12...)",
"default": true
},
"pause_on_backoff_gt": {
"type": "number",
"title": "Pause on backoff greater than",
"default": 0,
"minimum": 0,
"description": "Pause availability pings when backoff reaches over this limit until a new Zigbee message is received from the device. A value of zero disables pausing."
}
},
"required": ["timeout"]
+1 -1
View File
@@ -38,7 +38,7 @@ export const defaults: RecursivePartial<Settings> = {
},
availability: {
enabled: false,
active: {timeout: 10},
active: {timeout: 10, max_jitter: 30000, backoff: true, pause_on_backoff_gt: 0},
passive: {timeout: 1500},
},
frontend: {
+8 -6
View File
@@ -247,18 +247,20 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin
}
if (entity.isGroup()) {
return !entity.membersDevices().some((d) => !isAvailabilityEnabledForEntity(d, settings));
for (const memberDevice of entity.membersDevices()) {
if (!isAvailabilityEnabledForEntity(memberDevice, settings)) {
return false;
}
}
return true;
}
if (entity.options.availability != null) {
return !!entity.options.availability;
}
if (!settings.availability.enabled) {
return false;
}
return true;
return settings.availability.enabled;
}
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
+289 -1
View File
@@ -57,6 +57,9 @@ describe('Extension: Availability', () => {
settings.reRead();
settings.set(['availability'], {enabled: true});
settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false);
settings.set(['devices', devices.hue_twilight.ieeeAddr, 'availability'], {max_jitter: 1000});
settings.set(['devices', devices.GLEDOPTO_2ID.ieeeAddr, 'availability'], {backoff: false});
settings.set(['devices', devices.QBKG03LM.ieeeAddr, 'availability'], {pause_on_backoff_gt: 1.5});
Object.values(devices).forEach((d) => (d.lastSeen = utils.minutes(1)));
mocksClear.forEach((m) => m.mockClear());
await resetExtension();
@@ -137,7 +140,7 @@ describe('Extension: Availability', () => {
it('Should reset ping timer when device last seen changes for active device', async () => {
mockMQTTPublishAsync.mockClear();
await setTimeAndAdvanceTimers(utils.minutes(5));
await setTimeAndAdvanceTimers(utils.minutes(6));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
@@ -390,4 +393,289 @@ describe('Extension: Availability', () => {
await expect(() => availability.start()).rejects.toThrow();
});
it('jitters pings', async () => {
const devicesPings = [
devices.bulb_color.ping,
devices.TS0601_thermostat.ping,
devices.bulb_2.ping,
devices.ZNCZ02LM.ping,
devices.GLEDOPTO_2ID.ping,
devices.QBKG03LM.ping,
devices.hue_twilight.ping,
];
let called = 0;
let lastCalled = 0;
await setTimeAndAdvanceTimers(utils.minutes(10));
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toStrictEqual(0);
lastCalled = called;
called = 0;
await setTimeAndAdvanceTimers(utils.seconds(2)); // 10:02
expect(devices.hue_twilight.ping).toHaveBeenCalledTimes(1); // max_jitter: 1000
await setTimeAndAdvanceTimers(utils.seconds(8)); // 10:10
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toBeGreaterThanOrEqual(0);
expect(called).toBeLessThan(7);
expect(called).toBeGreaterThanOrEqual(lastCalled);
lastCalled = called;
called = 0;
await setTimeAndAdvanceTimers(utils.seconds(20)); // 10:20
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toBeGreaterThanOrEqual(lastCalled);
lastCalled = called;
called = 0;
await setTimeAndAdvanceTimers(utils.seconds(35)); // 10:35
for (const p of devicesPings) {
called += p.mock.calls.length;
}
expect(called).toStrictEqual(7);
for (const p of devicesPings) {
expect(p).toHaveBeenCalledTimes(1);
}
});
it('does not trigger backoff on ping success', async () => {
settings.set(['availability', 'active', 'max_jitter'], 0); // easier testing
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
});
it('triggers backoff on successive ping failures', async () => {
settings.set(['availability', 'active', 'max_jitter'], 0); // easier testing
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.GLEDOPTO_2ID}); // backoff: false
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
});
devices.GLEDOPTO_2ID.ping
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(2);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(2, false);
await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);
await setTimeAndAdvanceTimers(utils.minutes(59.5)); // 115:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 115:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);
// backoff was reset (4 mocks done)
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 126:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(6);
expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(13);
});
it('resets backoff on last seen', async () => {
settings.set(['availability', 'active', 'max_jitter'], 0); // easier testing
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
});
it('pauses when backoff reaches configured value and unpauses on last seen', async () => {
settings.set(['availability', 'active', 'max_jitter'], 0); // easier testing
settings.set(['availability', 'active', 'pause_on_backoff_gt'], 3);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM}); // pause_on_backoff_gt: 1.5
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
});
devices.QBKG03LM.ping
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);
expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(2, false);
await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(100));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(100));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM});
await setTimeAndAdvanceTimers(utils.minutes(10.5));
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
});
it('allows to disable backoff', async () => {
settings.set(['availability', 'active', 'max_jitter'], 0); // easier testing
settings.set(['availability', 'active', 'backoff'], false);
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
devices.bulb_color.ping
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
})
.mockImplementationOnce(() => {
throw new Error('failed');
});
await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 20:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 20:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 30:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 30:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 40:00
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 40:30
expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
});
});
+6 -1
View File
@@ -258,7 +258,12 @@ describe('Extension: Bridge', () => {
},
availability: {
enabled: false,
active: {timeout: 10},
active: {
timeout: 10,
max_jitter: 30000,
backoff: true,
pause_on_backoff_gt: 0,
},
passive: {timeout: 1500},
},
frontend: {
+4 -3
View File
@@ -236,7 +236,7 @@ export class Device {
interview: Mock;
interviewing: boolean;
meta: Record<string, unknown>;
ping: Mock;
ping: Mock<(disableRecovery?: boolean) => Promise<void>>;
removeFromNetwork: Mock;
removeFromDatabase: Mock;
customClusters: Record<string, unknown>;
@@ -1141,7 +1141,7 @@ export const devices = {
'0x00124b00cfcf3298',
18129,
0xfff1,
[new Endpoint(8, [0, 3, 4, 5, 6, 8], []), new Endpoint(242, [], [33])],
[new Endpoint(8, [0, 3, 4, 5, 6, 8], [], '0x00124b00cfcf3298'), new Endpoint(242, [], [33], '0x00124b00cfcf3298')],
true,
'DC Source',
'FanBee1',
@@ -1169,7 +1169,8 @@ export const mockController = {
(): Promise<AdapterTypes.CoordinatorVersion> => Promise.resolve({type: 'z-Stack', meta: {version: 1, revision: 20190425}}),
),
getNetworkParameters: vi.fn(
(): Promise<AdapterTypes.NetworkParameters> => Promise.resolve({panID: 0x162a, extendedPanID: '0x64c5fd698daf0c00', channel: 15}),
(): Promise<AdapterTypes.NetworkParameters> =>
Promise.resolve({panID: 0x162a, extendedPanID: '0x64c5fd698daf0c00', channel: 15, nwkUpdateID: 0}),
),
getDevices: vi.fn((): Device[] => []),
getDevicesIterator: vi.fn(function* (predicate?: (value: Device) => boolean): Generator<Device> {