Skip to content

Commit 122108e

Browse files
committed
Add Matter support for White series switches
This is currently untested.
1 parent 698972c commit 122108e

File tree

7 files changed

+7428
-3919
lines changed

7 files changed

+7428
-3919
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
@@ -202,6 +209,11 @@ def __init__(self, hass: HomeAssistant) -> None:
202209
self._handle_zwave_event,
203210
self._filter_zwave_events,
204211
),
212+
hass.bus.async_listen( # matter listener is for any state change event
213+
"state_changed",
214+
self._handle_matter_event,
215+
self._filter_matter_events,
216+
),
205217
]
206218

207219
async def add_coordinator(self, coordinator: LampieUpdateCoordinator) -> None:
@@ -292,6 +304,7 @@ async def _ensure_switch_setup_completed(self, switch_id: SwitchId) -> None:
292304
Integration.ZHA: self._zha_switch_setup,
293305
Integration.Z2M: self._z2m_switch_setup,
294306
Integration.ZWAVE: self._zwave_switch_setup,
307+
Integration.MATTER: self._matter_switch_setup,
295308
}[integration](switch_id, device, entity_entries)
296309

297310
self._switch_ids[_SwitchKey(_SwitchKeyType.DEVICE_ID, device_id)] = switch_id
@@ -414,6 +427,32 @@ async def _zwave_switch_setup( # noqa: PLR6301
414427
) -> ZWaveSwitchInfo:
415428
return ZWaveSwitchInfo()
416429

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

@@ -921,6 +960,7 @@ async def _dispatch_service_command(
921960
Integration.ZHA: self._zha_service_command,
922961
Integration.Z2M: self._z2m_service_command,
923962
Integration.ZWAVE: self._zwave_service_command,
963+
Integration.MATTER: self._matter_service_command,
924964
}[switch_info.integration]
925965

926966
await service_command(
@@ -1075,6 +1115,48 @@ async def _zwave_service_command(
10751115
},
10761116
)
10771117

1118+
async def _matter_service_command(
1119+
self,
1120+
*,
1121+
switch_id: SwitchId,
1122+
device: dr.DeviceEntry,
1123+
led_mode: _LEDMode,
1124+
params: dict[str, Any],
1125+
) -> None:
1126+
if (
1127+
led_mode == _LEDMode.INDIVIDUAL
1128+
and (led_number := params["led_number"]) != 0
1129+
):
1130+
_LOGGER.warning(
1131+
"skipping setting LED_%s (idx %s) on for model %s: "
1132+
"individual LEDs unsupported",
1133+
led_number + 1,
1134+
led_number,
1135+
device.model,
1136+
)
1137+
return
1138+
1139+
switch_info = self.switch_info(switch_id)
1140+
1141+
service_call_action = "turn_on"
1142+
service_call_data = {
1143+
"brightness_pct": params["led_level"],
1144+
"hs_color": [round(((params["led_color"] / 255) * 360), 1), 100],
1145+
}
1146+
1147+
if params["led_effect"] == Effect.CLEAR.value:
1148+
service_call_action = "turn_off"
1149+
service_call_data = {}
1150+
1151+
await self._hass.services.async_call(
1152+
LIGHT_DOMAIN,
1153+
service_call_action,
1154+
{
1155+
"entity_id": switch_info.integration_info.effect_id,
1156+
**service_call_data,
1157+
},
1158+
)
1159+
10781160
def _switch_command_led_params(
10791161
self, led: LEDConfig, switch_id: SwitchId
10801162
) -> dict[str, Any]:
@@ -1118,10 +1200,14 @@ def _z2m() -> bool:
11181200
def _zwave() -> bool:
11191201
return False # unsupported for now
11201202

1203+
def _matter() -> bool:
1204+
return False # unsupported for now
1205+
11211206
return {
11221207
Integration.ZHA: _zha,
11231208
Integration.Z2M: _z2m,
11241209
Integration.ZWAVE: _zwave,
1210+
Integration.MATTER: _matter,
11251211
}[integration]()
11261212

11271213
def _double_tap_clear_notifications_disabled(self, switch_id: SwitchId) -> bool:
@@ -1147,10 +1233,14 @@ def _z2m() -> bool:
11471233
def _zwave() -> bool:
11481234
return False # unsupported for now
11491235

1236+
def _matter() -> bool:
1237+
return False # unsupported for now
1238+
11501239
return {
11511240
Integration.ZHA: _zha,
11521241
Integration.Z2M: _z2m,
11531242
Integration.ZWAVE: _zwave,
1243+
Integration.MATTER: _matter,
11541244
}[integration]()
11551245

11561246
def _any_has_expiration_messaging(self, switches: Sequence[SwitchId]) -> bool:
@@ -1164,6 +1254,7 @@ def _any_has_expiration_messaging(self, switches: Sequence[SwitchId]) -> bool:
11641254
Z2M: Processes these and turns them into
11651255
`{"notificationComplete": "{ALL|LED_1-7}"}` messages.
11661256
ZWAVE: May or may not create these messages.
1257+
MATTER: May or may not create these messages.
11671258
11681259
Returns:
11691260
A boolean indicating if any of the switches are part of such an
@@ -1317,6 +1408,51 @@ async def _handle_zwave_event(self, event: Event[ZWaveEventData]) -> None:
13171408
integration=Integration.ZWAVE,
13181409
)
13191410

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

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)