forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Config Flow to LG Netcast (home-assistant#104913)
* 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
1 parent
a99ecb0
commit f62fb76
Showing
21 changed files
with
1,411 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}") |
Oops, something went wrong.