Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 41 additions & 19 deletions homeassistant/components/duco/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
}
Comment thread
ronaldvdmeer marked this conversation as resolved.

_STATE_TO_PRESET: dict[VentilationState, str] = {
VentilationState.AUTO: PRESET_AUTO,
Comment thread
ronaldvdmeer marked this conversation as resolved.
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,
}


Expand All @@ -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:
Expand All @@ -86,26 +110,24 @@ 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
return _STATE_TO_PERCENTAGE.get(node.ventilation.state)

@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)."""
Expand Down
6 changes: 5 additions & 1 deletion homeassistant/components/duco/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions tests/components/duco/snapshots/test_fan.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
'capabilities': dict({
'preset_modes': list([
'auto',
'man1',
'man2',
'man3',
'empt',
]),
}),
'config_entry_id': <ANY>,
Expand Down Expand Up @@ -49,6 +53,10 @@
'preset_mode': 'auto',
'preset_modes': list([
'auto',
'man1',
'man2',
'man3',
'empt',
]),
'supported_features': <FanEntityFeature: 9>,
}),
Expand Down
98 changes: 93 additions & 5 deletions tests/components/duco/test_fan.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"),
Comment thread
ronaldvdmeer marked this conversation as resolved.
],
)
async def test_fan_set_state(
Expand All @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down
Loading