From 533c14c70bfdca82666eb4a98f7807d5ad551ba8 Mon Sep 17 00:00:00 2001 From: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:48:53 +0100 Subject: [PATCH] Fix PJLink service errors breaking automation continue_on_error (#182186) --- .../components/pjlink/media_player.py | 25 ++++-- tests/components/pjlink/test_media_player.py | 83 +++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index ad61827594b9..d06a2f8b00cf 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -1,5 +1,6 @@ """Support for controlling projector via the PJLink protocol.""" +from collections.abc import Callable from typing import Any, override import probatio @@ -16,6 +17,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -188,27 +190,32 @@ class PjLinkDevice(MediaPlayerEntity): else: raise + def _run_command(self, command: Callable[[Projector], None]) -> None: + """Run a projector command and surface failures as HomeAssistantError.""" + try: + with self.projector() as projector: + command(projector) + except (ProjectorError, TimeoutError, OSError) as err: + # script helpers honor continue_on_error only for HomeAssistantError + raise HomeAssistantError(f"PJLink command failed: {err}") from err + @override def turn_off(self) -> None: """Turn projector off.""" - with self.projector() as projector: - projector.set_power("off") + self._run_command(lambda projector: projector.set_power("off")) @override def turn_on(self) -> None: """Turn projector on.""" - with self.projector() as projector: - projector.set_power("on") + self._run_command(lambda projector: projector.set_power("on")) @override def mute_volume(self, mute: bool) -> None: """Mute (true) of unmute (false) media player.""" - with self.projector() as projector: - projector.set_mute(MUTE_AUDIO, mute) + self._run_command(lambda projector: projector.set_mute(MUTE_AUDIO, mute)) @override def select_source(self, source: str) -> None: """Set the input source.""" - source = self._source_name_mapping[source] - with self.projector() as projector: - projector.set_input(*source) + selected = self._source_name_mapping[source] + self._run_command(lambda projector: projector.set_input(*selected)) diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index 0b5c88e93088..7030fd4c1514 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -2,6 +2,7 @@ from datetime import timedelta import socket +from typing import Any from unittest.mock import MagicMock, create_autospec, patch import pypjlink @@ -25,6 +26,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -322,3 +324,84 @@ async def test_failed_yaml_import( assert issue_registry.async_get_issue( DOMAIN, f"deprecated_yaml_import_issue_{error_str}" ) + + +_ERROR = ProjectorError("projector error") + + +@pytest.mark.parametrize( + ("service", "service_data", "method_name"), + [ + pytest.param("turn_off", {}, "set_power", id="turn_off"), + pytest.param("turn_on", {}, "set_power", id="turn_on"), + pytest.param( + "volume_mute", {"is_volume_muted": True}, "set_mute", id="volume_mute" + ), + pytest.param( + "select_source", {"source": "VGA 1"}, "set_input", id="select_source" + ), + ], +) +async def test_service_command_error_is_home_assistant_error( + mocked_projector: MagicMock, + hass: HomeAssistant, + service: str, + service_data: dict[str, Any], + method_name: str, +) -> None: + """Projector failures during a service command surface as HomeAssistantError.""" + + getattr(mocked_projector, method_name).side_effect = _ERROR + + await setup_pjlink_entry(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + media_player.DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.test", **service_data}, + blocking=True, + ) + + +async def test_service_error_when_projector_unreachable_is_home_assistant_error( + mocked_projector: MagicMock, + projector_from_address: MagicMock, + hass: HomeAssistant, +) -> None: + """A command on an unreachable projector surfaces as HomeAssistantError.""" + + await setup_pjlink_entry(hass) + + projector_from_address.side_effect = socket.timeout + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + media_player.DOMAIN, + "turn_off", + {ATTR_ENTITY_ID: "media_player.test"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "socket_error", [TimeoutError, OSError], ids=["timeout", "os_error"] +) +async def test_service_socket_error_during_command_is_home_assistant_error( + mocked_projector: MagicMock, + hass: HomeAssistant, + socket_error: type[Exception], +) -> None: + """A socket error raised by the command itself surfaces as HomeAssistantError.""" + + mocked_projector.set_power.side_effect = socket_error + + await setup_pjlink_entry(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + media_player.DOMAIN, + "turn_off", + {ATTR_ENTITY_ID: "media_player.test"}, + blocking=True, + )