Skip to content

Commit 7950f9a

Browse files
lboueNoRi2909joostlek
authored
Add Matter service actions for water_heater (home-assistant#153577)
Co-authored-by: Norbert Rittel <[email protected]> Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 66eeb41 commit 7950f9a

File tree

9 files changed

+241
-4
lines changed

9 files changed

+241
-4
lines changed

homeassistant/components/matter/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@
2020
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
2121
from homeassistant.core import Event, HomeAssistant, callback
2222
from homeassistant.exceptions import ConfigEntryNotReady
23-
from homeassistant.helpers import device_registry as dr
23+
from homeassistant.helpers import config_validation as cv, device_registry as dr
2424
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2525
from homeassistant.helpers.issue_registry import (
2626
IssueSeverity,
2727
async_create_issue,
2828
async_delete_issue,
2929
)
30+
from homeassistant.helpers.typing import ConfigType
3031

3132
from .adapter import MatterAdapter
3233
from .addon import get_addon_manager
@@ -40,10 +41,13 @@
4041
node_from_ha_device_id,
4142
)
4243
from .models import MatterDeviceInfo
44+
from .services import async_setup_services
4345

4446
CONNECT_TIMEOUT = 10
4547
LISTEN_READY_TIMEOUT = 30
4648

49+
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
50+
4751

4852
@callback
4953
@cache
@@ -64,6 +68,12 @@ def get_matter_device_info(
6468
)
6569

6670

71+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
72+
"""Set up the Matter integration services."""
73+
async_setup_services(hass)
74+
return True
75+
76+
6777
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
6878
"""Set up Matter from a config entry."""
6979
if use_addon := entry.data.get(CONF_USE_ADDON):

homeassistant/components/matter/button.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,18 @@ async def async_press(self) -> None:
155155
required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,),
156156
value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id,
157157
),
158+
MatterDiscoverySchema(
159+
platform=Platform.BUTTON,
160+
entity_description=MatterButtonEntityDescription(
161+
key="WaterHeaterManagementCancelBoost",
162+
translation_key="cancel_boost",
163+
command=clusters.WaterHeaterManagement.Commands.CancelBoost,
164+
),
165+
entity_class=MatterCommandButton,
166+
required_attributes=(
167+
clusters.WaterHeaterManagement.Attributes.AcceptedCommandList,
168+
),
169+
value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id,
170+
allow_multi=True, # Also used in water_heater
171+
),
158172
]

