Skip to content

Commit 94fc258

Browse files
author
NotixOfficial
committed
Add Fresh Air (ventilation) fan entity
Exposes the Fresh Air function for AC devices that advertise it, as a single fan entity: - fan.py: MideaFreshAirFan. The library exposes a single fresh_air_fan_speed (OFF/Low/Medium/High/Boost), so the fan derives on/off from the OFF value and uses speed_count with the ordered-list percentage helpers to map the fixed speeds to/from the HA fan percentage (no manual snapping). - Registers the fan platform in __init__.py. - en.json translation and a test (test_fan.py). Gated on supports_fresh_air, so devices without the capability show nothing. Requires msmart-ng with Fresh Air support (mill1000/midea-msmart#268, now merged); the test skips until it is available in a release, and the manifest requirement should be bumped to it.
1 parent 97b5e94 commit 94fc258

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

custom_components/midea_ac/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
Platform.BINARY_SENSOR,
3131
Platform.BUTTON,
3232
Platform.CLIMATE,
33+
Platform.FAN,
3334
Platform.NUMBER,
3435
Platform.SELECT,
3536
Platform.SENSOR,

custom_components/midea_ac/fan.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Fan platform for Midea Smart AC."""
2+
from __future__ import annotations
3+
4+
import logging
5+
from typing import Any, Optional
6+
7+
from homeassistant.components.fan import FanEntity, FanEntityFeature
8+
from homeassistant.config_entries import ConfigEntry
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
11+
from homeassistant.util.percentage import (ordered_list_item_to_percentage,
12+
percentage_to_ordered_list_item)
13+
14+
from .const import DOMAIN
15+
from .coordinator import MideaCoordinatorEntity, MideaDeviceUpdateCoordinator
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
20+
async def async_setup_entry(
21+
hass: HomeAssistant,
22+
config_entry: ConfigEntry,
23+
add_entities: AddEntitiesCallback,
24+
) -> None:
25+
"""Setup the fan platform for Midea Smart AC."""
26+
27+
_LOGGER.info("Setting up fan platform.")
28+
29+
# Fetch coordinator from global data
30+
coordinator = hass.data[DOMAIN][config_entry.entry_id]
31+
device = coordinator.device
32+
33+
# Create fans for supported features
34+
entities = []
35+
if hasattr(device, "fresh_air_fan_speed") and getattr(device, "supports_fresh_air", False):
36+
entities.append(MideaFreshAirFan(coordinator))
37+
38+
add_entities(entities)
39+
40+
41+
class MideaFreshAirFan(MideaCoordinatorEntity, FanEntity):
42+
"""Fresh air (ventilation) fan for Midea AC."""
43+
44+
_attr_translation_key = "fresh_air"
45+
_enable_turn_on_off_backwards_compatibility = False
46+
47+
def __init__(self, coordinator: MideaDeviceUpdateCoordinator) -> None:
48+
MideaCoordinatorEntity.__init__(self, coordinator)
49+
50+
self._enum_class = self._device.FreshAirFanSpeed
51+
self._off = self._enum_class.OFF
52+
53+
# Ordered list of selectable (non-off) speed levels, ascending
54+
self._speeds = [s for s in self._enum_class.list() if s != self._off]
55+
56+
self._supported_features = FanEntityFeature.SET_SPEED
57+
58+
# Attempt to add new TURN_OFF/TURN_ON features in HA 2024.8
59+
try:
60+
self._supported_features |= FanEntityFeature.TURN_OFF
61+
self._supported_features |= FanEntityFeature.TURN_ON
62+
except AttributeError:
63+
pass
64+
65+
@property
66+
def device_info(self) -> dict:
67+
"""Return info for device registry."""
68+
return {
69+
"identifiers": {
70+
(DOMAIN, self._device.id)
71+
},
72+
}
73+
74+
@property
75+
def has_entity_name(self) -> bool:
76+
"""Indicates if entity follows naming conventions."""
77+
return True
78+
79+
@property
80+
def unique_id(self) -> str:
81+
"""Return the unique ID of this entity."""
82+
return f"{self._device.id}-fresh_air"
83+
84+
@property
85+
def supported_features(self) -> FanEntityFeature:
86+
"""Return the supported features."""
87+
return self._supported_features
88+
89+
@property
90+
def available(self) -> bool:
91+
"""Check device availability."""
92+
return super().available and self._device.power_state
93+
94+
@property
95+
def is_on(self) -> bool:
96+
"""Return whether fresh air is on."""
97+
return self._device.fresh_air_fan_speed != self._off
98+
99+
@property
100+
def speed_count(self) -> int:
101+
"""Return the number of speeds the fan supports."""
102+
return len(self._speeds)
103+
104+
@property
105+
def percentage(self) -> int:
106+
"""Return the current speed as a percentage."""
107+
speed = self._device.fresh_air_fan_speed
108+
if speed == self._off:
109+
return 0
110+
111+
return ordered_list_item_to_percentage(self._speeds, speed)
112+
113+
async def async_set_percentage(self, percentage: int) -> None:
114+
"""Set the speed of the fan, as a percentage."""
115+
if percentage <= 0:
116+
self._device.fresh_air_fan_speed = self._off
117+
else:
118+
self._device.fresh_air_fan_speed = percentage_to_ordered_list_item(
119+
self._speeds, percentage)
120+
121+
await self.coordinator.apply()
122+
123+
async def async_turn_on(self,
124+
percentage: Optional[int] = None,
125+
preset_mode: Optional[str] = None,
126+
**kwargs: Any) -> None:
127+
"""Turn the fan on."""
128+
if percentage is not None:
129+
self._device.fresh_air_fan_speed = percentage_to_ordered_list_item(
130+
self._speeds, percentage)
131+
elif self._device.fresh_air_fan_speed == self._off and self._speeds:
132+
# Default to the lowest supported speed when turning on from off
133+
self._device.fresh_air_fan_speed = self._speeds[0]
134+
135+
await self.coordinator.apply()
136+
137+
async def async_turn_off(self, **kwargs: Any) -> None:
138+
"""Turn the fan off."""
139+
self._device.fresh_air_fan_speed = self._off
140+
await self.coordinator.apply()

custom_components/midea_ac/translations/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@
204204
"name": "Start self clean"
205205
}
206206
},
207+
"fan": {
208+
"fresh_air": {
209+
"name": "Fresh air"
210+
}
211+
},
207212
"number": {
208213
"fan_speed": {
209214
"name": "Fan speed"

tests/test_fan.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Tests for the fan platform."""
2+
3+
import logging
4+
from unittest.mock import AsyncMock, MagicMock
5+
6+
import pytest
7+
from homeassistant.config_entries import ConfigEntryState
8+
from homeassistant.const import Platform
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers import entity_registry as er
11+
from msmart.device import AirConditioner as AC
12+
from pytest_homeassistant_custom_component.common import MockConfigEntry
13+
14+
from custom_components.midea_ac.const import DOMAIN
15+
from custom_components.midea_ac.coordinator import MideaDeviceUpdateCoordinator
16+
17+
logging.basicConfig(level=logging.DEBUG)
18+
_LOGGER = logging.getLogger(__name__)
19+
20+
# Fresh air requires a msmart-ng with Fresh Air support
21+
# (see mill1000/midea-msmart#268); skip until that lands.
22+
pytestmark = pytest.mark.skipif(
23+
not hasattr(AC, "FreshAirFanSpeed"),
24+
reason="msmart-ng without Fresh Air support (mill1000/midea-msmart#268)",
25+
)
26+
27+
28+
async def test_fresh_air_fan(
29+
hass: HomeAssistant,
30+
entity_registry: er.EntityRegistry,
31+
mock_config_entry: MockConfigEntry,
32+
) -> None:
33+
"""Test an AC device that supports fresh air creates a fresh air fan."""
34+
35+
mock_config_entry.mock_state(hass, ConfigEntryState.LOADED)
36+
mock_config_entry.add_to_hass(hass)
37+
38+
# Create a dummy AC device and force fresh air support
39+
mock_device = AC("0.0.0.0", 0, 0)
40+
mock_device._online = True
41+
mock_device.power_state = True
42+
mock_device._capabilities.set(AC.Capability.FRESH_AIR, True)
43+
44+
# Create a mock coordinator
45+
coordinator = MagicMock(spec=MideaDeviceUpdateCoordinator)
46+
coordinator.device = mock_device
47+
coordinator.apply = AsyncMock()
48+
49+
# Store coordinator in global data
50+
hass.data.setdefault(DOMAIN, {})[mock_config_entry.entry_id] = coordinator
51+
52+
# Setup climate (to name the device) and fan platforms
53+
await hass.config_entries.async_forward_entry_setups(
54+
mock_config_entry, [Platform.CLIMATE]
55+
)
56+
await hass.async_block_till_done()
57+
58+
await hass.config_entries.async_forward_entry_setups(
59+
mock_config_entry, [Platform.FAN]
60+
)
61+
await hass.async_block_till_done()
62+
63+
# Verify the fresh air fan exists and is off by default
64+
entity_id = "fan.midea_ac_0_fresh_air"
65+
state = hass.states.get(entity_id)
66+
assert state
67+
assert state.state == "off"
68+
assert state.attributes["percentage"] == 0
69+
70+
entry = entity_registry.async_get(entity_id)
71+
assert entry
72+
assert entry.unique_id == "0-fresh_air"
73+
74+
# Setting a percentage turns it on. With 4 speeds the ordered-list buckets
75+
# are 25/50/75/100, so 75% maps to the 3rd level (High).
76+
await hass.services.async_call(
77+
Platform.FAN, "set_percentage",
78+
{"entity_id": entity_id, "percentage": 75}, blocking=True
79+
)
80+
assert mock_device.fresh_air_fan_speed == AC.FreshAirFanSpeed.HIGH
81+
coordinator.apply.assert_awaited()
82+
83+
# Turning the fan off sets the speed to OFF
84+
await hass.services.async_call(
85+
Platform.FAN, "turn_off", {"entity_id": entity_id}, blocking=True
86+
)
87+
assert mock_device.fresh_air_fan_speed == AC.FreshAirFanSpeed.OFF

0 commit comments

Comments
 (0)