Просмотр исходного кода

feat(devices): add FrankEver position-controlled water valve (#5720)

Adds support for the FrankEver Smart Water Valve with product ID
`nzx0kku9d6eq59nt`.

The device uses DP 101 for the target position, DP 102 for position
feedback, and DP 1 as the enable/off control. This also adds valve
support
for separate target and current-position DPs so Home Assistant reports
the
position correctly.

Countdown on DP 9 and power-on state on DP 38 are retained.

`duplicates` reports a 100% DPS match with the Tellur TL331501 profile.
The difference is that the Tellur profile treats DP 102 as the writable
position and DP 101 as a separate maximum setting, while this device
uses
DP 101 as the writable target and DP 102 as read-only feedback.

Tested against a physical protocol 3.5 device. Commands and DP feedback
were confirmed. Automated tests cover 0%, 20%, 50%, and 100%, including
open, close, target position, current position, and the DP 1
interaction.

Validation:
- 418 tests passed
- Ruff checks and formatting passed
- YAML lint passed

The two reported test warnings are pre-existing AsyncMock warnings in
the
humidifier tests and are unrelated to this change.
Lars Tobias Skjong-Børsting 3 часов назад
Родитель
Сommit
bfe821dbd6

+ 1 - 0
custom_components/tuya_local/devices/README.md

@@ -810,6 +810,7 @@ to use it for other length timers.
 ### `valve`
 - **valve** (required, boolean or integer): a dp that reports the current state of the valve, and if not readonly, can also be used to set the state.  If a number, it should be a percentage between 0 and 100 indicating how far open the valve is.  If a boolean, it should indicate open (true) or closed (false).
 - **switch** (optional, boolean): if the valve dp is an integer, the valve may also have a boolean switch dp for closing and opening the valve without affecting the open valve position.
+- **current_position** (optional, number 0-100): a dp that reports the actual position when the writable **valve** dp is only a target position.
 
 ### `water_heater`
 - **current_temperature** (optional, number): a dp that reports the current water temperature.

+ 50 - 0
custom_components/tuya_local/devices/frankever_watervalve.yaml

@@ -0,0 +1,50 @@
+name: Motorized water valve
+products:
+  - id: nzx0kku9d6eq59nt
+    manufacturer: FrankEver
+    name: Smart Water Valve
+entities:
+  - entity: valve
+    class: water
+    dps:
+      - id: 101
+        type: integer
+        name: valve
+        range:
+          min: 0
+          max: 100
+        mapping:
+          - step: 10
+      - id: 102
+        type: integer
+        name: current_position
+        range:
+          min: 0
+          max: 100
+      - id: 1
+        type: boolean
+        name: switch
+  - entity: time
+    translation_key: timer
+    category: config
+    dps:
+      - id: 9
+        type: integer
+        name: second
+        range:
+          min: 0
+          max: 86400
+  - entity: select
+    translation_key: initial_state
+    category: config
+    dps:
+      - id: 38
+        type: string
+        name: option
+        mapping:
+          - dps_val: "off"
+            value: "off"
+          - dps_val: "on"
+            value: "on"
+          - dps_val: memory
+            value: memory

+ 5 - 3
custom_components/tuya_local/valve.py

@@ -42,6 +42,7 @@ class TuyaLocalValve(TuyaLocalEntity, ValveEntity):
         dps_map = self._init_begin(device, config)
         self._valve_dp = dps_map.pop("valve")
         self._switch_dp = dps_map.pop("switch", None)
+        self._current_position_dp = dps_map.pop("current_position", None)
         self._init_end(dps_map)
 
         if not self._valve_dp.readonly or self._switch_dp:
@@ -85,9 +86,10 @@ class TuyaLocalValve(TuyaLocalEntity, ValveEntity):
         )
 
     @property
-    def current_position(self):
+    def current_valve_position(self):
         """Report the position of the valve."""
-        pos = self._valve_dp.get_value(self._device)
+        position_dp = self._current_position_dp or self._valve_dp
+        pos = position_dp.get_value(self._device)
         if isinstance(pos, int):
             return pos
 
@@ -96,7 +98,7 @@ class TuyaLocalValve(TuyaLocalEntity, ValveEntity):
         """Report whether the valve is closed."""
         if self._switch_dp and self._switch_dp.get_value(self._device) is False:
             return True
-        pos = self._valve_dp.get_value(self._device)
+        pos = self.current_valve_position
         return not pos
 
     async def async_open_valve(self):

+ 1 - 1
tests/test_device_config.py

