Răsfoiți Sursa

Add support for Kyvol E30 vacuum

- support for fan_speed added to vacuum platform

Issue #124
Jason Rumney 4 ani în urmă
părinte
comite
6e80c810de

+ 2 - 0
ACKNOWLEDGEMENTS.md

@@ -75,4 +75,6 @@ Further device support has been made with the assistance of users.  Please consi
  - [andreq](https://github.com/andreq) for assistance with Inkbird ITC-308 thermostats.
  - [dlosito](https://github.com/dlosito) for assistance with a second variant of Awow TH213 thermostat.
  - [UrZdcw9](https://github.com/UrZdcw9) for assistance with Arlec ceiling fan with light.
+ - [dlosito](https://github.com/dlosito) for assistance with Lefant M213 vacuum cleaners.
+- [kramttocs](https://github.com/kramttocs) for assistance with Kyvol E30 vacuum cleaners.
  

+ 5 - 0
README.md

@@ -132,6 +132,7 @@ the device will not work despite being listed below.
 - Mirabella Genio Smart plug with USB
 - Grid Connect double outlet with Energy Monitoring, Master and Individual switches and Child Lock.
 - DIGOO DG-SP202 dual smartplug with energy monitoring and timers.
+- DIGOO DG-SP01 USB smartplug with night light.
 - Grid Connect double outlet wall socket
 - Woox R4028/DIGOO DG-PS01 3 outlet + USB powerstrip with individual timers.
 Other brands may work with the above configurations
@@ -142,6 +143,10 @@ Other brands may work with the above configurations
 ### Covers
 - Simple Garage Door
 
+### Vacuum Cleaners
+- Lefant M213 Vacuum Cleaner
+- Kyvol E30 Vacuum Cleaner
+
 ### Miscellaneous
 - Qoto 03 Smart Water Valve / Sprinkler Controller
 

+ 194 - 0
custom_components/tuya_local/devices/kyvol_e30_vacuum.yaml

@@ -0,0 +1,194 @@
+name: Kyvol E30 Vacuum
+primary_entity:
+  entity: vacuum
+  dps:
+    - id: 1
+      type: boolean
+      name: power
+    - id: 2
+      type: boolean
+      name: activate
+    - id: 3
+      type: string
+      name: status
+      mapping:
+        - dps_val: standby
+          value: standby
+        - dps_val: random
+          value: random
+        - dps_val: smart
+          value: smart
+        - dps_val: wall_follow
+          value: wall_follow
+        - dps_val: mop
+          value: mop
+        - dps_val: spiral
+          value: clean_spot
+        - dps_val: left_spiral
+          value: left_spiral
+        - dps_val: right_spiral
+          value: right_spiral
+        - dps_val: bow
+          value: bow
+        - dps_val: left_bow
+          value: left_bow
+        - dps_val: right_bow
+          value: right_bow
+        - dps_val: partial_bow
+          value: partial_bow
+        - dps_val: chargego
+          value: return_to_base
+        - dps_val: single
+          value: single
+        - dps_val: zone
+          value: zone
+        - dps_val: pose
+          value: pose
+        - dps_val: point
+          value: point
+        - dps_val: part
+          value: part
+        - dps_val: pick_zone
+          value: pick_zone
+    - id: 4
+      type: string
+      name: direction_control
+      mapping:
+        - dps_val: forward
+          value: forward
+        - dps_val: backward
+          value: reverse
+        - dps_val: turn_left
+          value: left
+        - dps_val: turn_right
+          value: right
+        - dps_val: stop
+          value: stop
+    - id: 6
+      name: battery
+      type: integer
+      readonly: true
+    - id: 13
+      type: boolean
+      name: locate
+    - id: 14
+      type: string
+      name: fan_speed
+      mapping:
+        - dps_val: strong
+          value: strong
+        - dps_val: normal
+          value: normal
+        - dps_val: quiet
+          value: quiet
+        - dps_val: gentle
+          value: gentle
+        - dps_val: closed
+          value: closed
+#    - id: 15
+#      type: string
+#      name: clean_record
+#      readonly: true
+#    - id: 16
+#      type: integer
+#      name: clean_area
+#      unit: m2
+    - id: 18
+      type: bitfield
+      name: error
+      mapping:
+        - dps_val: 1
+          value: edge_sweep
+        - dps_val: 2
+          value: middle_sweep
+        - dps_val: 4
+          value: left_wheel
+        - dps_val: 8
+          value: right_wheel
+        - dps_val: 16
+          value: garbage_box
+        - dps_val: 32
+          value: land_check
+        - dps_val: 64
+          value: collision
+    - id: 101
+      type: string
+      name: unknown_101
+    - id: 102
+      type: string
+      name: unknown_102
+    - id: 104
+      type: string
+      name: unknown_104
+    - id: 107
+      type: integer
+      name: unknown_107
+secondary_entities:
+  - entity: sensor
+    name: Clean Time
+    category: diagnostic
+    icon: "mdi:clock-outline"
+    dps:
+      - id: 17
+        type: integer
+        name: sensor
+        unit: min
+  - entity: switch
+    name: Edge Brush Reset
+    category: config
+    icon: "mdi:arrow-expand-all"
+    dps:
+      - id: 10
+        type: boolean
+        name: switch
+  - entity: switch
+    name: Roll Brush Reset
+    icon: "mdi:refresh-circle"
+    category: config
+    dps:
+      - id: 11
+        type: boolean
+        name: switch
+  - entity: switch
+    name: Filter Reset
+    category: config
+    icon: "mdi:air-filter"
+    dps:
+      - id: 12
+        type: boolean
+        name: switch
+  - entity: sensor
+    name: Edge Brush
+    category: diagnostic
+    icon: "mdi-arrow-expand-all"
+    dps:
+      - id: 7
+        type: integer
+        name: sensor
+        unit: "%"
+  - entity: sensor
+    name: Roll Brush
+    category: diagnostic
+    icon: "mdi:circle"
+    dps:
+      - id: 8
+        type: integer
+        name: sensor
+        unit: "%"
+  - entity: sensor
+    name: Filter
+    category: diagnostic
+    icon: "mdi:air-filter"
+    dps:
+      - id: 9
+        type: integer
+        name: sensor
+        unit: "%"
+  - entity: sensor
+    name: Status
+    category: diagnostic
+    icon: "mdi:robot-vacuum"
+    dps:
+      - id: 5
+        type: string
+        name: sensor

+ 22 - 0
custom_components/tuya_local/generic/vacuum.py

@@ -9,6 +9,7 @@ from homeassistant.components.vacuum import (
     STATE_RETURNING,
     STATE_ERROR,
     SUPPORT_BATTERY,
+    SUPPORT_FAN_SPEED,
     SUPPORT_CLEAN_SPOT,
     SUPPORT_LOCATE,
     SUPPORT_PAUSE,
@@ -44,6 +45,7 @@ class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
         self._battery_dps = dps_map.pop("battery", None)
         self._direction_dps = dps_map.get("direction_control")
         self._error_dps = dps_map.get("error")
+        self._fan_dps = dps_map.pop("fan_speed", None)
 
         if self._status_dps is None:
             raise AttributeError(f"{config.name} is missing a status dps")
@@ -55,12 +57,15 @@ class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
         support = SUPPORT_STATE | SUPPORT_STATUS | SUPPORT_SEND_COMMAND
         if self._battery_dps:
             support |= SUPPORT_BATTERY
+        if self._fan_dps:
+            support |= SUPPORT_FAN_SPEED
         if self._power_dps:
             support |= SUPPORT_TURN_ON | SUPPORT_TURN_OFF
         if self._active_dps:
             support |= SUPPORT_START | SUPPORT_PAUSE
         if self._locate_dps:
             support |= SUPPORT_LOCATE
+
         status_support = self._status_dps.values(self._device)
         if SERVICE_RETURN_TO_BASE in status_support:
             support |= SUPPORT_RETURN_HOME
@@ -151,3 +156,20 @@ class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
             self._device
         ):
             await self._direction_dps.async_set_value(self._device, command)
+
+    @property
+    def fan_speed_list(self):
+        """Return the list of fan speeds supported"""
+        if self._fan_dps:
+            return self._fan_dps.values(self._device)
+
+    @property
+    def fan_speed(self):
+        """Return the current fan speed"""
+        if self._fan_dps:
+            return self._fan_dps.get_value(self._device)
+
+    async def async_set_fan_speed(self, speed, **kwargs):
+        """Set the fan speed of the vacuum."""
+        if self._fan_dps:
+            await self._fan_dps.async_set_value(self._device, speed)

+ 12 - 0
custom_components/tuya_local/translations/en.json

@@ -106,6 +106,9 @@
                     "sensor_charcoal_filter_life": "Include charcoal filter life as a sensor entity",
 		    "sensor_clean_area": "Include clean area as a sensor entity",
 		    "sensor_clean_time": "Include clean time as a sensor entity",
+		    "sensor_filter": "Include filter as a sensor entity",
+		    "sensor_edge_brush": "Include edge brush as a sensor entity",
+		    "sensor_roll_brush": "Include roll brush as a sensor entity",
                     "sensor_current": "Include current as a sensor entity",
                     "sensor_current_humidity": "Include current humidity as a sensor entity",
                     "sensor_current_temperature": "Include current temperature as a sensor entity",
@@ -118,6 +121,7 @@
                     "sensor_power_level": "Include power level as a sensor entity",
                     "sensor_pm2_5": "Include PM2.5 as a sensor entity",
                     "sensor_prefilter_life": "Include prefilter life as a sensor entity",
+		    "sensor_status": "Include status as a sensor entity",
                     "sensor_timer": "Include time remaining as a sensor entity",
                     "sensor_voltage": "Include voltage as a sensor entity",
                     "sensor_ambient_temperature": "Include ambient temperature as a sensor entity",
@@ -132,6 +136,8 @@
                     "switch_adaptive": "Include adaptive as a switch entity",
                     "switch_air_clean": "Include air clean as a switch entity",
                     "switch_anti_frost": "Include anti-frost as a switch entity",
+		    "switch_edge_brush_reset": "Include edge brush reset as a switch entity",
+		    "switch_roll_brush_reset": "Include roll brush reset as a switch entity",
 		    "switch_energy_reset": "Include energy reset as a switch entity",
 		    "switch_factory_reset": "Include factory reset as a switch entity",
                     "switch_filter_reset": "Include filter reset as a switch entity",
@@ -253,6 +259,9 @@
                     "sensor_charcoal_filter_life": "Include charcoal filter life as a sensor entity",
 		    "sensor_clean_area": "Include clean area as a sensor entity",
 		    "sensor_clean_time": "Include clean time as a sensor entity",
+		    "sensor_filter": "Include filter as a sensor entity",
+		    "sensor_edge_brush": "Include edge brush as a sensor entity",
+		    "sensor_roll_brush": "Include roll brush as a sensor entity",
                     "sensor_current": "Include current as a sensor entity",
                     "sensor_current_humidity": "Include current humidity as a sensor entity",
                     "sensor_current_temperature": "Include current temperature as a sensor entity",
@@ -265,6 +274,7 @@
                     "sensor_power_level": "Include power level as a sensor entity",
                     "sensor_pm2_5": "Include PM2.5 as a sensor entity",
                     "sensor_prefilter_life": "Include prefilter life as a sensor entity",
+		    "sensor_status": "Include status as a sensor entity",
                     "sensor_timer": "Include time remaining as a sensor entity",
                     "sensor_voltage": "Include voltage as a sensor entity",
                     "sensor_ambient_temperature": "Include ambient temperature as a sensor entity",
@@ -279,6 +289,8 @@
                     "switch_adaptive": "Include adaptive as a switch entity",
                     "switch_air_clean": "Include air clean as a switch entity",
                     "switch_anti_frost": "Include anti-frost as a switch entity",
+		    "switch_edge_brush_reset": "Include edge brush reset as a switch entity",
+		    "switch_roll_brush_reset": "Include roll brush reset as a switch entity",
 		    "switch_energy_reset": "Include energy reset as a switch entity",
 		    "switch_factory_reset": "Include factory reset as a switch entity",
                     "switch_filter_reset": "Include filter reset as a switch entity",

+ 20 - 19
hacs.json

@@ -1,19 +1,20 @@
-{
-  "name": "Tuya Local",
-  "render_readme": true,
-    "domains": [
-	"binary_sensor",
-	"climate",
-	"cover",
-	"fan",
-	"humidifier",
-	"light",
-	"lock",
-	"number",
-	"select",
-	"sensor",
-	"switch"
-    ],
-  "homeassistant": "2021.10.0",
-  "iot_class": "Local Polling"
-}
+{
+  "name": "Tuya Local",
+  "render_readme": true,
+    "domains": [
+	"binary_sensor",
+	"climate",
+	"cover",
+	"fan",
+	"humidifier",
+	"light",
+	"lock",
+	"number",
+	"select",
+	"sensor",
+	"switch",
+	"vacuum"
+    ],
+  "homeassistant": "2021.10.0",
+  "iot_class": "Local Polling"
+}

+ 23 - 0
tests/const.py

@@ -984,3 +984,26 @@ LEFANT_M213_VACUUM_PAYLOAD = {
     "106": "ChargeStage:DETSWITCGH",
     "108": "BatVol:13159",
 }
+
+KYVOL_E30_VACUUM_PAYLOAD = {
+    "1": True,
+    "2": False,
+    "3": "standby",
+    "4": "stop",
+    "5": "Charging_Base",
+    "6": 2,
+    "7": 20,
+    "8": 60,
+    "9": 20,
+    "10": False,
+    "11": False,
+    "12": False,
+    "13": False,
+    "14": "3",
+    "17": 0,
+    "18": 0,
+    "101": "2",
+    "102": "900234",
+    "104": "standby",
+    "107": 1,
+}

+ 301 - 0
tests/devices/test_kyvol_e30_vacuum.py

@@ -0,0 +1,301 @@
+from homeassistant.components.vacuum import (
+    STATE_CLEANING,
+    STATE_DOCKED,
+    STATE_ERROR,
+    STATE_RETURNING,
+    SUPPORT_BATTERY,
+    SUPPORT_CLEAN_SPOT,
+    SUPPORT_FAN_SPEED,
+    SUPPORT_LOCATE,
+    SUPPORT_PAUSE,
+    SUPPORT_RETURN_HOME,
+    SUPPORT_SEND_COMMAND,
+    SUPPORT_START,
+    SUPPORT_STATE,
+    SUPPORT_STATUS,
+    SUPPORT_TURN_OFF,
+    SUPPORT_TURN_ON,
+)
+from homeassistant.const import (
+    TIME_MINUTES,
+    PERCENTAGE,
+)
+
+from ..const import KYVOL_E30_VACUUM_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.sensor import MultiSensorTests
+from ..mixins.switch import MultiSwitchTests
+from .base_device_tests import TuyaDeviceTestCase
+
+POWER_DPS = "1"
+SWITCH_DPS = "2"
+COMMAND_DPS = "3"
+DIRECTION_DPS = "4"
+STATUS_DPS = "5"
+BATTERY_DPS = "6"
+EDGE_DPS = "7"
+ROLL_DPS = "8"
+FILTER_DPS = "9"
+RSTEDGE_DPS = "10"
+RSTROLL_DPS = "11"
+RSTFILTER_DPS = "12"
+LOCATE_DPS = "13"
+FAN_DPS = "14"
+TIME_DPS = "17"
+ERROR_DPS = "18"
+UNKNOWN101_DPS = "101"
+UNKNOWN102_DPS = "102"
+UNKNOWN104_DPS = "104"
+UNKNOWN106_DPS = "107"
+
+
+class TestKyvolE30Vacuum(MultiSensorTests, MultiSwitchTests, TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("kyvol_e30_vacuum.yaml", KYVOL_E30_VACUUM_PAYLOAD)
+        self.subject = self.entities.get("vacuum")
+        self.setUpMultiSensors(
+            [
+                {
+                    "dps": TIME_DPS,
+                    "name": "sensor_clean_time",
+                    "unit": TIME_MINUTES,
+                },
+                {
+                    "dps": EDGE_DPS,
+                    "name": "sensor_edge_brush",
+                    "unit": PERCENTAGE,
+                },
+                {
+                    "dps": ROLL_DPS,
+                    "name": "sensor_roll_brush",
+                    "unit": PERCENTAGE,
+                },
+                {
+                    "dps": FILTER_DPS,
+                    "name": "sensor_filter",
+                    "unit": PERCENTAGE,
+                },
+                {
+                    "dps": STATUS_DPS,
+                    "name": "sensor_status",
+                },
+            ],
+        )
+        self.setUpMultiSwitch(
+            [
+                {
+                    "dps": RSTEDGE_DPS,
+                    "name": "switch_edge_brush_reset",
+                },
+                {
+                    "dps": RSTROLL_DPS,
+                    "name": "switch_roll_brush_reset",
+                },
+                {
+                    "dps": RSTFILTER_DPS,
+                    "name": "switch_filter_reset",
+                },
+            ],
+        )
+        self.mark_secondary(
+            [
+                "sensor_clean_time",
+                "sensor_edge_brush",
+                "sensor_roll_brush",
+                "sensor_filter",
+                "sensor_status",
+                "switch_edge_brush_reset",
+                "switch_roll_brush_reset",
+                "switch_filter_reset",
+            ]
+        )
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_STATE
+            | SUPPORT_STATUS
+            | SUPPORT_SEND_COMMAND
+            | SUPPORT_BATTERY
+            | SUPPORT_FAN_SPEED
+            | SUPPORT_TURN_ON
+            | SUPPORT_TURN_OFF
+            | SUPPORT_START
+            | SUPPORT_PAUSE
+            | SUPPORT_LOCATE
+            | SUPPORT_RETURN_HOME
+            | SUPPORT_CLEAN_SPOT,
+        )
+
+    def test_battery_level(self):
+        self.dps[BATTERY_DPS] = 50
+        self.assertEqual(self.subject.battery_level, 50)
+
+    def test_status(self):
+        self.dps[COMMAND_DPS] = "standby"
+        self.assertEqual(self.subject.status, "standby")
+        self.dps[COMMAND_DPS] = "smart"
+        self.assertEqual(self.subject.status, "smart")
+        self.dps[COMMAND_DPS] = "chargego"
+        self.assertEqual(self.subject.status, "return_to_base")
+        self.dps[COMMAND_DPS] = "random"
+        self.assertEqual(self.subject.status, "random")
+        self.dps[COMMAND_DPS] = "wall_follow"
+        self.assertEqual(self.subject.status, "wall_follow")
+        self.dps[COMMAND_DPS] = "spiral"
+        self.assertEqual(self.subject.status, "clean_spot")
+
+    def test_state(self):
+        self.dps[POWER_DPS] = True
+        self.dps[SWITCH_DPS] = True
+        self.dps[ERROR_DPS] = 0
+        self.dps[COMMAND_DPS] = "return_to_base"
+        self.assertEqual(self.subject.state, STATE_RETURNING)
+        self.dps[COMMAND_DPS] = "standby"
+        self.assertEqual(self.subject.state, STATE_DOCKED)
+        self.dps[COMMAND_DPS] = "random"
+        self.assertEqual(self.subject.state, STATE_CLEANING)
+        self.dps[POWER_DPS] = False
+        self.assertEqual(self.subject.state, STATE_DOCKED)
+        self.dps[POWER_DPS] = True
+        self.dps[SWITCH_DPS] = False
+        self.assertEqual(self.subject.state, STATE_DOCKED)
+        self.dps[ERROR_DPS] = 1
+        self.assertEqual(self.subject.state, STATE_ERROR)
+
+    async def test_async_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DPS: True},
+        ):
+            await self.subject.async_turn_on()
+
+    async def test_async_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DPS: False},
+        ):
+            await self.subject.async_turn_off()
+
+    async def test_async_toggle(self):
+        self.dps[POWER_DPS] = False
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POWER_DPS: True},
+        ):
+            await self.subject.async_toggle()
+
+    async def test_async_start(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWITCH_DPS: True},
+        ):
+            await self.subject.async_start()
+
+    async def test_async_pause(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWITCH_DPS: False},
+        ):
+            await self.subject.async_pause()
+
+    async def test_async_return_to_base(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "chargego"},
+        ):
+            await self.subject.async_return_to_base()
+
+    async def test_async_clean_spot(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "spiral"},
+        ):
+            await self.subject.async_clean_spot()
+
+    async def test_async_locate(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {LOCATE_DPS: True},
+        ):
+            await self.subject.async_locate()
+
+    async def test_async_send_standby_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "standby"},
+        ):
+            await self.subject.async_send_command("standby")
+
+    async def test_async_send_smart_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "smart"},
+        ):
+            await self.subject.async_send_command("smart")
+
+    async def test_async_send_random_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "random"},
+        ):
+            await self.subject.async_send_command("random")
+
+    async def test_async_send_wall_follow_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "wall_follow"},
+        ):
+            await self.subject.async_send_command("wall_follow")
+
+    async def test_async_send_reverse_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {DIRECTION_DPS: "backward"},
+        ):
+            await self.subject.async_send_command("reverse")
+
+    async def test_async_send_left_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {DIRECTION_DPS: "turn_left"},
+        ):
+            await self.subject.async_send_command("left")
+
+    async def test_async_send_right_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {DIRECTION_DPS: "turn_right"},
+        ):
+            await self.subject.async_send_command("right")
+
+    async def test_async_send_stop_command(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {DIRECTION_DPS: "stop"},
+        ):
+            await self.subject.async_send_command("stop")
+
+    def test_fan_speed(self):
+        self.dps[FAN_DPS] = "quiet"
+        self.assertEqual(self.subject.fan_speed, "quiet")
+
+    def test_fan_speed_list(self):
+        self.assertCountEqual(
+            self.subject.fan_speed_list,
+            [
+                "strong",
+                "normal",
+                "quiet",
+                "gentle",
+                "closed",
+            ],
+        )
+
+    async def test_async_set_fan_speed(self):
+        async with assert_device_properties_set(
+            self.subject._device, {FAN_DPS: "gentle"}
+        ):
+            await self.subject.async_set_fan_speed("gentle")