Refactor Whirlpool sensor platform (#141958)

* Refactor Whirlpool sensor platform

* Rename sensor classes

* Remove unused logging

* Split washer dryer translation keys to use icon translations

* Address review comments

* Remove entity name; fix sentence casing
This commit is contained in:
Abílio Costa 2025-04-01 19:02:24 +01:00 committed by GitHub
parent c28a6a867d
commit 704777444c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 187 additions and 133 deletions

View File

@ -0,0 +1,38 @@
"""Base entity for the Whirlpool integration."""
from whirlpool.appliance import Appliance
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class WhirlpoolEntity(Entity):
"""Base class for Whirlpool entities."""
_attr_has_entity_name = True
def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None:
"""Initialize the entity."""
self._appliance = appliance
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance.said)},
name=appliance.name.capitalize(),
manufacturer="Whirlpool",
)
self._attr_unique_id = f"{appliance.said}{unique_id_suffix}"
async def async_added_to_hass(self) -> None:
"""Register attribute updates callback."""
self._appliance.register_attr_callback(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Unregister attribute updates callback."""
self._appliance.unregister_attr_callback(self.async_write_ha_state)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._appliance.get_online()

View File

@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"washer_state": {
"default": "mdi:washing-machine"
},
"dryer_state": {
"default": "mdi:tumble-dryer"
}
}
}
}

View File

@ -3,8 +3,9 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import override
from whirlpool.appliance import Appliance
from whirlpool.washerdryer import MachineState, WasherDryer
from homeassistant.components.sensor import (
@ -13,16 +14,17 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from . import WhirlpoolConfigEntry
from .const import DOMAIN
from .entity import WhirlpoolEntity
TANK_FILL = {
SCAN_INTERVAL = timedelta(minutes=5)
WASHER_TANK_FILL = {
"0": "unknown",
"1": "empty",
"2": "25",
@ -31,7 +33,7 @@ TANK_FILL = {
"5": "active",
}
MACHINE_STATE = {
WASHER_DRYER_MACHINE_STATE = {
MachineState.Standby: "standby",
MachineState.Setting: "setting",
MachineState.DelayCountdownMode: "delay_countdown",
@ -53,7 +55,7 @@ MACHINE_STATE = {
MachineState.SystemInit: "system_initialize",
}
CYCLE_FUNC = [
WASHER_DRYER_CYCLE_FUNC = [
(WasherDryer.get_cycle_status_filling, "cycle_filling"),
(WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"),
(WasherDryer.get_cycle_status_sensing, "cycle_sensing"),
@ -62,60 +64,69 @@ CYCLE_FUNC = [
(WasherDryer.get_cycle_status_washing, "cycle_washing"),
]
DOOR_OPEN = "door_open"
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=5)
STATE_DOOR_OPEN = "door_open"
def washer_state(washer: WasherDryer) -> str | None:
"""Determine correct states for a washer."""
def washer_dryer_state(washer_dryer: WasherDryer) -> str | None:
"""Determine correct states for a washer/dryer."""
if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1":
return DOOR_OPEN
if washer_dryer.get_attribute("Cavity_OpStatusDoorOpen") == "1":
return STATE_DOOR_OPEN
machine_state = washer.get_machine_state()
machine_state = washer_dryer.get_machine_state()
if machine_state == MachineState.RunningMainCycle:
for func, cycle_name in CYCLE_FUNC:
if func(washer):
for func, cycle_name in WASHER_DRYER_CYCLE_FUNC:
if func(washer_dryer):
return cycle_name
return MACHINE_STATE.get(machine_state)
return WASHER_DRYER_MACHINE_STATE.get(machine_state)
@dataclass(frozen=True, kw_only=True)
class WhirlpoolSensorEntityDescription(SensorEntityDescription):
"""Describes Whirlpool Washer sensor entity."""
"""Describes a Whirlpool sensor entity."""
value_fn: Callable
value_fn: Callable[[Appliance], str | None]
SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = (
WASHER_DRYER_STATE_OPTIONS = (
list(WASHER_DRYER_MACHINE_STATE.values())
+ [value for _, value in WASHER_DRYER_CYCLE_FUNC]
+ [STATE_DOOR_OPEN]
)
WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = (
WhirlpoolSensorEntityDescription(
key="state",
translation_key="whirlpool_machine",
translation_key="washer_state",
device_class=SensorDeviceClass.ENUM,
options=(
list(MACHINE_STATE.values())
+ [value for _, value in CYCLE_FUNC]
+ [DOOR_OPEN]
),
value_fn=washer_state,
options=WASHER_DRYER_STATE_OPTIONS,
value_fn=washer_dryer_state,
),
WhirlpoolSensorEntityDescription(
key="DispenseLevel",
translation_key="whirlpool_tank",
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=list(TANK_FILL.values()),
value_fn=lambda WasherDryer: TANK_FILL.get(
WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level")
options=list(WASHER_TANK_FILL.values()),
value_fn=lambda washer: WASHER_TANK_FILL.get(
washer.get_attribute("WashCavity_OpStatusBulkDispense1Level")
),
),
)
SENSOR_TIMER: tuple[SensorEntityDescription] = (
DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = (
WhirlpoolSensorEntityDescription(
key="state",
translation_key="dryer_state",
device_class=SensorDeviceClass.ENUM,
options=WASHER_DRYER_STATE_OPTIONS,
value_fn=washer_dryer_state,
),
)
WASHER_DRYER_TIME_SENSORS: tuple[SensorEntityDescription] = (
SensorEntityDescription(
key="timeremaining",
translation_key="end_time",
@ -134,106 +145,71 @@ async def async_setup_entry(
entities: list = []
appliances_manager = config_entry.runtime_data
for washer_dryer in appliances_manager.washer_dryers:
sensor_descriptions = (
DRYER_SENSORS
if "dryer" in washer_dryer.appliance_info.data_model.lower()
else WASHER_SENSORS
)
entities.extend(
[WasherDryerClass(washer_dryer, description) for description in SENSORS]
WhirlpoolSensor(washer_dryer, description)
for description in sensor_descriptions
)
entities.extend(
[
WasherDryerTimeClass(washer_dryer, description)
for description in SENSOR_TIMER
]
WasherDryerTimeSensor(washer_dryer, description)
for description in WASHER_DRYER_TIME_SENSORS
)
async_add_entities(entities)
class WasherDryerClass(SensorEntity):
"""A class for the whirlpool/maytag washer account."""
class WhirlpoolSensor(WhirlpoolEntity, SensorEntity):
"""A class for the Whirlpool sensors."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self, washer_dryer: WasherDryer, description: WhirlpoolSensorEntityDescription
self, appliance: Appliance, description: WhirlpoolSensorEntityDescription
) -> None:
"""Initialize the washer sensor."""
self._wd: WasherDryer = washer_dryer
self._attr_icon = (
"mdi:tumble-dryer"
if "dryer" in washer_dryer.appliance_info.data_model.lower()
else "mdi:washing-machine"
)
super().__init__(appliance, unique_id_suffix=f"-{description.key}")
self.entity_description: WhirlpoolSensorEntityDescription = description
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, washer_dryer.said)},
name=washer_dryer.name.capitalize(),
manufacturer="Whirlpool",
)
self._attr_unique_id = f"{washer_dryer.said}-{description.key}"
async def async_added_to_hass(self) -> None:
"""Register updates callback."""
self._wd.register_attr_callback(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Unregister updates callback."""
self._wd.unregister_attr_callback(self.async_write_ha_state)
@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)
return self.entity_description.value_fn(self._appliance)
class WasherDryerTimeClass(RestoreSensor):
"""A timestamp class for the whirlpool/maytag washer account."""
class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor):
"""A timestamp class for the Whirlpool washer/dryer."""
_attr_should_poll = True
_attr_has_entity_name = True
def __init__(
self, washer_dryer: WasherDryer, description: SensorEntityDescription
) -> None:
"""Initialize the washer sensor."""
self._wd: WasherDryer = washer_dryer
super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}")
self.entity_description = description
self.entity_description: SensorEntityDescription = description
self._wd = washer_dryer
self._running: bool | None = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, washer_dryer.said)},
name=washer_dryer.name.capitalize(),
manufacturer="Whirlpool",
)
self._attr_unique_id = f"{washer_dryer.said}-{description.key}"
self._value: datetime | None = None
async def async_added_to_hass(self) -> None:
"""Connect washer/dryer to the cloud."""
"""Register attribute updates callback."""
if restored_data := await self.async_get_last_sensor_data():
self._attr_native_value = restored_data.native_value
if isinstance(restored_data.native_value, datetime):
self._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."""
self._wd.unregister_attr_callback(self.update_from_latest_data)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._wd.get_online()
async def async_update(self) -> None:
"""Update status of Whirlpool."""
await self._wd.fetch_data()
@callback
def update_from_latest_data(self) -> None:
@override
@property
def native_value(self) -> datetime | None:
"""Calculate the time stamp for completion."""
machine_state = self._wd.get_machine_state()
now = utcnow()
@ -243,8 +219,7 @@ class WasherDryerTimeClass(RestoreSensor):
and self._running
):
self._running = False
self._attr_native_value = now
self._async_write_ha_state()
self._value = now
if machine_state is MachineState.RunningMainCycle:
self._running = True
@ -253,9 +228,9 @@ class WasherDryerTimeClass(RestoreSensor):
seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining"))
)
if self._attr_native_value is None or (
isinstance(self._attr_native_value, datetime)
and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60)
if self._value is None or (
isinstance(self._value, datetime)
and abs(new_timestamp - self._value) > timedelta(seconds=60)
):
self._attr_native_value = new_timestamp
self._async_write_ha_state()
self._value = new_timestamp
return self._value

