mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Add LetPot integration (#134925)
This commit is contained in:
@ -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.*
|
||||
|
@ -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
|
||||
|
94
homeassistant/components/letpot/__init__.py
Normal file
94
homeassistant/components/letpot/__init__.py
Normal 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
|
92
homeassistant/components/letpot/config_flow.py
Normal file
92
homeassistant/components/letpot/config_flow.py
Normal 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
|
||||
)
|
10
homeassistant/components/letpot/const.py
Normal file
10
homeassistant/components/letpot/const.py
Normal 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
|
67
homeassistant/components/letpot/coordinator.py
Normal file
67
homeassistant/components/letpot/coordinator.py
Normal 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
|
25
homeassistant/components/letpot/entity.py
Normal file
25
homeassistant/components/letpot/entity.py
Normal 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,
|
||||
)
|
11
homeassistant/components/letpot/manifest.json
Normal file
11
homeassistant/components/letpot/manifest.json
Normal 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"]
|
||||
}
|
75
homeassistant/components/letpot/quality_scale.yaml
Normal file
75
homeassistant/components/letpot/quality_scale.yaml
Normal 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
|
34
homeassistant/components/letpot/strings.json
Normal file
34
homeassistant/components/letpot/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
93
homeassistant/components/letpot/time.py
Normal file
93
homeassistant/components/letpot/time.py
Normal 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
|
||||
)
|
@ -331,6 +331,7 @@ FLOWS = {
|
||||
"leaone",
|
||||
"led_ble",
|
||||
"lektrico",
|
||||
"letpot",
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
|
@ -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": [
|
||||
|
10
mypy.ini
10
mypy.ini
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
12
tests/components/letpot/__init__.py
Normal file
12
tests/components/letpot/__init__.py
Normal 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",
|
||||
)
|
46
tests/components/letpot/conftest.py
Normal file
46
tests/components/letpot/conftest.py
Normal 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,
|
||||
)
|
147
tests/components/letpot/test_config_flow.py
Normal file
147
tests/components/letpot/test_config_flow.py
Normal 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
|
Reference in New Issue
Block a user