Skip to content

Commit 3340d6a

Browse files
committed
Add Matter support for White series switches
This is currently untested.
1 parent e5ece5f commit 3340d6a

File tree

7 files changed

+7877
-4383
lines changed

7 files changed

+7877
-4383
lines changed

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ What it can do:
2626
- **Even more**
2727
There are some more goodies in to docs below. Enjoy!
2828

29-
_Note: currently this is limited to Blue switches using ZHA or Zigbee2MQTT and Red switches using Z-Wave JS, but other Inovelli switches will be added in the future._
30-
3129
**Configure notifications** for multiple switches easily:
3230

3331
<img width="300" alt="Image" src="https://github.com/user-attachments/assets/02f4888b-836c-4114-8a1d-bff66738087e" />
@@ -286,6 +284,17 @@ Unlike the Blue series switches under ZHA, there is no way to receive events for
286284

287285
This integration therefore handles notification expiration itself for switches configured with Z-Wave. This may change unexpectedly in the future—if and when it is possible, Lampie will change to sending durations to the firmware.
288286

287+
##### Matter
288+
289+
White series switches only have a single LED and do not support effects. Lampie will do the following for these switches:
290+
291+
- All will simply indicate to enable the notification (besides `CLEAR`)
292+
- If [individual LEDs](#full-led-configuration) are configured, the first LED settings will be used
293+
294+
Unlike the Blue series switches under ZHA, there is no way to receive events for when a notification expires (it only supports, for instance, when the config button is dobule pressed via a state change on `event.<switch_id>_config` with `event_type="multi_press_2"`). This may be supported in the firmware and not yet available for end user consumption.
295+
296+
This integration therefore handles notification expiration itself for switches configured with Matter. This may change unexpectedly in the future—if and when it is possible, Lampie will change to sending durations to the firmware.
297+
289298
## More Screenshots
290299

291300
Once configured, the integration links the various entities to logical devices:

custom_components/lampie/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"LZW30-SN", # red on/off switch
3636
"LZW31-SN", # red dimmer
3737
"LZW36", # red fan/light combo
38+
"VTM31-SN", # white dimmer (2-in-1 switch/dimmer)
39+
"VTM35-SN", # white fan
3840
"VZM30-SN", # blue switch
3941
"VZM31-SN", # blue 2-in-1 switch/dimmer
4042
"VZM35-SN", # blue fan switch

custom_components/lampie/orchestrator.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, Unpack
1414

1515
from homeassistant.components import mqtt
16+
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
1617
from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN
1718
from homeassistant.components.mqtt.models import MqttData
1819
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
@@ -52,6 +53,7 @@
5253
LEDConfig,
5354
LEDConfigSource,
5455
LEDConfigSourceType,
56+
MatterSwitchInfo,
5557
Slug,
5658
SwitchId,
5759
Z2MSwitchInfo,
@@ -64,6 +66,7 @@
6466

6567
type ZHAEventData = dict[str, Any]
6668
type ZWaveEventData = dict[str, Any]
69+
type MatterEventData = dict[str, Any]
6770
type MQTTDeviceName = str
6871

6972
_LOGGER = logging.getLogger(__name__)
@@ -75,13 +78,15 @@
7578
model: {Effect[effect_key].value: value for effect_key, value in mapping.items()}
7679
for model, mapping in _ZWAVE_EFFECT_MAPPING.items()
7780
}
81+
MATTER_DOMAIN: Final = "matter"
7882

7983
ALREADY_EXPIRED: Final = 0
8084

8185
SWITCH_INTEGRATIONS = {
8286
ZHA_DOMAIN: Integration.ZHA,
8387
MQTT_DOMAIN: Integration.Z2M,
8488
ZWAVE_DOMAIN: Integration.ZWAVE,
89+
MATTER_DOMAIN: Integration.MATTER,
8590
}
8691

8792
FIRMWARE_SECONDS_MAX = dt.timedelta(seconds=60).total_seconds()
@@ -127,6 +132,8 @@
127132
("003", "KeyPressed2x"): "button_3_double",
128133
}
129134

135+
MATTER_COMMAND_MAP = {("config", "multi_press_2"): "button_3_double"}
136+
130137

131138
class _LEDMode(IntEnum):
132139
ALL = 1
@@ -198,6 +205,11 @@ def __init__(self, hass: HomeAssistant) -> None:
198205
self._handle_zwave_event,
199206
self._filter_zwave_events,
200207
),
208+
hass.bus.async_listen( # matter listener is for any state change event
209+
"state_changed",
210+
self._handle_matter_event,
211+
self._filter_matter_events,
212+
),
201213
]
202214

203215
async def add_coordinator(self, coordinator: LampieUpdateCoordinator) -> None:
@@ -288,6 +300,7 @@ async def _ensure_switch_setup_completed(self, switch_id: SwitchId) -> None:
288300
Integration.ZHA: self._zha_switch_setup,
289301
Integration.Z2M: self._z2m_switch_setup,
290302
Integration.ZWAVE: self._zwave_switch_setup,
303+
Integration.MATTER: self._matter_switch_setup,
291304
}[integration](switch_id, device, entity_entries)
292305

