Skip to content

Commit 79c957a

Browse files
committed
Add Matter support for White series switches
This is currently untested.
1 parent f7b5dcc commit 79c957a

File tree

7 files changed

+7427
-3933
lines changed

7 files changed

+7427
-3933
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" />
@@ -285,6 +283,17 @@ Unlike the Blue series switches under ZHA, there is no way to receive events for
285283

286284
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.
287285

286+
##### Matter
287+
288+
White series switches only have a single LED and do not support effects. Lampie will do the following for these switches:
289+
290+
- All will simply indicate to enable the notification (besides `CLEAR`)
291+
- If [individual LEDs](#full-led-configuration) are configured, the first LED settings will be used
292+
293+
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.
294+
295+
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.
296+
288297
## More Screenshots
289298

290299
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

@@ -930,6 +969,7 @@ async def _dispatch_service_command(
930969
Integration.ZHA: self._zha_service_command,
931970
Integration.Z2M: self._z2m_service_command,
932971
Integration.ZWAVE: self._zwave_service_command,
972+
Integration.MATTER: self._matter_service_command,
933973
}[switch_info.integration]
934974

935975
await service_command(
@@ -1084,6 +1124,48 @@ async def _zwave_service_command(
10841124
},
10851125
)
10861126

1127+
async def _matter_service_command(
1128+
self,
1129+
*,
1130+
switch_id: SwitchId,
1131+
device: dr.DeviceEntry,
1132+
led_mode: _LEDMode,
1133+
params: dict[str, Any],
1134+
) -> None:
1135+
if (
1136+
led_mode == _LEDMode.INDIVIDUAL
1137+
and (led_number := params["led_number"]) != 0
1138+
):
1139+
_LOGGER.warning(
1140+
"skipping setting LED_%s (idx %s) on for model %s: "
1141+
"individual LEDs unsupported",
1142+
led_number + 1,
1143+
led_number,
1144+
device.model,
1145+
)
1146+
return
1147+
1148+
switch_info = self.switch_info(switch_id)
1149+
1150+
service_call_action = "turn_on"
1151+
service_call_data = {
1152+
"brightness_pct": params["led_level"],
1153+
"hs_color": [round(((params["led_color"] / 255) * 360), 1), 100],
1154+
}
1155+
1156+
if params["led_effect"] == Effect.CLEAR.value:
1157+
service_call_action = "turn_off"
1158+
service_call_data = {}
1159+
1160+
await self._hass.services.async_call(
1161+
LIGHT_DOMAIN,
1162+
service_call_action,
1163+
{
1164+
"entity_id": switch_info.integration_info.effect_id,
1165+
**service_call_data,
1166+
},
1167+
)
1168+
10871169
def _switch_command_led_params(
10881170
self, led: LEDConfig, switch_id: SwitchId
10891171
) -> dict[str, Any]:
@@ -1127,10 +1209,14 @@ def _z2m() -> bool:
11271209
def _zwave() -> bool:
11281210
return False # unsupported for now
11291211

1212+
def _matter() -> bool:
1213+
return False # unsupported for now
1214+
11301215
return {
11311216
Integration.ZHA: _zha,
11321217
Integration.Z2M: _z2m,
11331218
Integration.ZWAVE: _zwave,
1219+
Integration.MATTER: _matter,
11341220
}[integration]()
11351221

11361222
def _double_tap_clear_notifications_disabled(self, switch_id: SwitchId) -> bool:
@@ -1156,10 +1242,14 @@ def _z2m() -> bool:
11561242
def _zwave() -> bool:
11571243
return False # unsupported for now
11581244

1245+
def _matter() -> bool:
1246+
return False # unsupported for now
1247+
11591248
return {
11601249
Integration.ZHA: _zha,
11611250
Integration.Z2M: _z2m,
11621251
Integration.ZWAVE: _zwave,
1252+
Integration.MATTER: _matter,
11631253
}[integration]()
11641254

11651255
def _any_has_expiration_messaging(self, switches: Sequence[SwitchId]) -> bool:
@@ -1173,6 +1263,7 @@ def _any_has_expiration_messaging(self, switches: Sequence[SwitchId]) -> bool:
11731263
Z2M: Processes these and turns them into
11741264
`{"notificationComplete": "{ALL|LED_1-7}"}` messages.
11751265
ZWAVE: May or may not create these messages.
1266+
MATTER: May or may not create these messages.
11761267
11771268
Returns:
11781269
A boolean indicating if any of the switches are part of such an
@@ -1326,6 +1417,51 @@ async def _handle_zwave_event(self, event: Event[ZWaveEventData]) -> None:
13261417
integration=Integration.ZWAVE,
13271418
)
13281419

1420+
@callback
1421+
def _filter_matter_events(
1422+
self,
1423+
event_data: MatterEventData,
1424+
) -> bool:
1425+
entity_id = event_data["entity_id"]
1426+
1427+
if (
1428+
not entity_id.startswith("event.")
1429+
or not (entity_registry := er.async_get(self._hass))
1430+
or not (event_entity := entity_registry.async_get(entity_id))
1431+
or not (
1432+
switch_key := _SwitchKey(
1433+
_SwitchKeyType.DEVICE_ID, event_entity.device_id
1434+
)
1435+
)
1436+
):
1437+
return False
1438+
1439+
state = event_data["new_state"]
1440+
attributes = state["attributes"]
1441+
command = MATTER_COMMAND_MAP.get(
1442+
(event_entity.translation_key, attributes.get("event_type"))
1443+
)
1444+
return switch_key in self._switch_ids and command in DISMISSAL_COMMANDS
1445+
1446+
@callback
1447+
async def _handle_matter_event(self, event: Event[MatterEventData]) -> None:
1448+
entity_id = event.data["entity_id"]
1449+
entity_registry = er.async_get(self._hass)
1450+
event_entity = entity_registry.async_get(entity_id)
1451+
device_id = event_entity.device_id
1452+
switch_key = _SwitchKey(_SwitchKeyType.DEVICE_ID, device_id)
1453+
switch_id = self._switch_ids[switch_key]
1454+
state = event.data["new_state"]
1455+
attributes = state["attributes"]
1456+
command = MATTER_COMMAND_MAP[
1457+
event_entity.translation_key, attributes["event_type"]
1458+
]
1459+
await self._handle_generic_event(
1460+
command=command,
1461+
switch_id=switch_id,
1462+
integration=Integration.MATTER,
1463+
)
1464+
13291465
async def _handle_generic_event(
13301466
self,
13311467
*,

custom_components/lampie/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ class Integration(StrEnum):
227227
ZHA = auto()
228228
Z2M = auto()
229229
ZWAVE = auto()
230+
MATTER = auto()
230231

231232

232233
@dataclass(frozen=True)
@@ -277,6 +278,13 @@ class ZWaveSwitchInfo:
277278
"""ZWave switch info data class."""
278279

279280

281+
@dataclass(frozen=True)
282+
class MatterSwitchInfo:
283+
"""Matter switch info data class."""
284+
285+
effect_id: EntityId | None = None
286+
287+
280288
@dataclass(frozen=True)
281289
class LampieSwitchInfo:
282290
"""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)