From d7fb245213a846ec9aafab663cd7d2409cc9b211 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Fri, 30 Aug 2024 22:12:49 +0900 Subject: [PATCH] Add LG ThinQ Integration (#123860) * Add manifest.json * add switch entity * Add tests * fix function's name * adjust the changes after running scipt * Update homeassistant/components/lgthinq/__init__.py Accept the suggested change about format. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Accept suggested change for log removal Co-authored-by: Franck Nijhof * Delete homeassistant/components/lgthinq/services.yaml * Update homeassistant/components/lgthinq/switch.py Accpet suggested change for log removal Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/strings.json Accept suggested change for service removal Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/manifest.json Accept suggested change for spaces removal Co-authored-by: Franck Nijhof * Delete homeassistant/components/lgthinq/icons.json * Update __init__.py Remove unnecessary check code * Modification to pass ruff-format * Modification for mypy issues * Remove service registry and related code * Update strings.json Modification to pass the prettier issues * Update manifest.json Modification to pass the prettier issues * Update homeassistant/components/lgthinq/__init__.py Remove the unnecessary log. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Remove unnecessary log. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Remove unnecessary code. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Co-authored-by: Franck Nijhof * Modifications for the review and related autocheck * Update homeassistant/components/lgthinq/config_flow.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/config_flow.py Co-authored-by: Franck Nijhof * Modifications for reviews and autocheck * Modifications for the reviews and autocheck * Update homeassistant/components/lgthinq/const.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/const.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/const.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/device.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/device.py Co-authored-by: Franck Nijhof * Remove type definition after Final * Update const.py Do not use Final for DOMAIN * Refactoring for reviews - remove thinq.py - remove type definition - remove entry name in config flow - put config flow steps into a single step * Update tests - remove region * Refactoring for reviews - move property.py into PyPI library - replace error_code handling with try/catch - remove http response handling - remove generic - remove unnecessary class or map instance - refactor adding entities logic * Refactoring - remove unused code - change import path * Update tests * Refactoring for reviews 1. Use coordinator extended class instead of LGDevice 2. Rename entity_helper.py to entity.py 3. Move entity description to each entity file 4. Remove dynamic device creation code * Refactoring for reviews * Update requirements * Fix for reviews * Modify tests for reviews * Update for reviews * Remove property info and description class * Update tests/components/lgthinq/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/lgthinq/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lgthinq/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lgthinq/switch.py Co-authored-by: Joost Lekkerkerker * Update tests/components/lgthinq/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update for reviews * Update homeassistant/components/lgthinq/switch.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lgthinq/switch.py Co-authored-by: Joost Lekkerkerker * Update for reviews * Fix ruff issues * Fix ruff check * Fix for reviews * Fix ruff check * Fix for reviews * Fix prettier failure and hassfest failure --------- Co-authored-by: Jangwon Lee Co-authored-by: yunseon.park Co-authored-by: nahyun.lee Co-authored-by: Franck Nijhof Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/lgthinq/__init__.py | 105 +++++++++++++ .../components/lgthinq/config_flow.py | 103 +++++++++++++ homeassistant/components/lgthinq/const.py | 82 ++++++++++ .../components/lgthinq/coordinator.py | 142 ++++++++++++++++++ homeassistant/components/lgthinq/entity.py | 80 ++++++++++ homeassistant/components/lgthinq/icons.json | 9 ++ .../components/lgthinq/manifest.json | 11 ++ homeassistant/components/lgthinq/strings.json | 28 ++++ homeassistant/components/lgthinq/switch.py | 118 +++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/lgthinq/__init__.py | 1 + tests/components/lgthinq/conftest.py | 86 +++++++++++ tests/components/lgthinq/const.py | 8 + tests/components/lgthinq/test_config_flow.py | 66 ++++++++ 18 files changed, 854 insertions(+) create mode 100644 homeassistant/components/lgthinq/__init__.py create mode 100644 homeassistant/components/lgthinq/config_flow.py create mode 100644 homeassistant/components/lgthinq/const.py create mode 100644 homeassistant/components/lgthinq/coordinator.py create mode 100644 homeassistant/components/lgthinq/entity.py create mode 100644 homeassistant/components/lgthinq/icons.json create mode 100644 homeassistant/components/lgthinq/manifest.json create mode 100644 homeassistant/components/lgthinq/strings.json create mode 100644 homeassistant/components/lgthinq/switch.py create mode 100644 tests/components/lgthinq/__init__.py create mode 100644 tests/components/lgthinq/conftest.py create mode 100644 tests/components/lgthinq/const.py create mode 100644 tests/components/lgthinq/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index c31056089de..0ebc49eda50 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -803,6 +803,8 @@ build.json @home-assistant/supervisor /tests/components/lektrico/ @lektrico /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 +/homeassistant/components/lgthinq/ @LG-ThinQ-Integration +/tests/components/lgthinq/ @LG-ThinQ-Integration /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/lifx/ @Djelibeybi diff --git a/homeassistant/components/lgthinq/__init__.py b/homeassistant/components/lgthinq/__init__.py new file mode 100644 index 00000000000..259d494902e --- /dev/null +++ b/homeassistant/components/lgthinq/__init__.py @@ -0,0 +1,105 @@ +"""Support for LG ThinQ Connect device.""" + +from __future__ import annotations + +import asyncio +import logging + +from thinqconnect import ThinQApi, ThinQAPIException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_CONNECT_CLIENT_ID +from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator + +type ThinqConfigEntry = ConfigEntry[dict[str, DeviceDataUpdateCoordinator]] + +PLATFORMS = [Platform.SWITCH] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ThinqConfigEntry) -> bool: + """Set up an entry.""" + access_token = entry.data[CONF_ACCESS_TOKEN] + client_id = entry.data[CONF_CONNECT_CLIENT_ID] + country_code = entry.data[CONF_COUNTRY] + + thinq_api = ThinQApi( + session=async_get_clientsession(hass), + access_token=access_token, + country_code=country_code, + client_id=client_id, + ) + + # Setup coordinators and register devices. + await async_setup_coordinators(hass, entry, thinq_api) + + # Set up all platforms for this device/entry. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Clean up devices they are no longer in use. + async_cleanup_device_registry(hass, entry) + + return True + + +async def async_setup_coordinators( + hass: HomeAssistant, + entry: ThinqConfigEntry, + thinq_api: ThinQApi, +) -> None: + """Set up coordinators and register devices.""" + entry.runtime_data = {} + + # Get a device list from the server. + try: + device_list = await thinq_api.async_get_device_list() + except ThinQAPIException as exc: + raise ConfigEntryNotReady(exc.message) from exc + + if not device_list: + return + + # Setup coordinator per device. + coordinator_list: list[DeviceDataUpdateCoordinator] = [] + task_list = [ + hass.async_create_task(async_setup_device_coordinator(hass, thinq_api, device)) + for device in device_list + ] + task_result = await asyncio.gather(*task_list) + for coordinators in task_result: + if coordinators: + coordinator_list += coordinators + + for coordinator in coordinator_list: + entry.runtime_data[coordinator.unique_id] = coordinator + + +@callback +def async_cleanup_device_registry(hass: HomeAssistant, entry: ThinqConfigEntry) -> None: + """Clean up device registry.""" + new_device_unique_ids = [ + coordinator.unique_id for coordinator in entry.runtime_data.values() + ] + device_registry = dr.async_get(hass) + existing_entries = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + + # Remove devices that are no longer exist. + for old_entry in existing_entries: + old_unique_id = next(iter(old_entry.identifiers))[1] + if old_unique_id not in new_device_unique_ids: + device_registry.async_remove_device(old_entry.id) + _LOGGER.debug("Remove device_registry: device_id=%s", old_entry.id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ThinqConfigEntry) -> bool: + """Unload the entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lgthinq/config_flow.py b/homeassistant/components/lgthinq/config_flow.py new file mode 100644 index 00000000000..cdb41916688 --- /dev/null +++ b/homeassistant/components/lgthinq/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for LG ThinQ.""" + +from __future__ import annotations + +import logging +from typing import Any +import uuid + +from thinqconnect import ThinQApi, ThinQAPIException +from thinqconnect.country import Country +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import CountrySelector, CountrySelectorConfig + +from .const import ( + CLIENT_PREFIX, + CONF_CONNECT_CLIENT_ID, + DEFAULT_COUNTRY, + DOMAIN, + THINQ_DEFAULT_NAME, + THINQ_PAT_URL, +) + +SUPPORTED_COUNTRIES = [country.value for country in Country] + +_LOGGER = logging.getLogger(__name__) + + +class ThinQFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def _get_default_country_code(self) -> str: + """Get the default country code based on config.""" + country = self.hass.config.country + if country is not None and country in SUPPORTED_COUNTRIES: + return country + + return DEFAULT_COUNTRY + + async def _validate_and_create_entry( + self, access_token: str, country_code: str + ) -> ConfigFlowResult: + """Create an entry for the flow.""" + connect_client_id = f"{CLIENT_PREFIX}-{uuid.uuid4()!s}" + + # To verify PAT, create an api to retrieve the device list. + await ThinQApi( + session=async_get_clientsession(self.hass), + access_token=access_token, + country_code=country_code, + client_id=connect_client_id, + ).async_get_device_list() + + # If verification is success, create entry. + return self.async_create_entry( + title=THINQ_DEFAULT_NAME, + data={ + CONF_ACCESS_TOKEN: access_token, + CONF_CONNECT_CLIENT_ID: connect_client_id, + CONF_COUNTRY: country_code, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + access_token = user_input[CONF_ACCESS_TOKEN] + country_code = user_input[CONF_COUNTRY] + + # Check if PAT is already configured. + await self.async_set_unique_id(access_token) + self._abort_if_unique_id_configured() + + try: + return await self._validate_and_create_entry(access_token, country_code) + except ThinQAPIException: + errors["base"] = "token_unauthorized" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required( + CONF_COUNTRY, default=self._get_default_country_code() + ): CountrySelector( + CountrySelectorConfig(countries=SUPPORTED_COUNTRIES) + ), + } + ), + description_placeholders={"pat_url": THINQ_PAT_URL}, + errors=errors, + ) diff --git a/homeassistant/components/lgthinq/const.py b/homeassistant/components/lgthinq/const.py new file mode 100644 index 00000000000..9b9b162bb06 --- /dev/null +++ b/homeassistant/components/lgthinq/const.py @@ -0,0 +1,82 @@ +"""Constants for LG ThinQ.""" + +# Base component constants. +from typing import Final + +from thinqconnect import ( + AirConditionerDevice, + AirPurifierDevice, + AirPurifierFanDevice, + CeilingFanDevice, + CooktopDevice, + DehumidifierDevice, + DeviceType, + DishWasherDevice, + DryerDevice, + HomeBrewDevice, + HoodDevice, + HumidifierDevice, + KimchiRefrigeratorDevice, + MicrowaveOvenDevice, + OvenDevice, + PlantCultivatorDevice, + RefrigeratorDevice, + RobotCleanerDevice, + StickCleanerDevice, + StylerDevice, + SystemBoilerDevice, + WashcomboMainDevice, + WashcomboMiniDevice, + WasherDevice, + WashtowerDevice, + WashtowerDryerDevice, + WashtowerWasherDevice, + WaterHeaterDevice, + WaterPurifierDevice, + WineCellarDevice, +) + +# Common +DOMAIN = "lgthinq" +COMPANY = "LGE" +THINQ_DEFAULT_NAME: Final = "LG ThinQ" +THINQ_PAT_URL: Final = "https://connect-pat.lgthinq.com" + +# Config Flow +CLIENT_PREFIX: Final = "home-assistant" +CONF_CONNECT_CLIENT_ID: Final = "connect_client_id" +DEFAULT_COUNTRY: Final = "US" + +THINQ_DEVICE_ADDED: Final = "thinq_device_added" + +DEVICE_TYPE_API_MAP: Final = { + DeviceType.AIR_CONDITIONER: AirConditionerDevice, + DeviceType.AIR_PURIFIER_FAN: AirPurifierFanDevice, + DeviceType.AIR_PURIFIER: AirPurifierDevice, + DeviceType.CEILING_FAN: CeilingFanDevice, + DeviceType.COOKTOP: CooktopDevice, + DeviceType.DEHUMIDIFIER: DehumidifierDevice, + DeviceType.DISH_WASHER: DishWasherDevice, + DeviceType.DRYER: DryerDevice, + DeviceType.HOME_BREW: HomeBrewDevice, + DeviceType.HOOD: HoodDevice, + DeviceType.HUMIDIFIER: HumidifierDevice, + DeviceType.KIMCHI_REFRIGERATOR: KimchiRefrigeratorDevice, + DeviceType.MICROWAVE_OVEN: MicrowaveOvenDevice, + DeviceType.OVEN: OvenDevice, + DeviceType.PLANT_CULTIVATOR: PlantCultivatorDevice, + DeviceType.REFRIGERATOR: RefrigeratorDevice, + DeviceType.ROBOT_CLEANER: RobotCleanerDevice, + DeviceType.STICK_CLEANER: StickCleanerDevice, + DeviceType.STYLER: StylerDevice, + DeviceType.SYSTEM_BOILER: SystemBoilerDevice, + DeviceType.WASHER: WasherDevice, + DeviceType.WASHCOMBO_MAIN: WashcomboMainDevice, + DeviceType.WASHCOMBO_MINI: WashcomboMiniDevice, + DeviceType.WASHTOWER_DRYER: WashtowerDryerDevice, + DeviceType.WASHTOWER: WashtowerDevice, + DeviceType.WASHTOWER_WASHER: WashtowerWasherDevice, + DeviceType.WATER_HEATER: WaterHeaterDevice, + DeviceType.WATER_PURIFIER: WaterPurifierDevice, + DeviceType.WINE_CELLAR: WineCellarDevice, +} diff --git a/homeassistant/components/lgthinq/coordinator.py b/homeassistant/components/lgthinq/coordinator.py new file mode 100644 index 00000000000..1a23b70d8a7 --- /dev/null +++ b/homeassistant/components/lgthinq/coordinator.py @@ -0,0 +1,142 @@ +"""DataUpdateCoordinator for the LG ThinQ device.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import ConnectBaseDevice, DeviceType, ThinQApi, ThinQAPIException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEVICE_TYPE_API_MAP, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """LG Device's Data Update Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + device_api: ConnectBaseDevice, + *, + sub_id: str | None = None, + ) -> None: + """Initialize data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{device_api.device_id}", + ) + + # For washTower's washer or dryer + self.sub_id = sub_id + + # The device name is usually set to 'alias'. + # But, if the sub_id exists, it will be set to 'alias {sub_id}'. + # e.g. alias='MyWashTower', sub_id='dryer' then 'MyWashTower dryer'. + self.device_name = ( + f"{device_api.alias} {self.sub_id}" if self.sub_id else device_api.alias + ) + + # The unique id is usually set to 'device_id'. + # But, if the sub_id exists, it will be set to 'device_id_{sub_id}'. + # e.g. device_id='TQSXXXX', sub_id='dryer' then 'TQSXXXX_dryer'. + self.unique_id = ( + f"{device_api.device_id}_{self.sub_id}" + if self.sub_id + else device_api.device_id + ) + + # Get the api instance. + self.device_api = device_api.get_sub_device(self.sub_id) or device_api + + async def _async_update_data(self) -> dict[str, Any]: + """Request to the server to update the status from full response data.""" + try: + data = await self.device_api.thinq_api.async_get_device_status( + self.device_api.device_id + ) + except ThinQAPIException as exc: + raise UpdateFailed(exc) from exc + + # Full response data into the device api. + self.device_api.set_status(data) + return data + + +async def async_setup_device_coordinator( + hass: HomeAssistant, thinq_api: ThinQApi, device: dict[str, Any] +) -> list[DeviceDataUpdateCoordinator] | None: + """Create DeviceDataUpdateCoordinator and device_api per device.""" + device_id = device["deviceId"] + device_info = device["deviceInfo"] + + # Get an appropriate class constructor for the device type. + device_type = device_info.get("deviceType") + constructor = DEVICE_TYPE_API_MAP.get(device_type) + if constructor is None: + _LOGGER.error( + "Failed to setup device(%s): not supported device. type=%s", + device_id, + device_type, + ) + return None + + # Get a device profile from the server. + try: + profile = await thinq_api.async_get_device_profile(device_id) + except ThinQAPIException: + _LOGGER.warning("Failed to setup device(%s): no profile", device_id) + return None + + device_group_id = device_info.get("groupId") + + # Create new device api instance. + device_api: ConnectBaseDevice = ( + constructor( + thinq_api=thinq_api, + device_id=device_id, + device_type=device_type, + model_name=device_info.get("modelName"), + alias=device_info.get("alias"), + group_id=device_group_id, + reportable=device_info.get("reportable"), + profile=profile, + ) + if device_group_id + else constructor( + thinq_api=thinq_api, + device_id=device_id, + device_type=device_type, + model_name=device_info.get("modelName"), + alias=device_info.get("alias"), + reportable=device_info.get("reportable"), + profile=profile, + ) + ) + + # Create a list of sub-devices from the profile. + # Note that some devices may have more than two device profiles. + # In this case we should create multiple lg device instance. + # e.g. 'WashTower-Single-Unit' = 'WashTower{dryer}' + 'WashTower{washer}'. + device_sub_ids = ( + list(profile.keys()) + if device_type == DeviceType.WASHTOWER and "property" not in profile + else [None] + ) + + # Create new device coordinator instances. + coordinator_list: list[DeviceDataUpdateCoordinator] = [] + for sub_id in device_sub_ids: + coordinator = DeviceDataUpdateCoordinator(hass, device_api, sub_id=sub_id) + await coordinator.async_config_entry_first_refresh() + + # Finally add a device coordinator into the result list. + coordinator_list.append(coordinator) + _LOGGER.debug("Setup device's coordinator: %s", coordinator) + + return coordinator_list diff --git a/homeassistant/components/lgthinq/entity.py b/homeassistant/components/lgthinq/entity.py new file mode 100644 index 00000000000..151687aabb8 --- /dev/null +++ b/homeassistant/components/lgthinq/entity.py @@ -0,0 +1,80 @@ +"""Base class for ThinQ entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import ThinQAPIException +from thinqconnect.integration.homeassistant.property import Property as ThinQProperty + +from homeassistant.core import callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import COMPANY, DOMAIN +from .coordinator import DeviceDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): + """The base implementation of all lg thinq entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: EntityDescription, + property: ThinQProperty, + ) -> None: + """Initialize an entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self.property = property + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, coordinator.unique_id)}, + manufacturer=COMPANY, + model=coordinator.device_api.model_name, + name=coordinator.device_name, + ) + + # Set the unique key. If there exist a location, add the prefix location name. + unique_key = ( + f"{entity_description.key}" + if property.location is None + else f"{property.location}_{entity_description.key}" + ) + self._attr_unique_id = f"{coordinator.unique_id}_{unique_key}" + + # Update initial status. + self._update_status() + + async def async_post_value(self, value: Any) -> None: + """Post the value of entity to server.""" + try: + await self.property.async_post_value(value) + except ThinQAPIException as exc: + raise ServiceValidationError( + exc.message, + translation_domain=DOMAIN, + translation_key=exc.code, + ) from exc + finally: + await self.coordinator.async_request_refresh() + + def _update_status(self) -> None: + """Update status itself. + + All inherited classes can update their own status in here. + """ + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_status() + self.async_write_ha_state() diff --git a/homeassistant/components/lgthinq/icons.json b/homeassistant/components/lgthinq/icons.json new file mode 100644 index 00000000000..6a4ff48494a --- /dev/null +++ b/homeassistant/components/lgthinq/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "operation_power": { + "default": "mdi:power" + } + } + } +} diff --git a/homeassistant/components/lgthinq/manifest.json b/homeassistant/components/lgthinq/manifest.json new file mode 100644 index 00000000000..641c78844f9 --- /dev/null +++ b/homeassistant/components/lgthinq/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lgthinq", + "name": "LG ThinQ", + "codeowners": ["@LG-ThinQ-Integration"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/lgthinq/", + "iot_class": "cloud_push", + "loggers": ["thinqconnect"], + "requirements": ["thinqconnect==0.9.5"] +} diff --git a/homeassistant/components/lgthinq/strings.json b/homeassistant/components/lgthinq/strings.json new file mode 100644 index 00000000000..6334fd9a893 --- /dev/null +++ b/homeassistant/components/lgthinq/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "token_unauthorized": "The token is invalid or unauthorized." + }, + "step": { + "user": { + "title": "Connect to ThinQ", + "description": "Please enter a ThinQ [PAT(Personal Access Token)]({pat_url}) created with your LG ThinQ account.", + "data": { + "access_token": "Personal Access Token", + "country": "Country" + } + } + } + }, + "entity": { + "switch": { + "operation_power": { + "name": "Power" + } + } + } +} diff --git a/homeassistant/components/lgthinq/switch.py b/homeassistant/components/lgthinq/switch.py new file mode 100644 index 00000000000..ee7dfdb02d7 --- /dev/null +++ b/homeassistant/components/lgthinq/switch.py @@ -0,0 +1,118 @@ +"""Support for switch entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import PROPERTY_WRITABLE, DeviceType +from thinqconnect.devices.const import Property as ThinQProperty +from thinqconnect.integration.homeassistant.property import create_properties + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ThinqConfigEntry +from .entity import ThinQEntity + +OPERATION_SWITCH_DESC: dict[ThinQProperty, SwitchEntityDescription] = { + ThinQProperty.AIR_FAN_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.AIR_FAN_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.AIR_PURIFIER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.AIR_PURIFIER_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.BOILER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.BOILER_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.DEHUMIDIFIER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.DEHUMIDIFIER_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.HUMIDIFIER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.HUMIDIFIER_OPERATION_MODE, + translation_key="operation_power", + ), +} + +DEVIE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[SwitchEntityDescription, ...]] = { + DeviceType.AIR_PURIFIER_FAN: ( + OPERATION_SWITCH_DESC[ThinQProperty.AIR_FAN_OPERATION_MODE], + ), + DeviceType.AIR_PURIFIER: ( + OPERATION_SWITCH_DESC[ThinQProperty.AIR_PURIFIER_OPERATION_MODE], + ), + DeviceType.DEHUMIDIFIER: ( + OPERATION_SWITCH_DESC[ThinQProperty.DEHUMIDIFIER_OPERATION_MODE], + ), + DeviceType.HUMIDIFIER: ( + OPERATION_SWITCH_DESC[ThinQProperty.HUMIDIFIER_OPERATION_MODE], + ), + DeviceType.SYSTEM_BOILER: ( + OPERATION_SWITCH_DESC[ThinQProperty.BOILER_OPERATION_MODE], + ), +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThinqConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up an entry for switch platform.""" + entities: list[ThinQSwitchEntity] = [] + for coordinator in entry.runtime_data.values(): + if ( + descriptions := DEVIE_TYPE_SWITCH_MAP.get( + coordinator.device_api.device_type + ) + ) is not None: + for description in descriptions: + properties = create_properties( + device_api=coordinator.device_api, + key=description.key, + children_keys=None, + rw_type=PROPERTY_WRITABLE, + ) + if not properties: + continue + + entities.extend( + ThinQSwitchEntity(coordinator, description, prop) + for prop in properties + ) + + if entities: + async_add_entities(entities) + + +class ThinQSwitchEntity(ThinQEntity, SwitchEntity): + """Represent a thinq switch platform.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + + self._attr_is_on = self.property.get_value_as_bool() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + _LOGGER.debug("[%s] async_turn_on", self.name) + await self.async_post_value("POWER_ON") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + _LOGGER.debug("[%s] async_turn_off", self.name) + await self.async_post_value("POWER_OFF") diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0ca3335725f..1756a896d25 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -318,6 +318,7 @@ FLOWS = { "lektrico", "lg_netcast", "lg_soundbar", + "lgthinq", "lidarr", "lifx", "linear_garage_door", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2e9199a3b0a..d7cfe503dd9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3246,6 +3246,12 @@ } } }, + "lgthinq": { + "name": "LG ThinQ", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "lidarr": { "name": "Lidarr", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 1bfe1756173..8f89d72d9a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2785,6 +2785,9 @@ thermoworks-smoke==0.1.8 # homeassistant.components.thingspeak thingspeak==1.0.0 +# homeassistant.components.lgthinq +thinqconnect==0.9.5 + # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a63a54d0b8..a1862a1340d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2198,6 +2198,9 @@ thermobeacon-ble==0.7.0 # homeassistant.components.thermopro thermopro-ble==0.10.0 +# homeassistant.components.lgthinq +thinqconnect==0.9.5 + # homeassistant.components.tilt_ble tilt-ble==0.2.3 diff --git a/tests/components/lgthinq/__init__.py b/tests/components/lgthinq/__init__.py new file mode 100644 index 00000000000..68ffb960f71 --- /dev/null +++ b/tests/components/lgthinq/__init__.py @@ -0,0 +1 @@ +"""Tests for the lgthinq integration.""" diff --git a/tests/components/lgthinq/conftest.py b/tests/components/lgthinq/conftest.py new file mode 100644 index 00000000000..321c770ee8d --- /dev/null +++ b/tests/components/lgthinq/conftest.py @@ -0,0 +1,86 @@ +"""Configure tests for the LGThinQ integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from thinqconnect import ThinQAPIException + +from homeassistant.components.lgthinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY + +from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID + +from tests.common import MockConfigEntry + + +def mock_thinq_api_response( + *, + status: int = 200, + body: dict | None = None, + error_code: str | None = None, + error_message: str | None = None, +) -> MagicMock: + """Create a mock thinq api response.""" + response = MagicMock() + response.status = status + response.body = body + response.error_code = error_code + response.error_message = error_message + return response + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=f"Test {DOMAIN}", + unique_id=MOCK_PAT, + data={ + CONF_ACCESS_TOKEN: MOCK_PAT, + CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, + CONF_COUNTRY: MOCK_COUNTRY, + }, + ) + + +@pytest.fixture +def mock_uuid() -> Generator[AsyncMock]: + """Mock a uuid.""" + with ( + patch("uuid.uuid4", autospec=True, return_value=MOCK_UUID) as mock_uuid, + patch( + "homeassistant.components.lgthinq.config_flow.uuid.uuid4", + new=mock_uuid, + ), + ): + yield mock_uuid.return_value + + +@pytest.fixture +def mock_thinq_api() -> Generator[AsyncMock]: + """Mock a thinq api.""" + with ( + patch("thinqconnect.ThinQApi", autospec=True) as mock_api, + patch( + "homeassistant.components.lgthinq.config_flow.ThinQApi", + new=mock_api, + ), + ): + thinq_api = mock_api.return_value + thinq_api.async_get_device_list = AsyncMock( + return_value=mock_thinq_api_response(status=200, body={}) + ) + yield thinq_api + + +@pytest.fixture +def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: + """Mock an invalid thinq api.""" + mock_thinq_api.async_get_device_list = AsyncMock( + side_effect=ThinQAPIException( + code="1309", message="Not allowed api call", headers=None + ) + ) + return mock_thinq_api diff --git a/tests/components/lgthinq/const.py b/tests/components/lgthinq/const.py new file mode 100644 index 00000000000..f46baa61c38 --- /dev/null +++ b/tests/components/lgthinq/const.py @@ -0,0 +1,8 @@ +"""Constants for lgthinq test.""" + +from typing import Final + +MOCK_PAT: Final[str] = "123abc4567de8f90g123h4ij56klmn789012p345rst6uvw789xy" +MOCK_UUID: Final[str] = "1b3deabc-123d-456d-987d-2a1c7b3bdb67" +MOCK_CONNECT_CLIENT_ID: Final[str] = f"home-assistant-{MOCK_UUID}" +MOCK_COUNTRY: Final[str] = "KR" diff --git a/tests/components/lgthinq/test_config_flow.py b/tests/components/lgthinq/test_config_flow.py new file mode 100644 index 00000000000..457549ccb7e --- /dev/null +++ b/tests/components/lgthinq/test_config_flow.py @@ -0,0 +1,66 @@ +"""Test the lgthinq config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.lgthinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT + +from tests.common import MockConfigEntry + + +async def test_config_flow( + hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_uuid: AsyncMock +) -> None: + """Test that an thinq entry is normally created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ACCESS_TOKEN: MOCK_PAT, + CONF_COUNTRY: MOCK_COUNTRY, + CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, + } + + mock_thinq_api.async_get_device_list.assert_called_once() + + +async def test_config_flow_invalid_pat( + hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock +) -> None: + """Test that an thinq flow should be aborted with an invalid PAT.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "token_unauthorized"} + mock_invalid_thinq_api.async_get_device_list.assert_called_once() + + +async def test_config_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_thinq_api: AsyncMock +) -> None: + """Test that thinq flow should be aborted when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"