From 27413cee19cb587ac7993f356e8338666c13ecc5 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 4 Oct 2022 10:40:49 -0400 Subject: [PATCH] Bump zwave_js lib to 0.43.0 and fix multi-file firmware updates (#79342) --- homeassistant/components/zwave_js/api.py | 31 +-- .../components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/update.py | 77 +++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 47 +++-- tests/components/zwave_js/test_update.py | 182 +++--------------- 7 files changed, 111 insertions(+), 232 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 7ceca062ee4..4a5b233a2f0 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import dataclasses from functools import partial, wraps -from typing import Any, Literal, cast +from typing import Any, Literal from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -27,7 +27,7 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) -from zwave_js_server.firmware import begin_firmware_update +from zwave_js_server.firmware import update_firmware from zwave_js_server.model.controller import ( ControllerStatistics, InclusionGrant, @@ -36,8 +36,9 @@ from zwave_js_server.model.controller import ( ) from zwave_js_server.model.driver import Driver from zwave_js_server.model.firmware import ( - FirmwareUpdateFinished, + FirmwareUpdateData, FirmwareUpdateProgress, + FirmwareUpdateResult, ) from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage @@ -1897,11 +1898,14 @@ async def websocket_is_node_firmware_update_in_progress( def _get_firmware_update_progress_dict( progress: FirmwareUpdateProgress, -) -> dict[str, int]: +) -> dict[str, int | float]: """Get a dictionary of firmware update progress.""" return { + "current_file": progress.current_file, + "total_files": progress.total_files, "sent_fragments": progress.sent_fragments, "total_fragments": progress.total_fragments, + "progress": progress.progress, } @@ -1943,14 +1947,16 @@ async def websocket_subscribe_firmware_update_status( @callback def forward_finished(event: dict) -> None: - finished: FirmwareUpdateFinished = event["firmware_update_finished"] + finished: FirmwareUpdateResult = event["firmware_update_finished"] connection.send_message( websocket_api.event_message( msg[ID], { "event": event["event"], "status": finished.status, + "success": finished.success, "wait_time": finished.wait_time, + "reinterview": finished.reinterview, }, ) ) @@ -2052,21 +2058,20 @@ class FirmwareUploadView(HomeAssistantView): if "file" not in data or not isinstance(data["file"], web_request.FileField): raise web_exceptions.HTTPBadRequest - target = None - if "target" in data: - target = int(cast(str, data["target"])) - uploaded_file: web_request.FileField = data["file"] try: - await begin_firmware_update( + await update_firmware( node.client.ws_server_url, node, - uploaded_file.filename, - await hass.async_add_executor_job(uploaded_file.file.read), + [ + FirmwareUpdateData( + uploaded_file.filename, + await hass.async_add_executor_job(uploaded_file.file.read), + ) + ], async_get_clientsession(hass), additional_user_agent_components=USER_AGENT, - target=target, ) except BaseZwaveJSServerError as err: raise web_exceptions.HTTPBadRequest(reason=str(err)) from err diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 8f0c93f6c3e..5b085ab0bb3 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.42.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.43.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 52c7e0d46e1..0c458d6e1a8 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import datetime, timedelta -from math import floor from typing import Any from awesomeversion import AwesomeVersion @@ -13,10 +12,9 @@ from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand from zwave_js_server.model.driver import Driver from zwave_js_server.model.firmware import ( - FirmwareUpdateFinished, FirmwareUpdateInfo, FirmwareUpdateProgress, - FirmwareUpdateStatus, + FirmwareUpdateResult, ) from zwave_js_server.model.node import Node as ZwaveNode @@ -91,9 +89,8 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._poll_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None self._finished_unsub: Callable[[], None] | None = None - self._num_files_installed: int = 0 self._finished_event = asyncio.Event() - self._finished_status: FirmwareUpdateStatus | None = None + self._result: FirmwareUpdateResult | None = None # Entity class attributes self._attr_name = "Firmware" @@ -115,25 +112,14 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): progress: FirmwareUpdateProgress = event["firmware_update_progress"] if not self._latest_version_firmware: return - # We will assume that each file in the firmware update represents an equal - # percentage of the overall progress. This is likely not true because each file - # may be a different size, but it's the best we can do since we don't know the - # total number of fragments across all files. - self._attr_in_progress = floor( - 100 - * ( - self._num_files_installed - + (progress.sent_fragments / progress.total_fragments) - ) - / len(self._latest_version_firmware.files) - ) + self._attr_in_progress = int(progress.progress) self.async_write_ha_state() @callback def _update_finished(self, event: dict[str, Any]) -> None: """Update install progress on event.""" - finished: FirmwareUpdateFinished = event["firmware_update_finished"] - self._finished_status = finished.status + result: FirmwareUpdateResult = event["firmware_update_finished"] + self._result = result self._finished_event.set() @callback @@ -149,10 +135,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._finished_unsub() self._finished_unsub = None - self._finished_status = None + self._result = None self._finished_event.clear() - self._num_files_installed = 0 - self._attr_in_progress = 0 + self._attr_in_progress = False if write_state: self.async_write_ha_state() @@ -235,41 +220,23 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): "firmware update finished", self._update_finished ) - for file in firmware.files: - try: - await self.driver.controller.async_begin_ota_firmware_update( - self.node, file - ) - except BaseZwaveJSServerError as err: - self._unsub_firmware_events_and_reset_progress() - raise HomeAssistantError(err) from err - - # We need to block until we receive the `firmware update finished` event - await self._finished_event.wait() - # Clear the event so that a second firmware update blocks again - self._finished_event.clear() - assert self._finished_status is not None - - # If status is not OK, we should throw an error to let the user know - if self._finished_status not in ( - FirmwareUpdateStatus.OK_NO_RESTART, - FirmwareUpdateStatus.OK_RESTART_PENDING, - FirmwareUpdateStatus.OK_WAITING_FOR_ACTIVATION, - ): - status = self._finished_status - self._unsub_firmware_events_and_reset_progress() - raise HomeAssistantError(status.name.replace("_", " ").title()) - - # If we get here, the firmware installation was successful and we need to - # update progress accordingly - self._num_files_installed += 1 - self._attr_in_progress = floor( - 100 * self._num_files_installed / len(firmware.files) + try: + await self.driver.controller.async_firmware_update_ota( + self.node, firmware.files ) + except BaseZwaveJSServerError as err: + self._unsub_firmware_events_and_reset_progress() + raise HomeAssistantError(err) from err - # Clear the status so we can get a new one - self._finished_status = None - self.async_write_ha_state() + # We need to block until we receive the `firmware update finished` event + await self._finished_event.wait() + assert self._result is not None + + # If the update was not successful, we should throw an error to let the user know + if not self._result.success: + error_msg = self._result.status.name.replace("_", " ").title() + self._unsub_firmware_events_and_reset_progress() + raise HomeAssistantError(error_msg) # If we get here, all files were installed successfully self._attr_installed_version = self._attr_latest_version = firmware.version diff --git a/requirements_all.txt b/requirements_all.txt index 7bc44ebacf3..08537c10e56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2622,7 +2622,7 @@ zigpy==0.51.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.42.0 +zwave-js-server-python==0.43.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1692d9380b..271a40151fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1814,7 +1814,7 @@ zigpy-znp==0.9.0 zigpy==0.51.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.42.0 +zwave-js-server-python==0.43.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.6 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index b55f4941a49..caea283e25c 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -28,6 +28,7 @@ from zwave_js_server.model.controller import ( ProvisioningEntry, QRProvisioningInformation, ) +from zwave_js_server.model.firmware import FirmwareUpdateData from zwave_js_server.model.node import Node from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND @@ -2815,18 +2816,20 @@ async def test_firmware_upload_view( client = await hass_client() device = get_device(hass, multisensor_6) with patch( - "homeassistant.components.zwave_js.api.begin_firmware_update", + "homeassistant.components.zwave_js.api.update_firmware", ) as mock_cmd, patch.dict( "homeassistant.components.zwave_js.api.USER_AGENT", {"HomeAssistant": "0.0.0"}, ): resp = await client.post( f"/api/zwave_js/firmware/upload/{device.id}", - data={"file": firmware_file, "target": "15"}, + data={"file": firmware_file}, + ) + assert mock_cmd.call_args[0][1:3] == ( + multisensor_6, + [FirmwareUpdateData("file", bytes(10))], ) - assert mock_cmd.call_args[0][1:4] == (multisensor_6, "file", bytes(10)) assert mock_cmd.call_args[1] == { - "target": 15, "additional_user_agent_components": {"HomeAssistant": "0.0.0"}, } assert json.loads(await resp.text()) is None @@ -2839,7 +2842,7 @@ async def test_firmware_upload_view_failed_command( client = await hass_client() device = get_device(hass, multisensor_6) with patch( - "homeassistant.components.zwave_js.api.begin_firmware_update", + "homeassistant.components.zwave_js.api.update_firmware", side_effect=FailedCommand("test", "test"), ): resp = await client.post( @@ -3502,8 +3505,13 @@ async def test_subscribe_firmware_update_status( "source": "node", "event": "firmware update progress", "nodeId": multisensor_6.node_id, - "sentFragments": 1, - "totalFragments": 10, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 10, + "progress": 10.0, + }, }, ) multisensor_6.receive_event(event) @@ -3511,8 +3519,11 @@ async def test_subscribe_firmware_update_status( msg = await ws_client.receive_json() assert msg["event"] == { "event": "firmware update progress", + "current_file": 1, + "total_files": 1, "sent_fragments": 1, "total_fragments": 10, + "progress": 10.0, } event = Event( @@ -3521,8 +3532,12 @@ async def test_subscribe_firmware_update_status( "source": "node", "event": "firmware update finished", "nodeId": multisensor_6.node_id, - "status": 255, - "waitTime": 10, + "result": { + "status": 255, + "success": True, + "waitTime": 10, + "reInterview": False, + }, }, ) multisensor_6.receive_event(event) @@ -3531,7 +3546,9 @@ async def test_subscribe_firmware_update_status( assert msg["event"] == { "event": "firmware update finished", "status": 255, + "success": True, "wait_time": 10, + "reinterview": False, } @@ -3551,8 +3568,13 @@ async def test_subscribe_firmware_update_status_initial_value( "source": "node", "event": "firmware update progress", "nodeId": multisensor_6.node_id, - "sentFragments": 1, - "totalFragments": 10, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 10, + "progress": 10.0, + }, }, ) multisensor_6.receive_event(event) @@ -3574,8 +3596,11 @@ async def test_subscribe_firmware_update_status_initial_value( msg = await ws_client.receive_json() assert msg["event"] == { "event": "firmware update progress", + "current_file": 1, + "total_files": 1, "sent_fragments": 1, "total_fragments": 10, + "progress": 10.0, } diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index b2517c3dd34..4c00c1c9a3a 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -324,7 +324,7 @@ async def test_update_entity_progress( assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = None + client.async_send_command.return_value = {"success": False} # Test successful install call without a version install_task = hass.async_create_task( @@ -352,8 +352,13 @@ async def test_update_entity_progress( "source": "node", "event": "firmware update progress", "nodeId": node.node_id, - "sentFragments": 1, - "totalFragments": 20, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, }, ) node.receive_event(event) @@ -370,7 +375,11 @@ async def test_update_entity_progress( "source": "node", "event": "firmware update finished", "nodeId": node.node_id, - "status": FirmwareUpdateStatus.OK_NO_RESTART, + "result": { + "status": FirmwareUpdateStatus.OK_NO_RESTART, + "success": True, + "reInterview": False, + }, }, ) @@ -381,142 +390,7 @@ async def test_update_entity_progress( state = hass.states.get(UPDATE_ENTITY) assert state attrs = state.attributes - assert attrs[ATTR_IN_PROGRESS] == 0 - assert attrs[ATTR_INSTALLED_VERSION] == "11.2.4" - assert attrs[ATTR_LATEST_VERSION] == "11.2.4" - assert state.state == STATE_OFF - - await install_task - - -async def test_update_entity_progress_multiple( - hass, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, -): - """Test update entity progress with multiple files.""" - node = climate_radio_thermostat_ct100_plus_different_endpoints - client.async_send_command.return_value = FIRMWARE_UPDATE_MULTIPLE_FILES - - 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_ON - attrs = state.attributes - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" - assert attrs[ATTR_LATEST_VERSION] == "11.2.4" - - client.async_send_command.reset_mock() - client.async_send_command.return_value = None - - # Test successful install call without a version - install_task = hass.async_create_task( - hass.services.async_call( - UPDATE_DOMAIN, - SERVICE_INSTALL, - { - ATTR_ENTITY_ID: UPDATE_ENTITY, - }, - blocking=True, - ) - ) - - # Sleep so that task starts - await asyncio.sleep(0.1) - - state = hass.states.get(UPDATE_ENTITY) - assert state - attrs = state.attributes - assert attrs[ATTR_IN_PROGRESS] is True - - node.receive_event( - Event( - type="firmware update progress", - data={ - "source": "node", - "event": "firmware update progress", - "nodeId": node.node_id, - "sentFragments": 1, - "totalFragments": 20, - }, - ) - ) - - # Block so HA can do its thing - await asyncio.sleep(0) - - # Validate that the progress is updated (two files means progress is 50% of 5) - state = hass.states.get(UPDATE_ENTITY) - assert state - attrs = state.attributes - assert attrs[ATTR_IN_PROGRESS] == 2 - - node.receive_event( - Event( - type="firmware update finished", - data={ - "source": "node", - "event": "firmware update finished", - "nodeId": node.node_id, - "status": FirmwareUpdateStatus.OK_NO_RESTART, - }, - ) - ) - - # Block so HA can do its thing - await asyncio.sleep(0) - - # One file done, progress should be 50% - state = hass.states.get(UPDATE_ENTITY) - assert state - attrs = state.attributes - assert attrs[ATTR_IN_PROGRESS] == 50 - - node.receive_event( - Event( - type="firmware update progress", - data={ - "source": "node", - "event": "firmware update progress", - "nodeId": node.node_id, - "sentFragments": 1, - "totalFragments": 20, - }, - ) - ) - - # Block so HA can do its thing - await asyncio.sleep(0) - - # Validate that the progress is updated (50% + 50% of 5) - state = hass.states.get(UPDATE_ENTITY) - assert state - attrs = state.attributes - assert attrs[ATTR_IN_PROGRESS] == 52 - - node.receive_event( - Event( - type="firmware update finished", - data={ - "source": "node", - "event": "firmware update finished", - "nodeId": node.node_id, - "status": FirmwareUpdateStatus.OK_NO_RESTART, - }, - ) - ) - - # Block so HA can do its thing - await asyncio.sleep(0) - - # Validate that progress is reset and entity reflects new version - state = hass.states.get(UPDATE_ENTITY) - assert state - attrs = state.attributes - assert attrs[ATTR_IN_PROGRESS] == 0 + assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_INSTALLED_VERSION] == "11.2.4" assert attrs[ATTR_LATEST_VERSION] == "11.2.4" assert state.state == STATE_OFF @@ -546,10 +420,11 @@ async def test_update_entity_install_failed( assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = None + client.async_send_command.return_value = {"success": False} - async def call_install(): - await hass.services.async_call( + # Test install call - we expect it to finish fail + install_task = hass.async_create_task( + hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, { @@ -557,9 +432,7 @@ async def test_update_entity_install_failed( }, blocking=True, ) - - # Test install call - we expect it to raise - install_task = hass.async_create_task(call_install()) + ) # Sleep so that task starts await asyncio.sleep(0.1) @@ -570,8 +443,13 @@ async def test_update_entity_install_failed( "source": "node", "event": "firmware update progress", "nodeId": node.node_id, - "sentFragments": 1, - "totalFragments": 20, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, }, ) node.receive_event(event) @@ -588,7 +466,11 @@ async def test_update_entity_install_failed( "source": "node", "event": "firmware update finished", "nodeId": node.node_id, - "status": FirmwareUpdateStatus.ERROR_TIMEOUT, + "result": { + "status": FirmwareUpdateStatus.ERROR_TIMEOUT, + "success": False, + "reInterview": False, + }, }, ) @@ -599,7 +481,7 @@ async def test_update_entity_install_failed( state = hass.states.get(UPDATE_ENTITY) assert state attrs = state.attributes - assert attrs[ATTR_IN_PROGRESS] == 0 + assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_INSTALLED_VERSION] == "10.7" assert attrs[ATTR_LATEST_VERSION] == "11.2.4" assert state.state == STATE_ON