1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add delta report type support for Tuya sensors (#160285)

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
abelyliu
2026-01-23 14:58:32 +08:00
committed by GitHub
parent be373a76a7
commit c208b06c6a
6 changed files with 203 additions and 12 deletions

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -8861,7 +8861,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.084',
'state': '0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.ha_socket_delta_test_voltage-entry]

View File

@@ -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