Skip to content

Commit

Permalink
Add Config Flow to LG Netcast (home-assistant#104913)
Browse files Browse the repository at this point in the history
* Add Config Flow to lg_netcast

* Add YAML import to Lg Netcast ConfigFlow

Deprecates YAML config support

* Add LG Netcast Device triggers for turn_on action

* Add myself to LG Netcast codeowners

* Remove unnecessary user_input validation check.

* Move netcast discovery logic to the backend

* Use FlowResultType Enum for tests

* Mock pylgnetcast.query_device_info instead of _send_to_tv

* Refactor lg_netcast client discovery, simplify YAML import

* Simplify CONF_NAME to use friendly name

Fix: Use Friendly name for Name

* Expose model to DeviceInfo

* Add test for testing YAML import when not TV not online

* Switch to entity_name for LGTVDevice

* Add data_description to host field in user step

* Wrap try only around _get_session_id

* Send regular request for access_token to ensure it display on the TV

* Stop displaying access token when flow is aborted

* Remove config_flow only consts and minor fixups

* Simplify media_player logic & raise new migration issue

* Add async_unload_entry

* Create issues when import config flow fails, and raise only a single yaml deprecation issue type

* Remove single use trigger helpers

* Bump issue deprecation breakage version

* Lint

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
  • Loading branch information
splinter98 and emontnemery authored Apr 16, 2024
1 parent a99ecb0 commit f62fb76
Show file tree
Hide file tree
Showing 21 changed files with 1,411 additions and 30 deletions.
3 changes: 2 additions & 1 deletion CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,8 @@ build.json @home-assistant/supervisor
/tests/components/leaone/ @bdraco
/homeassistant/components/led_ble/ @bdraco
/tests/components/led_ble/ @bdraco
/homeassistant/components/lg_netcast/ @Drafteed
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob
/homeassistant/components/light/ @home-assistant/core
Expand Down
32 changes: 32 additions & 0 deletions homeassistant/components/lg_netcast/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,33 @@
"""The lg_netcast component."""

from typing import Final

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv

from .const import DOMAIN

PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER]

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up a config entry."""
hass.data.setdefault(DOMAIN, {})

await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

if unload_ok:
del hass.data[DOMAIN][entry.entry_id]

return unload_ok
217 changes: 217 additions & 0 deletions homeassistant/components/lg_netcast/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""Config flow to configure the LG Netcast TV integration."""

from __future__ import annotations

import contextlib
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any

from pylgnetcast import AccessTokenError, LgNetCastClient, SessionIdError
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_HOST,
CONF_ID,
CONF_MODEL,
CONF_NAME,
)
from homeassistant.core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util.network import is_host_valid

from .const import DEFAULT_NAME, DOMAIN
from .helpers import LGNetCastDetailDiscoveryError, async_discover_netcast_details

DISPLAY_ACCESS_TOKEN_INTERVAL = timedelta(seconds=1)


