Add Whirlpool washer and dryer to Whirlpool integration (#85066)

* redo Add sensor

* move back to ClientError
simplify washer_state

* Cleanup Sensor definitions

* Seperated EndTimeSensor

* Clean up WasherDryerTimeClass

* Start with Timestamp = None

* Clean up class description

* One more ClientError

* change to restore sensor

* Don't update when no state change

* Seperate washer tests
Add restore_state test

* Remove unused loop in washer sensor test

* No loops in sensor tests

* Remove unnecessary SensorTestInstance
This commit is contained in:
mkmer 2023-01-06 15:41:46 -05:00 committed by GitHub
parent 968cf641b8
commit d75087ede5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 735 additions and 54 deletions

View File

@ -1306,8 +1306,8 @@ build.json @home-assistant/supervisor
/tests/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/wemo/ @esev /homeassistant/components/wemo/ @esev
/tests/components/wemo/ @esev /tests/components/wemo/ @esev
/homeassistant/components/whirlpool/ @abmantis /homeassistant/components/whirlpool/ @abmantis @mkmer
/tests/components/whirlpool/ @abmantis /tests/components/whirlpool/ @abmantis @mkmer
/homeassistant/components/whois/ @frenck /homeassistant/components/whois/ @frenck
/tests/components/whois/ @frenck /tests/components/whois/ @frenck
/homeassistant/components/wiffi/ @mampfes /homeassistant/components/wiffi/ @mampfes

View File

@ -1,4 +1,5 @@
"""The Whirlpool Sixth Sense integration.""" """The Whirlpool Appliances integration."""
import asyncio
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
@ -17,7 +18,7 @@ from .util import get_brand_for_region
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -30,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
auth = Auth(backend_selector, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) auth = Auth(backend_selector, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
try: try:
await auth.do_auth(store=False) await auth.do_auth(store=False)
except aiohttp.ClientError as ex: except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
raise ConfigEntryNotReady("Cannot connect") from ex raise ConfigEntryNotReady("Cannot connect") from ex
if not auth.is_access_token_valid(): if not auth.is_access_token_valid():
@ -49,7 +50,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@ -71,9 +71,6 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up entry.""" """Set up entry."""
whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id] whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id]
if not (aircons := whirlpool_data.appliances_manager.aircons):
_LOGGER.debug("No aircons found")
return
aircons = [ aircons = [
AirConEntity( AirConEntity(
@ -83,7 +80,7 @@ async def async_setup_entry(
whirlpool_data.backend_selector, whirlpool_data.backend_selector,
whirlpool_data.auth, whirlpool_data.auth,
) )
for ac_data in aircons for ac_data in whirlpool_data.appliances_manager.aircons
] ]
async_add_entities(aircons, True) async_add_entities(aircons, True)

View File

@ -1,4 +1,4 @@
"""Config flow for Whirlpool Sixth Sense integration.""" """Config flow for Whirlpool Appliances integration."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -8,6 +8,7 @@ from typing import Any
import aiohttp import aiohttp
import voluptuous as vol import voluptuous as vol
from whirlpool.appliancesmanager import AppliancesManager
from whirlpool.auth import Auth from whirlpool.auth import Auth
from whirlpool.backendselector import BackendSelector from whirlpool.backendselector import BackendSelector
@ -45,12 +46,17 @@ async def validate_input(
auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD]) auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD])
try: try:
await auth.do_auth() await auth.do_auth()
except (asyncio.TimeoutError, aiohttp.ClientConnectionError) as exc: except (asyncio.TimeoutError, aiohttp.ClientError) as exc:
raise CannotConnect from exc raise CannotConnect from exc
if not auth.is_access_token_valid(): if not auth.is_access_token_valid():
raise InvalidAuth raise InvalidAuth
appliances_manager = AppliancesManager(backend_selector, auth)
await appliances_manager.fetch_appliances()
if appliances_manager.aircons is None and appliances_manager.washer_dryers is None:
raise NoAppliances
return {"title": data[CONF_USERNAME]} return {"title": data[CONF_USERNAME]}
@ -118,6 +124,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except NoAppliances:
errors["base"] = "no_appliances"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
@ -139,3 +147,7 @@ class CannotConnect(exceptions.HomeAssistantError):
class InvalidAuth(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth.""" """Error to indicate there is invalid auth."""
class NoAppliances(exceptions.HomeAssistantError):
"""Error to indicate no supported appliances in the user account."""

View File

@ -1,4 +1,4 @@
"""Constants for the Whirlpool Sixth Sense integration.""" """Constants for the Whirlpool Appliances integration."""
from whirlpool.backendselector import Region from whirlpool.backendselector import Region

View File

@ -1,10 +1,11 @@
{ {
"domain": "whirlpool", "domain": "whirlpool",
"name": "Whirlpool Sixth Sense", "name": "Whirlpool Appliances",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/whirlpool", "documentation": "https://www.home-assistant.io/integrations/whirlpool",
"requirements": ["whirlpool-sixth-sense==0.18.0"], "requirements": ["whirlpool-sixth-sense==0.18.0"],
"codeowners": ["@abmantis"], "codeowners": ["@abmantis", "@mkmer"],
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["whirlpool"] "loggers": ["whirlpool"],
"integration_type": "hub"
} }

View File

@ -0,0 +1,287 @@
"""The Washer/Dryer Sensor for Whirlpool Appliances."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from whirlpool.washerdryer import MachineState, WasherDryer
from homeassistant.components.sensor import (
RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from . import WhirlpoolData
from .const import DOMAIN
TANK_FILL = {
"0": "Unknown",
"1": "Empty",
"2": "25%",
"3": "50%",
"4": "100%",
"5": "Active",
}
MACHINE_STATE = {
MachineState.Standby: "Standby",
MachineState.Setting: "Setting",
MachineState.DelayCountdownMode: "Delay Countdown",
MachineState.DelayPause: "Delay Paused",
MachineState.SmartDelay: "Smart Delay",
MachineState.SmartGridPause: "Smart Grid Pause",
MachineState.Pause: "Pause",
MachineState.RunningMainCycle: "Running Maincycle",
MachineState.RunningPostCycle: "Running Postcycle",
MachineState.Exceptions: "Exception",
MachineState.Complete: "Complete",
MachineState.PowerFailure: "Power Failure",
MachineState.ServiceDiagnostic: "Service Diagnostic Mode",
MachineState.FactoryDiagnostic: "Factory Diagnostic Mode",
MachineState.LifeTest: "Life Test",
MachineState.CustomerFocusMode: "Customer Focus Mode",
MachineState.DemoMode: "Demo Mode",
MachineState.HardStopOrError: "Hard Stop or Error",
MachineState.SystemInit: "System Initialize",
}
CYCLE_FUNC = [
(WasherDryer.get_cycle_status_filling, "Cycle Filling"),
(WasherDryer.get_cycle_status_rinsing, "Cycle Rinsing"),
(WasherDryer.get_cycle_status_sensing, "Cycle Sensing"),
(WasherDryer.get_cycle_status_soaking, "Cycle Soaking"),
(WasherDryer.get_cycle_status_spinning, "Cycle Spinning"),
(WasherDryer.get_cycle_status_washing, "Cycle Washing"),
]
ICON_D = "mdi:tumble-dryer"
ICON_W = "mdi:washing-machine"
_LOGGER = logging.getLogger(__name__)
def washer_state(washer: WasherDryer) -> str | None:
"""Determine correct states for a washer."""
if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1":
return "Door open"
machine_state = washer.get_machine_state()
if machine_state == MachineState.RunningMainCycle:
for func, cycle_name in CYCLE_FUNC:
if func(washer):
return cycle_name
return MACHINE_STATE.get(machine_state, STATE_UNKNOWN)
@dataclass
class WhirlpoolSensorEntityDescriptionMixin:
"""Mixin for required keys."""
value_fn: Callable
@dataclass
class WhirlpoolSensorEntityDescription(
SensorEntityDescription, WhirlpoolSensorEntityDescriptionMixin
):
"""Describes Whirlpool Washer sensor entity."""
SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = (
WhirlpoolSensorEntityDescription(
key="state",
name="State",
icon=ICON_W,
has_entity_name=True,
value_fn=washer_state,
),
WhirlpoolSensorEntityDescription(
key="DispenseLevel",
name="Detergent Level",
icon=ICON_W,
has_entity_name=True,
value_fn=lambda WasherDryer: TANK_FILL[
WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level")
],
),
)
SENSOR_TIMER: tuple[SensorEntityDescription] = (
SensorEntityDescription(
key="timeremaining",
name="End Time",
device_class=SensorDeviceClass.TIMESTAMP,
icon=ICON_W,
has_entity_name=True,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Config flow entry for Whrilpool Laundry."""
entities: list = []
whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id]
for appliance in whirlpool_data.appliances_manager.washer_dryers:
_wd = WasherDryer(
whirlpool_data.backend_selector,
whirlpool_data.auth,
appliance["SAID"],
)
await _wd.connect()
entities.extend(
[
WasherDryerClass(
appliance["SAID"],
appliance["NAME"],
description,
_wd,
)
for description in SENSORS
]
)
entities.extend(
[
WasherDryerTimeClass(
appliance["SAID"],
appliance["NAME"],
description,
_wd,
)
for description in SENSOR_TIMER
]
)
async_add_entities(entities)
class WasherDryerClass(SensorEntity):
"""A class for the whirlpool/maytag washer account."""
_attr_should_poll = False
def __init__(
self,
said: str,
name: str,
description: WhirlpoolSensorEntityDescription,
washdry: WasherDryer,
) -> None:
"""Initialize the washer sensor."""
self._name = name.capitalize()
self._wd: WasherDryer = washdry
if self._name == "Dryer":
self._attr_icon = ICON_D
self.entity_description: WhirlpoolSensorEntityDescription = description
self._attr_unique_id = f"{said}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, said)},
name=self._name,
manufacturer="Whirlpool",
)
async def async_added_to_hass(self) -> None:
"""Connect washer/dryer to the cloud."""
self._wd.register_attr_callback(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Close Whrilpool Appliance sockets before removing."""
await self._wd.disconnect()
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._wd.get_online()
@property
def native_value(self) -> StateType | str:
"""Return native value of sensor."""
return self.entity_description.value_fn(self._wd)
class WasherDryerTimeClass(RestoreSensor):
"""A timestamp class for the whirlpool/maytag washer account."""
_attr_should_poll = False
def __init__(
self,
said: str,
name: str,
description: SensorEntityDescription,
washdry: WasherDryer,
) -> None:
"""Initialize the washer sensor."""
self._name = name.capitalize()
self._wd: WasherDryer = washdry
if self._name == "Dryer":
self._attr_icon = ICON_D
self.entity_description: SensorEntityDescription = description
self._attr_unique_id = f"{said}-{description.key}"
self._running: bool | None = None
self._timestamp: datetime | None = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, said)},
name=self._name,
manufacturer="Whirlpool",
)
async def async_added_to_hass(self) -> None:
"""Connect washer/dryer to the cloud."""
if restored_data := await self.async_get_last_sensor_data():
self._attr_native_value = restored_data.native_value
await super().async_added_to_hass()
self._wd.register_attr_callback(self.update_from_latest_data)
async def async_will_remove_from_hass(self) -> None:
"""Close Whrilpool Appliance sockets before removing."""
await self._wd.disconnect()
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._wd.get_online()
@callback
def update_from_latest_data(self) -> None:
"""Calculate the time stamp for completion."""
machine_state = self._wd.get_machine_state()
now = utcnow()
if (
machine_state.value
in {MachineState.Complete.value, MachineState.Standby.value}
and self._running
):
self._running = False
self._attr_native_value = now
self._async_write_ha_state()
if machine_state is MachineState.RunningMainCycle:
self._running = True
self._attr_native_value = now + timedelta(
seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining"))
)
self._async_write_ha_state()

View File

@ -11,7 +11,8 @@
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]",
"no_appliances": "No supported appliances found"
} }
} }
} }

