Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ homeassistant.components.kaleidescape.*
homeassistant.components.knx.*
homeassistant.components.kraken.*
homeassistant.components.lametric.*
homeassistant.components.laundrify.*
homeassistant.components.lcn.*
homeassistant.components.light.*
homeassistant.components.local_ip.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/lametric/ @robbiet480 @frenck
/homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol
/tests/components/launch_library/ @ludeeus @DurgNomis-drol
/homeassistant/components/laundrify/ @xLarry
/tests/components/laundrify/ @xLarry
/homeassistant/components/lcn/ @alengwenus
/tests/components/lcn/ @alengwenus
/homeassistant/components/lg_netcast/ @Drafteed
Expand Down
52 changes: 52 additions & 0 deletions homeassistant/components/laundrify/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""The laundrify integration."""
from __future__ import annotations

from laundrify_aio import LaundrifyAPI
from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException

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

from .const import DEFAULT_POLL_INTERVAL, DOMAIN
from .coordinator import LaundrifyUpdateCoordinator

PLATFORMS = [Platform.BINARY_SENSOR]


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

session = async_get_clientsession(hass)
api_client = LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN], session)

try:
await api_client.validate_token()
except UnauthorizedException as err:
raise ConfigEntryAuthFailed("Invalid authentication") from err
except ApiConnectionException as err:
raise ConfigEntryNotReady("Cannot reach laundrify API") from err

coordinator = LaundrifyUpdateCoordinator(hass, api_client, DEFAULT_POLL_INTERVAL)

await coordinator.async_config_entry_first_refresh()

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"api": api_client,
"coordinator": coordinator,
}

hass.config_entries.async_setup_platforms(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""

if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
84 changes: 84 additions & 0 deletions homeassistant/components/laundrify/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Platform for binary sensor integration."""
from __future__ import annotations

import logging

from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN, MANUFACTURER, MODEL
from .coordinator import LaundrifyUpdateCoordinator
from .model import LaundrifyDevice

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up sensors from a config entry created in the integrations UI."""

coordinator = hass.data[DOMAIN][config.entry_id]["coordinator"]

async_add_entities(
LaundrifyPowerPlug(coordinator, device) for device in coordinator.data.values()
)


class LaundrifyPowerPlug(
CoordinatorEntity[LaundrifyUpdateCoordinator], BinarySensorEntity
):
"""Representation of a laundrify Power Plug."""

_attr_device_class = BinarySensorDeviceClass.RUNNING
_attr_icon = "mdi:washing-machine"

def __init__(
self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
self._device = device
self._attr_unique_id = device["_id"]

@property
def device_info(self) -> DeviceInfo:
"""Configure the Device of this Entity."""
return DeviceInfo(
identifiers={(DOMAIN, self._device["_id"])},
name=self.name,
manufacturer=MANUFACTURER,
model=MODEL,
sw_version=self._device["firmwareVersion"],
)

@property
def available(self) -> bool:
"""Check if the device is available."""
return (
self.unique_id in self.coordinator.data
and self.coordinator.last_update_success
)

@property
def name(self) -> str:
"""Name of the entity."""
return self._device["name"]

@property
def is_on(self) -> bool:
"""Return entity state."""
return self._device["status"] == "ON"

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._device = self.coordinator.data[self.unique_id]
super()._handle_coordinator_update()
94 changes: 94 additions & 0 deletions homeassistant/components/laundrify/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Config flow for laundrify integration."""
from __future__ import annotations

import logging
from typing import Any

from laundrify_aio import LaundrifyAPI
from laundrify_aio.exceptions import (
ApiConnectionException,
InvalidFormat,
UnknownAuthCode,
)
from voluptuous import Required, Schema

from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

CONFIG_SCHEMA = Schema({Required(CONF_CODE): str})


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

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
return await self.async_step_init(user_input)