293306
self._switch_ids[_SwitchKey(_SwitchKeyType.DEVICE_ID, device_id)] = switch_id
@@ -410,6 +423,32 @@ async def _zwave_switch_setup( # noqa: PLR6301
410423
) -> ZWaveSwitchInfo:
411424
return ZWaveSwitchInfo()
412425

426+
async def _matter_switch_setup( # noqa: PLR6301
427+
self,
428+
switch_id: SwitchId, # noqa: ARG002
429+
device: dr.DeviceEntry, # noqa: ARG002
430+
entity_entries: list[er.RegistryEntry],
431+
) -> MatterSwitchInfo:
432+
effect_id = None
433+
434+
def is_matter_platform(entry: er.RegistryEntry) -> bool:
435+
return bool(entry.platform == MATTER_DOMAIN)
436+
437+
for entity_entry in filter(is_matter_platform, entity_entries):
438+
if (
439+
entity_entry.domain == LIGHT_DOMAIN
440+
and entity_entry.translation_key == "effect"
441+
):
442+
effect_id = entity_entry.entity_id
443+
_LOGGER.debug(
444+
"found Matter effect entity: %s",
445+
effect_id,
446+
)
447+
448+
return MatterSwitchInfo(
449+
effect_id=effect_id,
450+
)
451+
413452
def has_notification(self, slug: Slug) -> bool:
414453
return slug in self._coordinators
415454

@@ -940,6 +979,7 @@ async def _dispatch_service_command(
940979
Integration.ZHA: self._zha_service_command,
941980
Integration.Z2M: self._z2m_service_command,
942981
Integration.ZWAVE: self._zwave_service_command,
982+
Integration.MATTER: self._matter_service_command,
943983
}[switch_info.integration]
944984

945985
await service_command(
@@ -1094,6 +1134,48 @@ async def _zwave_service_command(
10941134
},
10951135
)
10961136

1137+
async def _matter_service_command(
1138+
self,
1139+
*,
1140+
switch_id: SwitchId,
1141+
device: dr.DeviceEntry,
1142+
led_mode: _LEDMode,
1143+
params: dict[str, Any],
1144+
) -> None:
1145+
if (
1146+
led_mode == _LEDMode.INDIVIDUAL
1147+
and (led_number := params["led_number"]) != 0
1148+
):
1149+
_LOGGER.warning(
1150+
"skipping setting LED_%s (idx %s) on for model %s: "
1151+
"individual LEDs unsupported",
1152+
led_number + 1,
1153+
led_number,
1154+
device.model,
1155+
)
1156+
return
1157+
1158+
switch_info = self.switch_info(switch_id)
1159+
1160+
service_call_action = "turn_on"
1161+
service_call_data = {
1162+
"brightness_pct": params["led_level"],
1163+
"hs_color": [round(((params["led_color"] / 255) * 360), 1), 100],
1164+
}
1165+
1166+
if params["led_effect"] == Effect.CLEAR.value:
1167+
service_call_action = "turn_off"
1168+
service_call_data = {}
1169+
1170+
await self._hass.services.async_call(
1171+
LIGHT_DOMAIN,
1172+
service_call_action,
1173+
{
1174+
"entity_id": switch_info.integration_info.effect_id,
1175+
**service_call_data,
1176+
},
1177+
)
1178+
10971179
def _switch_command_led_params(
10981180
self, led: LEDConfig, switch_id: SwitchId
10991181
) -> dict[str, Any]:
@@ -1137,10 +1219,14 @@ def _z2m() -> bool:
11371219
def _zwave() -> bool:
11381220
return False # unsupported for now
11391221

1222+
def _matter() -> bool:
1223+
return False # unsupported for now
1224+
11401225
return {
11411226
Integration.ZHA: _zha,
11421227
Integration.Z2M: _z2m,
11431228
Integration.ZWAVE: _zwave,
1229+
Integration.MATTER: _matter,
11441230
}[integration]()
11451231

11461232
def _double_tap_clear_notifications_disabled(self, switch_id: SwitchId) -> bool:
@@ -1166,10 +1252,14 @@ def _z2m() -> bool:
11661252
def _zwave() -> bool:
11671253
return False # unsupported for now
11681254

1255+
def _matter() -> bool:
1256+
return False # unsupported for now
1257+
11691258
return {
11701259
Integration.ZHA: _zha,
11711260
Integration.Z2M: _z2m,
11721261
Integration.ZWAVE: _zwave,
1262+
Integration.MATTER: _matter,
11731263
}[integration]()
11741264

