Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Bottein
0063dc81d3 Copilot suggestions 2026-03-21 19:09:13 +01:00
Paul Bottein
7463bb79dd Remove expiration 2026-03-21 19:07:13 +01:00
Paul Bottein
d17b681477 Add time sync button to Matter integration 2026-03-21 18:57:50 +01:00
5 changed files with 465 additions and 1 deletions

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
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
from chip.clusters.Types import NullValue
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -17,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
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 +55,67 @@ class MatterCommandButton(MatterEntity, ButtonEntity):
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()
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_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
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
await self.send_device_command(
clusters.TimeSynchronization.Commands.SetDSTOffset(
DSTOffset=[
clusters.TimeSynchronization.Structs.DSTOffsetStruct(
offset=dst_offset,
validStarting=0,
validUntil=NullValue,
)
]
)
)
# 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 +233,16 @@ DISCOVERY_SCHEMAS = [
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,
),
]

View File

@@ -20,6 +20,9 @@
},
"stop": {
"default": "mdi:stop"
},
"sync_time": {
"default": "mdi:clock-check-outline"
}
},
"fan": {

View File

@@ -141,6 +141,9 @@
},
"stop": {
"name": "[%key:common::action::stop%]"
},
"sync_time": {
"name": "Sync time"
}
},
"climate": {

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.eve_shutter_switch_20eci1701_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.eve_shutter_switch_20eci1701_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.eve_thermo_20ebp1701_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.eve_thermo_20ebp1701_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.eve_thermo_20ecd1701_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.eve_thermo_20ecd1701_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.alpstuga_air_quality_monitor_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.alpstuga_air_quality_monitor_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.water_leak_detector_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.water_leak_detector_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.light_switch_example_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.light_switch_example_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[silabs_refrigerator][button.refrigerator_identify-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -1,8 +1,10 @@
"""Test Matter switches."""
"""Test Matter buttons."""
from datetime import UTC, datetime
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
@@ -10,6 +12,7 @@ from syrupy.assertion import SnapshotAssertion
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 +110,82 @@ 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)
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_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
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=NullValue,
)
]
),
)
# 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,
),
)