Fix swallowed exceptions in deconz actions (#175646)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
This commit is contained in:
mattreim
2026-07-06 15:17:35 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Ariel Ebersberger
parent c6eef394c8
commit 7678cf98ca
4 changed files with 182 additions and 48 deletions
+55 -30
View File
@@ -1,11 +1,11 @@
"""deCONZ services."""
from typing import TYPE_CHECKING
from pydeconz import errors
from pydeconz.utils import normalize_bridge_id
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -14,14 +14,10 @@ from homeassistant.helpers import (
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util.read_only_dict import ReadOnlyDict
from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER
from .const import CONF_BRIDGE_ID, DOMAIN
from .hub import DeconzHub
from .util import get_master_hub
if TYPE_CHECKING:
from . import DeconzConfigEntry
DECONZ_SERVICES = "deconz_services"
SERVICE_FIELD = "field"
@@ -68,27 +64,34 @@ def async_setup_services(hass: HomeAssistant) -> None:
service_data = service_call.data
if CONF_BRIDGE_ID in service_data:
found_hub = False
bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID])
entry: DeconzConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
possible_hub = entry.runtime_data
if possible_hub.bridgeid == bridge_id:
hub = possible_hub
found_hub = True
break
hub: DeconzHub | None = next(
(
entry.runtime_data
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
if entry.runtime_data.bridgeid == bridge_id
),
None,
)
if hub is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="gateway_not_found",
translation_placeholders={
"bridge_id": bridge_id,
},
)
if not found_hub:
LOGGER.error("Could not find the gateway %s", bridge_id)
return
else:
try:
hub = get_master_hub(hass)
# pylint: disable-next=home-assistant-action-swallowed-exception
except ValueError:
LOGGER.error("No master gateway available")
return
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_master_gateway",
) from err
if service == SERVICE_CONFIGURE_DEVICE:
await async_configure_service(hub, service_data)
@@ -132,19 +135,38 @@ async def async_configure_service(hub: DeconzHub, data: ReadOnlyDict) -> None:
if entity_id:
try:
field = hub.deconz_ids[entity_id] + field
except KeyError:
LOGGER.error("Could not find the entity %s", entity_id)
return
except KeyError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={
"entity_id": entity_id,
},
) from err
await hub.api.request("put", field, json=data)
try:
await hub.api.request("put", field, json=data)
except (TimeoutError, errors.pydeconzException) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="configure_failed",
) from err
async def async_refresh_devices_service(hub: DeconzHub) -> None:
"""Refresh available devices from deCONZ."""
hub.ignore_state_updates = True
await hub.api.refresh_state()
hub.load_ignored_devices()
hub.ignore_state_updates = False
try:
await hub.api.refresh_state()
hub.load_ignored_devices()
except (TimeoutError, errors.pydeconzException) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_refresh_failed",
) from err
finally:
hub.ignore_state_updates = False
async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
@@ -183,6 +205,7 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
if entry.device_id in devices_to_be_removed:
devices_to_be_removed.remove(entry.device_id)
continue
# Remove entities that are not available
entities_to_be_removed.append(entry.entity_id)
@@ -195,7 +218,9 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
if (
len(
er.async_entries_for_device(
entity_registry, device_id, include_disabled_entities=True
entity_registry,
device_id,
include_disabled_entities=True,
)
)
== 0
@@ -96,6 +96,23 @@
"remote_turned_counter_clockwise": "Device turned counterclockwise"
}
},
"exceptions": {
"configure_failed": {
"message": "Failed to configure device"
},
"device_refresh_failed": {
"message": "Failed to refresh devices"
},
"entity_not_found": {
"message": "Could not find entity {entity_id}"
},
"gateway_not_found": {
"message": "Could not find gateway {bridge_id}"
},
"no_master_gateway": {
"message": "No master gateway available"
}
},
"options": {
"step": {
"deconz_devices": {
+22 -8
View File
@@ -87,16 +87,27 @@ def fixture_config_entry_source() -> str:
@pytest.fixture(name="mock_put_request")
def fixture_put_request(
aioclient_mock: AiohttpClientMocker, config_entry_data: MappingProxyType[str, Any]
) -> Callable[[str, str], AiohttpClientMocker]:
aioclient_mock: AiohttpClientMocker,
config_entry_data: MappingProxyType[str, Any],
) -> Callable[..., AiohttpClientMocker]:
"""Mock a deCONZ put request."""
_host = config_entry_data[CONF_HOST]
_port = config_entry_data[CONF_PORT]
_api_key = config_entry_data[CONF_API_KEY]
def __mock_requests(path: str, host: str = "") -> AiohttpClientMocker:
def __mock_requests(
path: str,
host: str = "",
*,
exc: Exception | type[Exception] | None = None,
) -> AiohttpClientMocker:
url = f"http://{host or _host}:{_port}/api/{_api_key}{path}"
aioclient_mock.put(url, json={}, headers={"content-type": CONTENT_TYPE_JSON})
aioclient_mock.put(
url,
json={},
exc=exc,
headers={"content-type": CONTENT_TYPE_JSON},
)
return aioclient_mock
return __mock_requests
@@ -129,14 +140,17 @@ def fixture_get_request(
sensor_payload = {"0": sensor_payload}
data.setdefault("sensors", sensor_payload)
def __mock_requests(host: str = "") -> None:
def __mock_requests(
host: str = "",
*,
exc: Exception | type[Exception] | None = None,
) -> None:
url = f"http://{host or _host}:{_port}/api/{_api_key}"
aioclient_mock.get(
url,
json=deconz_payload | {"config": config_payload},
headers={
"content-type": CONTENT_TYPE_JSON,
},
exc=exc,
headers={"content-type": CONTENT_TYPE_JSON},
)
return __mock_requests
+88 -10
View File
@@ -3,6 +3,7 @@
from collections.abc import Callable
from typing import Any
from pydeconz.errors import RequestError
import pytest
import voluptuous as vol
@@ -22,6 +23,7 @@ from homeassistant.components.deconz.services import (
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .test_hub import BRIDGE_ID
@@ -111,7 +113,8 @@ async def test_configure_service_with_entity_and_field(
@pytest.mark.usefixtures("config_entry_setup")
async def test_configure_service_with_faulty_bridgeid(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that service fails on a bad bridge id."""
aioclient_mock.clear_requests()
@@ -122,9 +125,15 @@ async def test_configure_service_with_faulty_bridgeid(
SERVICE_DATA: {"on": True},
}
await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
DOMAIN,
SERVICE_CONFIGURE_DEVICE,
service_data=data,
blocking=True,
)
assert err.value.translation_key == "gateway_not_found"
assert len(aioclient_mock.mock_calls) == 0
@@ -141,9 +150,10 @@ async def test_configure_service_with_faulty_field(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("config_entry_setup")
async def test_configure_service_with_faulty_entity(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that service on a non existing entity."""
"""Test that service fails on a non-existing entity."""
aioclient_mock.clear_requests()
data = {
@@ -151,16 +161,24 @@ async def test_configure_service_with_faulty_entity(
SERVICE_DATA: {},
}
await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
DOMAIN,
SERVICE_CONFIGURE_DEVICE,
service_data=data,
blocking=True,
)
assert err.value.translation_key == "entity_not_found"
assert err.value.translation_placeholders == {"entity_id": "light.nonexisting"}
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize("config_entry_options", [{CONF_MASTER_GATEWAY: False}])
@pytest.mark.usefixtures("config_entry_setup")
async def test_calling_service_with_no_master_gateway_fails(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that service call fails when no master gateway exist."""
aioclient_mock.clear_requests()
@@ -170,9 +188,15 @@ async def test_calling_service_with_no_master_gateway_fails(
SERVICE_DATA: {"on": True},
}
await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
DOMAIN,
SERVICE_CONFIGURE_DEVICE,
service_data=data,
blocking=True,
)
assert err.value.translation_key == "no_master_gateway"
assert len(aioclient_mock.mock_calls) == 0
@@ -390,3 +414,57 @@ async def test_remove_orphaned_entries_service(
)
== 2 # Light and switch battery
)
@pytest.mark.usefixtures("config_entry_setup")
async def test_configure_service_request_error(
hass: HomeAssistant,
mock_put_request: Callable[..., AiohttpClientMocker],
) -> None:
"""Test configure service handles API request errors."""
data = {
SERVICE_FIELD: "/lights/2",
CONF_BRIDGE_ID: BRIDGE_ID,
SERVICE_DATA: {"on": True},
}
mock_put_request(
"/lights/2",
exc=RequestError("Request failed"),
)
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_CONFIGURE_DEVICE,
service_data=data,
blocking=True,
)
assert exc_info.value.translation_key == "configure_failed"
async def test_service_refresh_devices_failure(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
config_entry_setup: MockConfigEntry,
mock_requests: Callable[..., None],
) -> None:
"""Test refresh service handles request failures."""
aioclient_mock.clear_requests()
mock_requests(exc=TimeoutError)
hub = config_entry_setup.runtime_data
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_DEVICE_REFRESH,
service_data={CONF_BRIDGE_ID: BRIDGE_ID},
blocking=True,
)
assert exc_info.value.translation_key == "device_refresh_failed"
assert hub.ignore_state_updates is False