Преглед изворни кода

Add support for lawn_mower platform.

- Moebot S: deprecate vacuum entity and add lawn_mower as replacement

Issue #1068 (part 1)
Jason Rumney пре 2 година
родитељ
комит
6ccf24e88c

+ 61 - 14
custom_components/tuya_local/devices/moebot_s_mower.yaml

@@ -5,31 +5,30 @@ products:
   - id: icw5sal7xfcevsve
     name: Parkside PMRDA 20-Li A1
 primary_entity:
-  entity: vacuum
-  icon: "mdi:robot-mower"
+  entity: lawn_mower
   dps:
     - id: 101
-      name: status
+      name: activity
       type: string
       mapping:
         - dps_val: STANDBY
-          value: standby
+          value: docked
         - dps_val: MOWING
           value: mowing
         - dps_val: CHARGING
-          value: charging
+          value: docked
         - dps_val: EMERGENCY
-          value: manually stopped
+          value: error
         - dps_val: LOCKED
-          value: locked
+          value: docked
         - dps_val: PAUSED
           value: paused
         - dps_val: PARK
-          value: returning
+          value: paused
         - dps_val: CHARGING_WITH_TASK_SUSPEND
-          value: charging
+          value: docked
         - dps_val: FIXED_MOWING
-          value: spot mowing
+          value: mowing
         - dps_val: ERROR
           value: error
     - id: 115
@@ -38,15 +37,20 @@ primary_entity:
       optional: true
       mapping:
         - dps_val: StartMowing
-          value: start
+          value: start_mowing
         - dps_val: StartFixedMowing
-          value: clean_spot
+          value: start_mowing
+          hidden: true
         - dps_val: PauseWork
           value: pause
         - dps_val: CancelWork
-          value: stop
+          value: pause
+          hidden: true
         - dps_val: StartReturnStation
-          value: return_to_base
+          value: dock
+    - id: 101
+      name: raw_activity
+      type: string
     - id: 102
       name: error
       type: bitfield
@@ -75,6 +79,49 @@ primary_entity:
       name: auto_mode
       optional: true
 secondary_entities:
+  - entity: vacuum
+    icon: "mdi:robot-mower"
+    deprecated: lawn_mower
+    dps:
+      - id: 101
+        name: status
+        type: string
+        mapping:
+          - dps_val: STANDBY
+            value: standby
+          - dps_val: MOWING
+            value: mowing
+          - dps_val: CHARGING
+            value: charging
+          - dps_val: EMERGENCY
+            value: manually stopped
+          - dps_val: LOCKED
+            value: locked
+          - dps_val: PAUSED
+            value: paused
+          - dps_val: PARK
+            value: returning
+          - dps_val: CHARGING_WITH_TASK_SUSPEND
+            value: charging
+          - dps_val: FIXED_MOWING
+            value: spot mowing
+          - dps_val: ERROR
+            value: error
+      - id: 115
+        name: command
+        type: string
+        optional: true
+        mapping:
+          - dps_val: StartMowing
+            value: start
+          - dps_val: StartFixedMowing
+            value: clean_spot
+          - dps_val: PauseWork
+            value: pause
+          - dps_val: CancelWork
+            value: stop
+          - dps_val: StartReturnStation
+            value: return_to_base
   - entity: sensor
     class: battery
     dps:

+ 74 - 0
custom_components/tuya_local/lawn_mower.py

@@ -0,0 +1,74 @@
+"""
+Setup for different kinds of Tuya lawn mowers
+"""
+from homeassistant.components.lawn_mower import LawnMowerEntity
+from homeassistant.components.lawn_mower.const import (
+    LawnMowerActivity,
+    LawnMowerEntityFeature,
+    SERVICE_DOCK,
+    SERVICE_PAUSE,
+    SERVICE_START_MOWING,
+)
+
+from .device import TuyaLocalDevice
+from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "lawn_mower",
+        TuyaLocalLawnMower,
+    )
+
+
+class TuyaLocalLawnMower(TuyaLocalEntity, LawnMowerEntity):
+    """Representation of a Tuya Lawn Mower"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the lawn mower.
+        Args:
+            device (TuyaLocalDevice): the device API instance.
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        super().__init__()
+        dps_map = self._init_begin(device, config)
+        self._activity_dp = dps_map.pop("activity", None)
+        self._command_dp = dps_map.pop("command", None)
+        self._init_end(dps_map)
+
+        self._attr_supported_features = 0
+        if self._command_dp:
+            available_commands = self._command_dp.values(self._device)
+            if SERVICE_START_MOWING in available_commands:
+                self._attr_supported_features |= LawnMowerEntityFeature.START_MOWING
+            if SERVICE_PAUSE in available_commands:
+                self._attr_supported_features |= LawnMowerEntityFeature.PAUSE
+            if SERVICE_DOCK in available_commands:
+                self._attr_supported_features |= LawnMowerEntityFeature.DOCK
+
+    @property
+    def activity(self) -> LawnMowerActivity | None:
+        """Return the status of the lawn mower."""
+        return LawnMowerActivity(self._activity_dp.get_value(self._device))
+
+    async def async_start_mowing(self) -> None:
+        """Start mowing the lawn."""
+        if self._command_dp:
+            await self._command_dp.async_set_value(self._device, SERVICE_START_MOWING)
+
+    async def async_pause(self):
+        """Pause lawn mowing."""
+        if self._command_dp:
+            await self._command_dp.async_set_value(self._device, SERVICE_PAUSE)
+
+    async def async_dock(self):
+        """Stop mowing and return to dock."""
+        if self._command_dp:
+            await self._command_dp.async_set_value(self._device, SERVICE_DOCK)

+ 2 - 0
tests/devices/base_device_tests.py

@@ -16,6 +16,7 @@ from custom_components.tuya_local.helpers.device_config import (
     possible_matches,
 )
 from custom_components.tuya_local.humidifier import TuyaLocalHumidifier
+from custom_components.tuya_local.lawn_mower import TuyaLocalLawnMower
 from custom_components.tuya_local.light import TuyaLocalLight
 from custom_components.tuya_local.lock import TuyaLocalLock
 from custom_components.tuya_local.number import TuyaLocalNumber
@@ -36,6 +37,7 @@ DEVICE_TYPES = {
     "cover": TuyaLocalCover,
     "fan": TuyaLocalFan,
     "humidifier": TuyaLocalHumidifier,
+    "lawn_mower": TuyaLocalLawnMower,
     "light": TuyaLocalLight,
     "lock": TuyaLocalLock,
     "number": TuyaLocalNumber,

+ 88 - 2
tests/devices/test_moebot.py

@@ -1,9 +1,13 @@
 """
 Test MoeBot S mower.
