Skip to content

Commit 4fae491

Browse files
epenetCopilot
andauthored
Add wrapper class for integer values in Tuya models (home-assistant#156039)
Co-authored-by: Copilot <[email protected]>
1 parent 36268ff commit 4fae491

File tree

3 files changed

+68
-93
lines changed

3 files changed

+68
-93
lines changed

homeassistant/components/tuya/models.py

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,16 @@ def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
120120
}
121121

122122

123-
@dataclass
124123
class DPCodeWrapper:
125124
"""Base DPCode wrapper.
126125
127126
Used as a common interface for referring to a DPCode, and
128127
access read conversion routines.
129128
"""
130129

131-
dpcode: str
130+
def __init__(self, dpcode: str) -> None:
131+
"""Init DPCodeWrapper."""
132+
self.dpcode = dpcode
132133

133134
def _read_device_status_raw(self, device: CustomerDevice) -> Any | None:
134135
"""Read the raw device status for the DPCode.
@@ -139,10 +140,9 @@ def _read_device_status_raw(self, device: CustomerDevice) -> Any | None:
139140

140141
def read_device_status(self, device: CustomerDevice) -> Any | None:
141142
"""Read the device value for the dpcode."""
142-
raise NotImplementedError("read_device_value must be implemented")
143+
raise NotImplementedError("read_device_status must be implemented")
143144

144145

145-
@dataclass
146146
class DPCodeBooleanWrapper(DPCodeWrapper):
147147
"""Simple wrapper for boolean values.
148148
@@ -156,11 +156,39 @@ def read_device_status(self, device: CustomerDevice) -> bool | None:
156156
return None
157157

158158

159-
@dataclass(kw_only=True)
160-
class DPCodeEnumWrapper(DPCodeWrapper):
159+
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
160+
"""Base DPCode wrapper with Type Information."""
161+
162+
DPTYPE: DPType
163+
type_information: T
164+
165+
def __init__(self, dpcode: str, type_information: T) -> None:
166+
"""Init DPCodeWrapper."""
167+
super().__init__(dpcode)
168+
self.type_information = type_information
169+
170+
@classmethod
171+
def find_dpcode(
172+
cls,
173+
device: CustomerDevice,
174+
dpcodes: str | DPCode | tuple[DPCode, ...],
175+
*,
176+
prefer_function: bool = False,
177+
) -> Self | None:
178+
"""Find and return a DPCodeTypeInformationWrapper for the given DP codes."""
179+
if type_information := find_dpcode( # type: ignore[call-overload]
180+
device, dpcodes, dptype=cls.DPTYPE, prefer_function=prefer_function
181+
):
182+
return cls(
183+
dpcode=type_information.dpcode, type_information=type_information
184+
)
185+
return None
186+
187+
188+
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
161189
"""Simple wrapper for EnumTypeData values."""
162190

163-
type_information: EnumTypeData
191+
DPTYPE = DPType.ENUM
164192

