Browse Source

send command to set curtain motors' position

Fabian Peter Hammerle 2 years ago
parent
commit
d9ed83a845

+ 1 - 1
.pylintrc

@@ -8,7 +8,7 @@ disable=missing-function-docstring,
 # avoid marking param list of implementation as duplicate of abstract declaration
 # https://github.com/PyCQA/pylint/issues/3239
 # https://github.com/PyCQA/pylint/issues/214
-min-similarity-lines=6
+min-similarity-lines=7
 
 [DESIGN]
 

+ 2 - 0
CHANGELOG.md

@@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 ### Added
+- MQTT messages on topic `homeassistant/cover/switchbot-curtain/MAC_ADDRESS/position/set-percent`
+  trigger command to set curtain motors' position (payload: decimal integer in range `[0, 100]`)
 - support `PySwitchbot` `v0.11.0` and `v0.12.0`
 
 ### Removed

+ 9 - 1
README.md

@@ -45,12 +45,19 @@ The report may be requested manually by sending a MQTT message to the topic
 
 ### Curtain Motor
 
-Send `OPEN`, `CLOSE`, or `STOP` to topic `homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/set`.
+Send `OPEN`, `CLOSE`, or `STOP` to topic `homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/set`:
 
 ```sh
 $ mosquitto_pub -h MQTT_BROKER -t homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/set -m CLOSE
 ```
 
+Or a position in percent (0 fully closed, 100 fully opened) to topic
+`homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent`:
+
+```sh
+$ mosquitto_pub -h MQTT_BROKER -t homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent -m 42
+```
+
 The command-line option `--fetch-device-info` enables position reports on topic
 `homeassistant/cover/switchbot-curtain/MAC_ADDRESS/position` after `STOP` commands
 and battery level reports on topic `homeassistant/cover/switchbot-curtain/MAC_ADDRESS/battery-percentage`
@@ -128,6 +135,7 @@ cover:
 - platform: mqtt
   name: switchbot_curtains
   command_topic: homeassistant/cover/switchbot-curtain/11:22:33:44:55:66/set
