fix: Apply throttle and debounce to last seen updates from non-emitted frames (#33423)

This commit is contained in:
Cestercian authored and GitHub committed 2026-10-18 07:33:48 +02:00
1 parent c588eca20e
commit 7234d5bdbc
3 files changed
+77 -1

No files matched your search

+23
View File
@@ -21,6 +21,29 @@ export default class Receive extends Extension {
override async start(): Promise<void> {
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
this.eventBus.onDeviceMessage(this, this.onDeviceMessage);
this.eventBus.onLastSeenChanged(this, this.onLastSeenChanged);
}
/**
* Frames that zigbee-herdsman does not emit as a message (e.g. default responses) only fire a
* lastSeenChanged event. For devices with debounce or throttle set, publish those through the same
* per-device debounce/throttle as regular messages instead of publishing the full state right away.
* See `utils.publishLastSeen()`, which skips them for these devices.
*/
@bind async onLastSeenChanged(data: eventdata.LastSeenChanged): Promise<void> {
const lastSeen = settings.get().advanced.last_seen;
if (data.reason !== "messageNonEmitted" || !lastSeen || lastSeen === "disable") {
return;
}
const {debounce, debounce_ignore, throttle} = data.device.options;
if (debounce) {
this.publishDebounce(data.device, {}, debounce, debounce_ignore);
} else if (throttle) {
await this.publishThrottle(data.device, {}, throttle);
}
}
@bind onPublishEntityState(data: eventdata.PublishEntityState): void {
+9 -1
View File
@@ -288,6 +288,10 @@ export const hours = (hours: number): number => 1000 * 60 * 60 * hours;
export const minutes = (minutes: number): number => 1000 * 60 * minutes;
export const seconds = (seconds: number): number => 1000 * seconds;
function isDebouncedOrThrottled(device: Device): boolean {
return Boolean(device.options.debounce || device.options.throttle);
}
async function publishLastSeen(
data: eventdata.LastSeenChanged,
settings: Settings,
@@ -300,8 +304,12 @@ async function publishLastSeen(
* message based on the received zigbee message. In this case allowMessageEmitted has to be true.
* - In case reason !== messageEmitted, controller.ts will call this based on the zigbee-herdsman
* lastSeenChanged event.
* - In case reason == messageNonEmitted and the device has debounce or throttle set, receive.ts publishes
* it through the per-device debounce/throttle, so skip it here.
*/
const allow = data.reason !== "messageEmitted" || (data.reason === "messageEmitted" && allowMessageEmitted);
const allow =
(data.reason !== "messageEmitted" || (data.reason === "messageEmitted" && allowMessageEmitted)) &&
!(data.reason === "messageNonEmitted" && isDebouncedOrThrottled(data.device));
if (settings.advanced.last_seen && settings.advanced.last_seen !== "disable" && allow) {
await publishEntityState(data.device, {}, "lastSeenChanged");
}
+45
View File
@@ -214,6 +214,51 @@ describe("Extension: Receive", () => {
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
});
it("Should throttle last seen updates from frames that are not emitted as a message", async () => {
const device = devices.WSDCGQ11LM;
settings.set(["devices", device.ieeeAddr, "throttle"], 1);
settings.set(["advanced", "last_seen"], "ISO_8601");
const devicePublishes = (): unknown[] => mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === "zigbee2mqtt/weather_sensor");
await mockZHEvents.lastSeenChanged({device, reason: "messageNonEmitted"});
await mockZHEvents.lastSeenChanged({device, reason: "messageNonEmitted"});
await mockZHEvents.lastSeenChanged({device, reason: "messageNonEmitted"});
await flushPromises();
expect(devicePublishes()).toHaveLength(1);
vi.advanceTimersByTime(2000);
await flushPromises();
expect(devicePublishes()).toHaveLength(2);
});
it("Should debounce last seen updates from frames that are not emitted as a message", async () => {
const device = devices.WSDCGQ11LM;
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
settings.set(["advanced", "last_seen"], "ISO_8601");
const devicePublishes = (): unknown[] => mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === "zigbee2mqtt/weather_sensor");
await mockZHEvents.lastSeenChanged({device, reason: "messageNonEmitted"});
await mockZHEvents.lastSeenChanged({device, reason: "messageNonEmitted"});
await flushPromises();
vi.advanceTimersByTime(50);
expect(devicePublishes()).toHaveLength(0);
vi.runOnlyPendingTimers();
await flushPromises();
expect(devicePublishes()).toHaveLength(1);
});
it("Should publish last seen updates from frames that are not emitted as a message right away without debounce or throttle", async () => {
const device = devices.WSDCGQ11LM;
settings.set(["advanced", "last_seen"], "ISO_8601");
const devicePublishes = (): unknown[] => mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === "zigbee2mqtt/weather_sensor");
await mockZHEvents.lastSeenChanged({device, reason: "messageNonEmitted"});
await mockZHEvents.lastSeenChanged({device, reason: "messageNonEmitted"});
await flushPromises();
expect(devicePublishes()).toHaveLength(2);
});
it("Should debounce and retain messages when set via device_options", async () => {
const device = devices.WSDCGQ11LM;
settings.set(["device_options", "debounce"], 0.1);