Skip to content

Commit 410c3df

Browse files
lboueNoRi2909
andauthored
Add Matter service actions for vacuum area (home-assistant#151467)
Co-authored-by: Norbert Rittel <[email protected]>
1 parent f1bf28d commit 410c3df

File tree

11 files changed

+1070
-2
lines changed

11 files changed

+1070
-2
lines changed

homeassistant/components/matter/const.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@
1515
ID_TYPE_SERIAL = "serial"
1616

1717
FEATUREMAP_ATTRIBUTE_ID = 65532
18+
19+
# vacuum entity service actions
20+
SERVICE_GET_AREAS = "get_areas" # get SupportedAreas and SupportedMaps
21+
SERVICE_SELECT_AREAS = "select_areas" # call SelectAreas Matter command
22+
SERVICE_CLEAN_AREAS = "clean_areas" # call SelectAreas Matter command and start RVC

homeassistant/components/matter/icons.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,5 +150,16 @@
150150
"default": "mdi:ev-station"
151151
}
152152
}
153+
},
154+
"services": {
155+
"clean_areas": {
156+
"service": "mdi:robot-vacuum"
157+
},
158+
"get_areas": {
159+
"service": "mdi:map"
160+
},
161+
"select_areas": {
162+
"service": "mdi:map"
163+
}
153164
}
154165
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Service descriptions for Matter integration
2+
3+
get_areas:
4+
target:
5+
entity:
6+
domain: vacuum
7+
8+
select_areas:
9+
target:
10+
entity:
11+
domain: vacuum
12+
fields:
13+
areas:
14+
required: true
15+
example: [1, 3]
16+
17+
clean_areas:
18+
target:
19+
entity:
20+
domain: vacuum
21+
fields:
22+
areas:
23+
required: true
24+
example: [1, 3]

homeassistant/components/matter/strings.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,30 @@
548548
"description": "The Matter device to add to the other Matter network."
549549
}
550550
}
551+
},
552+
"get_areas": {
553+
"name": "Get areas",
554+
"description": "Returns a list of available areas and maps for robot vacuum cleaners."
555+
},
556+
"select_areas": {
557+
"name": "Select areas",
558+
"description": "Selects the specified areas for cleaning. The areas must be specified as a list of area IDs.",
559+
"fields": {
560+
"areas": {
561+
"name": "Areas",
562+
"description": "A list of area IDs to select."
563+
}
564+
}
565+
},
566+
"clean_areas": {
567+
"name": "Clean areas",
568+
"description": "Instructs the Matter vacuum cleaner to clean the specified areas.",
569+
"fields": {
570+
"areas": {
571+
"name": "Areas",
572+
"description": "A list of area IDs to clean."
573+
}
574+
}
551575
}
552576
}
553577
}

homeassistant/components/matter/vacuum.py

Lines changed: 210 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from __future__ import annotations
44

55
from enum import IntEnum
6-
from typing import TYPE_CHECKING, Any
6+
from typing import TYPE_CHECKING, Any, cast
77

88
from chip.clusters import Objects as clusters
9+
from chip.clusters.Objects import NullValue
910
from matter_server.client.models import device_types
11+
import voluptuous as vol
1012

1113
from homeassistant.components.vacuum import (
1214
StateVacuumEntity,
@@ -16,14 +18,25 @@
1618
)
1719
from homeassistant.config_entries import ConfigEntry
1820
from homeassistant.const import Platform
19-
from homeassistant.core import HomeAssistant, callback
21+
from homeassistant.core import (
22+
HomeAssistant,
23+
ServiceResponse,
24+
SupportsResponse,
25+
callback,
26+
)
2027
from homeassistant.exceptions import HomeAssistantError
28+
from homeassistant.helpers import config_validation as cv, entity_platform
2129
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
2230

31+
from .const import SERVICE_CLEAN_AREAS, SERVICE_GET_AREAS, SERVICE_SELECT_AREAS
2332
from .entity import MatterEntity
2433
from .helpers import get_matter
2534
from .models import MatterDiscoverySchema
2635

36+
ATTR_CURRENT_AREA = "current_area"
37+
ATTR_CURRENT_AREA_NAME = "current_area_name"
38+
ATTR_SELECTED_AREAS = "selected_areas"
39+
2740

