Files
core/homeassistant/components/matter/switch.py
T
2026-09-05 15:43:16 +02:00

493 lines
17 KiB
Python

"""Matter switches."""
from asyncio import Lock
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, override
from weakref import WeakKeyDictionary
from chip.clusters import Objects as clusters
from chip.clusters.Objects import ClusterCommand, NullValue
from matter_server.client.models import device_types
from matter_server.client.models.node import MatterEndpoint
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import MatterConfigEntry
from .models import MatterDiscoverySchema
EVSE_SUPPLY_STATE_MAP = {
clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False,
clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True,
clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False,
clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False,
}
ALARM_MODE_VISUAL = clusters.BooleanStateConfiguration.Bitmaps.AlarmModeBitmap.kVisual
ALARM_MODE_AUDIBLE = clusters.BooleanStateConfiguration.Bitmaps.AlarmModeBitmap.kAudible
BOOLEAN_STATE_CONFIGURATION_FEATURE_VISUAL = (
clusters.BooleanStateConfiguration.Bitmaps.Feature.kVisual
)
BOOLEAN_STATE_CONFIGURATION_FEATURE_AUDIBLE = (
clusters.BooleanStateConfiguration.Bitmaps.Feature.kAudible
)
@dataclass
class _AlarmEnabledState:
"""Track pending alarm state for an endpoint."""
lock: Lock = field(default_factory=Lock)
pending_alarms_enabled: int | None = None
ALARM_ENABLED_STATES: WeakKeyDictionary[MatterEndpoint, _AlarmEnabledState] = (
WeakKeyDictionary()
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MatterConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter switches from Config Entry."""
matter = config_entry.runtime_data.adapter
matter.register_platform_handler(Platform.SWITCH, async_add_entities)
@dataclass(frozen=True, kw_only=True)
class MatterSwitchEntityDescription(SwitchEntityDescription, MatterEntityDescription):
"""Describe Matter Switch entities."""
inverted: bool = False
class MatterSwitch(MatterEntity, SwitchEntity):
"""Representation of a Matter switch."""
entity_description: MatterSwitchEntityDescription
_platform_translation_key = "switch"
def _get_command_for_value(self, value: bool) -> ClusterCommand:
"""Get the appropriate command for the desired value.
Applies inversion if needed (e.g., for inverted logic like mute).
"""
send_value = not value if self.entity_description.inverted else value
return (
clusters.OnOff.Commands.On()
if send_value
else clusters.OnOff.Commands.Off()
)
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn switch on."""
await self.send_device_command(self._get_command_for_value(True))
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn switch off."""
await self.send_device_command(self._get_command_for_value(False))
@callback
@override
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if self.entity_description.inverted:
value = not value
self._attr_is_on = value
class MatterGenericCommandSwitch(MatterSwitch):
"""Representation of a Matter switch."""
entity_description: MatterGenericCommandSwitchEntityDescription
_platform_translation_key = "switch"
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn switch on."""
if self.entity_description.on_command:
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.on_command(),
self.entity_description.command_timeout,
)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn switch off."""
if self.entity_description.off_command:
await self.send_device_command(
self.entity_description.off_command(),
self.entity_description.command_timeout,
)
@callback
@override
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.device_to_ha:
value = value_convert(value)
self._attr_is_on = value
@override
async def send_device_command(
self,
command: ClusterCommand,
command_timeout: int | None = None,
**kwargs: Any,
) -> None:
"""Send device command with timeout."""
await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=command,
timed_request_timeout_ms=command_timeout,
**kwargs,
)
@dataclass(frozen=True, kw_only=True)
class MatterGenericCommandSwitchEntityDescription(MatterSwitchEntityDescription):
"""Describe Matter Generic command Switch entities."""
# command: a custom callback to create the command to send to the device
on_command: Callable[[], Any] | None = None
off_command: Callable[[], Any] | None = None
command_timeout: int | None = None
@dataclass(frozen=True, kw_only=True)
class MatterNumericSwitchEntityDescription(MatterSwitchEntityDescription):
"""Describe Matter Numeric Switch entities."""
class MatterNumericSwitch(MatterSwitch):
"""Representation of a Matter Enum Attribute as a Switch entity."""
entity_description: MatterNumericSwitchEntityDescription
async def _async_set_native_value(self, value: bool) -> None:
"""Update the current value."""
send_value: Any = value
if value_convert := self.entity_description.ha_to_device:
send_value = value_convert(value)
await self.write_attribute(value=send_value)
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn switch on."""
await self._async_set_native_value(True)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn switch off."""
await self._async_set_native_value(False)
@callback
@override
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.device_to_ha:
value = value_convert(value)
self._attr_is_on = value
@dataclass(frozen=True, kw_only=True)
class MatterAlarmEnabledSwitchEntityDescription(MatterSwitchEntityDescription):
"""Describe Matter alarm enabled Switch entities."""
alarm_mode: int
class MatterAlarmEnabledSwitch(MatterSwitch):
"""Representation of a Matter Boolean State Configuration alarm switch."""
entity_description: MatterAlarmEnabledSwitchEntityDescription
async def _async_set_alarm_enabled(self, value: bool) -> None:
"""Set the enabled state for an alarm mode."""
state = ALARM_ENABLED_STATES.setdefault(self._endpoint, _AlarmEnabledState())
async with state.lock:
alarms_enabled = state.pending_alarms_enabled
if alarms_enabled is None:
alarms_enabled = (
self.get_matter_attribute_value(
clusters.BooleanStateConfiguration.Attributes.AlarmsEnabled
)
or 0
)
if value:
alarms_enabled |= self.entity_description.alarm_mode
else:
alarms_enabled &= ~self.entity_description.alarm_mode
state.pending_alarms_enabled = alarms_enabled
try:
await self.send_device_command(
clusters.BooleanStateConfiguration.Commands.EnableDisableAlarm(
alarmsToEnableDisable=alarms_enabled,
)
)
except BaseException:
if state.pending_alarms_enabled == alarms_enabled:
state.pending_alarms_enabled = None
raise
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn alarm mode on."""
await self._async_set_alarm_enabled(True)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn alarm mode off."""
await self._async_set_alarm_enabled(False)
@callback
@override
def _update_from_device(self) -> None:
"""Update from device."""
alarm_mode = self.entity_description.alarm_mode
alarms_supported = (
self.get_matter_attribute_value(
clusters.BooleanStateConfiguration.Attributes.AlarmsSupported
)
or 0
)
self._attr_available = self._attr_available and bool(
alarms_supported & alarm_mode
)
alarms_enabled = (
self.get_matter_attribute_value(
clusters.BooleanStateConfiguration.Attributes.AlarmsEnabled
)
or 0
)
if state := ALARM_ENABLED_STATES.get(self._endpoint):
state.pending_alarms_enabled = alarms_enabled
self._attr_is_on = bool(alarms_enabled & alarm_mode)
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterSwitchEntityDescription(
key="MatterPlug",
device_class=SwitchDeviceClass.OUTLET,
name=None,
),
entity_class=MatterSwitch,
required_attributes=(clusters.OnOff.Attributes.OnOff,),
device_type=(device_types.OnOffPlugInUnit,),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterSwitchEntityDescription(
key="MatterPowerToggle",
device_class=SwitchDeviceClass.SWITCH,
translation_key="power",
),
entity_class=MatterSwitch,
required_attributes=(clusters.OnOff.Attributes.OnOff,),
device_type=(
device_types.AirPurifier,
device_types.BasicVideoPlayer,
device_types.CastingVideoPlayer,
device_types.CookSurface,
device_types.Cooktop,
device_types.Dishwasher,
device_types.ExtractorHood,
device_types.LaundryDryer,
device_types.LaundryWasher,
device_types.Oven,
device_types.Pump,
device_types.PumpController,
device_types.Refrigerator,
device_types.RoboticVacuumCleaner,
device_types.RoomAirConditioner,
),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterSwitchEntityDescription(
key="MatterSwitch",
entity_category=EntityCategory.CONFIG,
translation_key="display",
),
entity_class=MatterSwitch,
required_attributes=(clusters.OnOff.Attributes.OnOff,),
vendor_id=(4476,), # IKEA of Sweden
product_id=(12289,), # ALPSTUGA air quality monitor
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterSwitchEntityDescription(
key="MatterSwitch",
device_class=SwitchDeviceClass.OUTLET,
name=None,
),
entity_class=MatterSwitch,
required_attributes=(clusters.OnOff.Attributes.OnOff,),
not_device_type=(
device_types.ColorTemperatureLight,
device_types.DimmableLight,
device_types.ExtendedColorLight,
device_types.DimmerSwitch,
device_types.ColorDimmerSwitch,
device_types.OnOffLight,
device_types.AirPurifier,
device_types.BasicVideoPlayer,
device_types.CastingVideoPlayer,
device_types.CookSurface,
device_types.Cooktop,
device_types.Dishwasher,
device_types.ExtractorHood,
device_types.Fan,
device_types.LaundryDryer,
device_types.LaundryWasher,
device_types.Oven,
device_types.Pump,
device_types.PumpController,
device_types.Refrigerator,
device_types.RoboticVacuumCleaner,
device_types.RoomAirConditioner,
device_types.Speaker,
),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterSwitchEntityDescription(
key="MatterMuteToggle",
translation_key="speaker_mute",
inverted=True,
),
entity_class=MatterSwitch,
required_attributes=(clusters.OnOff.Attributes.OnOff,),
device_type=(device_types.Speaker,),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterNumericSwitchEntityDescription(
key="EveTrvChildLock",
entity_category=EntityCategory.CONFIG,
translation_key="child_lock",
device_to_ha={
0: False,
1: True,
}.get,
ha_to_device={
False: 0,
True: 1,
}.get,
),
entity_class=MatterNumericSwitch,
required_attributes=(
clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout,
),
vendor_id=(4874,),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterNumericSwitchEntityDescription(
key="DoorLockEnablePrivacyModeButton",
entity_category=EntityCategory.CONFIG,
translation_key="privacy_mode_button",
device_to_ha=bool,
ha_to_device=int,
),
entity_class=MatterNumericSwitch,
required_attributes=(clusters.DoorLock.Attributes.EnablePrivacyModeButton,),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterGenericCommandSwitchEntityDescription(
key="EnergyEvseChargingSwitch",
translation_key="evse_charging_switch",
on_command=lambda: clusters.EnergyEvse.Commands.EnableCharging(
chargingEnabledUntil=NullValue,
minimumChargeCurrent=0,
maximumChargeCurrent=0,
),
off_command=clusters.EnergyEvse.Commands.Disable,
command_timeout=3000,
device_to_ha=EVSE_SUPPLY_STATE_MAP.get,
),
entity_class=MatterGenericCommandSwitch,
required_attributes=(
clusters.EnergyEvse.Attributes.SupplyState,
clusters.EnergyEvse.Attributes.AcceptedCommandList,
),
value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id,
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterNumericSwitchEntityDescription(
key="EveChildLock",
entity_category=EntityCategory.CONFIG,
translation_key="child_lock",
),
entity_class=MatterNumericSwitch,
required_attributes=(clusters.EveCluster.Attributes.ChildLock,),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterAlarmEnabledSwitchEntityDescription(
key="BooleanStateConfigurationVisualAlarmEnabled",
entity_category=EntityCategory.CONFIG,
translation_key="visual_alarm_enabled",
alarm_mode=ALARM_MODE_VISUAL,
),
entity_class=MatterAlarmEnabledSwitch,
required_attributes=(
clusters.BooleanStateConfiguration.Attributes.AlarmsEnabled,
clusters.BooleanStateConfiguration.Attributes.AcceptedCommandList,
clusters.BooleanStateConfiguration.Attributes.AlarmsSupported,
),
secondary_value_contains=(
clusters.BooleanStateConfiguration.Commands.EnableDisableAlarm.command_id
),
featuremap_contains=BOOLEAN_STATE_CONFIGURATION_FEATURE_VISUAL,
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterAlarmEnabledSwitchEntityDescription(
key="BooleanStateConfigurationAudibleAlarmEnabled",
entity_category=EntityCategory.CONFIG,
translation_key="audible_alarm_enabled",
alarm_mode=ALARM_MODE_AUDIBLE,
),
entity_class=MatterAlarmEnabledSwitch,
required_attributes=(
clusters.BooleanStateConfiguration.Attributes.AlarmsEnabled,
clusters.BooleanStateConfiguration.Attributes.AcceptedCommandList,
clusters.BooleanStateConfiguration.Attributes.AlarmsSupported,
),
secondary_value_contains=(
clusters.BooleanStateConfiguration.Commands.EnableDisableAlarm.command_id
),
featuremap_contains=BOOLEAN_STATE_CONFIGURATION_FEATURE_AUDIBLE,
allow_multi=True,
),
]