diff --git a/homeassistant/components/duco/fan.py b/homeassistant/components/duco/fan.py index 480f2381d125e2..612156abbd1d45 100644 --- a/homeassistant/components/duco/fan.py +++ b/homeassistant/components/duco/fan.py @@ -27,6 +27,10 @@ ] PRESET_AUTO = "auto" +PRESET_MAN1 = "man1" +PRESET_MAN2 = "man2" +PRESET_MAN3 = "man3" +PRESET_EMPT = "empt" # Upper-bound percentages for 3 speed levels: 33 / 66 / 100. # Using upper bounds guarantees that reading a percentage back and writing it @@ -36,21 +40,35 @@ for i, _ in enumerate(ORDERED_NAMED_FAN_SPEEDS) ] -# Maps every active Duco state (including timed MAN variants) to its -# display percentage so externally-set timed modes show the correct level. _STATE_TO_PERCENTAGE: dict[VentilationState, int] = { VentilationState.CNT1: _SPEED_LEVEL_PERCENTAGES[0], - VentilationState.MAN1: _SPEED_LEVEL_PERCENTAGES[0], - VentilationState.MAN1x2: _SPEED_LEVEL_PERCENTAGES[0], - VentilationState.MAN1x3: _SPEED_LEVEL_PERCENTAGES[0], VentilationState.CNT2: _SPEED_LEVEL_PERCENTAGES[1], - VentilationState.MAN2: _SPEED_LEVEL_PERCENTAGES[1], - VentilationState.MAN2x2: _SPEED_LEVEL_PERCENTAGES[1], - VentilationState.MAN2x3: _SPEED_LEVEL_PERCENTAGES[1], VentilationState.CNT3: _SPEED_LEVEL_PERCENTAGES[2], - VentilationState.MAN3: _SPEED_LEVEL_PERCENTAGES[2], - VentilationState.MAN3x2: _SPEED_LEVEL_PERCENTAGES[2], - VentilationState.MAN3x3: _SPEED_LEVEL_PERCENTAGES[2], +} + +_STATE_TO_PRESET: dict[VentilationState, str] = { + VentilationState.AUTO: PRESET_AUTO, + VentilationState.AUT1: PRESET_AUTO, + VentilationState.AUT2: PRESET_AUTO, + VentilationState.AUT3: PRESET_AUTO, + VentilationState.MAN1: PRESET_MAN1, + VentilationState.MAN1x2: PRESET_MAN1, + VentilationState.MAN1x3: PRESET_MAN1, + VentilationState.MAN2: PRESET_MAN2, + VentilationState.MAN2x2: PRESET_MAN2, + VentilationState.MAN2x3: PRESET_MAN2, + VentilationState.MAN3: PRESET_MAN3, + VentilationState.MAN3x2: PRESET_MAN3, + VentilationState.MAN3x3: PRESET_MAN3, + VentilationState.EMPT: PRESET_EMPT, +} + +_PRESET_TO_STATE: dict[str, VentilationState] = { + PRESET_AUTO: VentilationState.AUTO, + PRESET_MAN1: VentilationState.MAN1, + PRESET_MAN2: VentilationState.MAN2, + PRESET_MAN3: VentilationState.MAN3, + PRESET_EMPT: VentilationState.EMPT, } @@ -76,7 +94,13 @@ class DucoVentilationFanEntity(DucoEntity, FanEntity): _attr_translation_key = "ventilation" _attr_name = None _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE - _attr_preset_modes = [PRESET_AUTO] + _attr_preset_modes = [ + PRESET_AUTO, + PRESET_MAN1, + PRESET_MAN2, + PRESET_MAN3, + PRESET_EMPT, + ] _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: @@ -86,7 +110,7 @@ def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: @property def percentage(self) -> int | None: - """Return the current speed as a percentage, or None when in AUTO mode.""" + """Return the current speed as a percentage, or None when a preset is active.""" node = self._node if node.ventilation is None: return None @@ -94,18 +118,16 @@ def percentage(self) -> int | None: @property def preset_mode(self) -> str | None: - """Return the current preset mode (auto when Duco controls, else None).""" + """Return the active preset, or None when a CNT speed level is set.""" node = self._node if node.ventilation is None: return None - if node.ventilation.state not in _STATE_TO_PERCENTAGE: - return PRESET_AUTO - return None + return _STATE_TO_PRESET.get(node.ventilation.state) async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set preset mode: 'auto' hands control back to Duco.""" + """Set the ventilation preset mode.""" self._valid_preset_mode_or_raise(preset_mode) - await self._async_set_state(VentilationState.AUTO) + await self._async_set_state(_PRESET_TO_STATE[preset_mode]) async def async_set_percentage(self, percentage: int) -> None: """Set the fan speed as a percentage (maps to low/medium/high).""" diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index d44cbef2d044bf..213a47606031b5 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -40,7 +40,11 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "[%key:common::state::auto%]" + "auto": "[%key:common::state::auto%]", + "empt": "Empty house", + "man1": "Timed low speed", + "man2": "Timed medium speed", + "man3": "Timed high speed" } } } diff --git a/tests/components/duco/snapshots/test_fan.ambr b/tests/components/duco/snapshots/test_fan.ambr index acbaa0a4d7a2ac..44991b466cd201 100644 --- a/tests/components/duco/snapshots/test_fan.ambr +++ b/tests/components/duco/snapshots/test_fan.ambr @@ -8,6 +8,10 @@ 'capabilities': dict({ 'preset_modes': list([ 'auto', + 'man1', + 'man2', + 'man3', + 'empt', ]), }), 'config_entry_id': , @@ -49,6 +53,10 @@ 'preset_mode': 'auto', 'preset_modes': list([ 'auto', + 'man1', + 'man2', + 'man3', + 'empt', ]), 'supported_features': , }), diff --git a/tests/components/duco/test_fan.py b/tests/components/duco/test_fan.py index 6069fde48004de..89610f352c2dbd 100644 --- a/tests/components/duco/test_fan.py +++ b/tests/components/duco/test_fan.py @@ -1,9 +1,15 @@ """Tests for the Duco fan platform.""" -import logging from unittest.mock import AsyncMock, patch from duco.exceptions import DucoConnectionError, DucoError, DucoRateLimitError +from duco.models import ( + Node, + NodeGeneralInfo, + NodeSensorInfo, + NodeVentilationInfo, + VentilationState, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -60,6 +66,10 @@ async def test_fan_entity_state( (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 66}, "CNT2"), (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 100}, "CNT3"), (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "auto"}, "AUTO"), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "man1"}, "MAN1"), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "man2"}, "MAN2"), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "man3"}, "MAN3"), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "empt"}, "EMPT"), ], ) async def test_fan_set_state( @@ -84,6 +94,87 @@ async def test_fan_set_state( ) +def _box_node_with_state(state: VentilationState) -> Node: + """Return a BOX node fixture with the given ventilation state.""" + return Node( + node_id=1, + general=NodeGeneralInfo( + node_type="BOX", + sub_type=1, + network_type="VIRT", + parent=0, + asso=0, + name="Living", + identify=0, + ), + ventilation=NodeVentilationInfo( + state=state.value, + time_state_remain=0, + time_state_end=0, + mode="AUTO", + flow_lvl_tgt=0, + ), + sensor=NodeSensorInfo( + co2=None, + iaq_co2=None, + rh=None, + iaq_rh=None, + temp=27.9, + ), + ) + + +@pytest.mark.parametrize( + ("ventilation_state", "expected_preset", "expected_percentage"), + [ + (VentilationState.AUTO, "auto", None), + (VentilationState.AUT1, "auto", None), + (VentilationState.AUT2, "auto", None), + (VentilationState.AUT3, "auto", None), + (VentilationState.MAN1, "man1", None), + (VentilationState.MAN1x2, "man1", None), + (VentilationState.MAN1x3, "man1", None), + (VentilationState.MAN2, "man2", None), + (VentilationState.MAN2x2, "man2", None), + (VentilationState.MAN2x3, "man2", None), + (VentilationState.MAN3, "man3", None), + (VentilationState.MAN3x2, "man3", None), + (VentilationState.MAN3x3, "man3", None), + (VentilationState.EMPT, "empt", None), + (VentilationState.CNT1, None, 33), + (VentilationState.CNT2, None, 66), + (VentilationState.CNT3, None, 100), + ], +) +async def test_fan_read_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + freezer: FrozenDateTimeFactory, + ventilation_state: VentilationState, + expected_preset: str | None, + expected_percentage: int | None, +) -> None: + """Test that preset_mode and percentage reflect the reported VentilationState.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.duco.PLATFORMS", [Platform.FAN]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + updated_nodes = [_box_node_with_state(ventilation_state), *mock_nodes[1:]] + mock_duco_client.async_get_nodes = AsyncMock(return_value=updated_nodes) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(_FAN_ENTITY) + assert state is not None + assert state.attributes.get("preset_mode") == expected_preset + assert state.attributes.get("percentage") == expected_percentage + + @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( ("exception", "match"), @@ -122,10 +213,7 @@ async def test_fan_set_state_rate_limit_logs_warning( side_effect=DucoRateLimitError() ) - with ( - pytest.raises(HomeAssistantError), - caplog.at_level(logging.WARNING, logger="homeassistant.components.duco.fan"), - ): + with pytest.raises(HomeAssistantError): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE,