Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add LetPot integration #134925

Merged
merged 7 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,8 @@ build.json @home-assistant/supervisor
/tests/components/led_ble/ @bdraco
/homeassistant/components/lektrico/ @lektrico
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
/tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
Expand Down
95 changes: 95 additions & 0 deletions homeassistant/components/letpot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""The LetPot integration."""

from __future__ import annotations

import asyncio

from letpot.client import LetPotClient
from letpot.converters import CONVERTERS
from letpot.exceptions import LetPotAuthenticationException, LetPotException
from letpot.models import AuthenticationInfo

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import (
CONF_ACCESS_TOKEN_EXPIRES,
CONF_REFRESH_TOKEN,
CONF_REFRESH_TOKEN_EXPIRES,
CONF_USER_ID,
)
from .coordinator import LetPotDeviceCoordinator

PLATFORMS: list[Platform] = [Platform.TIME]

type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]]


async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
"""Set up LetPot from a config entry."""

auth = AuthenticationInfo(
access_token=entry.data[CONF_ACCESS_TOKEN],
access_token_expires=entry.data[CONF_ACCESS_TOKEN_EXPIRES],
refresh_token=entry.data[CONF_REFRESH_TOKEN],
refresh_token_expires=entry.data[CONF_REFRESH_TOKEN_EXPIRES],
user_id=entry.data[CONF_USER_ID],
email=entry.data[CONF_EMAIL],
)
websession = async_get_clientsession(hass)
client = LetPotClient(websession, auth)

if not auth.is_valid:
try:
auth = await client.refresh_token()
hass.config_entries.async_update_entry(
entry,
data={
CONF_ACCESS_TOKEN: auth.access_token,
CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires,
CONF_REFRESH_TOKEN: auth.refresh_token,
CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires,
CONF_USER_ID: auth.user_id,
CONF_EMAIL: auth.email,
},
)
except LetPotAuthenticationException as exc:
raise ConfigEntryAuthFailed from exc

try:
devices = await client.get_devices()
except LetPotAuthenticationException as exc:
raise ConfigEntryAuthFailed from exc
jpelgrom marked this conversation as resolved.
Show resolved Hide resolved
except LetPotException as exc:
raise ConfigEntryNotReady from exc

supported_devices = [
device
for device in devices
if any(converter.supports_type(device.device_type) for converter in CONVERTERS)
]

coordinators: list[LetPotDeviceCoordinator] = [
LetPotDeviceCoordinator(hass, auth, device) for device in supported_devices
]
jpelgrom marked this conversation as resolved.
Show resolved Hide resolved

await asyncio.gather(
*[
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators
]
)

entry.runtime_data = coordinators

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
92 changes: 92 additions & 0 deletions homeassistant/components/letpot/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Config flow for the LetPot integration."""

from __future__ import annotations

import logging
from typing import Any

from letpot.client import LetPotClient
from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)

from .const import (
CONF_ACCESS_TOKEN_EXPIRES,
CONF_REFRESH_TOKEN,
CONF_REFRESH_TOKEN_EXPIRES,
CONF_USER_ID,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
),
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
),
),
}
)


class LetPotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LetPot."""

VERSION = 1

async def _async_validate_credentials(
self, email: str, password: str
) -> dict[str, Any]:
websession = async_get_clientsession(self.hass)
client = LetPotClient(websession)
auth = await client.login(email, password)
return {
CONF_ACCESS_TOKEN: auth.access_token,
CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires,
CONF_REFRESH_TOKEN: auth.refresh_token,
CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires,
CONF_USER_ID: auth.user_id,
CONF_EMAIL: auth.email,
}

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
try:
data_dict = await self._async_validate_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except LetPotConnectionException:
errors["base"] = "cannot_connect"
except LetPotAuthenticationException:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(data_dict[CONF_USER_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=str(data_dict[CONF_EMAIL]), data=data_dict
jpelgrom marked this conversation as resolved.
Show resolved Hide resolved
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
10 changes: 10 additions & 0 deletions homeassistant/components/letpot/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Constants for the LetPot integration."""

DOMAIN = "letpot"

CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires"
CONF_USER_ID = "user_id"

REQUEST_UPDATE_TIMEOUT = 10
76 changes: 76 additions & 0 deletions homeassistant/components/letpot/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Coordinator for the LetPot integration."""

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING

from letpot.deviceclient import LetPotDeviceClient
from letpot.exceptions import LetPotException
from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus

from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import REQUEST_UPDATE_TIMEOUT

if TYPE_CHECKING:
from . import LetPotConfigEntry

_LOGGER = logging.getLogger(__name__)


class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
"""Class to handle data updates for a specific garden."""

config_entry: LetPotConfigEntry

device: LetPotDevice
deviceclient: LetPotDeviceClient
_update_event: asyncio.Event | None = None
_subscription: asyncio.Task | None = None

def __init__(
self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"LetPot {device.serial_number}",
)
self._info = info
self.device = device
self.deviceclient = LetPotDeviceClient(self._info, self.device.serial_number)
jpelgrom marked this conversation as resolved.
Show resolved Hide resolved

def _handle_status_update(self, status: LetPotDeviceStatus) -> None:
"""Distribute status update to entities."""
self.async_set_updated_data(data=status)
if self._update_event is not None and not self._update_event.is_set():
self._update_event.set()

async def _async_update_data(self) -> LetPotDeviceStatus:
"""Request an update from the device and wait for a status update or timeout."""
self._update_event = asyncio.Event()

try:
async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT):
if self._subscription is None or self._subscription.done():
# Set up the subscription, which will request a status update when connected
self._subscription = self.config_entry.async_create_background_task(
hass=self.hass,
target=self.deviceclient.subscribe(self._handle_status_update),
name=f"{self.device.serial_number}_subscription_task",
)
else:
# Request an update, existing subscription will receive status update
await self.deviceclient.request_status_update()
jpelgrom marked this conversation as resolved.
Show resolved Hide resolved

await self._update_event.wait()
except LetPotException as exc:
raise UpdateFailed(exc) from exc

# The subscription task will have updated coordinator.data, so return that data.
# If we don't return anything here, coordinator.data will be set to None.
return self.data
25 changes: 25 additions & 0 deletions homeassistant/components/letpot/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Base class for LetPot entities."""

from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import LetPotDeviceCoordinator


class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
"""Defines a base LetPot entity."""

_attr_has_entity_name = True

def __init__(self, coordinator: LetPotDeviceCoordinator) -> None:
"""Initialize a LetPot entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device.serial_number)},
name=coordinator.device.name,
manufacturer="LetPot",
model=coordinator.deviceclient.device_model_name,
model_id=coordinator.deviceclient.device_model_code,
serial_number=coordinator.device.serial_number,
)
11 changes: 11 additions & 0 deletions homeassistant/components/letpot/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "letpot",
"name": "LetPot",
"codeowners": ["@jpelgrom"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/letpot",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["letpot==0.1.3"]
}
Loading
Loading