@@ -293,7 +293,7 @@ KNOWN_DPS = {
     },
     "valve": {
         "required": ["valve"],
-        "optional": ["switch"],
+        "optional": ["switch", "current_position"],
     },
     "water_heater": {
         "required": [],

+ 112 - 0
tests/test_valve.py

@@ -3,6 +3,11 @@
 from unittest.mock import AsyncMock, Mock
 
 import pytest
+from homeassistant.components.valve import (
+    ValveEntityFeature,
+    ValveState,
+)
+from homeassistant.components.valve.const import ValveEntityStateAttribute
 from pytest_homeassistant_custom_component.common import MockConfigEntry
 
 from custom_components.tuya_local.const import (
@@ -11,8 +16,29 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
+from custom_components.tuya_local.helpers.device_config import get_config
 from custom_components.tuya_local.valve import TuyaLocalValve, async_setup_entry
 
+from .helpers import assert_device_properties_set, mock_device
+
+FRANKEVER_DPS = {
+    "1": True,
+    "9": 0,
+    "38": "memory",
+    "101": 100,
+    "102": 100,
+}
+
+
+def _make_frankever_valve(mocker, dps=None):
+    """Create a valve using the FrankEver position-controlled profile."""
+    config = get_config("frankever_watervalve")
+    entity_config = next(
+        entity for entity in config.all_entities() if entity.entity == "valve"
+    )
+    device = mock_device(dps or FRANKEVER_DPS, mocker)
+    return TuyaLocalValve(device, entity_config), device
+
 
 @pytest.mark.asyncio
 async def test_init_entry(hass):
@@ -66,6 +92,92 @@ async def test_init_entry_fails_if_device_has_no_valve(hass):
     m_add_entities.assert_not_called()
 
 
+@pytest.mark.parametrize(
+    ("position", "expected_state"),
+    [
+        (0, ValveState.CLOSED),
+        (20, ValveState.OPEN),
+        (100, ValveState.OPEN),
+    ],
+)
+def test_separate_current_position(mocker, position, expected_state):
+    """Current position is reported by the read-only feedback DP."""
+    dps = {**FRANKEVER_DPS, "101": position, "102": position}
+    valve, _ = _make_frankever_valve(mocker, dps)
+
+    assert valve.current_valve_position == position
+    assert valve.is_closed is (position == 0)
+    assert valve.state == expected_state
+    assert valve.state_attributes == {
+        ValveEntityStateAttribute.IS_CLOSED: position == 0,
+        ValveEntityStateAttribute.CURRENT_POSITION: position,
+    }
+    assert valve.supported_features == (
+        ValveEntityFeature.OPEN
+        | ValveEntityFeature.CLOSE
+        | ValveEntityFeature.SET_POSITION
+    )
+
+
+@pytest.mark.asyncio
+async def test_set_position_writes_target_only(mocker):
+    """Setting a target position does not change the separate switch."""
+    valve, device = _make_frankever_valve(mocker)
+
+    async with assert_device_properties_set(device, {"101": 50}):
+        await valve.async_set_valve_position(50)
+
+
+@pytest.mark.asyncio
+async def test_set_zero_writes_target_only(mocker):
+    """Setting a zero target position does not change the separate switch."""
+    valve, device = _make_frankever_valve(mocker)
+
+    async with assert_device_properties_set(device, {"101": 0}):
+        await valve.async_set_valve_position(0)
+
+
+@pytest.mark.asyncio
+async def test_open_uses_switch(mocker):
+    """Opening uses the switch without changing a non-zero target position."""
+    valve, device = _make_frankever_valve(mocker)
+
+    async with assert_device_properties_set(device, {"1": True}):
+        await valve.async_open_valve()
+
+
+@pytest.mark.asyncio
+async def test_close_uses_switch(mocker):
+    """Closing uses the switch without changing the target position."""
+    valve, device = _make_frankever_valve(mocker)
+
+    async with assert_device_properties_set(device, {"1": False}):
+        await valve.async_close_valve()
+
+
+def test_frankever_product_match_is_preferred():
+    """The explicit product match is stronger than the Tellur DPS match."""
+    frankever = get_config("frankever_watervalve")
+    tellur = get_config("tellur_tll331501_watervalve")
+    product_ids = ["nzx0kku9d6eq59nt"]
+
+    assert frankever.matches_product(product_ids[0])
+    assert frankever.match_quality(FRANKEVER_DPS, product_ids) == 101
+    assert tellur.match_quality(FRANKEVER_DPS) == 100
+
+
+def test_frankever_auxiliary_entity_values(mocker):
+    """Countdown and initial-state mappings retain their observed values."""
+    config = get_config("frankever_watervalve")
+    entities = {entity.entity: entity for entity in config.all_entities()}
+    device = mock_device(FRANKEVER_DPS, mocker)
+
+    assert entities["time"].find_dps("second").get_value(device) == 0
+    initial_state = entities["select"].find_dps("option")
+    assert initial_state.get_value(device) == "memory"
+    assert initial_state.values(device) == ["off", "on", "memory"]
+
+
 @pytest.mark.asyncio
 async def test_init_entry_fails_if_config_is_missing(hass):
     """Test initialisation when device has no matching entity"""