From ed9a810908dae567c21e0e25c2dc8b211070d1bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:46:40 +0100 Subject: [PATCH] Fix unavailable status in Tuya (#162709) --- homeassistant/components/tuya/models.py | 24 +++++++++++++--- tests/components/tuya/__init__.py | 31 +++++++++++++++++---- tests/components/tuya/test_binary_sensor.py | 5 ++-- tests/components/tuya/test_number.py | 5 ++-- tests/components/tuya/test_select.py | 5 ++-- tests/components/tuya/test_sensor.py | 5 ++-- tests/components/tuya/test_siren.py | 5 ++-- tests/components/tuya/test_switch.py | 5 ++-- tests/components/tuya/test_valve.py | 5 ++-- 9 files changed, 67 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 481aeb6d296..f5937b32a29 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -51,9 +51,14 @@ class DeviceWrapper[T]: ) -> bool: """Determine if the wrapper should skip an update. - The default is to always skip, unless overridden in subclasses. + The default is to always skip if updated properties is given, + unless overridden in subclasses. """ - return True + # If updated_status_properties is None, we should not skip, + # as we don't have information on what was updated + # This happens for example on online/offline updates, where + # we still want to update the entity state + return updated_status_properties is not None def read_device_status(self, device: CustomerDevice) -> T | None: """Read device status and convert to a Home Assistant value.""" @@ -88,9 +93,13 @@ class DPCodeWrapper(DeviceWrapper): By default, skip if updated_status_properties is given and does not include this dpcode. """ + # If updated_status_properties is None, we should not skip, + # as we don't have information on what was updated + # This happens for example on online/offline updates, where + # we still want to update the entity state return ( - updated_status_properties is None - or self.dpcode not in updated_status_properties + updated_status_properties is not None + and self.dpcode not in updated_status_properties ) def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: @@ -250,6 +259,13 @@ class DPCodeDeltaIntegerWrapper(DPCodeIntegerWrapper): Processes delta accumulation before determining if update should be skipped. """ + # If updated_status_properties is None, we should not skip, + # as we don't have information on what was updated + # This happens for example on online/offline updates, where + # we still want to update the entity state but we have nothing + # to accumulate, so we return False to not skip the update + if updated_status_properties is None: + return False if ( super().skip_update(device, updated_status_properties, dp_timestamps) or dp_timestamps is None diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 21815ddb99c..d7380a0b437 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -16,6 +16,7 @@ from tuya_sharing import ( ) from homeassistant.components.tuya import DOMAIN, DeviceListener +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps from homeassistant.util import dt as dt_util @@ -38,10 +39,13 @@ class MockDeviceListener(DeviceListener): device: CustomerDevice, updated_status_properties: dict[str, Any] | None = None, dp_timestamps: dict[str, int] | None = None, + *, + online: bool | None = None, ) -> None: """Mock update device method.""" - property_list: list[str] = [] - if updated_status_properties: + property_list: list[str] | None = None + if updated_status_properties is not None: + property_list = [] for key, value in updated_status_properties.items(): if key not in device.status: raise ValueError( @@ -49,6 +53,8 @@ class MockDeviceListener(DeviceListener): ) device.status[key] = value property_list.append(key) + if online is not None: + device.online = online self.update_device(device, property_list, dp_timestamps) await hass.async_block_till_done() @@ -185,15 +191,30 @@ async def check_selective_state_update( the entity state is not changed and last_reported is not updated. """ initial_reported = "2024-01-01T00:00:00+00:00" + unavailable_reported = "2024-01-01T00:00:10+00:00" + available_reported = "2024-01-01T00:00:20+00:00" assert hass.states.get(entity_id).state == initial_state assert hass.states.get(entity_id).last_reported.isoformat() == initial_reported - # Force update the dpcode and trigger device update - freezer.tick(30) + # Trigger device offline + freezer.tick(10) + await mock_listener.async_send_device_update(hass, mock_device, online=False) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).last_reported.isoformat() == unavailable_reported + + # Trigger device online + freezer.tick(10) + await mock_listener.async_send_device_update(hass, mock_device, online=True) + assert hass.states.get(entity_id).state == initial_state + assert hass.states.get(entity_id).last_reported.isoformat() == available_reported + + # Force update the dpcode and trigger device update without the dpcode + # in updated properties - state should not change + freezer.tick(10) mock_device.status[dpcode] = None await mock_listener.async_send_device_update(hass, mock_device, {}) assert hass.states.get(entity_id).state == initial_state - assert hass.states.get(entity_id).last_reported.isoformat() == initial_reported + assert hass.states.get(entity_id).last_reported.isoformat() == available_reported # Trigger device update with provided updates freezer.tick(30) diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index 0366732155b..afa84744467 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -42,8 +42,9 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( ("updates", "expected_state", "last_reported"), [ - # Update without dpcode - state should not change, last_reported stays at initial - ({"battery_percentage": 80}, "off", "2024-01-01T00:00:00+00:00"), + # Update without dpcode - state should not change, last_reported stays + # at available_reported + ({"battery_percentage": 80}, "off", "2024-01-01T00:00:20+00:00"), # Update with dpcode - state should change, last_reported advances ({"doorcontact_state": True}, "on", "2024-01-01T00:01:00+00:00"), # Update with multiple properties including dpcode - state should change diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index af59b13f5ca..d4ed776c56b 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -46,8 +46,9 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( ("updates", "expected_state", "last_reported"), [ - # Update without dpcode - state should not change, last_reported stays at initial - ({"switch_alarm_sound": True}, "15.0", "2024-01-01T00:00:00+00:00"), + # Update without dpcode - state should not change, last_reported stays + # at available_reported + ({"switch_alarm_sound": True}, "15.0", "2024-01-01T00:00:20+00:00"), # Update with dpcode - state should change, last_reported advances ({"delay_set": 17}, "17.0", "2024-01-01T00:01:00+00:00"), # Update with multiple properties including dpcode - state should change diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index d28d8f9d2ba..66a58ea8b83 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -47,8 +47,9 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( ("updates", "expected_state", "last_reported"), [ - # Update without dpcode - state should not change, last_reported stays at initial - ({"control": "stop"}, "forward", "2024-01-01T00:00:00+00:00"), + # Update without dpcode - state should not change, last_reported stays + # at available_reported + ({"control": "stop"}, "forward", "2024-01-01T00:00:20+00:00"), # Update with dpcode - state should change, last_reported advances ({"control_back_mode": "back"}, "back", "2024-01-01T00:01:00+00:00"), # Update with multiple properties including dpcode - state should change diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index 39b8c1e4121..bfe6d56adca 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -43,8 +43,9 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( ("updates", "expected_state", "last_reported"), [ - # Update without dpcode - state should not change, last_reported stays at initial - ({"doorcontact_state": True}, "62.0", "2024-01-01T00:00:00+00:00"), + # Update without dpcode - state should not change, last_reported stays + # at available_reported + ({"doorcontact_state": True}, "62.0", "2024-01-01T00:00:20+00:00"), # Update with dpcode - state should change, last_reported advances ({"battery_percentage": 50}, "50.0", "2024-01-01T00:01:00+00:00"), # Update with multiple properties including dpcode - state should change diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py index e0d09418bc4..e4abcaa293d 100644 --- a/tests/components/tuya/test_siren.py +++ b/tests/components/tuya/test_siren.py @@ -46,8 +46,9 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( ("updates", "expected_state", "last_reported"), [ - # Update without dpcode - state should not change, last_reported stays at initial - ({"basic_wdr": False}, "off", "2024-01-01T00:00:00+00:00"), + # Update without dpcode - state should not change, last_reported stays + # at available_reported + ({"basic_wdr": False}, "off", "2024-01-01T00:00:20+00:00"), # Update with dpcode - state should change, last_reported advances ({"siren_switch": True}, "on", "2024-01-01T00:01:00+00:00"), # Update with multiple properties including dpcode - state should change diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index b6cdb560de2..8431bb9f07c 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -47,8 +47,9 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( ("updates", "expected_state", "last_reported"), [ - # Update without dpcode - state should not change, last_reported stays at initial - ({"countdown_1": 50}, "off", "2024-01-01T00:00:00+00:00"), + # Update without dpcode - state should not change, last_reported stays + # at available_reported + ({"countdown_1": 50}, "off", "2024-01-01T00:00:20+00:00"), # Update with dpcode - state should change, last_reported advances ({"switch": True}, "on", "2024-01-01T00:01:00+00:00"), # Update with multiple properties including dpcode - state should change diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py index 903f1b59efc..8791ae499e9 100644 --- a/tests/components/tuya/test_valve.py +++ b/tests/components/tuya/test_valve.py @@ -46,8 +46,9 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( ("updates", "expected_state", "last_reported"), [ - # Update without dpcode - state should not change, last_reported stays at initial - ({"battery_percentage": 50}, "open", "2024-01-01T00:00:00+00:00"), + # Update without dpcode - state should not change, last_reported stays + # at available_reported + ({"battery_percentage": 50}, "open", "2024-01-01T00:00:20+00:00"), # Update with dpcode - state should change, last_reported advances ({"switch_1": False}, "closed", "2024-01-01T00:01:00+00:00"), # Update with multiple properties including dpcode - state should change