diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 747a5676e00..481aeb6d296 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging from typing import Any, Self from tuya_sharing import CustomerDevice +from homeassistant.components.sensor import SensorStateClass + from .type_information import ( BitmapTypeInformation, BooleanTypeInformation, @@ -17,12 +20,15 @@ from .type_information import ( TypeInformation, ) +_LOGGER = logging.getLogger(__name__) + class DeviceWrapper[T]: """Base device wrapper.""" native_unit: str | None = None suggested_unit: str | None = None + state_class: SensorStateClass | None = None max_value: float min_value: float @@ -30,6 +36,13 @@ class DeviceWrapper[T]: options: list[str] + def initialize(self, device: CustomerDevice) -> None: + """Initialize the wrapper with device data. + + Called when the entity is added to Home Assistant. + Override in subclasses to perform initialization logic. + """ + def skip_update( self, device: CustomerDevice, @@ -210,6 +223,59 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeInformation]) ) +class DPCodeDeltaIntegerWrapper(DPCodeIntegerWrapper): + """Wrapper for integer values with delta report accumulation. + + This wrapper handles sensors that report incremental (delta) values + instead of cumulative totals. It accumulates the delta values locally + to provide a running total. + """ + + _accumulated_value: float = 0 + _last_dp_timestamp: int | None = None + + def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: + """Init DPCodeDeltaIntegerWrapper.""" + super().__init__(dpcode, type_information) + # Delta reports use TOTAL_INCREASING state class + self.state_class = SensorStateClass.TOTAL_INCREASING + + def skip_update( + self, + device: CustomerDevice, + updated_status_properties: list[str] | None, + dp_timestamps: dict[str, int] | None, + ) -> bool: + """Override skip_update to process delta updates. + + Processes delta accumulation before determining if update should be skipped. + """ + if ( + super().skip_update(device, updated_status_properties, dp_timestamps) + or dp_timestamps is None + or (current_timestamp := dp_timestamps.get(self.dpcode)) is None + or current_timestamp == self._last_dp_timestamp + or (raw_value := super().read_device_status(device)) is None + ): + return True + + delta = float(raw_value) + self._accumulated_value += delta + _LOGGER.debug( + "Delta update for %s: +%s, total: %s", + self.dpcode, + delta, + self._accumulated_value, + ) + + self._last_dp_timestamp = current_timestamp + return False + + def read_device_status(self, device: CustomerDevice) -> float | None: + """Read device status, returning accumulated value for delta reports.""" + return self._accumulated_value + + class DPCodeRawWrapper(DPCodeTypeInformationWrapper[RawTypeInformation]): """Wrapper to extract information from a RAW/binary value.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 0c4a120c5cf..595ad82e48d 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -40,6 +40,7 @@ from .const import ( from .entity import TuyaEntity from .models import ( DeviceWrapper, + DPCodeDeltaIntegerWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper, DPCodeJsonWrapper, @@ -48,7 +49,7 @@ from .models import ( DPCodeWrapper, ) from .raw_data_models import ElectricityData -from .type_information import EnumTypeInformation +from .type_information import EnumTypeInformation, IntegerTypeInformation class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]): @@ -1739,11 +1740,13 @@ def _get_dpcode_wrapper( return wrapper return None - for cls in (DPCodeIntegerWrapper, DPCodeEnumWrapper): - if wrapper := cls.find_dpcode(device, dpcode): - return wrapper + # Check for integer type first, using delta wrapper only for sum report_type + if type_information := IntegerTypeInformation.find_dpcode(device, dpcode): + if type_information.report_type == "sum": + return DPCodeDeltaIntegerWrapper(type_information.dpcode, type_information) + return DPCodeIntegerWrapper(type_information.dpcode, type_information) - return None + return DPCodeEnumWrapper.find_dpcode(device, dpcode) async def async_setup_entry( @@ -1798,6 +1801,8 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit if description.suggested_unit_of_measurement is None: self._attr_suggested_unit_of_measurement = dpcode_wrapper.suggested_unit + if description.state_class is None: + self._attr_state_class = dpcode_wrapper.state_class self._validate_device_class_unit() diff --git a/homeassistant/components/tuya/type_information.py b/homeassistant/components/tuya/type_information.py index ff06b22c28b..a3a2122c055 100644 --- a/homeassistant/components/tuya/type_information.py +++ b/homeassistant/components/tuya/type_information.py @@ -54,7 +54,9 @@ class TypeInformation[T]: return raw_value @classmethod - def _from_json(cls, dpcode: str, type_data: str) -> Self | None: + def _from_json( + cls, dpcode: str, type_data: str, *, report_type: str | None + ) -> Self | None: """Load JSON string and return a TypeInformation object.""" return cls(dpcode=dpcode, type_data=type_data) @@ -80,13 +82,18 @@ class TypeInformation[T]: ) for dpcode in dpcodes: + report_type = ( + sr.report_type if (sr := device.status_range.get(dpcode)) else None + ) for device_specs in lookup_tuple: if ( (current_definition := device_specs.get(dpcode)) and parse_dptype(current_definition.type) is cls._DPTYPE and ( type_information := cls._from_json( - dpcode=dpcode, type_data=current_definition.values + dpcode=dpcode, + type_data=current_definition.values, + report_type=report_type, ) ) ): @@ -104,7 +111,9 @@ class BitmapTypeInformation(TypeInformation[int]): label: list[str] @classmethod - def _from_json(cls, dpcode: str, type_data: str) -> Self | None: + def _from_json( + cls, dpcode: str, type_data: str, *, report_type: str | None + ) -> Self | None: """Load JSON string and return a BitmapTypeInformation object.""" if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): return None @@ -177,7 +186,9 @@ class EnumTypeInformation(TypeInformation[str]): return raw_value @classmethod - def _from_json(cls, dpcode: str, type_data: str) -> Self | None: + def _from_json( + cls, dpcode: str, type_data: str, *, report_type: str | None + ) -> Self | None: """Load JSON string and return an EnumTypeInformation object.""" if not (parsed := json_loads_object(type_data)): return None @@ -199,6 +210,7 @@ class IntegerTypeInformation(TypeInformation[float]): scale: int step: int unit: str | None = None + report_type: str | None def scale_value(self, value: int) -> float: """Scale a value.""" @@ -234,7 +246,9 @@ class IntegerTypeInformation(TypeInformation[float]): return raw_value / (10**self.scale) @classmethod - def _from_json(cls, dpcode: str, type_data: str) -> Self | None: + def _from_json( + cls, dpcode: str, type_data: str, *, report_type: str | None + ) -> Self | None: """Load JSON string and return an IntegerTypeInformation object.""" if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): return None @@ -247,6 +261,7 @@ class IntegerTypeInformation(TypeInformation[float]): scale=int(parsed["scale"]), step=int(parsed["step"]), unit=parsed.get("unit"), + report_type=report_type, ) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index a7752b52859..80fa925b4d0 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -29,6 +29,7 @@ class MockDeviceListener(DeviceListener): hass: HomeAssistant, device: CustomerDevice, updated_status_properties: dict[str, Any] | None = None, + dp_timestamps: dict[str, int] | None = None, ) -> None: """Mock update device method.""" property_list: list[str] = [] @@ -40,7 +41,7 @@ class MockDeviceListener(DeviceListener): ) device.status[key] = value property_list.append(key) - self.update_device(device, property_list) + self.update_device(device, property_list, dp_timestamps) await hass.async_block_till_done() diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 7366b0c16ce..50027413d56 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -8861,7 +8861,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.084', + 'state': '0', }) # --- # name: test_platform_setup_and_discovery[sensor.ha_socket_delta_test_voltage-entry] diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index ae79efd4a86..39b8c1e4121 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -10,6 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice, Manager +from homeassistant.components.sensor import SensorStateClass from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -81,3 +82,106 @@ async def test_selective_state_update( expected_state=expected_state, last_reported=last_reported, ) + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.parametrize("mock_device_code", ["cz_guitoc9iylae4axs"]) +async def test_delta_report_sensor( + hass: HomeAssistant, + mock_manager: Manager, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + mock_listener: MockDeviceListener, +) -> None: + """Test delta report sensor behavior.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + entity_id = "sensor.ha_socket_delta_test_total_energy" + timestamp = 1000 + + # Delta sensors start from zero and accumulate values + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "0" + assert state.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING + + # Send delta update + await mock_listener.async_send_device_update( + hass, + mock_device, + {"add_ele": 200}, + {"add_ele": timestamp}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == pytest.approx(0.2) + + # Send delta update (multiple dpcode) + timestamp += 100 + await mock_listener.async_send_device_update( + hass, + mock_device, + {"add_ele": 300, "switch_1": True}, + {"add_ele": timestamp, "switch_1": timestamp}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == pytest.approx(0.5) + + # Send delta update (timestamp not incremented) + await mock_listener.async_send_device_update( + hass, + mock_device, + {"add_ele": 500}, + {"add_ele": timestamp}, # same timestamp + ) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == pytest.approx(0.5) # unchanged + + # Send delta update (unrelated dpcode) + await mock_listener.async_send_device_update( + hass, + mock_device, + {"switch_1": False}, + {"switch_1": timestamp + 100}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == pytest.approx(0.5) # unchanged + + # Send delta update + timestamp += 100 + await mock_listener.async_send_device_update( + hass, + mock_device, + {"add_ele": 100}, + {"add_ele": timestamp}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == pytest.approx(0.6) + + # Send delta update (None value) + timestamp += 100 + mock_device.status["add_ele"] = None + await mock_listener.async_send_device_update( + hass, + mock_device, + {"add_ele": None}, + {"add_ele": timestamp}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == pytest.approx(0.6) # unchanged + + # Send delta update (no timestamp - skipped) + mock_device.status["add_ele"] = 200 + await mock_listener.async_send_device_update( + hass, + mock_device, + {"add_ele": 200}, + None, + ) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == pytest.approx(0.6) # unchanged