From 9daaaf90404956d4f881abf848f03440ff7d7534 Mon Sep 17 00:00:00 2001 From: bittles Date: Wed, 14 Dec 2022 01:51:22 -0500 Subject: [PATCH] get rid of sucks stuff to isolate custom component with its changes --- .travis.yml | 9 - README.md | 152 --- appveyor.yml | 24 - .../ecovacs}/__init__.py | 15 +- .../ecovacs}/manifest.json | 3 +- custom_components/ecovacs/services.yaml | 102 ++ .../ecovacs}/sucksbumper.py | 47 +- .../ecovacs}/vacuum.py | 2 +- log_clean.py | 86 -- setup.py | 101 -- sucks.sh | 11 - sucks/__init__.py | 1096 ----------------- sucks/cli.py | 244 ---- tests/test_cli.py | 62 - tests/test_commands.py | 184 --- tests/test_ecovacs_api.py | 190 --- tests/test_ecovacs_iotmq.py | 236 ---- tests/test_ecovacs_xmpp.py | 91 -- tests/test_vacbot.py | 398 ------ 19 files changed, 147 insertions(+), 2906 deletions(-) delete mode 100644 .travis.yml delete mode 100644 README.md delete mode 100644 appveyor.yml rename {ecovacs => custom_components/ecovacs}/__init__.py (86%) rename {ecovacs => custom_components/ecovacs}/manifest.json (58%) create mode 100644 custom_components/ecovacs/services.yaml rename {ecovacs => custom_components/ecovacs}/sucksbumper.py (96%) rename {ecovacs => custom_components/ecovacs}/vacuum.py (99%) delete mode 100644 log_clean.py delete mode 100644 setup.py delete mode 100644 sucks.sh delete mode 100644 sucks/__init__.py delete mode 100644 sucks/cli.py delete mode 100644 tests/test_cli.py delete mode 100644 tests/test_commands.py delete mode 100644 tests/test_ecovacs_api.py delete mode 100644 tests/test_ecovacs_iotmq.py delete mode 100644 tests/test_ecovacs_xmpp.py delete mode 100644 tests/test_vacbot.py diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6a82e93..0000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: python -python: - - "3.4" - - "3.5" - - "3.6" -install: - - pip install -e .[dev] - -script: nosetests \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 99f99cf..0000000 --- a/README.md +++ /dev/null @@ -1,152 +0,0 @@ -Linux: [![Build Status](https://travis-ci.org/wpietri/sucks.svg?branch=master)](https://travis-ci.org/wpietri/sucks) -Windows: [![Build Status](https://ci.appveyor.com/api/projects/status/github/wpietri/sucks)](https://ci.appveyor.com/project/wpietri/sucks) - - -sucks -===== - -A simple command-line python script to drive a robot vacuum. Currently -known to work with the Ecovacs Deebot N79, M80 Pro, M81, M88 -Pro, and R95 MKII from both North America and Europe. - -Does it work for your model as well? Join the discussion on the -[sucks-users mailing -list](https://groups.google.com/forum/#!forum/sucks-users). - -If you're curious about the protocol, I have [a rough doc](http://github.com/wpietri/sucks/blob/master/protocol.md) -started. I'll happily accept pull requests for it. - -Why the project name? Well, a) it's ridiculous that I needed to MITM -my own vacuum. This is not the future I signed up for. And b), -it's a vacuum. - -## Installation - -If you have a recent version of Python 3, you should be able to -do `pip install sucks` to get the most recently released version of -this. - -## Usage - -To get started, you'll need to have already set up an EcoVacs account -using your smartphone. - -With that ready, step one is to log in: -``` - % sucks login - Ecovacs app email: [your email] - Ecovacs app password: [your password] - your two-letter country code: us - your two-letter continent code: na - Config saved. -``` - -That creates a config file in a platform-appropriate place. The password -is hashed before saving, so it's reasonably safe. (If it doesn't appear -to work for your continent, try "ww", their world-wide catchall.) - -With that set up, you could have it clean in auto mode for 10 minutes -and return to its charger: - -``` - % sucks clean 10 -``` - -You could have it clean for 15 minutes and then do an extra 10 minutes -of edging: - -``` - % sucks clean 15 edge 10 -``` - -If you wanted it to clean for 5 minutes and then stop without charging: - -``` - % sucks clean 5 stop -``` - -If it's running amok and you'd just like it to stop where it is: - -``` - % sucks stop -``` - -To tell it to go plug in: - -``` - % sucks charge -``` - -I run mine from my crontab, but I didn't want it to clean every day, -so it also has a mode where it randomly decides to run or not based on -a frequency you give it. My crontab entry looks like this: - -``` -0 10 * * * /home/william/projects/sucks/sucks.sh clean -f 4/7 15 edge -f 1/14 10 -``` - -This means that every day at 10 am, it might do something. 4 days out -of 7, it will do 15 minutes of automatic cleaning. 1 day out of 14, -it will do 10 minutes of edging. And afterward it will always go back to -charge. - -## Library use - -You are welcome to try using this as a python library for other efforts. The -API is still experimental, so expect changes. Please join the [mailing -list](https://groups.google.com/forum/#!forum/sucks-users) to participate in -shaping the API. - -A simple usage might go something like this: - -```python -from sucks import * - -config = ... - -api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'], - config['country'], config['continent']) -my_vac = api.devices()[0] -vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, my_vac, config['continent']) -vacbot.connect_and_wait_until_ready() - -vacbot.run(Clean()) # start cleaning -time.sleep(900) # clean for 15 minutes -vacbot.run(Charge()) # return to the charger -``` - -## Developing - -If you'd like to join in on developing, I recommend checking out the code, -setting up a virtual environment, and installing this package in editable -mode. You can confirm your environment works by running the tests. And please -do join the [mailing list](https://groups.google.com/forum/#!forum/sucks-users) -to discuss your plans. - -For more information see [the development documentation](developing.md). - - - -## See also - -There are now similar libraries in [Javascript](https://github.com/joostth/sucks.js) -and [Go](https://github.com/skburgart/go-vacbot). - -## Thanks - -My heartfelt thanks to: - -* [xmpppeek](https://www.beneaththewaves.net/Software/XMPPPeek.html), -a great library for examining XMPP traffic flows (yes, your vacuum -speaks Jabbber!), -* [mitmproxy](https://mitmproxy.org/), a fantastic tool for analyzing HTTPS, -* [click](http://click.pocoo.org/), a wonderfully complete and thoughtful -library for making Python command-line interfaces, -* [requests](http://docs.python-requests.org/en/master/), a polished Python -library for HTTP requests, -* [Decompilers online](http://www.javadecompilers.com/apk), which was -very helpful in figuring out what the Android app was up to, -* Albert Louw, who was kind enough to post code from [his own -experiments](https://community.smartthings.com/t/ecovacs-deebot-n79/93410/33) -with his device, and -* All the users who have given useful feedback and contributed code! diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 47aad33..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,24 +0,0 @@ -environment: - - matrix: - - # For Python versions available on Appveyor, see - # http://www.appveyor.com/docs/installed-software#python - - - PYTHON: "C:\\Python35" - -install: - # We need wheel installed to build wheels - - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - python --version - - pip install -e .[dev] - -build: off - -test_script: - # Put your test command here. - # Note that you must use the environment variable %PYTHON% to refer to - # the interpreter you're using - Appveyor does not do anything special - # to put the Python version you want to use on PATH. - - "nosetests" - diff --git a/ecovacs/__init__.py b/custom_components/ecovacs/__init__.py similarity index 86% rename from ecovacs/__init__.py rename to custom_components/ecovacs/__init__.py index f0aa2d9..b9c6100 100644 --- a/ecovacs/__init__.py +++ b/custom_components/ecovacs/__init__.py @@ -24,6 +24,10 @@ DOMAIN = "ecovacs" CONF_COUNTRY = "country" CONF_CONTINENT = "continent" +CONF_BUMPER = "bumper" +CONF_BUMPER_SERVER = "bumper_server" +server_address = None + CONFIG_SCHEMA = vol.Schema( { @@ -33,7 +37,9 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string), vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string), - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_BUMPER, default=False): cv.boolean, + vol.Optional(CONF_BUMPER_SERVER): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, } ) }, @@ -53,6 +59,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.debug("Creating new Ecovacs component") hass.data[ECOVACS_DEVICES] = [] + if CONF_BUMPER == True: + server_address = (config[DOMAIN].get(CONF_BUMPER_SERVER), 5223) + else: + server_address = None ecovacs_api = EcoVacsAPI( ECOVACS_API_DEVICEID, @@ -79,8 +89,9 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ecovacs_api.user_access_token, device, config[DOMAIN].get(CONF_CONTINENT).lower(), - monitor=True, + server_address, config[DOMAIN].get(CONF_VERIFY_SSL), + monitor=True, ) hass.data[ECOVACS_DEVICES].append(vacbot) diff --git a/ecovacs/manifest.json b/custom_components/ecovacs/manifest.json similarity index 58% rename from ecovacs/manifest.json rename to custom_components/ecovacs/manifest.json index 6879ed1..20075dd 100644 --- a/ecovacs/manifest.json +++ b/custom_components/ecovacs/manifest.json @@ -1,8 +1,9 @@ { "domain": "ecovacs", "name": "Ecovacs Bumper", + "version": "1.0.2", "documentation": "https://www.home-assistant.io/integrations/ecovacs", - "requirements": [""], + "requirements": ["sleekxmppfs>=1.3.4", "click>=6", "requests>=2.18", "pycryptodome>=3.4", "pycountry-convert>=0.5", "paho-mqtt>=1.4", "stringcase>=1.2"], "codeowners": ["@OverloadUT", "@mib1185"], "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucksbumper"] diff --git a/custom_components/ecovacs/services.yaml b/custom_components/ecovacs/services.yaml new file mode 100644 index 0000000..26c8d74 --- /dev/null +++ b/custom_components/ecovacs/services.yaml @@ -0,0 +1,102 @@ +# Describes the format for available vacuum services + +turn_on: + name: Turn on + description: Start a new cleaning task. + target: + entity: + domain: vacuum + +turn_off: + name: Turn off + description: Stop the current cleaning task and return to home. + target: + entity: + domain: vacuum + +stop: + name: Stop + description: Stop the current cleaning task. + target: + entity: + domain: vacuum + +locate: + name: Locate + description: Locate the vacuum cleaner robot. + target: + entity: + domain: vacuum + +start_pause: + name: Start/Pause + description: Start, pause, or resume the cleaning task. + target: + entity: + domain: vacuum + +start: + name: Start + description: Start or resume the cleaning task. + target: + entity: + domain: vacuum + +pause: + name: Pause + description: Pause the cleaning task. + target: + entity: + domain: vacuum + +return_to_base: + name: Return to base + description: Tell the vacuum cleaner to return to its dock. + target: + entity: + domain: vacuum + +clean_spot: + name: Clean spot + description: Tell the vacuum cleaner to do a spot clean-up. + target: + entity: + domain: vacuum + +send_command: + name: Send command + description: Send a raw command to the vacuum cleaner. + target: + entity: + domain: vacuum + fields: + command: + name: Command + description: Command to execute. + required: true + example: "set_dnd_timer" + selector: + text: + params: + name: Parameters + description: Parameters for the command. + example: '{ "key": "value" }' + selector: + object: + +set_fan_speed: + name: Set fan speed + description: Set the fan speed of the vacuum cleaner. + target: + entity: + domain: vacuum + fields: + fan_speed: + name: Fan speed + description: + Platform dependent vacuum cleaner fan speed, with speed steps, like + 'medium' or by percentage, between 0 and 100. + required: true + example: "low" + selector: + text: diff --git a/ecovacs/sucksbumper.py b/custom_components/ecovacs/sucksbumper.py similarity index 96% rename from ecovacs/sucksbumper.py rename to custom_components/ecovacs/sucksbumper.py index e129da3..2386f7d 100644 --- a/ecovacs/sucksbumper.py +++ b/custom_components/ecovacs/sucksbumper.py @@ -376,10 +376,11 @@ class EventListener(object): self._emitter.unsubscribe(self) class VacBot(): - def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False, verify_ssl=True): + def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, verify_ssl=True, monitor=False): self.vacuum = vacuum + self.server_address = server_address # If True, the VacBot object will handle keeping track of all statuses, # including the initial request for statuses, and new requests after the # VacBot returns from being offline. It will also cause it to regularly @@ -410,10 +411,14 @@ class VacBot(): self.iotmq = None if not vacuum['iotmq']: - self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) - #Uncomment line to allow unencrypted plain auth - #self.xmpp['feature_mechanisms'].unencrypted_plain = True - self.xmpp.subscribe_to_ctls(self._handle_ctl) + if self.server_address is not None: + vacuum = {"did": "none", "class": "none"} + super().__init__("sucks", "ecouser.net", "", "", vacuum, "") + else: + self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) + #Uncomment line to allow unencrypted plain auth + #self.xmpp['feature_mechanisms'].unencrypted_plain = True + self.xmpp.subscribe_to_ctls(self._handle_ctl) else: self.iotmq = EcoVacsIOTMQ(user, domain, resource, secret, continent, vacuum, server_address, verify_ssl=verify_ssl) @@ -426,21 +431,27 @@ class VacBot(): #self.xmpp.subscribe_to_ctls(self._handle_ctl) def connect_and_wait_until_ready(self): - if not self.vacuum['iotmq']: - self.xmpp.connect_and_wait_until_ready() - self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + if self.server_address: + logging.info("connecting") + self.xmpp.connect(self.server_address) + self.xmpp.process() + self.xmpp.wait_until_ready() else: - self.iotmq.connect_and_wait_until_ready() - self.iotmq.schedule(30, self.send_ping) - #self.xmpp.connect_and_wait_until_ready() #Leaving in case xmpp is given to iotmq in the future - - if self._monitor: - # Do a first ping, which will also fetch initial statuses if the ping succeeds - self.send_ping() - if not self.vacuum['iotmq']: - self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) + if not self.vacuum['iotmq']: + self.xmpp.connect_and_wait_until_ready() + self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) else: - self.iotmq.schedule(3600,self.refresh_components) + self.iotmq.connect_and_wait_until_ready() + self.iotmq.schedule(30, self.send_ping) + #self.xmpp.connect_and_wait_until_ready() #Leaving in case xmpp is given to iotmq in the future + + if self._monitor: + # Do a first ping, which will also fetch initial statuses if the ping succeeds + self.send_ping() + if not self.vacuum['iotmq']: + self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) + else: + self.iotmq.schedule(3600,self.refresh_components) def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] diff --git a/ecovacs/vacuum.py b/custom_components/ecovacs/vacuum.py similarity index 99% rename from ecovacs/vacuum.py rename to custom_components/ecovacs/vacuum.py index 61030e8..f3a15bd 100644 --- a/ecovacs/vacuum.py +++ b/custom_components/ecovacs/vacuum.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -import .sucksbumper +from . import sucksbumper from homeassistant.components.vacuum import VacuumEntity, VacuumEntityFeature from homeassistant.core import HomeAssistant diff --git a/log_clean.py b/log_clean.py deleted file mode 100644 index 25dea57..0000000 --- a/log_clean.py +++ /dev/null @@ -1,86 +0,0 @@ -import base64 -import re -import sys - -# a script to take an xmpppeek log of a Ecovacs app session with a Deebot N79 and strip out some of the nonsense, -# including any private identifiers - -source_ip = None -userid = None -resourceid = None -robotid = None -auth_glob = None - -for line in sys.stdin: - # remove the garbage - line = line.rstrip() - line = re.sub("\[\\d{4}-\\d{2}-\\d{2} ", '', line) - line = re.sub("\.\\d{6}-\\d{2}:\\d{2}\] \[", ' ', line) - line = re.sub("]$", ' ', line) - line = re.sub("\(([SC])2[SC]\) [.0-9]+:\\d+ -> [.0-9]+:\d+\]", '\\1', line) - line = re.sub("\}\}\}", '', line) - line = re.sub("\{\{\{", '', line) - - # find the private bits and remove them - if not source_ip: - match = re.search('Client connect from ([.0-9]+)', line) - if match: - source_ip = match.group(1) - if not userid: - match = re.search('(20\d{6}[0-9a-f]{13})@ecouser.net/([0-9a-f]{8})', line) - if match: - userid = match.group(1) - resourceid = match.group(2) - if not robotid: - match = re.search('(E\d{8,})@126.ecorobot.net/atom', line) - if match: - robotid = match.group(1) - if not auth_glob: - match = re.search('([-A-Za-z0-9+/=]+)', - line) - if match: - auth_glob = match.group(1) - if source_ip: - line = re.sub(source_ip, 'SOURCEIP', line) - if userid: - line = re.sub(userid, 'USERID', line) - if resourceid: - line = re.sub(resourceid, 'RESOURCEID', line) - if robotid: - line = re.sub(robotid, 'ROBOTID', line) - if auth_glob: - line = re.sub(auth_glob, 'AUTHGLOB', line) - - # translate client commmands - - line = re.sub( - '()', - 'id=\\1 command=\\2', line) - - # translate server responses - - line = re.sub( - '', - 'id=\\1 result =empty', line) - line = re.sub( - '', - 'id=\\1 id=\\2 result=\\3', line) - line = re.sub( - '(', - 'id=\\1 response=\\2', line) - - print(line) - -# per SASL plain auth: https://tools.ietf.org/html/rfc4616 -(authentication_id, authorization_id, password) = base64.b64decode(auth_glob).decode().split(sep='\0') - -# no idea what the leading field is, and the resource appears to be the same -(mystery, resource, secret) = password.split('/') - -print("------------------") -print("sample config:") -print("user=" + userid) -print("domain=ecouser.net") -print("resource=" + resourceid) -print("secret=" + secret) -print("vacuum=" + robotid + "@126.ecorobot.net") diff --git a/setup.py b/setup.py deleted file mode 100644 index 22bb6d1..0000000 --- a/setup.py +++ /dev/null @@ -1,101 +0,0 @@ -from codecs import open -from os import path - -from setuptools import setup, find_packages - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -long_description = '' -try: - with open(path.join(here, 'README.rst'), encoding='utf-8') as f: - long_description = f.read() -except FileNotFoundError: - print("can't find python README; skipping") - -setup( - name='sucks', - version='0.9.3', - - description='a library for controlling certain robot vacuums', - long_description=long_description, - - url='https://github.com/wpietri/sucks', - - # Author details - author='William Pietri', - author_email='sucks-users@googlegroups.com', - - # Choose your license - license='GPL-3.0', - - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - 'Development Status :: 4 - Beta', - - # Indicate who your project is intended for - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Libraries', - 'Topic :: Home Automation', - - # Pick your license as you wish (should match "license" above) - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - 'Programming Language :: Python :: 3.5', - ], - - # What does your project relate to? - keywords='home automation vacuum robot', - - # You can just specify the packages manually here if your project is - # simple. Or you can use find_packages(). - packages=find_packages(exclude=['contrib', 'docs', 'tests']), - - # List run-time dependencies here. These will be installed by pip when - # your project is installed. For an analysis of "install_requires" vs pip's - # requirements files see: - # https://packaging.python.org/en/latest/requirements.html - - install_requires=[ - 'sleekxmppfs>=1.3.4', - 'click>=6', - 'requests>=2.18', - 'pycryptodome>=3.4', - 'pycountry-convert>=0.5', - 'paho-mqtt>=1.4', - 'stringcase>=1.2' - ], - - # List additional groups of dependencies here (e.g. development - # dependencies). You can install these using the following syntax, - # for example: - # $ pip install -e .[dev,test] - extras_require={ - 'dev': [ - 'nose', - 'requests-mock>=1.3' - ], - }, - - # If there are data files included in your packages that need to be - # installed, specify them here. If using Python 2.6 or less, then these - # have to be included in MANIFEST.in as well. - # package_data={ - # 'sample': ['package_data.dat'], - # }, - - # To provide executable scripts, use entry points in preference to the - # "scripts" keyword. Entry points provide cross-platform support and allow - # pip to create the appropriate form of executable for the target platform. - entry_points={ - 'console_scripts': [ - 'sucks=sucks.cli:cli', - ], - }, -) diff --git a/sucks.sh b/sucks.sh deleted file mode 100644 index 3c5dfe4..0000000 --- a/sucks.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -# -# A command-line script for running the cli if -# you haven't installed it using pip. Mainly -# useful for developers, I think. - - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -cd ${DIR} -pipenv run -- python -m sucks.cli "$@" diff --git a/sucks/__init__.py b/sucks/__init__.py deleted file mode 100644 index e129da3..0000000 --- a/sucks/__init__.py +++ /dev/null @@ -1,1096 +0,0 @@ -import hashlib -import logging -import time -from base64 import b64decode, b64encode -from collections import OrderedDict -from threading import Event -import threading -import sched -import random -import ssl -import requests -import stringcase -import os -from sleekxmppfs import ClientXMPP, Callback, MatchXPath -from sleekxmppfs.xmlstream import ET -from sleekxmppfs.exceptions import XMPPError - -from paho.mqtt.client import Client as ClientMQTT -from paho.mqtt import publish as MQTTPublish -from paho.mqtt import subscribe as MQTTSubscribe - -_LOGGER = logging.getLogger(__name__) - -# These consts define all of the vocabulary used by this library when presenting various states and components. -# Applications implementing this library should import these rather than hard-code the strings, for future-proofing. - -CLEAN_MODE_AUTO = 'auto' -CLEAN_MODE_EDGE = 'edge' -CLEAN_MODE_SPOT = 'spot' -CLEAN_MODE_SPOT_AREA = 'spot_area' -CLEAN_MODE_SINGLE_ROOM = 'single_room' -CLEAN_MODE_STOP = 'stop' - -CLEAN_ACTION_START = 'start' -CLEAN_ACTION_PAUSE = 'pause' -CLEAN_ACTION_RESUME = 'resume' -CLEAN_ACTION_STOP = 'stop' - -FAN_SPEED_NORMAL = 'normal' -FAN_SPEED_HIGH = 'high' - -CHARGE_MODE_RETURN = 'return' -CHARGE_MODE_RETURNING = 'returning' -CHARGE_MODE_CHARGING = 'charging' -CHARGE_MODE_IDLE = 'idle' - -COMPONENT_SIDE_BRUSH = 'side_brush' -COMPONENT_MAIN_BRUSH = 'main_brush' -COMPONENT_FILTER = 'filter' - -VACUUM_STATUS_OFFLINE = 'offline' - -CLEANING_STATES = {CLEAN_MODE_AUTO, CLEAN_MODE_EDGE, CLEAN_MODE_SPOT, CLEAN_MODE_SPOT_AREA, CLEAN_MODE_SINGLE_ROOM} -CHARGING_STATES = {CHARGE_MODE_CHARGING} - -# These dictionaries convert to and from Sucks's consts (which closely match what the UI and manuals use) -# to and from what the Ecovacs API uses (which are sometimes very oddly named and have random capitalization.) -CLEAN_MODE_TO_ECOVACS = { - CLEAN_MODE_AUTO: 'auto', - CLEAN_MODE_EDGE: 'border', - CLEAN_MODE_SPOT: 'spot', - CLEAN_MODE_SPOT_AREA: 'SpotArea', - CLEAN_MODE_SINGLE_ROOM: 'singleroom', - CLEAN_MODE_STOP: 'stop' -} - -CLEAN_ACTION_TO_ECOVACS = { - CLEAN_ACTION_START: 's', - CLEAN_ACTION_PAUSE: 'p', - CLEAN_ACTION_RESUME: 'r', - CLEAN_ACTION_STOP: 'h', -} - -CLEAN_ACTION_FROM_ECOVACS = { - 's': CLEAN_ACTION_START, - 'p': CLEAN_ACTION_PAUSE, - 'r': CLEAN_ACTION_RESUME, - 'h': CLEAN_ACTION_STOP, -} - -CLEAN_MODE_FROM_ECOVACS = { - 'auto': CLEAN_MODE_AUTO, - 'border': CLEAN_MODE_EDGE, - 'spot': CLEAN_MODE_SPOT, - 'spot_area': CLEAN_MODE_SPOT_AREA, - 'singleroom': CLEAN_MODE_SINGLE_ROOM, - 'stop': CLEAN_MODE_STOP, - 'going': CHARGE_MODE_RETURNING -} - -FAN_SPEED_TO_ECOVACS = { - FAN_SPEED_NORMAL: 'standard', - FAN_SPEED_HIGH: 'strong' -} - -FAN_SPEED_FROM_ECOVACS = { - 'standard': FAN_SPEED_NORMAL, - 'strong': FAN_SPEED_HIGH -} - -CHARGE_MODE_TO_ECOVACS = { - CHARGE_MODE_RETURN: 'go', - CHARGE_MODE_RETURNING: 'Going', - CHARGE_MODE_CHARGING: 'SlotCharging', - CHARGE_MODE_IDLE: 'Idle' -} - -CHARGE_MODE_FROM_ECOVACS = { - 'going': CHARGE_MODE_RETURNING, - 'slot_charging': CHARGE_MODE_CHARGING, - 'idle': CHARGE_MODE_IDLE -} - -COMPONENT_TO_ECOVACS = { - COMPONENT_MAIN_BRUSH: 'Brush', - COMPONENT_SIDE_BRUSH: 'SideBrush', - COMPONENT_FILTER: 'DustCaseHeap' -} - -COMPONENT_FROM_ECOVACS = { - 'brush': COMPONENT_MAIN_BRUSH, - 'side_brush': COMPONENT_SIDE_BRUSH, - 'dust_case_heap': COMPONENT_FILTER -} - -def str_to_bool_or_cert(s): - if s == 'True' or s == True: - return True - elif s == 'False' or s == False: - return False - else: - if not s == None: - if os.path.exists(s): # User could provide a path to a CA Cert as well, which is useful for Bumper - if os.path.isfile(s): - return s - else: - raise ValueError("Certificate path provided is not a file - {}".format(s)) - - raise ValueError("Cannot covert {} to a bool or certificate path".format(s)) - - -class EcoVacsAPI: - CLIENT_KEY = "eJUWrzRv34qFSaYk" - SECRET = "Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GC" - PUBLIC_KEY = 'MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ' - MAIN_URL_FORMAT = 'https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType}' - USER_URL_FORMAT = 'https://users-{continent}.ecouser.net:8000/user.do' - PORTAL_URL_FORMAT = 'https://portal-{continent}.ecouser.net/api' - - USERSAPI = 'users/user.do' - IOTDEVMANAGERAPI = 'iot/devmanager.do' # IOT Device Manager - This provides control of "IOT" products via RestAPI, some bots use this instead of XMPP - PRODUCTAPI = 'pim/product' # Leaving this open, the only endpoint known currently is "Product IOT Map" - pim/product/getProductIotMap - This provides a list of "IOT" products. Not sure what this provides the app. - - - REALM = 'ecouser.net' - - def __init__(self, device_id, account_id, password_hash, country, continent, verify_ssl=True): - self.meta = { - 'country': country, - 'lang': 'en', - 'deviceId': device_id, - 'appCode': 'i_eco_e', - #'appCode': 'i_eco_a' - iphone - 'appVersion': '1.3.5', - #'appVersion': '1.4.6' - iphone - 'channel': 'c_googleplay', - #'channel': 'c_iphone', - iphone - 'deviceType': '1' - #'deviceType': '2' - iphone - } - - self.verify_ssl = str_to_bool_or_cert(verify_ssl) - _LOGGER.debug("Setting up EcoVacsAPI") - self.resource = device_id[0:8] - self.country = country - self.continent = continent - login_info = self.__call_main_api('user/login', - ('account', self.encrypt(account_id)), - ('password', self.encrypt(password_hash))) - self.uid = login_info['uid'] - self.login_access_token = login_info['accessToken'] - self.auth_code = self.__call_main_api('user/getAuthCode', - ('uid', self.uid), - ('accessToken', self.login_access_token))['authCode'] - login_response = self.__call_login_by_it_token() - self.user_access_token = login_response['token'] - if login_response['userId'] != self.uid: - logging.debug("Switching to shorter UID " + login_response['userId']) - self.uid = login_response['userId'] - logging.debug("EcoVacsAPI connection complete") - - def __sign(self, params): - result = params.copy() - result['authTimespan'] = int(time.time() * 1000) - result['authTimeZone'] = 'GMT-8' - - sign_on = self.meta.copy() - sign_on.update(result) - sign_on_text = EcoVacsAPI.CLIENT_KEY + ''.join( - [k + '=' + str(sign_on[k]) for k in sorted(sign_on.keys())]) + EcoVacsAPI.SECRET - - result['authAppkey'] = EcoVacsAPI.CLIENT_KEY - result['authSign'] = self.md5(sign_on_text) - return result - - def __call_main_api(self, function, *args): - _LOGGER.debug("calling main api {} with {}".format(function, args)) - params = OrderedDict(args) - params['requestId'] = self.md5(time.time()) - url = (EcoVacsAPI.MAIN_URL_FORMAT + "/" + function).format(**self.meta) - api_response = requests.get(url, self.__sign(params), verify=self.verify_ssl) - json = api_response.json() - _LOGGER.debug("got {}".format(json)) - if json['code'] == '0000': - return json['data'] - elif json['code'] == '1005': - _LOGGER.warning("incorrect email or password") - raise ValueError("incorrect email or password") - else: - _LOGGER.error("call to {} failed with {}".format(function, json)) - raise RuntimeError("failure code {} ({}) for call {} and parameters {}".format( - json['code'], json['msg'], function, args)) - - def __call_user_api(self, function, args): - _LOGGER.debug("calling user api {} with {}".format(function, args)) - params = {'todo': function} - params.update(args) - response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params, verify=self.verify_ssl) - json = response.json() - _LOGGER.debug("got {}".format(json)) - if json['result'] == 'ok': - return json - else: - _LOGGER.error("call to {} failed with {}".format(function, json)) - raise RuntimeError( - "failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) - - def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs): - - if api == self.USERSAPI: - params = {'todo': function} - params.update(args) - else: - params = {} - params.update(args) - - _LOGGER.debug("calling portal api {} function {} with {}".format(api, function, params)) - - continent = self.continent - if 'continent' in kwargs: - continent = kwargs.get('continent') - - url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=continent, **self.meta) - - response = requests.post(url, json=params, verify=verify_ssl) - - json = response.json() - _LOGGER.debug("got {}".format(json)) - if api == self.USERSAPI: - if json['result'] == 'ok': - return json - elif json['result'] == 'fail': - if json['error'] == 'set token error.': # If it is a set token error try again - if not 'set_token' in kwargs: - _LOGGER.debug("loginByItToken set token error, trying again (2/3)") - return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=1) - elif kwargs.get('set_token') == 1: - _LOGGER.debug("loginByItToken set token error, trying again with ww (3/3)") - return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=2, continent="ww") - else: - _LOGGER.debug("loginByItToken set token error, failed after 3 attempts") - - if api.startswith(self.PRODUCTAPI): - if json['code'] == 0: - return json - - else: - _LOGGER.error("call to {} failed with {}".format(function, json)) - raise RuntimeError( - "failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) - - def __call_login_by_it_token(self): - return self.__call_portal_api(self.USERSAPI,'loginByItToken', - {'country': self.meta['country'].upper(), - 'resource': self.resource, - 'realm': EcoVacsAPI.REALM, - 'userId': self.uid, - 'token': self.auth_code} - , verify_ssl=self.verify_ssl) - - def getdevices(self): - return self.__call_portal_api(self.USERSAPI,'GetDeviceList', { - 'userid': self.uid, - 'auth': { - 'with': 'users', - 'userid': self.uid, - 'realm': EcoVacsAPI.REALM, - 'token': self.user_access_token, - 'resource': self.resource - } - }, verify_ssl=self.verify_ssl)['devices'] - - def getiotProducts(self): - return self.__call_portal_api(self.PRODUCTAPI + '/getProductIotMap','', { - 'channel': '', - 'auth': { - 'with': 'users', - 'userid': self.uid, - 'realm': EcoVacsAPI.REALM, - 'token': self.user_access_token, - 'resource': self.resource - } - }, verify_ssl=self.verify_ssl)['data'] - - def SetIOTDevices(self, devices, iotproducts): - #Originally added for D900, and not actively used in code now - Not sure what the app checks the items in this list for - for device in devices: #Check if the device is part of iotProducts - device['iot_product'] = False - for iotProduct in iotproducts: - if device['class'] in iotProduct['classid']: - device['iot_product'] = True - - return devices - - def SetIOTMQDevices(self, devices): - #Added for devices that utilize MQTT instead of XMPP for communication - for device in devices: - device['iotmq'] = False - if device['company'] == 'eco-ng': #Check if the device is part of the list - device['iotmq'] = True - - return devices - - def devices(self): - return self.SetIOTMQDevices(self.getdevices()) - - @staticmethod - def md5(text): - return hashlib.md5(bytes(str(text), 'utf8')).hexdigest() - - @staticmethod - def encrypt(text): - from Crypto.PublicKey import RSA - from Crypto.Cipher import PKCS1_v1_5 - key = RSA.import_key(b64decode(EcoVacsAPI.PUBLIC_KEY)) - cipher = PKCS1_v1_5.new(key) - result = cipher.encrypt(bytes(text, 'utf8')) - return str(b64encode(result), 'utf8') - - -class EventEmitter(object): - """A very simple event emitting system.""" - def __init__(self): - self._subscribers = [] - - def subscribe(self, callback): - listener = EventListener(self, callback) - self._subscribers.append(listener) - return listener - - def unsubscribe(self, listener): - self._subscribers.remove(listener) - - def notify(self, event): - for subscriber in self._subscribers: - subscriber.callback(event) - - -class EventListener(object): - """Object that allows event consumers to easily unsubscribe from events.""" - def __init__(self, emitter, callback): - self._emitter = emitter - self.callback = callback - - def unsubscribe(self): - self._emitter.unsubscribe(self) - -class VacBot(): - def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False, verify_ssl=True): - - self.vacuum = vacuum - - # If True, the VacBot object will handle keeping track of all statuses, - # including the initial request for statuses, and new requests after the - # VacBot returns from being offline. It will also cause it to regularly - # request component lifespans - self._monitor = monitor - - self._failed_pings = 0 - - # These three are representations of the vacuum state as reported by the API - self.clean_status = None - self.charge_status = None - self.battery_status = None - - # This is an aggregate state managed by the sucks library, combining the clean and charge events to a single state - self.vacuum_status = None - self.fan_speed = None - - # Populated by component Lifespan reports - self.components = {} - - self.statusEvents = EventEmitter() - self.batteryEvents = EventEmitter() - self.lifespanEvents = EventEmitter() - self.errorEvents = EventEmitter() - - #Set none for clients to start - self.xmpp = None - self.iotmq = None - - if not vacuum['iotmq']: - self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) - #Uncomment line to allow unencrypted plain auth - #self.xmpp['feature_mechanisms'].unencrypted_plain = True - self.xmpp.subscribe_to_ctls(self._handle_ctl) - - else: - self.iotmq = EcoVacsIOTMQ(user, domain, resource, secret, continent, vacuum, server_address, verify_ssl=verify_ssl) - self.iotmq.subscribe_to_ctls(self._handle_ctl) - #The app still connects to XMPP as well, but only issues ping commands. - #Everything works without XMPP, so leaving the below commented out. - #self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) - #Uncomment line to allow unencrypted plain auth - #self.xmpp['feature_mechanisms'].unencrypted_plain = True - #self.xmpp.subscribe_to_ctls(self._handle_ctl) - - def connect_and_wait_until_ready(self): - if not self.vacuum['iotmq']: - self.xmpp.connect_and_wait_until_ready() - self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) - else: - self.iotmq.connect_and_wait_until_ready() - self.iotmq.schedule(30, self.send_ping) - #self.xmpp.connect_and_wait_until_ready() #Leaving in case xmpp is given to iotmq in the future - - if self._monitor: - # Do a first ping, which will also fetch initial statuses if the ping succeeds - self.send_ping() - if not self.vacuum['iotmq']: - self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) - else: - self.iotmq.schedule(3600,self.refresh_components) - - def _handle_ctl(self, ctl): - method = '_handle_' + ctl['event'] - if hasattr(self, method): - getattr(self, method)(ctl) - - def _handle_error(self, event): - if 'error' in event: - error = event['error'] - elif 'errs' in event: - error = event['errs'] - - if not error == '': - self.errorEvents.notify(error) - _LOGGER.debug("*** error = " + error) - - def _handle_life_span(self, event): - type = event['type'] - try: - type = COMPONENT_FROM_ECOVACS[type] - except KeyError: - _LOGGER.warning("Unknown component type: '" + type + "'") - - if 'val' in event: - lifespan = int(event['val']) / 100 - else: - lifespan = int(event['left']) / 60 #This works for a D901 - self.components[type] = lifespan - - lifespan_event = {'type': type, 'lifespan': lifespan} - self.lifespanEvents.notify(lifespan_event) - _LOGGER.debug("*** life_span " + type + " = " + str(lifespan)) - - def _handle_clean_report(self, event): - type = event['type'] - try: - type = CLEAN_MODE_FROM_ECOVACS[type] - if self.vacuum['iotmq']: #Was able to parse additional status from the IOTMQ, may apply to XMPP too - statustype = event['st'] - statustype = CLEAN_ACTION_FROM_ECOVACS[statustype] - if statustype == CLEAN_ACTION_STOP or statustype == CLEAN_ACTION_PAUSE: - type = statustype - except KeyError: - _LOGGER.warning("Unknown cleaning status '" + type + "'") - self.clean_status = type - self.vacuum_status = type - - fan = event.get('speed', None) - if fan is not None: - try: - fan = FAN_SPEED_FROM_ECOVACS[fan] - except KeyError: - _LOGGER.warning("Unknown fan speed: '" + fan + "'") - self.fan_speed = fan - self.statusEvents.notify(self.vacuum_status) - if self.fan_speed: - _LOGGER.debug("*** clean_status = " + self.clean_status + " fan_speed = " + self.fan_speed) - else: - _LOGGER.debug("*** clean_status = " + self.clean_status + " fan_speed = None") - - def _handle_battery_info(self, iq): - try: - self.battery_status = float(iq['power']) / 100 - except ValueError: - _LOGGER.warning("couldn't parse battery status " + ET.tostring(iq)) - else: - self.batteryEvents.notify(self.battery_status) - _LOGGER.debug("*** battery_status = {:.0%}".format(self.battery_status)) - - def _handle_charge_state(self, event): - if 'type' in event: - status = event['type'] - elif 'errno' in event: #Handle error - if event['ret'] == 'fail' and event['errno'] == '8': #Already charging - status = 'slot_charging' - elif event['ret'] == 'fail' and event['errno'] == '5': #Busy with another command - status = 'idle' - elif event['ret'] == 'fail' and event['errno'] == '3': #Bot in stuck state, example dust bin out - status = 'idle' - else: - status = 'idle' #Fall back to Idle status - _LOGGER.error("Unknown charging status '" + event['errno'] + "'") #Log this so we can identify more errors - - try: - status = CHARGE_MODE_FROM_ECOVACS[status] - except KeyError: - _LOGGER.warning("Unknown charging status '" + status + "'") - - self.charge_status = status - if status != 'idle' or self.vacuum_status == 'charging': - # We have to ignore the idle messages, because all it means is that it's not - # currently charging, in which case the clean_status is a better indicator - # of what the vacuum is currently up to. - self.vacuum_status = status - self.statusEvents.notify(self.vacuum_status) - _LOGGER.debug("*** charge_status = " + self.charge_status) - - def _vacuum_address(self): - if not self.vacuum['iotmq']: - return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom' - else: - return self.vacuum['did'] #IOTMQ only uses the did - - @property - def is_charging(self) -> bool: - return self.vacuum_status in CHARGING_STATES - - @property - def is_cleaning(self) -> bool: - return self.vacuum_status in CLEANING_STATES - - def send_ping(self): - try: - if not self.vacuum['iotmq']: - self.xmpp.send_ping(self._vacuum_address()) - elif self.vacuum['iotmq']: - if not self.iotmq.send_ping(): - raise RuntimeError() - - except XMPPError as err: - _LOGGER.warning("Ping did not reach VacBot. Will retry.") - _LOGGER.debug("*** Error type: " + err.etype) - _LOGGER.debug("*** Error condition: " + err.condition) - self._failed_pings += 1 - if self._failed_pings >= 4: - self.vacuum_status = 'offline' - self.statusEvents.notify(self.vacuum_status) - - except RuntimeError as err: - _LOGGER.warning("Ping did not reach VacBot. Will retry.") - self._failed_pings += 1 - if self._failed_pings >= 4: - self.vacuum_status = 'offline' - self.statusEvents.notify(self.vacuum_status) - - else: - self._failed_pings = 0 - if self._monitor: - # If we don't yet have a vacuum status, request initial statuses again now that the ping succeeded - if self.vacuum_status == 'offline' or self.vacuum_status is None: - self.request_all_statuses() - else: - # If we're not auto-monitoring the status, then just reset the status to None, which indicates unknown - if self.vacuum_status == 'offline': - self.vacuum_status = None - self.statusEvents.notify(self.vacuum_status) - - def refresh_components(self): - try: - self.run(GetLifeSpan('main_brush')) - self.run(GetLifeSpan('side_brush')) - self.run(GetLifeSpan('filter')) - except XMPPError as err: - _LOGGER.warning("Component refresh requests failed to reach VacBot. Will try again later.") - _LOGGER.debug("*** Error type: " + err.etype) - _LOGGER.debug("*** Error condition: " + err.condition) - - def refresh_statuses(self): - try: - self.run(GetCleanState()) - self.run(GetChargeState()) - self.run(GetBatteryState()) - except XMPPError as err: - _LOGGER.warning("Initial status requests failed to reach VacBot. Will try again on next ping.") - _LOGGER.debug("*** Error type: " + err.etype) - _LOGGER.debug("*** Error condition: " + err.condition) - - def request_all_statuses(self): - self.refresh_statuses() - self.refresh_components() - - def send_command(self, action): - if not self.vacuum['iotmq']: - self.xmpp.send_command(action.to_xml(), self._vacuum_address()) - else: - #IOTMQ issues commands via RestAPI, and listens on MQTT for status updates - self.iotmq.send_command(action, self._vacuum_address()) #IOTMQ devices need the full action for additional parsing - - def run(self, action): - self.send_command(action) - - def disconnect(self, wait=False): - if not self.vacuum['iotmq']: - self.xmpp.disconnect(wait=wait) - else: - self.iotmq._disconnect() - #self.xmpp.disconnect(wait=wait) #Leaving in case xmpp is added to iotmq in the future - -#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict -def RepresentsInt(stringvar): - try: - int(stringvar) - return True - except ValueError: - return False - -class EcoVacsIOTMQ(ClientMQTT): - def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None, verify_ssl=True): - ClientMQTT.__init__(self) - self.ctl_subscribers = [] - self.user = user - self.domain = str(domain).split(".")[0] #MQTT is using domain without tld extension - self.resource = resource - self.secret = secret - self.continent = continent - self.vacuum = vacuum - self.scheduler = sched.scheduler(time.time, time.sleep) - self.scheduler_thread = threading.Thread(target=self.scheduler.run, daemon=True, name="mqtt_schedule_thread") - self.verify_ssl = str_to_bool_or_cert(verify_ssl) - - if server_address is None: - self.hostname = ('mq-{}.ecouser.net'.format(self.continent)) - self.port = 8883 - else: - saddress = server_address.split(":") - if len(saddress) > 1: - self.hostname = saddress[0] - if RepresentsInt(saddress[1]): - self.port = int(saddress[1]) - else: - self.port = 8883 - - self._client_id = self.user + '@' + self.domain.split(".")[0] + '/' + self.resource - self.username_pw_set(self.user + '@' + self.domain, secret) - - self.ready_flag = Event() - - def connect_and_wait_until_ready(self): - #self._on_log = self.on_log #This provides more logging than needed, even for debug - self._on_message = self._handle_ctl_mqtt - self._on_connect = self.on_connect - - #TODO: This is pretty insecure and accepts any cert, maybe actually check? - ssl_ctx = ssl.create_default_context() - ssl_ctx.check_hostname = False - ssl_ctx.verify_mode = ssl.CERT_NONE - self.tls_set_context(ssl_ctx) - self.tls_insecure_set(True) - - self.connect(self.hostname, self.port) - self.loop_start() - self.wait_until_ready() - - def subscribe_to_ctls(self, function): - self.ctl_subscribers.append(function) - - def _disconnect(self): - self.disconnect() #disconnect mqtt connection - self.scheduler.empty() #Clear schedule queue - - def _run_scheduled_func(self, timer_seconds, timer_function): - timer_function() - self.schedule(timer_seconds, timer_function) - - def schedule(self, timer_seconds, timer_function): - self.scheduler.enter(timer_seconds, 1, self._run_scheduled_func,(timer_seconds, timer_function)) - if not self.scheduler_thread.isAlive(): - self.scheduler_thread.start() - - def wait_until_ready(self): - self.ready_flag.wait() - - def on_connect(self, client, userdata, flags, rc): - if rc != 0: - _LOGGER.error("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc)) - raise RuntimeError("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc)) - - else: - _LOGGER.debug("EcoVacsMQTT - Connected with result code "+str(rc)) - _LOGGER.debug("EcoVacsMQTT - Subscribing to all") - - self.subscribe('iot/atr/+/' + self.vacuum['did'] + '/' + self.vacuum['class'] + '/' + self.vacuum['resource'] + '/+', qos=0) - self.ready_flag.set() - - #def on_log(self, client, userdata, level, buf): #This is very noisy and verbose - # _LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf)) - - def send_ping(self): - _LOGGER.debug("*** MQTT sending ping ***") - rc = self._send_simple_command(MQTTPublish.paho.PINGREQ) - if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS: - return True - else: - return False - - def send_command(self, action, recipient): - if action.name == "Clean": #For handling Clean when action not specified (i.e. CLI) - action.args['clean']['act'] = CLEAN_ACTION_TO_ECOVACS['start'] #Inject a start action - c = self._wrap_command(action, recipient) - _LOGGER.debug('Sending command {0}'.format(c)) - self._handle_ctl_api(action, - self.__call_iotdevmanager_api(c ,verify_ssl=self.verify_ssl ) - ) - - def _wrap_command(self, cmd, recipient): - #Remove the td from ctl xml for RestAPI - payloadxml = cmd.to_xml() - payloadxml.attrib.pop("td") - - return { - 'auth': { - 'realm': EcoVacsAPI.REALM, - 'resource': self.resource, - 'token': self.secret, - 'userid': self.user, - 'with': 'users', - }, - "cmdName": cmd.name, - "payload": ET.tostring(payloadxml).decode(), - - "payloadType": "x", - "td": "q", - "toId": recipient, - "toRes": self.vacuum['resource'], - "toType": self.vacuum['class'] - } - - def __call_iotdevmanager_api(self, args, verify_ssl=True): - _LOGGER.debug("calling iotdevmanager api with {}".format(args)) - params = {} - params.update(args) - - url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/iot/devmanager.do").format(continent=self.continent) - response = None - try: #The RestAPI sometimes doesnt provide a response depending on command, reduce timeout to 3 to accomodate and make requests faster - response = requests.post(url, json=params, timeout=3, verify=verify_ssl) #May think about having timeout as an arg that could be provided in the future - except requests.exceptions.ReadTimeout: - _LOGGER.debug("call to iotdevmanager failed with ReadTimeout") - return {} - - json = response.json() - if json['ret'] == 'ok': - return json - elif json['ret'] == 'fail': - if 'debug' in json: - if json['debug'] == 'wait for response timed out': - #TODO - Maybe handle timeout for IOT better in the future - _LOGGER.error("call to iotdevmanager failed with {}".format(json)) - return {} - else: - #TODO - Not sure if we want to raise an error yet, just return empty for now - _LOGGER.error("call to iotdevmanager failed with {}".format(json)) - return {} - #raise RuntimeError( - #"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) - - def _handle_ctl_api(self, action, message): - if not message == {}: - resp = self._ctl_to_dict_api(action, message['resp']) - if resp is not None: - for s in self.ctl_subscribers: - s(resp) - - def _ctl_to_dict_api(self, action, xmlstring): - xml = ET.fromstring(xmlstring) - - xmlchild = xml.getchildren() - if len(xmlchild) > 0: - result = xmlchild[0].attrib.copy() - #Fix for difference in XMPP vs API response - #Depending on the report will use the tag and add "report" to fit the mold of sucks library - if xmlchild[0].tag == "clean": - result['event'] = "CleanReport" - elif xmlchild[0].tag == "charge": - result['event'] = "ChargeState" - elif xmlchild[0].tag == "battery": - result['event'] = "BatteryInfo" - else: #Default back to replacing Get from the api cmdName - result['event'] = action.name.replace("Get","",1) - - else: - result = xml.attrib.copy() - result['event'] = action.name.replace("Get","",1) - if 'ret' in result: #Handle errors as needed - if result['ret'] == 'fail': - if action.name == "Charge": #So far only seen this with Charge, when already docked - result['event'] = "ChargeState" - - for key in result: - if not RepresentsInt(result[key]): #Fix to handle negative int values - result[key] = stringcase.snakecase(result[key]) - - return result - - def _handle_ctl_mqtt(self, client, userdata, message): - #_LOGGER.debug("EcoVacs MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) - as_dict = self._ctl_to_dict_mqtt(message.topic, str(message.payload.decode("utf-8"))) - if as_dict is not None: - for s in self.ctl_subscribers: - s(as_dict) - - def _ctl_to_dict_mqtt(self, topic, xmlstring): - #I haven't seen the need to fall back to data within the topic (like we do with IOT rest call actions), but it is here in case of future need - xml = ET.fromstring(xmlstring) #Convert from string to xml (like IOT rest calls), other than this it is similar to XMPP - - #Including changes from jasonarends @ 28da7c2 below - result = xml.attrib.copy() - if 'td' not in result: - # This happens for commands with no response data, such as PlaySound - # Handle response data with no 'td' - - if 'type' in result: # single element with type and val - result['event'] = "LifeSpan" # seems to always be LifeSpan type - - else: - if len(xml) > 0: # case where there is child element - if 'clean' in xml[0].tag: - result['event'] = "CleanReport" - elif 'charge' in xml[0].tag: - result['event'] = "ChargeState" - elif 'battery' in xml[0].tag: - result['event'] = "BatteryInfo" - else: - return - result.update(xml[0].attrib) - else: # for non-'type' result with no child element, e.g., result of PlaySound - return - else: # response includes 'td' - result['event'] = result.pop('td') - if xml: - result.update(xml[0].attrib) - - for key in result: - #Check for RepresentInt to handle negative int values, and ',' for ignoring position updates - if not RepresentsInt(result[key]) and ',' not in result[key]: - result[key] = stringcase.snakecase(result[key]) - - return result - - -class EcoVacsXMPP(ClientXMPP): - def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ): - ClientXMPP.__init__(self, "{}@{}/{}".format(user, domain,resource), '0/' + resource + '/' + secret) #Init with resource to bind it - self.user = user - self.domain = domain - self.resource = resource - self.continent = continent - self.vacuum = vacuum - self.credentials['authzid'] = user - if server_address is None: - self.server_address = ('msg-{}.ecouser.net'.format(self.continent), '5223') - else: - self.server_address = server_address - self.add_event_handler("session_start", self.session_start) - self.ctl_subscribers = [] - self.ready_flag = Event() - - - def wait_until_ready(self): - self.ready_flag.wait() - - def session_start(self, event): - _LOGGER.debug("----------------- starting session ----------------") - _LOGGER.debug("event = {}".format(event)) - self.register_handler(Callback("general", - MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}'), - self._handle_ctl)) - self.ready_flag.set() - - def subscribe_to_ctls(self, function): - self.ctl_subscribers.append(function) - - def _handle_ctl(self, message): - the_good_part = message.get_payload()[0][0] - as_dict = self._ctl_to_dict(the_good_part) - if as_dict is not None: - for s in self.ctl_subscribers: - s(as_dict) - - def _ctl_to_dict(self, xml): - result = xml.attrib.copy() - if 'td' not in result: - # This happens for commands with no response data, such as PlaySound - return - - result['event'] = result.pop('td') - if xml: - result.update(xml[0].attrib) - - for key in result: - if not RepresentsInt(result[key]): #Fix to handle negative int values - result[key] = stringcase.snakecase(result[key]) - - return result - - def register_callback(self, userdata, message): - - self.register_handler(Callback(kind, - MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}ctl[@td="' + kind + '"]'), - function)) - - def send_command(self, xml, recipient): - c = self._wrap_command(xml, recipient) - _LOGGER.debug('Sending command {0}'.format(c)) - c.send() - - def _wrap_command(self, ctl, recipient): - q = self.make_iq_query(xmlns=u'com:ctl', ito=recipient, ifrom=self._my_address()) - q['type'] = 'set' - if not "id" in ctl.attrib: - ctl.attrib["id"] = self.getReqID() #If no ctl id provided, add an id to the ctl. This was required for the ozmo930 and shouldn't hurt others - for child in q.xml: - if child.tag.endswith('query'): - child.append(ctl) - return q - - def getReqID(self, customid="0"): #Generate a somewhat random string for request id, with minium 8 chars. Works similar to ecovacs app. - if customid != "0": - return "{}".format(customid) #return provided id as string - else: - rtnval = str(random.randint(1,50)) - while len(str(rtnval)) <= 8: - rtnval = "{}{}".format(rtnval,random.randint(0,50)) - - return "{}".format(rtnval) #return as string - - def _my_address(self): - if not self.vacuum['iotmq']: - return self.user + '@' + self.domain + '/' + self.boundjid.resource - else: - return self.user + '@' + self.domain + '/' + self.resource - - - def send_ping(self, to): - q = self.make_iq_get(ito=to, ifrom=self._my_address()) - q.xml.append(ET.Element('ping', {'xmlns': 'urn:xmpp:ping'})) - _LOGGER.debug("*** sending ping ***") - q.send() - - def connect_and_wait_until_ready(self): - self.connect(self.server_address) - self.process() - self.wait_until_ready() - -class VacBotCommand: - ACTION = { - 'forward': 'forward', - 'backward': 'backward', - 'left': 'SpinLeft', - 'right': 'SpinRight', - 'turn_around': 'TurnAround', - 'stop': 'stop' - } - - def __init__(self, name, args=None, **kwargs): - if args is None: - args = {} - self.name = name - self.args = args - - def to_xml(self): - ctl = ET.Element('ctl', {'td': self.name}) - for key, value in self.args.items(): - if type(value) is dict: - inner = ET.Element(key, value) - ctl.append(inner) - elif type(value) is list: - for item in value: - ixml = self.listobject_to_xml(key, item) - ctl.append(ixml) - else: - ctl.set(key, value) - - return ctl - - def __str__(self, *args, **kwargs): - return self.command_name() + " command" - - def command_name(self): - return self.__class__.__name__.lower() - - def listobject_to_xml(self, tag, conv_object): - rtnobject = ET.Element(tag) - if type(conv_object) is dict: - for key, value in conv_object.items(): - rtnobject.set(key, value) - else: - rtnobject.set(tag, conv_object) - return rtnobject - -class Clean(VacBotCommand): - def __init__(self, mode='auto', speed='normal', iotmq=False, action='start',terminal=False, **kwargs): - if kwargs == {}: - #Looks like action is needed for some bots, shouldn't affect older models - super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed],'act': CLEAN_ACTION_TO_ECOVACS[action]}}) - else: - initcmd = {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]} - for kkey, kvalue in kwargs.items(): - initcmd[kkey] = kvalue - super().__init__('Clean', {'clean': initcmd}) - -class Edge(Clean): - def __init__(self): - super().__init__('edge', 'high') - - -class Spot(Clean): - def __init__(self): - super().__init__('spot', 'high') - - -class Stop(Clean): - def __init__(self): - super().__init__('stop', 'normal') - -class SpotArea(Clean): - def __init__(self, action='start', area='', map_position='', cleanings='1'): - if area != '': #For cleaning specified area - super().__init__('spot_area', 'normal', act=CLEAN_ACTION_TO_ECOVACS[action], mid=area) - elif map_position != '': #For cleaning custom map area, and specify deep amount 1x/2x - super().__init__('spot_area' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[action], p=map_position, deep=cleanings) - else: - #no valid entries - raise ValueError("must provide area or map_position for spotarea clean") - -class Charge(VacBotCommand): - def __init__(self): - super().__init__('Charge', {'charge': {'type': CHARGE_MODE_TO_ECOVACS['return']}}) - - -class Move(VacBotCommand): - def __init__(self, action): - super().__init__('Move', {'move': {'action': self.ACTION[action]}}) - - -class PlaySound(VacBotCommand): - def __init__(self, sid="0"): - super().__init__('PlaySound', {'sid': sid}) - - -class GetCleanState(VacBotCommand): - def __init__(self): - super().__init__('GetCleanState') - - -class GetChargeState(VacBotCommand): - def __init__(self): - super().__init__('GetChargeState') - - -class GetBatteryState(VacBotCommand): - def __init__(self): - super().__init__('GetBatteryInfo') - - -class GetLifeSpan(VacBotCommand): - def __init__(self, component): - super().__init__('GetLifeSpan', {'type': COMPONENT_TO_ECOVACS[component]}) - - -class SetTime(VacBotCommand): - def __init__(self, timestamp, timezone): - super().__init__('SetTime', {'time': {'t': timestamp, 'tz': timezone}}) diff --git a/sucks/cli.py b/sucks/cli.py deleted file mode 100644 index 843cc5a..0000000 --- a/sucks/cli.py +++ /dev/null @@ -1,244 +0,0 @@ -import configparser -import itertools -import os -import platform -import random -import re - -import click -from pycountry_convert import country_alpha2_to_continent_code - -from sucks import * - -_LOGGER = logging.getLogger(__name__) - - -class FrequencyParamType(click.ParamType): - name = 'frequency' - RATIONAL_PATTERN = re.compile(r'([.0-9]+)/([.0-9]+)') - - def convert(self, value, param, ctx): - result = None - try: - search = self.RATIONAL_PATTERN.search(value) - if search: - result = float(search.group(1)) / float(search.group(2)) - else: - try: - result = float(value) - except ValueError: - pass - except (ValueError, ArithmeticError): - pass - - if result is None: - self.fail('%s is not a valid frequency' % value, param, ctx) - if 0 <= result <= 1: - return result - - self.fail('%s is not between 0 and 1' % value, param, ctx) - - -FREQUENCY = FrequencyParamType() - - -class BotWait(): - pass - - def wait(self, bot): - raise NotImplementedError() - - -class TimeWait(BotWait): - def __init__(self, seconds): - super().__init__() - self.seconds = seconds - - def wait(self, bot): - click.echo("waiting for " + str(self.seconds) + "s") - time.sleep(self.seconds) - - -class StatusWait(BotWait): - def __init__(self, wait_on, wait_for): - super().__init__() - self.wait_on = wait_on - self.wait_for = wait_for - - def wait(self, bot): - if not hasattr(bot, self.wait_on): - raise ValueError("object " + bot + " does not have method " + self.wait_on) - _LOGGER.debug("waiting on " + self.wait_on + " for value " + self.wait_for) - - while getattr(bot, self.wait_on) != self.wait_for: - time.sleep(0.5) - _LOGGER.debug("wait complete; " + self.wait_on + " is now " + self.wait_for) - - -class CliAction: - def __init__(self, vac_command, terminal=False, wait=None): - self.vac_command = vac_command - self.terminal = terminal - self.wait = wait - - -def config_file(): - if platform.system() == 'Windows': - return os.path.join(os.getenv('APPDATA'), 'sucks.conf') - else: - return os.path.expanduser('~/.config/sucks.conf') - - -def config_file_exists(): - return os.path.isfile(config_file()) - - -def read_config(): - parser = configparser.ConfigParser() - with open(config_file()) as fp: - parser.read_file(itertools.chain(['[global]'], fp), source=config_file()) - return parser['global'] - - -def write_config(config): - os.makedirs(os.path.dirname(config_file()), exist_ok=True) - with open(config_file(), 'w') as fp: - for key in config: - fp.write(key + '=' + str(config[key]) + "\n") - - -def current_country(): - # noinspection PyBroadException - try: - return requests.get('http://ipinfo.io/json').json()['country'].lower() - except: - return 'us' - - -def continent_for_country(country_code): - return country_alpha2_to_continent_code(country_code.upper()).lower() - - -def should_run(frequency): - if frequency is None: - return True - n = random.random() - result = n <= frequency - _LOGGER.debug("tossing coin: {:0.3f} <= {:0.3f}: {}".format(n, frequency, result)) - return result - - -@click.group(chain=True) -@click.option('--debug/--no-debug', default=False) -def cli(debug): - logging.basicConfig(format='%(name)-10s %(levelname)-8s %(message)s') - _LOGGER.parent.setLevel(logging.DEBUG if debug else logging.ERROR) - - -@cli.command(help='logs in with specified email; run this first') -@click.option('--email', prompt='Ecovacs app email') -@click.option('--password', prompt='Ecovacs app password', hide_input=True) -@click.option('--country-code', prompt='your two-letter country code', default=lambda: current_country()) -@click.option('--continent-code', prompt='your two-letter continent code', - default=lambda: continent_for_country(click.get_current_context().params['country_code'])) -@click.option('--verify-ssl', prompt='Verify SSL for API requests', default=True) -def login(email, password, country_code, continent_code, verify_ssl): - if config_file_exists() and not click.confirm('overwrite existing config?'): - click.echo("Skipping login.") - exit(0) - config = OrderedDict() - password_hash = EcoVacsAPI.md5(password) - device_id = EcoVacsAPI.md5(str(time.time())) - try: - EcoVacsAPI(device_id, email, password_hash, country_code, continent_code, verify_ssl) - except ValueError as e: - click.echo(e.args[0]) - exit(1) - config['email'] = email - config['password_hash'] = password_hash - config['device_id'] = device_id - config['country'] = country_code.lower() - config['continent'] = continent_code.lower() - config['verify_ssl'] = verify_ssl - write_config(config) - click.echo("Config saved.") - exit(0) - - -@cli.command(help='auto-cleans for the specified number of minutes, if minutes is 0 auto clean until bot returns to charger by itself') -@click.option('--frequency', '-f', type=FREQUENCY, help='frequency with which to run; e.g. 0.5 or 3/7') -@click.argument('minutes', type=click.FLOAT) -def clean(frequency, minutes): - waiter = StatusWait('charge_status', 'charging') - if minutes > 0: - waiter = TimeWait(minutes * 60) - - if should_run(frequency): - return CliAction(Clean(), wait=waiter) - - -@cli.command(help='cleans room edges for the specified number of minutes') -@click.option('--frequency', '-f', type=FREQUENCY, help='frequency with which to run; e.g. 0.5 or 3/7') -@click.argument('minutes', type=click.FLOAT) -def edge(frequency, minutes): - if should_run(frequency): - return CliAction(Edge(), wait=TimeWait(minutes * 60)) - - -@cli.command(help='cleans provided area(s), ex: "0,1"',context_settings={"ignore_unknown_options": True}) #ignore_unknown for map coordinates with negatives -@click.option("--map-position","-p", is_flag=True, help='clean provided map position instead of area, ex: "-602,1812,800,723"') -@click.argument('area', type=click.STRING, required=True) -def area(area, map_position): - if map_position: - return CliAction(SpotArea('start', map_position=area), wait=StatusWait('charge_status', 'returning')) - else: - return CliAction(SpotArea('start', area=area), wait=StatusWait('charge_status', 'returning')) - - -@cli.command(help='returns to charger') -def charge(): - return charge_action() - - -def charge_action(): - return CliAction(Charge(), terminal=True, wait=StatusWait('charge_status', 'charging')) - - -@cli.command(help='stops the robot in its current position') -def stop(): - return CliAction(Stop(), terminal=True, wait=StatusWait('clean_status', 'stop')) - - -@cli.resultcallback() -def run(actions, debug): - actions = list(filter(None.__ne__, actions)) - if actions and charge and not actions[-1].terminal: - actions.append(charge_action()) - - if not config_file_exists(): - click.echo("Not logged in. Do 'click login' first.") - exit(1) - - if debug: - _LOGGER.debug("will run {}".format(actions)) - - if actions: - config = read_config() - api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'], - config['country'], config['continent'], verify_ssl=config['verify_ssl']) - vacuum = api.devices()[0] - vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent'], verify_ssl=config['verify_ssl']) - vacbot.connect_and_wait_until_ready() - - for action in actions: - click.echo("performing " + str(action.vac_command)) - vacbot.run(action.vac_command) - action.wait.wait(vacbot) - - vacbot.disconnect(wait=True) - - click.echo("done") - - -if __name__ == '__main__': - cli() diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 0ccd861..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,62 +0,0 @@ -import tempfile -from unittest.mock import Mock, patch - -from nose.tools import * - -from sucks.cli import * - - -def test_config_file_name(): - if platform.system() == 'Windows': - print(config_file()) - assert_true(re.match(r'[A-Z]:\\.+\\\w+\\AppData(\\Roaming)?\\sucks.conf', config_file())) - else: - assert_true(re.match(r'/.+/\w+/.config/sucks.conf', config_file())) - - -def test_write_and_read_config(): - with patch('sucks.cli.config_file', - Mock(return_value=os.path.join(tempfile.mkdtemp(), 'some_other_dir', 'sucks.conf'))): - write_config({'a': "ayyy", 'b': 2}) - config2 = read_config() - assert_equals(config2['a'], 'ayyy') - assert_equals(config2['b'], '2') - - -def test_frequency_param_type(): - t = FREQUENCY - assert_equals(t.convert('0', None, None), 0) - assert_equals(t.convert('1', None, None), 1.0) - assert_equals(t.convert('1/2', None, None), 0.5) - assert_equals(t.convert('1/7', None, None), 1.0 / 7.0) - assert_equals(t.convert('1/14', None, None), 1.0 / 14.0) - assert_equals(t.convert('1/1000', None, None), 1.0 / 1000.0) - assert_equals(t.convert('1.5/2', None, None), 1.5 / 2.0) - assert_equals(t.convert('0/2', None, None), 0) - with assert_raises(click.exceptions.BadParameter): - t.convert('bob', None, None) - with assert_raises(click.exceptions.BadParameter): - t.convert('2', None, None) - with assert_raises(click.exceptions.BadParameter): - t.convert('7/5', None, None) - with assert_raises(click.exceptions.BadParameter): - t.convert('1/0', None, None) - - -def test_should_run(): - count = 0 - for _ in range(10000): - if should_run(0.1): - count += 1 - assert_almost_equal(count, 1000, delta=200) - - count = 0 - for _ in range(10000): - if should_run(0.9): - count += 1 - assert_almost_equal(count, 9000, delta=200) - - -def test_continent_for_country(): - assert_equal(continent_for_country('us'), 'na') - assert_equal(continent_for_country('fr'), 'eu') diff --git a/tests/test_commands.py b/tests/test_commands.py deleted file mode 100644 index c40baf4..0000000 --- a/tests/test_commands.py +++ /dev/null @@ -1,184 +0,0 @@ -from xml.etree import ElementTree - -from nose.tools import * - -from sucks import * - - -def test_custom_command(): - # Ensure a custom-built command generates the expected XML payload - c = VacBotCommand('CustomCommand', {'type': 'customtype'}) - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_custom_command_inner_tag(): - # Ensure a custom-built command generates the expected XML payload - c = VacBotCommand('CustomCommand', {'customtag': {'customvar': 'customvalue'}}) - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_custom_command_multiple_inner_tag(): - # Ensure a custom-built command with multiple inner tags generates the expected XML payload - c = VacBotCommand('CustomCommand', {"customtag":[{"customvar":"customvalue1"},{"customvar":"customvalue2"}]}) - logging.info(ElementTree.tostring(c.to_xml())) - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - -def test_custom_command_args_multiple_inner_tag(): - # Ensure a custom-built command with args and multiple inner tags generates the expected XML payload - c = VacBotCommand('CustomCommand', {"arg1":"value1","customtag":[{"customvar":"customvalue1"},{"customvar":"customvalue2"}]}) - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_custom_command_noargs(): - # Ensure a custom-built command with no args generates XML without an args element - c = VacBotCommand('CustomCommand') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_clean_command(): - c = Clean() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order - - c = Clean('edge', 'high') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order - - c = Clean(iotmq=True) - assert_equals(ElementTree.tostring(c.to_xml()), - b'') # test for iot act is added - - -def test_spotarea_command(): - assert_raises(ValueError, SpotArea, 'start') #Value error if SpotArea doesn't include a mid or p - - c = SpotArea('start', '0') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test namedarea clean - - c = SpotArea('start', area='0') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test namedarea keyword clean - - c = SpotArea('start', '', '-602,1812,800,723') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea clean - - c = SpotArea('start', '', '-602,1812,800,723', '2') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea clean with deep 2 - - c = SpotArea('start', '', map_position='-602,1812,800,723') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea keyword clean with deep default - - c = SpotArea('start', map_position='-602,1812,800,723', cleanings='2') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea keyword and cleanings keyword clean with deep default - - c = SpotArea('start', area='0', map_position='-602,1812,800,723', cleanings='2') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test all keywords specified, should default to only mid - - c = SpotArea('start', '0', '-602,1812,800,723','2') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test all keywords specified, should default to only mid - - -def test_edge_command(): - c = Edge() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order - - -def test_spot_command(): - c = Spot() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order - - -def test_charge_command(): - c = Charge() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_stop_command(): - c = Stop() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_play_sound_command(): - c = PlaySound() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_play_sound_command_with_sid(): - c = PlaySound(sid="1") - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_get_clean_state_command(): - c = GetCleanState() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_get_charge_state_command(): - c = GetChargeState() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_get_battery_state_command(): - c = GetBatteryState() - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_move_command(): - c = Move(action='left') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - c = Move(action='right') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - c = Move(action='turn_around') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - c = Move(action='forward') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - c = Move(action='backward') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - c = Move(action='stop') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - -def test_get_lifepsan_command(): - c = GetLifeSpan('main_brush') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - c = GetLifeSpan('side_brush') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - - c = GetLifeSpan('filter') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') - -def test_set_time_command(): - c = SetTime('1234', 'GMT-5') - assert_equals(ElementTree.tostring(c.to_xml()), - b'') diff --git a/tests/test_ecovacs_api.py b/tests/test_ecovacs_api.py deleted file mode 100644 index 65d4f08..0000000 --- a/tests/test_ecovacs_api.py +++ /dev/null @@ -1,190 +0,0 @@ -from re import compile - -import requests_mock -from nose.tools import * - -from sucks import * - - -def test_md5(): - assert_equal(EcoVacsAPI.md5("fnord"), "b15e400c8dbd6697f26385216d32a40f") - - -def test_encrypt(): - assert_equal(len(EcoVacsAPI.encrypt("fnord")), 172) - - -def test_main_api_setup(): - with requests_mock.mock() as m: - r1 = m.get(compile('user/login'), - text='{"time": 1511200804243, "data": {"accessToken": "7a375650b0b1efd780029284479c4e41", "uid": "2017102559f0ee63c588d", "username": null, "email": "william-ecovacs@pota.to", "country": "us"}, "code": "0000", "msg": "X"}') - r2 = m.get(compile('user/getAuthCode'), - text='{"time": 1511200804607, "data": {"authCode": "5c28dac1ff580210e11292df57e87bef"}, "code": "0000", "msg": "X"}') - r3 = m.post(compile('user.do'), - text='{"todo": "result", "token": "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s", "result": "ok", "userId": "2017102559f0ee63c588d", "resource": "f8d99c4d"}') - - api = EcoVacsAPI("long_device_id", "account_id", "password_hash", 'us', 'na') - - # verify setup - assert_equals(api.resource, "long_dev") - - # verify calls - assert_equals(r1.call_count, 1) - assert_equals(r2.call_count, 1) - assert_equals(r3.call_count, 1) - - # verify state - assert_equals(api.uid, "2017102559f0ee63c588d") - assert_equals(api.login_access_token, "7a375650b0b1efd780029284479c4e41") - assert_equals(api.auth_code, "5c28dac1ff580210e11292df57e87bef") - assert_equals(api.user_access_token, "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s") - - #Test old user api endpoint - postdata = {'country': 'US', - 'resource': "f8d99c4d", - 'realm': EcoVacsAPI.REALM, - 'userId': "2017102559f0ee63c588d", - 'token': "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s"} - - r = api._EcoVacsAPI__call_user_api("loginByItToken", postdata) - assert_equals(r3.call_count, 2) - # verify state - assert_equals(api.uid, "2017102559f0ee63c588d") - assert_equals(api.login_access_token, "7a375650b0b1efd780029284479c4e41") - assert_equals(api.auth_code, "5c28dac1ff580210e11292df57e87bef") - assert_equals(api.user_access_token, "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s") - - - - -def test_main_api_setup_with_alternate_uid(): - # Under mysterious circumstances, for certain people the last call sometimes returns a different userId - # along with the user access token. If that's the case, we should use that as the UID for future calls - - with requests_mock.mock() as m: - r1 = m.get(compile('user/login'), - text='{"time": 1511200804243, "data": {"accessToken": "7a375650b0b1efd780029284479c4e41", "uid": "2017102559f0ee63c588d", "username": null, "email": "william-ecovacs@pota.to", "country": "us"}, "code": "0000", "msg": "X"}') - r2 = m.get(compile('user/getAuthCode'), - text='{"time": 1511200804607, "data": {"authCode": "5c28dac1ff580210e11292df57e87bef"}, "code": "0000", "msg": "X"}') - r3 = m.post(compile('user.do'), - text='{"todo": "result", "token": "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s", "result": "ok", "userId": "abcdef", "resource": "f8d99c4d"}') - - api = EcoVacsAPI("long_device_id", "account_id", "password_hash", 'us', 'na') - - assert_equals(r1.call_count, 1) - assert_equals(r2.call_count, 1) - assert_equals(r3.call_count, 1) - - # verify state - assert_equals(api.uid, "abcdef") - assert_equals(api.login_access_token, "7a375650b0b1efd780029284479c4e41") - assert_equals(api.auth_code, "5c28dac1ff580210e11292df57e87bef") - assert_equals(api.user_access_token, "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s") - -def test_main_api_errorcode(): - with requests_mock.mock() as m: - r1 = m.get(compile('user/login'), #test with 0004 (invalid token) - text='{"time": 1511200804243, "code": "0004", "msg": "X", "data": null}') - - assert_raises(RuntimeError, EcoVacsAPI, "long_device_id", "account_id", "password_hash", 'us', 'na') #Runtime error from code 0004 - - -def test_main_api_badpassword(): - with requests_mock.mock() as m: - r1 = m.get(compile('user/login'), #test with 1005 (incorrect email or password) - text='{"time": 1511200804243, "code": "1005", "msg": "X", "data": null}') - - assert_raises(ValueError, EcoVacsAPI, "long_device_id", "account_id", "password_hash", 'us', 'na') #ValueError error from code 1005 - - -def test_device_lookup(): - api = make_api() - with requests_mock.mock() as m: - - #Not IOTMQ - device_id = 'E0000001234567890123' - device_class = '126' - device_company = 'eco-legacy' - r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "company": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_company, device_class)) - - d = api.devices() - assert_equals(r.call_count, 1) - assert_equals(len(d), 1) - vacuum = d[0] - assert_equals(vacuum['did'], device_id) - assert_equals(vacuum['class'], '126') - assert_equals(vacuum['iotmq'], False) - - #Is IOTMQ - device_class = 'ls1ok3' #D900 - device_company = 'eco-ng' - r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "company": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_company, device_class)) - - d = api.devices() - assert_equals(r.call_count, 1) - assert_equals(len(d), 1) - vacuum = d[0] - assert_equals(vacuum['did'], device_id) - assert_equals(vacuum['class'], device_class) - assert_equals(vacuum['iotmq'], True) - - -def test_device_lookup_IOTProduct(): - api = make_api() - with requests_mock.mock() as m: - - #Is IOTProduct - device_id = 'E0000001234567890123' - device_class = 'ls1ok3' #D900 - device_company = 'eco-ng' - - r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "company": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_company, device_class)) - r = m.post(compile('pim/product/getProductIotMap'), - text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') - - d = api.devices() - d = api.SetIOTDevices(d, api.getiotProducts()) - - assert_equals(r.call_count, 1) - assert_equals(len(d), 1) - vacuum = d[0] - assert_equals(vacuum['did'], device_id) - assert_equals(vacuum['class'], device_class) - assert_equals(vacuum['iot_product'], True) - assert_equals(vacuum['iotmq'], True) - - #Not IOTProduct - device_id = 'E0000001234567890123' - device_class = '126' - device_company = 'eco-legacy' - - r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "company": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_company, device_class)) - r = m.post(compile('pim/product/getProductIotMap'), - text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') - - d = api.devices() - d = api.SetIOTDevices(d, api.getiotProducts()) - - assert_equals(r.call_count, 1) - assert_equals(len(d), 1) - vacuum = d[0] - assert_equals(vacuum['did'], device_id) - assert_equals(vacuum['class'], device_class) - assert_equals(vacuum['iot_product'], False) - assert_equals(vacuum['iotmq'], False) - -def make_api(): - with requests_mock.mock() as m: - m.get(compile('user/login'), - text='{"time": 1511200804243, "data": {"accessToken": "0123456789abcdef0123456789abcdef", "uid": "20170101abcdefabcdefa", "username": null, "email": "username@example.com", "country": "us"}, "code": "0000", "msg": "X"}') - m.get(compile('user/getAuthCode'), - text='{"time": 1511200804607, "data": {"authCode": "abcdef01234567890abcdef012345678"}, "code": "0000", "msg": "X"}') - m.post(compile('user.do'), - text='{"todo": "result", "token": "base64base64base64base64base64ba", "result": "ok", "userId": "20170101abcdefabcdefa", "resource": "abcdef12"}') - m.post(compile('pim/product/getProductIotMap'), - text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') - return EcoVacsAPI("long_device_id", "account_id", "password_hash", 'us', 'na') diff --git a/tests/test_ecovacs_iotmq.py b/tests/test_ecovacs_iotmq.py deleted file mode 100644 index ceeff32..0000000 --- a/tests/test_ecovacs_iotmq.py +++ /dev/null @@ -1,236 +0,0 @@ -from re import search - -from nose.tools import * - -import requests_mock -import requests - -from sucks import * -import paho.mqtt - -# There are few tests for the MQTT stuff here because it's relatively complicated to test given -# the library's design and its multithreaded nature and lack of explicit testing support. - -def test_subscribe_to_ctls(): - response = None - - def save_response(value): - nonlocal response - response = value - - x = make_ecovacs_iotmq() - x.subscribe_to_ctls(save_response) - - #Test MQTT ctl - mqtt_message = paho.mqtt.client.MQTTMessage - mqtt_message.topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - mqtt_message.payload = b"" - x._handle_ctl_mqtt('','',mqtt_message) - assert_dict_equal(response, {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) - - #Test API ctl - api_message = {} - api_message['resp'] = ' ' - x.subscribe_to_ctls(save_response) - x._handle_ctl_api("Clean", api_message) - assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) - - -def test_is_iotmq(): - x = make_ecovacs_iotmq() - assert_equal(x.vacuum['iotmq'], True) - -def test_wrap_command(): - x = make_ecovacs_iotmq() - - c = x._wrap_command(Charge(), 'E0000000001234567890') - assert_equal(c['cmdName'], Charge().name) - assert_equal(c['toId'], 'E0000000001234567890') - assert_equal(c['payload'], '') - - -def test_iotapi_response(): - x = make_ecovacs_iotmq() - - with requests_mock.mock() as m: - url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/iot/devmanager.do").format(continent=x.continent) - #Test GetCleanState - resp = {"ret":"ok","resp":"","id":"Qgxa"} - r1 = m.post(url, json=resp) - #r1 = m.post(compile('devmanager.do'), json=resp) - cmd = VacBotCommand("GetCleanState") - c = x._wrap_command(cmd, x.vacuum['did']) - rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) - assert_equal(rtnval, {'ret':'ok','resp':"",'id':'Qgxa'}) - - #Test Exception ReadTimeout - r2 = m.post(url, exc=requests.exceptions.ReadTimeout) - #r2 = m.post(compile('iot/devmanager.do'),exc=requests.exceptions.ReadTimeout) - cmd = VacBotCommand("GetCleanState") - c = x._wrap_command(cmd, x.vacuum['did']) - rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) - assert_equal(rtnval, {}) #Right now it sends back a blank object - - #Test Response Fail - Timeout - resp = {"ret":"fail","resp": None, "debug":"wait for response timed out" ,"id":"Qgxa"} - r2 = m.post(url, json=resp) - #r1 = m.post(compile('devmanager.do'), json=resp) - cmd = VacBotCommand("TestCommand") - c = x._wrap_command(cmd, x.vacuum['did']) - rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) - assert_equal(rtnval, {}) - - #Test Response Fail - No debug - resp = {"ret":"fail","resp": None ,"id":"Qgxa"} - r2 = m.post(url, json=resp) - #r1 = m.post(compile('devmanager.do'), json=resp) - cmd = VacBotCommand("TestCommand") - c = x._wrap_command(cmd, x.vacuum['did']) - rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) - assert_equal(rtnval, {}) - -def test_send_command(): - from unittest.mock import MagicMock - x = make_ecovacs_iotmq() - x._handle_ctl_api = MagicMock() - EcoVacsIOTMQ._EcoVacsIOTMQ__call_iotdevmanager_api = MagicMock() - x.send_command(Clean(iotmq=True), '123') - -def test_send_ping(): - from unittest.mock import MagicMock - x = make_ecovacs_iotmq() - EcoVacsIOTMQ._send_simple_command = MagicMock(return_value=MQTTPublish.paho.MQTT_ERR_SUCCESS) - assert_true(x.send_ping()) #Test ping response success - - EcoVacsIOTMQ._send_simple_command = MagicMock(return_value=MQTTPublish.paho.MQTT_ERR_NOT_FOUND) - assert_false(x.send_ping()) #Test ping response fail - -def test_on_connect_rc_nonzero(): - x = make_ecovacs_iotmq() - assert_raises(RuntimeError, x.on_connect, "client", "userdata", "flags", 1) - -def test_xml_to_dict_mqtt(): - x = make_ecovacs_iotmq() - - test_topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) - - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'strong', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) - - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'strong', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) #Test without td - - test_topic = 'iot/atr/BatteryInfo/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'battery_info', 'ts':'1547823289924', 'power': '64'}) - - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'battery_info', 'ts':'1547823289924', 'power': '64'}) #Test without td - - test_topic = 'iot/atr/SleepStatus/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'sleep_status', 'ts':'1547823129670', 'st': '1'}) - - test_topic = 'iot/atr/errors/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'errors', 'ts':'1547822982581','old':'','new':'102'}) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'errors', 'ts':'1547822982581','old':'102','new':''}) - - test_topic = 'iot/atr/Pos/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'pos', 't':'p', 'p':'7,-10', 'a':'-42','valid':'0'}) - - test_topic = 'iot/atr/DustCaseST/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'dust_case_s_t', 'ts':'1547822871328','st':'1'}) - - test_topic = 'iot/atr/MapSt/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'map_st', 'ts':'1547823592934', 'st':'reloc_go_chg_start', 'method':'', 'info':''}) - - test_topic = 'iot/atr/LifeSpan/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ""), - {'event': 'life_span','ret':'ok', 'type': 'brush', 'left': '9876', 'total': '18000'}) - - test_topic = 'iot/atr/CustomCommand/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict_mqtt(test_topic, ''), - {'event': 'custom_command', 'customvar': 'customvalue1'}) - - -def test_xml_to_dict_api(): - x = make_ecovacs_iotmq() - message = {} - - cmd = VacBotCommand("Clean") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict_api(cmd,message['resp']), - {'event': 'clean_report', 'type': 'auto', 'speed': 'standard', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) - - cmd = VacBotCommand("Clean") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict_api(cmd,message['resp']), - {'event': 'clean_report', 'type': 'auto', 'speed': 'strong', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) - - cmd = VacBotCommand("GetBatteryInfo") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict_api(cmd,message['resp']), - {'event': 'battery_info', 'power': '82'}) - - cmd = VacBotCommand("GetLifeSpan") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict_api(cmd,message['resp']), - {'event': 'life_span','ret':'ok', 'type': 'brush', 'left': '9876', 'total': '18000'}) - - cmd = VacBotCommand("Charge") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict_api(cmd,message['resp']), - {'type': 'going', 'h': '', 'r': 'a', 's': '', 'g': '0', 'event': 'charge_state'}) - - cmd = VacBotCommand("GetTestCommand") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict_api(cmd,message['resp']), - {'type': 'command', 'event': 'test_command'}) #Test action.name.replace Get - - cmd = VacBotCommand("Charge") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict_api(cmd,message['resp']), - {'event': 'charge_state','ret':'fail', 'errno': '8'}) #Test fail from charge command - - -def test_bad_port(): - bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iotmq": True} - mqtt = EcoVacsIOTMQ('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:f123') - assert_equal(8883, mqtt.port) - -def test_good_port(): - bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iotmq": True} - mqtt = EcoVacsIOTMQ('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:8000') - assert_equal(8000, mqtt.port) - -def make_ecovacs_iotmq(bot=None): - if bot is None: - bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iotmq": True} - return EcoVacsIOTMQ('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py deleted file mode 100644 index 184aeaa..0000000 --- a/tests/test_ecovacs_xmpp.py +++ /dev/null @@ -1,91 +0,0 @@ -from re import search - -from nose.tools import * - -from sucks import * - - -# There are few tests for the XMPP stuff here because it's relatively complicated to test given -# the library's design and its multithreaded nature and lack of explicit testing support. - -def test_wrap_command(): - x = make_ecovacs_xmpp() - c = str(x._wrap_command(Clean().to_xml(), 'E0000000001234567890@126.ecorobot.net/atom')) - assert_true(search(r'from="20170101abcdefabcdefa@ecouser.net/abcdef12"', c)) - assert_true(search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c)) - #Convert to XML to make it easy to see if id was added to ctl - xml_test = ET.fromstring(c) - ctl = xml_test.getchildren()[0][0] - assert_true(ctl.get("id")) #Check that an id was added to ctl - - #Test if customid is added to ctl - cwithid = Clean().to_xml() - cwithid.attrib["id"] = "12345678" #customid 12345678 - c = str(x._wrap_command(cwithid, 'E0000000001234567890@126.ecorobot.net/atom')) - #Convert to XML to make it easy to see if id was added to ctl - xml_test = ET.fromstring(c) - ctl = xml_test.getchildren()[0][0] - assert_equals(ctl.get("id"), "12345678") #Check that an id was added to ctl - - -def test_getReqID(): - x = make_ecovacs_xmpp() - rid = x.getReqID("12345678") - assert_equals(rid, "12345678") #Check returned ID is the same as provided - - rid2 = x.getReqID() - assert_true(len(rid2) >= 8) #Check returned random ID is at least 8 chars - -def test_subscribe_to_ctls(): - response = None - - def save_response(value): - nonlocal response - response = value - - x = make_ecovacs_xmpp() - - query = x.make_iq_query() - query.set_payload( - ET.fromstring(' ')) - - x.subscribe_to_ctls(save_response) - x._handle_ctl(query) - assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) - -def test_xml_to_dict(): - x = make_ecovacs_xmpp() - - assert_dict_equal( - x._ctl_to_dict(make_ctl(' ')), - {'event': 'clean_report', 'type': 'auto'}) - assert_dict_equal( - x._ctl_to_dict(make_ctl(' ')), - {'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) - - assert_dict_equal( - x._ctl_to_dict(make_ctl('')), - {'event': 'battery_info', 'power': '095'}) - - assert_dict_equal( - x._ctl_to_dict(make_ctl('# ')), - {'event': 'life_span', 'type': 'brush', 'val': '099', 'total': '365'}) - - assert_dict_equal( - x._ctl_to_dict(make_ctl('')), - {'event': 'life_span', 'type': 'dust_case_heap', 'val': '-050', 'total': '365'}) - - assert_equals(x._ctl_to_dict(make_ctl('')), None) - - -def make_ecovacs_xmpp(bot=None, server_address=None): - if bot is None: - bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq": False} - return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address=server_address) - -def test_xmpp_customaddress(): - x = make_ecovacs_xmpp(server_address="test.xmppserver.com") - assert_equals(x.server_address, "test.xmppserver.com") - -def make_ctl(string): - return ET.fromstring('' + string + '')[0] diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py deleted file mode 100644 index c4e436a..0000000 --- a/tests/test_vacbot.py +++ /dev/null @@ -1,398 +0,0 @@ -from nose.tools import * - -from sucks import * -from unittest.mock import Mock, patch -from sleekxmppfs.exceptions import XMPPError -from paho.mqtt.client import MQTT_ERR_UNKNOWN as MQTTError - - -def test_handle_clean_report(): - v = a_vacbot() - assert_equals(None, v.clean_status) - - v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) - assert_equals('auto', v.clean_status) - assert_equals('high', v.fan_speed) - - v._handle_ctl({'event': 'clean_report', 'type': 'border', 'speed': 'standard'}) - assert_equals('edge', v.clean_status) - assert_equals('normal', v.fan_speed) - - # Missing fan_speed - v = a_vacbot() - v._handle_ctl({'event': 'clean_report', 'type': 'border'}) - assert_equals('edge', v.clean_status) - assert_is_none(v.fan_speed) - - # For states not handled by sucks constants, fall back to just using whatever the vacuum said - v._handle_ctl({'event': 'clean_report', 'type': 'a_type_not_supported_by_sucks', 'speed': 'a_weird_speed'}) - assert_equals('a_type_not_supported_by_sucks', v.clean_status) - assert_equals('a_weird_speed', v.fan_speed) - - - -def test_not_iot_send_command_clean(): - from unittest.mock import MagicMock - v = a_vacbot(iotmq=False) - v.xmpp.send_command = MagicMock() - v.send_command(VacBotCommand('Clean')) - assert v.xmpp.send_command.called #test when iot is False it uses xmpp.send_command - - -def test_iot_send_command_clean(): - from unittest.mock import MagicMock - v = a_vacbot(iotmq=True) - v.iotmq.send_command = MagicMock() - v.send_command(VacBotCommand('Clean')) - assert v.iotmq.send_command.called #test when iot is True it uses iotmq.send_command - - -def test_handle_charge_state(): - v = a_vacbot() - assert_equals(None, v.clean_status) - - v._handle_ctl({'event': 'charge_state', 'type': 'going'}) - assert_equals('returning', v.charge_status) - - v._handle_ctl({'event': 'charge_state', 'type': 'slot_charging'}) - assert_equals('charging', v.charge_status) - - v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) - assert_equals('idle', v.charge_status) - - v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '9'}) #Seen in IOT - "but on charger, but turned off" - assert_equals('idle', v.charge_status) - - v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '8'}) #Seen in IOT - could be "already charging" - assert_equals('charging', v.charge_status) - - v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '5'}) #Seen in IOT - could be "busy with another command" - assert_equals('idle', v.charge_status) - - v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '3'}) #Seen in IOT - could be "Bot in stuck state, example dust bin out" - assert_equals('idle', v.charge_status) - - v._handle_ctl({'event': 'charge_state', 'type': 'a_type_not_supported_by_sucks'}) - assert_equals('a_type_not_supported_by_sucks', v.charge_status) - - -def test_vacuum_states(): - # Vacuum state usually mirrors the latest charge or clean report, but there are some edge cases where it doesn't - # work that way. This test ensures the edge cases are handled correctly. - v = a_vacbot() - assert_equals(None, v.vacuum_status) - - v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) - assert_equals('auto', v.vacuum_status) - - # Ignore the "idle" charge state in most cases, as it can be reported during a cleaning (such as during initialization) - v._handle_ctl({'event': 'clean_report', 'type': 'auto'}) - v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) - assert_equals('auto', v.vacuum_status) - - # However, we do honor the idle state when our current state is charging, as that can happen in some certain combination of events - v._handle_ctl({'event': 'charge_state', 'type': 'slot_charging'}) - v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) - assert_equals('idle', v.vacuum_status) - -def test_handle_battery_info(): - v = a_vacbot() - assert_equals(None, v.battery_status) - - v._handle_ctl({'event': 'battery_info', 'power': '100'}) - assert_equals(1.0, v.battery_status) - - v._handle_ctl({'event': 'battery_info', 'power': '095'}) - assert_equals(0.95, v.battery_status) - - v._handle_ctl({'event': 'battery_info', 'power': '000'}) - assert_equals(0.0, v.battery_status) - - -def test_lifespan_reports(): - v = a_vacbot() - assert_equals({}, v.components) - - # Note: The "total" values don't seem to have any meaning - - v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '50'}) - assert_equals({'side_brush': 0.5}, v.components) - - v._handle_ctl({'event': 'life_span', 'type': 'brush', 'total': '200', 'val': '1'}) - assert_equals({'side_brush': 0.5, 'main_brush': 0.01}, v.components) - - v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '0'}) - assert_equals({'side_brush': 0, 'main_brush': 0.01}, v.components) - - v._handle_ctl({'event': 'life_span', 'type': 'a_weird_component', 'total': '100', 'val': '87'}) - assert_equals({'side_brush': 0, 'main_brush': 0.01, 'a_weird_component': 0.87}, v.components) - - v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'left': '120'}) - assert_equals(2.0, v.components['side_brush']) #test left (2 hours / 120 mins) instead of val - - -def test_is_cleaning(): - v = a_vacbot() - - assert_false(v.is_cleaning) - - v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) - assert_true(v.is_cleaning) - - v._handle_ctl({'event': 'clean_report', 'type': 'stop'}) - assert_false(v.is_cleaning) - - v._handle_ctl({'event': 'clean_report', 'type': 'edge', 'speed': 'normal'}) - assert_true(v.is_cleaning) - - v._handle_ctl({'event': 'charge_state', 'type': 'going'}) - assert_false(v.is_cleaning) - - v = a_vacbot(iotmq=True) - v._handle_ctl({'event': 'clean_report', 'type': 'spot_area', 'speed':'normal','st':'h'}) - assert_false(v.is_cleaning) #test iot and state paused - - v = a_vacbot(iotmq=True) - v._handle_ctl({'event': 'clean_report', 'type': 'spot_area', 'speed':'normal','st':'r'}) - assert_true(v.is_cleaning) #test iot and state running - -def test_is_charging(): - v = a_vacbot() - - assert_false(v.is_charging) - - v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) - assert_false(v.is_charging) - - v._handle_ctl({'event': 'charge_state', 'type': 'going'}) - assert_false(v.is_charging) - - v._handle_ctl({'event': 'charge_state', 'type': 'slot_charging'}) - assert_true(v.is_charging) - - v._handle_ctl({'event': 'clean_report', 'type': 'edge', 'speed': 'normal'}) - assert_false(v.is_charging) - - - - -def test_send_ping_no_monitor(): - #Test XMPP Ping - v = a_vacbot() - mock = v.xmpp.send_ping = Mock() - v.send_ping() - - # On four failed pings, vacuum state gets set to 'offline' - mock.side_effect = XMPPError() - v.send_ping() - v.send_ping() - v.send_ping() - assert_equals(None, v.vacuum_status) - v.send_ping() - assert_equals('offline', v.vacuum_status) - - # On a successful ping after the offline state, state gets reset to None, indicating that it is unknown - mock.side_effect = None - v.send_ping() - assert_equals(None, v.vacuum_status) - - #Test MQTT Ping - v = a_vacbot(iotmq=True) - mock = v.iotmq.send_ping = Mock() - v.send_ping() - - # On four failed pings, vacuum state gets set to 'offline' - mock.return_value = False - v.send_ping() - v.send_ping() - v.send_ping() - assert_equals(None, v.vacuum_status) - v.send_ping() - assert_equals('offline', v.vacuum_status) - - # On a successful ping after the offline state, state gets reset to None, indicating that it is unknown - mock.return_value = True - v.send_ping() - assert_equals(None, v.vacuum_status) - - -def test_send_ping_with_monitor(): - #Test XMPP Ping - v = a_vacbot(monitor=True) - - ping_mock = v.xmpp.send_ping = Mock() - request_statuses_mock = v.request_all_statuses = Mock() - - # First ping should try to fetch statuses - v.send_ping() - assert_equals(1, request_statuses_mock.call_count) - - # Nothing blowing up is success - - # On four failed pings, vacuum state gets set to 'offline' - ping_mock.side_effect = XMPPError() - v.send_ping() - v.send_ping() - v.send_ping() - assert_equals(None, v.vacuum_status) - v.send_ping() - assert_equals('offline', v.vacuum_status) - - # On a successful ping after the offline state, a request for initial statuses is made - ping_mock.side_effect = None - request_statuses_mock.reset_mock() - v.send_ping() - assert_equals(1, request_statuses_mock.call_count) - - #Test MQTT Ping - v = a_vacbot(iotmq=True, monitor=True) - - ping_mock = v.iotmq.send_ping = Mock() - request_statuses_mock = v.request_all_statuses = Mock() - - # First ping should try to fetch statuses - v.send_ping() - assert_equals(1, request_statuses_mock.call_count) - - # Nothing blowing up is success - - # On four failed pings, vacuum state gets set to 'offline' - ping_mock.return_value = False - v.send_ping() - v.send_ping() - v.send_ping() - assert_equals(None, v.vacuum_status) - v.send_ping() - assert_equals('offline', v.vacuum_status) - - # On a successful ping after the offline state, a request for initial statuses is made - ping_mock.return_value = True - request_statuses_mock.reset_mock() - v.send_ping() - assert_equals(1, request_statuses_mock.call_count) - - -def test_status_event_subscription(): - v = a_vacbot() - - mock = Mock() - v.statusEvents.subscribe(mock) - v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) - mock.assert_called_once_with('auto') - - mock = Mock() - v.statusEvents.subscribe(mock) - v._handle_ctl({'event': 'charge_state', 'type': 'going'}) - mock.assert_called_once_with('returning') - - # Test unsubscribe - mock = Mock() - subscription = v.statusEvents.subscribe(mock) - v._handle_ctl({'event': 'charge_state', 'type': 'going'}) - assert_equals(1, mock.call_count) - subscription.unsubscribe() - v._handle_ctl({'event': 'charge_state', 'type': 'slot_charging'}) - assert_equals(1, mock.call_count) - -def test_battery_event_subscription(): - v = a_vacbot() - - mock = Mock() - v.batteryEvents.subscribe(mock) - v._handle_ctl({'event': 'battery_info', 'power': '095'}) - mock.assert_called_once_with(0.95) - - # Test unsubscribe - mock = Mock() - subscription = v.batteryEvents.subscribe(mock) - v._handle_ctl({'event': 'battery_info', 'power': '095'}) - assert_equals(1, mock.call_count) - subscription.unsubscribe() - v._handle_ctl({'event': 'battery_info', 'power': '090'}) - assert_equals(1, mock.call_count) - -def test_lifespan_event_subscription(): - v = a_vacbot() - - mock = Mock() - v.lifespanEvents.subscribe(mock) - v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '50'}) - mock.assert_called_once_with({'type': 'side_brush', 'lifespan': 0.5}) - - # Test unsubscribe - mock = Mock() - subscription = v.lifespanEvents.subscribe(mock) - v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '50'}) - assert_equals(1, mock.call_count) - subscription.unsubscribe() - v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '25'}) - assert_equals(1, mock.call_count) - -def test_error_event_subscription(): - v = a_vacbot() - - mock = Mock() - v.errorEvents.subscribe(mock) - v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) - v._handle_ctl({'event': 'error', 'errs': 'an_error_name2'}) #added for testing errs - assert_equals(2, mock.call_count) - #mock.assert_called_once_with('an_error_name') - - - # Test unsubscribe - mock = Mock() - subscription = v.errorEvents.subscribe(mock) - v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) - assert_equals(1, mock.call_count) - subscription.unsubscribe() - v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) - assert_equals(1, mock.call_count) - -def test_handle_unknown_ctl(): - v = a_vacbot() - v._handle_ctl({'event': 'weird_and_unknown_event', 'type': 'pretty_weird'}) - # as long as it doesn't blow up, that's fine - - -# as-yet unhandled messages: -# -# -# -# -# -# -# -# plus errors! - -def test_bot_address(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq":False}) - assert_equals('E0000000001234567890@126.ecorobot.net/atom', v._vacuum_address()) - - -def test_bot_address_iot(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq":True}) - assert_equals('E0000000001234567890', v._vacuum_address()) - - -def test_model_variation(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "141", "nick": "bob","iotmq":False}) - assert_equals('E0000000001234567890@141.ecorobot.net/atom', v._vacuum_address()) - - - -def a_vacbot(bot=None, iotmq=False, monitor=False): - if bot is None: - bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq": iotmq} - return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', - bot, 'na', monitor=monitor) - -def test_str_to_bool(): - assert_raises(ValueError, str_to_bool_or_cert, None) #Value error if str_to_bool can't convert - assert_equals(True, str_to_bool_or_cert("True")) - assert_equals(False, str_to_bool_or_cert("False")) - assert_equals( - os.path.abspath(os.path.join(".", "tests", "test_vacbot.py")), - str_to_bool_or_cert(os.path.abspath(os.path.join(".","tests","test_vacbot.py"))) - ) - assert_raises(ValueError, str_to_bool_or_cert ,(os.path.abspath(os.path.join(".","tests")))) - \ No newline at end of file