From f4cbe55640fcb2b3b0df0b500fa77b70a236ab86 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 17:50:36 +0000 Subject: [PATCH 1/9] Move service registration in System Bridge integration to async_setup --- .../components/system_bridge/__init__.py | 280 +----------------- .../components/system_bridge/services.py | 269 +++++++++++++++++ 2 files changed, 282 insertions(+), 267 deletions(-) create mode 100644 homeassistant/components/system_bridge/services.py diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 931d4df86b7cd..a93e8f1468b8b 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -1,9 +1,7 @@ """The System Bridge integration.""" import asyncio -from dataclasses import asdict import logging -from typing import Any from systembridgeconnector.exceptions import ( AuthenticationException, @@ -11,68 +9,29 @@ ConnectionErrorException, DataMissingException, ) -from systembridgeconnector.models.keyboard_key import KeyboardKey -from systembridgeconnector.models.keyboard_text import KeyboardText -from systembridgeconnector.models.modules.processes import Process -from systembridgeconnector.models.open_path import OpenPath -from systembridgeconnector.models.open_url import OpenUrl from systembridgeconnector.version import Version -import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_COMMAND, CONF_ENTITY_ID, CONF_HOST, - CONF_ID, CONF_NAME, - CONF_PATH, CONF_PORT, CONF_TOKEN, - CONF_URL, Platform, ) -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, - discovery, -) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType from .config_flow import SystemBridgeConfigFlow from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator - - -def _get_coordinator( - hass: HomeAssistant, entry_id: str -) -> SystemBridgeDataUpdateCoordinator: - """Return the coordinator for a config entry id.""" - entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry( - entry_id - ) - if entry is None or entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device": entry_id}, - ) - return entry.runtime_data - +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -88,13 +47,6 @@ def _get_coordinator( CONF_KEY = "key" CONF_TEXT = "text" -SERVICE_GET_PROCESS_BY_ID = "get_process_by_id" -SERVICE_GET_PROCESSES_BY_NAME = "get_processes_by_name" -SERVICE_OPEN_PATH = "open_path" -SERVICE_POWER_COMMAND = "power_command" -SERVICE_OPEN_URL = "open_url" -SERVICE_SEND_KEYPRESS = "send_keypress" -SERVICE_SEND_TEXT = "send_text" POWER_COMMAND_MAP = { "hibernate": "power_hibernate", @@ -106,6 +58,13 @@ def _get_coordinator( } +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the System Bridge services.""" + + async_setup_services(hass) + return True + + async def async_setup_entry( hass: HomeAssistant, entry: SystemBridgeConfigEntry, @@ -231,219 +190,6 @@ async def async_setup_entry( ) ) - if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): - return True - - def valid_device(device: str) -> str: - """Check device is valid.""" - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device) - if device_entry is not None: - try: - return next( - entry.entry_id - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ) - except StopIteration as exception: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device": device}, - ) from exception - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device": device}, - ) - - async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse: - """Handle the get process by id service call.""" - _LOGGER.debug("Get process by id: %s", service_call.data) - coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) - processes: list[Process] = coordinator.data.processes - - # Find process.id from list, raise ServiceValidationError if not found - try: - return asdict( - next( - process - for process in processes - if process.id == service_call.data[CONF_ID] - ) - ) - except StopIteration as exception: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="process_not_found", - translation_placeholders={"id": service_call.data[CONF_ID]}, - ) from exception - - async def handle_get_processes_by_name( - service_call: ServiceCall, - ) -> ServiceResponse: - """Handle the get process by name service call.""" - _LOGGER.debug("Get process by name: %s", service_call.data) - coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) - - # Find processes from list - items: list[dict[str, Any]] = [ - asdict(process) - for process in coordinator.data.processes - if process.name is not None - and service_call.data[CONF_NAME].lower() in process.name.lower() - ] - - return { - "count": len(items), - "processes": list(items), - } - - async def handle_open_path(service_call: ServiceCall) -> ServiceResponse: - """Handle the open path service call.""" - _LOGGER.debug("Open path: %s", service_call.data) - coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) - response = await coordinator.websocket_client.open_path( - OpenPath(path=service_call.data[CONF_PATH]) - ) - return asdict(response) - - async def handle_power_command(service_call: ServiceCall) -> ServiceResponse: - """Handle the power command service call.""" - _LOGGER.debug("Power command: %s", service_call.data) - coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) - response = await getattr( - coordinator.websocket_client, - POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]], - )() - return asdict(response) - - async def handle_open_url(service_call: ServiceCall) -> ServiceResponse: - """Handle the open url service call.""" - _LOGGER.debug("Open URL: %s", service_call.data) - coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) - response = await coordinator.websocket_client.open_url( - OpenUrl(url=service_call.data[CONF_URL]) - ) - return asdict(response) - - async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: - """Handle the send_keypress service call.""" - coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) - response = await coordinator.websocket_client.keyboard_keypress( - KeyboardKey(key=service_call.data[CONF_KEY]) - ) - return asdict(response) - - async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: - """Handle the send_keypress service call.""" - coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) - response = await coordinator.websocket_client.keyboard_text( - KeyboardText(text=service_call.data[CONF_TEXT]) - ) - return asdict(response) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_GET_PROCESS_BY_ID, - handle_get_process_by_id, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_ID): cv.positive_int, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_GET_PROCESSES_BY_NAME, - handle_get_processes_by_name, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_NAME): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_OPEN_PATH, - handle_open_path, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_PATH): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_POWER_COMMAND, - handle_power_command, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP), - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_OPEN_URL, - handle_open_url, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_URL): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SEND_KEYPRESS, - handle_send_keypress, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_KEY): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - description_placeholders={ - "syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys" - }, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SEND_TEXT, - handle_send_text, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_TEXT): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - # Reload entry when its updated. entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/system_bridge/services.py b/homeassistant/components/system_bridge/services.py new file mode 100644 index 0000000000000..161510482b56d --- /dev/null +++ b/homeassistant/components/system_bridge/services.py @@ -0,0 +1,269 @@ +"""Service registration for System Bridge integration.""" + +from dataclasses import asdict +import logging +from typing import Any + +from systembridgeconnector.models.keyboard_key import KeyboardKey +from systembridgeconnector.models.keyboard_text import KeyboardText +from systembridgeconnector.models.modules.processes import Process +from systembridgeconnector.models.open_path import OpenPath +from systembridgeconnector.models.open_url import OpenUrl +import voluptuous as vol + +from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL +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, + service, +) + +from .const import DOMAIN +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +CONF_BRIDGE = "bridge" +CONF_KEY = "key" +CONF_TEXT = "text" + +POWER_COMMAND_MAP = { + "hibernate": "power_hibernate", + "lock": "power_lock", + "logout": "power_logout", + "restart": "power_restart", + "shutdown": "power_shutdown", + "sleep": "power_sleep", +} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for System Bridge integration.""" + + hass.services.async_register( + DOMAIN, + "get_process_by_id", + handle_get_process_by_id, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_ID): cv.positive_int, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "get_processes_by_name", + handle_get_processes_by_name, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_NAME): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "open_path", + handle_open_path, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_PATH): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "power_command", + handle_power_command, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP), + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "open_url", + handle_open_url, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_URL): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "send_keypress", + handle_send_keypress, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_KEY): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + description_placeholders={ + "syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys" + }, + ) + + hass.services.async_register( + DOMAIN, + "send_text", + handle_send_text, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_TEXT): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + +def _get_coordinator( + hass: HomeAssistant, device_id: str +) -> SystemBridgeDataUpdateCoordinator: + """Return the coordinator for a device id.""" + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device_id}, + ) + try: + entry_id = next( + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ) + except StopIteration as exception: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device_id}, + ) from exception + entry: SystemBridgeConfigEntry = service.async_get_config_entry( + hass, DOMAIN, entry_id + ) + return entry.runtime_data + + +async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse: + """Handle the get process by id service call.""" + _LOGGER.debug("Get process by id: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + processes: list[Process] = coordinator.data.processes + + # Find process.id from list, raise ServiceValidationError if not found + try: + return asdict( + next( + process + for process in processes + if process.id == service_call.data[CONF_ID] + ) + ) + except StopIteration as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="process_not_found", + translation_placeholders={"id": service_call.data[CONF_ID]}, + ) from exception + + +async def handle_get_processes_by_name( + service_call: ServiceCall, +) -> ServiceResponse: + """Handle the get process by name service call.""" + _LOGGER.debug("Get process by name: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + + # Find processes from list + items: list[dict[str, Any]] = [ + asdict(process) + for process in coordinator.data.processes + if process.name is not None + and service_call.data[CONF_NAME].lower() in process.name.lower() + ] + + return { + "count": len(items), + "processes": list(items), + } + + +async def handle_open_path(service_call: ServiceCall) -> ServiceResponse: + """Handle the open path service call.""" + _LOGGER.debug("Open path: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.open_path( + OpenPath(path=service_call.data[CONF_PATH]) + ) + return asdict(response) + + +async def handle_power_command(service_call: ServiceCall) -> ServiceResponse: + """Handle the power command service call.""" + _LOGGER.debug("Power command: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await getattr( + coordinator.websocket_client, + POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]], + )() + return asdict(response) + + +async def handle_open_url(service_call: ServiceCall) -> ServiceResponse: + """Handle the open url service call.""" + _LOGGER.debug("Open URL: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.open_url( + OpenUrl(url=service_call.data[CONF_URL]) + ) + return asdict(response) + + +async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: + """Handle the send_keypress service call.""" + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.keyboard_keypress( + KeyboardKey(key=service_call.data[CONF_KEY]) + ) + return asdict(response) + + +async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: + """Handle the send_keypress service call.""" + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.keyboard_text( + KeyboardText(text=service_call.data[CONF_TEXT]) + ) + return asdict(response) From bd5a8299ea577b116ee191662f75d21c802418a7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 20:10:24 +0200 Subject: [PATCH 2/9] Fix docstring Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/system_bridge/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/system_bridge/services.py b/homeassistant/components/system_bridge/services.py index 161510482b56d..f6caabc81131b 100644 --- a/homeassistant/components/system_bridge/services.py +++ b/homeassistant/components/system_bridge/services.py @@ -261,7 +261,7 @@ async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: - """Handle the send_keypress service call.""" + """Handle the send_text service call.""" coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.keyboard_text( KeyboardText(text=service_call.data[CONF_TEXT]) From eab5215fc3bd4ffd232dd506bddd282e2ee3e54a Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 18:13:40 +0000 Subject: [PATCH 3/9] config schema --- homeassistant/components/system_bridge/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index a93e8f1468b8b..c1f78d7f214cb 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -23,7 +23,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType @@ -35,6 +35,8 @@ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, From 3f2fee050bc72fdb20585175675e6da1e414b3d6 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 18:16:10 +0000 Subject: [PATCH 4/9] fix url --- homeassistant/components/system_bridge/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/system_bridge/services.py b/homeassistant/components/system_bridge/services.py index f6caabc81131b..f078c0fb180bc 100644 --- a/homeassistant/components/system_bridge/services.py +++ b/homeassistant/components/system_bridge/services.py @@ -126,7 +126,7 @@ def async_setup_services(hass: HomeAssistant) -> None: ), supports_response=SupportsResponse.ONLY, description_placeholders={ - "syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys" + "syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys" }, ) From ab461f6de9c7e8729cb2c3e800d7fc6ac1deec2c Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 20:13:23 +0000 Subject: [PATCH 5/9] remove duplicate consts --- homeassistant/components/system_bridge/__init__.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index c1f78d7f214cb..724498ab9a613 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -45,20 +45,6 @@ Platform.UPDATE, ] -CONF_BRIDGE = "bridge" -CONF_KEY = "key" -CONF_TEXT = "text" - - -POWER_COMMAND_MAP = { - "hibernate": "power_hibernate", - "lock": "power_lock", - "logout": "power_logout", - "restart": "power_restart", - "shutdown": "power_shutdown", - "sleep": "power_sleep", -} - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the System Bridge services.""" From ae8cb4496c5ad3c9e636c0bed621991827f5eb11 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 22:20:36 +0000 Subject: [PATCH 6/9] add tests --- .../components/system_bridge/services.py | 8 +- tests/components/system_bridge/conftest.py | 30 ++++ .../snapshots/test_services.ambr | 91 +++++++++++ .../components/system_bridge/test_services.py | 154 ++++++++++++++++++ 4 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 tests/components/system_bridge/snapshots/test_services.ambr create mode 100644 tests/components/system_bridge/test_services.py diff --git a/homeassistant/components/system_bridge/services.py b/homeassistant/components/system_bridge/services.py index f078c0fb180bc..a44f28dc6aabc 100644 --- a/homeassistant/components/system_bridge/services.py +++ b/homeassistant/components/system_bridge/services.py @@ -164,12 +164,12 @@ def _get_coordinator( for entry in hass.config_entries.async_entries(DOMAIN) if entry.entry_id in device_entry.config_entries ) - except StopIteration as exception: + except StopIteration as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="device_not_found", translation_placeholders={"device": device_id}, - ) from exception + ) from e entry: SystemBridgeConfigEntry = service.async_get_config_entry( hass, DOMAIN, entry_id ) @@ -191,12 +191,12 @@ async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse if process.id == service_call.data[CONF_ID] ) ) - except StopIteration as exception: + except StopIteration as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="process_not_found", translation_placeholders={"id": service_call.data[CONF_ID]}, - ) from exception + ) from e async def handle_get_processes_by_name( diff --git a/tests/components/system_bridge/conftest.py b/tests/components/system_bridge/conftest.py index 37933803a738d..b8f8359692ddd 100644 --- a/tests/components/system_bridge/conftest.py +++ b/tests/components/system_bridge/conftest.py @@ -126,6 +126,36 @@ def mock_websocket_client( message="Data listener registered", data={EventKey.MODULES: register_data_listener_model.modules}, ) + websocket_client.open_url.return_value = Response( + id=FIXTURE_REQUEST_ID, + type=EventType.OPENED, + message="Opened url", + data={"url": "https://example.com"}, + ) + websocket_client.open_path.return_value = Response( + id=FIXTURE_REQUEST_ID, + type=EventType.OPENED, + message="Opened file", + data={"path": "/home/user/documents"}, + ) + websocket_client.power_shutdown.return_value = Response( + id=FIXTURE_REQUEST_ID, + type=EventType.POWER_SHUTDOWN, + message="Shuttdown", + data={}, + ) + websocket_client.keyboard_keypress.return_value = Response( + id=FIXTURE_REQUEST_ID, + type=EventType.KEYBOARD_KEY_PRESSED, + message="Keyboard key pressed", + data={"key": "backspace"}, + ) + websocket_client.keyboard_text.return_value = Response( + id=FIXTURE_REQUEST_ID, + type=EventType.KEYBOARD_TEXT_SENT, + message="Keyboard text sent", + data={"text": "Hello world"}, + ) # Trigger callback when listener is registered websocket_client.listen.side_effect = mock_data_listener diff --git a/tests/components/system_bridge/snapshots/test_services.ambr b/tests/components/system_bridge/snapshots/test_services.ambr new file mode 100644 index 0000000000000..3b90a464132c9 --- /dev/null +++ b/tests/components/system_bridge/snapshots/test_services.ambr @@ -0,0 +1,91 @@ +# serializer version: 1 +# name: test_get_process_services[get_process_by_id] + dict({ + 'cpu_usage': 12.3, + 'created': 12.3, + 'id': 1234, + 'memory_usage': 12.3, + 'name': 'name', + 'path': '/path', + 'status': 'running', + 'username': 'username', + 'working_directory': '/working/directory', + }) +# --- +# name: test_get_process_services[get_processes_by_name] + dict({ + 'count': 1, + 'processes': list([ + dict({ + 'cpu_usage': 12.3, + 'created': 12.3, + 'id': 1234, + 'memory_usage': 12.3, + 'name': 'name', + 'path': '/path', + 'status': 'running', + 'username': 'username', + 'working_directory': '/working/directory', + }), + ]), + }) +# --- +# name: test_services[open_path] + dict({ + 'data': dict({ + 'path': '/home/user/documents', + }), + 'id': 'test', + 'message': 'Opened file', + 'module': None, + 'subtype': None, + 'type': , + }) +# --- +# name: test_services[open_url] + dict({ + 'data': dict({ + 'url': 'https://example.com', + }), + 'id': 'test', + 'message': 'Opened url', + 'module': None, + 'subtype': None, + 'type': , + }) +# --- +# name: test_services[power_command_shutdown] + dict({ + 'data': dict({ + }), + 'id': 'test', + 'message': 'Shuttdown', + 'module': None, + 'subtype': None, + 'type': , + }) +# --- +# name: test_services[send_keypress] + dict({ + 'data': dict({ + 'key': 'backspace', + }), + 'id': 'test', + 'message': 'Keyboard key pressed', + 'module': None, + 'subtype': None, + 'type': , + }) +# --- +# name: test_services[send_text] + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'id': 'test', + 'message': 'Keyboard text sent', + 'module': None, + 'subtype': None, + 'type': , + }) +# --- diff --git a/tests/components/system_bridge/test_services.py b/tests/components/system_bridge/test_services.py new file mode 100644 index 0000000000000..9064b9bd84abe --- /dev/null +++ b/tests/components/system_bridge/test_services.py @@ -0,0 +1,154 @@ +"""Tests for System Bridge actions.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +from systembridgeconnector.models.keyboard_key import KeyboardKey +from systembridgeconnector.models.keyboard_text import KeyboardText +from systembridgeconnector.models.open_path import OpenPath +from systembridgeconnector.models.open_url import OpenUrl +from voluptuous import Any + +from homeassistant.components.system_bridge.const import DOMAIN +from homeassistant.components.system_bridge.services import ( + CONF_BRIDGE, + CONF_KEY, + CONF_TEXT, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import FIXTURE_UUID + +from tests.common import AsyncMock, MockConfigEntry + + +@pytest.mark.parametrize( + ("service", "service_data", "call_method", "call_args"), + [ + ( + "open_path", + {CONF_PATH: "/home/user/documents"}, + "open_path", + [OpenPath(path="/home/user/documents")], + ), + ( + "open_url", + {CONF_URL: "https://example.com"}, + "open_url", + [OpenUrl(url="https://example.com")], + ), + ( + "power_command", + {CONF_COMMAND: "shutdown"}, + "power_shutdown", + [], + ), + ( + "send_keypress", + {CONF_KEY: "backspace"}, + "keyboard_keypress", + [KeyboardKey(key="backspace")], + ), + ( + "send_text", + {CONF_TEXT: "Hello world"}, + "keyboard_text", + [KeyboardText(text="Hello world")], + ), + ], + ids=[ + "open_path", + "open_url", + "power_command_shutdown", + "send_keypress", + "send_text", + ], +) +@pytest.mark.usefixtures("mock_version") +async def test_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_websocket_client: AsyncMock, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, + service: str, + service_data: dict[str, Any], + call_method: str, + call_args: list[Any], +) -> None: + """Test System Bridge service action calls.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, FIXTURE_UUID)} + ) + assert device_entry + + resp = await hass.services.async_call( + DOMAIN, + service, + { + CONF_BRIDGE: device_entry.id, + **service_data, + }, + blocking=True, + return_response=True, + ) + + getattr(mock_websocket_client, call_method).assert_awaited_once_with(*call_args) + assert resp == snapshot + + +@pytest.mark.parametrize( + ("service", "service_data"), + [ + ( + "get_process_by_id", + {CONF_ID: 1234}, + ), + ( + "get_processes_by_name", + {CONF_NAME: "name"}, + ), + ], + ids=["get_process_by_id", "get_processes_by_name"], +) +@pytest.mark.usefixtures("mock_version", "mock_websocket_client") +async def test_get_process_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, + service: str, + service_data: dict[str, Any], +) -> None: + """Test System Bridge get process service action calls.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, FIXTURE_UUID)} + ) + assert device_entry + + resp = await hass.services.async_call( + DOMAIN, + service, + { + CONF_BRIDGE: device_entry.id, + **service_data, + }, + blocking=True, + return_response=True, + ) + + assert resp == snapshot From ebd295e929b0db476b706cbe4c36939290f38637 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 22:26:32 +0000 Subject: [PATCH 7/9] fix --- tests/components/system_bridge/conftest.py | 2 +- tests/components/system_bridge/test_services.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/system_bridge/conftest.py b/tests/components/system_bridge/conftest.py index b8f8359692ddd..2d15134dc79d7 100644 --- a/tests/components/system_bridge/conftest.py +++ b/tests/components/system_bridge/conftest.py @@ -141,7 +141,7 @@ def mock_websocket_client( websocket_client.power_shutdown.return_value = Response( id=FIXTURE_REQUEST_ID, type=EventType.POWER_SHUTDOWN, - message="Shuttdown", + message="Shutdown", data={}, ) websocket_client.keyboard_keypress.return_value = Response( diff --git a/tests/components/system_bridge/test_services.py b/tests/components/system_bridge/test_services.py index 9064b9bd84abe..ac506201e8c02 100644 --- a/tests/components/system_bridge/test_services.py +++ b/tests/components/system_bridge/test_services.py @@ -1,12 +1,13 @@ """Tests for System Bridge actions.""" +from typing import Any + import pytest from syrupy.assertion import SnapshotAssertion from systembridgeconnector.models.keyboard_key import KeyboardKey from systembridgeconnector.models.keyboard_text import KeyboardText from systembridgeconnector.models.open_path import OpenPath from systembridgeconnector.models.open_url import OpenUrl -from voluptuous import Any from homeassistant.components.system_bridge.const import DOMAIN from homeassistant.components.system_bridge.services import ( From f0c703c1104b380b6c48bae1413a0cf1214d8e3d Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 22:33:39 +0000 Subject: [PATCH 8/9] update snapshot --- tests/components/system_bridge/snapshots/test_services.ambr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/system_bridge/snapshots/test_services.ambr b/tests/components/system_bridge/snapshots/test_services.ambr index 3b90a464132c9..60059569d7fb1 100644 --- a/tests/components/system_bridge/snapshots/test_services.ambr +++ b/tests/components/system_bridge/snapshots/test_services.ambr @@ -59,7 +59,7 @@ 'data': dict({ }), 'id': 'test', - 'message': 'Shuttdown', + 'message': 'Shutdown', 'module': None, 'subtype': None, 'type': , From d470d15c645f9ee60e7df53b1e1417f5f10c7faa Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 21 May 2026 22:41:55 +0000 Subject: [PATCH 9/9] ServiceValidationError --- homeassistant/components/system_bridge/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/system_bridge/services.py b/homeassistant/components/system_bridge/services.py index a44f28dc6aabc..2eaa3bce35bc9 100644 --- a/homeassistant/components/system_bridge/services.py +++ b/homeassistant/components/system_bridge/services.py @@ -19,7 +19,7 @@ SupportsResponse, callback, ) -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -192,7 +192,7 @@ async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse ) ) except StopIteration as e: - raise HomeAssistantError( + raise ServiceValidationError( translation_domain=DOMAIN, translation_key="process_not_found", translation_placeholders={"id": service_call.data[CONF_ID]},