From 58d987b52328a40b6ac92f343f3e8ceeae3f2a10 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 16 Feb 2020 16:00:15 +0100 Subject: [PATCH] Implement update_available attribute and discover Home Assistant sensor. #2948 --- lib/extension/homeassistant.js | 16 +++++++++++++++- lib/extension/otaUpdate.js | 20 ++++++++++++++++++++ test/homeassistant.test.js | 33 ++++++++++++++++++++++++++++++++- test/otaUpdate.test.js | 17 +++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.js index da0e7312d..67576252f 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -105,6 +105,15 @@ const cfg = { device_class: 'battery', }, }, + 'binary_sensor_update_available': { + type: 'binary_sensor', + object_id: 'update_available', + discovery_payload: { + payload_on: true, + payload_off: false, + value_template: '{{ value_json.update_available}}', + }, + }, 'binary_sensor_lock': { type: 'binary_sensor', object_id: 'lock', @@ -1276,7 +1285,12 @@ class HomeAssistant extends BaseExtension { return; } - mapping[mappedModel.model].forEach((config) => { + const configs = mapping[mappedModel.model].slice(); + if (mappedModel.hasOwnProperty('ota')) { + configs.push(cfg.binary_sensor_update_available); + } + + configs.forEach((config) => { const topic = this.getDiscoveryTopic(config, device); const payload = {...config.discovery_payload}; const stateTopic = `${settings.get().mqtt.base_topic}/${entity.friendlyName}`; diff --git a/lib/extension/otaUpdate.js b/lib/extension/otaUpdate.js index 975391d33..92978d48d 100644 --- a/lib/extension/otaUpdate.js +++ b/lib/extension/otaUpdate.js @@ -3,11 +3,13 @@ const logger = require('../util/logger'); const assert = require('assert'); const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/ota_update/.+$`); const BaseExtension = require('./baseExtension'); +const MINUTES_10 = 1000 * 60 * 10; class OTAUpdate extends BaseExtension { constructor(zigbee, mqtt, state, publishEntityState, eventBus) { super(zigbee, mqtt, state, publishEntityState, eventBus); this.inProgress = new Set(); + this.lastChecked = {}; } onMQTTConnected() { @@ -15,6 +17,21 @@ class OTAUpdate extends BaseExtension { this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/ota_update/update`); } + async onZigbeeEvent(type, data, mappedDevice, settingsDevice) { + if (data.type !== 'commandQueryNextImageRequest' || !mappedDevice.hasOwnProperty('ota')) return; + + // When a device does a next image request, it will usually do it a few times after each other + // with only 10 - 60 seconds inbetween. It doesn' make sense to check for a new update + // each time. + const check = this.lastChecked.hasOwnProperty(data.device.ieeeAddr) ? + (Date.now() - this.lastChecked[data.device.ieeeAddr]) > MINUTES_10 : true; + if (!check || this.inProgress.has(data.device.ieeeAddr)) return; + + this.lastChecked[data.device.ieeeAddr] = Date.now(); + const available = await mappedDevice.ota.isUpdateAvailable(data.device, logger, data.data); + this.publishEntityState(data.device.ieeeAddr, {update_available: available}); + } + async readSoftwareBuildIDAndDateCode(device, update) { try { const endpoint = device.endpoints.find((e) => e.supportsInputCluster('genBasic')); @@ -64,6 +81,8 @@ class OTAUpdate extends BaseExtension { logger.info(message); const meta = {status: available ? 'available' : 'not_available', device: device.name}; this.mqtt.log('ota_update', message, meta); + this.publishEntityState(device.device.ieeeAddr, {update_available: available}); + this.lastChecked[device.device.ieeeAddr] = Date.now(); } catch (error) { const message = `Failed to check if update available for '${device.name}' (${error.message})`; logger.error(message); @@ -92,6 +111,7 @@ class OTAUpdate extends BaseExtension { logger.info(message); const meta = {status: `update_succeeded`, device: device.name, from: from_, to}; this.mqtt.log('ota_update', message, meta); + this.publishEntityState(device.device.ieeeAddr, {update_available: false}); } catch (error) { const message = `Update of '${device.name}' failed (${error.message})`; logger.error(message); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 24834915b..e497e3e56 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -5,7 +5,6 @@ const zigbeeHerdsman = require('./stub/zigbeeHerdsman'); const flushPromises = () => new Promise(setImmediate); const MQTT = require('./stub/mqtt'); const Controller = require('../lib/controller'); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); describe('HomeAssistant extension', () => { beforeEach(async () => { @@ -688,4 +687,36 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledTimes(1); }); + + it('Should discover update_available sensor when device supports it', async () => { + controller = new Controller(false); + await controller.start(); + await flushPromises(); + const payload = { + "payload_on":true, + "payload_off":false, + "value_template":"{{ value_json.update_available}}", + "state_topic":"zigbee2mqtt/bulb", + "json_attributes_topic":"zigbee2mqtt/bulb", + "name":"bulb_update_available", + "unique_id":"0x000b57fffec6a5b2_update_available_zigbee2mqtt", + "device":{ + "identifiers":[ + "zigbee2mqtt_0x000b57fffec6a5b2" + ], + "name":"bulb", + 'sw_version': this.version, + "model":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)", + "manufacturer":"IKEA" + }, + "availability_topic":"zigbee2mqtt/bridge/state" + }; + + expect(MQTT.publish).toHaveBeenCalledWith( + 'homeassistant/binary_sensor/0x000b57fffec6a5b2/update_available/config', + JSON.stringify(payload), + { retain: true, qos: 0 }, + expect.any(Function), + ); + }); }); diff --git a/test/otaUpdate.test.js b/test/otaUpdate.test.js index b184a72b0..ad7ea1655 100644 --- a/test/otaUpdate.test.js +++ b/test/otaUpdate.test.js @@ -161,4 +161,21 @@ describe('OTA update', () => { await flushPromises(); expect(logger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`); }); + + it('Should check for update when device requests it', async () => { + const device = zigbeeHerdsman.devices.bulb; + const data = {imageType: 12382}; + const mapped = zigbeeHerdsmanConverters.findByZigbeeModel(device.modelID) + mockClear(mapped); + const payload = {data, cluster: 'genOta', device, endpoint: device.getEndpoint(1), type: 'commandQueryNextImageRequest', linkquality: 10}; + await zigbeeHerdsman.events.message(payload); + await flushPromises(); + expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); + expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledWith(device, logger, {"imageType": 12382}); + + // Should not request again when device asks again after a short time + await zigbeeHerdsman.events.message(payload); + await flushPromises(); + expect(mapped.ota.isUpdateAvailable).toHaveBeenCalledTimes(1); + }); });