class LGNetCast(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LG Netcast TV integration."""

VERSION = 1

def __init__(self) -> None:
"""Initialize config flow."""
self.client: LgNetCastClient | None = None
self.device_config: dict[str, Any] = {}
self._discovered_devices: dict[str, Any] = {}
self._track_interval: CALLBACK_TYPE | None = None

def create_client(self) -> None:
"""Create LG Netcast client from config."""
host = self.device_config[CONF_HOST]
access_token = self.device_config.get(CONF_ACCESS_TOKEN)
self.client = LgNetCastClient(host, access_token)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}

if user_input is not None:
host = user_input[CONF_HOST]
if is_host_valid(host):
self.device_config[CONF_HOST] = host
return await self.async_step_authorize()

errors[CONF_HOST] = "invalid_host"

return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)

async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult:
"""Import configuration from yaml."""
self.device_config = {
CONF_HOST: config[CONF_HOST],
CONF_NAME: config[CONF_NAME],
}

def _create_issue():
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.11.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "LG Netcast",
},
)

try:
result: ConfigFlowResult = await self.async_step_authorize(config)
except AbortFlow as err:
if err.reason != "already_configured":
async_create_issue(
self.hass,
DOMAIN,
"deprecated_yaml_import_issue_{err.reason}",
breaks_in_ha_version="2024.11.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{err.reason}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "LG Netcast",
"error_type": err.reason,
},
)
else:
_create_issue()
raise

_create_issue()

return result

async def async_discover_client(self):
"""Handle Discovery step."""
self.create_client()

if TYPE_CHECKING:
assert self.client is not None

if self.device_config.get(CONF_ID):
return

try:
details = await async_discover_netcast_details(self.hass, self.client)
except LGNetCastDetailDiscoveryError as err:
raise AbortFlow("cannot_connect") from err

if (unique_id := details["uuid"]) is None:
raise AbortFlow("invalid_host")

self.device_config[CONF_ID] = unique_id
self.device_config[CONF_MODEL] = details["model_name"]

if CONF_NAME not in self.device_config:
self.device_config[CONF_NAME] = details["friendly_name"] or DEFAULT_NAME

async def async_step_authorize(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle Authorize step."""
errors: dict[str, str] = {}
self.async_stop_display_access_token()

if user_input is not None and user_input.get(CONF_ACCESS_TOKEN) is not None:
self.device_config[CONF_ACCESS_TOKEN] = user_input[CONF_ACCESS_TOKEN]

await self.async_discover_client()
assert self.client is not None

await self.async_set_unique_id(self.device_config[CONF_ID])
self._abort_if_unique_id_configured(
updates={CONF_HOST: self.device_config[CONF_HOST]}
)

try:
await self.hass.async_add_executor_job(
self.client._get_session_id # pylint: disable=protected-access
)
except AccessTokenError:
if user_input is not None:
errors[CONF_ACCESS_TOKEN] = "invalid_access_token"
except SessionIdError:
errors["base"] = "cannot_connect"
else:
return await self.async_create_device()

self._track_interval = async_track_time_interval(
self.hass,
self.async_display_access_token,
DISPLAY_ACCESS_TOKEN_INTERVAL,
cancel_on_shutdown=True,
)

return self.async_show_form(
step_id="authorize",
data_schema=vol.Schema(
{
vol.Optional(CONF_ACCESS_TOKEN): vol.All(str, vol.Length(max=6)),
}
),
errors=errors,
)

async def async_display_access_token(self, _: datetime | None = None):
"""Display access token on screen."""
assert self.client is not None
with contextlib.suppress(AccessTokenError, SessionIdError):
await self.hass.async_add_executor_job(
self.client._get_session_id # pylint: disable=protected-access
)

@callback
def async_remove(self):
"""Terminate Access token display if flow is removed."""
self.async_stop_display_access_token()

def async_stop_display_access_token(self):
"""Stop Access token request if running."""
if self._track_interval is not None:
self._track_interval()
self._track_interval = None

async def async_create_device(self) -> ConfigFlowResult:
"""Create LG Netcast TV Device from config."""
assert self.client

return self.async_create_entry(
title=self.device_config[CONF_NAME], data=self.device_config
)
6 changes: 6 additions & 0 deletions homeassistant/components/lg_netcast/const.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""Constants for the lg_netcast component."""

from typing import Final

ATTR_MANUFACTURER: Final = "LG"

DEFAULT_NAME: Final = "LG Netcast TV"

DOMAIN = "lg_netcast"
88 changes: 88 additions & 0 deletions homeassistant/components/lg_netcast/device_trigger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Provides device triggers for LG Netcast."""

from __future__ import annotations

from typing import Any

import voluptuous as vol

from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType

from . import trigger
from .const import DOMAIN
from .helpers import async_get_device_entry_by_device_id
from .triggers.turn_on import (
PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE,
async_get_turn_on_trigger,
)

TRIGGER_TYPES = {TURN_ON_PLATFORM_TYPE}

TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
}
)


async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
config = TRIGGER_SCHEMA(config)

if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE:
device_id = config[CONF_DEVICE_ID]

try:
device = async_get_device_entry_by_device_id(hass, device_id)
except ValueError as err:
raise InvalidDeviceAutomationConfig(err) from err

if DOMAIN in hass.data:
for config_entry_id in device.config_entries:
if hass.data[DOMAIN].get(config_entry_id):
break
else:
raise InvalidDeviceAutomationConfig(
f"Device {device.id} is not from an existing {DOMAIN} config entry"
)

return config


async def async_get_triggers(
_hass: HomeAssistant, device_id: str
) -> list[dict[str, Any]]:
"""List device triggers for LG Netcast devices."""
return [async_get_turn_on_trigger(device_id)]


async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
if (trigger_type := config[CONF_TYPE]) == TURN_ON_PLATFORM_TYPE:
trigger_config = {
CONF_PLATFORM: trigger_type,
CONF_DEVICE_ID: config[CONF_DEVICE_ID],
}
trigger_config = await trigger.async_validate_trigger_config(
hass, trigger_config
)
return await trigger.async_attach_trigger(
hass, trigger_config, action, trigger_info
)

raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")
Loading

0 comments on commit f62fb76

Please sign in to comment.