Add LetPot integration (#134925)

This commit is contained in:
Joris Pelgröm
2025-01-08 21:38:52 +01:00
committed by GitHub
parent 4086d092ff
commit 4129697dd9
19 changed files with 732 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
{
"config": {
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "The email address of your LetPot account.",
"password": "The password of your LetPot account."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
},
"entity": {
"time": {
"light_schedule_end": {
"name": "Light off"
},
"light_schedule_start": {
"name": "Light on"
}
}
}
}

View File

@ -0,0 +1,93 @@
"""Support for LetPot time entities."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import time
from typing import Any
from letpot.deviceclient import LetPotDeviceClient
from letpot.models import LetPotDeviceStatus
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import LetPotConfigEntry
from .coordinator import LetPotDeviceCoordinator
from .entity import LetPotEntity
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class LetPotTimeEntityDescription(TimeEntityDescription):
"""Describes a LetPot time entity."""
value_fn: Callable[[LetPotDeviceStatus], time | None]
set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]]
TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
LetPotTimeEntityDescription(
key="light_schedule_end",
translation_key="light_schedule_end",
value_fn=lambda status: None if status is None else status.light_schedule_end,
set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
start=None, end=value
),
entity_category=EntityCategory.CONFIG,
),
LetPotTimeEntityDescription(
key="light_schedule_start",
translation_key="light_schedule_start",
value_fn=lambda status: None if status is None else status.light_schedule_start,
set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
start=value, end=None
),
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LetPot time entities based on a config entry."""
coordinators = entry.runtime_data
async_add_entities(
LetPotTimeEntity(coordinator, description)
for description in TIME_SENSORS
for coordinator in coordinators
)
class LetPotTimeEntity(LetPotEntity, TimeEntity):
"""Defines a LetPot time entity."""
entity_description: LetPotTimeEntityDescription
def __init__(
self,
coordinator: LetPotDeviceCoordinator,
description: LetPotTimeEntityDescription,
) -> None:
"""Initialize LetPot time entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
@property
def native_value(self) -> time | None:
"""Return the time."""
return self.entity_description.value_fn(self.coordinator.data)
async def async_set_value(self, value: time) -> None:
"""Set the time."""
await self.entity_description.set_value_fn(
self.coordinator.device_client, value
)

View File

@ -331,6 +331,7 @@ FLOWS = {
"leaone",
"led_ble",
"lektrico",
"letpot",
"lg_netcast",
"lg_soundbar",
"lg_thinq",

View File

@ -3303,6 +3303,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"letpot": {
"name": "LetPot",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"leviton": {
"name": "Leviton",
"iot_standards": [

View File

@ -2666,6 +2666,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.letpot.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lidarr.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -1301,6 +1301,9 @@ led-ble==1.1.1
# homeassistant.components.lektrico
lektricowifi==0.0.43
# homeassistant.components.letpot
letpot==0.2.0
# homeassistant.components.foscam
libpyfoscam==1.2.2

View File

@ -1100,6 +1100,9 @@ led-ble==1.1.1
# homeassistant.components.lektrico
lektricowifi==0.0.43
# homeassistant.components.letpot
letpot==0.2.0
# homeassistant.components.foscam
libpyfoscam==1.2.2

View File

@ -0,0 +1,12 @@
"""Tests for the LetPot integration."""
from letpot.models import AuthenticationInfo
AUTHENTICATION = AuthenticationInfo(
access_token="access_token",
access_token_expires=0,
refresh_token="refresh_token",
refresh_token_expires=0,
user_id="a1b2c3d4e5f6a1b2c3d4e5f6",
email="email@example.com",
)

View File

@ -0,0 +1,46 @@
"""Common fixtures for the LetPot tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.letpot.const import (
CONF_ACCESS_TOKEN_EXPIRES,
CONF_REFRESH_TOKEN,
CONF_REFRESH_TOKEN_EXPIRES,
CONF_USER_ID,
DOMAIN,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL
from . import AUTHENTICATION
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.letpot.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title=AUTHENTICATION.email,
data={
CONF_ACCESS_TOKEN: AUTHENTICATION.access_token,
CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires,
CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token,
CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires,
CONF_USER_ID: AUTHENTICATION.user_id,
CONF_EMAIL: AUTHENTICATION.email,
},
unique_id=AUTHENTICATION.user_id,
)

View File

@ -0,0 +1,147 @@
"""Test the LetPot config flow."""
from typing import Any
from unittest.mock import AsyncMock, patch
from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException
import pytest
from homeassistant.components.letpot.const import (
CONF_ACCESS_TOKEN_EXPIRES,
CONF_REFRESH_TOKEN,
CONF_REFRESH_TOKEN_EXPIRES,
CONF_USER_ID,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import AUTHENTICATION
from tests.common import MockConfigEntry
def _assert_result_success(result: Any) -> None:
"""Assert successful end of flow result, creating an entry."""
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == AUTHENTICATION.email
assert result["data"] == {
CONF_ACCESS_TOKEN: AUTHENTICATION.access_token,
CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires,
CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token,
CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires,
CONF_USER_ID: AUTHENTICATION.user_id,
CONF_EMAIL: AUTHENTICATION.email,
}
assert result["result"].unique_id == AUTHENTICATION.user_id
async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test full flow with success."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.letpot.config_flow.LetPotClient.login",
return_value=AUTHENTICATION,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "email@example.com",
CONF_PASSWORD: "test-password",
},
)
await hass.async_block_till_done()
_assert_result_success(result)
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(LetPotAuthenticationException, "invalid_auth"),
(LetPotConnectionException, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_flow_exceptions(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test flow with exception during login and recovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"homeassistant.components.letpot.config_flow.LetPotClient.login",
side_effect=exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "email@example.com",
CONF_PASSWORD: "test-password",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
# Retry to show recovery.
with patch(
"homeassistant.components.letpot.config_flow.LetPotClient.login",
return_value=AUTHENTICATION,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "email@example.com",
CONF_PASSWORD: "test-password",
},
)
await hass.async_block_till_done()
_assert_result_success(result)
assert len(mock_setup_entry.mock_calls) == 1
async def test_flow_duplicate(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test flow aborts when trying to add a previously added account."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"homeassistant.components.letpot.config_flow.LetPotClient.login",
return_value=AUTHENTICATION,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "email@example.com",
CONF_PASSWORD: "test-password",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert len(mock_setup_entry.mock_calls) == 0