Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99726d56a3 |
@@ -1,8 +1,3 @@
|
|||||||
# Builtin HASS integration has options to not verify self-signed certificate now, this integration no longer needed.
|
|
||||||
|
|
||||||
## 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
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,19 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import discovery
|
||||||
from homeassistant.helpers.typing import ConfigType
|
#from homeassistant.helpers.typing import ConfigType
|
||||||
import homeassistant.helpers.config_validation as cv
|
#import homeassistant.helpers.config_validation as cv
|
||||||
import voluptuous as vol
|
#import voluptuous as vol
|
||||||
#use local sucks
|
#use local sucks
|
||||||
from .sucks import VacBot
|
from .sucks import EcoVacsAPI, VacBot
|
||||||
from .sucks_api import EcoVacsAPI
|
from .const import (
|
||||||
from .const import *
|
# ECOVACS_DEVICES,
|
||||||
|
# DOMAIN,
|
||||||
import logging
|
CONF_CONTINENT,
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
DOMAIN: vol.Schema(
|
DOMAIN: vol.Schema(
|
||||||
@@ -38,31 +40,33 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_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")
|
||||||
def get_devices() -> list[VacBot]:
|
# hass.data[ECOVACS_DEVICES] = []
|
||||||
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
|
SERVER_ADDRESS = None
|
||||||
|
|
||||||
devices: list[VacBot] = []
|
ecovacs_api = EcoVacsAPI(
|
||||||
for device in ecovacs_devices:
|
ECOVACS_API_DEVICEID,
|
||||||
_LOGGER.info(
|
config_entry.data(CONF_USERNAME),
|
||||||
|
EcoVacsAPI.md5(config_entry.data(CONF_PASSWORD)),
|
||||||
|
config_entry.data(CONF_COUNTRY),
|
||||||
|
config_entry.data(CONF_CONTINENT),
|
||||||
|
config_entry.data(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",
|
"Discovered Ecovacs device on account: %s with nickname %s",
|
||||||
device.get("did"),
|
device.get("did"),
|
||||||
device.get("nick"),
|
device.get("nick"),
|
||||||
@@ -73,31 +77,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
ecovacs_api.resource,
|
ecovacs_api.resource,
|
||||||
ecovacs_api.user_access_token,
|
ecovacs_api.user_access_token,
|
||||||
device,
|
device,
|
||||||
config[DOMAIN].get(CONF_CONTINENT).lower(),
|
config_entry.data(CONF_CONTINENT).lower(),
|
||||||
config[DOMAIN].get(CONF_VERIFY_SSL), # add to class call
|
SERVER_ADDRESS, # include server address in class, if it's null should be no effect
|
||||||
monitor=True,
|
config_entry.data(CONF_VERIFY_SSL), # add to class call
|
||||||
|
monitor=True
|
||||||
)
|
)
|
||||||
|
hass.data[ECOVACS_DEVICES].append(vacbot)
|
||||||
|
|
||||||
devices.append(vacbot)
|
def stop(event: object) -> None:
|
||||||
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."""
|
"""Shut down open connections to Ecovacs XMPP server."""
|
||||||
devices: list[VacBot] = hass.data[ECOVACS_DEVICES]
|
for device in hass.data[ECOVACS_DEVICES]:
|
||||||
for device in 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"),
|
||||||
)
|
)
|
||||||
await hass.async_add_executor_job(device.disconnect)
|
device.disconnect()
|
||||||
|
|
||||||
# Listen for HA stop to disconnect.
|
# Listen for HA stop to disconnect.
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_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")
|
||||||
hass.async_create_task(
|
discovery.load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
|
||||||
discovery.async_load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import logging
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
#ecovacs constants
|
#ecovacs constants
|
||||||
#init constants
|
#init constants
|
||||||
ECOVACS_DEVICES = "ecovacs_devices"
|
#ECOVACS_DEVICES = "ecovacs_devices"
|
||||||
DOMAIN = "ecovacs"
|
#DOMAIN = "ecovacs"
|
||||||
CONF_CONTINENT = "continent"
|
CONF_CONTINENT = "continent"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Data update coordinator for Ecovacs vacuums."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from async_timeout import timeout
|
||||||
|
from sharkiq import (
|
||||||
|
AylaApi,
|
||||||
|
SharkIqAuthError,
|
||||||
|
SharkIqAuthExpiringError,
|
||||||
|
SharkIqNotAuthedError,
|
||||||
|
SharkIqVacuum,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
|
class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]):
|
||||||
|
"""Define a wrapper class to update Shark IQ data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
ayla_api: AylaApi,
|
||||||
|
shark_vacs: list[SharkIqVacuum],
|
||||||
|
) -> None:
|
||||||
|
"""Set up the SharkIqUpdateCoordinator class."""
|
||||||
|
self.ayla_api = ayla_api
|
||||||
|
self.shark_vacs: dict[str, SharkIqVacuum] = {
|
||||||
|
sharkiq.serial_number: sharkiq for sharkiq in shark_vacs
|
||||||
|
}
|
||||||
|
self._config_entry = config_entry
|
||||||
|
self._online_dsns: set[str] = set()
|
||||||
|
|
||||||
|
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def online_dsns(self) -> set[str]:
|
||||||
|
"""Get the set of all online DSNs."""
|
||||||
|
return self._online_dsns
|
||||||
|
|
||||||
|
def device_is_online(self, dsn: str) -> bool:
|
||||||
|
"""Return the online state of a given vacuum dsn."""
|
||||||
|
return dsn in self._online_dsns
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _async_update_vacuum(sharkiq: SharkIqVacuum) -> None:
|
||||||
|
"""Asynchronously update the data for a single vacuum."""
|
||||||
|
dsn = sharkiq.serial_number
|
||||||
|
LOGGER.debug("Updating sharkiq data for device DSN %s", dsn)
|
||||||
|
async with timeout(API_TIMEOUT):
|
||||||
|
await sharkiq.async_update()
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> bool:
|
||||||
|
"""Update data device by device."""
|
||||||
|
try:
|
||||||
|
all_vacuums = await self.ayla_api.async_list_devices()
|
||||||
|
self._online_dsns = {
|
||||||
|
v["dsn"]
|
||||||
|
for v in all_vacuums
|
||||||
|
if v["connection_status"] == "Online" and v["dsn"] in self.shark_vacs
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.debug("Updating sharkiq data")
|
||||||
|
online_vacs = (self.shark_vacs[dsn] for dsn in self.online_dsns)
|
||||||
|
await asyncio.gather(*(self._async_update_vacuum(v) for v in online_vacs))
|
||||||
|
except (
|
||||||
|
SharkIqAuthError,
|
||||||
|
SharkIqNotAuthedError,
|
||||||
|
SharkIqAuthExpiringError,
|
||||||
|
) as err:
|
||||||
|
LOGGER.debug("Bad auth state. Attempting re-auth", exc_info=err)
|
||||||
|
raise ConfigEntryAuthFailed from err
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.exception("Unexpected error updating SharkIQ")
|
||||||
|
raise UpdateFailed(err) from err
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -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,226 @@
|
|||||||
#import hashlib
|
import hashlib
|
||||||
#import time
|
import time
|
||||||
#import requests
|
import requests
|
||||||
import os
|
import os
|
||||||
#from base64 import b64decode, b64encode
|
from base64 import b64decode, b64encode
|
||||||
#from collections import OrderedDict
|
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
|
def str_to_bool_or_cert(s):
|
||||||
LOGGER = logging.getLogger(__name__)
|
if s == 'True' or s == True:
|
||||||
|
return True
|
||||||
|
elif s == 'False' or s == False:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if not s == None:
|
||||||
|
if os.path.exists(s): # User could provide a path to a CA Cert as well, which is useful for Bumper
|
||||||
|
if os.path.isfile(s):
|
||||||
|
return s
|
||||||
|
else:
|
||||||
|
raise ValueError("Certificate path provided is not a file - {}".format(s))
|
||||||
|
raise ValueError("Cannot covert {} to a bool or certificate path".format(s))
|
||||||
|
|
||||||
|
class EcoVacsAPI:
|
||||||
|
CLIENT_KEY = "eJUWrzRv34qFSaYk"
|
||||||
|
SECRET = "Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GC"
|
||||||
|
PUBLIC_KEY = 'MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ'
|
||||||
|
MAIN_URL_FORMAT = 'https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType}'
|
||||||
|
USER_URL_FORMAT = 'https://users-{continent}.ecouser.net:8000/user.do'
|
||||||
|
PORTAL_URL_FORMAT = 'https://portal-{continent}.ecouser.net/api'
|
||||||
|
USERSAPI = 'users/user.do'
|
||||||
|
IOTDEVMANAGERAPI = 'iot/devmanager.do' # IOT Device Manager - This provides control of "IOT" products via RestAPI, some bots use this instead of XMPP
|
||||||
|
PRODUCTAPI = 'pim/product' # Leaving this open, the only endpoint known currently is "Product IOT Map" - pim/product/getProductIotMap - This provides a list of "IOT" products. Not sure what this provides the app.
|
||||||
|
REALM = 'ecouser.net'
|
||||||
|
|
||||||
|
def __init__(self, device_id, account_id, password_hash, country, continent, verify_ssl=True):
|
||||||
|
self.meta = {
|
||||||
|
'country': country,
|
||||||
|
'lang': 'en',
|
||||||
|
'deviceId': device_id,
|
||||||
|
'appCode': 'i_eco_e',
|
||||||
|
#'appCode': 'i_eco_a' - iphone
|
||||||
|
'appVersion': '1.3.5',
|
||||||
|
#'appVersion': '1.4.6' - iphone
|
||||||
|
'channel': 'c_googleplay',
|
||||||
|
#'channel': 'c_iphone', - iphone
|
||||||
|
'deviceType': '1'
|
||||||
|
#'deviceType': '2' - iphone
|
||||||
|
}
|
||||||
|
self.verify_ssl = str_to_bool_or_cert(verify_ssl)
|
||||||
|
LOGGER.debug("Setting up EcoVacsAPI")
|
||||||
|
self.resource = device_id[0:8]
|
||||||
|
self.country = country
|
||||||
|
self.continent = continent
|
||||||
|
login_info = self.__call_main_api('user/login',
|
||||||
|
('account', self.encrypt(account_id)),
|
||||||
|
('password', self.encrypt(password_hash)))
|
||||||
|
self.uid = login_info['uid']
|
||||||
|
self.login_access_token = login_info['accessToken']
|
||||||
|
self.auth_code = self.__call_main_api('user/getAuthCode',
|
||||||
|
('uid', self.uid),
|
||||||
|
('accessToken', self.login_access_token))['authCode']
|
||||||
|
login_response = self.__call_login_by_it_token()
|
||||||
|
self.user_access_token = login_response['token']
|
||||||
|
if login_response['userId'] != self.uid:
|
||||||
|
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')
|
||||||
|
|
||||||
class EventEmitter(object):
|
class EventEmitter(object):
|
||||||
"""A very simple event emitting system."""
|
"""A very simple event emitting system."""
|
||||||
@@ -106,40 +312,14 @@ 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:
|
||||||
|
|||||||
@@ -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'
|
|
||||||
@@ -10,24 +10,7 @@ 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__)
|
|
||||||
|
|
||||||
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 +75,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):
|
||||||
@@ -134,7 +117,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,
|
||||||
@@ -154,7 +137,7 @@ class EcoVacsIOTMQ(ClientMQTT):
|
|||||||
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
|
||||||
@@ -186,7 +169,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
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ 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__)
|
|
||||||
|
|
||||||
#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):
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ from typing import Any
|
|||||||
#sucks
|
#sucks
|
||||||
from . import sucks
|
from . import sucks
|
||||||
|
|
||||||
from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
|
from homeassistant.components.vacuum import (
|
||||||
|
VacuumEntity,
|
||||||
|
VacuumEntityFeature,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.icon import icon_for_battery_level
|
from homeassistant.helpers.icon import icon_for_battery_level
|
||||||
@@ -20,30 +23,46 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
ATTR_ERROR = "error"
|
ATTR_ERROR = "error"
|
||||||
ATTR_COMPONENT_PREFIX = "component_"
|
ATTR_COMPONENT_PREFIX = "component_"
|
||||||
|
|
||||||
|
"""
|
||||||
async def async_setup_platform(
|
def setup_platform(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config: ConfigType,
|
config: ConfigType,
|
||||||
async_add_entities: AddEntitiesCallback,
|
add_entities: AddEntitiesCallback,
|
||||||
discovery_info: DiscoveryInfoType | None = None,
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Ecovacs vacuums."""
|
"""Set up the Ecovacs vacuums."""
|
||||||
vacuums = []
|
vacuums = []
|
||||||
devices: list[sucks.VacBot] = hass.data[ECOVACS_DEVICES]
|
for device in hass.data[ECOVACS_DEVICES]:
|
||||||
for device in devices:
|
|
||||||
await hass.async_add_executor_job(device.connect_and_wait_until_ready)
|
|
||||||
vacuums.append(EcovacsVacuum(device))
|
vacuums.append(EcovacsVacuum(device))
|
||||||
_LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
|
_LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
|
||||||
async_add_entities(vacuums)
|
add_entities(vacuums, True)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Ecovacs vacuums."""
|
||||||
|
coordinator: SharkIqUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
devices: Iterable[SharkIqVacuum] = coordinator.shark_vacs.values()
|
||||||
|
device_names = [d.name for d in devices]
|
||||||
|
LOGGER.debug(
|
||||||
|
"Found %d Shark IQ device(s): %s",
|
||||||
|
len(device_names),
|
||||||
|
", ".join([d.name for d in devices]),
|
||||||
|
)
|
||||||
|
async_add_entities([SharkVacuumEntity(d, coordinator) for d in devices])
|
||||||
|
|
||||||
class EcovacsVacuum(StateVacuumEntity):
|
class EcovacsVacuum(VacuumEntity):
|
||||||
"""Ecovacs Vacuums such as Deebot."""
|
"""Ecovacs Vacuums such as Deebot."""
|
||||||
|
|
||||||
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
|
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
VacuumEntityFeature.BATTERY
|
VacuumEntityFeature.BATTERY
|
||||||
|
| VacuumEntityFeature.FAN_SPEED
|
||||||
| VacuumEntityFeature.RETURN_HOME
|
| VacuumEntityFeature.RETURN_HOME
|
||||||
| VacuumEntityFeature.CLEAN_SPOT
|
| VacuumEntityFeature.CLEAN_SPOT
|
||||||
| VacuumEntityFeature.STOP
|
| VacuumEntityFeature.STOP
|
||||||
@@ -52,13 +71,13 @@ class EcovacsVacuum(StateVacuumEntity):
|
|||||||
| VacuumEntityFeature.LOCATE
|
| VacuumEntityFeature.LOCATE
|
||||||
| VacuumEntityFeature.STATUS
|
| VacuumEntityFeature.STATUS
|
||||||
| VacuumEntityFeature.SEND_COMMAND
|
| VacuumEntityFeature.SEND_COMMAND
|
||||||
| VacuumEntityFeature.FAN_SPEED
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, device: sucks.VacBot) -> None:
|
def __init__(self, device: sucks.VacBot) -> None:
|
||||||
"""Initialize the Ecovacs Vacuum."""
|
"""Initialize the Ecovacs Vacuum."""
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.device.connect_and_wait_until_ready()
|
||||||
if self.device.vacuum.get("nick") is not None:
|
if self.device.vacuum.get("nick") is not None:
|
||||||
self._attr_name = str(self.device.vacuum["nick"])
|
self._attr_name = str(self.device.vacuum["nick"])
|
||||||
else:
|
else:
|
||||||
@@ -66,6 +85,7 @@ class EcovacsVacuum(StateVacuumEntity):
|
|||||||
self._attr_name = str(format(self.device.vacuum["did"]))
|
self._attr_name = str(format(self.device.vacuum["did"]))
|
||||||
|
|
||||||
self._error = None
|
self._error = None
|
||||||
|
_LOGGER.debug("Vacuum initialized: %s", self.name)
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Set up the event listeners now that hass is ready."""
|
"""Set up the event listeners now that hass is ready."""
|
||||||
@@ -76,7 +96,6 @@ class EcovacsVacuum(StateVacuumEntity):
|
|||||||
|
|
||||||
def on_error(self, error):
|
def on_error(self, error):
|
||||||
"""Handle an error event from the robot.
|
"""Handle an error event from the robot.
|
||||||
|
|
||||||
This will not change the entity's state. If the error caused the state
|
This will not change the entity's state. If the error caused the state
|
||||||
to change, that will come through as a separate on_status event
|
to change, that will come through as a separate on_status event
|
||||||
"""
|
"""
|
||||||
@@ -127,7 +146,6 @@ class EcovacsVacuum(StateVacuumEntity):
|
|||||||
"""Return the battery level of the vacuum cleaner."""
|
"""Return the battery level of the vacuum cleaner."""
|
||||||
if self.device.battery_status is not None:
|
if self.device.battery_status is not None:
|
||||||
return self.device.battery_status * 100
|
return self.device.battery_status * 100
|
||||||
|
|
||||||
return super().battery_level
|
return super().battery_level
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -137,7 +155,6 @@ class EcovacsVacuum(StateVacuumEntity):
|
|||||||
|
|
||||||
def turn_on(self, **kwargs: Any) -> None:
|
def turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the vacuum on and start cleaning."""
|
"""Turn the vacuum on and start cleaning."""
|
||||||
|
|
||||||
self.device.run(sucks.Clean())
|
self.device.run(sucks.Clean())
|
||||||
|
|
||||||
def turn_off(self, **kwargs: Any) -> None:
|
def turn_off(self, **kwargs: Any) -> None:
|
||||||
@@ -146,23 +163,19 @@ class EcovacsVacuum(StateVacuumEntity):
|
|||||||
|
|
||||||
def stop(self, **kwargs: Any) -> None:
|
def stop(self, **kwargs: Any) -> None:
|
||||||
"""Stop the vacuum cleaner."""
|
"""Stop the vacuum cleaner."""
|
||||||
|
|
||||||
self.device.run(sucks.Stop())
|
self.device.run(sucks.Stop())
|
||||||
|
|
||||||
def clean_spot(self, **kwargs: Any) -> None:
|
def clean_spot(self, **kwargs: Any) -> None:
|
||||||
"""Perform a spot clean-up."""
|
"""Perform a spot clean-up."""
|
||||||
|
|
||||||
self.device.run(sucks.Spot())
|
self.device.run(sucks.Spot())
|
||||||
|
|
||||||
def locate(self, **kwargs: Any) -> None:
|
def locate(self, **kwargs: Any) -> None:
|
||||||
"""Locate the vacuum cleaner."""
|
"""Locate the vacuum cleaner."""
|
||||||
|
|
||||||
self.device.run(sucks.PlaySound())
|
self.device.run(sucks.PlaySound())
|
||||||
|
|
||||||
def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
||||||
"""Set fan speed."""
|
"""Set fan speed."""
|
||||||
if self.is_on:
|
if self.is_on:
|
||||||
|
|
||||||
self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed))
|
self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed))
|
||||||
|
|
||||||
def send_command(
|
def send_command(
|
||||||
@@ -179,9 +192,7 @@ class EcovacsVacuum(StateVacuumEntity):
|
|||||||
"""Return the device-specific state attributes of this vacuum."""
|
"""Return the device-specific state attributes of this vacuum."""
|
||||||
data: dict[str, Any] = {}
|
data: dict[str, Any] = {}
|
||||||
data[ATTR_ERROR] = self._error
|
data[ATTR_ERROR] = self._error
|
||||||
|
|
||||||
for key, val in self.device.components.items():
|
for key, val in self.device.components.items():
|
||||||
attr_name = ATTR_COMPONENT_PREFIX + key
|
attr_name = ATTR_COMPONENT_PREFIX + key
|
||||||
data[attr_name] = int(val * 100)
|
data[attr_name] = int(val * 100)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
Reference in New Issue
Block a user