-Primarily for testing the STOP command which this device is the first to use.
+Primarily for testing the STOP command which this device is the first to use,
+and the lawn_mower platform.
 """
 from homeassistant.components.vacuum import VacuumEntityFeature
-
+from homeassistant.components.lawn_mower.const import (
+    LawnMowerActivity,
+    LawnMowerEntityFeature,
+)
 from ..const import MOEBOT_PAYLOAD
 from ..helpers import assert_device_properties_set
 from .base_device_tests import TuyaDeviceTestCase
@@ -32,6 +36,7 @@ class TestMoebot(TuyaDeviceTestCase):
     def setUp(self):
         self.setUpForConfig("moebot_s_mower.yaml", MOEBOT_PAYLOAD)
         self.subject = self.entities.get("vacuum")
+        self.mower = self.entities.get("lawn_mower")
         self.mark_secondary(
             [
                 "binary_sensor_error",
@@ -58,6 +63,14 @@ class TestMoebot(TuyaDeviceTestCase):
                 | VacuumEntityFeature.STOP
             ),
         )
+        self.assertEqual(
+            self.mower.supported_features,
+            (
+                LawnMowerEntityFeature.START_MOWING
+                | LawnMowerEntityFeature.PAUSE
+                | LawnMowerEntityFeature.DOCK
+            ),
+        )
 
     async def test_async_stop(self):
         async with assert_device_properties_set(
@@ -65,3 +78,76 @@ class TestMoebot(TuyaDeviceTestCase):
             {COMMAND_DP: "CancelWork"},
         ):
             await self.subject.async_stop()
+
+    def test_lawnmower_activity(self):
+        self.dps[STATUS_DP] = "ERROR"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.ERROR
+        )
+        self.dps[STATUS_DP] = "EMERGENCY"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.ERROR
+        )
+        self.dps[STATUS_DP] = "PAUSED"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.PAUSED
+        )
+        self.dps[STATUS_DP] = "PARK"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.PAUSED
+        )
+        self.dps[STATUS_DP] = "MOWING"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.MOWING
+        )
+        self.dps[STATUS_DP] = "FIXED_MOWING"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.MOWING
+        )
+        self.dps[STATUS_DP] = "STANDBY"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.DOCKED
+        )
+        self.dps[STATUS_DP] = "CHARGING"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.DOCKED
+        )
+        self.dps[STATUS_DP] = "LOCKED"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.DOCKED
+        )
+        self.dps[STATUS_DP] = "CHARGING_WITH_TASK_SUSPEND"
+        self.assertEqual(
+            self.mower.activity,
+            LawnMowerActivity.DOCKED
+        )
+
+    async def test_async_start_mowing(self):
+        async with assert_device_properties_set(
+            self.mower._device,
+            {COMMAND_DP: "StartMowing"},
+        ):
+            await self.mower.async_start_mowing()
+
+    async def test_async_pause(self):
+        async with assert_device_properties_set(
+            self.mower._device,
+            {COMMAND_DP: "PauseWork"},
+        ):
+            await self.mower.async_pause()
+
+    async def test_async_dock(self):
+        async with assert_device_properties_set(
+            self.mower._device,
+            {COMMAND_DP: "StartReturnStation"},
+        ):
+            await self.mower.async_dock()

+ 93 - 0
tests/test_lawn_mower.py

@@ -0,0 +1,93 @@
+"""Tests for the lawn_mower entity."""
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.lawn_mower import TuyaLocalLawnMower, async_setup_entry
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "moebot_s_mower",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {
+            "device": m_device,
+        },
+    }
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["lawn_mower"]) == TuyaLocalLawnMower
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_device_has_no_lawn_mower(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "kogan_heater",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {
+            "device": m_device,
+        },
+    }
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_config_is_missing(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "non_existing",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()