From f69904fee97fe58c3b649a3e3024a4d930ef1223 Mon Sep 17 00:00:00 2001 From: Aleix Murtra Date: Sat, 17 Sep 2022 11:36:20 +0200 Subject: [PATCH] Add new features --- Pipfile | 13 ++ Pipfile.lock | 79 +++++++++++ README.md | 34 ++++- custom_components/cecotec_conga/__init__.py | 95 ++++++++++++- .../cecotec_conga/config_flow.py | 4 +- custom_components/cecotec_conga/const.py | 8 ++ custom_components/cecotec_conga/vacuum.py | 130 +++++++++++++++++- test.py | 28 ++-- 8 files changed, 372 insertions(+), 19 deletions(-) create mode 100644 Pipfile create mode 100644 Pipfile.lock diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..81a8fed --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +boto3 = "*" +python-dotenv = "*" + +[dev-packages] + +[requires] +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..622cd4e --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,79 @@ +{ + "_meta": { + "hash": { + "sha256": "6adad263651e383c56b87cab4c54987f90cea72f6ac51d22411a73007735ef89" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "boto3": { + "hashes": [ + "sha256:293593dd93c0a5fe6088dd9a393002376824977c55c8e5756d53a1c0283473dd", + "sha256:def153fb4773c55d89193529e8b0f829eab5fed57cee1d28f3cdcaa18b8837c8" + ], + "index": "pypi", + "version": "==1.24.74" + }, + "botocore": { + "hashes": [ + "sha256:e103eb209e33c37bdb68e10ea3048088ee93e8973e5d9415af12d3c3f1ac46a6", + "sha256:e4430a48aaa8817342127b18c4c6c9cd44bdce85e3c0d980f9b36322d518fe2e" + ], + "version": "==1.27.74" + }, + "jmespath": { + "hashes": [ + "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", + "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe" + ], + "version": "==1.0.1" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "version": "==2.8.2" + }, + "python-dotenv": { + "hashes": [ + "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5", + "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045" + ], + "index": "pypi", + "version": "==0.21.0" + }, + "s3transfer": { + "hashes": [ + "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd", + "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947" + ], + "version": "==0.6.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "version": "==1.16.0" + }, + "urllib3": { + "hashes": [ + "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e", + "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997" + ], + "version": "==1.26.12" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index 2e5488d..6e3be3d 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,41 @@ This integration has been tested with the following vacuum cleaners: ## Supported functionalities So far the following features have been implemented: -* `TURN_ON` -* `RETURN_HOME` (`TURN_OFF` does the same) +* `TURN_ON` - Enables the button to start the vacuum. +* `RETURN_HOME` (`TURN_OFF` does the same) - Enables the button to return the vacuum to home. +* `BATTERY` - Shows the status of the battery. +* `FAN_SPEED` - Allows changing the speed of the fan when the vacuum is cleaning. +* `SEND_COMMAND` - Allows sending custom commands, like setting water drop or starting a plan (in future releases). A lot of ideas are in the backlog :) Do you have some idea? [Raise an issue!](https://github.com/alemuro/ha-cecotec-conga/issues/new?assignees=&labels=&template=feature_request.md&title=) +### Set water drop level + +This is allowed through the `vacuum.send_command` service. Use the command `set_water_level` and provide the param `water_level` with some of these values: `Off`, `Low`, `Medium` or `High`. Only works when the vacuum is already cleaning. Allowed levels are shown as an attribute of the vacuum entity. + +``` +service: vacuum.send_command +target: + entity_id: vacuum.conga +data: + command: set_water_level + params: + water_level: Medium +``` + +## Developers + +### Local testing + +If you already have a Conga, I encourage you to test it by executing the `test.py`. + +1. Install dependencies throuhg `pipenv`. Execute `pipenv install`. +2. Open virtualenv: `pipenv shell`. +3. Create a `.env` file with the variables `CONGA_USERNAME`, `CONGA_PASSWORD` and `CONGA_SN` (serial number). The serial number is retrieved by the script. +4. Execute `python test.py`. + +There is a Makefile target to start a Docker container with this integration installed as a `custom_component`. Execute `make test-local`. + ## Legal notice This is a personal project and isn't in any way affiliated with, sponsored or endorsed by [CECOTEC](https://www.cecotec.es/). diff --git a/custom_components/cecotec_conga/__init__.py b/custom_components/cecotec_conga/__init__.py index 96fdfb0..5c89588 100644 --- a/custom_components/cecotec_conga/__init__.py +++ b/custom_components/cecotec_conga/__init__.py @@ -1,5 +1,7 @@ import logging +import random import requests +import string import json import boto3 import datetime @@ -13,8 +15,8 @@ async def async_setup_entry(hass, entry): - """Set up Cecotec Conga vacuums based on a config entry.""" - + """Set up Cecotec Conga sensors based on a config entry.""" + _LOGGER.info("Setting up Cecotec Conga integration") hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "vacuum") ) @@ -42,12 +44,43 @@ def list_vacuums(self): _LOGGER.warn(self._devices) return self._devices + def list_plans(self, sn): + self._refresh_api_token() + response = requests.post( + f'{CECOTEC_API_BASE_URL}/api/user/file_list', + json={ + "sn": sn, + "file_type": 2, + "sort": -1, + "page_size": 10, + "last_page_key": None + }, + auth=self._api_token + ) + response.raise_for_status() + try: + pages = response.json()["data"]["page_items"] + plans = [] + plan_names = [] + for page in pages: + if "planName" in page["task_cmd"]["cmd"]: + plans.append(page["task_cmd"]["cmd"]) + plan_names.append(page["task_cmd"]["cmd"]["planName"]) + + self._plans = plans + self._plan_names = plan_names + except: + self._plans = [] + self._plan_names = [] + + return self._plan_names + def status(self, sn): self._refresh_iot_client() r = self._iot_client.get_thing_shadow(thingName=sn) return json.load(r['payload']) - def start(self, sn): + def start(self, sn, fan_speed): payload = { "state": { "desired": { @@ -56,7 +89,7 @@ def start(self, sn): "body": { "mode": "Auto", "deepClean": 0, - "fanLevel": 1, + "fanLevel": fan_speed, "water": 1, "autoBoost": 0, "params": "[]" @@ -68,6 +101,53 @@ def start(self, sn): self._send_payload(sn, payload) + def set_fan_speed(self, sn, level): + payload = { + "state": { + "desired": { + "workNoisy": level + } + } + } + _LOGGER.debug(payload) + self._refresh_iot_client() + self._iot_client.update_thing_shadow( + thingName=sn, + payload=bytes(json.dumps(payload), "ascii") + ) + + def set_water_level(self, sn, level): + payload = { + "state": { + "desired": { + "water": level + } + } + } + _LOGGER.debug(payload) + self._refresh_iot_client() + self._iot_client.update_thing_shadow( + thingName=sn, + payload=bytes(json.dumps(payload), "ascii") + ) + + def start_plan(self, sn, plan_name): + allowed_chars = string.ascii_lowercase + \ + string.ascii_uppercase + string.digits + result_str = ''.join(random.choice(allowed_chars) for i in range(10)) + plan = self._get_plan_details(plan_name) + payload = { + "state": { + "desired": { + "StartTimedCleanTask": { + "id": result_str, + "params": json.dumps(plan) + } + } + } + } + self._send_payload(sn, payload) + def home(self, sn): payload = { "state": { @@ -79,7 +159,14 @@ def home(self, sn): self._send_payload(sn, payload) + def _get_plan_details(self, plan_name): + for plan in self._plans: + if plan['planName'] == plan_name: + return plan + return "" + def _send_payload(self, sn, payload): + _LOGGER.debug(payload) self._refresh_iot_client() self._iot_client.update_thing_shadow( thingName=sn, diff --git a/custom_components/cecotec_conga/config_flow.py b/custom_components/cecotec_conga/config_flow.py index 29e6768..b36d9c6 100644 --- a/custom_components/cecotec_conga/config_flow.py +++ b/custom_components/cecotec_conga/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for TMB.""" +"""Config flow for Cecotec Conga.""" import logging from requests.models import HTTPError @@ -19,7 +19,7 @@ class CecotecCongaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """TMB config flow.""" + """Cecotec Conga config flow.""" VERSION = 1 diff --git a/custom_components/cecotec_conga/const.py b/custom_components/cecotec_conga/const.py index 60b26dd..29d95b5 100644 --- a/custom_components/cecotec_conga/const.py +++ b/custom_components/cecotec_conga/const.py @@ -3,3 +3,11 @@ CONF_USERNAME = "username" CONF_PASSWORD = "password" CONF_DEVICES = "devices" +FAN_SPEED_0 = "Off" +FAN_SPEED_1 = "Eco" +FAN_SPEED_2 = "Normal" +FAN_SPEED_3 = "Turbo" +WATER_LEVEL_0 = "Off" +WATER_LEVEL_1 = "Low" +WATER_LEVEL_2 = "Medium" +WATER_LEVEL_3 = "High" diff --git a/custom_components/cecotec_conga/vacuum.py b/custom_components/cecotec_conga/vacuum.py index 64535f9..b066c97 100644 --- a/custom_components/cecotec_conga/vacuum.py +++ b/custom_components/cecotec_conga/vacuum.py @@ -8,6 +8,7 @@ from homeassistant.components.vacuum import ( STATE_CLEANING, STATE_DOCKED, + STATE_PAUSED, STATE_RETURNING, STATE_ERROR, StateVacuumEntity, @@ -21,6 +22,14 @@ CONF_DEVICES, CONF_USERNAME, CONF_PASSWORD, + FAN_SPEED_0, + FAN_SPEED_1, + FAN_SPEED_2, + FAN_SPEED_3, + WATER_LEVEL_0, + WATER_LEVEL_1, + WATER_LEVEL_2, + WATER_LEVEL_3 ) SUPPORTED_FEATURES = ( @@ -28,6 +37,9 @@ | VacuumEntityFeature.TURN_OFF | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.SEND_COMMAND ) _LOGGER = logging.getLogger(__name__) @@ -36,6 +48,22 @@ ATTR_SN = "Serial Number" ATTR_NAME = "Name" +ATTR_PLANS = "plans" +ATTR_WATER_LEVELS = "water_levels" + +FAN_SPEEDS = [ + FAN_SPEED_0, + FAN_SPEED_1, + FAN_SPEED_2, + FAN_SPEED_3 +] + +WATER_LEVELS = [ + WATER_LEVEL_0, + WATER_LEVEL_1, + WATER_LEVEL_2, + WATER_LEVEL_3 +] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -43,7 +71,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the TMB sensor from a config entry.""" + """Set up the Cecotec Conga sensor from a config entry.""" entities = [] conga_client = Conga( @@ -67,7 +95,14 @@ def __init__(self, conga_client, name, sn): self._conga_client = conga_client self._name = name self._sn = sn + self._battery = 0 self._state = "loading" + self._state_all = {} + self._plans = [] + self._water_levels = WATER_LEVELS + self._water_level = WATER_LEVEL_1 + self._fan_speeds = FAN_SPEEDS + self._fan_speed = FAN_SPEED_1 self._supported_features = SUPPORTED_FEATURES @property @@ -94,18 +129,67 @@ def state(self): return STATE_RETURNING elif self._state == "fullcharge" or self._state == "charge": return STATE_DOCKED + elif self._state == "pause": + return STATE_PAUSED else: _LOGGER.warn(f"Unknown status: {self._state}") return STATE_ERROR + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + return self._battery + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + charging = "" + if self._state == "charge": + charging = "-charging" + + battery = "-100" + if self._battery < 10: + battery = "-outline" + elif self._battery < 20: + battery = "-10" + elif self._battery < 30: + battery = "-20" + elif self._battery < 40: + battery = "-30" + elif self._battery < 50: + battery = "-40" + elif self._battery < 60: + battery = "-50" + elif self._battery < 70: + battery = "-60" + elif self._battery < 80: + battery = "-70" + elif self._battery < 90: + battery = "-80" + elif self._battery < 100: + battery = "-90" + return f"mdi:battery{charging}{battery}" + @property def extra_state_attributes(self): """Return some attributes.""" return { ATTR_SN: self._sn, ATTR_NAME: self._name, + ATTR_PLANS: ",".join(self._plans), + ATTR_WATER_LEVELS: ",".join(self._water_levels) } + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + return self._fan_speed + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + return self._fan_speeds + @property def supported_features(self): """Flag supported features.""" @@ -117,7 +201,8 @@ def start(self): def turn_on(self, **kwargs): """Turn the vacuum on.""" - self._conga_client.start(self._sn) + self._conga_client.start( + self._sn, self._fan_speeds.index(self._fan_speed)) self.schedule_update_ha_state() def turn_off(self, **kwargs): @@ -129,12 +214,51 @@ def return_to_base(self, **kwargs): self._conga_client.home(self._sn) self.schedule_update_ha_state() + def set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + + _LOGGER.info(f"Setting fan speed to {fan_speed}") + + self._conga_client.set_fan_speed( + self._sn, self._fan_speeds.index(fan_speed)) + self._fan_speed = fan_speed + self.schedule_update_ha_state() + + def send_command(self, command, params=None, **kwargs): + """Send raw command.""" + _LOGGER.info(f"Sending command {command} with params {params}") + + if command == "start_plan": + plan = params["plan"] + if plan in self._plans: + self._conga_client.start_plan( + self._sn, self._plans.index(plan)) + self.schedule_update_ha_state() + else: + _LOGGER.error( + f"Plan {plan} not found. Allowed plans: {self._plans}") + elif command == "set_water_level": + water_level = params["water_level"] + if water_level in self._water_levels: + self._conga_client.set_water_level( + self._sn, self._water_levels.index(water_level)) + self.schedule_update_ha_state() + else: + _LOGGER.error( + f"Invalid water level: {water_level}. Allowed water levels: {self._water_levels}") + else: + _LOGGER.error(f"Unknown command {command}") + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the next bus information.""" try: response = self._conga_client.status(self._sn) - self._state = response["state"]["reported"]["mode"] + self._state_all = response["state"]["reported"] + + self._battery = self._state_all["elec"] + self._state = self._state_all["mode"] + self._plans = self._conga_client.list_plans(self._sn) except HTTPError: _LOGGER.error( "Unable to fetch data from API" diff --git a/test.py b/test.py index e7bd068..b81acc8 100644 --- a/test.py +++ b/test.py @@ -1,10 +1,22 @@ -import datetime -from dateutil.tz import tzlocal +import os +from dotenv import load_dotenv +import custom_components.cecotec_conga as Conga -a = datetime.datetime(2022, 7, 10, 13, 47, 36, tzinfo = tzlocal()).timestamp() -b = datetime.datetime.now().timestamp() +load_dotenv() -print(a) -print(b) -print(a>b) -print(b>a) +conga_username = os.environ['CONGA_USERNAME'] +conga_password = os.environ['CONGA_PASSWORD'] +conga_sn = os.environ['CONGA_SN'] + +print(f"\nLogging in as {conga_username}") +conga_client = Conga.Conga(conga_username, conga_password) + +print(f"\nGetting status for {conga_sn}") +print(conga_client.list_vacuums()) +print(conga_client.status(conga_sn)) + +print(f"\nGetting plans for {conga_sn}") +print(conga_client.list_plans(conga_sn)) + +# print(f"\nStarting plan for {conga_sn}") +# print(conga_client.start_plan(conga_sn, "Quick"))