diff --git a/.gitignore b/.gitignore index e0d16c7..9c4cd19 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,11 @@ dist # Nosetests files cover/ .coverage + +# Ignore Vscode files +.vscode/ + +# Ignore sucks.egg-info +sucks.egg-info/ +.noseids +nosetests.xml diff --git a/README.md b/README.md index d1f8101..99f99cf 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,8 @@ shaping the API. A simple usage might go something like this: -``` -import sucks +```python +from sucks import * config = ... diff --git a/protocol.md b/protocol.md index 98db5ee..8569236 100644 --- a/protocol.md +++ b/protocol.md @@ -1,8 +1,8 @@ # Ecovacs Protocol -There are two protocols involved in the communication between the client and Ecovacs systems. There are a series of HTTPS requests +Depending on the device there are a few different protocols involved in the communication between the client and Ecovacs systems. There are a series of HTTPS requests used to log in and find devices. Once logged in, you get a token that is -used to connect to an XMPP server, which mediates communication with the +used for connecting to different services. In many cases this involves connecting to an XMPP server, which mediates communication with the vacuum. That's right, your robot housecleaner, like an errant teen, spends all its free time hanging out in an internet chat room. @@ -22,26 +22,16 @@ For example, a Canadian user must authenticate on country-specific HTTPS server, but XMPP commands work both on the worldwide server msg-ww.ecouser.net) and the North America server (msg-na.ecouser.net) -The Android App uses the following XMPP messaging servers: - -``` -CH: msg.ecouser.net -TW, MY, JP, SG, TH, HK, IN, KR: msg-as.ecouser.net -US: msg-na.ecouser.net -FR, ES, UK, NO, MX, DE, PT, CH, AU, IT, NL, SE, BE, DK: msg-eu.ecouser.net -Any other country: msg-ww.ecouser.net -``` - ## HTTPS There are two sorts of URLs in the basic login flow. The first set use a format like this: -``` +` https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType} -``` - +` + They also have a complicated API request signature that seems overelaborate to me. See the Python code for more details. @@ -53,32 +43,110 @@ access token. 3. GET eco-us-api.ecovacs.com ... user/getAuthCode - sends uid, accessToken; gets back an auth code + *Under mysterious circumstances, for some people the getAuthCode call will + return a different userId than is passed in. In that case, apparently the new userId should be used for future calls, or an Auth 1004 error results.* + Now we switch to posting to a different server, and the request and response style change substantially. I think of this at the user server, or perhaps the XMPP/device server. +` + https://portal-{continent}.ecouser.net/api +` -4. POST users-na.ecouser.net:8000/user.do loginByItToken - trades the +There are a few different endpoints within the API that have been seen and are used in the library: + + +| Endpoint | Description | +| - | - | +| /users/user.do | Handles user / account functions | +| /iot/devmanager.do | Provides a RestAPI that handles sending commands to "IOTMQ" devices | +| /pim/product/getProductIotMap | Provides a listing of "IOT" Products | + + +1. POST portal-na.ecouser.net/api/users/user.do loginByItToken - trades the authCode from the previous call for yet another token 5. POST ne-na.ecouser.net:8018/notify_engine.do - not sure what this is for; my script skips this and seems to work fine -6. POST users-na.ecouser.net:8000/user.do GetDeviceList - Using the token +6. POST portal-na.ecouser.net/api/users/user.do GetDeviceList - Using the token from step 4, gets the list of devices; that's needed for talking to the vacuum via XMPP +7. POST +portal-na.ecouser.net/api/pim/product/getProductIotMap +getProductIotMap - Provides a list of "IOT" products, it isn't clear what the app uses these for at this time, possibly for determining how to get updates. -Under mysterious circumstances, for some people the getAuthCode call will -return a different userId than is passed in. In that case, apparently the -new userId should be used for future calls, or an Auth 1004 error results. +At this point depending on your device you will connect to either an XMPP server, or an MQTT server. +| "IOT XMPP" Products | "IOT MQ" Products | +| - | - | +| Connect to an XMPP server to send commands to devices and receive status results | Connect to an MQTT server to subscribe to status messages and results. A Rest API is utilized to send commands to devices, but can also be used to obtain statuses. | -## XMPP + +## XMPP - ("IOT XMPP") The app establishes a connection to an XMPP server and logs in using a secret that comes from the earlier HTTPS calls. It then sends XMPP IQ commands. It describes them as queries, but they all contain "ctl" elements that appear to be commands. +The Android App uses the following XMPP messaging servers: + +|Country|URL| +| - | - | +|CH|msg.ecouser.net| +|TW, MY, JP, SG, TH, HK, IN, KR|msg-as.ecouser.net| +|US|msg-na.ecouser.net| +|FR, ES, UK, NO, MX, DE, PT, CH, AU, IT, NL, SE, BE, DK|msg-eu.ecouser.net| +|Any other|msg-ww.ecouser.net| + +## MQTT - ("IOT MQ") + +The app establishes a connection to an MQTT server and logs in using +a secret that comes from the earlier HTTPS calls. + +It then subscribes to a topic where various status and result messages are published by the device. +The topic looks like this: + +` +iot/atr/+/{deviceID}/{deviceClass}/{deviceResource}/+ +` + +It is believed the MQTT servers mirror the XMPP servers, but only the NA and WW have been tested so far. + +|Country|URL| +|-|-| +|US|mq-na.ecouser.net| +|"World-wide"|mq-ww.ecouser.net| + +## Rest API - ("IOT MQ") + +For IOT MQ devices the app sends commands to the device over a Rest API utilizing the secret that comes from the earlier HTTPS calls. This API has only been tested from an "IOT MQ" device, but could possibly work for other devices as well. + +The Rest API utilizes the same portal URL as used previously, but with the iot/devmanager endpoint: +` + https://portal-{continent}.ecouser.net/api/iot/devmanager.do +` +Commands are sent via POST in the format of: +```json +{ + "auth": { + "realm": "ecouser.net", + "resource": "resource", + "token": "token", + "userid": "userid", + "with": "users", +}, +"cmdName": "cmd.name", +"payload": "cmd.args", +"payloadType": "x", +"td": "q", +"toId": "vacuum.serial", +"toRes": "vacuum.resource", +"toType": "vacuum.class" +} +``` +## Commands ### Cleaning @@ -93,6 +161,7 @@ elements that appear to be commands. - type `spot` spot cleaning program - type `singleroom` cleaning a single room - type `stop` bot at full stop + - type `SpotArea` cleaning a mapped room (mapping robots only) - speed `standard` regular fan speed (suction) - speed `strong` high fan speed (suction) @@ -112,11 +181,10 @@ elements that appear to be commands. - `WireCharging` currently charging by cable - ### Battery State Battery charge level. 080 = 80% charged. State is broadcast -continously when the robot is running och charging, but can also +continously when the robot is running or charging, but can also be requested manually. - *Request* `` @@ -139,27 +207,60 @@ It's presumed that the timers need to be reset manually. ### Manually moving around -**Command** -- Move forward: `` -- Spin left 360 degrees: `` -- Spin right 360 degrees: `` -- Turn 180 degrees: `` -- Stop the ongoing action: `` +|**Command**|**Control**| +|-|-| +|Move forward|``| +|Move backward|``| +|Spin left 360 degrees|``| +|Spin right 360 degrees|``| +|Turn 180 degrees|``| +|Stop the ongoing action|``| ### Configuration -**Set/get robot internal clock** +#### Set/get robot internal clock - `` - `` - Time is specified as a UNIX timestamp and timezone + or - UTC offset. -**Get firmware version** +#### Get firmware version `` -**Get robot logs** +#### Get robot logs `` +#### Get/Set option value +Gets or sets value for option (0==Off, 1==On) +##### GetOnOff + - Do Not Disturb - `` + - Continuous Cleaning - `` + - Silence Voice Report - `` + + Returns `` +##### SetOnOff + - Do Not Disturb - `` + - Continuous Cleaning - `` + - Silence Voice Report - `` + + Returns `` + +#### Mopping Water Amount +Models with mopping capability (Ozmo) allow for changing the amount of water dispersed. The value ranges from 1 (low) to 3 (high). + +`` + + +#### Schedules +##### GetSched +`` + +Gets any schedules for the robot. + +- No Schedules + - `` +- Schedule + - `` ### Errors @@ -180,26 +281,122 @@ HostHang, then proceeds to stop and broadcasts 100 NoError. **Known error codes** -- 100 NoError: Robot is operational -- 101 BatteryLow: Low battery -- 102 HostHang: Robot is stuck -- 103 WheelAbnormal: Wheels are not moving as expected -- 104 DownSensorAbnormal: Down sensor is getting abnormal values -- 110 NoDustBox: Dust Bin Not installed - -These codes are taken from model M81 Pro. Error codes may differ + +|Code|Description| +|-|-| +|100|NoError: Robot is operational| +|101|BatteryLow: Low battery| +|102|HostHang: Robot is off the floor| +|103|WheelAbnormal: Driving Wheel malfunction| +|104|DownSensorAbnormal: Excess dust on the Anti-Drop Sensors| +|105|Stuck: Robot is stuck| +|106|SideBrushExhausted: Side Brushes have expired| +|107|DustCaseHeapExhausted: Dust case filter expired| +|108|SideAbnormal: Side Brushes are tangled| +|109|RollAbnormal: Main Brush is tangled| +|110|NoDustBox: Dust Bin Not installed| +|111|BumpAbnormal: Bump sensor stuck| +|112|LDS: LDS "Laser Distance Sensor" malfunction| +|113|MainBrushExhausted: Main brush has expired| +|114|DustCaseFilled: Dust bin full| +|115|BatteryError: | +|116|ForwardLookingError: | +|117|GyroscopeError: | +|118|StrainerBlock: | +|119|FanError: | +|120|WaterBoxError: | +|201|AirFilterUninstall: | +|202|UltrasonicComponentAbnormal| +|203|SmallWheelError| +|UNKNOW|"unknow"| + +These codes were gathered from the Android app source, but may differ between models. +### Sounds +Different sid "Sound IDs" will play different sounds. If the vacuum has Voice Report disabled, these won't play. The table below was compiled by testing against a D900 series. + +`` + +| SID | Description | +|-|-| +| 0 | Startup Music Chime | +| 3 | I Am Suspended | +| 4 | Check Driving Wheels | +| 5 | Please Help Me Out | +| 6 | Please Install Dust Bin | +| 17 | Chime / Beep | +| 18 | My Battery Is Low | +| 29 | Please power me on before charging | +| 30 | I Am Here | +| 31 | Brush is tangled please clean my brush | +| 35 | Please clean my antidrop sensors | +| 48 | Brush is tangled | +| 55 | I am relocating | +| 56 | Upgrade succeeded | +| 63 | I am returning to the charging dock | +| 65 | Cleaning paused | +| 69 | Connected please go back to ecovacs app to continue setup | +| 71 | I am restoring the map please do not stand beside me | +| 73 | My battery is low returning to the charging dock | +| 74 | Difficult to locate I am starting a new cleaning cycle | +| 75 | I am resuming the clean | +| 76 | Upgrade failed please try again | +| 77 | Please place me on the charging dock | +| 79 | Resume the clean | +| 80 | I am starting the clean | +| 81 | I am starting the clean | +| 82 | I am starting the clean | +| 84 | I am ready for mopping | +| 85 | Please remove the mopping plate when I am building the map | +| 86 | Cleaning is complete returning to the charging dock | +| 89 | LDS Malfunction please try to tap the LDS | +| 90 | I am upgrading please wait | + +### SpotAreas +For bots with mapping capability this tells a bot to clean specified rooms. + +For the CLI - the `area` command takes a csv of ints - ex `area 0,1` + +You can add the option `--map-position` or `-p` to clean a specified map coordinate - ex `area -p "-602,1812,800,723"` + +For the Library - you could use `vacbot.run(SpotArea('start', '0,1'))` + +"0,1" is a list of mapIDs the bot should clean. Each of these corresponds to a room or area the bot mapped. In the app, these are what show the letters over rooms mapID (0) == room ("A"), (1) == "B", etc. + +If you want to see your MapSet areas, you can use the library. Set --debug for sucks and then use a custom command: +`vacbot.run(VacBotCommand("GetMapSet", {"tp":"sa"}))` + +You'll see in DEBUG something like: +``` +sucks DEBUG got {'id': 'ralnsy', 'ret': 'ok', 'resp': ""} +``` +This tells you I have 9 rooms mapped (mid= 0 - 8) or A-I, but you should be able to compare to the map in the app now to know which mid == what room. -### Untested commands +#### SpotArea Friendly Names +For bots with mapping capability the app automatically names areas (rooms) A-Z. You can rename these to "friendly names" - something the app won't let you do natively. +Use the above "GetMapSet" custom command and then convert the xml to json: +``` xml + +``` +becomes +``` javascript +{"ctl":{"ret":"ok","tp":"sa","msid":"11","m":[{"mid":"0","p":"1"},{"mid":"1","p":"1"},{"mid":"2","p":"1"},{"mid":"3","p":"1"},{"mid":"4","p":"1"},{"mid":"5","p":"1"},{"mid":"6","p":"1"},{"mid":"7","p":"1"},{"mid":"8","p":"1"}]}} ``` - - - - +Now you need to add a "n" attribute which contains the friendly name: +``` javascript +{"ctl":{"ret":"ok","tp":"sa","msid":"11","m":[{"mid":"0","n":"Entry"},{"mid":"1","n":"Master Bath"},{"mid":"2","n":"Master"},{"mid":"3","n":"Office"},{"mid":"4","n":"Play Room"},{"mid":"5","n":"Craft Room"},{"mid":"6","n":"Kitchen"},{"mid":"7","n":"Sun Room"},{"mid":"8","n":"Garage Entry"}]}} ``` +Remove the api response details ("ctl" and "ret"): +``` javascript +{"tp":"sa","msid":"11","m":[{"mid":"0","n":"Entry"},{"mid":"1","n":"Master Bath"},{"mid":"2","n":"Master"},{"mid":"3","n":"Office"},{"mid":"4","n":"Play Room"},{"mid":"5","n":"Craft Room"},{"mid":"6","n":"Kitchen"},{"mid":"7","n":"Sun Room"},{"mid":"8","n":"Garage Entry"}]} +``` +Lastly use the below command to issue the rename: +``` +vacbot.run(VacBotCommand("RenameM", {"tp":"sa","msid":"11","m":[{"mid":"0","n":"Entry"},{"mid":"1","n":"Master Bath"},{"mid":"2","n":"Master"},{"mid":"3","n":"Office"},{"mid":"4","n":"Play Room"},{"mid":"5","n":"Craft Room"},{"mid":"6","n":"Kitchen"},{"mid":"7","n":"Sun Room"},{"mid":"8","n":"Garage Entry"}]})) +``` +You should then see the friendly names in the app when selecting an area to clean. + +**Note:** You cannot use the friendly names when starting a clean, you must use the mid. -It appears that it adds an extra id when it cares to receive a specific response. -This is a little odd in that the iq blocks already contain ids, but perhaps one -is more a server id and the other is used by the robot itself. diff --git a/setup.py b/setup.py index 1d3e458..22bb6d1 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ 'requests>=2.18', 'pycryptodome>=3.4', 'pycountry-convert>=0.5', + 'paho-mqtt>=1.4', 'stringcase>=1.2' ], diff --git a/sucks/__init__.py b/sucks/__init__.py index 65264c6..3662bfd 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -4,13 +4,21 @@ 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. @@ -19,9 +27,15 @@ 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' @@ -36,7 +50,7 @@ VACUUM_STATUS_OFFLINE = 'offline' -CLEANING_STATES = {CLEAN_MODE_AUTO, CLEAN_MODE_EDGE, CLEAN_MODE_SPOT, CLEAN_MODE_SINGLE_ROOM} +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) @@ -45,14 +59,30 @@ 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 @@ -93,24 +123,53 @@ '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-ww.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): + 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 @@ -149,7 +208,7 @@ def __call_main_api(self, 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)) + 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': @@ -166,7 +225,7 @@ 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) + 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': @@ -176,17 +235,45 @@ def __call_user_api(self, function, args): 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): + _LOGGER.debug("calling portal api {} function {} with {}".format(api, function, args)) + if api == self.USERSAPI: + params = {'todo': function} + params.update(args) + else: + params = {} + params.update(args) + + url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=self.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 + + 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_user_api('loginByItToken', + 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} - ) - - def devices(self): - devices = self.__call_user_api('GetDeviceList', { + , verify_ssl=self.verify_ssl) + + def getdevices(self): + return self.__call_portal_api(self.USERSAPI,'GetDeviceList', { 'userid': self.uid, 'auth': { 'with': 'users', @@ -195,9 +282,42 @@ def devices(self): 'token': self.user_access_token, 'resource': self.resource } - })['devices'] + }, 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() @@ -239,9 +359,8 @@ def __init__(self, emitter, callback): def unsubscribe(self): self._emitter.unsubscribe(self) - class VacBot(): - def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False): + def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False, verify_ssl=True): self.vacuum = vacuum @@ -270,18 +389,42 @@ def __init__(self, user, domain, resource, secret, vacuum, continent, server_add self.lifespanEvents = EventEmitter() self.errorEvents = EventEmitter() - self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, server_address) - self.xmpp.subscribe_to_ctls(self._handle_ctl) + #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): - self.xmpp.connect_and_wait_until_ready() - - self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), 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.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() - self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) + 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'] @@ -289,9 +432,14 @@ def _handle_ctl(self, ctl): getattr(self, method)(ctl) def _handle_error(self, event): - error = event['error'] - self.errorEvents.notify(error) - _LOGGER.debug("*** error = " + error) + 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'] @@ -300,9 +448,12 @@ def _handle_life_span(self, event): except KeyError: _LOGGER.warning("Unknown component type: '" + type + "'") - lifespan = int(event['val']) / 100 + 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)) @@ -311,10 +462,16 @@ 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 + self.vacuum_status = type + fan = event.get('speed', None) if fan is not None: try: @@ -338,7 +495,19 @@ def _handle_battery_info(self, iq): _LOGGER.debug("*** battery_status = {:.0%}".format(self.battery_status)) def _handle_charge_state(self, event): - status = event['type'] + 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: @@ -354,7 +523,10 @@ def _handle_charge_state(self, event): _LOGGER.debug("*** charge_status = " + self.charge_status) def _vacuum_address(self): - return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom' + 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: @@ -366,7 +538,12 @@ def is_cleaning(self) -> bool: def send_ping(self): try: - self.xmpp.send_ping(self._vacuum_address()) + 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) @@ -375,6 +552,14 @@ def send_ping(self): 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: @@ -397,7 +582,7 @@ def refresh_components(self): _LOGGER.debug("*** Error type: " + err.etype) _LOGGER.debug("*** Error condition: " + err.condition) - def request_all_statuses(self): + def refresh_statuses(self): try: self.run(GetCleanState()) self.run(GetChargeState()) @@ -406,27 +591,278 @@ def request_all_statuses(self): _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: - self.refresh_components() + 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 - def send_command(self, xml): - self.xmpp.send_command(xml, self._vacuum_address()) + self._client_id = self.user + '@' + self.domain.split(".")[0] + '/' + self.resource + self.username_pw_set(self.user + '@' + self.domain, secret) - def run(self, action): - self.send_command(action.to_xml()) + self.ready_flag = Event() - def disconnect(self, wait=False): - self.xmpp.disconnect(wait=wait) + 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) -class EcoVacsXMPP(ClientXMPP): - def __init__(self, user, domain, resource, secret, continent, server_address=None): - ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret) + 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') @@ -436,6 +872,7 @@ def __init__(self, user, domain, resource, secret, continent, server_address=Non self.ctl_subscribers = [] self.ready_flag = Event() + def wait_until_ready(self): self.ready_flag.wait() @@ -468,10 +905,13 @@ def _ctl_to_dict(self, xml): result.update(xml[0].attrib) for key in result: - result[key] = stringcase.snakecase(result[key]) + if not RepresentsInt(result[key]): #Fix to handle negative int values + result[key] = stringcase.snakecase(result[key]) + return result - def register_callback(self, kind, function): + def register_callback(self, userdata, message): + self.register_handler(Callback(kind, MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}ctl[@td="' + kind + '"]'), function)) @@ -483,14 +923,30 @@ def send_command(self, xml, recipient): def _wrap_command(self, ctl, recipient): q = self.make_iq_query(xmlns=u'com:ctl', ito=recipient, ifrom=self._my_address()) - q['type'] = 'set' + 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): - return self.user + '@' + self.domain + '/' + self.boundjid.resource + 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()) @@ -498,22 +954,22 @@ def send_ping(self, to): _LOGGER.debug("*** sending ping ***") q.send() - def connect_and_wait_until_ready(self): + 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): + def __init__(self, name, args=None, **kwargs): if args is None: args = {} self.name = name @@ -521,12 +977,17 @@ def __init__(self, name, args=None): def to_xml(self): ctl = ET.Element('ctl', {'td': self.name}) - for key, value in self.args.items(): + 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): @@ -535,11 +996,25 @@ def __str__(self, *args, **kwargs): 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', terminal=False): - super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) - + 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): @@ -555,6 +1030,15 @@ 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): diff --git a/sucks/cli.py b/sucks/cli.py index 5903f11..c7c9739 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -141,7 +141,8 @@ def cli(debug): @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'])) -def login(email, password, country_code, continent_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) @@ -149,7 +150,7 @@ def login(email, password, country_code, continent_code): password_hash = EcoVacsAPI.md5(password) device_id = EcoVacsAPI.md5(str(time.time())) try: - EcoVacsAPI(device_id, email, password_hash, country_code, continent_code) + EcoVacsAPI(device_id, email, password_hash, country_code, continent_code, verify_ssl) except ValueError as e: click.echo(e.args[0]) exit(1) @@ -158,6 +159,7 @@ def login(email, password, country_code, continent_code): 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) @@ -179,6 +181,16 @@ def edge(frequency, minutes): 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() @@ -209,9 +221,9 @@ def run(actions, debug): if actions: config = read_config() api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'], - config['country'], config['continent']) + 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']) + 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: diff --git a/tests/test_commands.py b/tests/test_commands.py index a3039b3..c40baf4 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -19,6 +19,20 @@ def test_custom_command_inner_tag(): 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') @@ -29,22 +43,63 @@ def test_custom_command_noargs(): def test_clean_command(): c = Clean() assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order + 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 + 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 + 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 + b'') # protocol has attribs in other order def test_charge_command(): @@ -56,7 +111,7 @@ def test_charge_command(): def test_stop_command(): c = Stop() assert_equals(ElementTree.tostring(c.to_xml()), - b'') + b'') def test_play_sound_command(): @@ -89,7 +144,6 @@ def test_get_battery_state_command(): b'') - def test_move_command(): c = Move(action='left') assert_equals(ElementTree.tostring(c.to_xml()), @@ -103,6 +157,9 @@ def test_move_command(): 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'') @@ -112,9 +169,16 @@ 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 index e71b197..65d4f08 100644 --- a/tests/test_ecovacs_api.py +++ b/tests/test_ecovacs_api.py @@ -39,6 +39,23 @@ def test_main_api_setup(): 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 @@ -64,22 +81,101 @@ def test_main_api_setup_with_alternate_uid(): 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: - device_id = 'E0000001234567890123' + #Not IOTMQ + device_id = 'E0000001234567890123' + device_class = '126' + device_company = 'eco-legacy' r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "class": "126", "nick": "bob"}], "result": "ok"}' % device_id) + 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: @@ -88,5 +184,7 @@ def make_api(): 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"}') + 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 new file mode 100644 index 0000000..ceeff32 --- /dev/null +++ b/tests/test_ecovacs_iotmq.py @@ -0,0 +1,236 @@ +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 index ba79d4c..184aeaa 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -12,8 +12,29 @@ 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)) + 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 @@ -32,7 +53,6 @@ def save_response(value): x._handle_ctl(query) assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) - def test_xml_to_dict(): x = make_ecovacs_xmpp() @@ -51,10 +71,21 @@ def test_xml_to_dict(): 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(): - return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na') +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 index e99f146..c4e436a 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -1,9 +1,9 @@ from nose.tools import * from sucks import * - -from unittest.mock import Mock +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(): @@ -30,6 +30,23 @@ def test_handle_clean_report(): 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) @@ -43,6 +60,18 @@ def test_handle_charge_state(): 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) @@ -79,6 +108,7 @@ def test_handle_battery_info(): 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) @@ -97,6 +127,10 @@ def test_lifespan_reports(): 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() @@ -114,6 +148,14 @@ def test_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() @@ -131,9 +173,12 @@ def test_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() @@ -151,8 +196,28 @@ def test_send_ping_no_monitor(): 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() @@ -179,6 +244,33 @@ def test_send_ping_with_monitor(): 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() @@ -242,7 +334,10 @@ def test_error_event_subscription(): mock = Mock() v.errorEvents.subscribe(mock) v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) - mock.assert_called_once_with('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() @@ -270,18 +365,34 @@ def test_handle_unknown_ctl(): # plus errors! def test_bot_address(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob"}) + 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"}) + 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, monitor=False): +def a_vacbot(bot=None, iotmq=False, monitor=False): if bot is None: - bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob"} + 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