Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c82752f327 |
@@ -1,7 +1,16 @@
|
|||||||
"""Support for Ecovacs Deebot vacuums."""
|
"""Support for Ecovacs Deebot vacuums."""
|
||||||
|
import asyncio
|
||||||
|
from functools import partial
|
||||||
|
import async_timeout
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
#import asyncio ## to do will need to convert to slixmpp to do this i believe
|
import logging
|
||||||
|
# Use local sucks
|
||||||
|
from .sucks import EcoVacsAPI, VacBot
|
||||||
|
|
||||||
|
from homeassistant import exceptions
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
@@ -9,36 +18,40 @@ from homeassistant.const import (
|
|||||||
CONF_COUNTRY,
|
CONF_COUNTRY,
|
||||||
CONF_VERIFY_SSL,
|
CONF_VERIFY_SSL,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
Platform,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import discovery
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
import voluptuous as vol
|
|
||||||
#use local sucks
|
|
||||||
from .sucks import VacBot
|
|
||||||
from .sucks_api import EcoVacsAPI
|
|
||||||
from .const import *
|
|
||||||
|
|
||||||
import logging
|
from .const import (
|
||||||
LOGGER = logging.getLogger(__name__)
|
ECOVACS_DEVICES,
|
||||||
|
DOMAIN,
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
PLATFORMS,
|
||||||
{
|
CONF_CONTINENT,
|
||||||
DOMAIN: vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_USERNAME): cv.string,
|
|
||||||
vol.Required(CONF_PASSWORD): cv.string,
|
|
||||||
vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
|
|
||||||
vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string),
|
|
||||||
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, # can probably get rid of this and set verify ssl false if
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
extra=vol.ALLOW_EXTRA,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# from homeassistant.core import HomeAssistant
|
||||||
|
# from homeassistant.helpers import discovery
|
||||||
|
# from homeassistant.helpers.typing import ConfigType
|
||||||
|
# import homeassistant.helpers.config_validation as cv
|
||||||
|
# import voluptuous as vol
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# CONFIG_SCHEMA = vol.Schema(
|
||||||
|
# {
|
||||||
|
# DOMAIN: vol.Schema(
|
||||||
|
# {
|
||||||
|
# vol.Required(CONF_USERNAME): cv.string,
|
||||||
|
# vol.Required(CONF_PASSWORD): cv.string,
|
||||||
|
# vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
|
||||||
|
# vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string),
|
||||||
|
# vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, # can probably get rid of this and set verify ssl false if
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
# },
|
||||||
|
# extra=vol.ALLOW_EXTRA,
|
||||||
|
# )
|
||||||
|
|
||||||
# Generate a random device ID on each bootup
|
# Generate a random device ID on each bootup
|
||||||
ECOVACS_API_DEVICEID = "".join(
|
ECOVACS_API_DEVICEID = "".join(
|
||||||
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
|
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
|
||||||
@@ -46,7 +59,7 @@ ECOVACS_API_DEVICEID = "".join(
|
|||||||
|
|
||||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Ecovacs component."""
|
"""Set up the Ecovacs component."""
|
||||||
LOGGER.debug("Creating new Ecovacs component")
|
_LOGGER.debug("Creating new Ecovacs component")
|
||||||
hass.data[ECOVACS_DEVICES] = []
|
hass.data[ECOVACS_DEVICES] = []
|
||||||
SERVER_ADDRESS = None
|
SERVER_ADDRESS = None
|
||||||
|
|
||||||
@@ -60,10 +73,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
devices = ecovacs_api.devices()
|
devices = ecovacs_api.devices()
|
||||||
LOGGER.debug("Ecobot devices: %s", devices)
|
_LOGGER.debug("Ecobot devices: %s", devices)
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
LOGGER.info(
|
_LOGGER.info(
|
||||||
"Discovered Ecovacs device on account: %s with nickname %s",
|
"Discovered Ecovacs device on account: %s with nickname %s",
|
||||||
device.get("did"),
|
device.get("did"),
|
||||||
device.get("nick"),
|
device.get("nick"),
|
||||||
@@ -84,7 +97,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
def stop(event: object) -> None:
|
def stop(event: object) -> None:
|
||||||
"""Shut down open connections to Ecovacs XMPP server."""
|
"""Shut down open connections to Ecovacs XMPP server."""
|
||||||
for device in hass.data[ECOVACS_DEVICES]:
|
for device in hass.data[ECOVACS_DEVICES]:
|
||||||
LOGGER.info(
|
_LOGGER.info(
|
||||||
"Shutting down connection to Ecovacs device %s",
|
"Shutting down connection to Ecovacs device %s",
|
||||||
device.vacuum.get("did"),
|
device.vacuum.get("did"),
|
||||||
)
|
)
|
||||||
@@ -93,6 +106,70 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
# Listen for HA stop to disconnect.
|
# Listen for HA stop to disconnect.
|
||||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
||||||
if hass.data[ECOVACS_DEVICES]:
|
if hass.data[ECOVACS_DEVICES]:
|
||||||
LOGGER.debug("Starting vacuum components")
|
_LOGGER.debug("Starting vacuum components")
|
||||||
discovery.load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
|
discovery.load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
|
"""Set the config entry up."""
|
||||||
|
# Set up Ecovacs platforms with config entry
|
||||||
|
_LOGGER.debug("Creating new Ecovacs component")
|
||||||
|
hass.data[ECOVACS_DEVICES] = []
|
||||||
|
SERVER_ADDRESS = None
|
||||||
|
|
||||||
|
ecovacs_api = EcoVacsAPI(
|
||||||
|
ECOVACS_API_DEVICEID,
|
||||||
|
config[DOMAIN].get(CONF_USERNAME),
|
||||||
|
EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)),
|
||||||
|
config[DOMAIN].get(CONF_COUNTRY),
|
||||||
|
config[DOMAIN].get(CONF_CONTINENT),
|
||||||
|
config[DOMAIN].get(CONF_VERIFY_SSL), # add to class call
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if not config_entry.options:
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry,
|
||||||
|
options={
|
||||||
|
CONF_CONTINUOUS: config_entry.data[CONF_CONTINUOUS],
|
||||||
|
CONF_DELAY: config_entry.data[CONF_DELAY],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
roomba = await hass.async_add_executor_job(
|
||||||
|
partial(
|
||||||
|
RoombaFactory.create_roomba,
|
||||||
|
address=config_entry.data[CONF_HOST],
|
||||||
|
blid=config_entry.data[CONF_BLID],
|
||||||
|
password=config_entry.data[CONF_PASSWORD],
|
||||||
|
continuous=config_entry.options[CONF_CONTINUOUS],
|
||||||
|
delay=config_entry.options[CONF_DELAY],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not await async_connect_or_timeout(hass, roomba):
|
||||||
|
return False
|
||||||
|
except CannotConnect as err:
|
||||||
|
raise exceptions.ConfigEntryNotReady from err
|
||||||
|
|
||||||
|
async def _async_disconnect_roomba(event):
|
||||||
|
await async_disconnect_or_timeout(hass, roomba)
|
||||||
|
|
||||||
|
cancel_stop = hass.bus.async_listen_once(
|
||||||
|
EVENT_HOMEASSISTANT_STOP, _async_disconnect_roomba
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][config_entry.entry_id] = {
|
||||||
|
ROOMBA_SESSION: roomba,
|
||||||
|
BLID: config_entry.data[CONF_BLID],
|
||||||
|
CANCEL_STOP: cancel_stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||||
|
|
||||||
|
if not config_entry.update_listeners:
|
||||||
|
config_entry.add_update_listener(async_update_options)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#ecovacs constants
|
""" Ecovacs constants. """
|
||||||
#init constants
|
from homeassistant.const import Platform
|
||||||
ECOVACS_DEVICES = "ecovacs_devices"
|
|
||||||
DOMAIN = "ecovacs"
|
DOMAIN = "ecovacs"
|
||||||
|
PLATFORMS = [Platform.Vacuum]
|
||||||
|
ECOVACS_DEVICES = "ecovacs_devices"
|
||||||
|
|
||||||
CONF_CONTINENT = "continent"
|
CONF_CONTINENT = "continent"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "ecovacs",
|
"domain": "ecovacs",
|
||||||
"name": "Ecovacs Bumper",
|
"name": "Ecovacs Bumper",
|
||||||
"version": "1.5.3",
|
"version": "1.4.2",
|
||||||
"documentation": "https://github.com/bittles/ha_ecovacs_bumper",
|
"documentation": "https://github.com/bittles/ha_ecovacs_bumper",
|
||||||
"issue_tracker": "https://github.com/bittles/ha_ecovacs_bumper/issues",
|
"issue_tracker": "https://github.com/bittles/ha_ecovacs_bumper/issues",
|
||||||
"requirements": ["sleekxmppfs==1.4.1", "requests>=2.18", "pycryptodome>=3.4", "pycountry-convert>=0.5", "paho-mqtt>=1.4", "stringcase>=1.2"],
|
"requirements": ["sleekxmppfs==1.4.1", "requests>=2.18", "pycryptodome>=3.4", "pycountry-convert>=0.5", "paho-mqtt>=1.4", "stringcase>=1.2"],
|
||||||
|
|||||||
@@ -1,20 +1,249 @@
|
|||||||
#import hashlib
|
import hashlib
|
||||||
#import time
|
import time
|
||||||
#import requests
|
import requests
|
||||||
import os
|
import os
|
||||||
#from base64 import b64decode, b64encode
|
import logging
|
||||||
#from collections import OrderedDict
|
import aiohttp
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
|
from collections import OrderedDict
|
||||||
from sleekxmppfs.xmlstream import ET
|
from sleekxmppfs.xmlstream import ET
|
||||||
from sleekxmppfs.exceptions import XMPPError
|
from sleekxmppfs.exceptions import XMPPError
|
||||||
|
|
||||||
#from . import sucks_api
|
|
||||||
from .sucks_mqtt import EcoVacsIOTMQ
|
from .sucks_mqtt import EcoVacsIOTMQ
|
||||||
from .sucks_xmpp import EcoVacsXMPP
|
from .sucks_xmpp import EcoVacsXMPP
|
||||||
|
|
||||||
|
#from .const import LOGGER
|
||||||
from .sucks_const import *
|
from .sucks_const import *
|
||||||
|
|
||||||
import logging
|
_LOGGER = logging.getLogger(__name__)
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
def get_ecovacs_api(device_id: str, username: str, password: str, country: str, continent: str, verify_ssl: bool, websession: Optional[aiohttp.ClientSession] = None)
|
||||||
|
""" Get Ecovacs api object """
|
||||||
|
return EcoVacsAPI(device_id, username, password, country, continent, verify_ssl, websession)
|
||||||
|
|
||||||
|
class EcoVacsAPI:
|
||||||
|
CLIENT_KEY = "eJUWrzRv34qFSaYk"
|
||||||
|
SECRET = "Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GC"
|
||||||
|
PUBLIC_KEY = 'MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ'
|
||||||
|
MAIN_URL_FORMAT = 'https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType}'
|
||||||
|
USER_URL_FORMAT = 'https://users-{continent}.ecouser.net:8000/user.do'
|
||||||
|
PORTAL_URL_FORMAT = 'https://portal-{continent}.ecouser.net/api'
|
||||||
|
USERSAPI = 'users/user.do'
|
||||||
|
IOTDEVMANAGERAPI = 'iot/devmanager.do' # IOT Device Manager - This provides control of "IOT" products via RestAPI, some bots use this instead of XMPP
|
||||||
|
PRODUCTAPI = 'pim/product' # Leaving this open, the only endpoint known currently is "Product IOT Map" - pim/product/getProductIotMap - This provides a list of "IOT" products. Not sure what this provides the app.
|
||||||
|
REALM = 'ecouser.net'
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_id,
|
||||||
|
account_id,
|
||||||
|
password_hash,
|
||||||
|
country,
|
||||||
|
continent,
|
||||||
|
verify_ssl=True,
|
||||||
|
websession: Optional[aiohttp.ClientSession] = None):
|
||||||
|
self.meta = {
|
||||||
|
'country': country,
|
||||||
|
'lang': 'en',
|
||||||
|
'deviceId': device_id,
|
||||||
|
'appCode': 'i_eco_e',
|
||||||
|
#'appCode': 'i_eco_a' - iphone
|
||||||
|
'appVersion': '1.3.5',
|
||||||
|
#'appVersion': '1.4.6' - iphone
|
||||||
|
'channel': 'c_googleplay',
|
||||||
|
#'channel': 'c_iphone', - iphone
|
||||||
|
'deviceType': '1'
|
||||||
|
#'deviceType': '2' - iphone
|
||||||
|
}
|
||||||
|
self.verify_ssl = str_to_bool_or_cert(verify_ssl)
|
||||||
|
_LOGGER.debug("Setting up EcoVacsAPI")
|
||||||
|
self.resource = device_id[0:8]
|
||||||
|
self.country = country
|
||||||
|
self.continent = continent
|
||||||
|
login_info = self.__call_main_api('user/login',
|
||||||
|
('account', self.encrypt(account_id)),
|
||||||
|
('password', self.encrypt(password_hash)))
|
||||||
|
self.uid = login_info['uid']
|
||||||
|
self.login_access_token = login_info['accessToken']
|
||||||
|
self.auth_code = self.__call_main_api('user/getAuthCode',
|
||||||
|
('uid', self.uid),
|
||||||
|
('accessToken', self.login_access_token))['authCode']
|
||||||
|
login_response = self.__call_login_by_it_token()
|
||||||
|
self.user_access_token = login_response['token']
|
||||||
|
if login_response['userId'] != self.uid:
|
||||||
|
_LOGGER.debug("Switching to shorter UID " + login_response['userId'])
|
||||||
|
self.uid = login_response['userId']
|
||||||
|
self.websession = websession
|
||||||
|
_LOGGER.debug("EcoVacsAPI connection complete")
|
||||||
|
|
||||||
|
def ensure_session(self) -> aiohttp.ClientSession:
|
||||||
|
"""Ensure that we have an aiohttp ClientSession"""
|
||||||
|
if self.websession is None:
|
||||||
|
self.websession = aiohttp.ClientSession()
|
||||||
|
return self.websession
|
||||||
|
|
||||||
|
def __sign(self, params):
|
||||||
|
result = params.copy()
|
||||||
|
result['authTimespan'] = int(time.time() * 1000)
|
||||||
|
result['authTimeZone'] = 'GMT-8'
|
||||||
|
sign_on = self.meta.copy()
|
||||||
|
sign_on.update(result)
|
||||||
|
sign_on_text = EcoVacsAPI.CLIENT_KEY + ''.join(
|
||||||
|
[k + '=' + str(sign_on[k]) for k in sorted(sign_on.keys())]) + EcoVacsAPI.SECRET
|
||||||
|
result['authAppkey'] = EcoVacsAPI.CLIENT_KEY
|
||||||
|
result['authSign'] = self.md5(sign_on_text)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __call_main_api(self, function, *args):
|
||||||
|
_LOGGER.debug("calling main api {} with {}".format(function, args))
|
||||||
|
params = OrderedDict(args)
|
||||||
|
params['requestId'] = self.md5(time.time())
|
||||||
|
url = (EcoVacsAPI.MAIN_URL_FORMAT + "/" + function).format(**self.meta)
|
||||||
|
api_response = requests.get(url, self.__sign(params), verify=self.verify_ssl)
|
||||||
|
json = api_response.json()
|
||||||
|
_LOGGER.debug("got {}".format(json))
|
||||||
|
if json['code'] == '0000':
|
||||||
|
return json['data']
|
||||||
|
elif json['code'] == '1005':
|
||||||
|
_LOGGER.error("incorrect email or password")
|
||||||
|
raise ValueError("incorrect email or password")
|
||||||
|
else:
|
||||||
|
_LOGGER.error("call to {} failed with {}".format(function, json))
|
||||||
|
raise RuntimeError("failure code {} ({}) for call {} and parameters {}".format(
|
||||||
|
json['code'], json['msg'], function, args))
|
||||||
|
|
||||||
|
def __call_user_api(self, function, args):
|
||||||
|
_LOGGER.debug("calling user api {} with {}".format(function, args))
|
||||||
|
params = {'todo': function}
|
||||||
|
params.update(args)
|
||||||
|
response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params, verify=self.verify_ssl)
|
||||||
|
json = response.json()
|
||||||
|
_LOGGER.debug("got {}".format(json))
|
||||||
|
if json['result'] == 'ok':
|
||||||
|
return json
|
||||||
|
else:
|
||||||
|
_LOGGER.error("call to {} failed with {}".format(function, json))
|
||||||
|
raise RuntimeError(
|
||||||
|
"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
|
||||||
|
|
||||||
|
def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs):
|
||||||
|
if api == self.USERSAPI:
|
||||||
|
params = {'todo': function}
|
||||||
|
params.update(args)
|
||||||
|
else:
|
||||||
|
params = {}
|
||||||
|
params.update(args)
|
||||||
|
_LOGGER.debug("calling portal api {} function {} with {}".format(api, function, params))
|
||||||
|
continent = self.continent
|
||||||
|
if 'continent' in kwargs:
|
||||||
|
continent = kwargs.get('continent')
|
||||||
|
url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=continent, **self.meta)
|
||||||
|
response = requests.post(url, json=params, verify=verify_ssl)
|
||||||
|
json = response.json()
|
||||||
|
_LOGGER.debug("got {}".format(json))
|
||||||
|
if api == self.USERSAPI:
|
||||||
|
if json['result'] == 'ok':
|
||||||
|
return json
|
||||||
|
elif json['result'] == 'fail':
|
||||||
|
if json['error'] == 'set token error.': # If it is a set token error try again
|
||||||
|
if not 'set_token' in kwargs:
|
||||||
|
_LOGGER.debug("loginByItToken set token error, trying again (2/3)")
|
||||||
|
return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=1)
|
||||||
|
elif kwargs.get('set_token') == 1:
|
||||||
|
_LOGGER.debug("loginByItToken set token error, trying again with ww (3/3)")
|
||||||
|
return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=2, continent="ww")
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("loginByItToken set token error, failed after 3 attempts")
|
||||||
|
if api.startswith(self.PRODUCTAPI):
|
||||||
|
if json['code'] == 0:
|
||||||
|
return json
|
||||||
|
|
||||||
|
else:
|
||||||
|
_LOGGER.error("call to {} failed with {}".format(function, json))
|
||||||
|
raise RuntimeError(
|
||||||
|
"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
|
||||||
|
|
||||||
|
def __call_login_by_it_token(self):
|
||||||
|
return self.__call_portal_api(self.USERSAPI,'loginByItToken',
|
||||||
|
{'country': self.meta['country'].upper(),
|
||||||
|
'resource': self.resource,
|
||||||
|
'realm': EcoVacsAPI.REALM,
|
||||||
|
'userId': self.uid,
|
||||||
|
'token': self.auth_code}
|
||||||
|
, verify_ssl=self.verify_ssl)
|
||||||
|
|
||||||
|
def getdevices(self):
|
||||||
|
return self.__call_portal_api(self.USERSAPI,'GetDeviceList', {
|
||||||
|
'userid': self.uid,
|
||||||
|
'auth': {
|
||||||
|
'with': 'users',
|
||||||
|
'userid': self.uid,
|
||||||
|
'realm': EcoVacsAPI.REALM,
|
||||||
|
'token': self.user_access_token,
|
||||||
|
'resource': self.resource
|
||||||
|
}
|
||||||
|
}, verify_ssl=self.verify_ssl)['devices']
|
||||||
|
|
||||||
|
def SetIOTMQDevices(self, devices):
|
||||||
|
#Added for devices that utilize MQTT instead of XMPP for communication
|
||||||
|
for device in devices:
|
||||||
|
device['iotmq'] = False
|
||||||
|
if device['company'] == 'eco-ng': #Check if the device is part of the list
|
||||||
|
device['iotmq'] = True
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def devices(self):
|
||||||
|
return self.SetIOTMQDevices(self.getdevices())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def md5(text):
|
||||||
|
return hashlib.md5(bytes(str(text), 'utf8')).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encrypt(text):
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from Crypto.Cipher import PKCS1_v1_5
|
||||||
|
key = RSA.import_key(b64decode(EcoVacsAPI.PUBLIC_KEY))
|
||||||
|
cipher = PKCS1_v1_5.new(key)
|
||||||
|
result = cipher.encrypt(bytes(text, 'utf8'))
|
||||||
|
return str(b64encode(result), 'utf8')
|
||||||
|
|
||||||
|
""" older currently unused code from before app update
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
class EventEmitter(object):
|
class EventEmitter(object):
|
||||||
"""A very simple event emitting system."""
|
"""A very simple event emitting system."""
|
||||||
@@ -106,55 +335,29 @@ class VacBot():
|
|||||||
getattr(self, method)(ctl)
|
getattr(self, method)(ctl)
|
||||||
|
|
||||||
def _handle_error(self, event):
|
def _handle_error(self, event):
|
||||||
if 'error' in event or 'errs' in event:
|
|
||||||
error = '' # init error var so it's available outside of first if loop
|
|
||||||
if 'error' in event:
|
if 'error' in event:
|
||||||
error = event['error']
|
error = event['error']
|
||||||
elif 'errs' in event:
|
elif 'errs' in event:
|
||||||
error = event['errs']
|
error = event['errs']
|
||||||
|
if not error == '':
|
||||||
self.errorEvents.notify(error)
|
self.errorEvents.notify(error)
|
||||||
LOGGER.error("*** error = " + error)
|
_LOGGER.error("*** error = " + error)
|
||||||
|
|
||||||
# if not error == '':
|
|
||||||
|
|
||||||
|
|
||||||
# Errors
|
|
||||||
# The bot broadcasts error codes for a number of cases.
|
|
||||||
|
|
||||||
# <ctl td="error" error="BatteryLow" errno="101"></ctl>
|
|
||||||
|
|
||||||
# The latest error can be requested like so:
|
|
||||||
|
|
||||||
# Request <ctl td="GetError" />
|
|
||||||
# Response <ctl ret="ok" errs="100"/>
|
|
||||||
# However in some cases the robot sends to code 100 shortly after an error has occurred, meaning that we cannot trust the GetError request to contain the last relevant error. For example, if the robot gets stuck it broadcasts 102 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 between models.
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_life_span(self, event):
|
def _handle_life_span(self, event):
|
||||||
type = event['type']
|
type = event['type']
|
||||||
try:
|
try:
|
||||||
type = COMPONENT_FROM_ECOVACS[type]
|
type = COMPONENT_FROM_ECOVACS[type]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOGGER.warning("Unknown component type: '" + type + "'")
|
_LOGGER.warning("Unknown component type: '" + type + "'")
|
||||||
if 'val' in event:
|
if 'val' in event:
|
||||||
lifespan = int(event['val']) / 100
|
lifespan = int(event['val']) / 100
|
||||||
LOGGER.info("**********Component " + type + " has lifespan of " + str(lifespan) + ".")
|
_LOGGER.info("**********Component " + type + " has lifespan of " + str(lifespan) + ".")
|
||||||
else:
|
else:
|
||||||
lifespan = int(event['left']) / 60 #This works for a D901
|
lifespan = int(event['left']) / 60 #This works for a D901
|
||||||
self.components[type] = lifespan
|
self.components[type] = lifespan
|
||||||
lifespan_event = {'type': type, 'lifespan': lifespan}
|
lifespan_event = {'type': type, 'lifespan': lifespan}
|
||||||
self.lifespanEvents.notify(lifespan_event)
|
self.lifespanEvents.notify(lifespan_event)
|
||||||
LOGGER.info("*** life_span " + type + " = " + str(lifespan))
|
_LOGGER.info("*** life_span " + type + " = " + str(lifespan))
|
||||||
|
|
||||||
def _handle_clean_report(self, event):
|
def _handle_clean_report(self, event):
|
||||||
type = event['type']
|
type = event['type']
|
||||||
@@ -166,7 +369,7 @@ class VacBot():
|
|||||||
if statustype == CLEAN_ACTION_STOP or statustype == CLEAN_ACTION_PAUSE:
|
if statustype == CLEAN_ACTION_STOP or statustype == CLEAN_ACTION_PAUSE:
|
||||||
type = statustype
|
type = statustype
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOGGER.warning("Unknown cleaning status '" + type + "'")
|
_LOGGER.warning("Unknown cleaning status '" + type + "'")
|
||||||
self.clean_status = type
|
self.clean_status = type
|
||||||
self.vacuum_status = type
|
self.vacuum_status = type
|
||||||
fan = event.get('speed', None)
|
fan = event.get('speed', None)
|
||||||
@@ -174,22 +377,22 @@ class VacBot():
|
|||||||
try:
|
try:
|
||||||
fan = FAN_SPEED_FROM_ECOVACS[fan]
|
fan = FAN_SPEED_FROM_ECOVACS[fan]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOGGER.warning("Unknown fan speed: '" + fan + "'")
|
_LOGGER.warning("Unknown fan speed: '" + fan + "'")
|
||||||
self.fan_speed = fan
|
self.fan_speed = fan
|
||||||
self.statusEvents.notify(self.vacuum_status)
|
self.statusEvents.notify(self.vacuum_status)
|
||||||
if self.fan_speed:
|
if self.fan_speed:
|
||||||
LOGGER.info("*** clean_status = " + self.clean_status + " fan_speed = " + self.fan_speed)
|
_LOGGER.info("*** clean_status = " + self.clean_status + " fan_speed = " + self.fan_speed)
|
||||||
else:
|
else:
|
||||||
LOGGER.info("*** clean_status = " + self.clean_status + " fan_speed = None")
|
_LOGGER.info("*** clean_status = " + self.clean_status + " fan_speed = None")
|
||||||
|
|
||||||
def _handle_battery_info(self, iq):
|
def _handle_battery_info(self, iq):
|
||||||
try:
|
try:
|
||||||
self.battery_status = float(iq['power']) / 100
|
self.battery_status = float(iq['power']) / 100
|
||||||
except ValueError:
|
except ValueError:
|
||||||
LOGGER.warning("couldn't parse battery status " + ET.tostring(iq))
|
_LOGGER.warning("couldn't parse battery status " + ET.tostring(iq))
|
||||||
else:
|
else:
|
||||||
self.batteryEvents.notify(self.battery_status)
|
self.batteryEvents.notify(self.battery_status)
|
||||||
LOGGER.info("*** battery_status = {:.0%}".format(self.battery_status))
|
_LOGGER.info("*** battery_status = {:.0%}".format(self.battery_status))
|
||||||
|
|
||||||
def _handle_charge_state(self, event):
|
def _handle_charge_state(self, event):
|
||||||
if 'type' in event:
|
if 'type' in event:
|
||||||
@@ -203,11 +406,11 @@ class VacBot():
|
|||||||
status = 'idle'
|
status = 'idle'
|
||||||
else:
|
else:
|
||||||
status = 'idle' #Fall back to Idle status
|
status = 'idle' #Fall back to Idle status
|
||||||
LOGGER.error("Unknown charging status '" + event['errno'] + "'") #Log this so we can identify more errors
|
_LOGGER.error("Unknown charging status '" + event['errno'] + "'") #Log this so we can identify more errors
|
||||||
try:
|
try:
|
||||||
status = CHARGE_MODE_FROM_ECOVACS[status]
|
status = CHARGE_MODE_FROM_ECOVACS[status]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOGGER.warning("Unknown charging status '" + status + "'")
|
_LOGGER.warning("Unknown charging status '" + status + "'")
|
||||||
self.charge_status = status
|
self.charge_status = status
|
||||||
if status != 'idle' or self.vacuum_status == 'charging':
|
if status != 'idle' or self.vacuum_status == 'charging':
|
||||||
# We have to ignore the idle messages, because all it means is that it's not
|
# We have to ignore the idle messages, because all it means is that it's not
|
||||||
@@ -215,7 +418,7 @@ class VacBot():
|
|||||||
# of what the vacuum is currently up to.
|
# of what the vacuum is currently up to.
|
||||||
self.vacuum_status = status
|
self.vacuum_status = status
|
||||||
self.statusEvents.notify(self.vacuum_status)
|
self.statusEvents.notify(self.vacuum_status)
|
||||||
LOGGER.info("*** charge_status = " + self.charge_status)
|
_LOGGER.info("*** charge_status = " + self.charge_status)
|
||||||
|
|
||||||
def _vacuum_address(self):
|
def _vacuum_address(self):
|
||||||
if not self.vacuum['iotmq']:
|
if not self.vacuum['iotmq']:
|
||||||
@@ -239,15 +442,15 @@ class VacBot():
|
|||||||
if not self.iotmq.send_ping():
|
if not self.iotmq.send_ping():
|
||||||
raise RuntimeError()
|
raise RuntimeError()
|
||||||
except XMPPError as err:
|
except XMPPError as err:
|
||||||
LOGGER.warning("Ping did not reach VacBot. Will retry.")
|
_LOGGER.warning("Ping did not reach VacBot. Will retry.")
|
||||||
LOGGER.error("*** Error type: " + err.etype)
|
_LOGGER.error("*** Error type: " + err.etype)
|
||||||
LOGGER.error("*** Error condition: " + err.condition)
|
_LOGGER.error("*** Error condition: " + err.condition)
|
||||||
self._failed_pings += 1
|
self._failed_pings += 1
|
||||||
if self._failed_pings >= 4:
|
if self._failed_pings >= 4:
|
||||||
self.vacuum_status = 'offline'
|
self.vacuum_status = 'offline'
|
||||||
self.statusEvents.notify(self.vacuum_status)
|
self.statusEvents.notify(self.vacuum_status)
|
||||||
except RuntimeError as err:
|
except RuntimeError as err:
|
||||||
LOGGER.warning("Ping did not reach VacBot. Will retry.")
|
_LOGGER.warning("Ping did not reach VacBot. Will retry.")
|
||||||
self._failed_pings += 1
|
self._failed_pings += 1
|
||||||
if self._failed_pings >= 4:
|
if self._failed_pings >= 4:
|
||||||
self.vacuum_status = 'offline'
|
self.vacuum_status = 'offline'
|
||||||
@@ -270,9 +473,9 @@ class VacBot():
|
|||||||
self.run(GetLifeSpan('side_brush'))
|
self.run(GetLifeSpan('side_brush'))
|
||||||
self.run(GetLifeSpan('filter'))
|
self.run(GetLifeSpan('filter'))
|
||||||
except XMPPError as err:
|
except XMPPError as err:
|
||||||
LOGGER.warning("Component refresh requests failed to reach VacBot. Will try again later.")
|
_LOGGER.warning("Component refresh requests failed to reach VacBot. Will try again later.")
|
||||||
LOGGER.error("*** Error type: " + err.etype)
|
_LOGGER.error("*** Error type: " + err.etype)
|
||||||
LOGGER.error("*** Error condition: " + err.condition)
|
_LOGGER.error("*** Error condition: " + err.condition)
|
||||||
|
|
||||||
def refresh_statuses(self):
|
def refresh_statuses(self):
|
||||||
try:
|
try:
|
||||||
@@ -280,9 +483,9 @@ class VacBot():
|
|||||||
self.run(GetChargeState())
|
self.run(GetChargeState())
|
||||||
self.run(GetBatteryState())
|
self.run(GetBatteryState())
|
||||||
except XMPPError as err:
|
except XMPPError as err:
|
||||||
LOGGER.warning("Initial status requests failed to reach VacBot. Will try again on next ping.")
|
_LOGGER.warning("Initial status requests failed to reach VacBot. Will try again on next ping.")
|
||||||
LOGGER.error("*** Error type: " + err.etype)
|
_LOGGER.error("*** Error type: " + err.etype)
|
||||||
LOGGER.error("*** Error condition: " + err.condition)
|
_LOGGER.error("*** Error condition: " + err.condition)
|
||||||
|
|
||||||
def request_all_statuses(self):
|
def request_all_statuses(self):
|
||||||
self.refresh_statuses()
|
self.refresh_statuses()
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import time
|
|
||||||
import requests
|
|
||||||
#import os
|
|
||||||
from base64 import b64decode, b64encode
|
|
||||||
from collections import OrderedDict
|
|
||||||
#from sleekxmppfs.xmlstream import ET
|
|
||||||
#from sleekxmppfs.exceptions import XMPPError
|
|
||||||
|
|
||||||
#from .sucks_mqtt import EcoVacsIOTMQ
|
|
||||||
#from .sucks_xmpp import EcoVacsXMPP
|
|
||||||
from .sucks_api_const import *
|
|
||||||
|
|
||||||
import logging
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
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 = API_CLIENT_KEY
|
|
||||||
SECRET = API_SECRET
|
|
||||||
PUBLIC_KEY = API_PUBLIC_KEY
|
|
||||||
MAIN_URL_FORMAT = API_MAIN_URL_FORMAT
|
|
||||||
USER_URL_FORMAT = API_USER_URL_FORMAT
|
|
||||||
PORTAL_URL_FORMAT = API_PORTAL_URL_FORMAT
|
|
||||||
USERSAPI = API_USERSAPI
|
|
||||||
IOTDEVMANAGERAPI = API_IOTDEVMANAGERAPI # IOT Device Manager - This provides control of "IOT" products via RestAPI, some bots use this instead of XMPP
|
|
||||||
PRODUCTAPI = API_PRODUCTAPI # 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 = API_REALM
|
|
||||||
|
|
||||||
def __init__(self, device_id, account_id, password_hash, country, continent, verify_ssl=True):
|
|
||||||
self.meta = {
|
|
||||||
'country': country,
|
|
||||||
'lang': 'en',
|
|
||||||
'deviceId': device_id,
|
|
||||||
'appCode': 'i_eco_e',
|
|
||||||
#'appCode': 'i_eco_a' - iphone
|
|
||||||
'appVersion': '1.3.5',
|
|
||||||
#'appVersion': '1.4.6' - iphone
|
|
||||||
'channel': 'c_googleplay',
|
|
||||||
#'channel': 'c_iphone', - iphone
|
|
||||||
'deviceType': '1'
|
|
||||||
#'deviceType': '2' - iphone
|
|
||||||
}
|
|
||||||
self.verify_ssl = str_to_bool_or_cert(verify_ssl)
|
|
||||||
LOGGER.debug("Setting up EcoVacsAPI")
|
|
||||||
self.resource = device_id[0:8]
|
|
||||||
self.country = country
|
|
||||||
self.continent = continent
|
|
||||||
login_info = self.__call_main_api('user/login',
|
|
||||||
('account', self.encrypt(account_id)),
|
|
||||||
('password', self.encrypt(password_hash)))
|
|
||||||
self.uid = login_info['uid']
|
|
||||||
self.login_access_token = login_info['accessToken']
|
|
||||||
self.auth_code = self.__call_main_api('user/getAuthCode',
|
|
||||||
('uid', self.uid),
|
|
||||||
('accessToken', self.login_access_token))['authCode']
|
|
||||||
login_response = self.__call_login_by_it_token()
|
|
||||||
self.user_access_token = login_response['token']
|
|
||||||
if login_response['userId'] != self.uid:
|
|
||||||
LOGGER.debug("Switching to shorter UID " + login_response['userId'])
|
|
||||||
self.uid = login_response['userId']
|
|
||||||
LOGGER.debug("EcoVacsAPI connection complete")
|
|
||||||
|
|
||||||
def __sign(self, params):
|
|
||||||
result = params.copy()
|
|
||||||
result['authTimespan'] = int(time.time() * 1000)
|
|
||||||
result['authTimeZone'] = 'GMT-8'
|
|
||||||
sign_on = self.meta.copy()
|
|
||||||
sign_on.update(result)
|
|
||||||
sign_on_text = EcoVacsAPI.CLIENT_KEY + ''.join(
|
|
||||||
[k + '=' + str(sign_on[k]) for k in sorted(sign_on.keys())]) + EcoVacsAPI.SECRET
|
|
||||||
result['authAppkey'] = EcoVacsAPI.CLIENT_KEY
|
|
||||||
result['authSign'] = self.md5(sign_on_text)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def __call_main_api(self, function, *args):
|
|
||||||
LOGGER.debug("calling main api {} with {}".format(function, args))
|
|
||||||
params = OrderedDict(args)
|
|
||||||
params['requestId'] = self.md5(time.time())
|
|
||||||
url = (EcoVacsAPI.MAIN_URL_FORMAT + "/" + function).format(**self.meta)
|
|
||||||
api_response = requests.get(url, self.__sign(params), verify=self.verify_ssl)
|
|
||||||
json = api_response.json()
|
|
||||||
LOGGER.debug("got {}".format(json))
|
|
||||||
if json['code'] == '0000':
|
|
||||||
return json['data']
|
|
||||||
elif json['code'] == '1005':
|
|
||||||
LOGGER.error("incorrect email or password")
|
|
||||||
raise ValueError("incorrect email or password")
|
|
||||||
else:
|
|
||||||
LOGGER.error("call to {} failed with {}".format(function, json))
|
|
||||||
raise RuntimeError("failure code {} ({}) for call {} and parameters {}".format(
|
|
||||||
json['code'], json['msg'], function, args))
|
|
||||||
|
|
||||||
def __call_user_api(self, function, args):
|
|
||||||
LOGGER.debug("calling user api {} with {}".format(function, args))
|
|
||||||
params = {'todo': function}
|
|
||||||
params.update(args)
|
|
||||||
response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params, verify=self.verify_ssl)
|
|
||||||
json = response.json()
|
|
||||||
LOGGER.debug("got {}".format(json))
|
|
||||||
if json['result'] == 'ok':
|
|
||||||
return json
|
|
||||||
else:
|
|
||||||
LOGGER.error("call to {} failed with {}".format(function, json))
|
|
||||||
raise RuntimeError(
|
|
||||||
"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
|
|
||||||
|
|
||||||
def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs):
|
|
||||||
if api == self.USERSAPI:
|
|
||||||
params = {'todo': function}
|
|
||||||
params.update(args)
|
|
||||||
else:
|
|
||||||
params = {}
|
|
||||||
params.update(args)
|
|
||||||
LOGGER.debug("calling portal api {} function {} with {}".format(api, function, params))
|
|
||||||
continent = self.continent
|
|
||||||
if 'continent' in kwargs:
|
|
||||||
continent = kwargs.get('continent')
|
|
||||||
url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=continent, **self.meta)
|
|
||||||
response = requests.post(url, json=params, verify=verify_ssl)
|
|
||||||
json = response.json()
|
|
||||||
LOGGER.debug("got {}".format(json))
|
|
||||||
if api == self.USERSAPI:
|
|
||||||
if json['result'] == 'ok':
|
|
||||||
return json
|
|
||||||
elif json['result'] == 'fail':
|
|
||||||
if json['error'] == 'set token error.': # If it is a set token error try again
|
|
||||||
if not 'set_token' in kwargs:
|
|
||||||
LOGGER.debug("loginByItToken set token error, trying again (2/3)")
|
|
||||||
return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=1)
|
|
||||||
elif kwargs.get('set_token') == 1:
|
|
||||||
LOGGER.debug("loginByItToken set token error, trying again with ww (3/3)")
|
|
||||||
return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=2, continent="ww")
|
|
||||||
else:
|
|
||||||
LOGGER.debug("loginByItToken set token error, failed after 3 attempts")
|
|
||||||
if api.startswith(self.PRODUCTAPI):
|
|
||||||
if json['code'] == 0:
|
|
||||||
return json
|
|
||||||
|
|
||||||
else:
|
|
||||||
LOGGER.error("call to {} failed with {}".format(function, json))
|
|
||||||
raise RuntimeError(
|
|
||||||
"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
|
|
||||||
|
|
||||||
def __call_login_by_it_token(self):
|
|
||||||
return self.__call_portal_api(self.USERSAPI,'loginByItToken',
|
|
||||||
{'country': self.meta['country'].upper(),
|
|
||||||
'resource': self.resource,
|
|
||||||
'realm': EcoVacsAPI.REALM,
|
|
||||||
'userId': self.uid,
|
|
||||||
'token': self.auth_code}
|
|
||||||
, verify_ssl=self.verify_ssl)
|
|
||||||
|
|
||||||
def getdevices(self):
|
|
||||||
return self.__call_portal_api(self.USERSAPI,'GetDeviceList', {
|
|
||||||
'userid': self.uid,
|
|
||||||
'auth': {
|
|
||||||
'with': 'users',
|
|
||||||
'userid': self.uid,
|
|
||||||
'realm': EcoVacsAPI.REALM,
|
|
||||||
'token': self.user_access_token,
|
|
||||||
'resource': self.resource
|
|
||||||
}
|
|
||||||
}, verify_ssl=self.verify_ssl)['devices']
|
|
||||||
|
|
||||||
def getiotProducts(self):
|
|
||||||
return self.__call_portal_api(self.PRODUCTAPI + '/getProductIotMap','', {
|
|
||||||
'channel': '',
|
|
||||||
'auth': {
|
|
||||||
'with': 'users',
|
|
||||||
'userid': self.uid,
|
|
||||||
'realm': EcoVacsAPI.REALM,
|
|
||||||
'token': self.user_access_token,
|
|
||||||
'resource': self.resource
|
|
||||||
}
|
|
||||||
}, verify_ssl=self.verify_ssl)['data']
|
|
||||||
|
|
||||||
def SetIOTDevices(self, devices, iotproducts):
|
|
||||||
#Originally added for D900, and not actively used in code now - Not sure what the app checks the items in this list for
|
|
||||||
for device in devices: #Check if the device is part of iotProducts
|
|
||||||
device['iot_product'] = False
|
|
||||||
for iotProduct in iotproducts:
|
|
||||||
if device['class'] in iotProduct['classid']:
|
|
||||||
device['iot_product'] = True
|
|
||||||
|
|
||||||
return devices
|
|
||||||
|
|
||||||
def SetIOTMQDevices(self, devices):
|
|
||||||
#Added for devices that utilize MQTT instead of XMPP for communication
|
|
||||||
for device in devices:
|
|
||||||
device['iotmq'] = False
|
|
||||||
if device['company'] == 'eco-ng': #Check if the device is part of the list
|
|
||||||
device['iotmq'] = True
|
|
||||||
|
|
||||||
return devices
|
|
||||||
|
|
||||||
def devices(self):
|
|
||||||
return self.SetIOTMQDevices(self.getdevices())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def md5(text):
|
|
||||||
return hashlib.md5(bytes(str(text), 'utf8')).hexdigest()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def encrypt(text):
|
|
||||||
from Crypto.PublicKey import RSA
|
|
||||||
from Crypto.Cipher import PKCS1_v1_5
|
|
||||||
key = RSA.import_key(b64decode(EcoVacsAPI.PUBLIC_KEY))
|
|
||||||
cipher = PKCS1_v1_5.new(key)
|
|
||||||
result = cipher.encrypt(bytes(text, 'utf8'))
|
|
||||||
return str(b64encode(result), 'utf8')
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# API Constants
|
|
||||||
API_CLIENT_KEY = "eJUWrzRv34qFSaYk"
|
|
||||||
API_SECRET = "Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GC"
|
|
||||||
API_PUBLIC_KEY = 'MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ'
|
|
||||||
API_MAIN_URL_FORMAT = 'https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType}'
|
|
||||||
API_USER_URL_FORMAT = 'https://users-{continent}.ecouser.net:8000/user.do'
|
|
||||||
API_PORTAL_URL_FORMAT = 'https://portal-{continent}.ecouser.net/api'
|
|
||||||
API_USERSAPI = 'users/user.do'
|
|
||||||
API_IOTDEVMANAGERAPI = 'iot/devmanager.do'
|
|
||||||
API_PRODUCTAPI = 'pim/product'
|
|
||||||
API_REALM = 'ecouser.net'
|
|
||||||
@@ -4,30 +4,16 @@ import threading
|
|||||||
import ssl
|
import ssl
|
||||||
import requests
|
import requests
|
||||||
import stringcase
|
import stringcase
|
||||||
|
import logging
|
||||||
from threading import Event
|
from threading import Event
|
||||||
from paho.mqtt.client import Client as ClientMQTT
|
from paho.mqtt.client import Client as ClientMQTT
|
||||||
from paho.mqtt import publish as MQTTPublish
|
from paho.mqtt import publish as MQTTPublish
|
||||||
from paho.mqtt import subscribe as MQTTSubscribe
|
from paho.mqtt import subscribe as MQTTSubscribe
|
||||||
from sleekxmppfs.xmlstream import ET
|
from sleekxmppfs.xmlstream import ET
|
||||||
|
|
||||||
from .sucks_api_const import API_REALM, API_IOTDEVMANAGERAPI, API_PORTAL_URL_FORMAT
|
#from .const import LOGGER
|
||||||
|
|
||||||
import logging
|
_LOGGER = logging.getLogger(__name__)
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
|
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
|
||||||
def RepresentsInt(stringvar):
|
def RepresentsInt(stringvar):
|
||||||
@@ -92,7 +78,7 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
|
|
||||||
def schedule(self, 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))
|
self.scheduler.enter(timer_seconds, 1, self._run_scheduled_func,(timer_seconds, timer_function))
|
||||||
if not self.scheduler_thread.is_alive():
|
if not self.scheduler_thread.isAlive():
|
||||||
self.scheduler_thread.start()
|
self.scheduler_thread.start()
|
||||||
|
|
||||||
def wait_until_ready(self):
|
def wait_until_ready(self):
|
||||||
@@ -100,19 +86,19 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
|
|
||||||
def on_connect(self, client, userdata, flags, rc):
|
def on_connect(self, client, userdata, flags, rc):
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
LOGGER.error("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc))
|
_LOGGER.error("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc))
|
||||||
raise RuntimeError("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc))
|
raise RuntimeError("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc))
|
||||||
else:
|
else:
|
||||||
LOGGER.debug("EcoVacsMQTT - Connected with result code "+str(rc))
|
_LOGGER.debug("EcoVacsMQTT - Connected with result code "+str(rc))
|
||||||
LOGGER.debug("EcoVacsMQTT - Subscribing to all")
|
_LOGGER.debug("EcoVacsMQTT - Subscribing to all")
|
||||||
self.subscribe('iot/atr/+/' + self.vacuum['did'] + '/' + self.vacuum['class'] + '/' + self.vacuum['resource'] + '/+', qos=0)
|
self.subscribe('iot/atr/+/' + self.vacuum['did'] + '/' + self.vacuum['class'] + '/' + self.vacuum['resource'] + '/+', qos=0)
|
||||||
self.ready_flag.set()
|
self.ready_flag.set()
|
||||||
|
|
||||||
#def on_log(self, client, userdata, level, buf): #This is very noisy and verbose
|
#def on_log(self, client, userdata, level, buf): #This is very noisy and verbose
|
||||||
# LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf))
|
# _LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf))
|
||||||
|
|
||||||
def send_ping(self):
|
def send_ping(self):
|
||||||
LOGGER.debug("*** MQTT sending ping ***")
|
_LOGGER.debug("*** MQTT sending ping ***")
|
||||||
rc = self._send_simple_command(MQTTPublish.paho.PINGREQ)
|
rc = self._send_simple_command(MQTTPublish.paho.PINGREQ)
|
||||||
if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS:
|
if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS:
|
||||||
return True
|
return True
|
||||||
@@ -123,7 +109,7 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
if action.name == "Clean": #For handling Clean when action not specified (i.e. CLI)
|
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
|
action.args['clean']['act'] = CLEAN_ACTION_TO_ECOVACS['start'] #Inject a start action
|
||||||
c = self._wrap_command(action, recipient)
|
c = self._wrap_command(action, recipient)
|
||||||
LOGGER.debug('Sending command {0}'.format(c))
|
_LOGGER.debug('Sending command {0}'.format(c))
|
||||||
self._handle_ctl_api(action,
|
self._handle_ctl_api(action,
|
||||||
self.__call_iotdevmanager_api(c ,verify_ssl=self.verify_ssl )
|
self.__call_iotdevmanager_api(c ,verify_ssl=self.verify_ssl )
|
||||||
)
|
)
|
||||||
@@ -134,7 +120,7 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
payloadxml.attrib.pop("td")
|
payloadxml.attrib.pop("td")
|
||||||
return {
|
return {
|
||||||
'auth': {
|
'auth': {
|
||||||
'realm': API_REALM,
|
'realm': EcoVacsAPI.REALM,
|
||||||
'resource': self.resource,
|
'resource': self.resource,
|
||||||
'token': self.secret,
|
'token': self.secret,
|
||||||
'userid': self.user,
|
'userid': self.user,
|
||||||
@@ -151,15 +137,15 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def __call_iotdevmanager_api(self, args, verify_ssl=True):
|
def __call_iotdevmanager_api(self, args, verify_ssl=True):
|
||||||
LOGGER.debug("calling iotdevmanager api with {}".format(args))
|
_LOGGER.debug("calling iotdevmanager api with {}".format(args))
|
||||||
params = {}
|
params = {}
|
||||||
params.update(args)
|
params.update(args)
|
||||||
url = (API_PORTAL_URL_FORMAT + "/" + API_IOTDEVMANAGERAPI).format(continent=self.continent)
|
url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/iot/devmanager.do").format(continent=self.continent)
|
||||||
response = None
|
response = None
|
||||||
try: #The RestAPI sometimes doesnt provide a response depending on command, reduce timeout to 3 to accomodate and make requests faster
|
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
|
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:
|
except requests.exceptions.ReadTimeout:
|
||||||
LOGGER.debug("call to iotdevmanager failed with ReadTimeout")
|
_LOGGER.debug("call to iotdevmanager failed with ReadTimeout")
|
||||||
return {}
|
return {}
|
||||||
json = response.json()
|
json = response.json()
|
||||||
if json['ret'] == 'ok':
|
if json['ret'] == 'ok':
|
||||||
@@ -168,11 +154,11 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
if 'debug' in json:
|
if 'debug' in json:
|
||||||
if json['debug'] == 'wait for response timed out':
|
if json['debug'] == 'wait for response timed out':
|
||||||
#TODO - Maybe handle timeout for IOT better in the future
|
#TODO - Maybe handle timeout for IOT better in the future
|
||||||
LOGGER.error("call to iotdevmanager failed with {}".format(json))
|
_LOGGER.error("call to iotdevmanager failed with {}".format(json))
|
||||||
return {}
|
return {}
|
||||||
else:
|
else:
|
||||||
#TODO - Not sure if we want to raise an error yet, just return empty for now
|
#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))
|
_LOGGER.error("call to iotdevmanager failed with {}".format(json))
|
||||||
return {}
|
return {}
|
||||||
#raise RuntimeError(
|
#raise RuntimeError(
|
||||||
#"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
|
#"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
|
||||||
@@ -186,7 +172,7 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
|
|
||||||
def _ctl_to_dict_api(self, action, xmlstring):
|
def _ctl_to_dict_api(self, action, xmlstring):
|
||||||
xml = ET.fromstring(xmlstring)
|
xml = ET.fromstring(xmlstring)
|
||||||
xmlchild = list(xml)
|
xmlchild = xml.getchildren()
|
||||||
if len(xmlchild) > 0:
|
if len(xmlchild) > 0:
|
||||||
result = xmlchild[0].attrib.copy()
|
result = xmlchild[0].attrib.copy()
|
||||||
#Fix for difference in XMPP vs API response
|
#Fix for difference in XMPP vs API response
|
||||||
@@ -212,7 +198,7 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _handle_ctl_mqtt(self, client, userdata, message):
|
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"))))
|
#_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")))
|
as_dict = self._ctl_to_dict_mqtt(message.topic, str(message.payload.decode("utf-8")))
|
||||||
if as_dict is not None:
|
if as_dict is not None:
|
||||||
for s in self.ctl_subscribers:
|
for s in self.ctl_subscribers:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import stringcase
|
import stringcase
|
||||||
import random
|
import random
|
||||||
|
import logging
|
||||||
from threading import Event
|
from threading import Event
|
||||||
from sleekxmppfs import ClientXMPP, Callback, MatchXPath
|
from sleekxmppfs import ClientXMPP, Callback, MatchXPath
|
||||||
from sleekxmppfs.xmlstream import ET
|
from sleekxmppfs.xmlstream import ET
|
||||||
#from sleekxmppfs.exceptions import XMPPError
|
#from sleekxmppfs.exceptions import XMPPError
|
||||||
|
#from .const import LOGGER
|
||||||
|
|
||||||
import logging
|
_LOGGER = logging.getLogger(__name__)
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
|
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
|
||||||
def RepresentsInt(stringvar):
|
def RepresentsInt(stringvar):
|
||||||
try:
|
try:
|
||||||
@@ -37,8 +37,8 @@ class EcoVacsXMPP(ClientXMPP):
|
|||||||
self.ready_flag.wait()
|
self.ready_flag.wait()
|
||||||
|
|
||||||
def session_start(self, event):
|
def session_start(self, event):
|
||||||
LOGGER.debug("----------------- starting session ----------------")
|
_LOGGER.debug("----------------- starting session ----------------")
|
||||||
LOGGER.debug("event = {}".format(event))
|
_LOGGER.debug("event = {}".format(event))
|
||||||
self.register_handler(Callback("general",
|
self.register_handler(Callback("general",
|
||||||
MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}'),
|
MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}'),
|
||||||
self._handle_ctl))
|
self._handle_ctl))
|
||||||
@@ -65,7 +65,7 @@ class EcoVacsXMPP(ClientXMPP):
|
|||||||
try: # check for child xml
|
try: # check for child xml
|
||||||
childxml = xml[0]
|
childxml = xml[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
LOGGER.debug("No child xml")
|
_LOGGER.debug("No child xml")
|
||||||
if 'td' not in result:
|
if 'td' not in result:
|
||||||
# Handle response data with no 'td'
|
# Handle response data with no 'td'
|
||||||
if 'type' in result: # single element with type and val
|
if 'type' in result: # single element with type and val
|
||||||
@@ -100,7 +100,7 @@ class EcoVacsXMPP(ClientXMPP):
|
|||||||
|
|
||||||
def send_command(self, xml, recipient):
|
def send_command(self, xml, recipient):
|
||||||
c = self._wrap_command(xml, recipient)
|
c = self._wrap_command(xml, recipient)
|
||||||
LOGGER.debug('Sending command {0}'.format(c))
|
_LOGGER.debug('Sending command {0}'.format(c))
|
||||||
c.send()
|
c.send()
|
||||||
|
|
||||||
def _wrap_command(self, ctl, recipient):
|
def _wrap_command(self, ctl, recipient):
|
||||||
@@ -131,12 +131,12 @@ class EcoVacsXMPP(ClientXMPP):
|
|||||||
def send_ping(self, to):
|
def send_ping(self, to):
|
||||||
q = self.make_iq_get(ito=to, ifrom=self._my_address())
|
q = self.make_iq_get(ito=to, ifrom=self._my_address())
|
||||||
q.xml.append(ET.Element('ping', {'xmlns': 'urn:xmpp:ping'}))
|
q.xml.append(ET.Element('ping', {'xmlns': 'urn:xmpp:ping'}))
|
||||||
LOGGER.debug("*** sending ping ***")
|
_LOGGER.debug("*** sending ping ***")
|
||||||
q.send()
|
q.send()
|
||||||
|
|
||||||
# used some code from a sleekxmppfs plugin, seems to work fine
|
# used some code from a sleekxmppfs plugin, seems to work fine
|
||||||
def _handle_ping(self, iq):
|
def _handle_ping(self, iq):
|
||||||
LOGGER.debug("Pinged by %s", iq['from'])
|
_LOGGER.debug("Pinged by %s", iq['from'])
|
||||||
iq.reply().send()
|
iq.reply().send()
|
||||||
|
|
||||||
def connect_and_wait_until_ready(self):
|
def connect_and_wait_until_ready(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user