async def async_step_init(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to define this init step. Just merge this with the user step.

self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(step_id="init", data_schema=CONFIG_SCHEMA)

errors = {}

try:
access_token = await LaundrifyAPI.exchange_auth_code(user_input[CONF_CODE])

session = async_get_clientsession(self.hass)
api_client = LaundrifyAPI(access_token, session)

account_id = await api_client.get_account_id()
except InvalidFormat:
errors[CONF_CODE] = "invalid_format"
except UnknownAuthCode:
errors[CONF_CODE] = "invalid_auth"
except ApiConnectionException:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
entry_data = {CONF_ACCESS_TOKEN: access_token}

await self.async_set_unique_id(account_id)
self._abort_if_unique_id_configured()

# Create a new entry if it doesn't exist
return self.async_create_entry(
title=DOMAIN,
data=entry_data,
)

return self.async_show_form(
step_id="init", data_schema=CONFIG_SCHEMA, errors=errors
)

async def async_step_reauth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=Schema({}),
)
return await self.async_step_init()
10 changes: 10 additions & 0 deletions homeassistant/components/laundrify/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Constants for the laundrify integration."""

DOMAIN = "laundrify"

MANUFACTURER = "laundrify"
MODEL = "WLAN-Adapter (SU02)"

DEFAULT_POLL_INTERVAL = 60

REQUEST_TIMEOUT = 10
46 changes: 46 additions & 0 deletions homeassistant/components/laundrify/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Custom DataUpdateCoordinator for the laundrify integration."""
from datetime import timedelta
import logging

import async_timeout
from laundrify_aio import LaundrifyAPI
from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException

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

from .const import DOMAIN, REQUEST_TIMEOUT
from .model import LaundrifyDevice

_LOGGER = logging.getLogger(__name__)


class LaundrifyUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching laundrify API data."""

def __init__(
self, hass: HomeAssistant, laundrify_api: LaundrifyAPI, poll_interval: int
) -> None:
"""Initialize laundrify coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=poll_interval),
)
self.laundrify_api = laundrify_api

async def _async_update_data(self) -> dict[str, LaundrifyDevice]:
"""Fetch data from laundrify API."""
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
async with async_timeout.timeout(REQUEST_TIMEOUT):
return {m["_id"]: m for m in await self.laundrify_api.get_machines()}
except UnauthorizedException as err:
# Raising ConfigEntryAuthFailed will cancel future updates
# and start a config flow with SOURCE_REAUTH (async_step_reauth)
raise ConfigEntryAuthFailed from err
except ApiConnectionException as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
9 changes: 9 additions & 0 deletions homeassistant/components/laundrify/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"domain": "laundrify",
"name": "laundrify",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/laundrify",
"requirements": ["laundrify_aio==1.1.1"],
"codeowners": ["@xLarry"],
"iot_class": "cloud_polling"
}
13 changes: 13 additions & 0 deletions homeassistant/components/laundrify/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Models for laundrify platform."""
from __future__ import annotations

from typing import TypedDict


class LaundrifyDevice(TypedDict):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library should preferably define its model, eg with a dataclass, so we don't need to define the type here but can use the type from the library.

"""laundrify Power Plug."""

_id: str
name: str
status: str
firmwareVersion: str
25 changes: 25 additions & 0 deletions homeassistant/components/laundrify/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"config": {
"step": {
"init": {
"description": "Please enter your personal Auth Code that is shown in the laundrify-App.",
"data": {
"code": "Auth Code (xxx-xxx)"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The laundrify integration needs to re-authenticate."
}
},
"error": {
"invalid_format": "Invalid format. Please specify as xxx-xxx.",
"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": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

single_instance_allowed abort reason is never used. We're missing a reason for already_configured though.

}
}
}
25 changes: 25 additions & 0 deletions homeassistant/components/laundrify/translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"config": {
"abort": {
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"invalid_format": "Invalid format. Please specify as xxx-xxx.",
"unknown": "Unexpected error"
},
"step": {
"init": {
"data": {
"code": "Auth Code (xxx-xxx)"
},
"description": "Please enter your personal AuthCode that is shown in the laundrify-App."
},
"reauth_confirm": {
"description": "The laundrify integration needs to re-authenticate.",
"title": "Reauthenticate Integration"
}
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
"kraken",
"kulersky",
"launch_library",
"laundrify",
"life360",
"lifx",
"litejet",
Expand Down
Loading