File: /volume1/docker/homeassistant2025/custom_components/smartthinq_sensors/__init__.py
"""Support for LG SmartThinQ device."""
from __future__ import annotations
from datetime import timedelta
import logging
from homeassistant.components import persistent_notification
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_REGION,
CONF_TOKEN,
EVENT_HOMEASSISTANT_STOP,
MAJOR_VERSION,
MINOR_VERSION,
Platform,
UnitOfTemperature,
__version__,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CLIENT,
CONF_LANGUAGE,
CONF_OAUTH2_URL,
CONF_USE_API_V2,
CONF_USE_HA_SESSION,
DOMAIN,
LGE_DEVICES,
LGE_DISCOVERY_NEW,
MIN_HA_MAJ_VER,
MIN_HA_MIN_VER,
STARTUP,
__min_ha_version__,
)
from .wideq import (
DeviceInfo as ThinQDeviceInfo,
DeviceType,
TemperatureUnit,
get_lge_device,
)
from .wideq.core_async import ClientAsync
from .wideq.core_exceptions import (
AuthenticationError,
InvalidCredentialError,
MonitorRefreshError,
MonitorUnavailableError,
NotConnectedError,
)
from .wideq.device import Device as ThinQDevice
SMARTTHINQ_PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
]
AUTH_RETRY = "auth_retry"
MAX_AUTH_RETRY = 4
MAX_DISC_COUNT = 4
SIGNAL_RELOAD_ENTRY = f"{DOMAIN}_reload_entry"
DISCOVERED_DEVICES = "discovered_devices"
UNSUPPORTED_DEVICES = "unsupported_devices"
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
class LGEAuthentication:
"""Class to authenticate connection with LG ThinQ."""
def __init__(
self, hass: HomeAssistant, region: str, language: str, use_ha_session=False
) -> None:
"""Initialize the class."""
self._region = region
self._language = language
self._client_session = None
if use_ha_session:
self._client_session = async_get_clientsession(hass)
async def get_login_url(self) -> str | None:
"""Get an url to login in browser."""
try:
return await ClientAsync.get_login_url(
self._region, self._language, aiohttp_session=self._client_session
)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.exception("Error retrieving login URL from ThinQ", exc_info=exc)
return None
async def get_oauth_info_from_url(self, callback_url: str) -> dict[str, str] | None:
"""Retrieve oauth info from redirect url."""
try:
return await ClientAsync.oauth_info_from_url(
callback_url,
self._region,
self._language,
aiohttp_session=self._client_session,
)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.exception("Error retrieving OAuth info from ThinQ", exc_info=exc)
return None
async def get_oauth_info_from_login(
self, username: str, password: str
) -> dict[str, str] | None:
"""Retrieve oauth info from user login credential."""
try:
return await ClientAsync.oauth_info_from_user_login(
username,
password,
self._region,
self._language,
aiohttp_session=self._client_session,
)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.exception("Error retrieving OAuth info from ThinQ", exc_info=exc)
return None
async def create_client_from_token(
self, token: str, oauth_url: str | None = None, client_id: str | None = None
) -> ClientAsync:
"""Create a new client using refresh token."""
return await ClientAsync.from_token(
token,
country=self._region,
language=self._language,
oauth_url=oauth_url,
aiohttp_session=self._client_session,
client_id=client_id,
)
def is_min_ha_version(min_ha_major_ver: int, min_ha_minor_ver: int) -> bool:
"""Check if HA version at least a specific version."""
return MAJOR_VERSION > min_ha_major_ver or (
MAJOR_VERSION == min_ha_major_ver and MINOR_VERSION >= min_ha_minor_ver
)
def is_valid_ha_version() -> bool:
"""Check if HA version is valid for this integration."""
return is_min_ha_version(MIN_HA_MAJ_VER, MIN_HA_MIN_VER)
def _notify_message(
hass: HomeAssistant, notification_id: str, title: str, message: str
) -> None:
"""Notify user with persistent notification"""
persistent_notification.async_create(
hass, message, title, f"{DOMAIN}.{notification_id}"
)
@callback
def _migrate_old_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Migrate an old config entry if available."""
old_key = "outh_url" # old conf key with typo error
if old_key not in entry.data:
return
oauth2_url = entry.data[old_key]
new_data = {k: v for k, v in entry.data.items() if k != old_key}
hass.config_entries.async_update_entry(
entry, data={**new_data, CONF_OAUTH2_URL: oauth2_url}
)
@callback
def _add_clientid_config_entry(
hass: HomeAssistant, entry: ConfigEntry, client_id: str
) -> None:
"""Add the client id to the config entry, so it can be reused."""
if CONF_CLIENT_ID in entry.data or not client_id:
return
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_CLIENT_ID: client_id}
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SmartThinQ integration from a config entry."""
if not is_valid_ha_version():
msg = (
"This integration require at least HomeAssistant version "
f" {__min_ha_version__}, you are running version {__version__}."
" Please upgrade HomeAssistant to continue use this integration."
)
_notify_message(hass, "inv_ha_version", "SmartThinQ Sensors", msg)
_LOGGER.warning(msg)
return False
_migrate_old_config_entry(hass, entry)
region = entry.data[CONF_REGION]
language = entry.data[CONF_LANGUAGE]
refresh_token = entry.data[CONF_TOKEN]
oauth2_url = None # entry.data.get(CONF_OAUTH2_URL)
client_id: str | None = entry.data.get(CONF_CLIENT_ID)
use_api_v2 = entry.data.get(CONF_USE_API_V2, False)
use_ha_session = entry.data.get(CONF_USE_HA_SESSION, False)
if not use_api_v2:
_LOGGER.warning(
"Integration configuration is using ThinQ APIv1 that is unsupported. Please reconfigure"
)
# Launch config entries setup
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=entry.data
)
)
return False
log_info: bool = hass.data.get(DOMAIN, {}).get(SIGNAL_RELOAD_ENTRY, 0) < 2
if log_info:
hass.data[DOMAIN] = {SIGNAL_RELOAD_ENTRY: 2}
_LOGGER.info(STARTUP)
_LOGGER.info(
"Initializing ThinQ platform with region: %s - language: %s",
region,
language,
)
# if network is not connected we can have some error
# raising ConfigEntryNotReady platform setup will be retried
lge_auth = LGEAuthentication(hass, region, language, use_ha_session)
try:
client = await lge_auth.create_client_from_token(
refresh_token, oauth2_url, client_id
)
except (AuthenticationError, InvalidCredentialError) as exc:
if (auth_retry := hass.data[DOMAIN].get(AUTH_RETRY, 0)) >= MAX_AUTH_RETRY:
hass.data.pop(DOMAIN)
# Launch config entries reauth setup
raise ConfigEntryAuthFailed("ThinQ authentication failed") from exc
hass.data[DOMAIN][AUTH_RETRY] = auth_retry + 1
msg = (
"Invalid ThinQ credential error, integration setup aborted."
" Please use the LG App on your mobile device to ensure your"
" credentials are correct or there are new Term of Service to accept"
)
if log_info:
_LOGGER.warning(msg, exc_info=True)
raise ConfigEntryNotReady(msg) from exc
except Exception as exc:
if log_info:
_LOGGER.warning(
"Connection not available. ThinQ platform not ready", exc_info=True
)
raise ConfigEntryNotReady("ThinQ platform not ready") from exc
if not client.has_devices:
_LOGGER.error("No ThinQ devices found. Component setup aborted")
return False
_LOGGER.debug("ThinQ client connected")
if not client_id:
_add_clientid_config_entry(hass, entry, client.client_id)
try:
lge_devices, unsupported_devices, discovered_devices = await lge_devices_setup(
hass, client
)
except Exception as exc:
if log_info:
_LOGGER.warning(
"Connection not available. ThinQ platform not ready", exc_info=True
)
await client.close()
raise ConfigEntryNotReady("ThinQ platform not ready") from exc
if discovered_devices is None:
await client.close()
raise ConfigEntryNotReady("ThinQ platform not ready: no devices found.")
# remove device not available anymore
dev_ids = [v for ids in discovered_devices.values() for v in ids]
cleanup_orphan_lge_devices(hass, entry.entry_id, dev_ids)
async def _async_call_reload_entry():
"""Reload current entry."""
if SIGNAL_RELOAD_ENTRY in hass.data[DOMAIN]:
return
hass.data[DOMAIN][SIGNAL_RELOAD_ENTRY] = 1
await hass.config_entries.async_reload(entry.entry_id)
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_RELOAD_ENTRY, _async_call_reload_entry)
)
async def _close_lg_client(event: Event) -> None:
"""Close client to abort pollong."""
await client.close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_lg_client)
)
hass.data[DOMAIN] = {
CLIENT: client,
LGE_DEVICES: lge_devices,
UNSUPPORTED_DEVICES: unsupported_devices,
DISCOVERED_DEVICES: discovered_devices,
}
await hass.config_entries.async_forward_entry_setups(entry, SMARTTHINQ_PLATFORMS)
start_devices_discovery(hass, entry, client)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(
entry, SMARTTHINQ_PLATFORMS
):
data = hass.data.pop(DOMAIN)
reload = data.get(SIGNAL_RELOAD_ENTRY, 0)
if reload > 0:
hass.data[DOMAIN] = {SIGNAL_RELOAD_ENTRY: reload}
await data[CLIENT].close()
return unload_ok
class LGEDevice:
"""Generic class that represents a LGE device."""
def __init__(
self, device: ThinQDevice, hass: HomeAssistant, root_dev_id: str | None = None
):
"""initialize a LGE Device."""
self._device = device
self._hass = hass
self._root_dev_id = root_dev_id
self._name = device.name
self._device_id = device.unique_id
self._type = device.device_info.type
self._mac = None
if mac := device.device_info.macaddress:
self._mac = dr.format_mac(mac)
self._firmware = device.device_info.firmware
self._model = f"{device.device_info.model_name}"
self._unique_id = f"{self._type.name}:{self._device_id}"
self._state = None
self._coordinator: DataUpdateCoordinator | None = None
self._disc_count = 0
self._available = True
@property
def available(self) -> bool:
"""Return True if device is available."""
return self._available
@property
def assumed_state(self) -> bool:
"""Return True if unable to access real state of the entity."""
return self._available and self._disc_count >= MAX_DISC_COUNT
@property
def device(self):
"""The device instance"""
return self._device
@property
def device_id(self):
"""The device unique identifier"""
return self._device_id
@property
def name(self) -> str:
"""The device name"""
return self._name
@property
def type(self) -> DeviceType:
"""The device type"""
return self._type
@property
def unique_id(self) -> str:
"""Device unique ID"""
return self._unique_id
@property
def state(self):
"""Current device state"""
return self._state
@property
def available_features(self) -> dict:
"""Return a list of available features."""
return self._device.available_features
@property
def device_info(self) -> DeviceInfo:
"""Return device info for the device."""
data = DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
name=self._name,
manufacturer="LG",
model=f"{self._model} ({self._type.name})",
)
if self._firmware:
data["sw_version"] = self._firmware
if self._mac and not self._root_dev_id:
data["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)}
if self._root_dev_id:
data["via_device"] = (DOMAIN, self._root_dev_id)
return data
@property
def coordinator(self) -> DataUpdateCoordinator | None:
"""Return the DataUpdateCoordinator used by this device."""
return self._coordinator
async def init_device(self) -> bool:
"""Init the device status and start coordinator."""
if not await self._device.init_device_info():
return False
self._state = self._device.status
self._model = f"{self._model}-{self._device.model_info.model_type}"
# Create status update coordinator
await self._create_coordinator()
# Initialize device features
_ = self._state.device_features
return True
@callback
def async_set_updated(self):
"""Manually update state and notify coordinator entities."""
if self._coordinator:
self._coordinator.async_set_updated_data(self._state)
async def _create_coordinator(self) -> None:
"""Get the coordinator for a specific device."""
coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
self._hass,
_LOGGER,
name=f"{DOMAIN}-{self._name}",
update_method=self._async_update,
# Polling interval. Will only be polled if there are subscribers.
update_interval=SCAN_INTERVAL,
)
await coordinator.async_refresh()
self._coordinator = coordinator
async def _async_update(self):
"""Async update used by coordinator."""
await self._async_state_update()
return self._state
async def _async_state_update(self):
"""Update device state."""
_LOGGER.debug("Updating ThinQ device %s", self._name)
if self._disc_count < MAX_DISC_COUNT:
self._disc_count += 1
try:
# method poll should return None if status is not yet available
# or due to temporary connection failure that will be restored
state = await self._device.poll()
except (MonitorRefreshError, NotConnectedError):
# These exceptions are raised when device is not connected (turned off)
# or unreachable due to network or API errors
# If device status is "on" we reset the status, otherwise we just
# ignore and use previous known state
state = None
if self._state.is_on and self._disc_count >= MAX_DISC_COUNT:
_LOGGER.warning(
"Status for device %s was reset because disconnected or unreachable",
self._name,
)
self._state = self._device.reset_status()
except MonitorUnavailableError:
# This exception is raised when issue with ThinQ persist
# In this case available is set to false and device status
# is reset to avoid confusion when connection is restored
if not self._available:
return
_LOGGER.warning(
"Status for device %s was reset because ThinQ connection not available",
self._name,
)
self._available = False
self._state = self._device.reset_status()
return
except InvalidCredentialError:
# If we receive invalid credential, we reload integration
# to provide proper notification
async_dispatcher_send(self._hass, SIGNAL_RELOAD_ENTRY)
return
self._available = True
if state:
_LOGGER.debug("ThinQ status updated")
# l = dir(state)
# _LOGGER.debug('Status attributes: %s', l)
self._disc_count = 0
self._state = state
async def lge_devices_setup(
hass: HomeAssistant,
client: ClientAsync,
discovered_devices: dict[str, list[str]] | None = None,
) -> tuple[
dict[DeviceType, list[LGEDevice]],
dict[DeviceType, list[ThinQDeviceInfo]],
dict[str, list[str]],
]:
"""Query connected devices from LG ThinQ."""
_LOGGER.debug("Searching LGE ThinQ devices...")
wrapped_devices: dict[DeviceType, list[LGEDevice]] = {}
unsupported_devices: dict[DeviceType, list[ThinQDeviceInfo]] = {}
if not client.has_devices:
await client.refresh_devices()
# if client device is None somenthing is wrong
if (client_devices := client.devices) is None:
return wrapped_devices, unsupported_devices, discovered_devices
new_devices = {}
if discovered_devices is None:
discovered_devices = {}
device_count = 0
temp_unit = TemperatureUnit.CELSIUS
if hass.config.units.temperature_unit != UnitOfTemperature.CELSIUS:
temp_unit = TemperatureUnit.FAHRENHEIT
async def init_device(
lge_dev: ThinQDevice, device_info: ThinQDeviceInfo, root_dev_id: str
):
"""Initialize a new device."""
root_dev = None if root_dev_id == lge_dev.unique_id else root_dev_id
dev = LGEDevice(lge_dev, hass, root_dev)
if not await dev.init_device():
_LOGGER.error(
"Error initializing LGE Device. Name: %s - Type: %s - InfoUrl: %s",
device_info.name,
device_info.type.name,
device_info.model_info_url,
)
return False
new_devices[device_info.device_id].append(dev.device_id)
wrapped_devices.setdefault(device_info.type, []).append(dev)
_LOGGER.info(
"LGE Device added. Name: %s - Type: %s - Model: %s - ID: %s",
dev.name,
device_info.type.name,
device_info.model_name,
dev.device_id,
)
return True
for device_info in client_devices:
device_id = device_info.device_id
if device_id in discovered_devices:
new_devices[device_id] = discovered_devices[device_id]
continue
new_devices[device_id] = []
device_count += 1
lge_devs = get_lge_device(client, device_info, temp_unit)
if not lge_devs:
_LOGGER.info(
"Found unsupported LGE Device. Name: %s - Type: %s - NetworkType: %s",
device_info.name,
device_info.type.name,
device_info.network_type.name,
)
unsupported_devices.setdefault(device_info.type, []).append(device_info)
continue
root_dev = None
for idx, lge_dev in enumerate(lge_devs):
if idx == 0:
root_dev = lge_dev.unique_id
if not await init_device(lge_dev, device_info, root_dev):
break
if sub_dev := lge_dev.subkey_device:
await init_device(sub_dev, device_info, root_dev)
if device_count > 0:
_LOGGER.info("Founds %s LGE device(s)", device_count)
return wrapped_devices, unsupported_devices, new_devices
@callback
def cleanup_orphan_lge_devices(
hass: HomeAssistant, entry_id: str, valid_dev_ids: list[str]
) -> None:
"""Delete devices that are not registered in LG client app"""
# Load lg devices from registry
device_registry = dr.async_get(hass)
all_lg_dev_entries = dr.async_entries_for_config_entry(device_registry, entry_id)
# get list of valid devices
valid_reg_dev_ids = []
for device_id in valid_dev_ids:
dev = device_registry.async_get_device({(DOMAIN, device_id)})
if dev is not None:
valid_reg_dev_ids.append(dev.id)
# clean-up invalid devices
for dev_entry in all_lg_dev_entries:
dev_id = dev_entry.id
if dev_id in valid_reg_dev_ids:
continue
device_registry.async_remove_device(dev_id)
@callback
def start_devices_discovery(
hass: HomeAssistant, entry: ConfigEntry, client: ClientAsync
) -> None:
"""Start devices discovery."""
async def _async_discover_devices(_):
"""Discover new devices."""
_LOGGER.debug("Discovering new devices...")
old_devs = hass.data[DOMAIN][DISCOVERED_DEVICES]
lge_devs, unsupported_devs, new_devs = await lge_devices_setup(
hass, client, old_devs
)
hass.data[DOMAIN][DISCOVERED_DEVICES] = new_devs
# send signal to set up new entities
if lge_devs:
_notify_message(
hass, "new_devices", "SmartThinQ Sensors", "Discovered new devices."
)
async_dispatcher_send(hass, LGE_DISCOVERY_NEW, lge_devs)
# remove device not available anymore
if lge_devs or unsupported_devs or len(old_devs) != len(new_devs):
new_ids = [v for ids in new_devs.values() for v in ids]
cleanup_orphan_lge_devices(hass, entry.entry_id, new_ids)
# Update hass data LGE_DEVICES
prev_lge_devs: dict[DeviceType, list[LGEDevice]] = hass.data[DOMAIN][
LGE_DEVICES
]
new_lge_devs: dict[DeviceType, list[LGEDevice]] = {}
for dev_type, dev_list in prev_lge_devs.items():
new_dev_list = [dev for dev in dev_list if dev.device_id in new_ids]
if new_dev_list:
new_lge_devs[dev_type] = new_dev_list
for dev_type, dev_list in lge_devs.items():
if dev_type in new_lge_devs:
new_lge_devs[dev_type].extend(dev_list)
else:
new_lge_devs[dev_type] = dev_list
hass.data[DOMAIN][LGE_DEVICES] = new_lge_devs
# Update hass data UNSUPPORTED_DEVICES
prev_uns_devs: dict[DeviceType, list[ThinQDeviceInfo]] = hass.data[DOMAIN][
UNSUPPORTED_DEVICES
]
new_uns_devs: dict[DeviceType, list[ThinQDeviceInfo]] = {}
for dev_type, dev_list in prev_uns_devs.items():
new_dev_list = [dev for dev in dev_list if dev.device_id in new_devs]
if new_dev_list:
new_uns_devs[dev_type] = new_dev_list
for dev_type, dev_list in unsupported_devs.items():
if dev_type in new_uns_devs:
new_uns_devs[dev_type].extend(dev_list)
else:
new_uns_devs[dev_type] = dev_list
hass.data[DOMAIN][UNSUPPORTED_DEVICES] = new_uns_devs
# schedule discover new devices every 5 minutes
entry.async_on_unload(
async_track_time_interval(hass, _async_discover_devices, timedelta(minutes=5))
)