View File

@ -3,6 +3,7 @@
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication", "invalid_auth": "Invalid authentication",
"no_appliances": "No supported appliances found",
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"step": { "step": {

View File

@ -6046,7 +6046,7 @@
"iot_class": "local_push" "iot_class": "local_push"
}, },
"whirlpool": { "whirlpool": {
"name": "Whirlpool Sixth Sense", "name": "Whirlpool Appliances",
"integration_type": "hub", "integration_type": "hub",
"config_flow": true, "config_flow": true,
"iot_class": "cloud_push" "iot_class": "cloud_push"

View File

@ -9,6 +9,8 @@ from whirlpool.backendselector import Brand, Region
MOCK_SAID1 = "said1" MOCK_SAID1 = "said1"
MOCK_SAID2 = "said2" MOCK_SAID2 = "said2"
MOCK_SAID3 = "said3"
MOCK_SAID4 = "said4"
@pytest.fixture( @pytest.fixture(
@ -40,6 +42,10 @@ def fixture_mock_appliances_manager_api():
{"SAID": MOCK_SAID1, "NAME": "TestZone"}, {"SAID": MOCK_SAID1, "NAME": "TestZone"},
{"SAID": MOCK_SAID2, "NAME": "TestZone"}, {"SAID": MOCK_SAID2, "NAME": "TestZone"},
] ]
mock_appliances_manager.return_value.washer_dryers = [
{"SAID": MOCK_SAID3, "NAME": "washer"},
{"SAID": MOCK_SAID4, "NAME": "dryer"},
]
yield mock_appliances_manager yield mock_appliances_manager
@ -78,19 +84,19 @@ def get_aircon_mock(said):
return mock_aircon return mock_aircon
@pytest.fixture(name="mock_aircon1_api", autouse=True) @pytest.fixture(name="mock_aircon1_api", autouse=False)
def fixture_mock_aircon1_api(mock_auth_api, mock_appliances_manager_api): def fixture_mock_aircon1_api(mock_auth_api, mock_appliances_manager_api):
"""Set up air conditioner API fixture.""" """Set up air conditioner API fixture."""
yield get_aircon_mock(MOCK_SAID1) yield get_aircon_mock(MOCK_SAID1)
@pytest.fixture(name="mock_aircon2_api", autouse=True) @pytest.fixture(name="mock_aircon2_api", autouse=False)
def fixture_mock_aircon2_api(mock_auth_api, mock_appliances_manager_api): def fixture_mock_aircon2_api(mock_auth_api, mock_appliances_manager_api):
"""Set up air conditioner API fixture.""" """Set up air conditioner API fixture."""
yield get_aircon_mock(MOCK_SAID2) yield get_aircon_mock(MOCK_SAID2)
@pytest.fixture(name="mock_aircon_api_instances", autouse=True) @pytest.fixture(name="mock_aircon_api_instances", autouse=False)
def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api): def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api):
"""Set up air conditioner API fixture.""" """Set up air conditioner API fixture."""
with mock.patch( with mock.patch(
@ -98,3 +104,58 @@ def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api):
) as mock_aircon_api: ) as mock_aircon_api:
mock_aircon_api.side_effect = [mock_aircon1_api, mock_aircon2_api] mock_aircon_api.side_effect = [mock_aircon1_api, mock_aircon2_api]
yield mock_aircon_api yield mock_aircon_api
def side_effect_function(*args, **kwargs):
"""Return correct value for attribute."""
if args[0] == "Cavity_TimeStatusEstTimeRemaining":
return 3540
if args[0] == "Cavity_OpStatusDoorOpen":
return "0"
if args[0] == "WashCavity_OpStatusBulkDispense1Level":
return "3"
def get_sensor_mock(said):
"""Get a mock of a sensor."""
mock_sensor = mock.Mock(said=said)
mock_sensor.connect = AsyncMock()
mock_sensor.disconnect = AsyncMock()
mock_sensor.get_online.return_value = True
mock_sensor.get_machine_state.return_value = (
whirlpool.washerdryer.MachineState.Standby
)
mock_sensor.get_attribute.side_effect = side_effect_function
mock_sensor.get_cycle_status_filling.return_value = False
mock_sensor.get_cycle_status_rinsing.return_value = False
mock_sensor.get_cycle_status_sensing.return_value = False
mock_sensor.get_cycle_status_soaking.return_value = False
mock_sensor.get_cycle_status_spinning.return_value = False
mock_sensor.get_cycle_status_washing.return_value = False
return mock_sensor
@pytest.fixture(name="mock_sensor1_api", autouse=False)
def fixture_mock_sensor1_api(mock_auth_api, mock_appliances_manager_api):
"""Set up sensor API fixture."""
yield get_sensor_mock(MOCK_SAID3)
@pytest.fixture(name="mock_sensor2_api", autouse=False)
def fixture_mock_sensor2_api(mock_auth_api, mock_appliances_manager_api):
"""Set up sensor API fixture."""
yield get_sensor_mock(MOCK_SAID4)
@pytest.fixture(name="mock_sensor_api_instances", autouse=False)
def fixture_mock_sensor_api_instances(mock_sensor1_api, mock_sensor2_api):
"""Set up sensor API fixture."""
with mock.patch(
"homeassistant.components.whirlpool.sensor.WasherDryer"
) as mock_sensor_api:
mock_sensor_api.side_effect = [
mock_sensor1_api,
mock_sensor2_api,
]
yield mock_sensor_api

View File

@ -19,7 +19,10 @@ CONFIG_INPUT = {
} }
async def test_form(hass, region): async def test_form(
hass: HomeAssistant,
region,
) -> None:
"""Test we get the form.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -36,7 +39,13 @@ async def test_form(hass, region):
) as mock_backend_selector, patch( ) as mock_backend_selector, patch(
"homeassistant.components.whirlpool.async_setup_entry", "homeassistant.components.whirlpool.async_setup_entry",
return_value=True, return_value=True,
) as mock_setup_entry: ) as mock_setup_entry, patch(
"homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons",
return_value=["test"],
), patch(
"homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
CONFIG_INPUT | {"region": region[0]}, CONFIG_INPUT | {"region": region[0]},
@ -54,7 +63,7 @@ async def test_form(hass, region):
mock_backend_selector.assert_called_once_with(region[2], region[1]) mock_backend_selector.assert_called_once_with(region[2], region[1])
async def test_form_invalid_auth(hass, region): async def test_form_invalid_auth(hass: HomeAssistant, region) -> None:
"""Test we handle invalid auth.""" """Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -65,13 +74,16 @@ async def test_form_invalid_auth(hass, region):
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
CONFIG_INPUT | {"region": region[0]}, CONFIG_INPUT
| {
"region": region[0],
},
) )
assert result2["type"] == "form" assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"} assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass, region): async def test_form_cannot_connect(hass: HomeAssistant, region) -> None:
"""Test we handle cannot connect error.""" """Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -82,13 +94,16 @@ async def test_form_cannot_connect(hass, region):
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
CONFIG_INPUT | {"region": region[0]}, CONFIG_INPUT
| {
"region": region[0],
},
) )
assert result2["type"] == "form" assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"} assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_auth_timeout(hass, region): async def test_form_auth_timeout(hass: HomeAssistant, region) -> None:
"""Test we handle auth timeout error.""" """Test we handle auth timeout error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -99,13 +114,16 @@ async def test_form_auth_timeout(hass, region):
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
CONFIG_INPUT | {"region": region[0]}, CONFIG_INPUT
| {
"region": region[0],
},
) )
assert result2["type"] == "form" assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"} assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_generic_auth_exception(hass, region): async def test_form_generic_auth_exception(hass: HomeAssistant, region) -> None:
"""Test we handle cannot connect error.""" """Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -116,17 +134,20 @@ async def test_form_generic_auth_exception(hass, region):
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
CONFIG_INPUT | {"region": region[0]}, CONFIG_INPUT
| {
"region": region[0],
},
) )
assert result2["type"] == "form" assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"} assert result2["errors"] == {"base": "unknown"}
async def test_form_already_configured(hass, region): async def test_form_already_configured(hass: HomeAssistant, region) -> None:
"""Test we handle cannot connect error.""" """Test we handle cannot connect error."""
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, data=CONFIG_INPUT | {"region": region[0]},
unique_id="test-username", unique_id="test-username",
) )
mock_entry.add_to_hass(hass) mock_entry.add_to_hass(hass)
@ -141,10 +162,19 @@ async def test_form_already_configured(hass, region):
with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch(
"homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid",
return_value=True, return_value=True,
), patch(
"homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons",
return_value=["test"],
), patch(
"homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances",
return_value=True,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
CONFIG_INPUT | {"region": region[0]}, CONFIG_INPUT
| {
"region": region[0],
},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -152,9 +182,35 @@ async def test_form_already_configured(hass, region):
assert result2["reason"] == "already_configured" assert result2["reason"] == "already_configured"
async def test_no_appliances_flow(hass: HomeAssistant, region) -> None:
"""Test we get and error with no appliances."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == config_entries.SOURCE_USER
with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch(
"homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid",
return_value=True,
), patch(
"homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
CONFIG_INPUT | {"region": region[0]},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "no_appliances"}
async def test_reauth_flow(hass: HomeAssistant, region) -> None: async def test_reauth_flow(hass: HomeAssistant, region) -> None:
"""Test a successful reauth flow.""" """Test a successful reauth flow."""
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=CONFIG_INPUT | {"region": region[0]}, data=CONFIG_INPUT | {"region": region[0]},
@ -169,11 +225,7 @@ async def test_reauth_flow(hass: HomeAssistant, region) -> None:
"unique_id": mock_entry.unique_id, "unique_id": mock_entry.unique_id,
"entry_id": mock_entry.entry_id, "entry_id": mock_entry.entry_id,
}, },
data={ data=CONFIG_INPUT | {"region": region[0]},
"username": "test-username",
"password": "new-password",
"region": region[0],
},
) )
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
@ -186,6 +238,12 @@ async def test_reauth_flow(hass: HomeAssistant, region) -> None:
), patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( ), patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch(
"homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid",
return_value=True, return_value=True,
), patch(
"homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons",
return_value=["test"],
), patch(
"homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances",
return_value=True,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
@ -220,8 +278,8 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region) -> None:
"entry_id": mock_entry.entry_id, "entry_id": mock_entry.entry_id,
}, },
data={ data={
"username": "test-username", CONF_USERNAME: "test-username",
"password": "new-password", CONF_PASSWORD: "new-password",
"region": region[0], "region": region[0],
}, },
) )
@ -246,7 +304,7 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region) -> None:
assert result2["errors"] == {"base": "invalid_auth"} assert result2["errors"] == {"base": "invalid_auth"}
async def test_reauth_flow_connection_error(hass: HomeAssistant, region) -> None: async def test_reauth_flow_connnection_error(hass: HomeAssistant, region) -> None:
"""Test a connection error reauth flow.""" """Test a connection error reauth flow."""
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
@ -263,11 +321,7 @@ async def test_reauth_flow_connection_error(hass: HomeAssistant, region) -> None
"unique_id": mock_entry.unique_id, "unique_id": mock_entry.unique_id,
"entry_id": mock_entry.entry_id, "entry_id": mock_entry.entry_id,
}, },
data={ data=CONFIG_INPUT | {"region": region[0]},
CONF_USERNAME: "test-username",
CONF_PASSWORD: "new-password",
"region": region[0],
},
) )
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"

View File

@ -14,7 +14,12 @@ from . import init_integration, init_integration_with_entry
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
async def test_setup(hass: HomeAssistant, mock_backend_selector_api: MagicMock, region): async def test_setup(
hass: HomeAssistant,
mock_backend_selector_api: MagicMock,
region,
mock_aircon_api_instances: MagicMock,
):
"""Test setup.""" """Test setup."""
entry = await init_integration(hass, region[0]) entry = await init_integration(hass, region[0])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@ -23,12 +28,15 @@ async def test_setup(hass: HomeAssistant, mock_backend_selector_api: MagicMock,
async def test_setup_region_fallback( async def test_setup_region_fallback(
hass: HomeAssistant, mock_backend_selector_api: MagicMock hass: HomeAssistant,
mock_backend_selector_api: MagicMock,
mock_aircon_api_instances: MagicMock,
): ):
"""Test setup when no region is available on the ConfigEntry. """Test setup when no region is available on the ConfigEntry.
This can happen after a version update, since there was no region in the first versions. This can happen after a version update, since there was no region in the first versions.
""" """
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={ data={
@ -42,7 +50,11 @@ async def test_setup_region_fallback(
mock_backend_selector_api.assert_called_once_with(Brand.Whirlpool, Region.EU) mock_backend_selector_api.assert_called_once_with(Brand.Whirlpool, Region.EU)
async def test_setup_http_exception(hass: HomeAssistant, mock_auth_api: MagicMock): async def test_setup_http_exception(
hass: HomeAssistant,
mock_auth_api: MagicMock,
mock_aircon_api_instances: MagicMock,
):
"""Test setup with an http exception.""" """Test setup with an http exception."""
mock_auth_api.return_value.do_auth = AsyncMock( mock_auth_api.return_value.do_auth = AsyncMock(
side_effect=aiohttp.ClientConnectionError() side_effect=aiohttp.ClientConnectionError()
@ -52,7 +64,11 @@ async def test_setup_http_exception(hass: HomeAssistant, mock_auth_api: MagicMoc
assert entry.state is ConfigEntryState.SETUP_RETRY assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_auth_failed(hass: HomeAssistant, mock_auth_api: MagicMock): async def test_setup_auth_failed(
hass: HomeAssistant,
mock_auth_api: MagicMock,
mock_aircon_api_instances: MagicMock,
):
"""Test setup with failed auth.""" """Test setup with failed auth."""
mock_auth_api.return_value.do_auth = AsyncMock() mock_auth_api.return_value.do_auth = AsyncMock()
mock_auth_api.return_value.is_access_token_valid.return_value = False mock_auth_api.return_value.is_access_token_valid.return_value = False
@ -62,7 +78,9 @@ async def test_setup_auth_failed(hass: HomeAssistant, mock_auth_api: MagicMock):
async def test_setup_fetch_appliances_failed( async def test_setup_fetch_appliances_failed(
hass: HomeAssistant, mock_appliances_manager_api: MagicMock hass: HomeAssistant,
mock_appliances_manager_api: MagicMock,
mock_aircon_api_instances: MagicMock,
): ):
"""Test setup with failed fetch_appliances.""" """Test setup with failed fetch_appliances."""
mock_appliances_manager_api.return_value.fetch_appliances.return_value = False mock_appliances_manager_api.return_value.fetch_appliances.return_value = False
@ -71,7 +89,11 @@ async def test_setup_fetch_appliances_failed(
assert entry.state is ConfigEntryState.SETUP_ERROR assert entry.state is ConfigEntryState.SETUP_ERROR
async def test_unload_entry(hass: HomeAssistant): async def test_unload_entry(
hass: HomeAssistant,
mock_aircon_api_instances: MagicMock,
mock_sensor_api_instances: MagicMock,
):
"""Test successful unload of entry.""" """Test successful unload of entry."""
entry = await init_integration(hass) entry = await init_integration(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1

View File

@ -0,0 +1,245 @@
"""Test the Whirlpool Sensor domain."""
from datetime import datetime, timezone
from unittest.mock import MagicMock
from whirlpool.washerdryer import MachineState
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.helpers import entity_registry
from . import init_integration
from tests.common import mock_restore_cache_with_extra_data
async def update_sensor_state(
hass: HomeAssistant,
entity_id: str,
mock_sensor_api_instance: MagicMock,
):
"""Simulate an update trigger from the API."""
for call in mock_sensor_api_instance.register_attr_callback.call_args_list:
update_ha_state_cb = call[0][0]
update_ha_state_cb()
await hass.async_block_till_done()
return hass.states.get(entity_id)
def side_effect_function_open_door(*args, **kwargs):
"""Return correct value for attribute."""
if args[0] == "Cavity_TimeStatusEstTimeRemaining":
return 3540
if args[0] == "Cavity_OpStatusDoorOpen":
return "1"
if args[0] == "WashCavity_OpStatusBulkDispense1Level":
return "3"
async def test_dryer_sensor_values(
hass: HomeAssistant,
mock_sensor_api_instances: MagicMock,
mock_sensor2_api: MagicMock,
):
"""Test the sensor value callbacks."""
await init_integration(hass)
entity_id = "sensor.dryer_state"
mock_instance = mock_sensor2_api
registry = entity_registry.async_get(hass)
entry = registry.async_get(entity_id)
assert entry
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "Standby"
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
state_id = f"{entity_id.split('_')[0]}_end_time"
state = hass.states.get(state_id)
assert state is not None
mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle
mock_instance.get_cycle_status_filling.return_value = False
mock_instance.attr_value_to_bool.side_effect = [
False,
False,
False,
False,
False,
False,
]
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Running Maincycle"
mock_instance.get_machine_state.return_value = MachineState.Complete
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Complete"
async def test_washer_sensor_values(
hass: HomeAssistant,
mock_sensor_api_instances: MagicMock,
mock_sensor1_api: MagicMock,
):
"""Test the sensor value callbacks."""
await init_integration(hass)
entity_id = "sensor.washer_state"
mock_instance = mock_sensor1_api
registry = entity_registry.async_get(hass)
entry = registry.async_get(entity_id)
assert entry
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "Standby"
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
state_id = f"{entity_id.split('_')[0]}_end_time"
state = hass.states.get(state_id)
assert state is not None
state_id = f"{entity_id.split('_')[0]}_detergent_level"
state = hass.states.get(state_id)
assert state is not None
assert state.state == "50%"
# Test the washer cycle states
mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle
mock_instance.get_cycle_status_filling.return_value = True
mock_instance.attr_value_to_bool.side_effect = [
True,
False,
False,
False,
False,
False,
]
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Cycle Filling"
mock_instance.get_cycle_status_filling.return_value = False
mock_instance.get_cycle_status_rinsing.return_value = True
mock_instance.attr_value_to_bool.side_effect = [
False,
True,
False,
False,
False,
False,
]
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Cycle Rinsing"
mock_instance.get_cycle_status_rinsing.return_value = False
mock_instance.get_cycle_status_sensing.return_value = True
mock_instance.attr_value_to_bool.side_effect = [
False,
False,
True,
False,
False,
False,
]
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Cycle Sensing"
mock_instance.get_cycle_status_sensing.return_value = False
mock_instance.get_cycle_status_soaking.return_value = True
mock_instance.attr_value_to_bool.side_effect = [
False,
False,
False,
True,
False,
False,
]
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Cycle Soaking"
mock_instance.get_cycle_status_soaking.return_value = False
mock_instance.get_cycle_status_spinning.return_value = True
mock_instance.attr_value_to_bool.side_effect = [
False,
False,
False,
False,
True,
False,
]
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Cycle Spinning"
mock_instance.get_cycle_status_spinning.return_value = False
mock_instance.get_cycle_status_washing.return_value = True
mock_instance.attr_value_to_bool.side_effect = [
False,
False,
False,
False,
False,
True,
]
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Cycle Washing"
mock_instance.get_machine_state.return_value = MachineState.Complete
mock_instance.attr_value_to_bool.side_effect = None
mock_instance.get_attribute.side_effect = side_effect_function_open_door
state = await update_sensor_state(hass, entity_id, mock_instance)
assert state is not None
assert state.state == "Door open"
async def test_restore_state(
hass: HomeAssistant,
mock_sensor_api_instances: MagicMock,
):
"""Test sensor restore state."""
# Home assistant is not running yet
hass.state = CoreState.not_running
thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc)
mock_restore_cache_with_extra_data(
hass,
(
(
State(
"sensor.washer_end_time",
"1",
),
{"native_value": thetimestamp, "native_unit_of_measurement": None},
),
(
State("sensor.dryer_end_time", "1"),
{"native_value": thetimestamp, "native_unit_of_measurement": None},
),
),
)
# create and add entry
await init_integration(hass)
# restore from cache
state = hass.states.get("sensor.washer_end_time")
assert state.state == thetimestamp.isoformat()
state = hass.states.get("sensor.dryer_end_time")
assert state.state == thetimestamp.isoformat()