Browse Source

add support for switchbot curtain motors

https://github.com/fphammerle/switchbot-mqtt/issues/23
Fabian Peter Hammerle 3 years ago
parent
commit
44f2706bde

+ 1 - 1
.github/workflows/python.yml

@@ -53,7 +53,7 @@ jobs:
     - run: pipenv run pytest --cov=switchbot_mqtt --cov-report=term-missing --cov-fail-under=100
     - run: pipenv run pylint --load-plugins=pylint_import_requirements switchbot_mqtt
     # https://github.com/PyCQA/pylint/issues/352
-    - run: pipenv run pylint tests/*
+    - run: pipenv run pylint --disable=duplicate-code tests/*
     - run: pipenv run mypy switchbot_mqtt tests
     # >=1.9.0 to detect branch name
     # https://github.com/coveralls-clients/coveralls-python/pull/207

+ 7 - 3
CHANGELOG.md

@@ -5,11 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### Added
+- Control [SwitchBot Curtain](https://www.switch-bot.com/products/switchbot-curtain) motors
+  via `OPEN`, `CLOSE`, and `STOP` on topic `homeassistant/cover/switchbot-curtain/aa:bb:cc:dd:ee:ff/set`
+
 ### Changed
-- docker image:
-  - upgrade `paho-mqtt` to no longer suppress exceptions occuring in mqtt callbacks
+- Docker image:
+  - Upgrade `paho-mqtt` to no longer suppress exceptions occuring in mqtt callbacks
     ( https://github.com/eclipse/paho.mqtt.python/blob/v1.5.1/ChangeLog.txt#L4 )
-  - build stage: revert user after applying `chown` workaround for inter-stage copy
+  - Build stage: revert user after applying `chown` workaround for inter-stage copy
 
 ## [0.5.0] - 2020-11-22
 ### Added

+ 3 - 2
Pipfile.lock

@@ -30,9 +30,10 @@
         },
         "pyswitchbot": {
             "hashes": [
-                "sha256:af5188bde92c681feb61dba73966ea0547386f30ae0b39b39097148d77d2fd46"
+                "sha256:49a18ddbe0cde5171397cbf8bfc661e02adb2939a05042bd5d998df16ab8d602",
+                "sha256:ec5aee8ac9fbd458e388dd706c64761fcc74a44eac7c85b292f96a3001b6a15a"
             ],
-            "version": "==0.8.0"
+            "version": "==0.9.0"
         },
         "switchbot-mqtt": {
             "editable": true,

+ 18 - 1
README.md

@@ -7,14 +7,21 @@
 [![Compatible Python Versions](https://img.shields.io/pypi/pyversions/switchbot-mqtt.svg)](https://pypi.org/project/switchbot-mqtt/)
 
 MQTT client controlling [SwitchBot button automators](https://www.switch-bot.com/bot)
+and [curtain motors](https://www.switch-bot.com/products/switchbot-curtain)
 
 Compatible with [Home Assistant](https://www.home-assistant.io/)'s
-[MQTT Switch](https://www.home-assistant.io/integrations/switch.mqtt/) platform.
+[MQTT Switch](https://www.home-assistant.io/integrations/switch.mqtt/)
+and [MQTT Cover](https://www.home-assistant.io/integrations/cover.mqtt/) platform.
 
 ## Setup
 
 ```sh
 $ pip3 install --user --upgrade switchbot-mqtt
+```
+
+## Usage
+
+```sh
 $ switchbot-mqtt --mqtt-host HOSTNAME_OR_IP_ADDRESS
 ```
 
@@ -23,12 +30,22 @@ or select device settings > 3 dots on top right in
 [SwitchBot app](https://play.google.com/store/apps/details?id=com.theswitchbot.switchbot)
 to determine your SwitchBot's **mac address**.
 
+### Button Automator
+
 Send `ON` or `OFF` to topic `homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set`.
 
 ```sh
 $ mosquitto_pub -h MQTT_BROKER -t homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set -m ON
 ```
 
+### Curtain Motor
+
+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
+```
+
 ## Home Assistant 🏡
 
 ### Rationale

+ 14 - 12
setup.py

@@ -1,5 +1,5 @@
-# switchbot-mqtt - MQTT client controlling SwitchBot button automators,
-# compatible with home-assistant.io's MQTT Switch platform
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
 #
 # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
 #
@@ -27,12 +27,12 @@ setuptools.setup(
     use_scm_version={
         # > AssertionError: cant parse version docker/0.1.0-amd64
         # https://github.com/pypa/setuptools_scm/blob/master/src/setuptools_scm/git.py#L15
-        "git_describe_command": "git describe --dirty --tags --long --match v*",
+        "git_describe_command": "git describe --dirty --tags --long --match v*"
     },
     packages=setuptools.find_packages(),
-    description="MQTT client controlling SwitchBot button automators, "
+    description="MQTT client controlling SwitchBot button & curtain automators, "
     # https://www.home-assistant.io/integrations/switch.mqtt/
-    "compatible with home-assistant.io's MQTT Switch platform",
+    "compatible with home-assistant.io's MQTT Switch & Cover platform",
     long_description=pathlib.Path(__file__).parent.joinpath("README.md").read_text(),
     long_description_content_type="text/markdown",
     author="Fabian Peter Hammerle",
@@ -46,6 +46,8 @@ setuptools.setup(
         "bluetooth",
         "button",
         "click",
+        "cover",
+        "curtain",
         "home-assistant.io",
         "home-automation",
         "mqtt",
@@ -55,7 +57,7 @@ setuptools.setup(
     ],
     classifiers=[
         # https://pypi.org/classifiers/
-        "Development Status :: 2 - Pre-Alpha",
+        "Development Status :: 3 - Alpha",
         "Intended Audience :: End Users/Desktop",
         "Intended Audience :: System Administrators",
         "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
@@ -67,12 +69,12 @@ setuptools.setup(
         "Programming Language :: Python :: 3.8",
         "Topic :: Home Automation",
     ],
-    entry_points={
-        "console_scripts": [
-            "switchbot-mqtt = switchbot_mqtt:_main",
-        ]
-    },
-    install_requires=["PySwitchbot", "paho-mqtt<2"],
+    entry_points={"console_scripts": ["switchbot-mqtt = switchbot_mqtt:_main"]},
+    install_requires=[
+        # >=0.9.0 for SwitchbotCurtain
+        "PySwitchbot>=0.9.0,<0.10",
+        "paho-mqtt<2",
+    ],
     setup_requires=["setuptools_scm"],
     tests_require=["pytest"],
 )

+ 184 - 114
switchbot_mqtt/__init__.py

@@ -1,5 +1,5 @@
-# switchbot-mqtt - MQTT client controlling SwitchBot button automators,
-# compatible with home-assistant.io's MQTT Switch platform
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
 #
 # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
 #
@@ -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 abc
 import argparse
 import enum
 import logging
@@ -28,141 +29,211 @@ import switchbot
 
 _LOGGER = logging.getLogger(__name__)
 
-
-class _SwitchbotAction(enum.Enum):
-    ON = 1
-    OFF = 2
+_MAC_ADDRESS_REGEX = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
 
 
-class _SwitchbotState(enum.Enum):
-    ON = 1
-    OFF = 2
+class _MQTTTopicPlaceholder(enum.Enum):
+    MAC_ADDRESS = "MAC_ADDRESS"
 
 
-# https://www.home-assistant.io/docs/mqtt/discovery/#switches
-_MQTT_TOPIC_PREFIX_LEVELS = ["homeassistant", "switch", "switchbot"]
-_MQTT_TOPIC_MAC_ADDRESS_PLACEHOLDER = "{mac_address}"
-_MQTT_SET_TOPIC_LEVELS = _MQTT_TOPIC_PREFIX_LEVELS + [
-    _MQTT_TOPIC_MAC_ADDRESS_PLACEHOLDER,
-    "set",
-]
-_MQTT_SET_TOPIC = "/".join(_MQTT_SET_TOPIC_LEVELS).replace(
-    _MQTT_TOPIC_MAC_ADDRESS_PLACEHOLDER, "+"
-)
-_MQTT_STATE_TOPIC = "/".join(
-    _MQTT_TOPIC_PREFIX_LEVELS + [_MQTT_TOPIC_MAC_ADDRESS_PLACEHOLDER, "state"]
-)
-# https://www.home-assistant.io/integrations/switch.mqtt/#state_off
-_MQTT_STATE_PAYLOAD_MAPPING = {_SwitchbotState.ON: b"ON", _SwitchbotState.OFF: b"OFF"}
-_MAC_ADDRESS_REGEX = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
+_MQTTTopicLevel = typing.Union[str, _MQTTTopicPlaceholder]
+# "homeassistant" for historic reason, may be parametrized in future
+_MQTT_TOPIC_LEVELS_PREFIX = ["homeassistant"]  # type: typing.List[_MQTTTopicLevel]
 
 
 def _mac_address_valid(mac_address: str) -> bool:
     return _MAC_ADDRESS_REGEX.match(mac_address.lower()) is not None
 
 
-def _mqtt_on_connect(
-    mqtt_client: paho.mqtt.client.Client,
-    user_data: typing.Any,
-    flags: typing.Dict,
-    return_code: int,
-) -> None:
-    # pylint: disable=unused-argument; callback
-    # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
-    assert return_code == 0, return_code  # connection accepted
-    mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
-    _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
-    # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
-    mqtt_client.subscribe(_MQTT_SET_TOPIC)
+class _MQTTControlledActor(abc.ABC):
+    MQTT_COMMAND_TOPIC_LEVELS = NotImplemented  # type: typing.List[_MQTTTopicLevel]
+    MQTT_STATE_TOPIC_LEVELS = NotImplemented  # type: typing.List[_MQTTTopicLevel]
 
+    def __init__(self, mac_address: str) -> None:
+        self._mac_address = mac_address
 
-def _report_state(
-    mqtt_client: paho.mqtt.client.Client,
-    switchbot_mac_address: str,
-    switchbot_state: _SwitchbotState,
-) -> None:
-    # https://pypi.org/project/paho-mqtt/#publishing
-    topic = _MQTT_STATE_TOPIC.replace(
-        _MQTT_TOPIC_MAC_ADDRESS_PLACEHOLDER, switchbot_mac_address
-    )
-    payload = _MQTT_STATE_PAYLOAD_MAPPING[switchbot_state]
-    _LOGGER.debug("publishing topic=%s payload=%r", topic, payload)
-    message_info = mqtt_client.publish(
-        topic=topic, payload=payload, retain=True
-    )  # type: paho.mqtt.client.MQTTMessageInfo
-    if message_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
-        _LOGGER.error("failed to publish state (rc=%d)", message_info.rc)
+    @abc.abstractmethod
+    def execute_command(
+        self, mqtt_message_payload: bytes, mqtt_client: paho.mqtt.client.Client
+    ) -> None:
+        raise NotImplementedError()
+
+    @classmethod
+    def _mqtt_command_callback(
+        cls,
+        mqtt_client: paho.mqtt.client.Client,
+        userdata: None,
+        message: paho.mqtt.client.MQTTMessage,
+    ) -> None:
+        # pylint: disable=unused-argument; callback
+        # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
+        _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
+        if message.retain:
+            _LOGGER.info("ignoring retained message")
+            return
+        topic_split = message.topic.split("/")
+        if len(topic_split) != len(cls.MQTT_COMMAND_TOPIC_LEVELS):
+            _LOGGER.warning("unexpected topic %s", message.topic)
+            return
+        mac_address = None
+        for given_part, expected_part in zip(
+            topic_split, cls.MQTT_COMMAND_TOPIC_LEVELS
+        ):
+            if expected_part == _MQTTTopicPlaceholder.MAC_ADDRESS:
+                mac_address = given_part
+            elif expected_part != given_part:
+                _LOGGER.warning("unexpected topic %s", message.topic)
+                return
+        assert mac_address
+        if not _mac_address_valid(mac_address):
+            _LOGGER.warning("invalid mac address %s", mac_address)
+            return
+        cls(mac_address=mac_address).execute_command(
+            mqtt_message_payload=message.payload, mqtt_client=mqtt_client
+        )
 
+    @classmethod
+    def mqtt_subscribe(cls, mqtt_client: paho.mqtt.client.Client) -> None:
+        command_topic = "/".join(
+            "+" if isinstance(l, _MQTTTopicPlaceholder) else l
+            for l in cls.MQTT_COMMAND_TOPIC_LEVELS
+        )
+        mqtt_client.subscribe(command_topic)
+        mqtt_client.message_callback_add(
+            sub=command_topic, callback=cls._mqtt_command_callback
+        )
 
-def _send_command(
-    mqtt_client: paho.mqtt.client.Client,
-    switchbot_mac_address: str,
-    action: _SwitchbotAction,
-) -> None:
-    switchbot_device = switchbot.Switchbot(mac=switchbot_mac_address)
-    # pySwitchbot catches & logs bluetooth exceptions (see `test__send_command_bluetooth_error`)
-    if action == _SwitchbotAction.ON:
-        if not switchbot_device.turn_on():
-            _LOGGER.error("failed to turn on switchbot %s", switchbot_mac_address)
+    def report_state(self, state: bytes, mqtt_client: paho.mqtt.client.Client) -> None:
+        state_topic = "/".join(
+            self._mac_address
+            if l == _MQTTTopicPlaceholder.MAC_ADDRESS
+            else typing.cast(str, l)
+            for l in self.MQTT_STATE_TOPIC_LEVELS
+        )
+        # https://pypi.org/project/paho-mqtt/#publishing
+        _LOGGER.debug("publishing topic=%s payload=%r", state_topic, state)
+        message_info = mqtt_client.publish(
+            topic=state_topic, payload=state, retain=True
+        )  # type: paho.mqtt.client.MQTTMessageInfo
+        # wait before checking status?
+        if message_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
+            _LOGGER.error("failed to publish state (rc=%d)", message_info.rc)
+
+
+class _ButtonAutomator(_MQTTControlledActor):
+    # https://www.home-assistant.io/integrations/switch.mqtt/
+
+    MQTT_COMMAND_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "switch",
+        "switchbot",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "set",
+    ]
+
+    MQTT_STATE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "switch",
+        "switchbot",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "state",
+    ]
+
+    def __init__(self, mac_address) -> None:
+        self._device = switchbot.Switchbot(mac=mac_address)
+        super().__init__(mac_address=mac_address)
+
+    def execute_command(
+        self, mqtt_message_payload: bytes, mqtt_client: paho.mqtt.client.Client
+    ) -> None:
+        # https://www.home-assistant.io/integrations/switch.mqtt/#payload_on
+        if mqtt_message_payload.lower() == b"on":
+            if not self._device.turn_on():
+                _LOGGER.error("failed to turn on switchbot %s", self._mac_address)
+            else:
+                _LOGGER.info("switchbot %s turned on", self._mac_address)
+                # https://www.home-assistant.io/integrations/switch.mqtt/#state_on
+                self.report_state(mqtt_client=mqtt_client, state=b"ON")
+        # https://www.home-assistant.io/integrations/switch.mqtt/#payload_off
+        elif mqtt_message_payload.lower() == b"off":
+            if not self._device.turn_off():
+                _LOGGER.error("failed to turn off switchbot %s", self._mac_address)
+            else:
+                _LOGGER.info("switchbot %s turned off", self._mac_address)
+                self.report_state(mqtt_client=mqtt_client, state=b"OFF")
         else:
-            _LOGGER.info("switchbot %s turned on", switchbot_mac_address)
-            _report_state(
-                mqtt_client=mqtt_client,
-                switchbot_mac_address=switchbot_mac_address,
-                switchbot_state=_SwitchbotState.ON,
+            _LOGGER.warning(
+                "unexpected payload %r (expected 'ON' or 'OFF')", mqtt_message_payload
             )
-    else:
-        assert action == _SwitchbotAction.OFF, action
-        if not switchbot_device.turn_off():
-            _LOGGER.error("failed to turn off switchbot %s", switchbot_mac_address)
+
+
+class _CurtainMotor(_MQTTControlledActor):
+    # https://www.home-assistant.io/integrations/cover.mqtt/
+
+    MQTT_COMMAND_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "cover",
+        "switchbot-curtain",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "set",
+    ]
+
+    MQTT_STATE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "cover",
+        "switchbot-curtain",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "state",
+    ]
+
+    def __init__(self, mac_address) -> None:
+        self._device = switchbot.SwitchbotCurtain(mac=mac_address)
+        super().__init__(mac_address=mac_address)
+
+    def execute_command(
+        self, mqtt_message_payload: bytes, mqtt_client: paho.mqtt.client.Client
+    ) -> None:
+        # https://www.home-assistant.io/integrations/cover.mqtt/#payload_open
+        if mqtt_message_payload.lower() == b"open":
+            if not self._device.open():
+                _LOGGER.error("failed to open switchbot curtain %s", self._mac_address)
+            else:
+                _LOGGER.info("switchbot curtain %s opening", self._mac_address)
+                # > state_opening string (Optional, default: opening)
+                # https://www.home-assistant.io/integrations/cover.mqtt/#state_opening
+                self.report_state(mqtt_client=mqtt_client, state=b"opening")
+        elif mqtt_message_payload.lower() == b"close":
+            if not self._device.close():
+                _LOGGER.error("failed to close switchbot curtain %s", self._mac_address)
+            else:
+                _LOGGER.info("switchbot curtain %s closing", self._mac_address)
+                # https://www.home-assistant.io/integrations/cover.mqtt/#state_closing
+                self.report_state(mqtt_client=mqtt_client, state=b"closing")
+        elif mqtt_message_payload.lower() == b"stop":
+            if not self._device.stop():
+                _LOGGER.error("failed to stop switchbot curtain %s", self._mac_address)
+            else:
+                _LOGGER.info("switchbot curtain %s stopped", self._mac_address)
+                # no "stopped" state mentioned at
+                # https://www.home-assistant.io/integrations/cover.mqtt/#configuration-variables
+                # https://community.home-assistant.io/t/mqtt-how-to-remove-retained-messages/79029/2
+                self.report_state(mqtt_client=mqtt_client, state=b"")
         else:
-            _LOGGER.info("switchbot %s turned off", switchbot_mac_address)
-            _report_state(
-                mqtt_client=mqtt_client,
-                switchbot_mac_address=switchbot_mac_address,
-                switchbot_state=_SwitchbotState.OFF,
+            _LOGGER.warning(
+                "unexpected payload %r (expected 'OPEN', 'CLOSE', or 'STOP')",
+                mqtt_message_payload,
             )
 
 
-def _mqtt_on_message(
+def _mqtt_on_connect(
     mqtt_client: paho.mqtt.client.Client,
     user_data: typing.Any,
-    message: paho.mqtt.client.MQTTMessage,
+    flags: typing.Dict,
+    return_code: int,
 ) -> None:
     # pylint: disable=unused-argument; callback
-    # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
-    _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
-    if message.retain:
-        _LOGGER.info("ignoring retained message")
-        return
-    topic_split = message.topic.split("/")
-    if len(topic_split) != len(_MQTT_SET_TOPIC_LEVELS):
-        _LOGGER.warning("unexpected topic %s", message.topic)
-        return
-    switchbot_mac_address = None
-    for given_part, expected_part in zip(topic_split, _MQTT_SET_TOPIC_LEVELS):
-        if expected_part == _MQTT_TOPIC_MAC_ADDRESS_PLACEHOLDER:
-            switchbot_mac_address = given_part
-        elif expected_part != given_part:
-            _LOGGER.warning("unexpected topic %s", message.topic)
-            return
-    assert switchbot_mac_address
-    if not _mac_address_valid(switchbot_mac_address):
-        _LOGGER.warning("invalid mac address %s", switchbot_mac_address)
-        return
-    # https://www.home-assistant.io/integrations/switch.mqtt/#payload_off
-    if message.payload.lower() == b"on":
-        action = _SwitchbotAction.ON
-    elif message.payload.lower() == b"off":
-        action = _SwitchbotAction.OFF
-    else:
-        _LOGGER.warning("unexpected payload %r", message.payload)
-        return
-    _send_command(
-        mqtt_client=mqtt_client,
-        switchbot_mac_address=switchbot_mac_address,
-        action=action,
-    )
+    # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
+    assert return_code == 0, return_code  # connection accepted
+    mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
+    _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
+    _ButtonAutomator.mqtt_subscribe(mqtt_client=mqtt_client)
+    _CurtainMotor.mqtt_subscribe(mqtt_client=mqtt_client)
 
 
 def _run(
@@ -174,7 +245,6 @@ def _run(
     # https://pypi.org/project/paho-mqtt/
     mqtt_client = paho.mqtt.client.Client()
     mqtt_client.on_connect = _mqtt_on_connect
-    mqtt_client.on_message = _mqtt_on_message
     _LOGGER.info("connecting to MQTT broker %s:%d", mqtt_host, mqtt_port)
     if mqtt_username:
         mqtt_client.username_pw_set(username=mqtt_username, password=mqtt_password)

+ 44 - 0
tests/test_actor_base.py

@@ -0,0 +1,44 @@
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
+#
+# Copyright (C) 2020 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 paho.mqtt.client
+import pytest
+
+import switchbot_mqtt
+
+# pylint: disable=protected-access
+
+
+def test_abstract():
+    with pytest.raises(TypeError, match=r"\babstract class\b"):
+        # pylint: disable=abstract-class-instantiated
+        switchbot_mqtt._MQTTControlledActor(mac_address=None)
+
+
+def test_execute_command_abstract():
+    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+        def execute_command(
+            self, mqtt_message_payload: bytes, mqtt_client: paho.mqtt.client.Client
+        ) -> None:
+            super().execute_command(
+                mqtt_message_payload=mqtt_message_payload, mqtt_client=mqtt_client
+            )
+
+    actor = _ActorMock(mac_address=None)
+    with pytest.raises(NotImplementedError):
+        actor.execute_command(mqtt_message_payload=b"dummy", mqtt_client="dummy")

+ 18 - 0
tests/test_cli.py

@@ -1,3 +1,21 @@
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
+#
+# Copyright (C) 2020 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 unittest.mock
 
 import pytest

+ 18 - 0
tests/test_mac_address.py

@@ -1,3 +1,21 @@
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
+#
+# Copyright (C) 2020 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 pytest
 
 import switchbot_mqtt

+ 231 - 60
tests/test_mqtt.py

@@ -1,8 +1,27 @@
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
+#
+# Copyright (C) 2020 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 typing
 import unittest.mock
 
 import pytest
-from paho.mqtt.client import MQTT_ERR_QUEUE_SIZE, MQTT_ERR_SUCCESS, MQTTMessage
+from paho.mqtt.client import MQTT_ERR_QUEUE_SIZE, MQTT_ERR_SUCCESS, MQTTMessage, Client
 
 import switchbot_mqtt
 
@@ -12,11 +31,7 @@ import switchbot_mqtt
 @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
 @pytest.mark.parametrize("mqtt_port", [1833])
 def test__run(mqtt_host, mqtt_port):
-    with unittest.mock.patch(
-        "paho.mqtt.client.Client"
-    ) as mqtt_client_mock, unittest.mock.patch(
-        "switchbot_mqtt._mqtt_on_message"
-    ) as message_handler_mock:
+    with unittest.mock.patch("paho.mqtt.client.Client") as mqtt_client_mock:
         switchbot_mqtt._run(
             mqtt_host=mqtt_host,
             mqtt_port=mqtt_port,
@@ -28,12 +43,20 @@ def test__run(mqtt_host, mqtt_port):
     mqtt_client_mock().connect.assert_called_once_with(host=mqtt_host, port=mqtt_port)
     mqtt_client_mock().socket().getpeername.return_value = (mqtt_host, mqtt_port)
     mqtt_client_mock().on_connect(mqtt_client_mock(), None, {}, 0)
-    mqtt_client_mock().subscribe.assert_called_once_with(
-        "homeassistant/switch/switchbot/+/set"
-    )
-    mqtt_client_mock().on_message(mqtt_client_mock(), None, "message")
-    # assert_called_once new in python3.6
-    assert message_handler_mock.call_count == 1
+    assert mqtt_client_mock().subscribe.call_args_list == [
+        unittest.mock.call("homeassistant/switch/switchbot/+/set"),
+        unittest.mock.call("homeassistant/cover/switchbot-curtain/+/set"),
+    ]
+    assert mqtt_client_mock().message_callback_add.call_args_list == [
+        unittest.mock.call(
+            sub="homeassistant/switch/switchbot/+/set",
+            callback=switchbot_mqtt._ButtonAutomator._mqtt_command_callback,
+        ),
+        unittest.mock.call(
+            sub="homeassistant/cover/switchbot-curtain/+/set",
+            callback=switchbot_mqtt._CurtainMotor._mqtt_command_callback,
+        ),
+    ]
     mqtt_client_mock().loop_forever.assert_called_once_with()
 
 
@@ -70,127 +93,275 @@ def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_passwor
 
 
 @pytest.mark.parametrize(
-    ("topic", "payload", "expected_mac_address", "expected_action"),
+    ("command_topic_levels", "topic", "payload", "expected_mac_address"),
     [
         (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
             b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
             b"ON",
             "aa:bb:cc:dd:ee:ff",
-            switchbot_mqtt._SwitchbotAction.ON,
         ),
         (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
             b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
             b"OFF",
             "aa:bb:cc:dd:ee:ff",
-            switchbot_mqtt._SwitchbotAction.OFF,
         ),
         (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
             b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
             b"on",
             "aa:bb:cc:dd:ee:ff",
-            switchbot_mqtt._SwitchbotAction.ON,
         ),
         (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
             b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
             b"off",
             "aa:bb:cc:dd:ee:ff",
-            switchbot_mqtt._SwitchbotAction.OFF,
         ),
         (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
             b"homeassistant/switch/switchbot/aa:01:23:45:67:89/set",
             b"ON",
             "aa:01:23:45:67:89",
-            switchbot_mqtt._SwitchbotAction.ON,
+        ),
+        (
+            ["switchbot", switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS],
+            b"switchbot/aa:01:23:45:67:89",
+            b"ON",
+            "aa:01:23:45:67:89",
+        ),
+        (
+            switchbot_mqtt._CurtainMotor.MQTT_COMMAND_TOPIC_LEVELS,
+            b"homeassistant/cover/switchbot-curtain/aa:01:23:45:67:89/set",
+            b"OPEN",
+            "aa:01:23:45:67:89",
         ),
     ],
 )
-def test__mqtt_on_message(
+def test__mqtt_command_callback(
+    caplog,
+    command_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
     topic: bytes,
     payload: bytes,
     expected_mac_address: str,
-    expected_action: switchbot_mqtt._SwitchbotAction,
 ):
+    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+        MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
+
+        def __init__(self, mac_address):
+            super().__init__(mac_address=mac_address)
+
+        def execute_command(self, mqtt_message_payload: bytes, mqtt_client: Client):
+            pass
+
     message = MQTTMessage(topic=topic)
     message.payload = payload
-    with unittest.mock.patch("switchbot_mqtt._send_command") as send_command_mock:
-        switchbot_mqtt._mqtt_on_message("client_dummy", None, message)
-    send_command_mock.assert_called_once_with(
-        mqtt_client="client_dummy",
-        switchbot_mac_address=expected_mac_address,
-        action=expected_action,
+    with unittest.mock.patch.object(
+        _ActorMock, "__init__", return_value=None
+    ) as init_mock, unittest.mock.patch.object(
+        _ActorMock, "execute_command"
+    ) as execute_command_mock, caplog.at_level(
+        logging.DEBUG
+    ):
+        _ActorMock._mqtt_command_callback("client_dummy", None, message)
+    init_mock.assert_called_once_with(mac_address=expected_mac_address)
+    execute_command_mock.assert_called_once_with(
+        mqtt_client="client_dummy", mqtt_message_payload=payload
     )
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt",
+            logging.DEBUG,
+            "received topic={} payload={!r}".format(topic.decode(), payload),
+        )
+    ]
 
 
 @pytest.mark.parametrize(
     ("topic", "payload"),
     [
-        (b"homeassistant/switch/switchbot/aa:01:23:4E:RR:OR/set", b"ON"),
         (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff", b"on"),
         (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/change", b"ON"),
-        (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b""),
-        (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"EIN"),
+        (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set/suffix", b"ON"),
     ],
 )
-def test__mqtt_on_message_ignored(topic: bytes, payload: bytes):
+def test__mqtt_command_callback_unexpected_topic(caplog, topic: bytes, payload: bytes):
+    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+        MQTT_COMMAND_TOPIC_LEVELS = (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
+        )
+
+        def __init__(self, mac_address):
+            super().__init__(mac_address=mac_address)
+
+        def execute_command(self, mqtt_message_payload: bytes, mqtt_client: Client):
+            pass
+
     message = MQTTMessage(topic=topic)
     message.payload = payload
-    with unittest.mock.patch("switchbot_mqtt._send_command") as send_command_mock:
-        switchbot_mqtt._mqtt_on_message(None, None, message)
-    assert not send_command_mock.called
+    with unittest.mock.patch.object(
+        _ActorMock, "__init__", return_value=None
+    ) as init_mock, unittest.mock.patch.object(
+        _ActorMock, "execute_command"
+    ) as execute_command_mock, caplog.at_level(
+        logging.DEBUG
+    ):
+        _ActorMock._mqtt_command_callback("client_dummy", None, message)
+    init_mock.assert_not_called()
+    execute_command_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt",
+            logging.DEBUG,
+            "received topic={} payload={!r}".format(topic.decode(), payload),
+        ),
+        (
+            "switchbot_mqtt",
+            logging.WARNING,
+            "unexpected topic {}".format(topic.decode()),
+        ),
+    ]
+
+
+@pytest.mark.parametrize(("mac_address", "payload"), [("aa:01:23:4E:RR:OR", b"ON")])
+def test__mqtt_command_callback_invalid_mac_address(
+    caplog, mac_address: str, payload: bytes
+):
+    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+        MQTT_COMMAND_TOPIC_LEVELS = (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
+        )
+
+        def __init__(self, mac_address):
+            super().__init__(mac_address=mac_address)
+
+        def execute_command(self, mqtt_message_payload: bytes, mqtt_client: Client):
+            pass
+
+    topic = "homeassistant/switch/switchbot/{}/set".format(mac_address).encode()
+    message = MQTTMessage(topic=topic)
+    message.payload = payload
+    with unittest.mock.patch.object(
+        _ActorMock, "__init__", return_value=None
+    ) as init_mock, unittest.mock.patch.object(
+        _ActorMock, "execute_command"
+    ) as execute_command_mock, caplog.at_level(
+        logging.DEBUG
+    ):
+        _ActorMock._mqtt_command_callback("client_dummy", None, message)
+    init_mock.assert_not_called()
+    execute_command_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt",
+            logging.DEBUG,
+            "received topic={} payload={!r}".format(topic.decode(), payload),
+        ),
+        (
+            "switchbot_mqtt",
+            logging.WARNING,
+            "invalid mac address {}".format(mac_address),
+        ),
+    ]
 
 
 @pytest.mark.parametrize(
     ("topic", "payload"),
     [(b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"ON")],
 )
-def test__mqtt_on_message_ignored_retained(topic: bytes, payload: bytes):
+def test__mqtt_command_callback_ignore_retained(caplog, topic: bytes, payload: bytes):
+    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+        MQTT_COMMAND_TOPIC_LEVELS = (
+            switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
+        )
+
+        def __init__(self, mac_address):
+            super().__init__(mac_address=mac_address)
+
+        def execute_command(self, mqtt_message_payload: bytes, mqtt_client: Client):
+            pass
+
     message = MQTTMessage(topic=topic)
     message.payload = payload
     message.retain = True
-    with unittest.mock.patch("switchbot_mqtt._send_command") as send_command_mock:
-        switchbot_mqtt._mqtt_on_message(None, None, message)
-    assert not send_command_mock.called
+    with unittest.mock.patch.object(
+        _ActorMock, "__init__", return_value=None
+    ) as init_mock, unittest.mock.patch.object(
+        _ActorMock, "execute_command"
+    ) as execute_command_mock, caplog.at_level(
+        logging.DEBUG
+    ):
+        _ActorMock._mqtt_command_callback("client_dummy", None, message)
+    init_mock.assert_not_called()
+    execute_command_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt",
+            logging.DEBUG,
+            "received topic={} payload={!r}".format(topic.decode(), payload),
+        ),
+        ("switchbot_mqtt", logging.INFO, "ignoring retained message"),
+    ]
 
 
 @pytest.mark.parametrize(
-    ("switchbot_mac_address", "expected_topic"),
+    ("state_topic_levels", "mac_address", "expected_topic"),
     # https://www.home-assistant.io/docs/mqtt/discovery/#switches
-    [("aa:bb:cc:dd:ee:ff", "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state")],
-)
-@pytest.mark.parametrize(
-    ("state", "expected_payload"),
     [
-        (switchbot_mqtt._SwitchbotState.ON, b"ON"),
-        (switchbot_mqtt._SwitchbotState.OFF, b"OFF"),
+        (
+            switchbot_mqtt._ButtonAutomator.MQTT_STATE_TOPIC_LEVELS,
+            "aa:bb:cc:dd:ee:ff",
+            "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
+        ),
+        (
+            ["switchbot", switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
+            "aa:bb:cc:dd:ee:gg",
+            "switchbot/aa:bb:cc:dd:ee:gg/state",
+        ),
     ],
 )
+@pytest.mark.parametrize("state", [b"ON", b"CLOSE"])
 @pytest.mark.parametrize("return_code", [MQTT_ERR_SUCCESS, MQTT_ERR_QUEUE_SIZE])
 def test__report_state(
     caplog,
-    state: switchbot_mqtt._SwitchbotState,
-    switchbot_mac_address: str,
+    state_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
+    mac_address: str,
     expected_topic: str,
-    expected_payload: bytes,
+    state: bytes,
     return_code: int,
 ):
     # pylint: disable=too-many-arguments
+    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+        MQTT_STATE_TOPIC_LEVELS = state_topic_levels
+
+        def __init__(self, mac_address):
+            super().__init__(mac_address=mac_address)
+
+        def execute_command(self, mqtt_message_payload: bytes, mqtt_client: Client):
+            pass
+
     mqtt_client_mock = unittest.mock.MagicMock()
     mqtt_client_mock.publish.return_value.rc = return_code
-    with caplog.at_level(logging.WARNING):
-        switchbot_mqtt._report_state(
-            mqtt_client=mqtt_client_mock,
-            switchbot_mac_address=switchbot_mac_address,
-            switchbot_state=state,
+    with caplog.at_level(logging.DEBUG):
+        _ActorMock(mac_address=mac_address).report_state(
+            state=state, mqtt_client=mqtt_client_mock
         )
     mqtt_client_mock.publish.assert_called_once_with(
-        topic=expected_topic, payload=expected_payload, retain=True
+        topic=expected_topic, payload=state, retain=True
+    )
+    assert caplog.record_tuples[0] == (
+        "switchbot_mqtt",
+        logging.DEBUG,
+        "publishing topic={} payload={!r}".format(expected_topic, state),
     )
     if return_code == MQTT_ERR_SUCCESS:
-        assert len(caplog.records) == 0
+        assert not caplog.records[1:]
     else:
-        assert len(caplog.records) == 1
-        assert caplog.record_tuples[0] == (
-            "switchbot_mqtt",
-            logging.ERROR,
-            "failed to publish state (rc={})".format(return_code),
-        )
+        assert caplog.record_tuples[1:] == [
+            (
+                "switchbot_mqtt",
+                logging.ERROR,
+                "failed to publish state (rc={})".format(return_code),
+            )
+        ]

+ 0 - 87
tests/test_switchbot.py

@@ -1,87 +0,0 @@
-import logging
-import unittest.mock
-
-import bluepy.btle
-import pytest
-
-import switchbot_mqtt
-
-# pylint: disable=protected-access
-
-
-@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
-@pytest.mark.parametrize(
-    "action", [switchbot_mqtt._SwitchbotAction.ON, switchbot_mqtt._SwitchbotAction.OFF]
-)
-@pytest.mark.parametrize("command_successful", [True, False])
-def test__send_command(caplog, mac_address, action, command_successful):
-    with unittest.mock.patch("switchbot.Switchbot") as switchbot_device_mock:
-        switchbot_device_mock().turn_on.return_value = command_successful
-        switchbot_device_mock().turn_off.return_value = command_successful
-        switchbot_device_mock.reset_mock()
-        with unittest.mock.patch("switchbot_mqtt._report_state") as report_mock:
-            with caplog.at_level(logging.INFO):
-                switchbot_mqtt._send_command(
-                    mqtt_client="dummy",
-                    switchbot_mac_address=mac_address,
-                    action=action,
-                )
-    switchbot_device_mock.assert_called_once_with(mac=mac_address)
-    assert len(caplog.records) == 1
-    logger, log_level, log_message = caplog.record_tuples[0]
-    assert logger == "switchbot_mqtt"
-    if command_successful:
-        assert log_level == logging.INFO
-    else:
-        assert log_level == logging.ERROR
-        assert "failed" in log_message
-    assert mac_address in log_message
-    if action == switchbot_mqtt._SwitchbotAction.ON:
-        switchbot_device_mock().turn_on.assert_called_once_with()
-        assert not switchbot_device_mock().turn_off.called
-        assert "on" in log_message
-        expected_state = switchbot_mqtt._SwitchbotState.ON
-    else:
-        switchbot_device_mock().turn_off.assert_called_once_with()
-        assert not switchbot_device_mock().turn_on.called
-        assert "off" in log_message
-        expected_state = switchbot_mqtt._SwitchbotState.OFF
-    assert report_mock.called == command_successful
-    if command_successful:
-        report_mock.assert_called_once_with(
-            mqtt_client="dummy",
-            switchbot_mac_address=mac_address,
-            switchbot_state=expected_state,
-        )
-
-
-@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
-@pytest.mark.parametrize("action", [switchbot_mqtt._SwitchbotAction.ON])
-def test__send_command_bluetooth_error(caplog, mac_address, action):
-    """
-    paho.mqtt.python>=1.5.1 no longer implicitly suppresses exceptions in callbacks.
-    verify pySwitchbot catches exceptions raised in bluetooth stack.
-    https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L48
-    https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L94
-    """
-    with unittest.mock.patch(
-        "bluepy.btle.Peripheral",
-        side_effect=bluepy.btle.BTLEDisconnectError(
-            "Failed to connect to peripheral {}, addr type: random".format(mac_address)
-        ),
-    ), caplog.at_level(logging.ERROR):
-        switchbot_mqtt._send_command(
-            mqtt_client=None, switchbot_mac_address=mac_address, action=action
-        )
-    assert caplog.record_tuples == [
-        (
-            "switchbot",
-            logging.ERROR,
-            "Switchbot communication failed. Stopping trying.",
-        ),
-        (
-            "switchbot_mqtt",
-            logging.ERROR,
-            "failed to turn on switchbot {}".format(mac_address),
-        ),
-    ]

+ 140 - 0
tests/test_switchbot_button_automator.py

@@ -0,0 +1,140 @@
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
+#
+# Copyright (C) 2020 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 bluepy.btle
+import pytest
+
+import switchbot_mqtt
+
+# pylint: disable=protected-access
+
+
+@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff", "aa:bb:cc:11:22:33"])
+@pytest.mark.parametrize(
+    ("message_payload", "action_name"),
+    [
+        (b"on", "switchbot.Switchbot.turn_on"),
+        (b"ON", "switchbot.Switchbot.turn_on"),
+        (b"On", "switchbot.Switchbot.turn_on"),
+        (b"off", "switchbot.Switchbot.turn_off"),
+        (b"OFF", "switchbot.Switchbot.turn_off"),
+        (b"Off", "switchbot.Switchbot.turn_off"),
+    ],
+)
+@pytest.mark.parametrize("command_successful", [True, False])
+def test_execute_command(
+    caplog, mac_address, message_payload, action_name, command_successful
+):
+    with unittest.mock.patch(
+        "switchbot.Switchbot.__init__", return_value=None
+    ) as device_init_mock, caplog.at_level(logging.INFO):
+        actor = switchbot_mqtt._ButtonAutomator(mac_address=mac_address)
+        with unittest.mock.patch.object(
+            actor, "report_state"
+        ) as report_mock, unittest.mock.patch(
+            action_name, return_value=command_successful
+        ) as action_mock:
+            actor.execute_command(
+                mqtt_client="dummy", mqtt_message_payload=message_payload
+            )
+    device_init_mock.assert_called_once_with(mac=mac_address)
+    action_mock.assert_called_once_with()
+    if command_successful:
+        assert caplog.record_tuples == [
+            (
+                "switchbot_mqtt",
+                logging.INFO,
+                "switchbot {} turned {}".format(
+                    mac_address, message_payload.decode().lower()
+                ),
+            )
+        ]
+        report_mock.assert_called_once_with(
+            mqtt_client="dummy", state=message_payload.upper()
+        )
+    else:
+        assert caplog.record_tuples == [
+            (
+                "switchbot_mqtt",
+                logging.ERROR,
+                "failed to turn {} switchbot {}".format(
+                    message_payload.decode().lower(), mac_address
+                ),
+            )
+        ]
+        report_mock.assert_not_called()
+
+
+@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
+@pytest.mark.parametrize("message_payload", [b"EIN", b""])
+def test_execute_command_invalid_payload(caplog, mac_address, message_payload):
+    with unittest.mock.patch("switchbot.Switchbot") as device_mock, caplog.at_level(
+        logging.INFO
+    ):
+        actor = switchbot_mqtt._ButtonAutomator(mac_address=mac_address)
+        with unittest.mock.patch.object(actor, "report_state") as report_mock:
+            actor.execute_command(
+                mqtt_client="dummy", mqtt_message_payload=message_payload
+            )
+    device_mock.assert_called_once_with(mac=mac_address)
+    assert not device_mock().mock_calls  # no methods called
+    report_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt",
+            logging.WARNING,
+            "unexpected payload {!r} (expected 'ON' or 'OFF')".format(message_payload),
+        )
+    ]
+
+
+@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
+@pytest.mark.parametrize("message_payload", [b"ON", b"OFF"])
+def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
+    """
+    paho.mqtt.python>=1.5.1 no longer implicitly suppresses exceptions in callbacks.
+    verify pySwitchbot catches exceptions raised in bluetooth stack.
+    https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L48
+    https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L94
+    """
+    with unittest.mock.patch(
+        "bluepy.btle.Peripheral",
+        side_effect=bluepy.btle.BTLEDisconnectError(
+            "Failed to connect to peripheral {}, addr type: random".format(mac_address)
+        ),
+    ), caplog.at_level(logging.ERROR):
+        switchbot_mqtt._ButtonAutomator(mac_address=mac_address).execute_command(
+            mqtt_client="dummy", mqtt_message_payload=message_payload
+        )
+    assert caplog.record_tuples == [
+        (
+            "switchbot",
+            logging.ERROR,
+            "Switchbot communication failed. Stopping trying.",
+        ),
+        (
+            "switchbot_mqtt",
+            logging.ERROR,
+            "failed to turn {} switchbot {}".format(
+                message_payload.decode().lower(), mac_address
+            ),
+        ),
+    ]

+ 152 - 0
tests/test_switchbot_curtain_motor.py

@@ -0,0 +1,152 @@
+# switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
+# compatible with home-assistant.io's MQTT Switch & Cover platform
+#
+# Copyright (C) 2020 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 bluepy.btle
+import pytest
+
+import switchbot_mqtt
+
+# pylint: disable=protected-access,
+
+
+@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff", "aa:bb:cc:11:22:33"])
+@pytest.mark.parametrize(
+    ("message_payload", "action_name"),
+    [
+        (b"open", "switchbot.SwitchbotCurtain.open"),
+        (b"OPEN", "switchbot.SwitchbotCurtain.open"),
+        (b"Open", "switchbot.SwitchbotCurtain.open"),
+        (b"close", "switchbot.SwitchbotCurtain.close"),
+        (b"CLOSE", "switchbot.SwitchbotCurtain.close"),
+        (b"Close", "switchbot.SwitchbotCurtain.close"),
+        (b"stop", "switchbot.SwitchbotCurtain.stop"),
+        (b"STOP", "switchbot.SwitchbotCurtain.stop"),
+        (b"Stop", "switchbot.SwitchbotCurtain.stop"),
+    ],
+)
+@pytest.mark.parametrize("command_successful", [True, False])
+def test_execute_command(
+    caplog, mac_address, message_payload, action_name, command_successful
+):
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain.__init__", return_value=None
+    ) as device_init_mock, caplog.at_level(logging.INFO):
+        actor = switchbot_mqtt._CurtainMotor(mac_address=mac_address)
+        with unittest.mock.patch.object(
+            actor, "report_state"
+        ) as report_mock, unittest.mock.patch(
+            action_name, return_value=command_successful
+        ) as action_mock:
+            actor.execute_command(
+                mqtt_client="dummy", mqtt_message_payload=message_payload
+            )
+    device_init_mock.assert_called_once_with(mac=mac_address)
+    action_mock.assert_called_once_with()
+    if command_successful:
+        assert caplog.record_tuples == [
+            (
+                "switchbot_mqtt",
+                logging.INFO,
+                "switchbot curtain {} {}".format(
+                    mac_address,
+                    {b"open": "opening", b"close": "closing", b"stop": "stopped"}[
+                        message_payload.lower()
+                    ],
+                ),
+            )
+        ]
+        report_mock.assert_called_once_with(
+            mqtt_client="dummy",
+            # https://www.home-assistant.io/integrations/cover.mqtt/#state_opening
+            state={b"open": b"opening", b"close": b"closing", b"stop": b""}[
+                message_payload.lower()
+            ],
+        )
+    else:
+        assert caplog.record_tuples == [
+            (
+                "switchbot_mqtt",
+                logging.ERROR,
+                "failed to {} switchbot curtain {}".format(
+                    message_payload.decode().lower(), mac_address
+                ),
+            )
+        ]
+        report_mock.assert_not_called()
+
+
+@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
+@pytest.mark.parametrize("message_payload", [b"OEFFNEN", b""])
+def test_execute_command_invalid_payload(caplog, mac_address, message_payload):
+    with unittest.mock.patch(
+        "switchbot.SwitchbotCurtain"
+    ) as device_mock, caplog.at_level(logging.INFO):
+        actor = switchbot_mqtt._CurtainMotor(mac_address=mac_address)
+        with unittest.mock.patch.object(actor, "report_state") as report_mock:
+            actor.execute_command(
+                mqtt_client="dummy", mqtt_message_payload=message_payload
+            )
+    device_mock.assert_called_once_with(mac=mac_address)
+    assert not device_mock().mock_calls  # no methods called
+    report_mock.assert_not_called()
+    assert caplog.record_tuples == [
+        (
+            "switchbot_mqtt",
+            logging.WARNING,
+            "unexpected payload {!r} (expected 'OPEN', 'CLOSE', or 'STOP')".format(
+                message_payload
+            ),
+        )
+    ]
+
+
+@pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
+@pytest.mark.parametrize("message_payload", [b"OPEN", b"CLOSE", b"STOP"])
+def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
+    """
+    paho.mqtt.python>=1.5.1 no longer implicitly suppresses exceptions in callbacks.
+    verify pySwitchbot catches exceptions raised in bluetooth stack.
+    https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L48
+    https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L94
+    """
+    with unittest.mock.patch(
+        "bluepy.btle.Peripheral",
+        side_effect=bluepy.btle.BTLEDisconnectError(
+            "Failed to connect to peripheral {}, addr type: random".format(mac_address)
+        ),
+    ), caplog.at_level(logging.ERROR):
+        switchbot_mqtt._CurtainMotor(mac_address=mac_address).execute_command(
+            mqtt_client="dummy", mqtt_message_payload=message_payload
+        )
+    assert caplog.record_tuples == [
+        (
+            "switchbot",
+            logging.ERROR,
+            "Switchbot communication failed. Stopping trying.",
+        ),
+        (
+            "switchbot_mqtt",
+            logging.ERROR,
+            "failed to {} switchbot curtain {}".format(
+                message_payload.decode().lower(), mac_address
+            ),
+        ),
+    ]