+  set_position_topic: homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent
   state_topic: homeassistant/cover/switchbot-curtain/11:22:33:44:55:66/state
 ```
 

+ 3 - 1
setup.py

@@ -72,7 +72,8 @@ setuptools.setup(
         "Topic :: Home Automation",
     ],
     entry_points={"console_scripts": ["switchbot-mqtt = switchbot_mqtt._cli:_main"]},
-    # >=3.6 variable type hints, f-strings & * to force keyword-only arguments
+    # >=3.6 variable type hints, f-strings, collections.abc.Collection
+    #       & * to force keyword-only arguments
     # >=3.7 postponed evaluation of type annotations (PEP563) & dataclass
     python_requires=">=3.7",
     install_requires=[
@@ -81,6 +82,7 @@ setuptools.setup(
         # https://github.com/IanHarvey/bluepy/tree/v/1.3.0#release-notes
         "bluepy>=1.3.0,<2",
         # >=0.10.0 for SwitchbotCurtain.{update,get_position}
+        # >=0.9.0 for SwitchbotCurtain.set_position
         "PySwitchbot>=0.10.0,<0.13",
         "paho-mqtt<2",
     ],

+ 28 - 1
switchbot_mqtt/_actors/__init__.py

@@ -233,7 +233,34 @@ class _CurtainMotor(_MQTTControlledActor):
         userdata: _MQTTCallbackUserdata,
         message: paho.mqtt.client.MQTTMessage,
     ) -> None:
-        raise NotImplementedError()
+        # pylint: disable=unused-argument; callback
+        # https://github.com/eclipse/paho.mqtt.python/blob/v1.6.1/src/paho/mqtt/client.py#L3556
+        _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
+        if message.retain:
+            _LOGGER.info("ignoring retained message on topic %s", message.topic)
+            return
+        actor = cls._init_from_topic(
+            userdata=userdata,
+            topic=message.topic,
+            expected_topic_levels=cls._MQTT_SET_POSITION_TOPIC_LEVELS,
+        )
+        if not actor:
+            return  # warning in _init_from_topic
+        position_percent = int(message.payload.decode(), 10)
+        if position_percent < 0 or position_percent > 100:
+            _LOGGER.warning("invalid position %u%%, ignoring message", position_percent)
+            return
+        # pylint: disable=protected-access; own instance
+        if actor._get_device().set_position(position_percent):
+            _LOGGER.info(
+                "set position of switchbot curtain %s to %u%%",
+                actor._mac_address,
+                position_percent,
+            )
+        else:
+            _LOGGER.error(
+                "failed to set position of switchbot curtain %s", actor._mac_address
+            )
 
     @classmethod
     def _get_mqtt_message_callbacks(

+ 2 - 1
switchbot_mqtt/_actors/_base.py

@@ -19,6 +19,7 @@
 from __future__ import annotations  # PEP563 (default in python>=3.10)
 
 import abc
+import collections.abc
 import dataclasses
 import logging
 import queue
@@ -137,7 +138,7 @@ class _MQTTControlledActor(abc.ABC):
         cls,
         userdata: _MQTTCallbackUserdata,
         topic: str,
-        expected_topic_levels: typing.List[_MQTTTopicLevel],
+        expected_topic_levels: collections.abc.Collection[_MQTTTopicLevel],
     ) -> typing.Optional[_MQTTControlledActor]:
         try:
             mac_address = _parse_mqtt_topic(

+ 2 - 1
switchbot_mqtt/_utils.py

@@ -16,6 +16,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
+import collections.abc
 import enum
 import logging
 import queue  # pylint: disable=unused-import; in type hint
@@ -46,7 +47,7 @@ def _join_mqtt_topic_levels(
 
 
 def _parse_mqtt_topic(
-    topic: str, expected_levels: typing.List[_MQTTTopicLevel]
+    topic: str, expected_levels: collections.abc.Collection[_MQTTTopicLevel]
 ) -> typing.Dict[_MQTTTopicPlaceholder, str]:
     attrs: typing.Dict[_MQTTTopicPlaceholder, str] = {}
     topic_split = topic.split("/")

+ 0 - 12
tests/test_switchbot_curtain_motor.py

@@ -26,7 +26,6 @@ import pytest
 
 import switchbot_mqtt._utils
 from switchbot_mqtt._actors import _CurtainMotor
-from switchbot_mqtt._actors._base import _MQTTCallbackUserdata
 
 # pylint: disable=protected-access,
 # pylint: disable=too-many-arguments; these are tests, no API
@@ -331,14 +330,3 @@ def test_execute_command_bluetooth_error(
         logging.ERROR,
         f"failed to {message_payload.decode().lower()} switchbot curtain {mac_address}",
     )
-
-
-def test__mqtt_set_position_callback() -> None:
-    with pytest.raises(NotImplementedError):
-        _CurtainMotor._mqtt_set_position_callback(
-            mqtt_client="dummy",
-            userdata=_MQTTCallbackUserdata(
-                retry_count=3, device_passwords={}, fetch_device_info=False
-            ),
-            message=None,
-        )

+ 243 - 0
tests/test_switchbot_curtain_motor_position.py

@@ -0,0 +1,243 @@
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
+#
+# Copyright (C) 2022 Fabian Peter Hammerle <fabian@hammerle.me>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import logging
+import unittest.mock
+
+import _pytest.logging
+import pytest
+from paho.mqtt.client import MQTTMessage
+
+from switchbot_mqtt._actors import _CurtainMotor
+from switchbot_mqtt._actors._base import _MQTTCallbackUserdata
+
+# pylint: disable=protected-access
+
+
+@pytest.mark.parametrize(
+    ("topic", "payload", "expected_mac_address", "expected_position_percent"),
+    [
+        (
+            b"homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent",
+            b"42",
+            "aa:bb:cc:dd:ee:ff",
+            42,
+        ),
+        (
+            b"homeassistant/cover/switchbot-curtain/11:22:33:44:55:66/position/set-percent",
+            b"0",
+            "11:22:33:44:55:66",
+            0,
+        ),
+        (
+            b"homeassistant/cover/switchbot-curtain/11:22:33:44:55:66/position/set-percent",
+            b"100",
+            "11:22:33:44:55:66",
+            100,
+        ),
+    ],
+)
+@pytest.mark.parametrize("retry_count", (3, 42))
+def test__mqtt_set_position_callback(
+    caplog: _pytest.logging.LogCaptureFixture,
+    topic: bytes,
+    payload: bytes,
+    expected_mac_address: str,
+    retry_count: int,
+    expected_position_percent: int,
+) -> None:
+    callback_userdata = _MQTTCallbackUserdata(
+        retry_count=retry_count,
+        device_passwords={},
+        fetch_device_info=False,
+    )
+    message = MQTTMessage(topic=topic)
+    message.payload = payload
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain"
+    ) as device_init_mock, caplog.at_level(logging.DEBUG):
+        _CurtainMotor._mqtt_set_position_callback(
+            mqtt_client="client dummy", userdata=callback_userdata, message=message
+        )
+    device_init_mock.assert_called_once_with(
+        mac=expected_mac_address,
+        password=None,
+        retry_count=retry_count,
+        reverse_mode=True,
+    )
+    device_init_mock().set_position.assert_called_once_with(expected_position_percent)
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt._actors",
+            logging.DEBUG,
+            f"received topic=homeassistant/cover/switchbot-curtain/{expected_mac_address}"
+            f"/position/set-percent payload=b'{expected_position_percent}'",
+        ),
+        (
+            "switchbot_mqtt._actors",
+            logging.INFO,
+            f"set position of switchbot curtain {expected_mac_address}"
+            f" to {expected_position_percent}%",
+        ),
+    ]
+
+
+def test__mqtt_set_position_callback_ignore_retained(
+    caplog: _pytest.logging.LogCaptureFixture,
+) -> None:
+    callback_userdata = _MQTTCallbackUserdata(
+        retry_count=3,
+        device_passwords={},
+        fetch_device_info=False,
+    )
+    message = MQTTMessage(
+        topic=b"homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent"
+    )
+    message.payload = b"42"
+    message.retain = True
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain"
+    ) as device_init_mock, caplog.at_level(logging.INFO):
+        _CurtainMotor._mqtt_set_position_callback(
+            mqtt_client="client dummy", userdata=callback_userdata, message=message
+        )
+    device_init_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt._actors",
+            logging.INFO,
+            "ignoring retained message on topic"
+            " homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent",
+        ),
+    ]
+
+
+def test__mqtt_set_position_callback_unexpected_topic(
+    caplog: _pytest.logging.LogCaptureFixture,
+) -> None:
+    callback_userdata = _MQTTCallbackUserdata(
+        retry_count=3,
+        device_passwords={},
+        fetch_device_info=False,
+    )
+    message = MQTTMessage(topic=b"switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set")
+    message.payload = b"42"
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain"
+    ) as device_init_mock, caplog.at_level(logging.INFO):
+        _CurtainMotor._mqtt_set_position_callback(
+            mqtt_client="client dummy", userdata=callback_userdata, message=message
+        )
+    device_init_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt._actors._base",
+            logging.WARN,
+            "unexpected topic switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set",
+        ),
+    ]
+
+
+def test__mqtt_set_position_callback_invalid_mac_address(
+    caplog: _pytest.logging.LogCaptureFixture,
+) -> None:
+    callback_userdata = _MQTTCallbackUserdata(
+        retry_count=3,
+        device_passwords={},
+        fetch_device_info=False,
+    )
+    message = MQTTMessage(
+        topic=b"homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee/position/set-percent"
+    )
+    message.payload = b"42"
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain"
+    ) as device_init_mock, caplog.at_level(logging.INFO):
+        _CurtainMotor._mqtt_set_position_callback(
+            mqtt_client="client dummy", userdata=callback_userdata, message=message
+        )
+    device_init_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt._actors._base",
+            logging.WARN,
+            "invalid mac address aa:bb:cc:dd:ee",
+        ),
+    ]
+
+
+@pytest.mark.parametrize("payload", [b"-1", b"123"])
+def test__mqtt_set_position_callback_invalid_position(
+    caplog: _pytest.logging.LogCaptureFixture,
+    payload: bytes,
+) -> None:
+    callback_userdata = _MQTTCallbackUserdata(
+        retry_count=3,
+        device_passwords={},
+        fetch_device_info=False,
+    )
+    message = MQTTMessage(
+        topic=b"homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent"
+    )
+    message.payload = payload
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain"
+    ) as device_init_mock, caplog.at_level(logging.INFO):
+        _CurtainMotor._mqtt_set_position_callback(
+            mqtt_client="client dummy", userdata=callback_userdata, message=message
+        )
+    device_init_mock.assert_called_once()
+    device_init_mock().set_position.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt._actors",
+            logging.WARN,
+            f"invalid position {payload.decode()}%, ignoring message",
+        ),
+    ]
+
+
+def test__mqtt_set_position_callback_command_failed(
+    caplog: _pytest.logging.LogCaptureFixture,
+) -> None:
+    callback_userdata = _MQTTCallbackUserdata(
+        retry_count=3,
+        device_passwords={},
+        fetch_device_info=False,
+    )
+    message = MQTTMessage(
+        topic=b"homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/position/set-percent"
+    )
+    message.payload = b"21"
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain"
+    ) as device_init_mock, caplog.at_level(logging.INFO):
+        device_init_mock().set_position.return_value = False
+        device_init_mock.reset_mock()
+        _CurtainMotor._mqtt_set_position_callback(
+            mqtt_client="client dummy", userdata=callback_userdata, message=message
+        )
+    device_init_mock.assert_called_once()
+    device_init_mock().set_position.assert_called_with(21)
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt._actors",
+            logging.ERROR,
+            "failed to set position of switchbot curtain aa:bb:cc:dd:ee:ff",
+        ),
+    ]