From 07d6ebef4c7442cefc77eb51e81899a293a152ec Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 14 Oct 2025 00:18:41 +0200 Subject: [PATCH] Restore KNX sensor entity states (#154318) --- homeassistant/components/knx/binary_sensor.py | 2 +- homeassistant/components/knx/sensor.py | 42 ++++++++++------ tests/components/knx/test_binary_sensor.py | 8 +-- tests/components/knx/test_sensor.py | 49 +++++++++++++++++-- tests/components/knx/test_switch.py | 2 +- 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index d6f8fa62915..471034851f0 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -84,11 +84,11 @@ class _KnxBinarySensor(BinarySensorEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" - await super().async_added_to_hass() if ( last_state := await self.async_get_last_state() ) and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.update_value(last_state.state == STATE_ON) + await super().async_added_to_hass() @property def is_on(self) -> bool: diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index ccc0d27306f..2a3732e7fcd 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -6,15 +6,15 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial -from typing import Any from xknx import XKNX from xknx.core.connection_state import XknxConnectionState, XknxConnectionType -from xknx.devices import Sensor as XknxSensor +from xknx.devices import Device as XknxDevice, Sensor as XknxSensor from homeassistant import config_entries from homeassistant.components.sensor import ( CONF_STATE_CLASS, + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -25,6 +25,8 @@ from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, EntityCategory, Platform, ) @@ -141,7 +143,7 @@ def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor: ) -class KNXSensor(KnxYamlEntity, SensorEntity): +class KNXSensor(KnxYamlEntity, RestoreSensor): """Representation of a KNX sensor.""" _device: XknxSensor @@ -164,20 +166,30 @@ class KNXSensor(KnxYamlEntity, SensorEntity): self._attr_unique_id = str(self._device.sensor_value.group_address_state) self._attr_native_unit_of_measurement = self._device.unit_of_measurement() self._attr_state_class = config.get(CONF_STATE_CLASS) + self._attr_extra_state_attributes = {} - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self._device.resolve_state() + async def async_added_to_hass(self) -> None: + """Restore last state.""" + if ( + (last_state := await self.async_get_last_state()) + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and ( + (last_sensor_data := await self.async_get_last_sensor_data()) + is not None + ) + ): + self._attr_native_value = last_sensor_data.native_value + self._attr_extra_state_attributes.update(last_state.attributes) + await super().async_added_to_hass() - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return device specific state attributes.""" - attr: dict[str, Any] = {} - - if self._device.last_telegram is not None: - attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) - return attr + def after_update_callback(self, device: XknxDevice) -> None: + """Call after device was updated.""" + self._attr_native_value = self._device.resolve_state() + if telegram := self._device.last_telegram: + self._attr_extra_state_attributes[ATTR_SOURCE] = str( + telegram.source_address + ) + super().after_update_callback(device) class KNXSystemSensor(SensorEntity): diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 34382f742c8..6025713dbc4 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -284,8 +284,8 @@ async def test_binary_sensor_reset( assert state.state is STATE_OFF -async def test_binary_sensor_restore_and_respond(hass: HomeAssistant, knx) -> None: - """Test restoring KNX binary sensor state and respond to read.""" +async def test_binary_sensor_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test restoring KNX binary sensor state.""" _ADDRESS = "2/2/2" fake_state = State("binary_sensor.test", STATE_ON) mock_restore_cache(hass, (fake_state,)) @@ -312,7 +312,9 @@ async def test_binary_sensor_restore_and_respond(hass: HomeAssistant, knx) -> No assert state.state is STATE_OFF -async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: +async def test_binary_sensor_restore_invert( + hass: HomeAssistant, knx: KNXTestKit +) -> None: """Test restoring KNX binary sensor state with invert.""" _ADDRESS = "2/2/2" fake_state = State("binary_sensor.test", STATE_ON) diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index e5a17479629..b1168570337 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -2,14 +2,22 @@ from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.const import ( + ATTR_SOURCE, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, +) from homeassistant.components.knx.schema import SensorSchema from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from .conftest import KNXTestKit -from tests.common import async_capture_events, async_fire_time_changed +from tests.common import ( + async_capture_events, + async_fire_time_changed, + mock_restore_cache_with_extra_data, +) async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: @@ -43,6 +51,41 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_no_telegram() +async def test_sensor_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test restoring KNX sensor state.""" + ADDRESS = "2/2/2" + RAW_FLOAT_21_0 = (0x0C, 0x1A) + RESTORED_STATE = "21.0" + RESTORED_STATE_ATTRIBUTES = {ATTR_SOURCE: knx.INDIVIDUAL_ADDRESS} + fake_state = State( + "sensor.test", "ignored in favour of native_value", RESTORED_STATE_ATTRIBUTES + ) + extra_data = {"native_value": RESTORED_STATE, "native_unit_of_measurement": "°C"} + mock_restore_cache_with_extra_data(hass, [(fake_state, extra_data)]) + + await knx.setup_integration( + { + SensorSchema.PLATFORM: [ + { + CONF_NAME: "test", + CONF_STATE_ADDRESS: ADDRESS, + CONF_TYPE: "temperature", # 2 byte float + CONF_SYNC_STATE: False, + }, + ] + } + ) + + # restored state - no read-response due to sync_state False + knx.assert_state("sensor.test", RESTORED_STATE, **RESTORED_STATE_ATTRIBUTES) + await knx.assert_telegram_count(0) + + # receiving the restored value from restored source does not trigger state_changed event + events = async_capture_events(hass, "state_changed") + await knx.receive_write(ADDRESS, RAW_FLOAT_21_0) + assert not events + + async def test_last_reported( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index 969c11b8e1a..581388658ac 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -111,7 +111,7 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_telegram_count(0) -async def test_switch_restore_and_respond(hass: HomeAssistant, knx) -> None: +async def test_switch_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test restoring KNX switch state and respond to read.""" _ADDRESS = "1/1/1" fake_state = State("switch.test", "on")