From 7fa7a01d6bb5e36b12da2853b4bb5284bf43030c Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 8 May 2026 08:43:59 +0200 Subject: [PATCH 1/2] Preserve exception type in firmware update error messages A bare `TimeoutError()` from zigpy's `asyncio.timeout()` has an empty `str()`, which produced the unhelpful user-facing message `Update was not successful: ` with no clue about the failure. Fall back to `repr(ex)` when `str(ex)` is empty so the exception type is always visible. --- zha/application/platforms/update.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zha/application/platforms/update.py b/zha/application/platforms/update.py index d465f1171..38fcd0d52 100644 --- a/zha/application/platforms/update.py +++ b/zha/application/platforms/update.py @@ -262,7 +262,9 @@ async def async_install(self, version: str | None) -> None: except Exception as ex: self._attr_in_progress = False self.maybe_emit_state_changed_event() - raise ZHAException(f"Update was not successful: {ex}") from ex + raise ZHAException( + f"Update was not successful: {str(ex) or repr(ex)}" + ) from ex # If the update finished but was not successful, we should also throw an error if result != Status.SUCCESS: From 2a4c2c5fdf42dea58d22c199c94e662e5156ca17 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 8 May 2026 08:44:07 +0200 Subject: [PATCH 2/2] Add regression test for empty firmware update error messages Patches `Device.update_firmware` to raise a bare `TimeoutError()` and asserts the resulting `ZHAException` carries an identifiable message and preserves cause chaining. --- tests/test_update.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_update.py b/tests/test_update.py index 7ef940a5c..987bd535c 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -506,6 +506,49 @@ async def endpoint_reply(cluster, sequence, data, **kwargs): await zha_gateway.async_block_till_done() +async def test_firmware_update_empty_exception_message(zha_gateway: Gateway) -> None: + """Bare ``TimeoutError()`` from zigpy must still produce an identifiable message.""" + zigpy_device = zigpy_device_mock(zha_gateway) + zha_device, ota_cluster, fw_image, installed_fw_version = await setup_test_data( + zha_gateway, zigpy_device + ) + + entity = get_entity(zha_device, platform=Platform.UPDATE) + + await ota_cluster._handle_query_next_image( + foundation.ZCLHeader.cluster( + tsn=0x12, command_id=general.Ota.ServerCommandDefs.query_next_image.id + ), + general.QueryNextImageCommand( + field_control=fw_image.firmware.header.field_control, + manufacturer_code=zha_device.manufacturer_code, + image_type=fw_image.firmware.header.image_type, + current_file_version=installed_fw_version, + hardware_version=1, + ), + ) + await zha_gateway.async_block_till_done() + + raised = TimeoutError() + assert str(raised) == "" + + with ( + patch( + "zigpy.device.Device.update_firmware", + AsyncMock(side_effect=raised), + ), + pytest.raises(ZHAException) as exc_info, + ): + await entity.async_install( + version=f"0x{fw_image.firmware.header.file_version:08x}" + ) + await zha_gateway.async_block_till_done() + + assert str(exc_info.value) == "Update was not successful: TimeoutError()" + assert exc_info.value.__cause__ is raised + assert not entity.state[ATTR_IN_PROGRESS] + + async def test_firmware_update_downgrade(zha_gateway: Gateway) -> None: """Test ZHA update platform - force a firmware downgrade.""" zigpy_device = zigpy_device_mock(zha_gateway)