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
+76
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from chip.clusters import Objects as clusters from chip.clusters import Objects as clusters
from chip.clusters.Types import NullValue
from homeassistant.components.button import ( from homeassistant.components.button import (
ButtonDeviceClass, ButtonDeviceClass,
@@ -17,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .entity import MatterEntity, MatterEntityDescription from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter from .helpers import get_matter
@@ -52,6 +55,67 @@ class MatterCommandButton(MatterEntity, ButtonEntity):
await self.send_device_command(self.entity_description.command()) 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 schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [ DISCOVERY_SCHEMAS = [
MatterDiscoverySchema( MatterDiscoverySchema(
@@ -169,4 +233,16 @@ DISCOVERY_SCHEMAS = [
value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id, value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id,
allow_multi=True, # Also used in water_heater 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,
),
] ]
@@ -20,6 +20,9 @@
}, },
"stop": { "stop": {
"default": "mdi:stop" "default": "mdi:stop"
},
"sync_time": {
"default": "mdi:clock-check-outline"
} }
}, },
"fan": { "fan": {
@@ -141,6 +141,9 @@
}, },
"stop": { "stop": {
"name": "[%key:common::action::stop%]" "name": "[%key:common::action::stop%]"
},
"sync_time": {
"name": "Sync time"
} }
}, },
"climate": { "climate": {
@@ -1325,6 +1325,56 @@
'state': 'unknown', '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] # name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_identify-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': list([ 'aliases': list([
@@ -1376,6 +1426,56 @@
'state': 'unknown', '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] # name: test_buttons[eve_thermo_v5][button.eve_thermo_20ecd1701_identify-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': list([ 'aliases': list([
@@ -1427,6 +1527,56 @@
'state': 'unknown', '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] # name: test_buttons[eve_weather_sensor][button.eve_weather_identify_1-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': list([ 'aliases': list([
@@ -1935,6 +2085,56 @@
'state': 'unknown', '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] # name: test_buttons[inovelli_vtm30][button.white_series_onoff_switch_identify_load_control-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': list([ 'aliases': list([
@@ -2845,6 +3045,56 @@
'state': 'unknown', '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] # name: test_buttons[mock_lock][button.mock_lock_identify-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': list([ 'aliases': list([
@@ -4211,6 +4461,56 @@
'state': 'unknown', '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] # name: test_buttons[silabs_refrigerator][button.refrigerator_identify-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': list([ 'aliases': list([
+83 -1
View File
@@ -1,8 +1,10 @@
"""Test Matter switches.""" """Test Matter buttons."""
from datetime import UTC, datetime
from unittest.mock import MagicMock, call from unittest.mock import MagicMock, call
from chip.clusters import Objects as clusters from chip.clusters import Objects as clusters
from chip.clusters.Types import NullValue
from matter_server.client.models.node import MatterNode from matter_server.client.models.node import MatterNode
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@@ -10,6 +12,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .common import snapshot_matter_entities from .common import snapshot_matter_entities
@@ -107,3 +110,82 @@ async def test_smoke_detector_self_test(
endpoint_id=1, endpoint_id=1,
command=clusters.SmokeCoAlarm.Commands.SelfTestRequest(), 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,
),
)