mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
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:
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Constants for the Whirlpool Sixth Sense integration."""
|
||||
"""Constants for the Whirlpool Appliances integration."""
|
||||
|
||||
from whirlpool.backendselector import Region
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
|
287
homeassistant/components/whirlpool/sensor.py
Normal file
287
homeassistant/components/whirlpool/sensor.py
Normal 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()
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"no_appliances": "No supported appliances found",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
245
tests/components/whirlpool/test_sensor.py
Normal file
245
tests/components/whirlpool/test_sensor.py
Normal 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()
|
Reference in New Issue
Block a user