Compare commits

..

38 Commits

Author SHA1 Message Date
bittles 590772ec2c Update README.md 2024-04-27 17:49:07 -05:00
bittles 4f92ce5eba async changes put back in to push for 1.7.0 2024-04-27 17:40:15 -05:00
bittles ba0a2f9ed8 revert async to push minimal change update for release
has StateVacuumEntity but no async, will push release like this that should work and push another release with async
2024-04-27 17:35:41 -05:00
bittles d1bb42c769 change VacuumEntity to StateVacuumEntity 2024-04-27 17:32:39 -05:00
bittles 4ac0dc84d1 Make setup of Ecovacs async (#96200) * make setup async * apply suggestions
Bring code up to date with ecovacs component from commit cce9d93 on 7/24/23
2024-04-27 17:16:45 -05:00
bittles cacb0b40bb Merge pull request #17 from bittles/dev
cant comment block inside class call
2023-01-19 18:44:55 -05:00
bittles dc032f6719 cant comment block inside class call 2023-01-19 18:44:33 -05:00
bittles 9355f86e2d Merge pull request #16 from bittles/dev
Update README.md
2023-01-19 16:57:54 -05:00
bittles d2a0537b37 Update README.md 2023-01-19 16:57:35 -05:00
bittles f8ba0dd6da Merge pull request #15 from bittles/dev
fix longstanding error handling i hope
2023-01-19 16:54:47 -05:00
bittles d5370a5152 Update sucks.py 2023-01-19 16:53:49 -05:00
bittles a483d30625 fix error handling, bump to 1.5.2 2023-01-19 16:52:06 -05:00
bittles 64d62abf93 fix readme 2023-01-19 11:26:37 -05:00
bittles 6e5c0c170b readme* 2023-01-18 00:30:47 -05:00
bittles 9b52bc8e1d update readme 2023-01-18 00:28:36 -05:00
bittles 9652c47d42 Update sucks.py 2023-01-18 00:03:42 -05:00
bittles e24360fe91 Merge pull request #14 from bittles/dev
Fix import API_PORTAL_URL_FORMAT
2023-01-16 13:22:43 -05:00
bittles 45244f3744 Bump version to 1.5.1 2023-01-16 13:21:19 -05:00
bittles 646ff4ecbb Add API_PORTAL_URL_FORMAT import 2023-01-16 13:19:42 -05:00
bittles 3d5fd6c024 Fix const calls 2023-01-16 13:17:54 -05:00
bittles 22b541caf1 Merge pull request #13 from bittles/dev
split out api module
2023-01-15 17:16:53 -05:00
bittles 6cb13ce8a4 bump version, move str_to_bool to api module 2023-01-15 17:12:45 -05:00
bittles c7051bd51b split api module out, split out api constants, refer to api constants in mqtt module, pulling logger back into individual modules 2023-01-15 17:07:51 -05:00
bittles 455320c50e Merge pull request #12 from bittles/dev
import EcoVacsAPI back into mqtt library
2023-01-15 15:35:54 -05:00
bittles 2b66ffa426 bump version 2023-01-15 15:34:51 -05:00
bittles 2b252c0b22 import ecovacsapi back into mqtt library 2023-01-15 15:33:57 -05:00
bittles d7a7b2d185 Merge pull request #11 from bittles/dev
fixes for newer python versions
2023-01-15 15:11:04 -05:00
bittles 01165a4f85 version bump 2023-01-15 15:10:29 -05:00
bittles fa2c724333 fixes for newer python versions
isAlive and getchildren removed in python 3.9
2023-01-15 15:10:00 -05:00
bittles b21e2f95aa Merge pull request #10 from bittles/master
fix string to cert in mqtt
2023-01-15 14:11:04 -05:00
bittles 0e8c3ef0b6 fix string to cert in mqtt 2023-01-15 14:10:08 -05:00
bittles 4d8dc3fbe4 Merge pull request #9 from bittles/master
update dev to current master
2023-01-15 14:02:04 -05:00
bittles f1002d107f Merge pull request #7 from bittles/dev
shouldnt edit on phone, fixed now
2023-01-06 02:01:34 -05:00
bittles 24fc2ea74e Merge pull request #6 from bittles/dev
forgot to update module name
2023-01-06 01:51:24 -05:00
bittles 1fbe95c72f Merge pull request #5 from bittles/dev
1.4.0
2023-01-04 14:54:44 -05:00
bittles 3e59aa94aa Merge pull request #4 from bittles/dev
update docs
2023-01-04 13:31:06 -05:00
bittles e56266be86 Merge pull request #3 from bittles/dev
Dev
2023-01-03 23:02:16 -05:00
bittles 1decadee2e Merge pull request #2 from bittles/dev
mostly code cleanup
2023-01-03 14:15:03 -05:00
10 changed files with 428 additions and 453 deletions
+4 -1
View File
@@ -1,3 +1,6 @@
# Latest version is 1.7.0 that includes updates that SHOULD at least let the component work with newer HASS versions. This version includes async updates so the comoponent won't crap out on setup if it's unable to reach the vacuum. I am unable to tset these changes since I don't have HASS currently setup and I don't have my old Ecovacs vacuum anymore (handed down to younger sis).
# If 1.7.0 doesn't work, use version 1.6.0. I just made the bare minimum changes to the component that again SHOULD work, but I'm unable to test it. HASS ditched the VacuumEntity import for the vacuum component at some point and change it to StateVacuumEntity. That's all that's included in this update. Thanks @guillaume042 for making it easy for me by pointing me straight to a an issue with another component using VacuumEntity (though theirs was just leftover code that wasn't actually being used).
# Home Assistant Ecovacs Custom Component with Bumper Support
Based off the regular home assistant ecovacs components and bmartin's fork of sucks, https://github.com/bmartin5692/sucks. Replaces built in ecovacs component, with some upgrades and fixes. Allows SSL verification to be set to false to work with a self-hosted bumper server, https://github.com/bmartin5692/bumper, a replacement for Ecovacs servers to truly get local control.
@@ -78,4 +81,4 @@ Make component async, use config_flow, create device and clean up some of the ha
### Misc Info From Making This
Commit history is a bit of a mess. master branch shows changes from bmartins fork of sucks to v1.3.0 of this custom component. dev branch shows commits from my attempts at testing and getting this to work.
Added additional catches to sucks because my N79 sends some weird payloads, but attributes all pull in now for brush life spans. Couple initial queries it also sends weird that I'm in process of catching atm. As of version 1.3.0 (in the manifest.json) these initial queries and all attributes are working. Was using an implementation completely mine but saw in the MQTT class there were already catches for child payloads without the main payload having the expected td in its payload. Kept comments in giving credit and adapted them to work with xmpp.
Added additional catches to sucks because my N79 sends some weird payloads, but attributes all pull in now for brush life spans. Couple initial queries it also sends weird that I'm in process of catching atm. As of version 1.3.0 (in the manifest.json) these initial queries and all attributes are working. Was using an implementation completely mine but saw in the MQTT class there were already catches for child payloads without the main payload having the expected td in its payload. Kept comments in giving credit and adapted them to work with xmpp.
+72 -144
View File
@@ -1,16 +1,7 @@
"""Support for Ecovacs Deebot vacuums."""
import asyncio
from functools import partial
import async_timeout
import random
import string
import logging
# Use local sucks
from .sucks import EcoVacsAPI, VacBot
from homeassistant import exceptions
from homeassistant.config_entries import ConfigEntry
#import asyncio ## to do will need to convert to slixmpp to do this i believe
from homeassistant.const import (
CONF_USERNAME,
@@ -18,158 +9,95 @@ from homeassistant.const import (
CONF_COUNTRY,
CONF_VERIFY_SSL,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
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 *
from .const import (
ECOVACS_DEVICES,
DOMAIN,
PLATFORMS,
CONF_CONTINENT,
import logging
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,
)
# 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
ECOVACS_API_DEVICEID = "".join(
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Ecovacs component."""
_LOGGER.debug("Creating new Ecovacs component")
hass.data[ECOVACS_DEVICES] = []
LOGGER.debug("Creating new Ecovacs component")
def get_devices() -> list[VacBot]:
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),
)
ecovacs_devices = ecovacs_api.devices()
_LOGGER.debug("Ecobot devices: %s", 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
)
devices = ecovacs_api.devices()
_LOGGER.debug("Ecobot devices: %s", devices)
for device in devices:
_LOGGER.info(
"Discovered Ecovacs device on account: %s with nickname %s",
device.get("did"),
device.get("nick"),
)
vacbot = VacBot(
ecovacs_api.uid,
ecovacs_api.REALM,
ecovacs_api.resource,
ecovacs_api.user_access_token,
device,
config[DOMAIN].get(CONF_CONTINENT).lower(),
SERVER_ADDRESS, # include server address in class, if it's null should be no effect
config[DOMAIN].get(CONF_VERIFY_SSL), # add to class call
monitor=True
)
hass.data[ECOVACS_DEVICES].append(vacbot)
def stop(event: object) -> None:
"""Shut down open connections to Ecovacs XMPP server."""
for device in hass.data[ECOVACS_DEVICES]:
devices: list[VacBot] = []
for device in ecovacs_devices:
_LOGGER.info(
"Discovered Ecovacs device on account: %s with nickname %s",
device.get("did"),
device.get("nick"),
)
vacbot = VacBot(
ecovacs_api.uid,
ecovacs_api.REALM,
ecovacs_api.resource,
ecovacs_api.user_access_token,
device,
config[DOMAIN].get(CONF_CONTINENT).lower(),
config[DOMAIN].get(CONF_VERIFY_SSL), # add to class call
monitor=True,
)
devices.append(vacbot)
return devices
hass.data[ECOVACS_DEVICES] = await hass.async_add_executor_job(get_devices)
async def async_stop(event: object) -> None:
"""Shut down open connections to Ecovacs XMPP server."""
devices: list[VacBot] = hass.data[ECOVACS_DEVICES]
for device in devices:
LOGGER.info(
"Shutting down connection to Ecovacs device %s",
device.vacuum.get("did"),
)
device.disconnect()
await hass.async_add_executor_job(device.disconnect)
# Listen for HA stop to disconnect.
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop)
if hass.data[ECOVACS_DEVICES]:
_LOGGER.debug("Starting vacuum components")
discovery.load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
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],
},
LOGGER.debug("Starting vacuum components")
hass.async_create_task(
discovery.async_load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
)
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
return True
+3 -6
View File
@@ -1,8 +1,5 @@
""" Ecovacs constants. """
from homeassistant.const import Platform
DOMAIN = "ecovacs"
PLATFORMS = [Platform.Vacuum]
#ecovacs constants
#init constants
ECOVACS_DEVICES = "ecovacs_devices"
DOMAIN = "ecovacs"
CONF_CONTINENT = "continent"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"domain": "ecovacs",
"name": "Ecovacs Bumper",
"version": "1.4.2",
"version": "1.5.3",
"documentation": "https://github.com/bittles/ha_ecovacs_bumper",
"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"],
+62 -265
View File
@@ -1,249 +1,20 @@
import hashlib
import time
import requests
#import hashlib
#import time
#import requests
import os
import logging
import aiohttp
from base64 import b64decode, b64encode
from collections import OrderedDict
#from base64 import b64decode, b64encode
#from collections import OrderedDict
from sleekxmppfs.xmlstream import ET
from sleekxmppfs.exceptions import XMPPError
#from . import sucks_api
from .sucks_mqtt import EcoVacsIOTMQ
from .sucks_xmpp import EcoVacsXMPP
#from .const import LOGGER
from .sucks_const import *
_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
"""
import logging
LOGGER = logging.getLogger(__name__)
class EventEmitter(object):
"""A very simple event emitting system."""
@@ -335,29 +106,55 @@ class VacBot():
getattr(self, method)(ctl)
def _handle_error(self, event):
if 'error' in event:
error = event['error']
elif 'errs' in event:
error = event['errs']
if not error == '':
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:
error = event['error']
elif 'errs' in event:
error = event['errs']
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):
type = event['type']
try:
type = COMPONENT_FROM_ECOVACS[type]
except KeyError:
_LOGGER.warning("Unknown component type: '" + type + "'")
LOGGER.warning("Unknown component type: '" + type + "'")
if 'val' in event:
lifespan = int(event['val']) / 100
_LOGGER.info("**********Component " + type + " has lifespan of " + str(lifespan) + ".")
LOGGER.info("**********Component " + type + " has lifespan of " + str(lifespan) + ".")
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.info("*** life_span " + type + " = " + str(lifespan))
LOGGER.info("*** life_span " + type + " = " + str(lifespan))
def _handle_clean_report(self, event):
type = event['type']
@@ -369,7 +166,7 @@ class VacBot():
if statustype == CLEAN_ACTION_STOP or statustype == CLEAN_ACTION_PAUSE:
type = statustype
except KeyError:
_LOGGER.warning("Unknown cleaning status '" + type + "'")
LOGGER.warning("Unknown cleaning status '" + type + "'")
self.clean_status = type
self.vacuum_status = type
fan = event.get('speed', None)
@@ -377,22 +174,22 @@ class VacBot():
try:
fan = FAN_SPEED_FROM_ECOVACS[fan]
except KeyError:
_LOGGER.warning("Unknown fan speed: '" + fan + "'")
LOGGER.warning("Unknown fan speed: '" + fan + "'")
self.fan_speed = fan
self.statusEvents.notify(self.vacuum_status)
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:
_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):
try:
self.battery_status = float(iq['power']) / 100
except ValueError:
_LOGGER.warning("couldn't parse battery status " + ET.tostring(iq))
LOGGER.warning("couldn't parse battery status " + ET.tostring(iq))
else:
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):
if 'type' in event:
@@ -406,11 +203,11 @@ class VacBot():
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
LOGGER.error("Unknown charging status '" + event['errno'] + "'") #Log this so we can identify more errors
try:
status = CHARGE_MODE_FROM_ECOVACS[status]
except KeyError:
_LOGGER.warning("Unknown charging status '" + status + "'")
LOGGER.warning("Unknown charging status '" + status + "'")
self.charge_status = status
if status != 'idle' or self.vacuum_status == 'charging':
# We have to ignore the idle messages, because all it means is that it's not
@@ -418,7 +215,7 @@ class VacBot():
# of what the vacuum is currently up to.
self.vacuum_status = 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):
if not self.vacuum['iotmq']:
@@ -442,15 +239,15 @@ class VacBot():
if not self.iotmq.send_ping():
raise RuntimeError()
except XMPPError as err:
_LOGGER.warning("Ping did not reach VacBot. Will retry.")
_LOGGER.error("*** Error type: " + err.etype)
_LOGGER.error("*** Error condition: " + err.condition)
LOGGER.warning("Ping did not reach VacBot. Will retry.")
LOGGER.error("*** Error type: " + err.etype)
LOGGER.error("*** Error condition: " + err.condition)
self._failed_pings += 1
if self._failed_pings >= 4:
self.vacuum_status = 'offline'
self.statusEvents.notify(self.vacuum_status)
except RuntimeError as err:
_LOGGER.warning("Ping did not reach VacBot. Will retry.")
LOGGER.warning("Ping did not reach VacBot. Will retry.")
self._failed_pings += 1
if self._failed_pings >= 4:
self.vacuum_status = 'offline'
@@ -473,9 +270,9 @@ class VacBot():
self.run(GetLifeSpan('side_brush'))
self.run(GetLifeSpan('filter'))
except XMPPError as err:
_LOGGER.warning("Component refresh requests failed to reach VacBot. Will try again later.")
_LOGGER.error("*** Error type: " + err.etype)
_LOGGER.error("*** Error condition: " + err.condition)
LOGGER.warning("Component refresh requests failed to reach VacBot. Will try again later.")
LOGGER.error("*** Error type: " + err.etype)
LOGGER.error("*** Error condition: " + err.condition)
def refresh_statuses(self):
try:
@@ -483,9 +280,9 @@ class VacBot():
self.run(GetChargeState())
self.run(GetBatteryState())
except XMPPError as err:
_LOGGER.warning("Initial status requests failed to reach VacBot. Will try again on next ping.")
_LOGGER.error("*** Error type: " + err.etype)
_LOGGER.error("*** Error condition: " + err.condition)
LOGGER.warning("Initial status requests failed to reach VacBot. Will try again on next ping.")
LOGGER.error("*** Error type: " + err.etype)
LOGGER.error("*** Error condition: " + err.condition)
def request_all_statuses(self):
self.refresh_statuses()
+224
View File
@@ -0,0 +1,224 @@
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')
@@ -0,0 +1,11 @@
# 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'
+32 -18
View File
@@ -4,16 +4,30 @@ import threading
import ssl
import requests
import stringcase
import logging
from threading import Event
from paho.mqtt.client import Client as ClientMQTT
from paho.mqtt import publish as MQTTPublish
from paho.mqtt import subscribe as MQTTSubscribe
from sleekxmppfs.xmlstream import ET
#from .const import LOGGER
from .sucks_api_const import API_REALM, API_IOTDEVMANAGERAPI, API_PORTAL_URL_FORMAT
_LOGGER = logging.getLogger(__name__)
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))
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
def RepresentsInt(stringvar):
@@ -78,7 +92,7 @@ class EcoVacsIOTMQ(ClientMQTT):
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():
if not self.scheduler_thread.is_alive():
self.scheduler_thread.start()
def wait_until_ready(self):
@@ -86,19 +100,19 @@ class EcoVacsIOTMQ(ClientMQTT):
def on_connect(self, client, userdata, flags, rc):
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))
else:
_LOGGER.debug("EcoVacsMQTT - Connected with result code "+str(rc))
_LOGGER.debug("EcoVacsMQTT - Subscribing to all")
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))
# LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf))
def send_ping(self):
_LOGGER.debug("*** MQTT sending ping ***")
LOGGER.debug("*** MQTT sending ping ***")
rc = self._send_simple_command(MQTTPublish.paho.PINGREQ)
if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS:
return True
@@ -109,7 +123,7 @@ class EcoVacsIOTMQ(ClientMQTT):
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))
LOGGER.debug('Sending command {0}'.format(c))
self._handle_ctl_api(action,
self.__call_iotdevmanager_api(c ,verify_ssl=self.verify_ssl )
)
@@ -120,7 +134,7 @@ class EcoVacsIOTMQ(ClientMQTT):
payloadxml.attrib.pop("td")
return {
'auth': {
'realm': EcoVacsAPI.REALM,
'realm': API_REALM,
'resource': self.resource,
'token': self.secret,
'userid': self.user,
@@ -137,15 +151,15 @@ class EcoVacsIOTMQ(ClientMQTT):
}
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.update(args)
url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/iot/devmanager.do").format(continent=self.continent)
url = (API_PORTAL_URL_FORMAT + "/" + API_IOTDEVMANAGERAPI).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")
LOGGER.debug("call to iotdevmanager failed with ReadTimeout")
return {}
json = response.json()
if json['ret'] == 'ok':
@@ -154,11 +168,11 @@ class EcoVacsIOTMQ(ClientMQTT):
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))
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))
LOGGER.error("call to iotdevmanager failed with {}".format(json))
return {}
#raise RuntimeError(
#"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
@@ -172,7 +186,7 @@ class EcoVacsIOTMQ(ClientMQTT):
def _ctl_to_dict_api(self, action, xmlstring):
xml = ET.fromstring(xmlstring)
xmlchild = xml.getchildren()
xmlchild = list(xml)
if len(xmlchild) > 0:
result = xmlchild[0].attrib.copy()
#Fix for difference in XMPP vs API response
@@ -198,7 +212,7 @@ class EcoVacsIOTMQ(ClientMQTT):
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"))))
#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:
+9 -9
View File
@@ -1,13 +1,13 @@
import stringcase
import random
import logging
from threading import Event
from sleekxmppfs import ClientXMPP, Callback, MatchXPath
from sleekxmppfs.xmlstream import ET
#from sleekxmppfs.exceptions import XMPPError
#from .const import LOGGER
_LOGGER = logging.getLogger(__name__)
import logging
LOGGER = logging.getLogger(__name__)
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
def RepresentsInt(stringvar):
try:
@@ -37,8 +37,8 @@ class EcoVacsXMPP(ClientXMPP):
self.ready_flag.wait()
def session_start(self, event):
_LOGGER.debug("----------------- starting session ----------------")
_LOGGER.debug("event = {}".format(event))
LOGGER.debug("----------------- starting session ----------------")
LOGGER.debug("event = {}".format(event))
self.register_handler(Callback("general",
MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}'),
self._handle_ctl))
@@ -65,7 +65,7 @@ class EcoVacsXMPP(ClientXMPP):
try: # check for child xml
childxml = xml[0]
except IndexError:
_LOGGER.debug("No child xml")
LOGGER.debug("No child xml")
if 'td' not in result:
# Handle response data with no 'td'
if 'type' in result: # single element with type and val
@@ -100,7 +100,7 @@ class EcoVacsXMPP(ClientXMPP):
def send_command(self, xml, recipient):
c = self._wrap_command(xml, recipient)
_LOGGER.debug('Sending command {0}'.format(c))
LOGGER.debug('Sending command {0}'.format(c))
c.send()
def _wrap_command(self, ctl, recipient):
@@ -131,12 +131,12 @@ class EcoVacsXMPP(ClientXMPP):
def send_ping(self, to):
q = self.make_iq_get(ito=to, ifrom=self._my_address())
q.xml.append(ET.Element('ping', {'xmlns': 'urn:xmpp:ping'}))
_LOGGER.debug("*** sending ping ***")
LOGGER.debug("*** sending ping ***")
q.send()
# used some code from a sleekxmppfs plugin, seems to work fine
def _handle_ping(self, iq):
_LOGGER.debug("Pinged by %s", iq['from'])
LOGGER.debug("Pinged by %s", iq['from'])
iq.reply().send()
def connect_and_wait_until_ready(self):
+10 -9
View File
@@ -7,7 +7,7 @@ from typing import Any
#sucks
from . import sucks
from homeassistant.components.vacuum import VacuumEntity, VacuumEntityFeature
from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
@@ -21,21 +21,23 @@ ATTR_ERROR = "error"
ATTR_COMPONENT_PREFIX = "component_"
def setup_platform(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Ecovacs vacuums."""
vacuums = []
for device in hass.data[ECOVACS_DEVICES]:
devices: list[sucks.VacBot] = hass.data[ECOVACS_DEVICES]
for device in devices:
await hass.async_add_executor_job(device.connect_and_wait_until_ready)
vacuums.append(EcovacsVacuum(device))
_LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
add_entities(vacuums, True)
async_add_entities(vacuums)
class EcovacsVacuum(VacuumEntity):
class EcovacsVacuum(StateVacuumEntity):
"""Ecovacs Vacuums such as Deebot."""
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
@@ -56,7 +58,7 @@ class EcovacsVacuum(VacuumEntity):
def __init__(self, device: sucks.VacBot) -> None:
"""Initialize the Ecovacs Vacuum."""
self.device = device
self.device.connect_and_wait_until_ready()
if self.device.vacuum.get("nick") is not None:
self._attr_name = str(self.device.vacuum["nick"])
else:
@@ -64,7 +66,6 @@ class EcovacsVacuum(VacuumEntity):
self._attr_name = str(format(self.device.vacuum["did"]))
self._error = None
_LOGGER.debug("Vacuum initialized: %s", self.name)
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
@@ -183,4 +184,4 @@ class EcovacsVacuum(VacuumEntity):
attr_name = ATTR_COMPONENT_PREFIX + key
data[attr_name] = int(val * 100)
return data
return data