From 7234d5bdbce680bfd9c7e74b69f4585c7b20c308 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 18 Oct 2026 11:03:48 +0530 Subject: [PATCH] fix: Apply throttle and debounce to last seen updates from non-emitted frames (#33423) --- lib/extension/receive.ts | 23 +++++++++++++++++ lib/util/utils.ts | 10 +++++++- test/extensions/receive.test.ts | 45 +++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lib/extension/receive.ts b/lib/extension/receive.ts index 724a04c8f..f7487df7f 100755 --- a/lib/extension/receive.ts +++ b/lib/extension/receive.ts @@ -21,6 +21,29 @@ export default class Receive extends Extension { override async start(): Promise { 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 { + 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 { diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 62e1fea44..f4a4e5fef 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -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"); } diff --git a/test/extensions/receive.test.ts b/test/extensions/receive.test.ts index b5abd17a0..8723fcf8f 100644 --- a/test/extensions/receive.test.ts +++ b/test/extensions/receive.test.ts @@ -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);