Skip to content

Commit

Permalink
Add LetPot integration (#134925)
Browse files Browse the repository at this point in the history
  • Loading branch information
jpelgrom authored Jan 8, 2025
1 parent 4086d09 commit 4129697
Show file tree
Hide file tree
Showing 19 changed files with 732 additions and 0 deletions.
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
94 changes: 94 additions & 0 deletions homeassistant/components/letpot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""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 ConfigEntryError, 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 ConfigEntryError from exc

try:
devices = await client.get_devices()
except LetPotAuthenticationException as exc:
raise ConfigEntryError from exc
except LetPotException as exc:
raise ConfigEntryNotReady from exc

coordinators: list[LetPotDeviceCoordinator] = [
LetPotDeviceCoordinator(hass, auth, device)
for device in devices
if any(converter.supports_type(device.device_type) for converter in CONVERTERS)
]

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."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
for coordinator in entry.runtime_data:
coordinator.device_client.disconnect()
return unload_ok
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=data_dict[CONF_EMAIL], data=data_dict
)
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
67 changes: 67 additions & 0 deletions homeassistant/components/letpot/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""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 LetPotAuthenticationException, LetPotException
from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus

from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
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
device_client: LetPotDeviceClient

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.device_client = LetPotDeviceClient(info, device.serial_number)

def _handle_status_update(self, status: LetPotDeviceStatus) -> None:
"""Distribute status update to entities."""
self.async_set_updated_data(data=status)

async def _async_setup(self) -> None:
"""Set up subscription for coordinator."""
try:
await self.device_client.subscribe(self._handle_status_update)
except LetPotAuthenticationException as exc:
raise ConfigEntryError from exc

async def _async_update_data(self) -> LetPotDeviceStatus:
"""Request an update from the device and wait for a status update or timeout."""
try:
async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT):
await self.device_client.get_current_status()
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.device_client.device_model_name,
model_id=coordinator.device_client.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.2.0"]
}
75 changes: 75 additions & 0 deletions homeassistant/components/letpot/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration only receives push-based updates.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done

# Silver
action-exceptions: todo
config-entry-unloading:
status: done
comment: |
Push connection connects in coordinator _async_setup, disconnects in init async_unload_entry.
docs-configuration-parameters:
status: exempt
comment: |
The integration does not have configuration options.
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo

# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
Loading

0 comments on commit 4129697

Please sign in to comment.