Browse Source

refactor: move actor classes to switchbot_mqtt/_actors.py

Fabian Peter Hammerle 2 years ago
parent
commit
383e1e9cce

+ 4 - 404
switchbot_mqtt/__init__.py

@@ -16,419 +16,19 @@
 # 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 logging
-import queue
-import shlex
 import typing
 
-import bluepy.btle
 import paho.mqtt.client
-import switchbot
 
-from switchbot_mqtt._utils import (
-    _join_mqtt_topic_levels,
-    _mac_address_valid,
-    _MQTTTopicLevel,
-    _MQTTTopicPlaceholder,
-    _QueueLogHandler,
+from switchbot_mqtt._actors import (
+    _ButtonAutomator,
+    _CurtainMotor,
+    _MQTTCallbackUserdata,
 )
 
 _LOGGER = logging.getLogger(__name__)
 
-# "homeassistant" for historic reason, may be parametrized in future
-_MQTT_TOPIC_LEVELS_PREFIX: typing.List[_MQTTTopicLevel] = ["homeassistant"]
-
-
-class _MQTTCallbackUserdata:
-    # pylint: disable=too-few-public-methods; @dataclasses.dataclass when python_requires>=3.7
-    def __init__(
-        self,
-        *,
-        retry_count: int,
-        device_passwords: typing.Dict[str, str],
-        fetch_device_info: bool,
-    ) -> None:
-        self.retry_count = retry_count
-        self.device_passwords = device_passwords
-        self.fetch_device_info = fetch_device_info
-
-    def __eq__(self, other: object) -> bool:
-        return isinstance(other, type(self)) and vars(self) == vars(other)
-
-
-class _MQTTControlledActor(abc.ABC):
-    MQTT_COMMAND_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
-    MQTT_STATE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
-    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
-
-    @classmethod
-    def get_mqtt_battery_percentage_topic(cls, mac_address: str) -> str:
-        return _join_mqtt_topic_levels(
-            topic_levels=cls._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
-            mac_address=mac_address,
-        )
-
-    @abc.abstractmethod
-    def __init__(
-        self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
-    ) -> None:
-        # alternative: pySwitchbot >=0.10.0 provides SwitchbotDevice.get_mac()
-        self._mac_address = mac_address
-
-    @abc.abstractmethod
-    def _get_device(self) -> switchbot.SwitchbotDevice:
-        raise NotImplementedError()
-
-    def _update_device_info(self) -> None:
-        log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=0)
-        logging.getLogger("switchbot").addHandler(_QueueLogHandler(log_queue))
-        try:
-            self._get_device().update()
-            # pySwitchbot>=v0.10.1 catches bluepy.btle.BTLEManagementError :(
-            # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.1/switchbot/__init__.py#L141
-            while not log_queue.empty():
-                log_record = log_queue.get()
-                if log_record.exc_info:
-                    exc: typing.Optional[BaseException] = log_record.exc_info[1]
-                    if (
-                        isinstance(exc, bluepy.btle.BTLEManagementError)
-                        and exc.emsg == "Permission Denied"
-                    ):
-                        raise exc
-        except bluepy.btle.BTLEManagementError as exc:
-            if (
-                exc.emsg == "Permission Denied"
-                and exc.message == "Failed to execute management command 'le on'"
-            ):
-                raise PermissionError(
-                    "bluepy-helper failed to enable low energy mode"
-                    " due to insufficient permissions."
-                    "\nSee https://github.com/IanHarvey/bluepy/issues/313#issuecomment-428324639"
-                    ", https://github.com/fphammerle/switchbot-mqtt/pull/31#issuecomment-846383603"
-                    ", and https://github.com/IanHarvey/bluepy/blob/v/1.3.0/bluepy"
-                    "/bluepy-helper.c#L1260."
-                    "\nInsecure workaround:"
-                    "\n1. sudo apt-get install --no-install-recommends libcap2-bin"
-                    f"\n2. sudo setcap cap_net_admin+ep {shlex.quote(bluepy.btle.helperExe)}"
-                    "\n3. restart switchbot-mqtt"
-                    "\nIn docker-based setups, you could use"
-                    " `sudo docker run --cap-drop ALL --cap-add NET_ADMIN --user 0 …`"
-                    " (seriously insecure)."
-                ) from exc
-            raise
-
-    def _report_battery_level(self, mqtt_client: paho.mqtt.client.Client) -> None:
-        # > battery: Percentage of battery that is left.
-        # https://www.home-assistant.io/integrations/sensor/#device-class
-        self._mqtt_publish(
-            topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
-            payload=str(self._get_device().get_battery_percent()).encode(),
-            mqtt_client=mqtt_client,
-        )
-
-    def _update_and_report_device_info(
-        self, mqtt_client: paho.mqtt.client.Client
-    ) -> None:
-        self._update_device_info()
-        self._report_battery_level(mqtt_client=mqtt_client)
-
-    @abc.abstractmethod
-    def execute_command(
-        self,
-        mqtt_message_payload: bytes,
-        mqtt_client: paho.mqtt.client.Client,
-        update_device_info: bool,
-    ) -> None:
-        raise NotImplementedError()
-
-    @classmethod
-    def _mqtt_command_callback(
-        cls,
-        mqtt_client: paho.mqtt.client.Client,
-        userdata: _MQTTCallbackUserdata,
-        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
-        actor = cls(
-            mac_address=mac_address,
-            retry_count=userdata.retry_count,
-            password=userdata.device_passwords.get(mac_address, None),
-        )
-        actor.execute_command(
-            mqtt_message_payload=message.payload,
-            mqtt_client=mqtt_client,
-            # consider calling update+report method directly when adding support for battery levels
-            update_device_info=userdata.fetch_device_info,
-        )
-
-    @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
-        )
-        _LOGGER.info("subscribing to MQTT topic %r", command_topic)
-        mqtt_client.subscribe(command_topic)
-        mqtt_client.message_callback_add(
-            sub=command_topic,
-            callback=cls._mqtt_command_callback,
-        )
-
-    def _mqtt_publish(
-        self,
-        *,
-        topic_levels: typing.List[_MQTTTopicLevel],
-        payload: bytes,
-        mqtt_client: paho.mqtt.client.Client,
-    ) -> None:
-        topic = _join_mqtt_topic_levels(
-            topic_levels=topic_levels, mac_address=self._mac_address
-        )
-        # https://pypi.org/project/paho-mqtt/#publishing
-        _LOGGER.debug("publishing topic=%s payload=%r", topic, payload)
-        message_info: paho.mqtt.client.MQTTMessageInfo = mqtt_client.publish(
-            topic=topic, payload=payload, retain=True
-        )
-        # wait before checking status?
-        if message_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
-            _LOGGER.error(
-                "Failed to publish MQTT message on topic %s (rc=%d)",
-                topic,
-                message_info.rc,
-            )
-
-    def report_state(self, state: bytes, mqtt_client: paho.mqtt.client.Client) -> None:
-        self._mqtt_publish(
-            topic_levels=self.MQTT_STATE_TOPIC_LEVELS,
-            payload=state,
-            mqtt_client=mqtt_client,
-        )
-
-
-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",
-    ]
-    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
-        "switch",
-        "switchbot",
-        _MQTTTopicPlaceholder.MAC_ADDRESS,
-        "battery-percentage",
-    ]
-    # for downward compatibility (will be removed in v3):
-    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS_LEGACY = _MQTT_TOPIC_LEVELS_PREFIX + [
-        "cover",
-        "switchbot",
-        _MQTTTopicPlaceholder.MAC_ADDRESS,
-        "battery-percentage",
-    ]
-
-    def __init__(
-        self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
-    ) -> None:
-        self.__device = switchbot.Switchbot(
-            mac=mac_address, password=password, retry_count=retry_count
-        )
-        super().__init__(
-            mac_address=mac_address, retry_count=retry_count, password=password
-        )
-
-    def _get_device(self) -> switchbot.SwitchbotDevice:
-        return self.__device
-
-    def _report_battery_level(self, mqtt_client: paho.mqtt.client.Client) -> None:
-        super()._report_battery_level(mqtt_client=mqtt_client)
-        # kept for downward compatibility (will be removed in v3)
-        self._mqtt_publish(
-            topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS_LEGACY,
-            payload=str(self._get_device().get_battery_percent()).encode(),
-            mqtt_client=mqtt_client,
-        )
-
-    def execute_command(
-        self,
-        mqtt_message_payload: bytes,
-        mqtt_client: paho.mqtt.client.Client,
-        update_device_info: bool,
-    ) -> 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")
-                if update_device_info:
-                    self._update_and_report_device_info(mqtt_client)
-        # 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")
-                if update_device_info:
-                    self._update_and_report_device_info(mqtt_client)
-        else:
-            _LOGGER.warning(
-                "unexpected payload %r (expected 'ON' or 'OFF')", mqtt_message_payload
-            )
-
-
-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",
-    ]
-    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
-        "cover",
-        "switchbot-curtain",
-        _MQTTTopicPlaceholder.MAC_ADDRESS,
-        "battery-percentage",
-    ]
-    _MQTT_POSITION_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
-        "cover",
-        "switchbot-curtain",
-        _MQTTTopicPlaceholder.MAC_ADDRESS,
-        "position",
-    ]
-
-    @classmethod
-    def get_mqtt_position_topic(cls, mac_address: str) -> str:
-        return _join_mqtt_topic_levels(
-            topic_levels=cls._MQTT_POSITION_TOPIC_LEVELS, mac_address=mac_address
-        )
-
-    def __init__(
-        self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
-    ) -> None:
-        # > The position of the curtain is saved in self._pos with 0 = open and 100 = closed.
-        # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L150
-        self.__device = switchbot.SwitchbotCurtain(
-            mac=mac_address,
-            password=password,
-            retry_count=retry_count,
-            reverse_mode=True,
-        )
-        super().__init__(
-            mac_address=mac_address, retry_count=retry_count, password=password
-        )
-
-    def _get_device(self) -> switchbot.SwitchbotDevice:
-        return self.__device
-
-    def _report_position(self, mqtt_client: paho.mqtt.client.Client) -> None:
-        # > position_closed integer (Optional, default: 0)
-        # > position_open integer (Optional, default: 100)
-        # https://www.home-assistant.io/integrations/cover.mqtt/#position_closed
-        # SwitchbotCurtain.get_position() returns a cached value within [0, 100].
-        # SwitchbotCurtain.open() and .close() update the position optimistically,
-        # SwitchbotCurtain.update() fetches the real position via bluetooth.
-        # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L202
-        self._mqtt_publish(
-            topic_levels=self._MQTT_POSITION_TOPIC_LEVELS,
-            payload=str(int(self.__device.get_position())).encode(),
-            mqtt_client=mqtt_client,
-        )
-
-    def _update_and_report_device_info(  # pylint: disable=arguments-differ; report_position is optional
-        self, mqtt_client: paho.mqtt.client.Client, *, report_position: bool = True
-    ) -> None:
-        super()._update_and_report_device_info(mqtt_client)
-        if report_position:
-            self._report_position(mqtt_client=mqtt_client)
-
-    def execute_command(
-        self,
-        mqtt_message_payload: bytes,
-        mqtt_client: paho.mqtt.client.Client,
-        update_device_info: bool,
-    ) -> None:
-        # https://www.home-assistant.io/integrations/cover.mqtt/#payload_open
-        report_device_info, report_position = False, False
-        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")
-                report_device_info = update_device_info
-        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")
-                report_device_info = update_device_info
-        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"")
-                report_device_info = update_device_info
-                report_position = True
-        else:
-            _LOGGER.warning(
-                "unexpected payload %r (expected 'OPEN', 'CLOSE', or 'STOP')",
-                mqtt_message_payload,
-            )
-        if report_device_info:
-            self._update_and_report_device_info(
-                mqtt_client=mqtt_client, report_position=report_position
-            )
-
 
 def _mqtt_on_connect(
     mqtt_client: paho.mqtt.client.Client,

+ 430 - 0
switchbot_mqtt/_actors.py

@@ -0,0 +1,430 @@
+# 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 abc
+import logging
+import queue
+import shlex
+import typing
+
+import bluepy.btle
+import paho.mqtt.client
+import switchbot
+
+from switchbot_mqtt._utils import (
+    _join_mqtt_topic_levels,
+    _mac_address_valid,
+    _MQTTTopicLevel,
+    _MQTTTopicPlaceholder,
+    _QueueLogHandler,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+# "homeassistant" for historic reason, may be parametrized in future
+_MQTT_TOPIC_LEVELS_PREFIX: typing.List[_MQTTTopicLevel] = ["homeassistant"]
+
+
+class _MQTTCallbackUserdata:
+    # pylint: disable=too-few-public-methods; @dataclasses.dataclass when python_requires>=3.7
+    def __init__(
+        self,
+        *,
+        retry_count: int,
+        device_passwords: typing.Dict[str, str],
+        fetch_device_info: bool,
+    ) -> None:
+        self.retry_count = retry_count
+        self.device_passwords = device_passwords
+        self.fetch_device_info = fetch_device_info
+
+    def __eq__(self, other: object) -> bool:
+        return isinstance(other, type(self)) and vars(self) == vars(other)
+
+
+class _MQTTControlledActor(abc.ABC):
+    MQTT_COMMAND_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
+    MQTT_STATE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
+    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
+
+    @classmethod
+    def get_mqtt_battery_percentage_topic(cls, mac_address: str) -> str:
+        return _join_mqtt_topic_levels(
+            topic_levels=cls._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
+            mac_address=mac_address,
+        )
+
+    @abc.abstractmethod
+    def __init__(
+        self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
+    ) -> None:
+        # alternative: pySwitchbot >=0.10.0 provides SwitchbotDevice.get_mac()
+        self._mac_address = mac_address
+
+    @abc.abstractmethod
+    def _get_device(self) -> switchbot.SwitchbotDevice:
+        raise NotImplementedError()
+
+    def _update_device_info(self) -> None:
+        log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=0)
+        logging.getLogger("switchbot").addHandler(_QueueLogHandler(log_queue))
+        try:
+            self._get_device().update()
+            # pySwitchbot>=v0.10.1 catches bluepy.btle.BTLEManagementError :(
+            # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.1/switchbot/__init__.py#L141
+            while not log_queue.empty():
+                log_record = log_queue.get()
+                if log_record.exc_info:
+                    exc: typing.Optional[BaseException] = log_record.exc_info[1]
+                    if (
+                        isinstance(exc, bluepy.btle.BTLEManagementError)
+                        and exc.emsg == "Permission Denied"
+                    ):
+                        raise exc
+        except bluepy.btle.BTLEManagementError as exc:
+            if (
+                exc.emsg == "Permission Denied"
+                and exc.message == "Failed to execute management command 'le on'"
+            ):
+                raise PermissionError(
+                    "bluepy-helper failed to enable low energy mode"
+                    " due to insufficient permissions."
+                    "\nSee https://github.com/IanHarvey/bluepy/issues/313#issuecomment-428324639"
+                    ", https://github.com/fphammerle/switchbot-mqtt/pull/31#issuecomment-846383603"
+                    ", and https://github.com/IanHarvey/bluepy/blob/v/1.3.0/bluepy"
+                    "/bluepy-helper.c#L1260."
+                    "\nInsecure workaround:"
+                    "\n1. sudo apt-get install --no-install-recommends libcap2-bin"
+                    f"\n2. sudo setcap cap_net_admin+ep {shlex.quote(bluepy.btle.helperExe)}"
+                    "\n3. restart switchbot-mqtt"
+                    "\nIn docker-based setups, you could use"
+                    " `sudo docker run --cap-drop ALL --cap-add NET_ADMIN --user 0 …`"
+                    " (seriously insecure)."
+                ) from exc
+            raise
+
+    def _report_battery_level(self, mqtt_client: paho.mqtt.client.Client) -> None:
+        # > battery: Percentage of battery that is left.
+        # https://www.home-assistant.io/integrations/sensor/#device-class
+        self._mqtt_publish(
+            topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
+            payload=str(self._get_device().get_battery_percent()).encode(),
+            mqtt_client=mqtt_client,
+        )
+
+    def _update_and_report_device_info(
+        self, mqtt_client: paho.mqtt.client.Client
+    ) -> None:
+        self._update_device_info()
+        self._report_battery_level(mqtt_client=mqtt_client)
+
+    @abc.abstractmethod
+    def execute_command(
+        self,
+        mqtt_message_payload: bytes,
+        mqtt_client: paho.mqtt.client.Client,
+        update_device_info: bool,
+    ) -> None:
+        raise NotImplementedError()
+
+    @classmethod
+    def _mqtt_command_callback(
+        cls,
+        mqtt_client: paho.mqtt.client.Client,
+        userdata: _MQTTCallbackUserdata,
+        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
+        actor = cls(
+            mac_address=mac_address,
+            retry_count=userdata.retry_count,
+            password=userdata.device_passwords.get(mac_address, None),
+        )
+        actor.execute_command(
+            mqtt_message_payload=message.payload,
+            mqtt_client=mqtt_client,
+            # consider calling update+report method directly when adding support for battery levels
+            update_device_info=userdata.fetch_device_info,
+        )
+
+    @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
+        )
+        _LOGGER.info("subscribing to MQTT topic %r", command_topic)
+        mqtt_client.subscribe(command_topic)
+        mqtt_client.message_callback_add(
+            sub=command_topic,
+            callback=cls._mqtt_command_callback,
+        )
+
+    def _mqtt_publish(
+        self,
+        *,
+        topic_levels: typing.List[_MQTTTopicLevel],
+        payload: bytes,
+        mqtt_client: paho.mqtt.client.Client,
+    ) -> None:
+        topic = _join_mqtt_topic_levels(
+            topic_levels=topic_levels, mac_address=self._mac_address
+        )
+        # https://pypi.org/project/paho-mqtt/#publishing
+        _LOGGER.debug("publishing topic=%s payload=%r", topic, payload)
+        message_info: paho.mqtt.client.MQTTMessageInfo = mqtt_client.publish(
+            topic=topic, payload=payload, retain=True
+        )
+        # wait before checking status?
+        if message_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
+            _LOGGER.error(
+                "Failed to publish MQTT message on topic %s (rc=%d)",
+                topic,
+                message_info.rc,
+            )
+
+    def report_state(self, state: bytes, mqtt_client: paho.mqtt.client.Client) -> None:
+        self._mqtt_publish(
+            topic_levels=self.MQTT_STATE_TOPIC_LEVELS,
+            payload=state,
+            mqtt_client=mqtt_client,
+        )
+
+
+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",
+    ]
+    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "switch",
+        "switchbot",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "battery-percentage",
+    ]
+    # for downward compatibility (will be removed in v3):
+    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS_LEGACY = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "cover",
+        "switchbot",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "battery-percentage",
+    ]
+
+    def __init__(
+        self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
+    ) -> None:
+        self.__device = switchbot.Switchbot(
+            mac=mac_address, password=password, retry_count=retry_count
+        )
+        super().__init__(
+            mac_address=mac_address, retry_count=retry_count, password=password
+        )
+
+    def _get_device(self) -> switchbot.SwitchbotDevice:
+        return self.__device
+
+    def _report_battery_level(self, mqtt_client: paho.mqtt.client.Client) -> None:
+        super()._report_battery_level(mqtt_client=mqtt_client)
+        # kept for downward compatibility (will be removed in v3)
+        self._mqtt_publish(
+            topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS_LEGACY,
+            payload=str(self._get_device().get_battery_percent()).encode(),
+            mqtt_client=mqtt_client,
+        )
+
+    def execute_command(
+        self,
+        mqtt_message_payload: bytes,
+        mqtt_client: paho.mqtt.client.Client,
+        update_device_info: bool,
+    ) -> 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")
+                if update_device_info:
+                    self._update_and_report_device_info(mqtt_client)
+        # 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")
+                if update_device_info:
+                    self._update_and_report_device_info(mqtt_client)
+        else:
+            _LOGGER.warning(
+                "unexpected payload %r (expected 'ON' or 'OFF')", mqtt_message_payload
+            )
+
+
+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",
+    ]
+    _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "cover",
+        "switchbot-curtain",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "battery-percentage",
+    ]
+    _MQTT_POSITION_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
+        "cover",
+        "switchbot-curtain",
+        _MQTTTopicPlaceholder.MAC_ADDRESS,
+        "position",
+    ]
+
+    @classmethod
+    def get_mqtt_position_topic(cls, mac_address: str) -> str:
+        return _join_mqtt_topic_levels(
+            topic_levels=cls._MQTT_POSITION_TOPIC_LEVELS, mac_address=mac_address
+        )
+
+    def __init__(
+        self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
+    ) -> None:
+        # > The position of the curtain is saved in self._pos with 0 = open and 100 = closed.
+        # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L150
+        self.__device = switchbot.SwitchbotCurtain(
+            mac=mac_address,
+            password=password,
+            retry_count=retry_count,
+            reverse_mode=True,
+        )
+        super().__init__(
+            mac_address=mac_address, retry_count=retry_count, password=password
+        )
+
+    def _get_device(self) -> switchbot.SwitchbotDevice:
+        return self.__device
+
+    def _report_position(self, mqtt_client: paho.mqtt.client.Client) -> None:
+        # > position_closed integer (Optional, default: 0)
+        # > position_open integer (Optional, default: 100)
+        # https://www.home-assistant.io/integrations/cover.mqtt/#position_closed
+        # SwitchbotCurtain.get_position() returns a cached value within [0, 100].
+        # SwitchbotCurtain.open() and .close() update the position optimistically,
+        # SwitchbotCurtain.update() fetches the real position via bluetooth.
+        # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L202
+        self._mqtt_publish(
+            topic_levels=self._MQTT_POSITION_TOPIC_LEVELS,
+            payload=str(int(self.__device.get_position())).encode(),
+            mqtt_client=mqtt_client,
+        )
+
+    def _update_and_report_device_info(  # pylint: disable=arguments-differ; report_position is optional
+        self, mqtt_client: paho.mqtt.client.Client, *, report_position: bool = True
+    ) -> None:
+        super()._update_and_report_device_info(mqtt_client)
+        if report_position:
+            self._report_position(mqtt_client=mqtt_client)
+
+    def execute_command(
+        self,
+        mqtt_message_payload: bytes,
+        mqtt_client: paho.mqtt.client.Client,
+        update_device_info: bool,
+    ) -> None:
+        # https://www.home-assistant.io/integrations/cover.mqtt/#payload_open
+        report_device_info, report_position = False, False
+        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")
+                report_device_info = update_device_info
+        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")
+                report_device_info = update_device_info
+        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"")
+                report_device_info = update_device_info
+                report_position = True
+        else:
+            _LOGGER.warning(
+                "unexpected payload %r (expected 'OPEN', 'CLOSE', or 'STOP')",
+                mqtt_message_payload,
+            )
+        if report_device_info:
+            self._update_and_report_device_info(
+                mqtt_client=mqtt_client, report_position=report_position
+            )

