diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.js index e4c01e610..febabd400 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -166,6 +166,14 @@ const cfg = { }, // Sensor + 'sensor_update_state': { + type: 'sensor', + object_id: 'update_available', + discovery_payload: { + icon: 'mdi:update', + value_template: `{{ value_json['update']['state'] }}`, + }, + }, 'sensor_illuminance': { type: 'sensor', object_id: 'illuminance', @@ -1825,6 +1833,8 @@ class HomeAssistant extends Extension { // A map of all discoverd devices this.discovered = {}; this.discoveredTriggers = {}; + this.legacyApi = settings.get().advanced.legacy_api; + this.newApi = settings.get().experimental.new_api; if (!settings.get().advanced.cache_state) { logger.warn('In order for HomeAssistant integration to work properly set `cache_state: true'); @@ -1941,12 +1951,18 @@ class HomeAssistant extends Extension { * Publish a value for update_available (if not there yet) to prevent Home Assistant generating warnings of * this value not being available. */ + const supportsOTA = data.entity.definition && data.entity.definition.hasOwnProperty('ota'); const mockedValues = [ { property: 'update_available', - condition: data.entity.device && data.entity.definition && data.entity.definition.hasOwnProperty('ota'), + condition: supportsOTA && this.legacyApi, value: false, }, + { + property: 'update', + condition: supportsOTA && this.newApi, + value: {state: 'idle'}, + }, { property: 'water_leak', condition: data.entity.device && data.entity.definition && mapping[data.entity.definition.model] && @@ -1985,7 +2001,13 @@ class HomeAssistant extends Extension { configs.push(cfg.sensor_linkquality); if (mappedModel.hasOwnProperty('ota')) { - configs.push(cfg.binary_sensor_update_available); + if (this.legacyApi) { + configs.push(cfg.binary_sensor_update_available); + } + + if (this.newApi) { + configs.push(cfg.sensor_update_state); + } } if (deviceSettings && deviceSettings.hasOwnProperty('legacy') && !deviceSettings.legacy) { diff --git a/lib/extension/otaUpdate.js b/lib/extension/otaUpdate.js index 0be29313c..383b65a00 100644 --- a/lib/extension/otaUpdate.js +++ b/lib/extension/otaUpdate.js @@ -28,6 +28,17 @@ class OTAUpdate extends Extension { this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/check`); this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/update`); } + + for (const device of this.zigbee.getClients()) { + // In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state. + // remove them. + this.removeProgressAndRemainingFromState(device); + } + } + + removeProgressAndRemainingFromState(device) { + this.state.removeKey(device.ieeeAddr, ['update', 'progress']); + this.state.removeKey(device.ieeeAddr, ['update', 'remaining']); } async onZigbeeEvent(type, data, resolvedEntity) { @@ -44,7 +55,8 @@ class OTAUpdate extends Extension { this.lastChecked[data.device.ieeeAddr] = Date.now(); const available = await resolvedEntity.definition.ota.isUpdateAvailable(data.device, logger, data.data); - this.publishEntityState(data.device.ieeeAddr, {update_available: available}); + const payload = this.getEntityPublishPayload(available ? 'available' : 'idle'); + this.publishEntityState(data.device.ieeeAddr, payload); if (available) { const message = `Update available for '${resolvedEntity.settings.friendly_name}'`; @@ -88,6 +100,24 @@ class OTAUpdate extends Extension { } } + getEntityPublishPayload(state, progress=null, remaining=null) { + const payload = {}; + + /* istanbul ignore else */ + if (this.legacyApi) { + payload.update_available = state === 'available'; + } + + /* istanbul ignore else */ + if (settings.get().experimental.new_api) { + payload.update = {state}; + if (progress !== null) payload.update.progress = progress; + if (remaining !== null) payload.update.remaining = Math.round(remaining); + } + + return payload; + } + async onMQTTMessage(topic, message) { if ((!this.legacyApi || !topic.match(legacyTopicRegex)) && !topic.match(topicRegex)) { return null; @@ -147,7 +177,8 @@ class OTAUpdate extends Extension { ); } - this.publishEntityState(resolvedEntity.device.ieeeAddr, {update_available: available}); + const payload = this.getEntityPublishPayload(available ? 'available' : 'idle'); + this.publishEntityState(resolvedEntity.device.ieeeAddr, payload); this.lastChecked[resolvedEntity.device.ieeeAddr] = Date.now(); responseData.updateAvailable = available; } catch (e) { @@ -184,6 +215,9 @@ class OTAUpdate extends Extension { logger.info(msg); + const payload = this.getEntityPublishPayload('updating', progress, remaining); + this.publishEntityState(resolvedEntity.device.ieeeAddr, payload); + /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { const meta = {status: `update_progress`, device: resolvedEntity.name, progress}; @@ -198,7 +232,9 @@ class OTAUpdate extends Extension { const msg = `Finished update of '${resolvedEntity.name}'` + (to ? `, from '${fromS}' to '${toS}'` : ``); logger.info(msg); - this.publishEntityState(resolvedEntity.device.ieeeAddr, {update_available: false}); + this.removeProgressAndRemainingFromState(resolvedEntity.device); + const payload = this.getEntityPublishPayload('idle'); + this.publishEntityState(resolvedEntity.device.ieeeAddr, payload); responseData.from = from_ ? utils.toSnakeCase(from_) : null; responseData.to = to ? utils.toSnakeCase(to) : null; @@ -210,6 +246,10 @@ class OTAUpdate extends Extension { } catch (e) { error = `Update of '${resolvedEntity.name}' failed (${e.message})`; + this.removeProgressAndRemainingFromState(resolvedEntity.device); + const payload = this.getEntityPublishPayload('available'); + this.publishEntityState(resolvedEntity.device.ieeeAddr, payload); + /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { const meta = {status: `update_failed`, device: resolvedEntity.name}; diff --git a/lib/state.js b/lib/state.js index fef5dda70..02ce3d314 100644 --- a/lib/state.js +++ b/lib/state.js @@ -87,6 +87,24 @@ class State { this.eventBus.emit('stateChange', {ID, from: fromState, to: toState, reason}); } + removeKey(ID, path) { + if (this.exists(ID)) { + let state = this.state[ID]; + for (let i = 0; i < path.length; i++) { + const key = path[i]; + if (i === path.length - 1) { + delete state[key]; + } else { + if (state[key]) { + state = state[key]; + } else { + break; + } + } + } + } + } + remove(ID) { if (this.exists(ID)) { delete this.state[ID]; diff --git a/test/otaUpdate.test.js b/test/otaUpdate.test.js index 3de6813ed..090747569 100644 --- a/test/otaUpdate.test.js +++ b/test/otaUpdate.test.js @@ -47,7 +47,7 @@ describe('OTA update', () => { device.save.mockClear(); mapped.ota.updateToLatest.mockImplementationOnce((a, b, onUpdate) => { onUpdate(0, null); - onUpdate(10, 3600); + onUpdate(10, 3600.2123); }); MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', 'bulb'); @@ -62,6 +62,21 @@ describe('OTA update', () => { expect(device.save).toHaveBeenCalledTimes(1); expect(device.dateCode).toBe('20190102'); expect(device.softwareBuildID).toBe(2); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bulb', + JSON.stringify({"update_available":false,"update":{"state":"updating","progress":0}}), + {retain: true, qos: 0}, expect.any(Function) + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bulb', + JSON.stringify({"update_available":false,"update":{"state":"updating","progress":10,"remaining":3600}}), + {retain: true, qos: 0}, expect.any(Function) + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bulb', + JSON.stringify({"update_available":false,"update":{"state":"idle"}}), + {retain: true, qos: 0}, expect.any(Function) + ); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/device/ota_update/update', JSON.stringify({"data":{"id": "bulb","from":{"software_build_id":1,"date_code":"20190101"},"to":{"software_build_id":2,"date_code":"20190102"}},"status":"ok"}), @@ -82,6 +97,11 @@ describe('OTA update', () => { MQTT.events.message('zigbee2mqtt/bridge/request/device/ota_update/update', JSON.stringify({id: "bulb"})); await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bulb', + JSON.stringify({"update_available":true,"update":{"state":"available"}}), + {retain: true, qos: 0}, expect.any(Function) + ); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/device/ota_update/update', JSON.stringify({"data":{"id": "bulb"},"status":"error","error":"Update of 'bulb' failed (Update failed)"}), @@ -222,7 +242,12 @@ describe('OTA update', () => { mapped.ota.isUpdateAvailable.mockReturnValueOnce(false); await zigbeeHerdsman.events.message(payload); await flushPromises(); - expect(logger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`) + expect(logger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bulb', + JSON.stringify({"update_available":true,"update":{"state":"available"}}), + {retain: true, qos: 0}, expect.any(Function) + ); }); it('Should respond with NO_IMAGE_AVAILABLE when not supporting OTA', async () => {