Add child lock and wireless charging switches for air purifier (#167140)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Retha Runolfsson
2026-04-10 17:29:02 +08:00
committed by GitHub
parent 6ccede7f30
commit 038bb6c15d
5 changed files with 221 additions and 2 deletions

View File

@@ -114,21 +114,25 @@ PLATFORMS_BY_TYPE = {
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
Platform.SWITCH,
],
SupportedModels.AIR_PURIFIER_US.value: [
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
Platform.SWITCH,
],
SupportedModels.AIR_PURIFIER_TABLE_JP.value: [
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
Platform.SWITCH,
],
SupportedModels.AIR_PURIFIER_TABLE_US.value: [
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
Platform.SWITCH,
],
SupportedModels.EVAPORATIVE_HUMIDIFIER.value: [
Platform.HUMIDIFIER,

View File

@@ -145,6 +145,20 @@
"medium": "mdi:water"
}
}
},
"switch": {
"child_lock": {
"state": {
"off": "mdi:lock-open",
"on": "mdi:lock"
}
},
"wireless_charging": {
"state": {
"off": "mdi:battery-charging-wireless-outline",
"on": "mdi:battery-charging-wireless"
}
}
}
},
"services": {

View File

@@ -326,6 +326,12 @@
}
}
}
},
"child_lock": {
"name": "Child lock"
},
"wireless_charging": {
"name": "Wireless charging"
}
},
"vacuum": {

View File

@@ -2,22 +2,61 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import Any
import switchbot
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .const import AIRPURIFIER_BASIC_MODELS, AIRPURIFIER_TABLE_MODELS, DOMAIN
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotSwitchedEntity, exception_handler
@dataclass(frozen=True, kw_only=True)
class SwitchbotSwitchEntityDescription(SwitchEntityDescription):
"""Describes a Switchbot switch entity."""
is_on_fn: Callable[[switchbot.SwitchbotDevice], bool | None]
turn_on_fn: Callable[[switchbot.SwitchbotDevice], Awaitable[Any]]
turn_off_fn: Callable[[switchbot.SwitchbotDevice], Awaitable[Any]]
AIRPURIFIER_BASIC_SWITCHES: tuple[SwitchbotSwitchEntityDescription, ...] = (
SwitchbotSwitchEntityDescription(
key="child_lock",
translation_key="child_lock",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda device: device.is_child_lock_on(),
turn_on_fn=lambda device: device.open_child_lock(),
turn_off_fn=lambda device: device.close_child_lock(),
),
)
AIRPURIFIER_TABLE_SWITCHES: tuple[SwitchbotSwitchEntityDescription, ...] = (
*AIRPURIFIER_BASIC_SWITCHES,
SwitchbotSwitchEntityDescription(
key="wireless_charging",
translation_key="wireless_charging",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda device: device.is_wireless_charging_on(),
turn_on_fn=lambda device: device.open_wireless_charging(),
turn_off_fn=lambda device: device.close_wireless_charging(),
),
)
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@@ -36,10 +75,64 @@ async def async_setup_entry(
for channel in range(1, coordinator.device.channel + 1)
]
async_add_entities(entries)
elif coordinator.model in AIRPURIFIER_BASIC_MODELS:
async_add_entities(
[
SwitchbotGenericSwitch(coordinator, desc)
for desc in AIRPURIFIER_BASIC_SWITCHES
]
)
elif coordinator.model in AIRPURIFIER_TABLE_MODELS:
async_add_entities(
[
SwitchbotGenericSwitch(coordinator, desc)
for desc in AIRPURIFIER_TABLE_SWITCHES
]
)
else:
async_add_entities([SwitchBotSwitch(coordinator)])
class SwitchbotGenericSwitch(SwitchbotSwitchedEntity, SwitchEntity):
"""Representation of a Switchbot switch controlled via entity description."""
entity_description: SwitchbotSwitchEntityDescription
_device: switchbot.SwitchbotDevice
def __init__(
self,
coordinator: SwitchbotDataUpdateCoordinator,
description: SwitchbotSwitchEntityDescription,
) -> None:
"""Initialize the Switchbot generic switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.base_unique_id}-{description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if device is on."""
return self.entity_description.is_on_fn(self._device)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
_LOGGER.debug(
"Turning on %s for %s", self.entity_description.key, self._address
)
await self.entity_description.turn_on_fn(self._device)
self.async_write_ha_state()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
_LOGGER.debug(
"Turning off %s for %s", self.entity_description.key, self._address
)
await self.entity_description.turn_off_fn(self._device)
self.async_write_ha_state()
class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity):
"""Representation of a Switchbot switch."""

View File

@@ -18,6 +18,10 @@ from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from . import (
AIR_PURIFIER_JP_SERVICE_INFO,
AIR_PURIFIER_TABLE_JP_SERVICE_INFO,
AIR_PURIFIER_TABLE_US_SERVICE_INFO,
AIR_PURIFIER_US_SERVICE_INFO,
PLUG_MINI_EU_SERVICE_INFO,
RELAY_SWITCH_1_SERVICE_INFO,
RELAY_SWITCH_2PM_SERVICE_INFO,
@@ -294,3 +298,101 @@ async def test_relay_switch_control_with_exception(
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.parametrize(
(
"service_info",
"sensor_type",
"entity_id",
"turn_on_method",
"turn_off_method",
),
[
(
AIR_PURIFIER_JP_SERVICE_INFO,
"air_purifier_jp",
"switch.test_name_child_lock",
"open_child_lock",
"close_child_lock",
),
(
AIR_PURIFIER_TABLE_JP_SERVICE_INFO,
"air_purifier_table_jp",
"switch.test_name_child_lock",
"open_child_lock",
"close_child_lock",
),
(
AIR_PURIFIER_US_SERVICE_INFO,
"air_purifier_us",
"switch.test_name_child_lock",
"open_child_lock",
"close_child_lock",
),
(
AIR_PURIFIER_TABLE_US_SERVICE_INFO,
"air_purifier_table_us",
"switch.test_name_child_lock",
"open_child_lock",
"close_child_lock",
),
(
AIR_PURIFIER_TABLE_JP_SERVICE_INFO,
"air_purifier_table_jp",
"switch.test_name_wireless_charging",
"open_wireless_charging",
"close_wireless_charging",
),
(
AIR_PURIFIER_TABLE_US_SERVICE_INFO,
"air_purifier_table_us",
"switch.test_name_wireless_charging",
"open_wireless_charging",
"close_wireless_charging",
),
],
)
async def test_air_purifier_switch_control(
hass: HomeAssistant,
mock_entry_encrypted_factory: Callable[[str], MockConfigEntry],
service_info: BluetoothServiceInfoBleak,
sensor_type: str,
entity_id: str,
turn_on_method: str,
turn_off_method: str,
) -> None:
"""Test air purifier switch control."""
inject_bluetooth_service_info(hass, service_info)
entry = mock_entry_encrypted_factory(sensor_type=sensor_type)
entry.add_to_hass(hass)
mocked_turn_on = AsyncMock(return_value=True)
mocked_turn_off = AsyncMock(return_value=True)
with patch.multiple(
"homeassistant.components.switchbot.switch.switchbot.SwitchbotAirPurifier",
update=AsyncMock(return_value=None),
**{turn_on_method: mocked_turn_on, turn_off_method: mocked_turn_off},
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_turn_on.assert_awaited_once()
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_turn_off.assert_awaited_once()