mirror of
https://github.com/home-assistant/core.git
synced 2025-07-20 03:37:07 +00:00
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:
parent
85e9919bbd
commit
d0b2d1dc92
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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()
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@ -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": {
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user