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 <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/__init__.py

Accept suggested change for log removal

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Delete homeassistant/components/lgthinq/services.yaml

* Update homeassistant/components/lgthinq/switch.py

Accpet suggested change for log removal

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/strings.json

Accept suggested change for service removal

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/manifest.json

Accept suggested change for spaces removal

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* 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 <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/__init__.py

Remove unnecessary log.

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/__init__.py

Remove unnecessary code.

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Modifications for the review and related autocheck

* Update homeassistant/components/lgthinq/config_flow.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/config_flow.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Modifications for reviews and autocheck

* Modifications for the reviews and autocheck

* Update homeassistant/components/lgthinq/const.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/const.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/const.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/device.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/lgthinq/device.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* 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 <joostlek@outlook.com>

* Update tests/components/lgthinq/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/lgthinq/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/lgthinq/switch.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/lgthinq/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update for reviews

* Update homeassistant/components/lgthinq/switch.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/lgthinq/switch.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <jangwon.lee@lge.com>
Co-authored-by: yunseon.park <yunseon.park@lge.com>
Co-authored-by: nahyun.lee <nahyun.lee@lge.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
LG-ThinQ-Integration 2024-08-30 22:12:49 +09:00 committed by GitHub
parent 6467c8d611
commit d7fb245213
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 854 additions and 0 deletions

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -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,
}

View File

@ -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

View File

@ -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()

View File

@ -0,0 +1,9 @@
{
"entity": {
"switch": {
"operation_power": {
"default": "mdi:power"
}
}
}
}

View File

@ -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"]
}

View File

@ -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"
}
}
}
}

View File

@ -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")

View File

@ -318,6 +318,7 @@ FLOWS = {
"lektrico",
"lg_netcast",
"lg_soundbar",
"lgthinq",
"lidarr",
"lifx",
"linear_garage_door",

View File

@ -3246,6 +3246,12 @@
}
}
},
"lgthinq": {
"name": "LG ThinQ",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"lidarr": {
"name": "Lidarr",
"integration_type": "service",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the lgthinq integration."""

View File

@ -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

View File

@ -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"

View File

@ -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"