diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index fd180cc8e45..0832675744e 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -183,10 +183,35 @@ PUMP_CONTROL_MODE_MAP = { clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None, } +MATTER_2000_TO_UNIX_EPOCH_OFFSET = ( + 946684800 # Seconds from Matter 2000 epoch to Unix epoch +) HUMIDITY_SCALING_FACTOR = 100 TEMPERATURE_SCALING_FACTOR = 100 +def matter_epoch_seconds_to_utc(x: int | None) -> datetime | None: + """Convert Matter epoch seconds (since 2000-01-01) to UTC datetime. + + Returns None for non-positive or None values (represents unknown/absent). + """ + if x is None or x <= 0: + return None + return dt_util.utc_from_timestamp(x + MATTER_2000_TO_UNIX_EPOCH_OFFSET) + + +def matter_epoch_microseconds_to_utc(x: int | None) -> datetime | None: + """Convert Matter epoch microseconds (since 2000-01-01) to UTC datetime. + + The value is in microseconds; convert to seconds before applying offset. + Returns None for non-positive or None values. + """ + if x is None or x <= 0: + return None + seconds = x // 1_000_000 + return dt_util.utc_from_timestamp(seconds + MATTER_2000_TO_UNIX_EPOCH_OFFSET) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -1468,7 +1493,8 @@ DISCOVERY_SCHEMAS = [ translation_key="auto_close_time", device_class=SensorDeviceClass.TIMESTAMP, state_class=None, - device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None), + # AutoCloseTime is defined as epoch-us in the spec + device_to_ha=matter_epoch_microseconds_to_utc, ), entity_class=MatterSensor, featuremap_contains=clusters.ValveConfigurationAndControl.Bitmaps.Feature.kTimeSync, @@ -1483,7 +1509,8 @@ DISCOVERY_SCHEMAS = [ translation_key="estimated_end_time", device_class=SensorDeviceClass.TIMESTAMP, state_class=None, - device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None), + # EstimatedEndTime is defined as epoch-s (Matter 2000 epoch) in the spec + device_to_ha=matter_epoch_seconds_to_utc, ), entity_class=MatterSensor, required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,), diff --git a/tests/components/matter/fixtures/nodes/vacuum_cleaner.json b/tests/components/matter/fixtures/nodes/vacuum_cleaner.json index 69f2e9bff86..4a3630fdbf5 100644 --- a/tests/components/matter/fixtures/nodes/vacuum_cleaner.json +++ b/tests/components/matter/fixtures/nodes/vacuum_cleaner.json @@ -357,7 +357,7 @@ ], "1/336/2": [], "1/336/3": 7, - "1/336/4": 1756501200, + "1/336/4": 809816400, "1/336/5": [], "1/336/65532": 6, "1/336/65533": 1, diff --git a/tests/components/matter/fixtures/nodes/valve.json b/tests/components/matter/fixtures/nodes/valve.json index 5ba06412ca9..16ca08592ae 100644 --- a/tests/components/matter/fixtures/nodes/valve.json +++ b/tests/components/matter/fixtures/nodes/valve.json @@ -239,7 +239,7 @@ "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "1/129/0": 0, "1/129/1": 0, - "1/129/2": 0, + "1/129/2": 789004800000000, "1/129/3": null, "1/129/4": 0, "1/129/5": 0, @@ -248,7 +248,7 @@ "1/129/8": 100, "1/129/9": 0, "1/129/10": 0, - "1/129/65532": 0, + "1/129/65532": 1, "1/129/65533": 1, "1/129/65528": [], "1/129/65529": [0, 1], diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index d6fbd4c6ef0..53526eaf4f9 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -11147,6 +11147,55 @@ 'state': 'stopped', }) # --- +# name: test_sensors[valve][sensor.valve_auto_close_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.valve_auto_close_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto-close time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_close_time', + 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-ValveConfigurationAndControlAutoCloseTime-129-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[valve][sensor.valve_auto_close_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Valve Auto-close time', + }), + 'context': , + 'entity_id': 'sensor.valve_auto_close_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T00:00:00+00:00', + }) +# --- # name: test_sensors[window_covering_full][sensor.mock_full_window_covering_target_opening_position-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index d99c2051332..9a5f01321fd 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -642,7 +642,7 @@ async def test_vacuum_actions( assert state assert state.state == "2025-08-29T21:00:00+00:00" - set_node_attribute(matter_node, 1, 336, 4, 1756502000) + set_node_attribute(matter_node, 1, 336, 4, 809817200) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("sensor.mock_vacuum_estimated_end_time") @@ -732,3 +732,35 @@ async def test_optional_door_event_sensors_from_featuremap( state = hass.states.get(entity_id_closed) assert state assert state.state == "8" + + +@pytest.mark.parametrize("node_fixture", ["valve"]) +async def test_valve( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test valve AutoCloseTime sensor with Matter epoch microseconds conversion.""" + # ValveConfigurationAndControl Cluster / AutoCloseTime attribute (1/129/2) + # Initial value is 789004800000000 microseconds = 2025-01-01 00:00:00 UTC + state = hass.states.get("sensor.valve_auto_close_time") + assert state + assert state.state == "2025-01-01T00:00:00+00:00" + + # Set to another timestamp: 820540800000000 microseconds + # = 820540800 seconds since 2000-01-01 = 1767225600 Unix epoch + # = 2026-01-01 00:00:00 UTC + set_node_attribute(matter_node, 1, 129, 2, 820540800000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.valve_auto_close_time") + assert state + assert state.state == "2026-01-01T00:00:00+00:00" + + # Test setting to 0 (invalid/null) - should result in unknown state + set_node_attribute(matter_node, 1, 129, 2, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.valve_auto_close_time") + assert state + assert state.state == "unknown"