From d17b68147754bea11d3f0e7b85ab7ef6fef21ea1 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sat, 21 Mar 2026 18:57:50 +0100 Subject: [PATCH 1/3] Add time sync button to Matter integration --- homeassistant/components/matter/button.py | 69 ++++ homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/strings.json | 3 + .../matter/snapshots/test_button.ambr | 300 ++++++++++++++++++ tests/components/matter/test_button.py | 76 ++++- 5 files changed, 450 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 11a364622e34ec..1d9cd11d72759c 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters @@ -17,6 +18,7 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -52,6 +54,61 @@ async def async_press(self) -> None: await self.send_device_command(self.entity_description.command()) +# CHIP epoch: 2000-01-01 00:00:00 UTC +CHIP_EPOCH = datetime(2000, 1, 1, tzinfo=UTC) + + +class MatterTimeSyncButton(MatterEntity, ButtonEntity): + """Button to synchronize time to a Matter device.""" + + entity_description: MatterButtonEntityDescription + + async def async_press(self) -> None: + """Sync Home Assistant time to the Matter device.""" + now = dt_util.utcnow() + tz = dt_util.get_default_time_zone() + utc_us = int((now - CHIP_EPOCH).total_seconds() * 1_000_000) + + # Compute timezone and DST offsets + local_now = now.astimezone(tz) + utc_offset = int(local_now.utcoffset().total_seconds()) # type: ignore[union-attr] + dst_offset = int(local_now.dst().total_seconds()) if local_now.dst() else 0 # type: ignore[union-attr] + standard_offset = utc_offset - dst_offset + + # 1. Set timezone + await self.send_device_command( + clusters.TimeSynchronization.Commands.SetTimeZone( + timeZone=[ + clusters.TimeSynchronization.Structs.TimeZoneStruct( + offset=standard_offset, validAt=0, name=str(tz) + ) + ] + ) + ) + + # 2. Set DST offset + far_future_us = utc_us + (365 * 24 * 3600 * 1_000_000) + await self.send_device_command( + clusters.TimeSynchronization.Commands.SetDSTOffset( + DSTOffset=[ + clusters.TimeSynchronization.Structs.DSTOffsetStruct( + offset=dst_offset, + validStarting=0, + validUntil=far_future_us, + ) + ] + ) + ) + + # 3. Set UTC time + await self.send_device_command( + clusters.TimeSynchronization.Commands.SetUTCTime( + UTCTime=utc_us, + granularity=clusters.TimeSynchronization.Enums.GranularityEnum.kMicrosecondsGranularity, + ) + ) + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -169,4 +226,16 @@ async def async_press(self) -> None: value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id, allow_multi=True, # Also used in water_heater ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="TimeSynchronizationSyncTimeButton", + translation_key="sync_time", + entity_category=EntityCategory.CONFIG, + ), + entity_class=MatterTimeSyncButton, + required_attributes=(clusters.TimeSynchronization.Attributes.UTCTime,), + allow_multi=True, + allow_none_value=True, + ), ] diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index be65b462108085..86cf50ef06c99c 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -20,6 +20,9 @@ }, "stop": { "default": "mdi:stop" + }, + "sync_time": { + "default": "mdi:clock-check-outline" } }, "fan": { diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index b8db87c58b8ecc..5c055f7eb619fd 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -141,6 +141,9 @@ }, "stop": { "name": "[%key:common::action::stop%]" + }, + "sync_time": { + "name": "Sync time" } }, "climate": { diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 2802445a749338..36948ef573c163 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1325,6 +1325,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[eve_shutter][button.eve_shutter_switch_20eci1701_sync_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_shutter_switch_20eci1701_sync_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': '00000000000004D2-0000000000000094-MatterNodeDevice-0-TimeSynchronizationSyncTimeButton-56-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[eve_shutter][button.eve_shutter_switch_20eci1701_sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Shutter Switch 20ECI1701 Sync time', + }), + 'context': , + 'entity_id': 'button.eve_shutter_switch_20eci1701_sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1376,6 +1426,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_sync_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_thermo_20ebp1701_sync_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-TimeSynchronizationSyncTimeButton-56-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo 20EBP1701 Sync time', + }), + 'context': , + 'entity_id': 'button.eve_thermo_20ebp1701_sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[eve_thermo_v5][button.eve_thermo_20ecd1701_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1427,6 +1527,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[eve_thermo_v5][button.eve_thermo_20ecd1701_sync_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_thermo_20ecd1701_sync_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-0-TimeSynchronizationSyncTimeButton-56-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[eve_thermo_v5][button.eve_thermo_20ecd1701_sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo 20ECD1701 Sync time', + }), + 'context': , + 'entity_id': 'button.eve_thermo_20ecd1701_sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[eve_weather_sensor][button.eve_weather_identify_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1935,6 +2085,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[ikea_air_quality_monitor][button.alpstuga_air_quality_monitor_sync_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.alpstuga_air_quality_monitor_sync_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-0-TimeSynchronizationSyncTimeButton-56-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[ikea_air_quality_monitor][button.alpstuga_air_quality_monitor_sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ALPSTUGA air quality monitor Sync time', + }), + 'context': , + 'entity_id': 'button.alpstuga_air_quality_monitor_sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[inovelli_vtm30][button.white_series_onoff_switch_identify_load_control-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -2845,6 +3045,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[mock_leak_sensor][button.water_leak_detector_sync_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.water_leak_detector_sync_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': '00000000000004D2-0000000000000020-MatterNodeDevice-0-TimeSynchronizationSyncTimeButton-56-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[mock_leak_sensor][button.water_leak_detector_sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Leak Detector Sync time', + }), + 'context': , + 'entity_id': 'button.water_leak_detector_sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[mock_lock][button.mock_lock_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -4211,6 +4461,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_light_switch][button.light_switch_example_sync_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.light_switch_example_sync_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': '00000000000004D2-000000000000008E-MatterNodeDevice-0-TimeSynchronizationSyncTimeButton-56-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_light_switch][button.light_switch_example_sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Light switch example Sync time', + }), + 'context': , + 'entity_id': 'button.light_switch_example_sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[silabs_refrigerator][button.refrigerator_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index 56133805de9432..3ce49bd7f9b963 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -1,5 +1,6 @@ -"""Test Matter switches.""" +"""Test Matter buttons.""" +from datetime import UTC, datetime from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters @@ -10,6 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .common import snapshot_matter_entities @@ -107,3 +109,75 @@ async def test_smoke_detector_self_test( endpoint_id=1, command=clusters.SmokeCoAlarm.Commands.SelfTestRequest(), ) + + +@pytest.mark.freeze_time("2025-06-15T12:00:00+00:00") +@pytest.mark.parametrize("node_fixture", ["ikea_air_quality_monitor"]) +async def test_time_sync_button( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test button entity is created for a Matter TimeSynchronization Cluster.""" + entity_id = "button.alpstuga_air_quality_monitor_sync_time" + state = hass.states.get(entity_id) + assert state + assert state.attributes["friendly_name"] == "ALPSTUGA air quality monitor Sync time" + # test press action + await hass.services.async_call( + "button", + "press", + { + "entity_id": entity_id, + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 3 + + # Compute expected values based on HA's configured timezone + chip_epoch = datetime(2000, 1, 1, tzinfo=UTC) + frozen_now = datetime(2025, 6, 15, 12, 0, 0, tzinfo=UTC) + expected_utc_us = int((frozen_now - chip_epoch).total_seconds() * 1_000_000) + ha_tz = dt_util.get_default_time_zone() + local_now = frozen_now.astimezone(ha_tz) + utc_offset = int(local_now.utcoffset().total_seconds()) # type: ignore[union-attr] + dst_offset = int(local_now.dst().total_seconds()) if local_now.dst() else 0 # type: ignore[union-attr] + standard_offset = utc_offset - dst_offset + + # Verify SetTimeZone command + assert matter_client.send_device_command.call_args_list[0] == call( + node_id=matter_node.node_id, + endpoint_id=0, + command=clusters.TimeSynchronization.Commands.SetTimeZone( + timeZone=[ + clusters.TimeSynchronization.Structs.TimeZoneStruct( + offset=standard_offset, + validAt=0, + name=str(ha_tz), + ) + ] + ), + ) + # Verify SetDSTOffset command + assert matter_client.send_device_command.call_args_list[1] == call( + node_id=matter_node.node_id, + endpoint_id=0, + command=clusters.TimeSynchronization.Commands.SetDSTOffset( + DSTOffset=[ + clusters.TimeSynchronization.Structs.DSTOffsetStruct( + offset=dst_offset, + validStarting=0, + validUntil=expected_utc_us + (365 * 24 * 3600 * 1_000_000), + ) + ] + ), + ) + # Verify SetUTCTime command + assert matter_client.send_device_command.call_args_list[2] == call( + node_id=matter_node.node_id, + endpoint_id=0, + command=clusters.TimeSynchronization.Commands.SetUTCTime( + UTCTime=expected_utc_us, + granularity=clusters.TimeSynchronization.Enums.GranularityEnum.kMicrosecondsGranularity, + ), + ) From 7463bb79dd0073bd4cace56f78ab375fa4931582 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sat, 21 Mar 2026 19:06:30 +0100 Subject: [PATCH 2/3] Remove expiration --- homeassistant/components/matter/button.py | 4 ++-- tests/components/matter/test_button.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 1d9cd11d72759c..a7147402c34977 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters +from chip.clusters.Types import NullValue from homeassistant.components.button import ( ButtonDeviceClass, @@ -87,14 +88,13 @@ async def async_press(self) -> None: ) # 2. Set DST offset - far_future_us = utc_us + (365 * 24 * 3600 * 1_000_000) await self.send_device_command( clusters.TimeSynchronization.Commands.SetDSTOffset( DSTOffset=[ clusters.TimeSynchronization.Structs.DSTOffsetStruct( offset=dst_offset, validStarting=0, - validUntil=far_future_us, + validUntil=NullValue, ) ] ) diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index 3ce49bd7f9b963..4fe152067f88da 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Types import NullValue from matter_server.client.models.node import MatterNode import pytest from syrupy.assertion import SnapshotAssertion @@ -167,7 +168,7 @@ async def test_time_sync_button( clusters.TimeSynchronization.Structs.DSTOffsetStruct( offset=dst_offset, validStarting=0, - validUntil=expected_utc_us + (365 * 24 * 3600 * 1_000_000), + validUntil=NullValue, ) ] ), From 0063dc81d38ff7f18565e9378c0b569711ab6be5 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sat, 21 Mar 2026 19:09:13 +0100 Subject: [PATCH 3/3] Copilot suggestions --- homeassistant/components/matter/button.py | 13 ++++++++++--- tests/components/matter/test_button.py | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index a7147402c34977..73179c91c51b13 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -68,12 +68,19 @@ async def async_press(self) -> None: """Sync Home Assistant time to the Matter device.""" now = dt_util.utcnow() tz = dt_util.get_default_time_zone() - utc_us = int((now - CHIP_EPOCH).total_seconds() * 1_000_000) + delta = now - CHIP_EPOCH + utc_us = ( + (delta.days * 86400 * 1_000_000) + + (delta.seconds * 1_000_000) + + delta.microseconds + ) # Compute timezone and DST offsets local_now = now.astimezone(tz) - utc_offset = int(local_now.utcoffset().total_seconds()) # type: ignore[union-attr] - dst_offset = int(local_now.dst().total_seconds()) if local_now.dst() else 0 # type: ignore[union-attr] + utc_offset_delta = local_now.utcoffset() + utc_offset = int(utc_offset_delta.total_seconds()) if utc_offset_delta else 0 + dst_offset_delta = local_now.dst() + dst_offset = int(dst_offset_delta.total_seconds()) if dst_offset_delta else 0 standard_offset = utc_offset - dst_offset # 1. Set timezone diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index 4fe152067f88da..3621adc75f208d 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -138,11 +138,18 @@ async def test_time_sync_button( # Compute expected values based on HA's configured timezone chip_epoch = datetime(2000, 1, 1, tzinfo=UTC) frozen_now = datetime(2025, 6, 15, 12, 0, 0, tzinfo=UTC) - expected_utc_us = int((frozen_now - chip_epoch).total_seconds() * 1_000_000) + delta = frozen_now - chip_epoch + expected_utc_us = ( + (delta.days * 86400 * 1_000_000) + + (delta.seconds * 1_000_000) + + delta.microseconds + ) ha_tz = dt_util.get_default_time_zone() local_now = frozen_now.astimezone(ha_tz) - utc_offset = int(local_now.utcoffset().total_seconds()) # type: ignore[union-attr] - dst_offset = int(local_now.dst().total_seconds()) if local_now.dst() else 0 # type: ignore[union-attr] + utc_offset_delta = local_now.utcoffset() + utc_offset = int(utc_offset_delta.total_seconds()) if utc_offset_delta else 0 + dst_offset_delta = local_now.dst() + dst_offset = int(dst_offset_delta.total_seconds()) if dst_offset_delta else 0 standard_offset = utc_offset - dst_offset # Verify SetTimeZone command