homeassistant/components/matter/icons.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,5 +163,10 @@
163163
"default": "mdi:shield-lock"
164164
}
165165
}
166+
},
167+
"services": {
168+
"water_heater_boost": {
169+
"service": "mdi:water-boiler"
170+
}
166171
}
167172
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Services for Matter devices."""
2+
3+
from __future__ import annotations
4+
5+
import voluptuous as vol
6+
7+
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
8+
from homeassistant.core import HomeAssistant, callback
9+
from homeassistant.helpers import config_validation as cv, service
10+
11+
from .const import DOMAIN
12+
13+
ATTR_DURATION = "duration"
14+
ATTR_EMERGENCY_BOOST = "emergency_boost"
15+
ATTR_TEMPORARY_SETPOINT = "temporary_setpoint"
16+
17+
SERVICE_WATER_HEATER_BOOST = "water_heater_boost"
18+
19+
20+
@callback
21+
def async_setup_services(hass: HomeAssistant) -> None:
22+
"""Register the Matter services."""
23+
24+
service.async_register_platform_entity_service(
25+
hass,
26+
DOMAIN,
27+
SERVICE_WATER_HEATER_BOOST,
28+
entity_domain=WATER_HEATER_DOMAIN,
29+
schema={
30+
# duration >=1
31+
vol.Required(ATTR_DURATION): vol.All(vol.Coerce(int), vol.Range(min=1)),
32+
vol.Optional(ATTR_EMERGENCY_BOOST): cv.boolean,
33+
vol.Optional(ATTR_TEMPORARY_SETPOINT): vol.All(
34+
vol.Coerce(int), vol.Range(min=30, max=65)
35+
),
36+
},
37+
func="async_set_boost",
38+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
water_heater_boost:
2+
target:
3+
entity:
4+
domain: water_heater
5+
fields:
6+
duration:
7+
selector:
8+
number:
9+
min: 60
10+
max: 14400
11+
step: 60
12+
mode: box
13+
default: 3600
14+
required: true
15+
emergency_boost:
16+
selector:
17+
boolean:
18+
default: false
19+
temporary_setpoint:
20+
selector:
21+
number:
22+
min: 30
23+
max: 65
24+
step: 1
25+
mode: slider
26+
default: 65

homeassistant/components/matter/strings.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@
106106
}
107107
},
108108
"button": {
109+
"cancel_boost": {
110+
"name": "Cancel boost"
111+
},
109112
"pause": {
110113
"name": "[%key:common::action::pause%]"
111114
},
@@ -590,6 +593,24 @@
590593
"description": "The Matter device to add to the other Matter network."
591594
}
592595
}
596+
},
597+
"water_heater_boost": {
598+
"name": "Boost water heater",
599+
"description": "Enables water heater boost for a specific duration.",
600+
"fields": {
601+
"duration": {
602+
"name": "Duration",
603+
"description": "Boost duration"
604+
},
605+
"emergency_boost": {
606+
"name": "Emergency boost",
607+
"description": "Whether to enable emergency boost mode."
608+
},
609+
"temporary_setpoint": {
610+
"name": "Temporary setpoint",
611+
"description": "Temporary setpoint temperature in Celsius during the boost period."
612+
}
613+
}
593614
}
594615
}
595616
}

homeassistant/components/matter/water_heater.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from typing import Any, cast
77

88
from chip.clusters import Objects as clusters
9+
from chip.clusters.Types import Nullable
910
from matter_server.client.models import device_types
11+
from matter_server.common.errors import MatterError
1012
from matter_server.common.helpers.util import create_attribute_path_from_attribute
1113

1214
from homeassistant.components.water_heater import (
@@ -25,6 +27,7 @@
2527
UnitOfTemperature,
2628
)
2729
from homeassistant.core import HomeAssistant, callback
30+
from homeassistant.exceptions import HomeAssistantError
2831
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
2932

3033
from .entity import MatterEntity, MatterEntityDescription
@@ -40,6 +43,8 @@
4043
STATE_OFF: 0,
4144
}
4245

46+
DEFAULT_BOOST_DURATION = 3600 # 1 hour
47+
4348

4449
async def async_setup_entry(
4550
hass: HomeAssistant,
@@ -78,6 +83,30 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
7883
_attr_temperature_unit = UnitOfTemperature.CELSIUS
7984
_platform_translation_key = "water_heater"
8085

86+
async def async_set_boost(
87+
self,
88+
duration: int,
89+
emergency_boost: bool = False,
90+
temporary_setpoint: int | None = None,
91+
) -> None:
92+
"""Set boost."""
93+
boost_info: clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
94+
duration=duration,
95+
emergencyBoost=emergency_boost,
96+
temporarySetpoint=(
97+
temporary_setpoint * TEMPERATURE_SCALING_FACTOR
98+
if temporary_setpoint is not None
99+
else Nullable
100+
),
101+
)
102+
try:
103+
await self.send_device_command(
104+
clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info)
105+
)
106+
except MatterError as err:
107+
raise HomeAssistantError(f"Error sending Boost command: {err}") from err
108+
self._update_from_device()
109+
81110
async def async_set_temperature(self, **kwargs: Any) -> None:
82111
"""Set new target temperature."""
83112
target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE)
@@ -94,11 +123,11 @@ async def async_set_temperature(self, **kwargs: Any) -> None:
94123
async def async_set_operation_mode(self, operation_mode: str) -> None:
95124
"""Set new operation mode."""
96125
self._attr_current_operation = operation_mode
97-
# Boost 1h (3600s)
126+
# Use the constant for boost duration
98127
boost_info: type[
99128
clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct
100129
] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
101-
duration=3600
130+
duration=DEFAULT_BOOST_DURATION
102131
)
103132
system_mode_value = WATER_HEATER_SYSTEM_MODE_MAP[operation_mode]
104133
await self.write_attribute(

tests/components/matter/snapshots/test_button.ambr

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2479,6 +2479,54 @@
24792479
'state': 'unknown',
24802480
})
24812481
# ---
2482+
# name: test_buttons[silabs_water_heater][button.water_heater_cancel_boost-entry]
2483+
EntityRegistryEntrySnapshot({
2484+
'aliases': set({
2485+
}),
2486+
'area_id': None,
2487+
'capabilities': None,
2488+
'config_entry_id': <ANY>,
2489+
'config_subentry_id': <ANY>,
2490+
'device_class': None,
2491+
'device_id': <ANY>,
2492+
'disabled_by': None,
2493+
'domain': 'button',
2494+
'entity_category': None,
2495+
'entity_id': 'button.water_heater_cancel_boost',
2496+
'has_entity_name': True,
2497+
'hidden_by': None,
2498+
'icon': None,
2499+
'id': <ANY>,
2500+
'labels': set({
2501+
}),
2502+
'name': None,
2503+
'options': dict({
2504+
}),
2505+
'original_device_class': None,
2506+
'original_icon': None,
2507+
'original_name': 'Cancel boost',
2508+
'platform': 'matter',
2509+
'previous_unique_id': None,
2510+
'suggested_object_id': None,
2511+
'supported_features': 0,
2512+
'translation_key': 'cancel_boost',
2513+
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementCancelBoost-148-65529',
2514+
'unit_of_measurement': None,
2515+
})
2516+
# ---
2517+
# name: test_buttons[silabs_water_heater][button.water_heater_cancel_boost-state]
2518+
StateSnapshot({
2519+
'attributes': ReadOnlyDict({
2520+
'friendly_name': 'Water Heater Cancel boost',
2521+
}),
2522+
'context': <ANY>,
2523+
'entity_id': 'button.water_heater_cancel_boost',
2524+
'last_changed': <ANY>,
2525+
'last_reported': <ANY>,
2526+
'last_updated': <ANY>,
2527+
'state': 'unknown',
2528+
})
2529+
# ---
24822530
# name: test_buttons[smoke_detector][button.smoke_sensor_identify-entry]
24832531
EntityRegistryEntrySnapshot({
24842532
'aliases': set({

tests/components/matter/test_water_heater.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,19 @@
88
import pytest
99
from syrupy.assertion import SnapshotAssertion
1010

11+
from homeassistant.components.matter.services import (
12+
ATTR_DURATION,
13+
ATTR_EMERGENCY_BOOST,
14+
ATTR_TEMPORARY_SETPOINT,
15+
SERVICE_WATER_HEATER_BOOST,
16+
)
1117
from homeassistant.components.water_heater import (
1218
STATE_ECO,
1319
STATE_HIGH_DEMAND,
1420
STATE_OFF,
1521
WaterHeaterEntityFeature,
1622
)
17-
from homeassistant.const import Platform
23+
from homeassistant.const import ATTR_ENTITY_ID, Platform
1824
from homeassistant.core import HomeAssistant
1925
from homeassistant.helpers import entity_registry as er
2026

@@ -270,3 +276,43 @@ async def test_water_heater_turn_on_off(
270276
),
271277
value=4,
272278
)
279+
280+
281+
@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"])
282+
async def test_async_boost_actions(
283+
hass: HomeAssistant,
284+
matter_client: MagicMock,
285+
matter_node: MatterNode,
286+
) -> None:
287+
"""Test that set boost sends the correct command and updates the device."""
288+
state = hass.states.get("water_heater.water_heater")
289+
assert state
290+
291+
# Set boost with duration, emergency_boost, and temporary_setpoint
292+
await hass.services.async_call(
293+
"matter",
294+
SERVICE_WATER_HEATER_BOOST,
295+
{
296+
ATTR_ENTITY_ID: "water_heater.water_heater",
297+
ATTR_DURATION: 60,
298+
ATTR_EMERGENCY_BOOST: True,
299+
ATTR_TEMPORARY_SETPOINT: 55,
300+
},
301+
blocking=True,
302+
)
303+
assert matter_client.send_device_command.call_count == 1
304+
assert matter_client.send_device_command.call_args == call(
305+
node_id=matter_node.node_id,
306+
endpoint_id=2,
307+
command=clusters.WaterHeaterManagement.Commands.Boost(
308+
boostInfo=clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
309+
duration=60,
310+
oneShot=None,
311+
emergencyBoost=True,
312+
temporarySetpoint=5500,
313+
targetPercentage=None,
314+
targetReheat=None,
315+
)
316+
),
317+
)
318+
matter_client.send_device_command.reset_mock()

0 commit comments

Comments
 (0)