Skip to content

Commit

Permalink
Add translated action exceptions to LG webOS TV (#136397)
Browse files Browse the repository at this point in the history
* Add translated action exceptions to LG webOS TV

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <[email protected]>

---------

Co-authored-by: Joost Lekkerkerker <[email protected]>
  • Loading branch information
thecode and joostlek authored Jan 24, 2025
1 parent 3bbcd37 commit fe67069
Show file tree
Hide file tree
Showing 13 changed files with 213 additions and 170 deletions.
18 changes: 0 additions & 18 deletions homeassistant/components/webostv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,6 @@ async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -
await hass.config_entries.async_reload(entry.entry_id)


async def async_control_connect(
hass: HomeAssistant, host: str, key: str | None
) -> WebOsClient:
"""LG Connection."""
client = WebOsClient(
host,
key,
client_session=async_get_clientsession(hass),
)
try:
await client.connect()
except WebOsTvPairError:
_LOGGER.warning("Connected to LG webOS TV %s but not paired", host)
raise

return client


def update_client_key(
hass: HomeAssistant, entry: ConfigEntry, client: WebOsClient
) -> None:
Expand Down
35 changes: 28 additions & 7 deletions homeassistant/components/webostv/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@
from typing import Any, Self
from urllib.parse import urlparse

from aiowebostv import WebOsTvPairError
from aiowebostv import WebOsClient, WebOsTvPairError
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_FRIENDLY_NAME,
ATTR_UPNP_UDN,
SsdpServiceInfo,
)

from . import WebOsTvConfigEntry, async_control_connect
from . import WebOsTvConfigEntry
from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS
from .helpers import async_get_sources
from .helpers import get_sources