2841
class OperationalState(IntEnum):
2942
"""Operational State of the vacuum cleaner.
@@ -56,6 +69,33 @@ async def async_setup_entry(
5669
"""Set up Matter vacuum platform from Config Entry."""
5770
matter = get_matter(hass)
5871
matter.register_platform_handler(Platform.VACUUM, async_add_entities)
72+
platform = entity_platform.async_get_current_platform()
73+
74+
# This will call Entity.async_handle_get_areas
75+
platform.async_register_entity_service(
76+
SERVICE_GET_AREAS,
77+
schema=None,
78+
func="async_handle_get_areas",
79+
supports_response=SupportsResponse.ONLY,
80+
)
81+
# This will call Entity.async_handle_clean_areas
82+
platform.async_register_entity_service(
83+
SERVICE_CLEAN_AREAS,
84+
schema={
85+
vol.Required("areas"): vol.All(cv.ensure_list, [cv.positive_int]),
86+
},
87+
func="async_handle_clean_areas",
88+
supports_response=SupportsResponse.ONLY,
89+
)
90+
# This will call Entity.async_handle_select_areas
91+
platform.async_register_entity_service(
92+
SERVICE_SELECT_AREAS,
93+
schema={
94+
vol.Required("areas"): vol.All(cv.ensure_list, [cv.positive_int]),
95+
},
96+
func="async_handle_select_areas",
97+
supports_response=SupportsResponse.ONLY,
98+
)
5999

60100

61101
class MatterVacuum(MatterEntity, StateVacuumEntity):
@@ -65,9 +105,23 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
65105
_supported_run_modes: (
66106
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
67107
) = None
108+
_attr_matter_areas: dict[str, Any] | None = None
109+
_attr_current_area: int | None = None
110+
_attr_current_area_name: str | None = None
111+
_attr_selected_areas: list[int] | None = None
112+
_attr_supported_maps: list[dict[str, Any]] | None = None
68113
entity_description: StateVacuumEntityDescription
69114
_platform_translation_key = "vacuum"
70115

116+
@property
117+
def extra_state_attributes(self) -> dict[str, Any] | None:
118+
"""Return the state attributes of the entity."""
119+
return {
120+
ATTR_CURRENT_AREA: self._attr_current_area,
121+
ATTR_CURRENT_AREA_NAME: self._attr_current_area_name,
122+
ATTR_SELECTED_AREAS: self._attr_selected_areas,
123+
}
124+
71125
def _get_run_mode_by_tag(
72126
self, tag: ModeTag
73127
) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None:
@@ -136,10 +190,160 @@ async def async_pause(self) -> None:
136190
"""Pause the cleaning task."""
137191
await self.send_device_command(clusters.RvcOperationalState.Commands.Pause())
138192

193+
def async_get_areas(self, **kwargs: Any) -> dict[str, Any]:
194+
"""Get available area and map IDs from vacuum appliance."""
195+
196+
supported_areas = self.get_matter_attribute_value(
197+
clusters.ServiceArea.Attributes.SupportedAreas
198+
)
199+
if not supported_areas:
200+
raise HomeAssistantError("Can't get areas from the device.")
201+
202+
# Group by area_id: {area_id: {"map_id": ..., "name": ...}}
203+
areas = {}
204+
for area in supported_areas:
205+
area_id = getattr(area, "areaID", None)
206+
map_id = getattr(area, "mapID", None)
207+
location_name = None
208+
area_info = getattr(area, "areaInfo", None)
209+
if area_info is not None:
210+
location_info = getattr(area_info, "locationInfo", None)
211+
if location_info is not None:
212+
location_name = getattr(location_info, "locationName", None)
213+
if area_id is not None:
214+
areas[area_id] = {"map_id": map_id, "name": location_name}
215+
216+
# Optionally, also extract supported maps if available
217+
supported_maps = self.get_matter_attribute_value(
218+
clusters.ServiceArea.Attributes.SupportedMaps
219+
)
220+
maps = []
221+
if supported_maps:
222+
maps = [
223+
{
224+
"map_id": getattr(m, "mapID", None),
225+
"name": getattr(m, "name", None),
226+
}
227+
for m in supported_maps
228+
]
229+
230+
return {
231+
"areas": areas,
232+
"maps": maps,
233+
}
234+
235+
async def async_handle_get_areas(self, **kwargs: Any) -> ServiceResponse:
236+
"""Get available area and map IDs from vacuum appliance."""
237+
# Group by area_id: {area_id: {"map_id": ..., "name": ...}}
238+
areas = {}
239+
if self._attr_matter_areas is not None:
240+
for area in self._attr_matter_areas:
241+
area_id = getattr(area, "areaID", None)
242+
map_id = getattr(area, "mapID", None)
243+
location_name = None
244+
area_info = getattr(area, "areaInfo", None)
245+
if area_info is not None:
246+
location_info = getattr(area_info, "locationInfo", None)
247+
if location_info is not None:
248+
location_name = getattr(location_info, "locationName", None)
249+
if area_id is not None:
250+
if map_id is NullValue:
251+
areas[area_id] = {"name": location_name}
252+
else:
253+
areas[area_id] = {"map_id": map_id, "name": location_name}
254+
255+
# Optionally, also extract supported maps if available
256+
supported_maps = self.get_matter_attribute_value(
257+
clusters.ServiceArea.Attributes.SupportedMaps
258+
)
259+
maps = []
260+
if supported_maps != NullValue: # chip.clusters.Types.Nullable
261+
maps = [
262+
{
263+
"map_id": getattr(m, "mapID", None)
264+
if getattr(m, "mapID", None) != NullValue
265+
else None,
266+
"name": getattr(m, "name", None),
267+
}
268+
for m in supported_maps
269+
]
270+
271+
return cast(
272+
ServiceResponse,
273+
{
274+
"areas": areas,
275+
"maps": maps,
276+
},
277+
)
278+
return None
279+
280+
async def async_handle_select_areas(
281+
self, areas: list[int], **kwargs: Any
282+
) -> ServiceResponse:
283+
"""Select areas to clean."""
284+
selected_areas = areas
285+
# Matter command to the vacuum cleaner to select the areas.
286+
await self.send_device_command(
287+
clusters.ServiceArea.Commands.SelectAreas(newAreas=selected_areas)
288+
)
289+
# Return response indicating selected areas.
290+
return cast(
291+
ServiceResponse, {"status": "areas selected", "areas": selected_areas}
292+
)
293+
294+
async def async_handle_clean_areas(
295+
self, areas: list[int], **kwargs: Any
296+
) -> ServiceResponse:
297+
"""Start cleaning the specified areas."""
298+
# Matter command to the vacuum cleaner to select the areas.
299+
await self.send_device_command(
300+
clusters.ServiceArea.Commands.SelectAreas(newAreas=areas)
301+
)
302+
# Start the vacuum cleaner after selecting areas.
303+
await self.async_start()
304+
# Return response indicating selected areas.
305+
return cast(
306+
ServiceResponse, {"status": "cleaning areas selected", "areas": areas}
307+
)
308+
139309
@callback
140310
def _update_from_device(self) -> None:
141311
"""Update from device."""
142312
self._calculate_features()
313+
# ServiceArea: get areas from the device
314+
self._attr_matter_areas = self.get_matter_attribute_value(
315+
clusters.ServiceArea.Attributes.SupportedAreas
316+
)
317+
# optional CurrentArea attribute
318+
# pylint: disable=too-many-nested-blocks
319+
if self.get_matter_attribute_value(clusters.ServiceArea.Attributes.CurrentArea):
320+
current_area = self.get_matter_attribute_value(
321+
clusters.ServiceArea.Attributes.CurrentArea
322+
)
323+
# get areaInfo.locationInfo.locationName for current_area in SupportedAreas list
324+
area_name = None
325+
if self._attr_matter_areas:
326+
for area in self._attr_matter_areas:
327+
if getattr(area, "areaID", None) == current_area:
328+
area_info = getattr(area, "areaInfo", None)
329+
if area_info is not None:
330+
location_info = getattr(area_info, "locationInfo", None)
331+
if location_info is not None:
332+
area_name = getattr(location_info, "locationName", None)
333+
break
334+
self._attr_current_area = current_area
335+
self._attr_current_area_name = area_name
336+
else:
337+
self._attr_current_area = None
338+
self._attr_current_area_name = None
339+
340+
# optional SelectedAreas attribute
341+
if self.get_matter_attribute_value(
342+
clusters.ServiceArea.Attributes.SelectedAreas
343+
):
344+
self._attr_selected_areas = self.get_matter_attribute_value(
345+
clusters.ServiceArea.Attributes.SelectedAreas
346+
)
143347
# derive state from the run mode + operational state
144348
run_mode_raw: int = self.get_matter_attribute_value(
145349
clusters.RvcRunMode.Attributes.CurrentMode
@@ -220,6 +424,10 @@ def _calculate_features(self) -> None:
220424
clusters.RvcRunMode.Attributes.CurrentMode,
221425
clusters.RvcOperationalState.Attributes.OperationalState,
222426
),
427+
optional_attributes=(
428+
clusters.ServiceArea.Attributes.SelectedAreas,
429+
clusters.ServiceArea.Attributes.CurrentArea,
430+
),
223431
device_type=(device_types.RoboticVacuumCleaner,),
224432
allow_none_value=True,
225433
),

tests/components/matter/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ async def integration_fixture(
121121
"smoke_detector",
122122
"solar_power",
123123
"switch_unit",
124+
"switchbot_K11",
124125
"temperature_sensor",
125126
"thermostat",
126127
"vacuum_cleaner",

0 commit comments

Comments
 (0)