View File

@ -43,35 +43,64 @@
},
"entity": {
"sensor": {
"whirlpool_machine": {
"name": "State",
"washer_state": {
"state": {
"standby": "[%key:common::state::standby%]",
"setting": "Setting",
"delay_countdown": "Delay Countdown",
"delay_paused": "Delay Paused",
"smart_delay": "Smart Delay",
"smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]",
"delay_countdown": "Delay countdown",
"delay_paused": "Delay paused",
"smart_delay": "Smart delay",
"smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]",
"pause": "[%key:common::state::paused%]",
"running_maincycle": "Running Maincycle",
"running_postcycle": "Running Postcycle",
"running_maincycle": "Running maincycle",
"running_postcycle": "Running postcycle",
"exception": "Exception",
"complete": "Complete",
"power_failure": "Power Failure",
"service_diagnostic_mode": "Service Diagnostic Mode",
"factory_diagnostic_mode": "Factory Diagnostic Mode",
"life_test": "Life Test",
"customer_focus_mode": "Customer Focus Mode",
"demo_mode": "Demo Mode",
"hard_stop_or_error": "Hard Stop or Error",
"system_initialize": "System Initialize",
"cycle_filling": "Cycle Filling",
"cycle_rinsing": "Cycle Rinsing",
"cycle_sensing": "Cycle Sensing",
"cycle_soaking": "Cycle Soaking",
"cycle_spinning": "Cycle Spinning",
"cycle_washing": "Cycle Washing",
"door_open": "Door Open"
"power_failure": "Power failure",
"service_diagnostic_mode": "Service diagnostic mode",
"factory_diagnostic_mode": "Factory diagnostic mode",
"life_test": "Life test",
"customer_focus_mode": "Customer focus mode",
"demo_mode": "Demo mode",
"hard_stop_or_error": "Hard stop or error",
"system_initialize": "System initialize",
"cycle_filling": "Cycle filling",
"cycle_rinsing": "Cycle rinsing",
"cycle_sensing": "Cycle sensing",
"cycle_soaking": "Cycle soaking",
"cycle_spinning": "Cycle spinning",
"cycle_washing": "Cycle washing",
"door_open": "Door open"
}
},
"dryer_state": {
"state": {
"standby": "[%key:common::state::standby%]",
"setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]",
"delay_countdown": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_countdown%]",
"delay_paused": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_paused%]",
"smart_delay": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]",
"smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]",
"pause": "[%key:common::state::paused%]",
"running_maincycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_maincycle%]",
"running_postcycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_postcycle%]",
"exception": "[%key:component::whirlpool::entity::sensor::washer_state::state::exception%]",
"complete": "[%key:component::whirlpool::entity::sensor::washer_state::state::complete%]",
"power_failure": "[%key:component::whirlpool::entity::sensor::washer_state::state::power_failure%]",
"service_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::service_diagnostic_mode%]",
"factory_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::factory_diagnostic_mode%]",
"life_test": "[%key:component::whirlpool::entity::sensor::washer_state::state::life_test%]",
"customer_focus_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::customer_focus_mode%]",
"demo_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::demo_mode%]",
"hard_stop_or_error": "[%key:component::whirlpool::entity::sensor::washer_state::state::hard_stop_or_error%]",
"system_initialize": "[%key:component::whirlpool::entity::sensor::washer_state::state::system_initialize%]",
"cycle_filling": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_filling%]",
"cycle_rinsing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_rinsing%]",
"cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]",
"cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]",
"cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]",
"cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]",
"door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]"
}
},
"whirlpool_tank": {

View File

@ -153,12 +153,12 @@ def side_effect_function(*args, **kwargs):
return None
def get_sensor_mock(said):
def get_sensor_mock(said: str, data_model: str):
"""Get a mock of a sensor."""
mock_sensor = mock.Mock(said=said)
mock_sensor.name = f"WasherDryer {said}"
mock_sensor.register_attr_callback = MagicMock()
mock_sensor.appliance_info.data_model = "washer_dryer_model"
mock_sensor.appliance_info.data_model = data_model
mock_sensor.appliance_info.category = "washer_dryer"
mock_sensor.appliance_info.model_number = "12345"
mock_sensor.get_online.return_value = True
@ -179,13 +179,13 @@ def get_sensor_mock(said):
@pytest.fixture(name="mock_sensor1_api", autouse=False)
def fixture_mock_sensor1_api():
"""Set up sensor API fixture."""
return get_sensor_mock(MOCK_SAID3)
return get_sensor_mock(MOCK_SAID3, "washer")
@pytest.fixture(name="mock_sensor2_api", autouse=False)
def fixture_mock_sensor2_api():
"""Set up sensor API fixture."""
return get_sensor_mock(MOCK_SAID4)
return get_sensor_mock(MOCK_SAID4, "dryer")
@pytest.fixture(name="mock_sensor_api_instances", autouse=False)

View File

@ -19,12 +19,12 @@
'washer_dryers': dict({
'WasherDryer said3': dict({
'category': 'washer_dryer',
'data_model': 'washer_dryer_model',
'data_model': 'washer',
'model_number': '12345',
}),
'WasherDryer said4': dict({
'category': 'washer_dryer',
'data_model': 'washer_dryer_model',
'data_model': 'dryer',
'model_number': '12345',
}),
}),

View File

@ -66,7 +66,7 @@ async def test_dryer_sensor_values(
await init_integration(hass)
entity_id = f"sensor.washerdryer_{MOCK_SAID4}_state"
entity_id = f"sensor.washerdryer_{MOCK_SAID4}_none"
mock_instance = mock_sensor2_api
entry = entity_registry.async_get(entity_id)
assert entry
@ -130,7 +130,7 @@ async def test_washer_sensor_values(
)
await hass.async_block_till_done()
entity_id = f"sensor.washerdryer_{MOCK_SAID3}_state"
entity_id = f"sensor.washerdryer_{MOCK_SAID3}_none"
mock_instance = mock_sensor1_api
entry = entity_registry.async_get(entity_id)
assert entry