+ 3 - 3
tests/test_actor_base.py

@@ -21,7 +21,7 @@ import typing
 import paho.mqtt.client
 import pytest
 
-import switchbot_mqtt
+import switchbot_mqtt._actors
 
 # pylint: disable=protected-access
 
@@ -29,13 +29,13 @@ import switchbot_mqtt
 def test_abstract():
     with pytest.raises(TypeError, match=r"\babstract class\b"):
         # pylint: disable=abstract-class-instantiated
-        switchbot_mqtt._MQTTControlledActor(
+        switchbot_mqtt._actors._MQTTControlledActor(
             mac_address=None, retry_count=21, password=None
         )
 
 
 def test_execute_command_abstract():
-    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+    class _ActorMock(switchbot_mqtt._actors._MQTTControlledActor):
         def __init__(
             self, mac_address: str, retry_count: int, password: typing.Optional[str]
         ) -> None:

+ 21 - 24
tests/test_mqtt.py

@@ -24,6 +24,8 @@ import pytest
 from paho.mqtt.client import MQTT_ERR_QUEUE_SIZE, MQTT_ERR_SUCCESS, MQTTMessage, Client
 
 import switchbot_mqtt
+import switchbot_mqtt._actors
+from switchbot_mqtt._utils import _MQTTTopicLevel, _MQTTTopicPlaceholder
 
 # pylint: disable=protected-access
 # pylint: disable=too-many-arguments; these are tests, no API
@@ -91,12 +93,12 @@ def test__run(
             f"connected to MQTT broker {mqtt_host}:{mqtt_port}",
         ),
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.INFO,
             "subscribing to MQTT topic 'homeassistant/switch/switchbot/+/set'",
         ),
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.INFO,
             "subscribing to MQTT topic 'homeassistant/cover/switchbot-curtain/+/set'",
         ),
