Skip to content

Commit

Permalink
Complete rewrite of the thread loop. Include a queue for passing new …
Browse files Browse the repository at this point in the history
…commands into the queue. Ability to set and get zones. Bump of the tinytuya library to the latest.
  • Loading branch information
Whytey committed May 6, 2024
1 parent c7f2554 commit 3e1cb1a
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 46 deletions.
183 changes: 139 additions & 44 deletions pymoebot/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -23,20 +83,23 @@ 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
self.__mow_in_rain = 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]
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -138,31 +226,33 @@ 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:
return self.__mow_in_rain

@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:
Expand All @@ -185,32 +275,34 @@ 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()

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)
Expand All @@ -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()
Expand All @@ -235,3 +326,7 @@ class MoeBotStateException(Exception):

class MoeBotConnectionError(Exception):
pass


class MoeBotConfigException(Exception):
pass
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
tinytuya>=1.4.0
tinytuya>=1.13.2
2 changes: 1 addition & 1 deletion test/model_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import unittest
from unittest import mock

from pymoebot import MoeBotConnectionError, MoeBot
from pymoebot import MoeBot


class TestMoeBot(unittest.TestCase):
Expand Down

0 comments on commit 3e1cb1a

Please # to comment.