From df214c2d26cf8d2918d9d4b6b15f356b458dc864 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 Aug 2022 12:49:27 -0400 Subject: [PATCH] Add support for zwave_js firmware update service (#77401) Co-authored-by: Martin Hjelmare Co-authored-by: Paulus Schoutsen --- homeassistant/components/zwave_js/__init__.py | 15 +- homeassistant/components/zwave_js/const.py | 4 + homeassistant/components/zwave_js/update.py | 190 ++++++++++++ tests/components/zwave_js/conftest.py | 2 + tests/components/zwave_js/test_fan.py | 4 + tests/components/zwave_js/test_init.py | 12 +- tests/components/zwave_js/test_update.py | 275 ++++++++++++++++++ 7 files changed, 491 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/zwave_js/update.py create mode 100644 tests/components/zwave_js/test_update.py diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 482f635da65..538fe911dd0 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -19,8 +19,6 @@ from zwave_js_server.model.notification import ( ) from zwave_js_server.model.value import Value, ValueNotification -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, @@ -28,6 +26,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_URL, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -244,7 +243,7 @@ async def setup_driver( # noqa: C901 registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict) discovered_value_ids: dict[str, set[str]] = defaultdict(set) - async def async_setup_platform(platform: str) -> None: + async def async_setup_platform(platform: Platform) -> None: """Set up platform if needed.""" if platform not in platform_setup_tasks: platform_setup_tasks[platform] = hass.async_create_task( @@ -353,17 +352,23 @@ async def setup_driver( # noqa: C901 # No need for a ping button or node status sensor for controller nodes if not node.is_controller_node: # Create a node status sensor for each device - await async_setup_platform(SENSOR_DOMAIN) + await async_setup_platform(Platform.SENSOR) async_dispatcher_send( hass, f"{DOMAIN}_{entry.entry_id}_add_node_status_sensor", node ) # Create a ping button for each device - await async_setup_platform(BUTTON_DOMAIN) + await async_setup_platform(Platform.BUTTON) async_dispatcher_send( hass, f"{DOMAIN}_{entry.entry_id}_add_ping_button_entity", node ) + # Create a firmware update entity for each device + await async_setup_platform(Platform.UPDATE) + async_dispatcher_send( + hass, f"{DOMAIN}_{entry.entry_id}_add_firmware_update_entity", node + ) + # we only want to run discovery when the node has reached ready state, # otherwise we'll have all kinds of missing info issues. if node.ready: diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 3e0bdb9c3f6..cd10109bb3d 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -120,3 +120,7 @@ ENTITY_DESC_KEY_TEMPERATURE = "temperature" ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" + +# This API key is only for use with Home Assistant. Reach out to Z-Wave JS to apply for +# your own (https://github.com/zwave-js/firmware-updates/). +API_KEY_FIRMWARE_UPDATE_SERVICE = "b48e74337db217f44e1e003abb1e9144007d260a17e2b2422e0a45d0eaf6f4ad86f2a9943f17fee6dde343941f238a64" diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py new file mode 100644 index 00000000000..d179700c724 --- /dev/null +++ b/homeassistant/components/zwave_js/update.py @@ -0,0 +1,190 @@ +"""Representation of Z-Wave updates.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +from typing import Any + +from awesomeversion import AwesomeVersion +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import NodeStatus +from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.model.driver import Driver +from zwave_js_server.model.firmware import FirmwareUpdateInfo +from zwave_js_server.model.node import Node as ZwaveNode + +from homeassistant.components.update import UpdateDeviceClass, UpdateEntity +from homeassistant.components.update.const import UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER +from .helpers import get_device_id, get_valueless_base_unique_id + +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(days=1) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave button from config entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_firmware_update_entity(node: ZwaveNode) -> None: + """Add firmware update entity.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + async_add_entities([ZWaveNodeFirmwareUpdate(driver, node)], True) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_firmware_update_entity", + async_add_firmware_update_entity, + ) + ) + + +class ZWaveNodeFirmwareUpdate(UpdateEntity): + """Representation of a firmware update entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.RELEASE_NOTES + ) + _attr_has_entity_name = True + + def __init__(self, driver: Driver, node: ZwaveNode) -> None: + """Initialize a Z-Wave device firmware update entity.""" + self.driver = driver + self.node = node + self.available_firmware_updates: list[FirmwareUpdateInfo] = [] + self._latest_version_firmware: FirmwareUpdateInfo | None = None + self._status_unsub: Callable[[], None] | None = None + + # Entity class attributes + self._attr_name = "Firmware" + self._base_unique_id = get_valueless_base_unique_id(driver, node) + self._attr_unique_id = f"{self._base_unique_id}.firmware_update" + # device may not be precreated in main handler yet + self._attr_device_info = DeviceInfo( + identifiers={get_device_id(driver, node)}, + sw_version=node.firmware_version, + name=node.name or node.device_config.description or f"Node {node.node_id}", + model=node.device_config.label, + manufacturer=node.device_config.manufacturer, + suggested_area=node.location if node.location else None, + ) + + self._attr_installed_version = self._attr_latest_version = node.firmware_version + + def _update_on_wake_up(self, _: dict[str, Any]) -> None: + """Update the entity when node is awake.""" + self._status_unsub = None + self.hass.async_create_task(self.async_update(True)) + + async def async_update(self, write_state: bool = False) -> None: + """Update the entity.""" + if self.node.status == NodeStatus.ASLEEP: + if not self._status_unsub: + self._status_unsub = self.node.once("wake up", self._update_on_wake_up) + return + self.available_firmware_updates = ( + await self.driver.controller.async_get_available_firmware_updates( + self.node, API_KEY_FIRMWARE_UPDATE_SERVICE + ) + ) + self._async_process_available_updates(write_state) + + @callback + def _async_process_available_updates(self, write_state: bool = True) -> None: + """ + Process available firmware updates. + + Sets latest version attribute and FirmwareUpdateInfo instance. + """ + # If we have an available firmware update that is a higher version than what's + # on the node, we should advertise it, otherwise we are on the latest version + if self.available_firmware_updates and AwesomeVersion( + ( + firmware := max( + self.available_firmware_updates, + key=lambda x: AwesomeVersion(x.version), + ) + ).version + ) > AwesomeVersion(self.node.firmware_version): + self._latest_version_firmware = firmware + self._attr_latest_version = firmware.version + else: + self._latest_version_firmware = None + self._attr_latest_version = self._attr_installed_version + if write_state: + self.async_write_ha_state() + + async def async_release_notes(self) -> str | None: + """Get release notes.""" + if self._latest_version_firmware is None: + return None + return self._latest_version_firmware.changelog + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + firmware = self._latest_version_firmware + assert firmware + self._attr_in_progress = True + self.async_write_ha_state() + try: + for file in firmware.files: + await self.driver.controller.async_begin_ota_firmware_update( + self.node, file + ) + except BaseZwaveJSServerError as err: + raise HomeAssistantError(err) from err + else: + self._attr_installed_version = firmware.version + self.available_firmware_updates.remove(firmware) + self._async_process_available_updates() + finally: + self._attr_in_progress = False + + async def async_poll_value(self, _: bool) -> None: + """Poll a value.""" + LOGGER.error( + "There is no value to refresh for this entity so the zwave_js.refresh_value " + "service won't work for it" + ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed.""" + if self._status_unsub: + self._status_unsub() + self._status_unsub = None diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1524aca719e..7131b1ade69 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -844,6 +844,8 @@ async def integration_fixture(hass, client): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + client.async_send_command.reset_mock() + return entry diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 140d9fb3d83..27e300286d0 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -513,6 +513,8 @@ async def test_thermostat_fan(hass, client, climate_adc_t3000, integration): await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() + client.async_send_command.reset_mock() + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON @@ -774,6 +776,8 @@ async def test_thermostat_fan_without_off( await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() + client.async_send_command.reset_mock() + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 202088bb481..57f552c9502 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -211,8 +211,8 @@ async def test_on_node_added_not_ready( client.driver.receive_event(event) await hass.async_block_till_done() - # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 2 + # the only entities are the node status sensor, ping button, and firmware update + assert len(hass.states.async_all()) == 3 device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -254,8 +254,8 @@ async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integrati assert not device.model assert not device.sw_version - # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 2 + # the only entities are the node status sensor, ping button, and firmware update + assert len(hass.states.async_all()) == 3 device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -817,7 +817,7 @@ async def test_removed_device( # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 29 + assert len(entity_entries) == 31 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -829,7 +829,7 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 1 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 17 + assert len(entity_entries) == 18 assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py new file mode 100644 index 00000000000..852dcba5954 --- /dev/null +++ b/tests/components/zwave_js/test_update.py @@ -0,0 +1,275 @@ +"""Test the Z-Wave JS update entities.""" +from datetime import timedelta + +import pytest +from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand + +from homeassistant.components.update.const import ( + ATTR_AUTO_UPDATE, + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + ATTR_RELEASE_URL, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE +from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import datetime as dt_util + +from tests.common import async_fire_time_changed + +UPDATE_ENTITY = "update.z_wave_thermostat_firmware" + + +async def test_update_entity_success( + hass, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + controller_node, + integration, + caplog, + hass_ws_client, +): + """Test update entity.""" + ws_client = await hass_ws_client(hass) + await hass.async_block_till_done() + + assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + + client.async_send_command.return_value = {"updates": []} + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] is None + + client.async_send_command.return_value = { + "updates": [ + { + "version": "10.11.1", + "changelog": "blah 1", + "files": [ + {"target": 0, "url": "https://example1.com", "integrity": "sha1"} + ], + }, + { + "version": "11.2.4", + "changelog": "blah 2", + "files": [ + {"target": 0, "url": "https://example2.com", "integrity": "sha2"} + ], + }, + { + "version": "11.1.5", + "changelog": "blah 3", + "files": [ + {"target": 0, "url": "https://example3.com", "integrity": "sha3"} + ], + }, + ] + } + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2)) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_ON + attrs = state.attributes + assert not attrs[ATTR_AUTO_UPDATE] + assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == "11.2.4" + assert attrs[ATTR_RELEASE_URL] is None + + await ws_client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] == "blah 2" + + # Refresh value should not be supported by this entity + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + + assert "There is no value to refresh for this entity" in caplog.text + + # Assert a node firmware update entity is not created for the controller + driver = client.driver + node = driver.controller.nodes[1] + assert node.is_controller_node + assert ( + async_get(hass).async_get_entity_id( + DOMAIN, + "sensor", + f"{get_valueless_base_unique_id(driver, node)}.firmware_update", + ) + is None + ) + + client.async_send_command.reset_mock() + + # Test successful install call without a version + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "controller.begin_ota_firmware_update" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + assert args["update"] == { + "target": 0, + "url": "https://example2.com", + "integrity": "sha2", + } + + client.async_send_command.reset_mock() + + +async def test_update_entity_failure( + hass, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + controller_node, + integration, + caplog, + hass_ws_client, +): + """Test update entity failed install.""" + client.async_send_command.return_value = { + "updates": [ + { + "version": "10.11.1", + "changelog": "blah 1", + "files": [ + {"target": 0, "url": "https://example1.com", "integrity": "sha1"} + ], + }, + { + "version": "11.2.4", + "changelog": "blah 2", + "files": [ + {"target": 0, "url": "https://example2.com", "integrity": "sha2"} + ], + }, + { + "version": "11.1.5", + "changelog": "blah 3", + "files": [ + {"target": 0, "url": "https://example3.com", "integrity": "sha3"} + ], + }, + ] + } + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + await hass.async_block_till_done() + + # Test failed installation by driver + client.async_send_command.side_effect = FailedZWaveCommand("test", 12, "test") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + + +async def test_update_entity_sleep( + hass, + client, + multisensor_6, + integration, +): + """Test update occurs when device is asleep after it wakes up.""" + event = Event( + "sleep", + data={"source": "node", "event": "sleep", "nodeId": multisensor_6.node_id}, + ) + multisensor_6.receive_event(event) + client.async_send_command.reset_mock() + + client.async_send_command.return_value = { + "updates": [ + { + "version": "10.11.1", + "changelog": "blah 1", + "files": [ + {"target": 0, "url": "https://example1.com", "integrity": "sha1"} + ], + }, + { + "version": "11.2.4", + "changelog": "blah 2", + "files": [ + {"target": 0, "url": "https://example2.com", "integrity": "sha2"} + ], + }, + { + "version": "11.1.5", + "changelog": "blah 3", + "files": [ + {"target": 0, "url": "https://example3.com", "integrity": "sha3"} + ], + }, + ] + } + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + await hass.async_block_till_done() + + # Because node is asleep we shouldn't attempt to check for firmware updates + assert len(client.async_send_command.call_args_list) == 0 + + event = Event( + "wake up", + data={"source": "node", "event": "wake up", "nodeId": multisensor_6.node_id}, + ) + multisensor_6.receive_event(event) + await hass.async_block_till_done() + + # Now that the node is up we can check for updates + assert len(client.async_send_command.call_args_list) > 0 + + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == multisensor_6.node_id