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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user