diff --git a/CODEOWNERS b/CODEOWNERS index 0d93b4ed126..224cc873be6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index f335f2f5e01..2f1e1fbe21e 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -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 diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index cbc384e8636..75c8c272bcc 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -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) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 9bd404214ad..17ace442cac 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -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.""" diff --git a/homeassistant/components/whirlpool/const.py b/homeassistant/components/whirlpool/const.py index 8f6d1b93ca3..b472c7f9156 100644 --- a/homeassistant/components/whirlpool/const.py +++ b/homeassistant/components/whirlpool/const.py @@ -1,4 +1,4 @@ -"""Constants for the Whirlpool Sixth Sense integration.""" +"""Constants for the Whirlpool Appliances integration.""" from whirlpool.backendselector import Region diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 89315a64d85..13a1107c23e 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -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" } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py new file mode 100644 index 00000000000..41337aea9bd --- /dev/null +++ b/homeassistant/components/whirlpool/sensor.py @@ -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() diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 78a46954183..f6da89f1974 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -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" } } } diff --git a/homeassistant/components/whirlpool/translations/en.json b/homeassistant/components/whirlpool/translations/en.json index 74817db9ba7..99f65933631 100644 --- a/homeassistant/components/whirlpool/translations/en.json +++ b/homeassistant/components/whirlpool/translations/en.json @@ -3,6 +3,7 @@ "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", + "no_appliances": "No supported appliances found", "unknown": "Unexpected error" }, "step": { diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 37193ade1f9..9db269b9c1c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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" diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index faf188c288b..e411cfb8c2d 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -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 diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index a65dbd928c3..ad6620dc057 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -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" diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index dedaa26e618..233b8247840 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -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 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py new file mode 100644 index 00000000000..2da53521a05 --- /dev/null +++ b/tests/components/whirlpool/test_sensor.py @@ -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()