Add evaporative humidifier for switchbot integration (#146235)

* add support for evaporative humidifier

* add evaporative humidifier unit test

* clear the humidifier action in pyswitchbot

* fix ruff

* fix Sentence-casing issue

* add icon translation

* remove last run success

* use icon translations for water level

* remove the translation for last run success
This commit is contained in:
Retha Runolfsson 2025-06-25 15:32:33 +08:00 committed by GitHub
parent 85e9919bbd
commit d0b2d1dc92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 322 additions and 2 deletions

View File

@ -92,6 +92,7 @@ PLATFORMS_BY_TYPE = {
],
SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@ -117,6 +118,7 @@ CLASS_BY_DEVICE = {
SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock,
SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier,
SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier,
SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier,
}

View File

@ -48,6 +48,7 @@ class SupportedModels(StrEnum):
LOCK_ULTRA = "lock_ultra"
AIR_PURIFIER = "air_purifier"
AIR_PURIFIER_TABLE = "air_purifier_table"
EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@ -75,6 +76,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA,
SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER,
SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE,
SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@ -103,6 +105,7 @@ ENCRYPTED_MODELS = {
SwitchbotModel.LOCK_ULTRA,
SwitchbotModel.AIR_PURIFIER,
SwitchbotModel.AIR_PURIFIER_TABLE,
SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
}
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@ -116,6 +119,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock,
SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier,
SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier,
}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {

View File

@ -2,11 +2,16 @@
from __future__ import annotations
import logging
from typing import Any
import switchbot
from switchbot import HumidifierAction as SwitchbotHumidifierAction, HumidifierMode
from homeassistant.components.humidifier import (
MODE_AUTO,
MODE_NORMAL,
HumidifierAction,
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityFeature,
@ -17,7 +22,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry
from .entity import SwitchbotSwitchedEntity, exception_handler
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
EVAPORATIVE_HUMIDIFIER_ACTION_MAP: dict[int, HumidifierAction] = {
SwitchbotHumidifierAction.OFF: HumidifierAction.OFF,
SwitchbotHumidifierAction.HUMIDIFYING: HumidifierAction.HUMIDIFYING,
SwitchbotHumidifierAction.DRYING: HumidifierAction.DRYING,
}
async def async_setup_entry(
@ -26,7 +37,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot based on a config entry."""
async_add_entities([SwitchBotHumidifier(entry.runtime_data)])
coordinator = entry.runtime_data
if isinstance(coordinator.device, switchbot.SwitchbotEvaporativeHumidifier):
async_add_entities([SwitchBotEvaporativeHumidifier(coordinator)])
else:
async_add_entities([SwitchBotHumidifier(coordinator)])
class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity):
@ -69,3 +84,71 @@ class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity):
else:
self._last_run_success = await self._device.async_set_manual()
self.async_write_ha_state()
class SwitchBotEvaporativeHumidifier(SwitchbotSwitchedEntity, HumidifierEntity):
"""Representation of a Switchbot evaporative humidifier."""
_device: switchbot.SwitchbotEvaporativeHumidifier
_attr_device_class = HumidifierDeviceClass.HUMIDIFIER
_attr_supported_features = HumidifierEntityFeature.MODES
_attr_available_modes = HumidifierMode.get_modes()
_attr_min_humidity = 1
_attr_max_humidity = 99
_attr_translation_key = "evaporative_humidifier"
_attr_name = None
@property
def is_on(self) -> bool | None:
"""Return true if device is on."""
return self._device.is_on()
@property
def mode(self) -> str:
"""Return the evaporative humidifier current mode."""
return self._device.get_mode().name.lower()
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
return self._device.get_humidity()
@property
def target_humidity(self) -> int | None:
"""Return the humidity we try to reach."""
return self._device.get_target_humidity()
@property
def action(self) -> HumidifierAction | None:
"""Return the current action."""
return EVAPORATIVE_HUMIDIFIER_ACTION_MAP.get(
self._device.get_action(), HumidifierAction.IDLE
)
@exception_handler
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
_LOGGER.debug("Setting target humidity to: %s %s", humidity, self._address)
await self._device.set_target_humidity(humidity)
self.async_write_ha_state()
@exception_handler
async def async_set_mode(self, mode: str) -> None:
"""Set new evaporative humidifier mode."""
_LOGGER.debug("Setting mode to: %s %s", mode, self._address)
await self._device.set_mode(HumidifierMode[mode.upper()])
self.async_write_ha_state()
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the humidifier."""
_LOGGER.debug("Turning on the humidifier %s", self._address)
await self._device.turn_on()
self.async_write_ha_state()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the humidifier."""
_LOGGER.debug("Turning off the humidifier %s", self._address)
await self._device.turn_off()
self.async_write_ha_state()

View File

@ -1,5 +1,16 @@
{
"entity": {
"sensor": {
"water_level": {
"default": "mdi:water-percent",
"state": {
"empty": "mdi:water-off",
"low": "mdi:water-outline",
"medium": "mdi:water",
"high": "mdi:water-check"
}
}
},
"fan": {
"fan": {
"state_attributes": {
@ -31,6 +42,24 @@
}
}
}
},
"humidifier": {
"evaporative_humidifier": {
"state_attributes": {
"mode": {
"state": {
"high": "mdi:water-plus",
"medium": "mdi:water",
"low": "mdi:water-outline",
"quiet": "mdi:volume-off",
"target_humidity": "mdi:target",
"sleep": "mdi:weather-night",
"auto": "mdi:autorenew",
"drying_filter": "mdi:water-remove"
}
}
}
}
}
}
}

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from switchbot import HumidifierWaterLevel
from switchbot.const.air_purifier import AirQualityLevel
from homeassistant.components.bluetooth import async_last_service_info
@ -117,6 +118,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
),
"water_level": SensorEntityDescription(
key="water_level",
translation_key="water_level",
device_class=SensorDeviceClass.ENUM,
options=HumidifierWaterLevel.get_levels(),
),
}

View File

@ -114,6 +114,15 @@
"moderate": "Moderate",
"unhealthy": "Unhealthy"
}
},
"water_level": {
"name": "Water level",
"state": {
"empty": "Empty",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"high": "[%key:common::state::high%]"
}
}
},
"cover": {
@ -138,6 +147,22 @@
}
}
}
},
"evaporative_humidifier": {
"state_attributes": {
"mode": {
"state": {
"high": "[%key:common::state::high%]",
"medium": "[%key:common::state::medium%]",
"low": "[%key:common::state::low%]",
"quiet": "Quiet",
"target_humidity": "Target humidity",
"sleep": "Sleep",
"auto": "[%key:common::state::auto%]",
"drying_filter": "Drying filter"
}
}
}
}
},
"lock": {

View File

@ -859,3 +859,27 @@ AIR_PURIFIER_TABLE_VOC_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True,
tx_power=-127,
)
EVAPORATIVE_HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Evaporative Humidifier",
manufacturer_data={
2409: b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"#\x00\x00\x15\x1c\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Evaporative Humidifier",
manufacturer_data={
2409: b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"#\x00\x00\x15\x1c\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Evaporative Humidifier"),
time=0,
connectable=True,
tx_power=-127,
)

View File

@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import HUMIDIFIER_SERVICE_INFO
from . import EVAPORATIVE_HUMIDIFIER_SERVICE_INFO, HUMIDIFIER_SERVICE_INFO
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
@ -173,3 +173,89 @@ async def test_exception_handling_humidifier_service(
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.parametrize(
("service", "service_data", "mock_method"),
[
(SERVICE_TURN_ON, {}, "turn_on"),
(SERVICE_TURN_OFF, {}, "turn_off"),
(SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_target_humidity"),
(SERVICE_SET_MODE, {ATTR_MODE: "sleep"}, "set_mode"),
],
)
async def test_evaporative_humidifier_services(
hass: HomeAssistant,
mock_entry_encrypted_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
) -> None:
"""Test evaporative humidifier services with proper parameters."""
inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO)
entry = mock_entry_encrypted_factory(sensor_type="evaporative_humidifier")
entry.add_to_hass(hass)
entity_id = "humidifier.test_name"
mocked_instance = AsyncMock(return_value=True)
with patch.multiple(
"homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier",
update=AsyncMock(return_value=None),
**{mock_method: mocked_instance},
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_instance.assert_awaited_once()
@pytest.mark.parametrize(
("service", "service_data", "mock_method"),
[
(SERVICE_TURN_ON, {}, "turn_on"),
(SERVICE_TURN_OFF, {}, "turn_off"),
(SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_target_humidity"),
(SERVICE_SET_MODE, {ATTR_MODE: "sleep"}, "set_mode"),
],
)
async def test_evaporative_humidifier_services_with_exception(
hass: HomeAssistant,
mock_entry_encrypted_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
) -> None:
"""Test exception handling for evaporative humidifier services."""
inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO)
entry = mock_entry_encrypted_factory(sensor_type="evaporative_humidifier")
entry.add_to_hass(hass)
entity_id = "humidifier.test_name"
patch_target = f"homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier.{mock_method}"
with patch(
patch_target,
new=AsyncMock(side_effect=SwitchbotOperationError("Operation failed")),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(
HomeAssistantError,
match="An error occurred while performing the action: Operation failed",
):
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)

View File

@ -11,6 +11,7 @@ from homeassistant.components.switchbot.const import (
DOMAIN,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ADDRESS,
@ -23,6 +24,7 @@ from homeassistant.setup import async_setup_component
from . import (
CIRCULATOR_FAN_SERVICE_INFO,
EVAPORATIVE_HUMIDIFIER_SERVICE_INFO,
HUB3_SERVICE_INFO,
HUBMINI_MATTER_SERVICE_INFO,
LEAK_SERVICE_INFO,
@ -484,3 +486,61 @@ async def test_hub3_sensor(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_evaporative_humidifier_sensor(hass: HomeAssistant) -> None:
"""Test setting up creates the sensor for evaporative humidifier."""
await async_setup_component(hass, DOMAIN, {})
inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO)
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
CONF_NAME: "test-name",
CONF_SENSOR_TYPE: "evaporative_humidifier",
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
},
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier.update",
return_value=True,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 4
rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal")
rssi_sensor_attrs = rssi_sensor.attributes
assert rssi_sensor.state == "-60"
assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal"
assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm"
humidity_sensor = hass.states.get("sensor.test_name_humidity")
humidity_sensor_attrs = humidity_sensor.attributes
assert humidity_sensor.state == "53"
assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity"
assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%"
assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
temperature_sensor = hass.states.get("sensor.test_name_temperature")
temperature_sensor_attrs = temperature_sensor.attributes
assert temperature_sensor.state == "25.1"
assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature"
assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C"
assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
water_level_sensor = hass.states.get("sensor.test_name_water_level")
water_level_sensor_attrs = water_level_sensor.attributes
assert water_level_sensor.state == "medium"
assert water_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Water level"
assert water_level_sensor_attrs[ATTR_DEVICE_CLASS] == "enum"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()