From 30b76e42a3e79506359d15e0acf840a0fbba38ec Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 13 Nov 2025 22:32:09 +0100 Subject: [PATCH 01/25] Initial commit --- homeassistant/components/shelly/__init__.py | 84 ++++++++++++++++++++- homeassistant/components/shelly/const.py | 12 +++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 79f3914451597..f516507e8c3fc 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from typing import Final +from typing import TYPE_CHECKING, Final from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import BlockDevice @@ -26,8 +26,18 @@ CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -37,17 +47,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JsonValueType from .const import ( BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, CONF_COAP_PORT, + CONF_KEY, CONF_SLEEP_PERIOD, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID, LOGGER, MODELS_WITH_WRONG_SLEEP_PERIOD, PUSH_UPDATE_ISSUE_ID, + SERVICE_KVS_GET, + SERVICE_KVS_GET_SCHEMA, ) from .coordinator import ( ShellyBlockCoordinator, @@ -117,6 +131,70 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := config.get(DOMAIN)) is not None: hass.data[DOMAIN] = {CONF_COAP_PORT: conf[CONF_COAP_PORT]} + async def async_kvs_get(call: ServiceCall) -> ServiceResponse: + """Handle the kvs_get service call.""" + key = call.data[CONF_KEY] + device_id = call.data["device_id"] + + device_registry = dr.async_get(hass) + device = device_registry.async_get(device_id) + + if TYPE_CHECKING: + assert device is not None + + # Find the config entry for this device + config_entry: ShellyConfigEntry | None = None + for entry_id in device.config_entries: + entry = hass.config_entries.async_get_entry(entry_id) + if entry and entry.domain == DOMAIN: + config_entry = entry + break + + if TYPE_CHECKING: + assert config_entry is not None + + # Check if device is RPC (Gen2+) device + if get_device_entry_gen(config_entry) not in RPC_GENERATIONS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_rpc_device", + ) + + runtime_data = config_entry.runtime_data + + if not runtime_data.rpc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_ready", + ) + + try: + response = await runtime_data.rpc.device.call_rpc("KVS.Get", {"key": key}) + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_error", + translation_placeholders={"error": str(err)}, + ) from err + except DeviceConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_connection_error", + ) from err + else: + result: dict[str, JsonValueType] = {} + result["value"] = response.get("value") + + return result + + hass.services.async_register( + DOMAIN, + SERVICE_KVS_GET, + async_kvs_get, + schema=SERVICE_KVS_GET_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + return True diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index c7678a4b57ade..ff4df16d2def7 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -28,9 +28,11 @@ MODEL_WALL_DISPLAY_X2, MODEL_WALL_DISPLAY_XL, ) +import voluptuous as vol from homeassistant.components.number import NumberMode from homeassistant.const import UnitOfVolumeFlowRate +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "shelly" @@ -343,3 +345,13 @@ class DeprecatedFirmwareInfo(TypedDict): ROLE_GENERIC = "generic" TRV_CHANNEL = 0 + +CONF_KEY: Final = "key" + +SERVICE_KVS_GET: Final = "kvs_get" +SERVICE_KVS_GET_SCHEMA = vol.Schema( + { + vol.Required("device_id"): cv.string, + vol.Required(CONF_KEY): str, + } +) From c99f1b7562e8d5d2d2f829334cf081ea47c30310 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 13 Nov 2025 23:15:21 +0100 Subject: [PATCH 02/25] Move service to services.py --- homeassistant/components/shelly/__init__.py | 85 +-------------- homeassistant/components/shelly/const.py | 10 -- homeassistant/components/shelly/services.py | 102 ++++++++++++++++++ homeassistant/components/shelly/services.yaml | 12 +++ 4 files changed, 119 insertions(+), 90 deletions(-) create mode 100644 homeassistant/components/shelly/services.py create mode 100644 homeassistant/components/shelly/services.yaml diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index f516507e8c3fc..975f0ac2a9d78 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING, Final +from typing import Final from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import BlockDevice @@ -26,18 +26,8 @@ CONF_USERNAME, Platform, ) -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -47,21 +37,17 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import JsonValueType from .const import ( BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, CONF_COAP_PORT, - CONF_KEY, CONF_SLEEP_PERIOD, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID, LOGGER, MODELS_WITH_WRONG_SLEEP_PERIOD, PUSH_UPDATE_ISSUE_ID, - SERVICE_KVS_GET, - SERVICE_KVS_GET_SCHEMA, ) from .coordinator import ( ShellyBlockCoordinator, @@ -77,6 +63,7 @@ async_manage_open_wifi_ap_issue, async_manage_outbound_websocket_incorrectly_enabled_issue, ) +from .services import async_setup_services from .utils import ( async_create_issue_unsupported_firmware, async_migrate_rpc_virtual_components_unique_ids, @@ -131,69 +118,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := config.get(DOMAIN)) is not None: hass.data[DOMAIN] = {CONF_COAP_PORT: conf[CONF_COAP_PORT]} - async def async_kvs_get(call: ServiceCall) -> ServiceResponse: - """Handle the kvs_get service call.""" - key = call.data[CONF_KEY] - device_id = call.data["device_id"] - - device_registry = dr.async_get(hass) - device = device_registry.async_get(device_id) - - if TYPE_CHECKING: - assert device is not None - - # Find the config entry for this device - config_entry: ShellyConfigEntry | None = None - for entry_id in device.config_entries: - entry = hass.config_entries.async_get_entry(entry_id) - if entry and entry.domain == DOMAIN: - config_entry = entry - break - - if TYPE_CHECKING: - assert config_entry is not None - - # Check if device is RPC (Gen2+) device - if get_device_entry_gen(config_entry) not in RPC_GENERATIONS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_rpc_device", - ) - - runtime_data = config_entry.runtime_data - - if not runtime_data.rpc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="device_not_ready", - ) - - try: - response = await runtime_data.rpc.device.call_rpc("KVS.Get", {"key": key}) - except RpcCallError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="rpc_call_error", - translation_placeholders={"error": str(err)}, - ) from err - except DeviceConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_connection_error", - ) from err - else: - result: dict[str, JsonValueType] = {} - result["value"] = response.get("value") - - return result - - hass.services.async_register( - DOMAIN, - SERVICE_KVS_GET, - async_kvs_get, - schema=SERVICE_KVS_GET_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index ff4df16d2def7..adddabef5b482 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -28,11 +28,9 @@ MODEL_WALL_DISPLAY_X2, MODEL_WALL_DISPLAY_XL, ) -import voluptuous as vol from homeassistant.components.number import NumberMode from homeassistant.const import UnitOfVolumeFlowRate -from homeassistant.helpers import config_validation as cv DOMAIN: Final = "shelly" @@ -347,11 +345,3 @@ class DeprecatedFirmwareInfo(TypedDict): TRV_CHANNEL = 0 CONF_KEY: Final = "key" - -SERVICE_KVS_GET: Final = "kvs_get" -SERVICE_KVS_GET_SCHEMA = vol.Schema( - { - vol.Required("device_id"): cv.string, - vol.Required(CONF_KEY): str, - } -) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py new file mode 100644 index 0000000000000..b93dc5dad0912 --- /dev/null +++ b/homeassistant/components/shelly/services.py @@ -0,0 +1,102 @@ +"""Support for services.""" + +from typing import TYPE_CHECKING + +from aioshelly.const import RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.util.json import JsonValueType + +from .const import DOMAIN +from .coordinator import ShellyConfigEntry +from .utils import get_device_entry_gen + +ATTR_KEY = "key" + +SERVICE_KVS_GET = "kvs_get" +SERVICE_KVS_GET_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_KEY): str, + } +) + + +async def async_kvs_get(call: ServiceCall) -> ServiceResponse: + """Handle the kvs_get service call.""" + key = call.data[ATTR_KEY] + device_id = call.data["device_id"] + + device_registry = dr.async_get(call.hass) + device = device_registry.async_get(device_id) + + if TYPE_CHECKING: + assert device is not None + + # Find the config entry for this device + config_entry: ShellyConfigEntry | None = None + for entry_id in device.config_entries: + entry = call.hass.config_entries.async_get_entry(entry_id) + if entry and entry.domain == DOMAIN: + config_entry = entry + break + + if TYPE_CHECKING: + assert config_entry is not None + + # Check if device is RPC (Gen2+) device + if get_device_entry_gen(config_entry) not in RPC_GENERATIONS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_rpc_device", + ) + + runtime_data = config_entry.runtime_data + + if not runtime_data.rpc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_ready", + ) + + try: + response = await runtime_data.rpc.device.call_rpc("KVS.Get", {"key": key}) + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_error", + translation_placeholders={"error": str(err)}, + ) from err + except DeviceConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_connection_error", + ) from err + else: + result: dict[str, JsonValueType] = {} + result["value"] = response.get("value") + + return result + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for Shelly integration.""" + hass.services.async_register( + DOMAIN, + SERVICE_KVS_GET, + async_kvs_get, + schema=SERVICE_KVS_GET_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/shelly/services.yaml b/homeassistant/components/shelly/services.yaml new file mode 100644 index 0000000000000..763ac892d6169 --- /dev/null +++ b/homeassistant/components/shelly/services.yaml @@ -0,0 +1,12 @@ +get_kvs: + fields: + device_id: + required: true + selector: + device: + integration: shelly + key: + required: true + example: "my_key" + selector: + text: From 09135736d2f9bdc975335d550c3cea815f9e4d49 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 13 Nov 2025 23:30:33 +0100 Subject: [PATCH 03/25] Add translations for services --- homeassistant/components/shelly/services.yaml | 3 +-- homeassistant/components/shelly/strings.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/services.yaml b/homeassistant/components/shelly/services.yaml index 763ac892d6169..6b9ed896ccc78 100644 --- a/homeassistant/components/shelly/services.yaml +++ b/homeassistant/components/shelly/services.yaml @@ -1,4 +1,4 @@ -get_kvs: +kvs_get: fields: device_id: required: true @@ -7,6 +7,5 @@ get_kvs: integration: shelly key: required: true - example: "my_key" selector: text: diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 6be47bb940884..3c81f34bbb65b 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -748,5 +748,21 @@ "manual": "Enter address manually" } } + }, + "services": { + "kvs_get": { + "description": "Get a value from the device's Key-Value Storage.", + "fields": { + "device_id": { + "description": "The ID of the Shelly device to get the KVS value from.", + "name": "Device" + }, + "key": { + "description": "The name of the key for which the KVS value will be retrieved.", + "name": "Key" + } + }, + "name": "KVS get" + } } } From a1c3ab2b28488b92bd4e25a87fbc69263c329bc8 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 14 Nov 2025 16:36:25 +0100 Subject: [PATCH 04/25] Improvement --- homeassistant/components/shelly/services.py | 74 ++++++++++++-------- homeassistant/components/shelly/strings.json | 18 +++++ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index b93dc5dad0912..7e16460320bc9 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -1,11 +1,10 @@ """Support for services.""" -from typing import TYPE_CHECKING - from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, RpcCallError import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import ( HomeAssistant, @@ -33,41 +32,59 @@ ) -async def async_kvs_get(call: ServiceCall) -> ServiceResponse: - """Handle the kvs_get service call.""" - key = call.data[ATTR_KEY] - device_id = call.data["device_id"] - +@callback +def async_get_config_entry_for_service_call( + call: ServiceCall, +) -> ShellyConfigEntry: + """Get the config entry related to a service call (by device ID).""" device_registry = dr.async_get(call.hass) - device = device_registry.async_get(device_id) - - if TYPE_CHECKING: - assert device is not None - - # Find the config entry for this device - config_entry: ShellyConfigEntry | None = None - for entry_id in device.config_entries: - entry = call.hass.config_entries.async_get_entry(entry_id) - if entry and entry.domain == DOMAIN: - config_entry = entry - break + device_id = call.data[ATTR_DEVICE_ID] - if TYPE_CHECKING: - assert config_entry is not None - - # Check if device is RPC (Gen2+) device - if get_device_entry_gen(config_entry) not in RPC_GENERATIONS: + if (device_entry := device_registry.async_get(device_id)) is None: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="not_rpc_device", + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, ) + for entry_id in device_entry.config_entries: + if (config_entry := call.hass.config_entries.async_get_entry(entry_id)) is None: + continue + if config_entry.domain != DOMAIN: + continue + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + translation_placeholders={"device": config_entry.title}, + ) + if get_device_entry_gen(config_entry) not in RPC_GENERATIONS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_rpc_device", + translation_placeholders={"device": config_entry.title}, + ) + return config_entry + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"device_id": device_id}, + ) + + +async def async_kvs_get(call: ServiceCall) -> ServiceResponse: + """Handle the kvs_get service call.""" + key = call.data[ATTR_KEY] + config_entry = async_get_config_entry_for_service_call(call) + runtime_data = config_entry.runtime_data if not runtime_data.rpc: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="device_not_ready", + translation_key="device_not_initialized", + translation_placeholders={"device": config_entry.title}, ) try: @@ -76,12 +93,13 @@ async def async_kvs_get(call: ServiceCall) -> ServiceResponse: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="rpc_call_error", - translation_placeholders={"error": str(err)}, + translation_placeholders={"device": config_entry.title}, ) from err except DeviceConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="device_connection_error", + translation_key="device_communication_error", + translation_placeholders={"device": config_entry.title}, ) from err else: result: dict[str, JsonValueType] = {} diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 3c81f34bbb65b..c5d668783bb41 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -603,6 +603,9 @@ "auth_error": { "message": "Authentication failed for {device}, please update your credentials" }, + "config_entry_not_found": { + "message": "Config entry for device ID {device_id} not found" + }, "device_communication_action_error": { "message": "Device communication error occurred while calling action for {entity} of {device}" }, @@ -612,12 +615,24 @@ "device_not_found": { "message": "{device} not found while configuring device automation triggers" }, + "device_not_initialized": { + "message": "{device} not initialized" + }, + "entry_not_loaded": { + "message": "Config entry not loaded for {device}" + }, "firmware_unsupported": { "message": "{device} is running an unsupported firmware, please update the firmware" }, + "invalid_device_id": { + "message": "Invalid device ID specified: {device_id}" + }, "invalid_trigger": { "message": "Invalid device automation trigger (type, subtype): {trigger}" }, + "not_rpc_device": { + "message": "{device} does not support KVS" + }, "ota_update_connection_error": { "message": "Device communication error occurred while triggering OTA update for {device}" }, @@ -627,6 +642,9 @@ "rpc_call_action_error": { "message": "RPC call error occurred while calling action for {entity} of {device}" }, + "rpc_call_error": { + "message": "RPC call error occurred for {device}" + }, "update_error": { "message": "An error occurred while retrieving data from {device}" }, From 9ba3efaeddacb9b55a5477a5b3e414f9f56664bf Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 21 Nov 2025 11:18:13 +0100 Subject: [PATCH 05/25] Rename kvs_get to get_kvs_value --- homeassistant/components/shelly/services.py | 14 +++++++------- homeassistant/components/shelly/services.yaml | 2 +- homeassistant/components/shelly/strings.json | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index 7e16460320bc9..c743ee51ce77a 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -23,8 +23,8 @@ ATTR_KEY = "key" -SERVICE_KVS_GET = "kvs_get" -SERVICE_KVS_GET_SCHEMA = vol.Schema( +SERVICE_GET_KVS_VALUE = "get_kvs_value" +SERVICE_GET_KVS_VALUE_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, vol.Required(ATTR_KEY): str, @@ -73,8 +73,8 @@ def async_get_config_entry_for_service_call( ) -async def async_kvs_get(call: ServiceCall) -> ServiceResponse: - """Handle the kvs_get service call.""" +async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: + """Handle the get_kvs_value service call.""" key = call.data[ATTR_KEY] config_entry = async_get_config_entry_for_service_call(call) @@ -113,8 +113,8 @@ def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for Shelly integration.""" hass.services.async_register( DOMAIN, - SERVICE_KVS_GET, - async_kvs_get, - schema=SERVICE_KVS_GET_SCHEMA, + SERVICE_GET_KVS_VALUE, + async_get_kvs_value, + schema=SERVICE_GET_KVS_VALUE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/shelly/services.yaml b/homeassistant/components/shelly/services.yaml index 6b9ed896ccc78..64f29913324e5 100644 --- a/homeassistant/components/shelly/services.yaml +++ b/homeassistant/components/shelly/services.yaml @@ -1,4 +1,4 @@ -kvs_get: +get_kvs_value: fields: device_id: required: true diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index c5d668783bb41..99a81e4da8ce7 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -768,7 +768,7 @@ } }, "services": { - "kvs_get": { + "get_kvs_value": { "description": "Get a value from the device's Key-Value Storage.", "fields": { "device_id": { @@ -780,7 +780,7 @@ "name": "Key" } }, - "name": "KVS get" + "name": "Get KVS value" } } } From 2575d21c5bdaf40f20d584a996e3b540e8a9f197 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 21 Nov 2025 11:55:11 +0100 Subject: [PATCH 06/25] Add tests --- tests/components/shelly/test_services.py | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/components/shelly/test_services.py diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py new file mode 100644 index 0000000000000..81d09909caa4a --- /dev/null +++ b/tests/components/shelly/test_services.py @@ -0,0 +1,98 @@ +"""Tests for Shelly services.""" + +from unittest.mock import AsyncMock, Mock + +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +import pytest + +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from . import init_integration + + +async def test_service_get_kvs_value( + hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry +) -> None: + """Test get_kvs_value service.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + mock_rpc_device.call_rpc = AsyncMock(return_value={"value": "test_value"}) + + response = await hass.services.async_call( + DOMAIN, + "get_kvs_value", + {"device_id": device.id, "key": "my_key"}, + blocking=True, + return_response=True, + ) + + assert response == {"value": "test_value"} + mock_rpc_device.call_rpc.assert_called_once_with("KVS.Get", {"key": "my_key"}) + + +async def test_service_get_kvs_value_invalid_device(hass: HomeAssistant) -> None: + """Test get_kvs_value service with invalid device ID.""" + await init_integration(hass, 2) + + with pytest.raises(ServiceValidationError, match="Invalid device ID"): + await hass.services.async_call( + DOMAIN, + "get_kvs_value", + {"device_id": "invalid_device_id", "key": "my_key"}, + blocking=True, + return_response=True, + ) + + +async def test_service_get_kvs_value_block_device( + hass: HomeAssistant, mock_block_device: Mock, device_registry: dr.DeviceRegistry +) -> None: + """Test get_kvs_value service with non-RPC (Gen1) device.""" + entry = await init_integration(hass, 1) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + with pytest.raises(ServiceValidationError, match="does not support KVS"): + await hass.services.async_call( + DOMAIN, + "get_kvs_value", + {"device_id": device.id, "key": "my_key"}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("exc", "error"), + [ + (RpcCallError(999), "RPC call error"), + (DeviceConnectionError, "Device communication error"), + ], +) +async def test_service_get_kvs_value_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, + exc: Exception, + error: str, +) -> None: + """Test get_kvs_value service with exception.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + mock_rpc_device.call_rpc = AsyncMock(side_effect=exc) + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + DOMAIN, + "get_kvs_value", + {"device_id": device.id, "key": "my_key"}, + blocking=True, + return_response=True, + ) From f3043ec27d3477c7c96d5e973189244975b68cfb Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Nov 2025 10:42:18 +0000 Subject: [PATCH 07/25] Improve tests --- tests/components/shelly/test_services.py | 48 ++++++++++++++++-------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 81d09909caa4a..3becf413f4b82 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -6,6 +6,8 @@ import pytest from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.services import ATTR_KEY, SERVICE_GET_KVS_VALUE +from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr @@ -25,29 +27,35 @@ async def test_service_get_kvs_value( response = await hass.services.async_call( DOMAIN, - "get_kvs_value", - {"device_id": device.id, "key": "my_key"}, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, blocking=True, return_response=True, ) assert response == {"value": "test_value"} - mock_rpc_device.call_rpc.assert_called_once_with("KVS.Get", {"key": "my_key"}) + mock_rpc_device.call_rpc.assert_called_once_with("KVS.Get", {ATTR_KEY: "my_key"}) async def test_service_get_kvs_value_invalid_device(hass: HomeAssistant) -> None: """Test get_kvs_value service with invalid device ID.""" await init_integration(hass, 2) - with pytest.raises(ServiceValidationError, match="Invalid device ID"): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, - "get_kvs_value", - {"device_id": "invalid_device_id", "key": "my_key"}, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: "invalid_device_id", ATTR_KEY: "my_key"}, blocking=True, return_response=True, ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_device_id" + assert exc_info.value.translation_placeholders == { + ATTR_DEVICE_ID: "invalid_device_id" + } + async def test_service_get_kvs_value_block_device( hass: HomeAssistant, mock_block_device: Mock, device_registry: dr.DeviceRegistry @@ -57,21 +65,25 @@ async def test_service_get_kvs_value_block_device( device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - with pytest.raises(ServiceValidationError, match="does not support KVS"): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, - "get_kvs_value", - {"device_id": device.id, "key": "my_key"}, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, blocking=True, return_response=True, ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "not_rpc_device" + assert exc_info.value.translation_placeholders == {"device": entry.title} + @pytest.mark.parametrize( - ("exc", "error"), + ("exc", "translation_key"), [ - (RpcCallError(999), "RPC call error"), - (DeviceConnectionError, "Device communication error"), + (RpcCallError(999), "rpc_call_error"), + (DeviceConnectionError, "device_communication_error"), ], ) async def test_service_get_kvs_value_exc( @@ -79,7 +91,7 @@ async def test_service_get_kvs_value_exc( mock_rpc_device: Mock, device_registry: dr.DeviceRegistry, exc: Exception, - error: str, + translation_key: str, ) -> None: """Test get_kvs_value service with exception.""" entry = await init_integration(hass, 2) @@ -88,11 +100,15 @@ async def test_service_get_kvs_value_exc( mock_rpc_device.call_rpc = AsyncMock(side_effect=exc) - with pytest.raises(HomeAssistantError, match=error): + with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( DOMAIN, - "get_kvs_value", - {"device_id": device.id, "key": "my_key"}, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, blocking=True, return_response=True, ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == {"device": entry.title} From 1cfd519785e7b26384ebdcb58941d2e47cfde09b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Nov 2025 10:59:02 +0000 Subject: [PATCH 08/25] More tests --- tests/components/shelly/test_services.py | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 3becf413f4b82..d26a27013432b 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -7,6 +7,7 @@ from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.shelly.services import ATTR_KEY, SERVICE_GET_KVS_VALUE +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -112,3 +113,32 @@ async def test_service_get_kvs_value_exc( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == translation_key assert exc_info.value.translation_placeholders == {"device": entry.title} + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Test config entry not loaded.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "entry_not_loaded" + assert exc_info.value.translation_placeholders == {"device": entry.title} From 1b4b8353bcf7e8257808d5d84f37b0193cbcaa8a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Nov 2025 11:35:02 +0000 Subject: [PATCH 09/25] Sleeping device --- homeassistant/components/shelly/icons.json | 5 ++++ homeassistant/components/shelly/services.py | 10 +++++-- homeassistant/components/shelly/strings.json | 2 +- tests/components/shelly/test_services.py | 28 +++++++++++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index cabef1d5c0a1e..3168f4f5e5ade 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -105,5 +105,10 @@ } } } + }, + "services": { + "get_kvs_value": { + "service": "mdi:import" + } } } diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index c743ee51ce77a..6bd6baa99a973 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.util.json import JsonValueType -from .const import DOMAIN +from .const import CONF_SLEEP_PERIOD, DOMAIN from .coordinator import ShellyConfigEntry from .utils import get_device_entry_gen @@ -61,7 +61,13 @@ def async_get_config_entry_for_service_call( if get_device_entry_gen(config_entry) not in RPC_GENERATIONS: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="not_rpc_device", + translation_key="kvs_not_supported", + translation_placeholders={"device": config_entry.title}, + ) + if config_entry.data.get(CONF_SLEEP_PERIOD, 0) > 0: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="kvs_not_supported", translation_placeholders={"device": config_entry.title}, ) return config_entry diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 99a81e4da8ce7..4666f7fc4a588 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -630,7 +630,7 @@ "invalid_trigger": { "message": "Invalid device automation trigger (type, subtype): {trigger}" }, - "not_rpc_device": { + "kvs_not_supported": { "message": "{device} does not support KVS" }, "ota_update_connection_error": { diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index d26a27013432b..306cd25afb36b 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -76,7 +76,7 @@ async def test_service_get_kvs_value_block_device( ) assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "not_rpc_device" + assert exc_info.value.translation_key == "kvs_not_supported" assert exc_info.value.translation_placeholders == {"device": entry.title} @@ -142,3 +142,29 @@ async def test_config_entry_not_loaded( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "entry_not_loaded" assert exc_info.value.translation_placeholders == {"device": entry.title} + + +async def test_service_get_kvs_value_sleeping_device( + hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry +) -> None: + """Test get_kvs_value service with RPC sleeping device.""" + entry = await init_integration(hass, 2, sleep_period=1000) + + # Make device online + mock_rpc_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "kvs_not_supported" + assert exc_info.value.translation_placeholders == {"device": entry.title} From 0ef9029eeda289cd0e9ca08121ec6776b8646e4e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Nov 2025 11:52:17 +0000 Subject: [PATCH 10/25] Add set_kvs_value action --- homeassistant/components/shelly/icons.json | 3 + homeassistant/components/shelly/services.py | 73 ++++++++++++++++--- homeassistant/components/shelly/services.yaml | 16 ++++ homeassistant/components/shelly/strings.json | 18 +++++ 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 3168f4f5e5ade..f12ddea711b7c 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -109,6 +109,9 @@ "services": { "get_kvs_value": { "service": "mdi:import" + }, + "set_kvs_value": { + "service": "mdi:export" } } } diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index 6bd6baa99a973..ff1fa511ef7c0 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -22,14 +22,23 @@ from .utils import get_device_entry_gen ATTR_KEY = "key" +ATTR_VALUE = "value" SERVICE_GET_KVS_VALUE = "get_kvs_value" +SERVICE_SET_KVS_VALUE = "set_kvs_value" SERVICE_GET_KVS_VALUE_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, vol.Required(ATTR_KEY): str, } ) +SERVICE_SET_KVS_VALUE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): str, + } +) @callback @@ -94,7 +103,7 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: ) try: - response = await runtime_data.rpc.device.call_rpc("KVS.Get", {"key": key}) + response = await runtime_data.rpc.device.call_rpc("KVS.Get", {ATTR_KEY: key}) except RpcCallError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -109,18 +118,64 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: ) from err else: result: dict[str, JsonValueType] = {} - result["value"] = response.get("value") + result[ATTR_VALUE] = response.get(ATTR_VALUE) return result +async def async_set_kvs_value(call: ServiceCall) -> None: + """Handle the set_kvs_value service call.""" + key = call.data[ATTR_KEY] + config_entry = async_get_config_entry_for_service_call(call) + + runtime_data = config_entry.runtime_data + + if not runtime_data.rpc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_initialized", + translation_placeholders={"device": config_entry.title}, + ) + + try: + await runtime_data.rpc.device.call_rpc( + "KVS.Set", {ATTR_KEY: key, ATTR_VALUE: call.data[ATTR_VALUE]} + ) + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_error", + translation_placeholders={"device": config_entry.title}, + ) from err + except DeviceConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": config_entry.title}, + ) from err + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for Shelly integration.""" - hass.services.async_register( - DOMAIN, - SERVICE_GET_KVS_VALUE, - async_get_kvs_value, - schema=SERVICE_GET_KVS_VALUE_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) + for service, method, schema, response in ( + ( + SERVICE_GET_KVS_VALUE, + async_get_kvs_value, + SERVICE_GET_KVS_VALUE_SCHEMA, + SupportsResponse.ONLY, + ), + ( + SERVICE_SET_KVS_VALUE, + async_set_kvs_value, + SERVICE_SET_KVS_VALUE_SCHEMA, + SupportsResponse.NONE, + ), + ): + hass.services.async_register( + DOMAIN, + service, + method, + schema=schema, + supports_response=response, + ) diff --git a/homeassistant/components/shelly/services.yaml b/homeassistant/components/shelly/services.yaml index 64f29913324e5..128096fbd150d 100644 --- a/homeassistant/components/shelly/services.yaml +++ b/homeassistant/components/shelly/services.yaml @@ -9,3 +9,19 @@ get_kvs_value: required: true selector: text: + +set_kvs_value: + fields: + device_id: + required: true + selector: + device: + integration: shelly + key: + required: true + selector: + text: + value: + required: true + selector: + text: diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 4666f7fc4a588..79ed3e6079d54 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -781,6 +781,24 @@ } }, "name": "Get KVS value" + }, + "set_kvs_value": { + "description": "Set a value in the device's Key-Value Storage.", + "fields": { + "device_id": { + "description": "The ID of the Shelly device to set the KVS value.", + "name": "Device" + }, + "key": { + "description": "The name of the key for which the KVS value will be set.", + "name": "Key" + }, + "value": { + "description": "Value to set.", + "name": "Value" + } + }, + "name": "Set KVS value" } } } From 633414a3b216cb20ad41552df60d93e3acdcc580 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Nov 2025 12:25:49 +0000 Subject: [PATCH 11/25] Response type --- homeassistant/components/shelly/services.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index ff1fa511ef7c0..5d6bc243d79de 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -1,5 +1,7 @@ """Support for services.""" +from json import JSONDecodeError + from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, RpcCallError import voluptuous as vol @@ -15,7 +17,7 @@ ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.util.json import JsonValueType +from homeassistant.util.json import JsonValueType, json_loads from .const import CONF_SLEEP_PERIOD, DOMAIN from .coordinator import ShellyConfigEntry @@ -118,7 +120,12 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: ) from err else: result: dict[str, JsonValueType] = {} - result[ATTR_VALUE] = response.get(ATTR_VALUE) + raw_value = response[ATTR_VALUE] + try: + value = json_loads(raw_value) + except JSONDecodeError: + value = raw_value + result[ATTR_VALUE] = value return result From 2f21c2c9438acfa71e43e27f702788b2e1c2e81c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Nov 2025 12:51:03 +0000 Subject: [PATCH 12/25] Update quality scale --- homeassistant/components/shelly/quality_scale.yaml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml index f97faa88c270a..d3d6255ba6f0f 100644 --- a/homeassistant/components/shelly/quality_scale.yaml +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: The integration does not register services. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: The integration does not register services. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -24,9 +20,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: The integration does not register services. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done From 97828d13728aa9501c91732eae93d12ca3664fbb Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 25 Nov 2025 15:02:00 +0000 Subject: [PATCH 13/25] Use internal methods --- homeassistant/components/shelly/services.py | 6 ++---- tests/components/shelly/test_services.py | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index 5d6bc243d79de..4879fa0c3c2c2 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -105,7 +105,7 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: ) try: - response = await runtime_data.rpc.device.call_rpc("KVS.Get", {ATTR_KEY: key}) + response = await runtime_data.rpc.device.kvs_get(key) except RpcCallError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -145,9 +145,7 @@ async def async_set_kvs_value(call: ServiceCall) -> None: ) try: - await runtime_data.rpc.device.call_rpc( - "KVS.Set", {ATTR_KEY: key, ATTR_VALUE: call.data[ATTR_VALUE]} - ) + await runtime_data.rpc.device.kvs_set(key, call.data[ATTR_VALUE]) except RpcCallError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 306cd25afb36b..7d328ebf74584 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -1,6 +1,6 @@ """Tests for Shelly services.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import Mock from aioshelly.exceptions import DeviceConnectionError, RpcCallError import pytest @@ -24,7 +24,7 @@ async def test_service_get_kvs_value( device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - mock_rpc_device.call_rpc = AsyncMock(return_value={"value": "test_value"}) + mock_rpc_device.kvs_get.return_value = {"value": "test_value"} response = await hass.services.async_call( DOMAIN, @@ -35,7 +35,7 @@ async def test_service_get_kvs_value( ) assert response == {"value": "test_value"} - mock_rpc_device.call_rpc.assert_called_once_with("KVS.Get", {ATTR_KEY: "my_key"}) + mock_rpc_device.kvs_get.assert_called_once_with("my_key") async def test_service_get_kvs_value_invalid_device(hass: HomeAssistant) -> None: @@ -99,7 +99,7 @@ async def test_service_get_kvs_value_exc( device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - mock_rpc_device.call_rpc = AsyncMock(side_effect=exc) + mock_rpc_device.kvs_get.side_effect = exc with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( From ee9bf33c2f5772ee7a649fe525e59a5c949b71dc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Nov 2025 11:34:44 +0000 Subject: [PATCH 14/25] Allow all supported types --- homeassistant/components/shelly/services.py | 13 +++++++++++-- homeassistant/components/shelly/services.yaml | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index 4879fa0c3c2c2..d7abf8838e513 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -17,6 +17,7 @@ ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.json import json_dumps from homeassistant.util.json import JsonValueType, json_loads from .const import CONF_SLEEP_PERIOD, DOMAIN @@ -38,7 +39,9 @@ { vol.Required(ATTR_DEVICE_ID): cv.string, vol.Required(ATTR_KEY): str, - vol.Required(ATTR_VALUE): str, + vol.Required(ATTR_VALUE): vol.Any( + str, int, float, bool, dict, list, type(None) + ), } ) @@ -144,8 +147,14 @@ async def async_set_kvs_value(call: ServiceCall) -> None: translation_placeholders={"device": config_entry.title}, ) + raw_value = call.data[ATTR_VALUE] + if isinstance(raw_value, (dict, list)): + value = json_dumps(raw_value) + else: + value = raw_value + try: - await runtime_data.rpc.device.kvs_set(key, call.data[ATTR_VALUE]) + await runtime_data.rpc.device.kvs_set(key, value) except RpcCallError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/shelly/services.yaml b/homeassistant/components/shelly/services.yaml index 128096fbd150d..b559e17d9c484 100644 --- a/homeassistant/components/shelly/services.yaml +++ b/homeassistant/components/shelly/services.yaml @@ -24,4 +24,4 @@ set_kvs_value: value: required: true selector: - text: + object: From ca7ca104f45e081b10f27330b9dda0146d64fe71 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Nov 2025 11:47:06 +0000 Subject: [PATCH 15/25] type --- homeassistant/components/shelly/services.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index d7abf8838e513..69cf58af63e72 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -39,9 +39,7 @@ { vol.Required(ATTR_DEVICE_ID): cv.string, vol.Required(ATTR_KEY): str, - vol.Required(ATTR_VALUE): vol.Any( - str, int, float, bool, dict, list, type(None) - ), + vol.Required(ATTR_VALUE): vol.Any(str, int, float, bool, dict, list, None), } ) From 742ef8c04b53ed899ceb154b62a752efb450c2dd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Nov 2025 12:32:05 +0000 Subject: [PATCH 16/25] More tests --- tests/components/shelly/test_services.py | 74 ++++++++++++++++++++---- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 7d328ebf74584..f662900cf0340 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -6,7 +6,12 @@ import pytest from homeassistant.components.shelly.const import DOMAIN -from homeassistant.components.shelly.services import ATTR_KEY, SERVICE_GET_KVS_VALUE +from homeassistant.components.shelly.services import ( + ATTR_KEY, + ATTR_VALUE, + SERVICE_GET_KVS_VALUE, + SERVICE_SET_KVS_VALUE, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant @@ -16,26 +21,42 @@ from . import init_integration +@pytest.mark.parametrize( + ("raw_value", "expected_value"), + [ + ("test_value", "test_value"), + (42, 42), + ('{"a":1}', {"a": 1}), + ('[{"a":1},{"b":2}]', [{"a": 1}, {"b": 2}]), + ], +) async def test_service_get_kvs_value( - hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, + raw_value, + expected_value, ) -> None: """Test get_kvs_value service.""" entry = await init_integration(hass, 2) device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - mock_rpc_device.kvs_get.return_value = {"value": "test_value"} + mock_rpc_device.kvs_get.return_value = { + "etag": "16mLia9TRt8lGhj9Zf5Dp6Hw==", + "value": raw_value, + } response = await hass.services.async_call( DOMAIN, SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, blocking=True, return_response=True, ) - assert response == {"value": "test_value"} - mock_rpc_device.kvs_get.assert_called_once_with("my_key") + assert response == {"value": expected_value} + mock_rpc_device.kvs_get.assert_called_once_with("test_key") async def test_service_get_kvs_value_invalid_device(hass: HomeAssistant) -> None: @@ -46,7 +67,7 @@ async def test_service_get_kvs_value_invalid_device(hass: HomeAssistant) -> None await hass.services.async_call( DOMAIN, SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: "invalid_device_id", ATTR_KEY: "my_key"}, + {ATTR_DEVICE_ID: "invalid_device_id", ATTR_KEY: "test_key"}, blocking=True, return_response=True, ) @@ -70,7 +91,7 @@ async def test_service_get_kvs_value_block_device( await hass.services.async_call( DOMAIN, SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, blocking=True, return_response=True, ) @@ -105,7 +126,7 @@ async def test_service_get_kvs_value_exc( await hass.services.async_call( DOMAIN, SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, blocking=True, return_response=True, ) @@ -134,7 +155,7 @@ async def test_config_entry_not_loaded( await hass.services.async_call( DOMAIN, SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, blocking=True, return_response=True, ) @@ -160,7 +181,7 @@ async def test_service_get_kvs_value_sleeping_device( await hass.services.async_call( DOMAIN, SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "my_key"}, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, blocking=True, return_response=True, ) @@ -168,3 +189,34 @@ async def test_service_get_kvs_value_sleeping_device( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "kvs_not_supported" assert exc_info.value.translation_placeholders == {"device": entry.title} + + +@pytest.mark.parametrize( + ("raw_value", "expected_value"), + [ + ("test_value", "test_value"), + (42, 42), + ({"a": 1}, '{"a":1}'), + ([{"a": 1}, {"b": 2}], '[{"a":1},{"b":2}]'), + ], +) +async def test_service_set_kvs_value( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, + raw_value, + expected_value, +) -> None: + """Test set_kvs_value service.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key", ATTR_VALUE: raw_value}, + blocking=True, + ) + + mock_rpc_device.kvs_set.assert_called_once_with("test_key", expected_value) From d99c1303699a3239e5dc980e115ea8412e4a1e2c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Nov 2025 15:18:38 +0100 Subject: [PATCH 17/25] _async_execute_action --- homeassistant/components/shelly/services.py | 63 +++++++++------------ 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index 69cf58af63e72..fc5ca67767f59 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -1,6 +1,7 @@ """Support for services.""" from json import JSONDecodeError +from typing import Any, cast from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, RpcCallError @@ -91,9 +92,10 @@ def async_get_config_entry_for_service_call( ) -async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: - """Handle the get_kvs_value service call.""" - key = call.data[ATTR_KEY] +async def _async_execute_action( + call: ServiceCall, method: str, args: tuple +) -> dict[str, Any]: + """Execute action on the device.""" config_entry = async_get_config_entry_for_service_call(call) runtime_data = config_entry.runtime_data @@ -105,8 +107,10 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: translation_placeholders={"device": config_entry.title}, ) + action_method = getattr(runtime_data.rpc.device, method) + try: - response = await runtime_data.rpc.device.kvs_get(key) + response = await action_method(*args) except RpcCallError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -120,51 +124,36 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: translation_placeholders={"device": config_entry.title}, ) from err else: - result: dict[str, JsonValueType] = {} - raw_value = response[ATTR_VALUE] - try: - value = json_loads(raw_value) - except JSONDecodeError: - value = raw_value - result[ATTR_VALUE] = value - - return result + return cast(dict[str, Any], response) -async def async_set_kvs_value(call: ServiceCall) -> None: - """Handle the set_kvs_value service call.""" +async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: + """Handle the get_kvs_value service call.""" key = call.data[ATTR_KEY] - config_entry = async_get_config_entry_for_service_call(call) - runtime_data = config_entry.runtime_data + response = await _async_execute_action(call, "kvs_get", (key,)) - if not runtime_data.rpc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="device_not_initialized", - translation_placeholders={"device": config_entry.title}, - ) + result: dict[str, JsonValueType] = {} + raw_value = response[ATTR_VALUE] + try: + value = json_loads(raw_value) + except JSONDecodeError: + value = raw_value + result[ATTR_VALUE] = value + return result + + +async def async_set_kvs_value(call: ServiceCall) -> None: + """Handle the set_kvs_value service call.""" + key = call.data[ATTR_KEY] raw_value = call.data[ATTR_VALUE] if isinstance(raw_value, (dict, list)): value = json_dumps(raw_value) else: value = raw_value - try: - await runtime_data.rpc.device.kvs_set(key, value) - except RpcCallError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="rpc_call_error", - translation_placeholders={"device": config_entry.title}, - ) from err - except DeviceConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_communication_error", - translation_placeholders={"device": config_entry.title}, - ) from err + await _async_execute_action(call, "kvs_set", (key, value)) @callback From d17b5e59f60cac83e1c28fdc4549003429bf6f1b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Nov 2025 14:41:11 +0000 Subject: [PATCH 18/25] Move consts to the const file --- homeassistant/components/shelly/const.py | 3 ++- homeassistant/components/shelly/services.py | 5 +---- tests/components/shelly/test_services.py | 4 +--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index adddabef5b482..17e68f03573c4 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -344,4 +344,5 @@ class DeprecatedFirmwareInfo(TypedDict): TRV_CHANNEL = 0 -CONF_KEY: Final = "key" +ATTR_KEY = "key" +ATTR_VALUE = "value" diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index fc5ca67767f59..ce77a637a200d 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -21,13 +21,10 @@ from homeassistant.helpers.json import json_dumps from homeassistant.util.json import JsonValueType, json_loads -from .const import CONF_SLEEP_PERIOD, DOMAIN +from .const import ATTR_KEY, ATTR_VALUE, CONF_SLEEP_PERIOD, DOMAIN from .coordinator import ShellyConfigEntry from .utils import get_device_entry_gen -ATTR_KEY = "key" -ATTR_VALUE = "value" - SERVICE_GET_KVS_VALUE = "get_kvs_value" SERVICE_SET_KVS_VALUE = "set_kvs_value" SERVICE_GET_KVS_VALUE_SCHEMA = vol.Schema( diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index f662900cf0340..d82cb5dac794e 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -5,10 +5,8 @@ from aioshelly.exceptions import DeviceConnectionError, RpcCallError import pytest -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import ATTR_KEY, ATTR_VALUE, DOMAIN from homeassistant.components.shelly.services import ( - ATTR_KEY, - ATTR_VALUE, SERVICE_GET_KVS_VALUE, SERVICE_SET_KVS_VALUE, ) From eb9c8d0cf9f029e834da2ea638b15f71b2b47954 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Nov 2025 15:24:09 +0000 Subject: [PATCH 19/25] Cleaning --- homeassistant/components/shelly/services.py | 12 +++--------- tests/components/shelly/test_services.py | 15 ++------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index ce77a637a200d..f6abd585d7864 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -18,7 +18,6 @@ ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.json import json_dumps from homeassistant.util.json import JsonValueType, json_loads from .const import ATTR_KEY, ATTR_VALUE, CONF_SLEEP_PERIOD, DOMAIN @@ -143,14 +142,9 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: async def async_set_kvs_value(call: ServiceCall) -> None: """Handle the set_kvs_value service call.""" - key = call.data[ATTR_KEY] - raw_value = call.data[ATTR_VALUE] - if isinstance(raw_value, (dict, list)): - value = json_dumps(raw_value) - else: - value = raw_value - - await _async_execute_action(call, "kvs_set", (key, value)) + await _async_execute_action( + call, "kvs_set", (call.data[ATTR_KEY], call.data[ATTR_VALUE]) + ) @callback diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index d82cb5dac794e..f5a4d3b70b4b8 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -189,21 +189,10 @@ async def test_service_get_kvs_value_sleeping_device( assert exc_info.value.translation_placeholders == {"device": entry.title} -@pytest.mark.parametrize( - ("raw_value", "expected_value"), - [ - ("test_value", "test_value"), - (42, 42), - ({"a": 1}, '{"a":1}'), - ([{"a": 1}, {"b": 2}], '[{"a":1},{"b":2}]'), - ], -) async def test_service_set_kvs_value( hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry, - raw_value, - expected_value, ) -> None: """Test set_kvs_value service.""" entry = await init_integration(hass, 2) @@ -213,8 +202,8 @@ async def test_service_set_kvs_value( await hass.services.async_call( DOMAIN, SERVICE_SET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key", ATTR_VALUE: raw_value}, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key", ATTR_VALUE: "test_value"}, blocking=True, ) - mock_rpc_device.kvs_set.assert_called_once_with("test_key", expected_value) + mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value") From 9f623024b9b62ed6e919d050b910dce0c2601712 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Nov 2025 15:46:40 +0000 Subject: [PATCH 20/25] Cleaning --- homeassistant/components/shelly/services.py | 10 ++-------- tests/components/shelly/test_services.py | 15 ++------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index f6abd585d7864..988bfa5f36458 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -1,6 +1,5 @@ """Support for services.""" -from json import JSONDecodeError from typing import Any, cast from aioshelly.const import RPC_GENERATIONS @@ -18,7 +17,7 @@ ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.util.json import JsonValueType, json_loads +from homeassistant.util.json import JsonValueType from .const import ATTR_KEY, ATTR_VALUE, CONF_SLEEP_PERIOD, DOMAIN from .coordinator import ShellyConfigEntry @@ -130,12 +129,7 @@ async def async_get_kvs_value(call: ServiceCall) -> ServiceResponse: response = await _async_execute_action(call, "kvs_get", (key,)) result: dict[str, JsonValueType] = {} - raw_value = response[ATTR_VALUE] - try: - value = json_loads(raw_value) - except JSONDecodeError: - value = raw_value - result[ATTR_VALUE] = value + result[ATTR_VALUE] = response[ATTR_VALUE] return result diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index f5a4d3b70b4b8..0cd7e4a5f5ce5 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -19,21 +19,10 @@ from . import init_integration -@pytest.mark.parametrize( - ("raw_value", "expected_value"), - [ - ("test_value", "test_value"), - (42, 42), - ('{"a":1}', {"a": 1}), - ('[{"a":1},{"b":2}]', [{"a": 1}, {"b": 2}]), - ], -) async def test_service_get_kvs_value( hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry, - raw_value, - expected_value, ) -> None: """Test get_kvs_value service.""" entry = await init_integration(hass, 2) @@ -42,7 +31,7 @@ async def test_service_get_kvs_value( mock_rpc_device.kvs_get.return_value = { "etag": "16mLia9TRt8lGhj9Zf5Dp6Hw==", - "value": raw_value, + "value": "test_value", } response = await hass.services.async_call( @@ -53,7 +42,7 @@ async def test_service_get_kvs_value( return_response=True, ) - assert response == {"value": expected_value} + assert response == {"value": "test_value"} mock_rpc_device.kvs_get.assert_called_once_with("test_key") From 2707c461183ff5b1aa9e17c7f166cdfedc1d1a46 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 2 Dec 2025 21:17:42 +0000 Subject: [PATCH 21/25] Fix description --- homeassistant/components/shelly/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 79ed3e6079d54..5fcffcf728fcb 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -790,7 +790,7 @@ "name": "Device" }, "key": { - "description": "The name of the key for which the KVS value will be set.", + "description": "The name of the key under which the KVS value will be stored.", "name": "Key" }, "value": { From 115190ca9abe76a02fcb3174ea1d49c611e2417b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 2 Dec 2025 21:45:01 +0000 Subject: [PATCH 22/25] Coverage --- tests/components/shelly/test_services.py | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 0cd7e4a5f5ce5..5d4006e961649 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -196,3 +196,28 @@ async def test_service_set_kvs_value( ) mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value") + + +async def test_service_get_kvs_value_config_entry_not_found( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test device with no config entries.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + # Remove all config entries from device + device_registry.devices[device.id].config_entries.clear() + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "config_entry_not_found" + assert exc_info.value.translation_placeholders == {"device_id": device.id} From d445a92771dc8c973d37dd91426de3d0f61ea18b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 2 Dec 2025 22:06:04 +0000 Subject: [PATCH 23/25] Coverage --- tests/components/shelly/test_services.py | 29 +++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 5d4006e961649..94e65ed0cdd70 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -199,7 +199,7 @@ async def test_service_set_kvs_value( async def test_service_get_kvs_value_config_entry_not_found( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry ) -> None: """Test device with no config entries.""" entry = await init_integration(hass, 2) @@ -221,3 +221,30 @@ async def test_service_get_kvs_value_config_entry_not_found( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "config_entry_not_found" assert exc_info.value.translation_placeholders == {"device_id": device.id} + + +async def test_service_get_kvs_value_device_not_initialized( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_kvs_value if runtime_data.rpc is None.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + monkeypatch.delattr(entry.runtime_data, "rpc") + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "device_not_initialized" + assert exc_info.value.translation_placeholders == {"device": entry.title} From a2dba15bf9f3057779ddb573888aea90f26d1499 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 2 Dec 2025 22:17:49 +0000 Subject: [PATCH 24/25] Coverage --- tests/components/shelly/test_services.py | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 94e65ed0cdd70..2324b01ab02ac 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -18,6 +18,8 @@ from . import init_integration +from tests.common import MockConfigEntry + async def test_service_get_kvs_value( hass: HomeAssistant, @@ -248,3 +250,44 @@ async def test_service_get_kvs_value_device_not_initialized( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "device_not_initialized" assert exc_info.value.translation_placeholders == {"device": entry.title} + + +async def test_service_get_kvs_value_wrong_domain( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test get_kvs_value when device has config entries from different domains.""" + entry = await init_integration(hass, 2) + + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + # Create a config entry with different domain and add it to the device + other_entry = MockConfigEntry( + domain="other_domain", + data={}, + ) + other_entry.add_to_hass(hass) + + # Add the other domain's config entry to the device + device_registry.async_update_device( + device.id, add_config_entry_id=other_entry.entry_id + ) + + # Remove the original Shelly config entry + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_KVS_VALUE, + {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "config_entry_not_found" + assert exc_info.value.translation_placeholders == {"device_id": device.id} From bd9223878692de8a1b018148a7dc99b5e66ee3f4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 7 Dec 2025 19:52:26 +0000 Subject: [PATCH 25/25] Assert config_entry --- homeassistant/components/shelly/services.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/services.py b/homeassistant/components/shelly/services.py index 988bfa5f36458..759b62603e835 100644 --- a/homeassistant/components/shelly/services.py +++ b/homeassistant/components/shelly/services.py @@ -1,6 +1,6 @@ """Support for services.""" -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, RpcCallError @@ -56,8 +56,11 @@ def async_get_config_entry_for_service_call( ) for entry_id in device_entry.config_entries: - if (config_entry := call.hass.config_entries.async_get_entry(entry_id)) is None: - continue + config_entry = call.hass.config_entries.async_get_entry(entry_id) + + if TYPE_CHECKING: + assert config_entry + if config_entry.domain != DOMAIN: continue if config_entry.state is not ConfigEntryState.LOADED: