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
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
/homeassistant/components/wemo/ @esev
/tests/components/wemo/ @esev
/homeassistant/components/whirlpool/ @abmantis
/tests/components/whirlpool/ @abmantis
/homeassistant/components/whirlpool/ @abmantis @mkmer
/tests/components/whirlpool/ @abmantis @mkmer
/homeassistant/components/whois/ @frenck
/tests/components/whois/ @frenck
/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
import logging
@ -17,7 +18,7 @@ from .util import get_brand_for_region
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
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])
try:
await auth.do_auth(store=False)
except aiohttp.ClientError as ex:
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
raise ConfigEntryNotReady("Cannot connect") from ex
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)
return True

View File

@ -71,9 +71,6 @@ async def async_setup_entry(
) -> None:
"""Set up entry."""
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 = [
AirConEntity(
@ -83,7 +80,7 @@ async def async_setup_entry(
whirlpool_data.backend_selector,
whirlpool_data.auth,
)
for ac_data in aircons
for ac_data in whirlpool_data.appliances_manager.aircons
]
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
import asyncio
@ -8,6 +8,7 @@ from typing import Any
import aiohttp
import voluptuous as vol
from whirlpool.appliancesmanager import AppliancesManager
from whirlpool.auth import Auth
from whirlpool.backendselector import BackendSelector
@ -45,12 +46,17 @@ async def validate_input(
auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD])
try:
await auth.do_auth()
except (asyncio.TimeoutError, aiohttp.ClientConnectionError) as exc:
except (asyncio.TimeoutError, aiohttp.ClientError) as exc:
raise CannotConnect from exc
if not auth.is_access_token_valid():
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]}
@ -118,6 +124,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except NoAppliances:
errors["base"] = "no_appliances"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@ -139,3 +147,7 @@ class CannotConnect(exceptions.HomeAssistantError):
class InvalidAuth(exceptions.HomeAssistantError):
"""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

View File

@ -1,10 +1,11 @@
{
"domain": "whirlpool",
"name": "Whirlpool Sixth Sense",
"name": "Whirlpool Appliances",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/whirlpool",
"requirements": ["whirlpool-sixth-sense==0.18.0"],
"codeowners": ["@abmantis"],
"codeowners": ["@abmantis", "@mkmer"],
"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": {
"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%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"no_appliances": "No supported appliances found"
}
}
}

View File

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

View File

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

View File

@ -9,6 +9,8 @@ from whirlpool.backendselector import Brand, Region
MOCK_SAID1 = "said1"
MOCK_SAID2 = "said2"
MOCK_SAID3 = "said3"
MOCK_SAID4 = "said4"
@pytest.fixture(
@ -40,6 +42,10 @@ def fixture_mock_appliances_manager_api():
{"SAID": MOCK_SAID1, "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
@ -78,19 +84,19 @@ def get_aircon_mock(said):
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):
"""Set up air conditioner API fixture."""
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):
"""Set up air conditioner API fixture."""
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):
"""Set up air conditioner API fixture."""
with mock.patch(
@ -98,3 +104,58 @@ def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api):
) as mock_aircon_api:
mock_aircon_api.side_effect = [mock_aircon1_api, mock_aircon2_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."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -36,7 +39,13 @@ async def test_form(hass, region):
) as mock_backend_selector, patch(
"homeassistant.components.whirlpool.async_setup_entry",
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(
result["flow_id"],
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])
async def test_form_invalid_auth(hass, region):
async def test_form_invalid_auth(hass: HomeAssistant, region) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
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(
result["flow_id"],
CONFIG_INPUT | {"region": region[0]},
CONFIG_INPUT
| {
"region": region[0],
},
)
assert result2["type"] == "form"
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."""
result = await hass.config_entries.flow.async_init(
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(
result["flow_id"],
CONFIG_INPUT | {"region": region[0]},
CONFIG_INPUT
| {
"region": region[0],
},
)
assert result2["type"] == "form"
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."""
result = await hass.config_entries.flow.async_init(
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(
result["flow_id"],
CONFIG_INPUT | {"region": region[0]},
CONFIG_INPUT
| {
"region": region[0],
},
)
assert result2["type"] == "form"
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."""
result = await hass.config_entries.flow.async_init(
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(
result["flow_id"],
CONFIG_INPUT | {"region": region[0]},
CONFIG_INPUT
| {
"region": region[0],
},
)
assert result2["type"] == "form"
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."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
data=CONFIG_INPUT | {"region": region[0]},
unique_id="test-username",
)
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(
"homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid",
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(
result["flow_id"],
CONFIG_INPUT | {"region": region[0]},
CONFIG_INPUT
| {
"region": region[0],
},
)
await hass.async_block_till_done()
@ -152,9 +182,35 @@ async def test_form_already_configured(hass, region):
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:
"""Test a successful reauth flow."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
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,
"entry_id": mock_entry.entry_id,
},
data={
"username": "test-username",
"password": "new-password",
"region": region[0],
},
data=CONFIG_INPUT | {"region": region[0]},
)
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(
"homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid",
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(
result["flow_id"],
@ -220,8 +278,8 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region) -> None:
"entry_id": mock_entry.entry_id,
},
data={
"username": "test-username",
"password": "new-password",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "new-password",
"region": region[0],
},
)
@ -246,7 +304,7 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region) -> None:
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."""
mock_entry = MockConfigEntry(
@ -263,11 +321,7 @@ async def test_reauth_flow_connection_error(hass: HomeAssistant, region) -> None
"unique_id": mock_entry.unique_id,
"entry_id": mock_entry.entry_id,
},
data={
CONF_USERNAME: "test-username",
CONF_PASSWORD: "new-password",
"region": region[0],
},
data=CONFIG_INPUT | {"region": region[0]},
)
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
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."""
entry = await init_integration(hass, region[0])
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(
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.
This can happen after a version update, since there was no region in the first versions.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={
@ -42,7 +50,11 @@ async def test_setup_region_fallback(
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."""
mock_auth_api.return_value.do_auth = AsyncMock(
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
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."""
mock_auth_api.return_value.do_auth = AsyncMock()
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(
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."""
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
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."""
entry = await init_integration(hass)
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()