From 01b46cf74ec442923776b1245da2be4cd753c317 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 18 Dec 2022 22:05:16 +0100 Subject: [PATCH] Use fileVersion for Home Assistant update sensor instead of softwareBuildID (#15630) * Use fileVersion for Home Assistant update sensor instead of softwareBuildID. https://github.com/Koenkk/zigbee2mqtt/issues/15502 * Remove onlythis --- lib/extension/homeassistant.ts | 9 +++---- lib/extension/otaUpdate.ts | 49 ++++++++++++++++++++++------------ lib/types/types.d.ts | 7 +++-- test/frontend.test.js | 4 +-- test/homeassistant.test.js | 14 +++++----- test/otaUpdate.test.js | 24 +++++++++-------- 6 files changed, 63 insertions(+), 44 deletions(-) diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 290f547e2..2e53ef98f 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -1058,7 +1058,7 @@ export default class HomeAssistant extends Extension { const updateStateSensor: DiscoveryEntry = { type: 'sensor', object_id: 'update_state', - mockProperties: [{property: 'update', value: {state: null}}], + mockProperties: [], // update is mocked below with updateSensor discovery_payload: { icon: 'mdi:update', value_template: `{{ value_json['update']['state'] }}`, @@ -1094,8 +1094,7 @@ export default class HomeAssistant extends Extension { command_topic: `${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/update`, payload_install: `{"id": "${entity.ieeeAddr}"}`, value_template: `{{ value_json['update']['installed_version'] }}`, - latest_version_template: `{% if value_json['update']['state'] == "available" %}{{ 'newer' }}` + - `{% else %}{{ value_json['update']['installed_version'] }}{% endif %}`, + latest_version_template: `{{ value_json['update']['latest_version'] }}`, }, }; configs.push(updateSensor); @@ -1461,8 +1460,8 @@ export default class HomeAssistant extends Extension { } } - if (entity.isDevice() && entity.definition?.ota && message.hasOwnProperty('update')) { - message['update']['installed_version'] = entity.zh.softwareBuildID || 'unknown'; + if (entity.isDevice() && entity.definition?.ota && !message.update?.hasOwnProperty('installed_version')) { + message.update = {...message.update, installed_version: -1, latest_version: -1}; } } diff --git a/lib/extension/otaUpdate.ts b/lib/extension/otaUpdate.ts index ba9365064..98d4b582a 100644 --- a/lib/extension/otaUpdate.ts +++ b/lib/extension/otaUpdate.ts @@ -24,8 +24,13 @@ function isValidUrl(url: string): boolean { type UpdateState = 'updating' | 'idle' | 'available'; interface UpdatePayload { + update_available?: boolean // eslint-disable-next-line camelcase - update: {progress?: number, remaining?: number, state: UpdateState}, update_available?: boolean} + update: { + progress?: number, remaining?: number, state: UpdateState, + installed_version: number | null, latest_version: number | null + } +} const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`); const topicRegex = @@ -86,18 +91,18 @@ export default class OTAUpdate extends Extension { if (!check) return; this.lastChecked[data.device.ieeeAddr] = Date.now(); - let available = false; + let availableResult: zhc.OtaUpdateAvailableResult = null; try { - available = await data.device.definition.ota.isUpdateAvailable(data.device.zh, logger, data.data); + availableResult = await data.device.definition.ota.isUpdateAvailable(data.device.zh, logger, data.data); } catch (e) { supportsOTA = false; logger.debug(`Failed to check if update available for '${data.device.name}' (${e.message})`); } - const payload = this.getEntityPublishPayload(available ? 'available' : 'idle'); + const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle'); this.publishEntityState(data.device, payload); - if (available) { + if (availableResult?.available) { const message = `Update available for '${data.device.name}'`; logger.info(message); @@ -131,14 +136,22 @@ export default class OTAUpdate extends Extension { } } - private getEntityPublishPayload(state: UpdateState, progress: number=null, remaining: number=null): UpdatePayload { - const payload: UpdatePayload = {update: {state}}; + private getEntityPublishPayload(device: Device, state: zhc.OtaUpdateAvailableResult | UpdateState, + progress: number=null, remaining: number=null): UpdatePayload { + const deviceUpdateState = this.state.get(device).update; + const payload: UpdatePayload = {update: { + state: typeof state === 'string' ? state : (state.available ? 'available' : 'idle'), + installed_version: typeof state === 'string' ? + deviceUpdateState?.installed_version : state.currentFileVersion, + latest_version: typeof state === 'string' ? + deviceUpdateState?.latest_version : state.otaFileVersion, + }}; if (progress !== null) payload.update.progress = progress; if (remaining !== null) payload.update.remaining = Math.round(remaining); /* istanbul ignore else */ if (this.legacyApi) { - payload.update_available = state === 'available'; + payload.update_available = typeof state === 'string' ? state === 'available' : state.available; } return payload; @@ -189,23 +202,24 @@ export default class OTAUpdate extends Extension { } try { - const available = await device.definition.ota.isUpdateAvailable(device.zh, logger); - const msg = `${available ? 'Update' : 'No update'} available for '${device.name}'`; + const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, logger); + const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`; logger.info(msg); /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const meta = {status: available ? 'available' : 'not_available', device: device.name}; + const meta = { + status: availableResult.available ? 'available' : 'not_available', device: device.name}; this.mqtt.publish( 'bridge/log', stringify({type: `ota_update`, message: msg, meta}), ); } - const payload = this.getEntityPublishPayload(available ? 'available' : 'idle'); + const payload = this.getEntityPublishPayload(device, availableResult); this.publishEntityState(device, payload); this.lastChecked[device.ieeeAddr] = Date.now(); - responseData.updateAvailable = available; + responseData.updateAvailable = availableResult.available; } catch (e) { error = `Failed to check if update available for '${device.name}' (${e.message})`; errorStack = e.stack; @@ -241,7 +255,7 @@ export default class OTAUpdate extends Extension { logger.info(msg); - const payload = this.getEntityPublishPayload('updating', progress, remaining); + const payload = this.getEntityPublishPayload(device, 'updating', progress, remaining); this.publishEntityState(device, payload); /* istanbul ignore else */ @@ -252,11 +266,12 @@ export default class OTAUpdate extends Extension { }; const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate'); - await device.definition.ota.updateToLatest(device.zh, logger, onProgress); + const fileVersion = await device.definition.ota.updateToLatest(device.zh, logger, onProgress); logger.info(`Finished update of '${device.name}'`); this.eventBus.emitReconfigure({device}); this.removeProgressAndRemainingFromState(device); - const payload = this.getEntityPublishPayload('idle'); + const payload = this.getEntityPublishPayload(device, + {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}); this.publishEntityState(device, payload); const to = await this.readSoftwareBuildIDAndDateCode(device, 'active'); const [fromS, toS] = [stringify(from_), stringify(to)]; @@ -276,7 +291,7 @@ export default class OTAUpdate extends Extension { errorStack = e.stack; this.removeProgressAndRemainingFromState(device); - const payload = this.getEntityPublishPayload('available'); + const payload = this.getEntityPublishPayload(device, 'available'); this.publishEntityState(device, payload); /* istanbul ignore else */ diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 8ce796696..cd9b49a19 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -104,6 +104,8 @@ declare global { access: number, property: string, unit?: string, value_min?: number, value_max?: number} + interface OtaUpdateAvailableResult {available: boolean, currentFileVersion: number, otaFileVersion: number} + interface Definition { model: string, zigbeeModel: string[], @@ -120,9 +122,10 @@ declare global { onEvent?: (type: string, data: KeyValue, device: zh.Device, settings: KeyValue, state: KeyValue) => Promise; ota?: { - isUpdateAvailable: (device: zh.Device, logger: Logger, data?: KeyValue) => Promise; + isUpdateAvailable: (device: zh.Device, logger: Logger, data?: KeyValue) + => Promise; updateToLatest: (device: zh.Device, logger: Logger, - onProgress: (progress: number, remaining: number) => void) => Promise; + onProgress: (progress: number, remaining: number) => void) => Promise; } } diff --git a/test/frontend.test.js b/test/frontend.test.js index df0888678..c7dc24266 100644 --- a/test/frontend.test.js +++ b/test/frontend.test.js @@ -197,7 +197,7 @@ describe('Frontend', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: "unknown"}}), + stringify({state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: -1, latest_version: -1}}), { retain: false, qos: 0 }, expect.any(Function) ); @@ -208,7 +208,7 @@ describe('Frontend', () => { // Received message on socket expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1); - expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: "unknown"}}})); + expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: -1, latest_version: -1}}})); // Shouldnt set when not ready mockWSClient.implementation.send.mockClear(); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 5c3c75665..587cacbfa 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -956,7 +956,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null, "update_available": null, "power_on_behavior":null, "update": {"state": null, "installed_version": "unknown"}}), + stringify({"color":{"hue": 0, "saturation": 100, "h": 0, "s": 100}, "color_mode": "hs", "linkquality": null, "state": null, "update_available": null, "power_on_behavior":null, "update": {"state": null, "installed_version": -1, "latest_version": -1}}), { retain: false, qos: 0 }, expect.any(Function), ); @@ -972,7 +972,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null, "update_available": null, "power_on_behavior":null, "update": {"state": null, "installed_version": "unknown"}}), + stringify({"color": {"x": 0.4576,"y": 0.41}, "color_mode": "xy", "linkquality": null,"state": null, "update_available": null, "power_on_behavior":null, "update": {"state": null, "installed_version": -1, "latest_version": -1}}), { retain: false, qos: 0 }, expect.any(Function), ); @@ -988,7 +988,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({"linkquality": null,"state": "ON", "update_available": null, "power_on_behavior": null, "update": {"state": null, "installed_version": "unknown"}}), + stringify({"linkquality": null,"state": "ON", "update_available": null, "power_on_behavior": null, "update": {"state": null, "installed_version": -1, "latest_version": -1}}), { retain: false, qos: 0 }, expect.any(Function), ); @@ -1076,13 +1076,13 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb', - stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null, "update": {"state": null, "installed_version": "unknown"}}), + stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null, "update": {"state": null, "installed_version": -1, "latest_version": -1}}), { retain: true, qos: 0 }, expect.any(Function) ); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/remote', - stringify({"action":null,"action_duration":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null, "update": {"state": null, "installed_version": "unknown"}}), + stringify({"action":null,"action_duration":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null, "update": {"state": null, "installed_version": -1, "latest_version": -1}}), { retain: true, qos: 0 }, expect.any(Function) ); @@ -1106,13 +1106,13 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb', - stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null, "update": {"state": null, "installed_version": "unknown"}}), + stringify({"state":"ON","brightness":50,"color_temp":370,"linkquality":99,"power_on_behavior":null, "update_available": null, "update": {"state": null, "installed_version": -1, "latest_version": -1}}), { retain: true, qos: 0 }, expect.any(Function) ); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/remote', - stringify({"action":null,"action_duration":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null, "update": {"state": null, "installed_version": "unknown"}}), + stringify({"action":null,"action_duration":null,"battery":null,"brightness":255,"linkquality":null, "update_available": null, "update": {"state": null, "installed_version": -1, "latest_version": -1}}), { retain: true, qos: 0 }, expect.any(Function) ); diff --git a/test/otaUpdate.test.js b/test/otaUpdate.test.js index 807ec917e..edcd0d3be 100644 --- a/test/otaUpdate.test.js +++ b/test/otaUpdate.test.js @@ -72,6 +72,7 @@ describe('OTA update', () => { mapped.ota.updateToLatest.mockImplementationOnce((a, b, onUpdate) => { onUpdate(0, null); onUpdate(10, 3600.2123); + return 90; }); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', 'bulb'); @@ -99,7 +100,7 @@ describe('OTA update', () => { ); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb', - stringify({"update_available":false,"update":{"state":"idle"}}), + stringify({"update_available":false,"update":{"state":"idle","installed_version":90,"latest_version":90}}), {retain: true, qos: 0}, expect.any(Function) ); expect(MQTT.publish).toHaveBeenCalledWith( @@ -145,7 +146,7 @@ describe('OTA update', () => { const mapped = zigbeeHerdsmanConverters.findByDevice(device) mockClear(mapped); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(false); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10}); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb"); await flushPromises(); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); @@ -157,7 +158,7 @@ describe('OTA update', () => { ); MQTT.publish.mockClear(); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(true); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12}); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check', "bulb"); await flushPromises(); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(2); @@ -250,7 +251,7 @@ describe('OTA update', () => { const data = {imageType: 12382}; const mapped = zigbeeHerdsmanConverters.findByDevice(device) mockClear(mapped); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(true); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 12}); const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10}; logger.info.mockClear(); await zigbeeHerdsman.events.message(payload); @@ -267,13 +268,13 @@ describe('OTA update', () => { expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); logger.info.mockClear(); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(false); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10}); await zigbeeHerdsman.events.message(payload); await flushPromises(); expect(logger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb', - stringify({"update_available":true,"update":{"state":"available"}}), + stringify({"update_available":true,"update":{"state":"available","installed_version":10,"latest_version":12}}), {retain: true, qos: 0}, expect.any(Function) ); }); @@ -306,7 +307,7 @@ describe('OTA update', () => { const data = {imageType: 12382}; const mapped = zigbeeHerdsmanConverters.findByDevice(device) mockClear(mapped); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(false); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 13, otaFileVersion: 13}); const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10}; logger.info.mockClear(); await zigbeeHerdsman.events.message(payload); @@ -317,7 +318,7 @@ describe('OTA update', () => { expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {"status": 0x98}); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb', - stringify({"update_available":false,"update":{"state":"idle"}}), + stringify({"update_available":false,"update":{"state":"idle","installed_version": 13, "latest_version": 13}}), {retain: true, qos: 0}, expect.any(Function) ); }); @@ -328,7 +329,7 @@ describe('OTA update', () => { const data = {imageType: 12382}; const mapped = zigbeeHerdsmanConverters.findByDevice(device) mockClear(mapped); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(true); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 10, otaFileVersion: 13}); const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10}; logger.info.mockClear(); await zigbeeHerdsman.events.message(payload); @@ -373,6 +374,7 @@ describe('OTA update', () => { mapped.ota.updateToLatest.mockImplementationOnce((a, b, onUpdate) => { onUpdate(0, null); onUpdate(10, 3600); + return 91; }); MQTT.events.message('zigbee2mqtt/bridge/ota_update/update', 'bulb'); @@ -415,7 +417,7 @@ describe('OTA update', () => { mockClear(mapped); logger.info.mockClear(); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(false); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 13, otaFileVersion: 13}); MQTT.events.message('zigbee2mqtt/bridge/ota_update/check', 'bulb'); await flushPromises(); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); @@ -423,7 +425,7 @@ describe('OTA update', () => { expect(logger.info).toHaveBeenCalledWith(`No update available for 'bulb'`); logger.info.mockClear(); - mapped.ota.isUpdateAvailable.mockReturnValueOnce(true); + mapped.ota.isUpdateAvailable.mockReturnValueOnce({available: true, currentFileVersion: 13, otaFileVersion: 15}); MQTT.events.message('zigbee2mqtt/bridge/ota_update/check', 'bulb'); await flushPromises(); expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(2);