diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 2d9f6e98a4c..b40f64e8607 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( DOMAIN: Final = "velbus" +CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 3f0b1bd6cdb..765c5a0f674 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -9,15 +9,19 @@ from typing import TYPE_CHECKING import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.storage import STORAGE_DIR if TYPE_CHECKING: from . import VelbusConfigEntry from .const import ( + CONF_CONFIG_ENTRY, CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, @@ -32,6 +36,7 @@ def setup_services(hass: HomeAssistant) -> None: """Register the velbus services.""" def check_entry_id(interface: str) -> str: + """Check the config_entry for a specific interface.""" for config_entry in hass.config_entries.async_entries(DOMAIN): if "port" in config_entry.data and config_entry.data["port"] == interface: return config_entry.entry_id @@ -39,51 +44,71 @@ def setup_services(hass: HomeAssistant) -> None: "The interface provided is not defined as a port in a Velbus integration" ) - def get_config_entry(interface: str) -> VelbusConfigEntry | None: - for config_entry in hass.config_entries.async_entries(DOMAIN): - if "port" in config_entry.data and config_entry.data["port"] == interface: - return config_entry - return None + async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry: + """Get the config entry for this service call.""" + if CONF_CONFIG_ENTRY in call.data: + entry_id = call.data[CONF_CONFIG_ENTRY] + elif CONF_INTERFACE in call.data: + # Deprecated in 2025.2, to remove in 2025.8 + async_create_issue( + hass, + DOMAIN, + "deprecated_interface_parameter", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_interface_parameter", + ) + entry_id = call.data[CONF_INTERFACE] + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry async def scan(call: ServiceCall) -> None: """Handle a scan service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.scan() + entry = await get_config_entry(call) + await entry.runtime_data.controller.scan() async def syn_clock(call: ServiceCall) -> None: """Handle a sync clock service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.sync_clock() + entry = await get_config_entry(call) + await entry.runtime_data.controller.sync_clock() async def set_memo_text(call: ServiceCall) -> None: """Handle Memo Text service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - memo_text = call.data[CONF_MEMO_TEXT] - module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) - if module: - await module.set_memo_text(memo_text.async_render()) + entry = await get_config_entry(call) + memo_text = call.data[CONF_MEMO_TEXT] + module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) + if not module: + raise ServiceValidationError("Module not found") + await module.set_memo_text(memo_text.async_render()) async def clear_cache(call: ServiceCall) -> None: """Handle a clear cache service call.""" - # clear the cache + entry = await get_config_entry(call) with suppress(FileNotFoundError): if call.data.get(CONF_ADDRESS): await hass.async_add_executor_job( os.unlink, hass.config.path( STORAGE_DIR, - f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", + f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p", ), ) else: await hass.async_add_executor_job( shutil.rmtree, - hass.config.path( - STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" - ), + hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"), ) # call a scan to repopulate await scan(call) @@ -92,28 +117,73 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SCAN, scan, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SYNC, syn_clock, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SET_MEMO_TEXT, set_memo_text, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), ), ) @@ -121,12 +191,26 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_CLEAR_CACHE, clear_cache, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), ), ) diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index e3ecc3556f0..39886913692 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,29 +1,38 @@ sync_clock: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus scan: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus clear_cache: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: false selector: @@ -34,11 +43,14 @@ clear_cache: set_memo_text: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: true selector: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index be1d992056e..90938a6c1d2 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -20,6 +20,12 @@ "exceptions": { "invalid_hvac_mode": { "message": "Climate mode {hvac_mode} is not supported." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } }, "services": { @@ -30,6 +36,10 @@ "interface": { "name": "Interface", "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + }, + "config_entry": { + "name": "Config entry", + "description": "The config entry of the velbus integration" } } }, @@ -40,6 +50,10 @@ "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" } } }, @@ -51,6 +65,10 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page." @@ -65,6 +83,10 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page." @@ -75,5 +97,11 @@ } } } + }, + "issues": { + "deprecated_interface_parameter": { + "title": "Deprecated 'interface' parameter", + "description": "The 'interface' parameter in the Velbus service calls is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + } } } diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 95f691b34f8..20d26a895c0 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from velbusaio.channels import ( @@ -72,6 +72,7 @@ def mock_controller( 4: mock_module_no_subdevices, 99: mock_module_subdevices, } + cont.get_module.return_value = mock_module_subdevices yield controller @@ -300,7 +301,7 @@ def mock_cover_no_position() -> AsyncMock: @pytest.fixture(name="config_entry") async def mock_config_entry( hass: HomeAssistant, - controller: MagicMock, + controller: AsyncMock, ) -> VelbusConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py new file mode 100644 index 00000000000..2bcbac7b80d --- /dev/null +++ b/tests/components/velbus/test_services.py @@ -0,0 +1,172 @@ +"""Velbus services tests.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components.velbus.const import ( + CONF_CONFIG_ENTRY, + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_CLEAR_CACHE, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.issue_registry as ir + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_global_services_with_interface( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test services directed at the bus with an interface parameter.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + assert issue_registry.async_get_issue(DOMAIN, "deprecated_interface_parameter") + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_global_survices_with_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test services directed at the bus with a config_entry.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_set_memo_text( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: AsyncMock, +) -> None: + """Test the set_memo_text service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 1, + }, + blocking=True, + ) + config_entry.runtime_data.controller.get_module( + 1 + ).set_memo_text.assert_called_once_with("Test") + + # Test with unfound module + controller.return_value.get_module.return_value = None + with pytest.raises(ServiceValidationError, match="Module not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 2, + }, + blocking=True, + ) + + +async def test_clear_cache( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test the clear_cache service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id, CONF_ADDRESS: 1}, + blocking=True, + ) + assert config_entry.runtime_data.controller.scan.call_count == 2