From 544975088ff191c0aed11b94994919a04a9ee303 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 21 Jul 2024 11:49:01 +0200 Subject: [PATCH] feat: Support OTA update/check all devices --- lib/extension/otaUpdate.ts | 219 ++++++++++++++++++++----------------- test/otaUpdate.test.js | 42 +++++++ 2 files changed, 160 insertions(+), 101 deletions(-) diff --git a/lib/extension/otaUpdate.ts b/lib/extension/otaUpdate.ts index 0a4cab8ce3..68782f2bd2 100644 --- a/lib/extension/otaUpdate.ts +++ b/lib/extension/otaUpdate.ts @@ -37,7 +37,7 @@ interface UpdatePayload { } const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`); -const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check)`, 'i'); +const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|check_all|update_all)`, 'i'); export default class OTAUpdate extends Extension { private inProgress = new Set(); @@ -173,142 +173,159 @@ export default class OTAUpdate extends Extension { return null; } + const type = data.topic.substring(data.topic.lastIndexOf('/') + 1); + const forAll = type.endsWith('_all'); const message = utils.parseJSON(data.message, data.message); const ID = (typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message) as string; - const device = this.zigbee.resolveEntity(ID); - const type = data.topic.substring(data.topic.lastIndexOf('/') + 1); - const responseData: {id: string; updateAvailable?: boolean; from?: string; to?: string} = {id: ID}; + const responseDataSingle: {id: string; updateAvailable?: boolean; from?: string; to?: string} = {id: ID}; let error = null; let errorStack = null; - if (!(device instanceof Device)) { - error = `Device '${ID}' does not exist`; - } else if (!device.definition || !device.definition.ota) { - error = `Device '${device.name}' does not support OTA updates`; - - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - const meta = {status: `not_supported`, device: device.name}; - await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta})); - } - } else if (this.inProgress.has(device.ieeeAddr)) { - error = `Update or check for update already in progress for '${device.name}'`; + let devices: Device[] = []; + if (forAll) { + devices = this.zigbee.devices(false).filter((d) => d.definition?.ota); } else { - this.inProgress.add(device.ieeeAddr); - - if (type === 'check') { - const msg = `Checking if update available for '${device.name}'`; - logger.info(msg); + const device = this.zigbee.resolveEntity(ID); + if (!(device instanceof Device)) { + error = `Device '${ID}' does not exist`; + } else if (!device.definition || !device.definition.ota) { + error = `Device '${device.name}' does not support OTA updates`; /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const meta = {status: `checking_if_available`, device: device.name}; - await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta})); + const meta = {status: `not_supported`, device: device.name}; + await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta})); } + } else { + devices.push(device); + } + } + + for (const device of devices) { + if (this.inProgress.has(device.ieeeAddr)) { + const message = `Update or check for update already in progress for '${device.name}'`; + if (forAll) { + logger.warning(message); + } else { + error = message; + } + } else { + this.inProgress.add(device.ieeeAddr); - try { - const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, null); - const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`; + if (type === 'check') { + const msg = `Checking if update available for '${device.name}'`; logger.info(msg); /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const meta = { - status: availableResult.available ? 'available' : 'not_available', - device: device.name, - }; + const meta = {status: `checking_if_available`, device: device.name}; await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta})); } - const payload = this.getEntityPublishPayload(device, availableResult); - await this.publishEntityState(device, payload); - this.lastChecked[device.ieeeAddr] = Date.now(); - responseData.updateAvailable = availableResult.available; - } catch (e) { - error = `Failed to check if update available for '${device.name}' (${e.message})`; - errorStack = e.stack; + try { + const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, null); + 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: availableResult.available ? 'available' : 'not_available', + device: device.name, + }; + await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta})); + } + + const payload = this.getEntityPublishPayload(device, availableResult); + await this.publishEntityState(device, payload); + this.lastChecked[device.ieeeAddr] = Date.now(); + responseData.updateAvailable = availableResult.available; + } catch (e) { + error = `Failed to check if update available for '${device.name}' (${e.message})`; + errorStack = e.stack; + + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + const meta = {status: `check_failed`, device: device.name}; + await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta})); + } + } + } else { + // type === 'update' + const msg = `Updating '${device.name}' to latest firmware`; + logger.info(msg); /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const meta = {status: `check_failed`, device: device.name}; - await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta})); + const meta = {status: `update_in_progress`, device: device.name}; + await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta})); } - } - } else { - // type === 'update' - const msg = `Updating '${device.name}' to latest firmware`; - logger.info(msg); - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - const meta = {status: `update_in_progress`, device: device.name}; - await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta})); - } + try { + const onProgress = async (progress: number, remaining: number): Promise => { + let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`; + if (remaining) { + msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`; + } - try { - const onProgress = async (progress: number, remaining: number): Promise => { - let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`; - if (remaining) { - msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`; - } + logger.info(msg); - logger.info(msg); + const payload = this.getEntityPublishPayload(device, 'updating', progress, remaining); + await this.publishEntityState(device, payload); - const payload = this.getEntityPublishPayload(device, 'updating', progress, remaining); + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + const meta = {status: `update_progress`, device: device.name, progress}; + await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta})); + } + }; + + const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate'); + const fileVersion = await device.definition.ota.updateToLatest(device.zh, onProgress); + logger.info(`Finished update of '${device.name}'`); + this.removeProgressAndRemainingFromState(device); + const payload = this.getEntityPublishPayload(device, { + available: false, + currentFileVersion: fileVersion, + otaFileVersion: fileVersion, + }); await this.publishEntityState(device, payload); + const to = await this.readSoftwareBuildIDAndDateCode(device); + const [fromS, toS] = [stringify(from_), stringify(to)]; + logger.info(`Device '${device.name}' was updated from '${fromS}' to '${toS}'`); + responseData.from = from_ ? utils.toSnakeCase(from_) : null; + responseData.to = to ? utils.toSnakeCase(to) : null; + /** + * Re-configure after reading software build ID and date code, some devices use a + * custom attribute for this (e.g. Develco SMSZB-120) + */ + this.eventBus.emitReconfigure({device}); + this.eventBus.emitDevicesChanged(); /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const meta = {status: `update_progress`, device: device.name, progress}; - await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: msg, meta})); + const meta = {status: `update_succeeded`, device: device.name, from: from_, to}; + await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta})); } - }; - - const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate'); - const fileVersion = await device.definition.ota.updateToLatest(device.zh, onProgress); - logger.info(`Finished update of '${device.name}'`); - this.removeProgressAndRemainingFromState(device); - const payload = this.getEntityPublishPayload(device, { - available: false, - currentFileVersion: fileVersion, - otaFileVersion: fileVersion, - }); - await this.publishEntityState(device, payload); - const to = await this.readSoftwareBuildIDAndDateCode(device); - const [fromS, toS] = [stringify(from_), stringify(to)]; - logger.info(`Device '${device.name}' was updated from '${fromS}' to '${toS}'`); - responseData.from = from_ ? utils.toSnakeCase(from_) : null; - responseData.to = to ? utils.toSnakeCase(to) : null; - /** - * Re-configure after reading software build ID and date code, some devices use a - * custom attribute for this (e.g. Develco SMSZB-120) - */ - this.eventBus.emitReconfigure({device}); - this.eventBus.emitDevicesChanged(); + } catch (e) { + logger.debug(`Update of '${device.name}' failed (${e})`); + error = `Update of '${device.name}' failed (${e.message})`; + errorStack = e.stack; - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - const meta = {status: `update_succeeded`, device: device.name, from: from_, to}; - await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message, meta})); - } - } catch (e) { - logger.debug(`Update of '${device.name}' failed (${e})`); - error = `Update of '${device.name}' failed (${e.message})`; - errorStack = e.stack; - - this.removeProgressAndRemainingFromState(device); - const payload = this.getEntityPublishPayload(device, 'available'); - await this.publishEntityState(device, payload); + this.removeProgressAndRemainingFromState(device); + const payload = this.getEntityPublishPayload(device, 'available'); + await this.publishEntityState(device, payload); - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - const meta = {status: `update_failed`, device: device.name}; - await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta})); + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + const meta = {status: `update_failed`, device: device.name}; + await this.mqtt.publish('bridge/log', stringify({type: `ota_update`, message: error, meta})); + } } } - } - this.inProgress.delete(device.ieeeAddr); + this.inProgress.delete(device.ieeeAddr); + } } const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex); diff --git a/test/otaUpdate.test.js b/test/otaUpdate.test.js index 58a3098d7b..6fabaab32e 100644 --- a/test/otaUpdate.test.js +++ b/test/otaUpdate.test.js @@ -12,6 +12,12 @@ const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters'); const stringify = require('json-stable-stringify-without-jsonify'); const zigbeeOTA = require('zigbee-herdsman-converters/lib/ota/zigbeeOTA'); +zigbeeHerdsman.returnDevices.push(zigbeeHerdsman.devices.coordinator.ieeeAddr); +zigbeeHerdsman.returnDevices.push(zigbeeHerdsman.devices.bulb.ieeeAddr); +zigbeeHerdsman.returnDevices.push(zigbeeHerdsman.devices.bulb_color.ieeeAddr); +zigbeeHerdsman.returnDevices.push(zigbeeHerdsman.devices.HGZB04D.ieeeAddr); +zigbeeHerdsman.returnDevices.push(zigbeeHerdsman.devices.SV01.ieeeAddr); + const spyUseIndexOverride = jest.spyOn(zigbeeOTA, 'useIndexOverride'); describe('OTA update', () => { @@ -181,6 +187,42 @@ describe('OTA update', () => { expect.any(Function), ); }); + + it('onlythis Should be able to check if OTA update is available for all devices', async () => { + const bulb = await zigbeeHerdsmanConverters.findByDevice(zigbeeHerdsman.devices.bulb); + const bulb_color = await zigbeeHerdsmanConverters.findByDevice(zigbeeHerdsman.devices.bulb_color); + mockClear(bulb); + mockClear(bulb_color); + + bulb.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10}); + bulb_color.ota.isUpdateAvailable.mockReturnValueOnce({available: false, currentFileVersion: 10, otaFileVersion: 10}); + + MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/check_all', ''); + await flushPromises(); + expect(bulb.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); + expect(bulb.ota.updateToLatest).toHaveBeenCalledTimes(0); + expect(bulb_color.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); + expect(bulb_color.ota.updateToLatest).toHaveBeenCalledTimes(0); + // expect(MQTT.publish).toHaveBeenCalledWith( + // 'zigbee2mqtt/bridge/response/device/ota_update/check', + // stringify({data: {id: 'bulb', updateAvailable: false}, status: 'ok'}), + // {retain: false, qos: 0}, + // expect.any(Function), + // ); + + // MQTT.publish.mockClear(); + // 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); + // expect(mapped.ota.updateToLatest).toHaveBeenCalledTimes(0); + // expect(MQTT.publish).toHaveBeenCalledWith( + // 'zigbee2mqtt/bridge/response/device/ota_update/check', + // stringify({data: {id: 'bulb', updateAvailable: true}, status: 'ok'}), + // {retain: false, qos: 0}, + // expect.any(Function), + // ); + }); it('Should handle if OTA update check fails', async () => { const device = zigbeeHerdsman.devices.bulb;