DATA_SCHEMA = vol.Schema(
{
Expand All @@ -31,6 +32,21 @@
)


async def async_control_connect(
hass: HomeAssistant, host: str, key: str | None
) -> WebOsClient:
"""Create LG WebOS client and connect to the TV."""
client = WebOsClient(
host,
key,
client_session=async_get_clientsession(hass),
)

await client.connect()

return client


class FlowHandler(ConfigFlow, domain=DOMAIN):
"""WebosTV configuration flow."""

Expand Down Expand Up @@ -195,9 +211,14 @@ async def async_step_init(
options_input = {CONF_SOURCES: user_input[CONF_SOURCES]}
return self.async_create_entry(title="", data=options_input)
# Get sources
sources_list = await async_get_sources(self.hass, self.host, self.key)
if not sources_list:
errors["base"] = "cannot_retrieve"
sources_list = []
try:
client = await async_control_connect(self.hass, self.host, self.key)
sources_list = get_sources(client)
except WebOsTvPairError:
errors["base"] = "error_pairing"
except WEBOSTV_EXCEPTIONS:
errors["base"] = "cannot_connect"

option_sources = self.config_entry.options.get(CONF_SOURCES, [])
sources = [s for s in option_sources if s in sources_list]
Expand Down
8 changes: 6 additions & 2 deletions homeassistant/components/webostv/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType

from . import trigger
from . import DOMAIN, trigger
from .helpers import (
async_get_client_by_device_entry,
async_get_device_entry_by_device_id,
Expand Down Expand Up @@ -75,4 +75,8 @@ async def async_attach_trigger(
hass, trigger_config, action, trigger_info
)

raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unhandled_trigger_type",
translation_placeholders={"trigger_type": trigger_type},
)
11 changes: 3 additions & 8 deletions homeassistant/components/webostv/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry

from . import WebOsTvConfigEntry, async_control_connect
from .const import DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS
from . import WebOsTvConfigEntry
from .const import DOMAIN, LIVE_TV_APP_ID


@callback
Expand Down Expand Up @@ -72,13 +72,8 @@ def async_get_client_by_device_entry(
)


async def async_get_sources(hass: HomeAssistant, host: str, key: str) -> list[str]:
def get_sources(client: WebOsClient) -> list[str]:
"""Construct sources list."""
try:
client = await async_control_connect(hass, host, key)
except WEBOSTV_EXCEPTIONS:
return []

sources = []
found_live_tv = False
for app in client.apps.values():
Expand Down
32 changes: 19 additions & 13 deletions homeassistant/components/webostv/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,27 @@ def cmd[_T: LgWebOSMediaPlayerEntity, **_P](
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
if self.state is MediaPlayerState.OFF:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_off",
translation_placeholders={
"name": str(self._entry.title),
"func": func.__name__,
},
)
try:
await func(self, *args, **kwargs)
except WEBOSTV_EXCEPTIONS as exc:
if self.state != MediaPlayerState.OFF:
raise HomeAssistantError(
f"Error calling {func.__name__} on entity {self.entity_id},"
f" state:{self.state}"
) from exc
_LOGGER.warning(
"Error calling %s on entity %s, state:%s, error: %r",
func.__name__,
self.entity_id,
self.state,
exc,
)
except WEBOSTV_EXCEPTIONS as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={
"name": str(self._entry.title),
"func": func.__name__,
"error": str(error),
},
) from error

return cmd_wrapper

Expand Down
59 changes: 39 additions & 20 deletions homeassistant/components/webostv/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@

from __future__ import annotations

import logging
from typing import Any

from aiowebostv import WebOsClient, WebOsTvPairError
from aiowebostv import WebOsClient

from homeassistant.components.notify import ATTR_DATA, BaseNotificationService
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

from .const import ATTR_CONFIG_ENTRY_ID, WEBOSTV_EXCEPTIONS

_LOGGER = logging.getLogger(__name__)
from . import WebOsTvConfigEntry
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS

PARALLEL_UPDATES = 0

Expand All @@ -34,28 +33,48 @@ async def async_get_service(
)
assert config_entry is not None

return LgWebOSNotificationService(config_entry.runtime_data)
return LgWebOSNotificationService(config_entry)


class LgWebOSNotificationService(BaseNotificationService):
"""Implement the notification service for LG WebOS TV."""

def __init__(self, client: WebOsClient) -> None:
def __init__(self, entry: WebOsTvConfigEntry) -> None:
"""Initialize the service."""
self._client = client
self._entry = entry

async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the tv."""
client: WebOsClient = self._entry.runtime_data
data = kwargs[ATTR_DATA]
icon_path = data.get(ATTR_ICON) if data else None

if not client.is_on:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="notify_device_off",
translation_placeholders={
"name": str(self._entry.title),
"func": __name__,
},
)
try:
if not self._client.is_connected():
await self._client.connect()

data = kwargs[ATTR_DATA]
icon_path = data.get(ATTR_ICON) if data else None
await self._client.send_message(message, icon_path=icon_path)
except WebOsTvPairError:
_LOGGER.error("Pairing with TV failed")
except FileNotFoundError:
_LOGGER.error("Icon %s not found", icon_path)
except WEBOSTV_EXCEPTIONS:
_LOGGER.error("TV unreachable")
await client.send_message(message, icon_path=icon_path)
except FileNotFoundError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="notify_icon_not_found",
translation_placeholders={
"name": str(self._entry.title),
"icon_path": str(icon_path),
},
) from error
except WEBOSTV_EXCEPTIONS as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="notify_communication_error",
translation_placeholders={
"name": str(self._entry.title),
"error": str(error),
},
) from error
2 changes: 1 addition & 1 deletion homeassistant/components/webostv/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ rules:
entity-translations:
status: exempt
comment: There are no entities to translate.
exception-translations: todo
exception-translations: done
icon-translations:
status: exempt
comment: The only entity can use the device class.
Expand Down
23 changes: 22 additions & 1 deletion homeassistant/components/webostv/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
}
},
"error": {
"cannot_retrieve": "Unable to retrieve the list of sources. Make sure device is switched on"
"cannot_connect": "[%key:component::webostv::config::error::cannot_connect%]",
"error_pairing": "[%key:component::webostv::config::error::error_pairing%]"
}
},
"device_automation": {
Expand Down Expand Up @@ -109,5 +110,25 @@
}
}
}
},
"exceptions": {
"device_off": {
"message": "Error calling {func} for device {name}: Device is off and cannot be controlled."
},
"communication_error": {
"message": "Communication error while calling {func} for device {name}: {error}"
},
"notify_device_off": {
"message": "Error sending notification to device {name}: Device is off and cannot be controlled."
},
"notify_icon_not_found": {
"message": "Icon {icon_path} not found when sending notification for device {name}"
},
"notify_communication_error": {
"message": "Communication error while sending notification to device {name}: {error}"
},
"unhandled_trigger_type": {
"message": "Unhandled trigger type: {trigger_type}"
}
}
}
12 changes: 9 additions & 3 deletions tests/components/webostv/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture(name="client")
def client_fixture():
"""Patch of client library for tests."""
with patch(
"homeassistant.components.webostv.WebOsClient", autospec=True
) as mock_client_class:
with (
patch(
"homeassistant.components.webostv.WebOsClient", autospec=True
) as mock_client_class,
patch(
"homeassistant.components.webostv.config_flow.WebOsClient",
new=mock_client_class,
),
):
client = mock_client_class.return_value
client.hello_info = {"deviceUUID": FAKE_UUID}
client.software_info = {"major_ver": "major", "minor_ver": "minor"}
Expand Down
17 changes: 13 additions & 4 deletions tests/components/webostv/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,25 @@ async def test_options_flow_live_tv_in_apps(
assert result["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"]


async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None:
"""Test options config flow cannot retrieve sources."""
@pytest.mark.parametrize(
("side_effect", "error"),
[
(WebOsTvPairError, "error_pairing"),
(ConnectionResetError, "cannot_connect"),
],
)
async def test_options_flow_errors(
hass: HomeAssistant, client, side_effect, error
) -> None:
"""Test options config flow errors."""
entry = await setup_webostv(hass)

client.connect.side_effect = ConnectionResetError
client.connect.side_effect = side_effect
result = await hass.config_entries.options.async_init(entry.entry_id)
await hass.async_block_till_done()

assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_retrieve"}
assert result["errors"] == {"base": error}

# recover
client.connect.side_effect = None
Expand Down
2 changes: 1 addition & 1 deletion tests/components/webostv/test_device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ async def test_invalid_trigger_raises(
await setup_webostv(hass)

# Test wrong trigger platform type
with pytest.raises(HomeAssistantError):
with pytest.raises(HomeAssistantError, match="Unhandled trigger type: wrong.type"):
await device_trigger.async_attach_trigger(
hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {}
)
Expand Down
Loading

0 comments on commit fe67069

Please sign in to comment.