From 3e1cb1ad872732cfb5afe078ad4adb41fb1f2abd Mon Sep 17 00:00:00 2001 From: WhyTey Date: Mon, 6 May 2024 22:35:29 +1000 Subject: [PATCH] Complete rewrite of the thread loop. Include a queue for passing new commands into the queue. Ability to set and get zones. Bump of the tinytuya library to the latest. --- pymoebot/__init__.py | 183 ++++++++++++++++++++++++++++++++----------- requirements.txt | 2 +- test/model_test.py | 2 +- 3 files changed, 141 insertions(+), 46 deletions(-) diff --git a/pymoebot/__init__.py b/pymoebot/__init__.py index 085e7b6..af4cb02 100644 --- a/pymoebot/__init__.py +++ b/pymoebot/__init__.py @@ -1,15 +1,75 @@ +import base64 import logging -import threading +import time +from queue import Queue +from threading import Thread, Event import tinytuya from .__about__ import __version__ _log = logging.getLogger("pymoebot") +_log.addHandler(logging.StreamHandler()) +_log.setLevel(logging.DEBUG) class ZoneConfig: - pass + @staticmethod + def decode(zone_bytes): + binary = base64.b64decode(zone_bytes) + + zc = ZoneConfig(str(binary[3]), str(binary[4]), + str(binary[8]), str(binary[9]), + str(binary[13]), str(binary[14]), + str(binary[18]), str(binary[19]), + str(binary[23]), str(binary[24])) + return zc + + def __init__(self, + z1_distance, z1_ratio, + z2_distance, z2_ratio, + z3_distance, z3_ratio, + z4_distance, z4_ratio, + z5_distance, z5_ratio): + self._z1_distance = z1_distance + self._z1_ratio = z1_ratio + self._z2_distance = z2_distance + self._z2_ratio = z2_ratio + self._z3_distance = z3_distance + self._z3_ratio = z3_ratio + self._z4_distance = z4_distance + self._z4_ratio = z4_ratio + self._z5_distance = z5_distance + self._z5_ratio = z5_ratio + + @property + def zone1(self) -> (int, int): + return self._z1_distance, self._z1_ratio + + @property + def zone2(self) -> (int, int): + return self._z2_distance, self._z2_ratio + + @property + def zone3(self) -> (int, int): + return self._z3_distance, self._z3_ratio + + @property + def zone4(self) -> (int, int): + return self._z4_distance, self._z4_ratio + + @property + def zone5(self) -> (int, int): + return self._z5_distance, self._z5_ratio + + def encode(self): + set_binary = [0, 0, 0, self._z1_distance, self._z1_ratio, + 0, 0, 0, self._z2_distance, self._z2_ratio, + 0, 0, 0, self._z3_distance, self._z3_ratio, + 0, 0, 0, self._z4_distance, self._z4_ratio, + 0, 0, 0, self._z5_distance, self._z5_ratio] + zone_bytes = base64.b64encode(bytes(set_binary)) + return zone_bytes.decode("ascii") class MoeBot: @@ -23,13 +83,6 @@ def __init__(self, device_id: str, device_ip: str, local_key: str) -> None: self.__listeners = [] - self.__tuya_version = self.__do_proto_check() - self.__device.set_version(self.__tuya_version) - - self.__thread = None - self.__shutdown = threading.Event() - self.__shutdown.set() # The thread should be flagged as not running - self.__battery = None self.__state = None self.__emergency_state = None @@ -37,6 +90,16 @@ def __init__(self, device_id: str, device_ip: str, local_key: str) -> None: self.__mow_time = None self.__work_mode = None self.__online = False + self.__zones = None + + self.__last_update = None + self.__tuya_version = self.__do_proto_check() + self.__device.set_version(self.__tuya_version) + + self.__thread = None + self.__queue: Queue = None + self.__shutdown = Event() + self.__shutdown.set() # The thread should be flagged as not running def __do_proto_check(self) -> float: versions = [3.4, 3.3] @@ -47,6 +110,9 @@ def __do_proto_check(self) -> float: if self.__parse_payload(result): return version + _log.error("No TUYA version seems to be valid.") + return 0.0 + def __parse_payload(self, data) -> bool: if data is None or 'Err' in data or 'dps' not in data: _log.error("Error from device: %r" % data) @@ -67,41 +133,62 @@ def __parse_payload(self, data) -> bool: self.__mow_in_rain = dps['104'] if '105' in dps: self.__mow_time = dps['105'] + if '113' in dps: + self.__zones = ZoneConfig.decode(dps['113']) if '114' in dps: self.__work_mode = dps['114'] + if 't' in data: + self.__last_update = data['t'] + for listener in self.__listeners: listener(data) + return True - def poll(self): - result = self.__device.status() - self.__parse_payload(result) + def __loop(self, send_queue: Queue): + STATUS_TIMER = 30 + KEEPALIVE_TIMER = 12 - def __loop(self): - _log.debug(" > Send Request for Status < ") - payload = self.__device.generate_payload(tinytuya.DP_QUERY) - self.__device.send(payload) + _log.debug("Send an initial request for status") + self.poll() - _log.debug(" > Begin Monitor Loop <") + _log.debug("Begin the monitor loop") + heartbeat_time = time.time() + KEEPALIVE_TIMER + status_time = time.time() + STATUS_TIMER while True: if self.__shutdown.is_set(): _log.debug("Thread has been shutdown, exiting listen loop") break - # See if any data is available - data = self.__device.receive() - if data is not None: - _log.debug("Received Payload: %r", data) - - self.__parse_payload(data) + elif not send_queue.empty(): + command = send_queue.get() + _log.debug("We have a message in the queue: {}".format(command)) + data = self.__device.set_value(command[0], command[1]) + send_queue.task_done() + elif status_time and time.time() >= status_time: + _log.debug("Time to poll for status") + self.poll() + status_time = time.time() + STATUS_TIMER + heartbeat_time = time.time() + KEEPALIVE_TIMER + elif time.time() >= heartbeat_time: + _log.debug("Sending a heartbeat") + data = self.__device.heartbeat(nowait=False) + heartbeat_time = time.time() + KEEPALIVE_TIMER + else: + # no need to send anything, just listen for an asynchronous update + _log.debug("Just waiting for asynchronous data...") + data = self.__device.receive() - # Send keepalive heartbeat - payload = self.__device.generate_payload(tinytuya.HEART_BEAT) - self.__device.send(payload) + if data: + _log.debug("Received Payload: %r", data) + if not self.__parse_payload(data): + # rate limit retries so we don't hammer the device + time.sleep(2) def listen(self): if self.__shutdown.is_set(): - self.__thread = threading.Thread(target=self.__loop) + self.__queue = Queue(20) + self.__thread = Thread(target=self.__loop, args=(self.__queue,)) self.__thread.name = "pymoebot" self.__device.set_socketPersistent(True) self.__shutdown.clear() @@ -116,6 +203,7 @@ def unlisten(self) -> None: _log.debug("Unlistening to MoeBot") self.__shutdown.set() self.__thread.join() + self.__queue.join() self.__device.set_socketPersistent(False) @property @@ -138,14 +226,17 @@ def tuya_version(self) -> str: def pymoebot_version(self) -> str: return __version__ + @property + def last_update(self) -> int: + return self.__last_update + @property def mow_time(self) -> int: return self.__mow_time @mow_time.setter def mow_time(self, mow_time: int): - result = self.__device.set_value(105, mow_time) - self.__parse_payload(result) + self.__queue.put((105, mow_time)) @property def mow_in_rain(self) -> bool: @@ -153,16 +244,15 @@ def mow_in_rain(self) -> bool: @mow_in_rain.setter def mow_in_rain(self, mow_in_rain: bool): - result = self.__device.set_value(104, mow_in_rain) - self.__parse_payload(result) + self.__queue.put((104, mow_in_rain)) @property def zones(self) -> ZoneConfig: - pass + return self.__zones - @property.setter + @zones.setter def zones(self, zone_config: ZoneConfig): - pass + self.__queue.put((113, zone_config.encode())) @property def battery(self) -> int: @@ -185,23 +275,26 @@ def start(self, spiral=False) -> None: if self.__state in ("STANDBY", "PAUSED", "CHARGING"): if self.__state == "PAUSED": _log.debug("ContinueWork") - result = self.__device.set_value(115, "ContinueWork") + self.__queue.put((115, "ContinueWork")) elif not spiral: _log.debug("StartMowing") - result = self.__device.set_value(115, "StartMowing") + self.__queue.put((115, "StartMowing")) else: _log.debug("StartFixedMowing") - result = self.__device.set_value(115, "StartFixedMowing") - self.__parse_payload(result) + self.__queue.put((115, "StartFixedMowing")) else: _log.error("Unable to start due to current state: %r", self.__state) raise MoeBotStateException() + def poll(self): + result = self.__device.status() + self.__parse_payload(result) + self.__queue.put((109, '')) + def pause(self) -> None: _log.debug("Attempting to pause mowing: %r", self.__state) if self.__state in ("MOWING", "FIXED_MOWING"): - result = self.__device.set_value(115, "PauseWork") - self.__parse_payload(result) + self.__queue.put((115, "PauseWork")) else: _log.error("Unable to pause due to current state: %r", self.__state) raise MoeBotStateException() @@ -209,8 +302,7 @@ def pause(self) -> None: def cancel(self) -> None: _log.debug("Attempting to cancel mowing: %r", self.__state) if self.__state in ("PAUSED", "CHARGING_WITH_TASK_SUSPEND", "PARK"): - result = self.__device.set_value(115, "CancelWork") - self.__parse_payload(result) + self.__queue.put((115, "CancelWork")) else: _log.error("Unable to cancel due to current state: %r", self.__state) @@ -219,8 +311,7 @@ def cancel(self) -> None: def dock(self) -> None: _log.debug("Attempting to dock mower: %r", self.__state) if self.__state in ("STANDBY", "STANDBY"): - result = self.__device.set_value(115, "StartReturnStation") - self.__parse_payload(result) + self.__queue.put((115, "StartReturnStation")) else: _log.error("Unable to dock due to current state: %r", self.__state) raise MoeBotStateException() @@ -235,3 +326,7 @@ class MoeBotStateException(Exception): class MoeBotConnectionError(Exception): pass + + +class MoeBotConfigException(Exception): + pass diff --git a/requirements.txt b/requirements.txt index 33d6f01..dc80534 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -tinytuya>=1.4.0 +tinytuya>=1.13.2 diff --git a/test/model_test.py b/test/model_test.py index 5c7fdcf..d2b11e1 100644 --- a/test/model_test.py +++ b/test/model_test.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -from pymoebot import MoeBotConnectionError, MoeBot +from pymoebot import MoeBot class TestMoeBot(unittest.TestCase):