Fix PJLink service errors breaking automation continue_on_error (#182186)

This commit is contained in:
Abdellatif Anaflous
2026-09-15 17:48:53 +02:00
committed by GitHub
parent b022ccf137
commit 533c14c70b
2 changed files with 99 additions and 9 deletions
@@ -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))
@@ -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,
)