11751265
def _any_has_expiration_messaging(self, switches: Sequence[SwitchId]) -> bool:
@@ -1183,6 +1273,7 @@ def _any_has_expiration_messaging(self, switches: Sequence[SwitchId]) -> bool:
11831273
Z2M: Processes these and turns them into
11841274
`{"notificationComplete": "{ALL|LED_1-7}"}` messages.
11851275
ZWAVE: May or may not create these messages.
1276+
MATTER: May or may not create these messages.
11861277
11871278
Returns:
11881279
A boolean indicating if any of the switches are part of such an
@@ -1336,6 +1427,51 @@ async def _handle_zwave_event(self, event: Event[ZWaveEventData]) -> None:
13361427
integration=Integration.ZWAVE,
13371428
)
13381429

1430+
@callback
1431+
def _filter_matter_events(
1432+
self,
1433+
event_data: MatterEventData,
1434+
) -> bool:
1435+
entity_id = event_data["entity_id"]
1436+
1437+
if (
1438+
not entity_id.startswith("event.")
1439+
or not (entity_registry := er.async_get(self._hass))
1440+
or not (event_entity := entity_registry.async_get(entity_id))
1441+
or not (
1442+
switch_key := _SwitchKey(
1443+
_SwitchKeyType.DEVICE_ID, event_entity.device_id
1444+
)
1445+
)
1446+
):
1447+
return False
1448+
1449+
state = event_data["new_state"]
1450+
attributes = state["attributes"]
1451+
command = MATTER_COMMAND_MAP.get(
1452+
(event_entity.translation_key, attributes.get("event_type"))
1453+
)
1454+
return switch_key in self._switch_ids and command in DISMISSAL_COMMANDS
1455+
1456+
@callback
1457+
async def _handle_matter_event(self, event: Event[MatterEventData]) -> None:
1458+
entity_id = event.data["entity_id"]
1459+
entity_registry = er.async_get(self._hass)
1460+
event_entity = entity_registry.async_get(entity_id)
1461+
device_id = event_entity.device_id
1462+
switch_key = _SwitchKey(_SwitchKeyType.DEVICE_ID, device_id)
1463+
switch_id = self._switch_ids[switch_key]
1464+
state = event.data["new_state"]
1465+
attributes = state["attributes"]
1466+
command = MATTER_COMMAND_MAP[
1467+
event_entity.translation_key, attributes["event_type"]
1468+
]
1469+
await self._handle_generic_event(
1470+
command=command,
1471+
switch_id=switch_id,
1472+
integration=Integration.MATTER,
1473+
)
1474+
13391475
async def _handle_generic_event(
13401476
self,
13411477
*,

custom_components/lampie/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ class Integration(StrEnum):
251251
ZHA = auto()
252252
Z2M = auto()
253253
ZWAVE = auto()
254+
MATTER = auto()
254255

255256

256257
@dataclass(frozen=True)
@@ -301,6 +302,13 @@ class ZWaveSwitchInfo:
301302
"""ZWave switch info data class."""
302303

303304

305+
@dataclass(frozen=True)
306+
class MatterSwitchInfo:
307+
"""Matter switch info data class."""
308+
309+
effect_id: EntityId | None = None
310+
311+
304312
@dataclass(frozen=True)
305313
class LampieSwitchInfo:
306314
"""Lampie switch data class."""

tests/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def add_mock_switch(
8686
Integration.ZHA: "zha",
8787
Integration.Z2M: "mqtt",
8888
Integration.ZWAVE: "zwave_js",
89+
Integration.MATTER: "matter",
8990
}[integration]
9091

9192
identifiers = {
@@ -95,13 +96,15 @@ def add_mock_switch(
9596
"zwave_js",
9697
f"mock-zwave-driver-controller-id_mock-node-{object_id}",
9798
),
99+
Integration.MATTER: ("matter", f"mock:deviceid_{object_id}-nodeid"),
98100
}[integration]
99101

100102
model_key = "model_id" if integration == Integration.Z2M else "model"
101103
model = {
102104
Integration.ZHA: "VZM31-SN",
103105
Integration.Z2M: "VZM31-SN",
104106
Integration.ZWAVE: "VZW31-SN",
107+
Integration.MATTER: "VTM31-SN",
105108
}[integration]
106109

107110
device_registry = dr.async_get(hass)
@@ -177,6 +180,24 @@ def add_mock_switch(
177180
"discovery_payload"
178181
]["state_topic"] = f"home/z2m/{mock_config_entry.title}"
179182

183+
if integration == Integration.MATTER:
184+
entity_registry.async_get_or_create(
185+
"event",
186+
integration_domain,
187+
f"{object_id}-config",
188+
suggested_object_id=f"{object_id}_config",
189+
translation_key="config",
190+
device_id=device_entry.id,
191+
)
192+
entity_registry.async_get_or_create(
193+
"light",
194+
integration_domain,
195+
f"{object_id}-effect",
196+
suggested_object_id=f"{object_id}_effect",
197+
translation_key="effect",
198+
device_id=device_entry.id,
199+
)
200+
180201
return switch
181202

182203

0 commit comments

Comments
 (0)