mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
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:
co-authored by
Copilot Autofix powered by AI
Ariel Ebersberger
parent
c6eef394c8
commit
7678cf98ca
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user