@@ -146,9 +148,9 @@ def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_passwor
 
 
 def _mock_actor_class(
-    command_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
+    command_topic_levels: typing.List[_MQTTTopicLevel],
 ) -> typing.Type:
-    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+    class _ActorMock(switchbot_mqtt._actors._MQTTControlledActor):
         MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
 
         def __init__(self, mac_address, retry_count, password):
@@ -204,7 +206,7 @@ def _mock_actor_class(
             "aa:01:23:45:67:89",
         ),
         (
-            ["switchbot", switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS],
+            ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS],
             b"switchbot/aa:01:23:45:67:89",
             b"ON",
             "aa:01:23:45:67:89",
@@ -221,7 +223,7 @@ def _mock_actor_class(
 @pytest.mark.parametrize("fetch_device_info", [True, False])
 def test__mqtt_command_callback(
     caplog,
-    command_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
+    command_topic_levels: typing.List[_MQTTTopicLevel],
     topic: bytes,
     payload: bytes,
     expected_mac_address: str,
@@ -254,7 +256,7 @@ def test__mqtt_command_callback(
     )
     assert caplog.record_tuples == [
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.DEBUG,
             f"received topic={topic.decode()} payload={payload!r}",
         )
@@ -270,12 +272,7 @@ def test__mqtt_command_callback(
     ],
 )
 def test__mqtt_command_callback_password(mac_address, expected_password):
-    ActorMock = _mock_actor_class(
-        [
-            "switchbot",
-            switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS,
-        ]
-    )
+    ActorMock = _mock_actor_class(["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS])
     message = MQTTMessage(topic=b"switchbot/" + mac_address.encode())
     message.payload = b"whatever"
     callback_userdata = switchbot_mqtt._MQTTCallbackUserdata(
@@ -335,12 +332,12 @@ def test__mqtt_command_callback_unexpected_topic(caplog, topic: bytes, payload:
     execute_command_mock.assert_not_called()
     assert caplog.record_tuples == [
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.DEBUG,
             f"received topic={topic.decode()} payload={payload!r}",
         ),
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.WARNING,
             f"unexpected topic {topic.decode()}",
         ),
@@ -375,12 +372,12 @@ def test__mqtt_command_callback_invalid_mac_address(
     execute_command_mock.assert_not_called()
     assert caplog.record_tuples == [
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.DEBUG,
             f"received topic={topic.decode()} payload={payload!r}",
         ),
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.WARNING,
             f"invalid mac address {mac_address}",
         ),
@@ -416,11 +413,11 @@ def test__mqtt_command_callback_ignore_retained(caplog, topic: bytes, payload: b
     execute_command_mock.assert_not_called()
     assert caplog.record_tuples == [
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.DEBUG,
             f"received topic={topic.decode()} payload={payload!r}",
         ),
-        ("switchbot_mqtt", logging.INFO, "ignoring retained message"),
+        ("switchbot_mqtt._actors", logging.INFO, "ignoring retained message"),
     ]
 
 
@@ -434,7 +431,7 @@ def test__mqtt_command_callback_ignore_retained(caplog, topic: bytes, payload: b
             "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
         ),
         (
-            ["switchbot", switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
+            ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
             "aa:bb:cc:dd:ee:gg",
             "switchbot/aa:bb:cc:dd:ee:gg/state",
         ),
@@ -444,14 +441,14 @@ def test__mqtt_command_callback_ignore_retained(caplog, topic: bytes, payload: b
 @pytest.mark.parametrize("return_code", [MQTT_ERR_SUCCESS, MQTT_ERR_QUEUE_SIZE])
 def test__report_state(
     caplog,
-    state_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
+    state_topic_levels: typing.List[_MQTTTopicLevel],
     mac_address: str,
     expected_topic: str,
     state: bytes,
     return_code: int,
 ):
     # pylint: disable=too-many-arguments
-    class _ActorMock(switchbot_mqtt._MQTTControlledActor):
+    class _ActorMock(switchbot_mqtt._actors._MQTTControlledActor):
         MQTT_STATE_TOPIC_LEVELS = state_topic_levels
 
         def __init__(self, mac_address, retry_count, password):
@@ -479,7 +476,7 @@ def test__report_state(
         topic=expected_topic, payload=state, retain=True
     )
     assert caplog.record_tuples[0] == (
-        "switchbot_mqtt",
+        "switchbot_mqtt._actors",
         logging.DEBUG,
         f"publishing topic={expected_topic} payload={state!r}",
     )
@@ -488,7 +485,7 @@ def test__report_state(
     else:
         assert caplog.record_tuples[1:] == [
             (
-                "switchbot_mqtt",
+                "switchbot_mqtt._actors",
                 logging.ERROR,
                 f"Failed to publish MQTT message on topic {expected_topic} (rc={return_code})",
             )

+ 10 - 16
tests/test_switchbot_button_automator.py

@@ -22,7 +22,7 @@ import unittest.mock
 import bluepy.btle
 import pytest
 
-import switchbot_mqtt
+from switchbot_mqtt import _ButtonAutomator
 
 # pylint: disable=protected-access
 # pylint: disable=too-many-arguments; these are tests, no API
@@ -31,9 +31,7 @@ import switchbot_mqtt
 @pytest.mark.parametrize("mac_address", ["{MAC_ADDRESS}", "aa:bb:cc:dd:ee:ff"])
 def test_get_mqtt_battery_percentage_topic(mac_address):
     assert (
-        switchbot_mqtt._ButtonAutomator.get_mqtt_battery_percentage_topic(
-            mac_address=mac_address
-        )
+        _ButtonAutomator.get_mqtt_battery_percentage_topic(mac_address=mac_address)
         == f"homeassistant/switch/switchbot/{mac_address}/battery-percentage"
     )
 
@@ -43,9 +41,7 @@ def test__update_and_report_device_info(
     battery_percent: int, battery_percent_encoded: bytes
 ):
     with unittest.mock.patch("switchbot.SwitchbotCurtain.__init__", return_value=None):
-        actor = switchbot_mqtt._ButtonAutomator(
-            mac_address="dummy", retry_count=21, password=None
-        )
+        actor = _ButtonAutomator(mac_address="dummy", retry_count=21, password=None)
     actor._get_device()._battery_percent = battery_percent
     mqtt_client_mock = unittest.mock.MagicMock()
     with unittest.mock.patch("switchbot.Switchbot.update") as update_mock:
@@ -90,7 +86,7 @@ def test_execute_command(
     with unittest.mock.patch(
         "switchbot.Switchbot.__init__", return_value=None
     ) as device_init_mock, caplog.at_level(logging.INFO):
-        actor = switchbot_mqtt._ButtonAutomator(
+        actor = _ButtonAutomator(
             mac_address=mac_address, retry_count=retry_count, password=password
         )
         with unittest.mock.patch.object(
@@ -112,7 +108,7 @@ def test_execute_command(
     if command_successful:
         assert caplog.record_tuples == [
             (
-                "switchbot_mqtt",
+                "switchbot_mqtt._actors",
                 logging.INFO,
                 f"switchbot {mac_address} turned {message_payload.decode().lower()}",
             )
@@ -124,7 +120,7 @@ def test_execute_command(
     else:
         assert caplog.record_tuples == [
             (
-                "switchbot_mqtt",
+                "switchbot_mqtt._actors",
                 logging.ERROR,
                 f"failed to turn {message_payload.decode().lower()} switchbot {mac_address}",
             )
@@ -139,9 +135,7 @@ 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, retry_count=21, password=None
-        )
+        actor = _ButtonAutomator(mac_address=mac_address, retry_count=21, password=None)
         with unittest.mock.patch.object(actor, "report_state") as report_mock:
             actor.execute_command(
                 mqtt_client="dummy",
@@ -153,7 +147,7 @@ def test_execute_command_invalid_payload(caplog, mac_address, message_payload):
     report_mock.assert_not_called()
     assert caplog.record_tuples == [
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.WARNING,
             f"unexpected payload {message_payload!r} (expected 'ON' or 'OFF')",
         )
@@ -175,7 +169,7 @@ def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
             f"Failed to connect to peripheral {mac_address}, addr type: random"
         ),
     ), caplog.at_level(logging.ERROR):
-        switchbot_mqtt._ButtonAutomator(
+        _ButtonAutomator(
             mac_address=mac_address, retry_count=3, password=None
         ).execute_command(
             mqtt_client="dummy",
@@ -189,7 +183,7 @@ def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
             "Switchbot communication failed. Stopping trying.",
         ),
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.ERROR,
             f"failed to turn {message_payload.decode().lower()} switchbot {mac_address}",
         ),

+ 16 - 25
tests/test_switchbot_curtain_motor.py

@@ -22,7 +22,8 @@ import unittest.mock
 import bluepy.btle
 import pytest
 
-import switchbot_mqtt
+import switchbot_mqtt._utils
+from switchbot_mqtt._actors import _CurtainMotor
 
 # pylint: disable=protected-access,
 # pylint: disable=too-many-arguments; these are tests, no API
@@ -31,9 +32,7 @@ import switchbot_mqtt
 @pytest.mark.parametrize("mac_address", ["{MAC_ADDRESS}", "aa:bb:cc:dd:ee:ff"])
 def test_get_mqtt_battery_percentage_topic(mac_address):
     assert (
-        switchbot_mqtt._CurtainMotor.get_mqtt_battery_percentage_topic(
-            mac_address=mac_address
-        )
+        _CurtainMotor.get_mqtt_battery_percentage_topic(mac_address=mac_address)
         == f"homeassistant/cover/switchbot-curtain/{mac_address}/battery-percentage"
     )
 
@@ -41,7 +40,7 @@ def test_get_mqtt_battery_percentage_topic(mac_address):
 @pytest.mark.parametrize("mac_address", ["{MAC_ADDRESS}", "aa:bb:cc:dd:ee:ff"])
 def test_get_mqtt_position_topic(mac_address):
     assert (
-        switchbot_mqtt._CurtainMotor.get_mqtt_position_topic(mac_address=mac_address)
+        _CurtainMotor.get_mqtt_position_topic(mac_address=mac_address)
         == f"homeassistant/cover/switchbot-curtain/{mac_address}/position"
     )
 
@@ -59,9 +58,7 @@ def test__report_position(
     with unittest.mock.patch(
         "switchbot.SwitchbotCurtain.__init__", return_value=None
     ) as device_init_mock, caplog.at_level(logging.DEBUG):
-        actor = switchbot_mqtt._CurtainMotor(
-            mac_address=mac_address, retry_count=7, password=None
-        )
+        actor = _CurtainMotor(mac_address=mac_address, retry_count=7, password=None)
     device_init_mock.assert_called_once_with(
         mac=mac_address,
         retry_count=7,
@@ -84,7 +81,7 @@ def test__report_position(
             "homeassistant",
             "cover",
             "switchbot-curtain",
-            switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS,
+            switchbot_mqtt._utils._MQTTTopicPlaceholder.MAC_ADDRESS,
             "position",
         ],
         payload=expected_payload,
@@ -98,7 +95,7 @@ def test__report_position_invalid(caplog, position):
     with unittest.mock.patch(
         "switchbot.SwitchbotCurtain.__init__", return_value=None
     ), caplog.at_level(logging.DEBUG):
-        actor = switchbot_mqtt._CurtainMotor(
+        actor = _CurtainMotor(
             mac_address="aa:bb:cc:dd:ee:ff", retry_count=3, password=None
         )
     with unittest.mock.patch.object(
@@ -123,9 +120,7 @@ def test__update_and_report_device_info(
     position_encoded: bytes,
 ):
     with unittest.mock.patch("switchbot.SwitchbotCurtain.__init__", return_value=None):
-        actor = switchbot_mqtt._CurtainMotor(
-            mac_address="dummy", retry_count=21, password=None
-        )
+        actor = _CurtainMotor(mac_address="dummy", retry_count=21, password=None)
     actor._get_device()._battery_percent = battery_percent
     actor._get_device()._pos = position
     mqtt_client_mock = unittest.mock.MagicMock()
@@ -162,9 +157,7 @@ def test__update_and_report_device_info(
     ],
 )
 def test__update_and_report_device_info_update_error(exception):
-    actor = switchbot_mqtt._CurtainMotor(
-        mac_address="dummy", retry_count=21, password=None
-    )
+    actor = _CurtainMotor(mac_address="dummy", retry_count=21, password=None)
     mqtt_client_mock = unittest.mock.MagicMock()
     with unittest.mock.patch.object(
         actor._get_device(), "update", side_effect=exception
@@ -205,7 +198,7 @@ def test_execute_command(
     with unittest.mock.patch(
         "switchbot.SwitchbotCurtain.__init__", return_value=None
     ) as device_init_mock, caplog.at_level(logging.INFO):
-        actor = switchbot_mqtt._CurtainMotor(
+        actor = _CurtainMotor(
             mac_address=mac_address, retry_count=retry_count, password=password
         )
         with unittest.mock.patch.object(
@@ -230,7 +223,7 @@ def test_execute_command(
         ]
         assert caplog.record_tuples == [
             (
-                "switchbot_mqtt",
+                "switchbot_mqtt._actors",
                 logging.INFO,
                 f"switchbot curtain {mac_address} {state_str}",
             )
@@ -245,7 +238,7 @@ def test_execute_command(
     else:
         assert caplog.record_tuples == [
             (
-                "switchbot_mqtt",
+                "switchbot_mqtt._actors",
                 logging.ERROR,
                 f"failed to {message_payload.decode().lower()} switchbot curtain {mac_address}",
             )
@@ -269,9 +262,7 @@ def test_execute_command_invalid_payload(
     with unittest.mock.patch(
         "switchbot.SwitchbotCurtain"
     ) as device_mock, caplog.at_level(logging.INFO):
-        actor = switchbot_mqtt._CurtainMotor(
-            mac_address=mac_address, retry_count=7, password=password
-        )
+        actor = _CurtainMotor(mac_address=mac_address, retry_count=7, password=password)
         with unittest.mock.patch.object(actor, "report_state") as report_mock:
             actor.execute_command(
                 mqtt_client="dummy",
@@ -285,7 +276,7 @@ def test_execute_command_invalid_payload(
     report_mock.assert_not_called()
     assert caplog.record_tuples == [
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.WARNING,
             f"unexpected payload {message_payload!r} (expected 'OPEN', 'CLOSE', or 'STOP')",
         )
@@ -307,7 +298,7 @@ def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
             f"Failed to connect to peripheral {mac_address}, addr type: random"
         ),
     ), caplog.at_level(logging.ERROR):
-        switchbot_mqtt._CurtainMotor(
+        _CurtainMotor(
             mac_address=mac_address, retry_count=10, password="secret"
         ).execute_command(
             mqtt_client="dummy",
@@ -321,7 +312,7 @@ def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
             "Switchbot communication failed. Stopping trying.",
         ),
         (
-            "switchbot_mqtt",
+            "switchbot_mqtt._actors",
             logging.ERROR,
             f"failed to {message_payload.decode().lower()} switchbot curtain {mac_address}",
         ),