mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 03:07:37 +00:00
Add LetPot integration (#134925)
This commit is contained in:
parent
4086d092ff
commit
4129697dd9
@ -291,6 +291,7 @@ homeassistant.components.lcn.*
|
|||||||
homeassistant.components.ld2410_ble.*
|
homeassistant.components.ld2410_ble.*
|
||||||
homeassistant.components.led_ble.*
|
homeassistant.components.led_ble.*
|
||||||
homeassistant.components.lektrico.*
|
homeassistant.components.lektrico.*
|
||||||
|
homeassistant.components.letpot.*
|
||||||
homeassistant.components.lidarr.*
|
homeassistant.components.lidarr.*
|
||||||
homeassistant.components.lifx.*
|
homeassistant.components.lifx.*
|
||||||
homeassistant.components.light.*
|
homeassistant.components.light.*
|
||||||
|
@ -831,6 +831,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/led_ble/ @bdraco
|
/tests/components/led_ble/ @bdraco
|
||||||
/homeassistant/components/lektrico/ @lektrico
|
/homeassistant/components/lektrico/ @lektrico
|
||||||
/tests/components/lektrico/ @lektrico
|
/tests/components/lektrico/ @lektrico
|
||||||
|
/homeassistant/components/letpot/ @jpelgrom
|
||||||
|
/tests/components/letpot/ @jpelgrom
|
||||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
/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",
|
"leaone",
|
||||||
"led_ble",
|
"led_ble",
|
||||||
"lektrico",
|
"lektrico",
|
||||||
|
"letpot",
|
||||||
"lg_netcast",
|
"lg_netcast",
|
||||||
"lg_soundbar",
|
"lg_soundbar",
|
||||||
"lg_thinq",
|
"lg_thinq",
|
||||||
|
@ -3303,6 +3303,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"letpot": {
|
||||||
|
"name": "LetPot",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_push"
|
||||||
|
},
|
||||||
"leviton": {
|
"leviton": {
|
||||||
"name": "Leviton",
|
"name": "Leviton",
|
||||||
"iot_standards": [
|
"iot_standards": [
|
||||||
|
10
mypy.ini
10
mypy.ini
@ -2666,6 +2666,16 @@ disallow_untyped_defs = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = 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.*]
|
[mypy-homeassistant.components.lidarr.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
@ -1301,6 +1301,9 @@ led-ble==1.1.1
|
|||||||
# homeassistant.components.lektrico
|
# homeassistant.components.lektrico
|
||||||
lektricowifi==0.0.43
|
lektricowifi==0.0.43
|
||||||
|
|
||||||
|
# homeassistant.components.letpot
|
||||||
|
letpot==0.2.0
|
||||||
|
|
||||||
# homeassistant.components.foscam
|
# homeassistant.components.foscam
|
||||||
libpyfoscam==1.2.2
|
libpyfoscam==1.2.2
|
||||||
|
|
||||||
|
@ -1100,6 +1100,9 @@ led-ble==1.1.1
|
|||||||
# homeassistant.components.lektrico
|
# homeassistant.components.lektrico
|
||||||
lektricowifi==0.0.43
|
lektricowifi==0.0.43
|
||||||
|
|
||||||
|
# homeassistant.components.letpot
|
||||||
|
letpot==0.2.0
|
||||||
|
|
||||||
# homeassistant.components.foscam
|
# homeassistant.components.foscam
|
||||||
libpyfoscam==1.2.2
|
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
|
Loading…
x
Reference in New Issue
Block a user