diff --git a/lib/controller.ts b/lib/controller.ts index 643651c57..f48b69327 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -160,10 +160,8 @@ class Controller { } } - if (settings.get().advanced.last_seen && settings.get().advanced.last_seen !== 'disable') { - this.eventBus.onLastSeenChanged(this, (data) => - this.publishEntityState(data.device, {}, 'lastSeenChanged')); - } + this.eventBus.onLastSeenChanged(this, + (data) => utils.publishLastSeen(data, settings.get(), false, this.publishEntityState)); } @bind async enableDisableExtension(enable: boolean, name: string): Promise { diff --git a/lib/extension/receive.ts b/lib/extension/receive.ts index 181db5605..60302d7c4 100755 --- a/lib/extension/receive.ts +++ b/lib/extension/receive.ts @@ -4,6 +4,7 @@ import debounce from 'debounce'; import Extension from './extension'; import stringify from 'json-stable-stringify-without-jsonify'; import bind from 'bind-decorator'; +import utils from '../util/utils'; type DebounceFunction = (() => void) & { clear(): void; } & { flush(): void; }; @@ -107,7 +108,11 @@ export default class Receive extends Extension { data = {...data, device: this.zigbee.deviceByNetworkAddress(data.groupID)}; } - if (!this.shouldProcess(data)) return; + if (!this.shouldProcess(data)) { + utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, + settings.get(), true, this.publishEntityState); + return; + } const converters = data.device.definition.fromZigbee.filter((c) => { const type = Array.isArray(c.type) ? c.type.includes(data.type) : c.type === data.type; @@ -163,6 +168,9 @@ export default class Receive extends Extension { if (Object.keys(payload).length) { publish(payload); + } else { + utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, + settings.get(), true, this.publishEntityState); } } } diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index b5d40a5ed..12e50705b 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -136,7 +136,8 @@ declare global { type StateChange = { entity: Device | Group, from: KeyValue, to: KeyValue, reason: string | null, update: KeyValue }; type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload; - type LastSeenChanged = { device: Device }; + type LastSeenChanged = { device: Device, + reason: 'deviceAnnounce' | 'networkAddress' | 'deviceJoined' | 'messageEmitted' | 'messageNonEmitted'; }; type DeviceNetworkAddressChanged = { device: Device }; type DeviceAnnounce = { device: Device }; type DeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' }; diff --git a/lib/util/utils.ts b/lib/util/utils.ts index daecd7a6d..d74f30a9c 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -280,11 +280,26 @@ const hours = (hours: number): number => 1000 * 60 * 60 * hours; const minutes = (minutes: number): number => 1000 * 60 * minutes; const seconds = (seconds: number): number => 1000 * seconds; +function publishLastSeen(data: eventdata.LastSeenChanged, settings: Settings, allowMessageEmitted: boolean, + publishEntityState: PublishEntityState): void { + /** + * Prevent 2 MQTT publishes when 1 message event is received; + * - In case reason == messageEmitted, receive.ts will only call this when it did not publish a + * 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. + */ + const allow = data.reason !== 'messageEmitted' || (data.reason === 'messageEmitted' && allowMessageEmitted); + if (settings.advanced.last_seen && settings.advanced.last_seen !== 'disable' && allow) { + publishEntityState(data.device, {}, 'lastSeenChanged'); + } +} + export default { endpointNames, capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties, equalsPartial, getObjectProperty, getResponse, parseJSON, loadModuleFromText, loadModuleFromFile, getExternalConvertersDefinitions, removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase, parseEntityID, isEndpoint, isZHGroup, hours, minutes, seconds, validateFriendlyName, sleep, - sanitizeImageParameter, isAvailabilityEnabledForDevice, + sanitizeImageParameter, isAvailabilityEnabledForDevice, publishLastSeen, }; diff --git a/lib/zigbee.ts b/lib/zigbee.ts index 22a648df5..8907507aa 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -63,7 +63,7 @@ export default class Zigbee { this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected()); this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => { - this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)}); + this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr), reason: data.reason}); }); this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => { this.eventBus.emitPermitJoinChanged(data); diff --git a/test/controller.test.js b/test/controller.test.js index 95e3665b6..99ee9fd9e 100644 --- a/test/controller.test.js +++ b/test/controller.test.js @@ -644,9 +644,19 @@ describe('Controller', () => { await flushPromises(); MQTT.publish.mockClear(); const device = zigbeeHerdsman.devices.remote; - await zigbeeHerdsman.events.lastSeenChanged({device}); + await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'deviceAnnounce'}); expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/remote', stringify({"brightness":255,"last_seen":1000}), { qos: 0, retain: true }, expect.any(Function)); }); + + it('Should not publish last seen changes when reason is messageEmitted', async () => { + settings.set(['advanced', 'last_seen'], 'epoch'); + await controller.start(); + await flushPromises(); + MQTT.publish.mockClear(); + const device = zigbeeHerdsman.devices.remote; + await zigbeeHerdsman.events.lastSeenChanged({device, reason: 'messageEmitted'}); + expect(MQTT.publish).toHaveBeenCalledTimes(0); + }); });