165193
def read_device_status(self, device: CustomerDevice) -> str | None:
166194
"""Read the device value for the dpcode.
@@ -174,20 +202,20 @@ def read_device_status(self, device: CustomerDevice) -> str | None:
174202
return raw_value
175203
return None
176204

177-
@classmethod
178-
def find_dpcode(
179-
cls,
180-
device: CustomerDevice,
181-
dpcodes: str | DPCode | tuple[DPCode, ...],
182-
*,
183-
prefer_function: bool = False,
184-
) -> Self | None:
185-
"""Find and return a DPCodeEnumWrapper for the given DP codes."""
186-
if enum_type := find_dpcode(
187-
device, dpcodes, dptype=DPType.ENUM, prefer_function=prefer_function
188-
):
189-
return cls(dpcode=enum_type.dpcode, type_information=enum_type)
190-
return None
205+
206+
class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
207+
"""Simple wrapper for IntegerTypeData values."""
208+
209+
DPTYPE = DPType.INTEGER
210+
211+
def read_device_status(self, device: CustomerDevice) -> float | None:
212+
"""Read the device value for the dpcode.
213+
214+
Value will be scaled based on the Integer type information.
215+
"""
216+
if (raw_value := self._read_device_status_raw(device)) is None:
217+
return None
218+
return raw_value / (10**self.type_information.scale)
191219

192220

193221
@overload

homeassistant/components/tuya/number.py

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,9 @@
2323
TUYA_DISCOVERY_NEW,
2424
DeviceCategory,
2525
DPCode,
26-
DPType,
2726
)
2827
from .entity import TuyaEntity
29-
from .models import IntegerTypeData, find_dpcode
30-
from .util import ActionDPCodeNotFoundError
28+
from .models import DPCodeIntegerWrapper, IntegerTypeData
3129

3230
NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
3331
DeviceCategory.BH: (
@@ -456,9 +454,13 @@ def async_discover_device(device_ids: list[str]) -> None:
456454
device = manager.device_map[device_id]
457455
if descriptions := NUMBERS.get(device.category):
458456
entities.extend(
459-
TuyaNumberEntity(device, manager, description)
457+
TuyaNumberEntity(device, manager, description, dpcode_wrapper)
460458
for description in descriptions
461-
if description.key in device.status
459+
if (
460+
dpcode_wrapper := DPCodeIntegerWrapper.find_dpcode(
461+
device, description.key, prefer_function=True
462+
)
463+
)
462464
)
463465

464466
async_add_entities(entities)
@@ -480,21 +482,19 @@ def __init__(
480482
device: CustomerDevice,
481483
device_manager: Manager,
482484
description: NumberEntityDescription,
485+
dpcode_wrapper: DPCodeIntegerWrapper,
483486
) -> None:
484487
"""Init Tuya sensor."""
485488
super().__init__(device, device_manager)
486489
self.entity_description = description
487490
self._attr_unique_id = f"{super().unique_id}{description.key}"
491+
self._dpcode_wrapper = dpcode_wrapper
488492

489-
if int_type := find_dpcode(
490-
self.device, description.key, dptype=DPType.INTEGER, prefer_function=True
491-
):
492-
self._number = int_type
493-
self._attr_native_max_value = self._number.max_scaled
494-
self._attr_native_min_value = self._number.min_scaled
495-
self._attr_native_step = self._number.step_scaled
496-
if description.native_unit_of_measurement is None:
497-
self._attr_native_unit_of_measurement = int_type.unit
493+
self._attr_native_max_value = dpcode_wrapper.type_information.max_scaled
494+
self._attr_native_min_value = dpcode_wrapper.type_information.min_scaled
495+
self._attr_native_step = dpcode_wrapper.type_information.step_scaled
496+
if description.native_unit_of_measurement is None:
497+
self._attr_native_unit_of_measurement = dpcode_wrapper.type_information.unit
498498

499499
# Logic to ensure the set device class and API received Unit Of Measurement
500500
# match Home Assistants requirements.
@@ -538,26 +538,17 @@ def __init__(
538538
@property
539539
def native_value(self) -> float | None:
540540
"""Return the entity value to represent the entity state."""
541-
# Unknown or unsupported data type
542-
if self._number is None:
543-
return None
544-
545-
# Raw value
546-
if (value := self.device.status.get(self.entity_description.key)) is None:
547-
return None
548-
549-
return self._number.scale_value(value)
541+
return self._dpcode_wrapper.read_device_status(self.device)
550542

551543
def set_native_value(self, value: float) -> None:
552544
"""Set new value."""
553-
if self._number is None:
554-
raise ActionDPCodeNotFoundError(self.device, self.entity_description.key)
555-
556545
self._send_command(
557546
[
558547
{
559-
"code": self.entity_description.key,
560-
"value": self._number.scale_value_back(value),
548+
"code": self._dpcode_wrapper.dpcode,
549+
"value": (
550+
self._dpcode_wrapper.type_information.scale_value_back(value)
551+
),
561552
}
562553
]
563554
)

tests/components/tuya/test_number.py

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
)
1616
from homeassistant.const import ATTR_ENTITY_ID, Platform
1717
from homeassistant.core import HomeAssistant
18-
from homeassistant.exceptions import ServiceValidationError
1918
from homeassistant.helpers import entity_registry as er
2019

2120
from . import initialize_entry
@@ -66,46 +65,3 @@ async def test_set_value(
6665
mock_manager.send_commands.assert_called_once_with(
6766
mock_device.id, [{"code": "delay_set", "value": 18}]
6867
)
69-
70-
71-
@pytest.mark.parametrize(
72-
"mock_device_code",
73-
["mal_gyitctrjj1kefxp2"],
74-
)
75-
async def test_set_value_no_function(
76-
hass: HomeAssistant,
77-
mock_manager: Manager,
78-
mock_config_entry: MockConfigEntry,
79-
mock_device: CustomerDevice,
80-
) -> None:
81-
"""Test set value when no function available."""
82-
83-
# Mock a device with delay_set in status but not in function or status_range
84-
mock_device.function.pop("delay_set")
85-
mock_device.status_range.pop("delay_set")
86-
87-
entity_id = "number.multifunction_alarm_arm_delay"
88-
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
89-
90-
state = hass.states.get(entity_id)
91-
assert state is not None, f"{entity_id} does not exist"
92-
with pytest.raises(ServiceValidationError) as err:
93-
await hass.services.async_call(
94-
NUMBER_DOMAIN,
95-
SERVICE_SET_VALUE,
96-
{
97-
ATTR_ENTITY_ID: entity_id,
98-
ATTR_VALUE: 18,
99-
},
100-
blocking=True,
101-
)
102-
assert err.value.translation_key == "action_dpcode_not_found"
103-
assert err.value.translation_placeholders == {
104-
"expected": "['delay_set']",
105-
"available": (
106-
"['alarm_delay_time', 'alarm_time', 'master_mode', 'master_state', "
107-
"'muffling', 'sub_admin', 'sub_class', 'switch_alarm_light', "
108-
"'switch_alarm_propel', 'switch_alarm_sound', 'switch_kb_light', "
109-
"'switch_kb_sound', 'switch_mode_sound']"
110-
),
111-
}

0 commit comments

Comments
 (0)