Merge branch 'master' into pr/6

This commit is contained in:
Brian Martin
2019-06-14 07:25:04 -05:00
committed by GitHub
11 changed files with 1355 additions and 125 deletions
+8
View File
@@ -9,3 +9,11 @@ dist
# Nosetests files
cover/
.coverage
# Ignore Vscode files
.vscode/
# Ignore sucks.egg-info
sucks.egg-info/
.noseids
nosetests.xml
+2 -2
View File
@@ -99,8 +99,8 @@ shaping the API.
A simple usage might go something like this:
```
import sucks
```python
from sucks import *
config = ...
+244 -47
View File
@@ -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* `<ctl td="GetBatteryInfo" />`
@@ -139,27 +207,60 @@ It's presumed that the timers need to be reset manually.
### Manually moving around
**Command**
- Move forward: `<ctl td="Move"><move action="forward"/></ctl>`
- Spin left 360 degrees: `<ctl td="Move"><move action="SpinLeft"/></ctl>`
- Spin right 360 degrees: `<ctl td="Move"><move action="SpinRight"/></ctl>`
- Turn 180 degrees: `<ctl td="Move"><move action="TurnAround"/></ctl>`
- Stop the ongoing action: `<ctl td="Move"><move action="stop"/></ctl>`
|**Command**|**Control**|
|-|-|
|Move forward|`<ctl td="Move"><move action="forward"/></ctl>`|
|Move backward|`<ctl td="Move"><move action="backward"/></ctl>`|
|Spin left 360 degrees|`<ctl td="Move"><move action="SpinLeft"/></ctl>`|
|Spin right 360 degrees|`<ctl td="Move"><move action="SpinRight"/></ctl>`|
|Turn 180 degrees|`<ctl td="Move"><move action="TurnAround"/></ctl>`|
|Stop the ongoing action|`<ctl td="Move"><move action="stop"/></ctl>`|
### Configuration
**Set/get robot internal clock**
#### Set/get robot internal clock
- `<ctl td="SetTime"><time t="1509622697" tz="+8"/></ctl>`
- `<ctl td="GetTime"></ctl>`
- Time is specified as a UNIX timestamp and timezone + or - UTC offset.
**Get firmware version**
#### Get firmware version
`<ctl td="GetVersion" name="FW"/>`
**Get robot logs**
#### Get robot logs
`<ctl td="GetLog"></ctl>`
#### Get/Set option value
Gets or sets value for option (0==Off, 1==On)
##### GetOnOff
- Do Not Disturb - `<ctl td="GetOnOff" t="b"/>`
- Continuous Cleaning - `<ctl td="GetOnOff" t="g"/>`
- Silence Voice Report - `<ctl td="GetOnOff" t="s"/>`
Returns `<ctl ret='ok' on='1'/>`
##### SetOnOff
- Do Not Disturb - `<ctl td="SetOnOff" t="b" on="0"/>`
- Continuous Cleaning - `<ctl td="SetOnOff" t="g" on="0"/>`
- Silence Voice Report - `<ctl td="SetOnOff" t="s" on="0"/>`
Returns `<ctl ret='ok'/>`
#### 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).
`<ctl td="SetWaterPermeability" v="1"/>`
#### Schedules
##### GetSched
`<ctl td="GetSched"/>`
Gets any schedules for the robot.
- No Schedules
- `<ctl ret='ok'/>`
- Schedule
- `<ctl ret='ok'><s n='FRASITLP' o='0' h='13' m='2' r='0000000' f='p'><ctl td='Clean'><clean type='auto'/></ctl></s></ctl>`
### 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.
`<ctl td="PlaySound" sid="0" />`
### Untested commands
| 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:
```
<ctl td="SetOnOff" t="b" on="1"/>
<ctl td="GetOnOff" />
<ctl id="12351409" td="PlaySound" sid="0"/>
<ctl id="30800321" td="GetSched"/>
sucks DEBUG got {'id': 'ralnsy', 'ret': 'ok', 'resp': "<ctl ret='ok' tp='sa' msid='11'><m mid='0' p='1'/><m mid='1' p='1'/><m mid='2' p='1'/><m mid='3' p='1'/><m mid='4' p='1'/><m mid='5' p='1'/><m mid='6' p='1'/><m mid='7' p='1'/><m mid='8' p='1'/></ctl>"}
```
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.
#### 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
<ctl ret='ok' tp='sa' msid='11'><m mid='0' p='1'/><m mid='1' p='1'/><m mid='2' p='1'/><m mid='3' p='1'/><m mid='4' p='1'/><m mid='5' p='1'/><m mid='6' p='1'/><m mid='7' p='1'/><m mid='8' p='1'/></ctl>
```
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.
+1
View File
@@ -68,6 +68,7 @@ setup(
'requests>=2.18',
'pycryptodome>=3.4',
'pycountry-convert>=0.5',
'paho-mqtt>=1.4',
'stringcase>=1.2'
],
+533 -50
View File
@@ -4,13 +4,20 @@ 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
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 +26,15 @@ _LOGGER = logging.getLogger(__name__)
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 +49,7 @@ COMPONENT_FILTER = 'filter'
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 +58,30 @@ 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
@@ -93,24 +122,45 @@ COMPONENT_FROM_ECOVACS = {
'dust_case_heap': COMPONENT_FILTER
}
def str_to_bool(s):
if s == 'True' or s == True:
return True
elif s == 'False' or s == False:
return False
else:
raise ValueError("Cannot covert {} to a bool".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):
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(verify_ssl)
_LOGGER.debug("Setting up EcoVacsAPI")
self.resource = device_id[0:8]
self.country = country
@@ -149,7 +199,7 @@ class EcoVacsAPI:
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 +216,7 @@ class EcoVacsAPI:
_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 +226,45 @@ class EcoVacsAPI:
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 +273,50 @@ class EcoVacsAPI:
'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
#At this time the list is updated manually, so far only the D900 has been seen to use this
#These items were found in the Android app source by searching for "new IOTMqDevice("
iotmqdevices = [
'ls1ok3', #D900 / DE5G
'dl8fht', #D600
# Possibly the Atmobot AA30 - qqy0di
# Possibly the Slim4 - wbueya
]
for device in devices:
device['iotmq'] = False
if device['class'] in iotmqdevices: #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 +358,8 @@ class EventListener(object):
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 +388,42 @@ class VacBot():
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 +431,14 @@ class VacBot():
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 +447,12 @@ class VacBot():
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 +461,16 @@ class VacBot():
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 +494,19 @@ class VacBot():
_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 +522,10 @@ class VacBot():
_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 +537,12 @@ class VacBot():
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 +551,14 @@ class VacBot():
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 +581,7 @@ class VacBot():
_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 +590,278 @@ class VacBot():
_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)
else:
self.refresh_components()
def send_command(self, xml):
self.xmpp.send_command(xml, self._vacuum_address())
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.to_xml())
self.send_command(action)
def disconnect(self, wait=False):
self.xmpp.disconnect(wait=wait)
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(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, server_address=None):
ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret)
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 +871,7 @@ class EcoVacsXMPP(ClientXMPP):
self.ctl_subscribers = []
self.ready_flag = Event()
def wait_until_ready(self):
self.ready_flag.wait()
@@ -468,10 +904,13 @@ class EcoVacsXMPP(ClientXMPP):
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 +922,30 @@ class EcoVacsXMPP(ClientXMPP):
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 +953,22 @@ class EcoVacsXMPP(ClientXMPP):
_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 +976,17 @@ class VacBotCommand:
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 +995,25 @@ class VacBotCommand:
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 +1029,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):
+16 -4
View File
@@ -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:
+70 -6
View File
@@ -19,6 +19,20 @@ def test_custom_command_inner_tag():
b'<ctl td="CustomCommand"><customtag customvar="customvalue" /></ctl>')
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'<ctl td="CustomCommand"><customtag customvar="customvalue1" /><customtag customvar="customvalue2" /></ctl>')
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'<ctl arg1="value1" td="CustomCommand"><customtag customvar="customvalue1" /><customtag customvar="customvalue2" /></ctl>')
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'<ctl td="Clean"><clean speed="standard" type="auto" /></ctl>') # protocol has attribs in other order
b'<ctl td="Clean"><clean act="s" speed="standard" type="auto" /></ctl>') # protocol has attribs in other order
c = Clean('edge', 'high')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean speed="strong" type="border" /></ctl>') # protocol has attribs in other order
b'<ctl td="Clean"><clean act="s" speed="strong" type="border" /></ctl>') # protocol has attribs in other order
c = Clean(iotmq=True)
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean act="s" speed="standard" type="auto" /></ctl>') # 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'<ctl td="Clean"><clean act="s" mid="0" speed="standard" type="SpotArea" /></ctl>') #Test namedarea clean
c = SpotArea('start', area='0')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean act="s" mid="0" speed="standard" type="SpotArea" /></ctl>') #Test namedarea keyword clean
c = SpotArea('start', '', '-602,1812,800,723')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean act="s" deep="1" p="-602,1812,800,723" speed="standard" type="SpotArea" /></ctl>') #Test customarea clean
c = SpotArea('start', '', '-602,1812,800,723', '2')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean act="s" deep="2" p="-602,1812,800,723" speed="standard" type="SpotArea" /></ctl>') #Test customarea clean with deep 2
c = SpotArea('start', '', map_position='-602,1812,800,723')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean act="s" deep="1" p="-602,1812,800,723" speed="standard" type="SpotArea" /></ctl>') #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'<ctl td="Clean"><clean act="s" deep="2" p="-602,1812,800,723" speed="standard" type="SpotArea" /></ctl>') #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'<ctl td="Clean"><clean act="s" mid="0" speed="standard" type="SpotArea" /></ctl>') #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'<ctl td="Clean"><clean act="s" mid="0" speed="standard" type="SpotArea" /></ctl>') #Test all keywords specified, should default to only mid
def test_edge_command():
c = Edge()
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean speed="strong" type="border" /></ctl>') # protocol has attribs in other order
b'<ctl td="Clean"><clean act="s" speed="strong" type="border" /></ctl>') # protocol has attribs in other order
def test_spot_command():
c = Spot()
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Clean"><clean speed="strong" type="spot" /></ctl>') # protocol has attribs in other order
b'<ctl td="Clean"><clean act="s" speed="strong" type="spot" /></ctl>') # 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'<ctl td="Clean"><clean speed="standard" type="stop" /></ctl>')
b'<ctl td="Clean"><clean act="s" speed="standard" type="stop" /></ctl>')
def test_play_sound_command():
@@ -89,7 +144,6 @@ def test_get_battery_state_command():
b'<ctl td="GetBatteryInfo" />')
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'<ctl td="Move"><move action="forward" /></ctl>')
c = Move(action='backward')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Move"><move action="backward" /></ctl>')
c = Move(action='stop')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="Move"><move action="stop" /></ctl>')
@@ -112,9 +169,16 @@ def test_get_lifepsan_command():
c = GetLifeSpan('main_brush')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="GetLifeSpan" type="Brush" />')
c = GetLifeSpan('side_brush')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="GetLifeSpan" type="SideBrush" />')
c = GetLifeSpan('filter')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="GetLifeSpan" type="DustCaseHeap" />')
def test_set_time_command():
c = SetTime('1234', 'GMT-5')
assert_equals(ElementTree.tostring(c.to_xml()),
b'<ctl td="SetTime"><time t="1234" tz="GMT-5" /></ctl>')
+97 -3
View File
@@ -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,97 @@ 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'
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", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, 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
r = m.post(compile('user.do'),
text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, 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
r = m.post(compile('user.do'),
text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, 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'
r = m.post(compile('user.do'),
text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, 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 +180,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')
+236
View File
@@ -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"<ctl ts='1547824270099' td='CleanReport'><clean type='auto' speed='standard' st='h' rsn='a' a='' l='' sts=''/></ctl>"
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'] = '<ctl td="CleanReport"> <clean type="auto" /> </ctl>'
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'], '<ctl><charge type="go" /></ctl>')
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":"<ctl ret='ok'><clean type='auto' speed='standard' st='h' t='1159' a='15' s='0' tr=''/></ctl>","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':"<ctl ret='ok'><clean type='auto' speed='standard' st='h' t='1159' a='15' s='0' tr=''/></ctl>",'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, "<ctl ts='1547824270099' td='CleanReport'><clean type='auto' speed='standard' st='h' rsn='a' a='' l='' sts=''/></ctl>"),
{'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, "<ctl ts='1547824270099' td='CleanReport'><clean type='auto' speed='strong' st='h' rsn='a' a='' l='' sts=''/></ctl>"),
{'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, "<ctl ts='1547824270099'><clean type='auto' speed='strong' st='h' rsn='a' a='' l='' sts=''/></ctl>"),
{'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, "<ctl ts='1547823289924' td='BatteryInfo'><battery power='64'/></ctl>"),
{'event': 'battery_info', 'ts':'1547823289924', 'power': '64'})
assert_dict_equal(
x._ctl_to_dict_mqtt(test_topic, "<ctl ts='1547823289924'><battery power='64'/></ctl>"),
{'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, "<ctl ts='1547823129670' td='SleepStatus' st='1'/>"),
{'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, "<ctl ts='1547822982581' td='errors' old='' new='102'/>"),
{'event': 'errors', 'ts':'1547822982581','old':'','new':'102'})
assert_dict_equal(
x._ctl_to_dict_mqtt(test_topic, "<ctl ts='1547822982581' td='errors' old='102' new=''/>"),
{'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, "<ctl td='Pos' t='p' p='7,-10' a='-42' valid='0'/>"),
{'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, "<ctl ts='1547822871328' td='DustCaseST' st='1'/>"),
{'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, "<ctl ts='1547823592934' td='MapSt' st='relocGoChgStart' method='' info=''/>"),
{'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, "<ctl ret='ok' type='Brush' left='9876' total='18000'/>"),
{'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, '<ctl td="CustomCommand"><customtag customvar="customvalue1" /></ctl>'),
{'event': 'custom_command', 'customvar': 'customvalue1'})
def test_xml_to_dict_api():
x = make_ecovacs_iotmq()
message = {}
cmd = VacBotCommand("Clean")
message['resp'] = "<ctl ret='ok'><clean type='auto' speed='standard' st='h' t='1159' a='15' s='0' tr=''/></ctl>"
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'] = "<ctl ret='ok'><clean type='auto' speed='strong' st='h' t='1159' a='15' s='0' tr=''/></ctl>"
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'] = "<ctl ret='ok'><battery power='82'/></ctl>"
assert_dict_equal(
x._ctl_to_dict_api(cmd,message['resp']),
{'event': 'battery_info', 'power': '82'})
cmd = VacBotCommand("GetLifeSpan")
message['resp'] = "<ctl ret='ok' type='Brush' left='9876' total='18000'/>"
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'] = "<ctl ts='1547823651958' td='ChargeState'><charge type='Going' h='' r='a' s='' g='0'/></ctl>"
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'] = "<ctl td='TestCommand'><test type='command'/></ctl>"
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'] = "<ctl ret='fail' errno='8'/>"
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)
+35 -4
View File
@@ -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 test_subscribe_to_ctls():
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('# <ctl td="LifeSpan" type="Brush" val="099" total="365"/>')),
{'event': 'life_span', 'type': 'brush', 'val': '099', 'total': '365'})
assert_dict_equal(
x._ctl_to_dict(make_ctl('<ctl td="LifeSpan" type="DustCaseHeap" val="-050" total="365"/>')),
{'event': 'life_span', 'type': 'dust_case_heap', 'val': '-050', 'total': '365'})
assert_equals(x._ctl_to_dict(make_ctl('<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('<query xmlns="com:ctl">' + string + '</query>')[0]
+113 -9
View File
@@ -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():
v = a_vacbot()
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,27 @@ 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, None) #Value error if str_to_bool can't convert