diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index fea8124..72271cd 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -49,3 +49,6 @@ version-resolver: template: | [![Downloads for this release](https://img.shields.io/github/downloads/And3rsL/Deebotozmo/$RESOLVED_VERSION/total.svg)](https://github.com/And3rsL/Deebotozmo/releases/$RESOLVED_VERSION) $CHANGES + + **Like my work and want to support me?** + Buy Me A Coffee diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6458d85..0d9a450 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: pip install -r requirements.txt - name: Run pre-commit checks run: | - pre-commit run --hook-stage manual --all-files --show-diff-on-failure + SKIP=no-commit-to-branch pre-commit run --hook-stage manual --all-files --show-diff-on-failure - name: Pylint review run: | pylint custom_components diff --git a/.gitignore b/.gitignore index 492cda3..9c74cfc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ venv .venv .coverage .idea +.mypy_cache +.pytest_cache diff --git a/README.md b/README.md index 1c34245..1c27194 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) -
Buy Me A Coffee +
Buy Me A Coffee # Home Assistant Custom Component for Ecovacs vacuum cleaner @@ -88,12 +88,7 @@ This integration expose a number of sensors - sensor.ROBOTNAME_stats_type (Clean Type - Auto|Manual|Custom) - sensor.ROBOTNAME_water_level (Current set water level, you can get fan speed by vacuum attributes) - binary_sensor.ROBOTNAME_mop_attached (On/off is mop is attached) - -### Live Map: - -If is true live_map it will try to generate a live map camera feed - -- camera.ROBOTNAME_liveMap +- camera.ROBOTNAME_liveMap The live map ## UI examples @@ -115,55 +110,132 @@ Get room numbers dynamically, very helpful if your robot is multi-floor or if yo ## Example commands: +```yaml +# Clean all +service: vacuum.start +target: + entity_id: vacuum.YOUR_ROBOT_NAME +``` + Relocate Robot (the little GPS icon in the APP) ```yaml # Relocate Robot -entity_id: vacuum.YOUR_ROBOT_NAME -command: relocate +service: vacuum.send_command +target: + entity_id: vacuum.YOUR_ROBOT_NAME +data: + command: relocate ``` You can clean certain area by specify it in rooms params, you can find room number under vacuum attributes ```yaml # Clean Area -entity_id: vacuum.YOUR_ROBOT_NAME -command: spot_area -params: - rooms: 10,14 - cleanings: 1 +service: vacuum.send_command +target: + entity_id: vacuum.YOUR_ROBOT_NAME +data: + command: spot_area + params: + rooms: 10,14 + cleanings: 1 ``` ```yaml # Customize Clean -# You can get coordinates with fiddler and the official APP [Advance User] -entity_id: vacuum.YOUR_ROBOT_NAME -command: custom_area -params: - coordinates: -1339,-1511,296,-2587 +service: vacuum.send_command +target: + entity_id: vacuum.YOUR_ROBOT_NAME +data: + command: custom_area + params: + coordinates: -1339,-1511,296,-2587 ``` +Use the app to send the vacuum to a custom area and afterwards search your logs for `Last custom area values (x1,y1,x2,y2):` entries to get the coordinates. + ```yaml # Set Water Level # Possible amount values: low|medium|high|ultrahigh -entity_id: vacuum.YOUR_ROBOT_NAME -command: set_water -params: - amount: ultrahigh +service: vacuum.send_command +target: + entity_id: vacuum.YOUR_ROBOT_NAME +data: + command: set_water + params: + amount: ultrahigh +``` + +### Custom commands + +It's also possible to send commands, which are not officially supported by this integration yet. +For that use also the `vacuum.send_command` service and you will get the response as `deebot_custom_command` event. + +Example with the command `getAdvancedMode` + +```yaml +service: vacuum.send_command +target: + entity_id: vacuum.YOUR_ROBOT_NAME +data: + command: getAdvancedMode ``` +When calling the above example you will get the event `deebot_custom_command` similar to: + +```json +{ + "event_type": "deebot_custom_command", + "data": { + "name": "getAdvancedMode", + "response": { + "header": { + "pri": 1, + "tzm": 480, + "ts": "1295442034442", + "ver": "0.0.1", + "fwVer": "1.8.2", + "hwVer": "0.1.1" + }, + "body": { + "code": 0, + "msg": "ok", + "data": { + "enable": 1 + } + } + } + }, + "origin": "LOCAL", + "time_fired": "2021-10-05T21:45:40.294958+00:00", + "context": { + "id": "[REMOVED]", + "parent_id": null, + "user_id": null + } +} +``` + +The interesting part is normally inside `response->body->data`. In the example above it means I have enabled the advanced mode. + +## Services + +This integration adds the service `deebot.refresh`, which allows to manually refresh some parts of the vacuum. +In addition to the vacuum entity you must specify part you want to refresh. +An example call looks like: + ```yaml -# Clean -#Possible values: auto -entity_id: vacuum.YOUR_ROBOT_NAME -command: auto_clean -params: - type: auto +service: deebot.refresh +data: + part: Status +target: + entity_id: vacuum.YOUR_ROBOT_NAME ``` -### Issues +## Issues -If you have an issue with this component, please file a GitHub Issue and include your Home Assistant logs in the report. To get full debug output from both the Ecovacs integration and the underlying deebotozmo library, place this in your configuration.yaml file: +If you have an issue with this component, please file a GitHub Issue and include y`ur Home Assistant logs in the report. To get full debug output from both the Ecovacs integration and the underlying deebotozmo library, place this in your configuration.yaml file: ```yaml logger: @@ -176,6 +248,6 @@ logger: YAML Warning: doing this will cause your authentication token to visible in your log files. Be sure to remove any tokens and other authentication details from your log before posting them in an issue. -### Misc +## Misc An SVG of the Deebot 950 can be found under [images](docs/images/deebot950.svg) diff --git a/custom_components/deebot/const.py b/custom_components/deebot/const.py index 4263db6..3b92ee2 100644 --- a/custom_components/deebot/const.py +++ b/custom_components/deebot/const.py @@ -49,12 +49,12 @@ DEEBOT_DEVICES = f"{DOMAIN}_devices" VACUUMSTATE_TO_STATE = { - VacuumState.STATE_IDLE: STATE_IDLE, - VacuumState.STATE_CLEANING: STATE_CLEANING, - VacuumState.STATE_RETURNING: STATE_RETURNING, - VacuumState.STATE_DOCKED: STATE_DOCKED, - VacuumState.STATE_ERROR: STATE_ERROR, - VacuumState.STATE_PAUSED: STATE_PAUSED, + VacuumState.IDLE: STATE_IDLE, + VacuumState.CLEANING: STATE_CLEANING, + VacuumState.RETURNING: STATE_RETURNING, + VacuumState.DOCKED: STATE_DOCKED, + VacuumState.ERROR: STATE_ERROR, + VacuumState.PAUSED: STATE_PAUSED, } LAST_ERROR = "last_error" @@ -69,3 +69,5 @@ EVENT_LIFE_SPAN = "Life spans" EVENT_ROOMS = "Rooms" EVENT_MAP = "Map" + +EVENT_CUSTOM_COMMAND = "deebot_custom_command" diff --git a/custom_components/deebot/hub.py b/custom_components/deebot/hub.py index 70299f1..eb1fccc 100644 --- a/custom_components/deebot/hub.py +++ b/custom_components/deebot/hub.py @@ -3,7 +3,7 @@ import logging import random import string -from typing import Any, List, Mapping, Optional +from typing import Any, List, Mapping import aiohttp from aiohttp import ClientError @@ -48,7 +48,10 @@ def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]): random.choice(string.ascii_uppercase + string.digits) for _ in range(12) ) - self._mqtt: Optional[EcovacsMqtt] = None + self._mqtt: EcovacsMqtt = EcovacsMqtt( + continent=self._continent, country=self._country + ) + self._ecovacs_api = EcovacsAPI( self._session, device_id, @@ -62,12 +65,13 @@ def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]): async def async_setup(self) -> None: """Init hub.""" try: + if self._mqtt: + self.disconnect() + await self._ecovacs_api.login() auth = await self._ecovacs_api.get_request_auth() - self._mqtt = EcovacsMqtt( - auth, continent=self._continent, country=self._country - ) + await self._mqtt.initialize(auth) devices = await self._ecovacs_api.get_devices() @@ -97,8 +101,7 @@ async def async_setup(self) -> None: def disconnect(self) -> None: """Disconnect hub.""" - if self._mqtt: - self._mqtt.disconnect() + self._mqtt.disconnect() @property def name(self) -> str: diff --git a/custom_components/deebot/manifest.json b/custom_components/deebot/manifest.json index ddab313..d6d5a22 100644 --- a/custom_components/deebot/manifest.json +++ b/custom_components/deebot/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://github.com/And3rsL/Deebot-for-Home-Assistant", "issue_tracker": "https://github.com/And3rsL/Deebot-for-Home-Assistant/issues", - "requirements": ["deebotozmo==3.0.0b1"], + "requirements": ["deebotozmo==3.0.0"], "codeowners": ["@And3rsL", "@edenhaus"], "iot_class": "cloud_polling" } diff --git a/custom_components/deebot/sensor.py b/custom_components/deebot/sensor.py index 607eca5..a57ef5b 100644 --- a/custom_components/deebot/sensor.py +++ b/custom_components/deebot/sensor.py @@ -2,15 +2,12 @@ import logging from typing import Any, Dict, Optional -from deebotozmo.constants import ( - COMPONENT_FILTER, - COMPONENT_MAIN_BRUSH, - COMPONENT_SIDE_BRUSH, -) +from deebotozmo.commands.life_span import LifeSpan from deebotozmo.event_emitter import EventListener from deebotozmo.events import ( CleanLogEvent, ErrorEvent, + LifeSpanEvent, StatsEvent, StatusEvent, WaterInfoEvent, @@ -45,9 +42,9 @@ async def async_setup_entry( new_devices.append(DeebotLastErrorSensor(vacbot)) # Components - new_devices.append(DeebotComponentSensor(vacbot, COMPONENT_MAIN_BRUSH)) - new_devices.append(DeebotComponentSensor(vacbot, COMPONENT_SIDE_BRUSH)) - new_devices.append(DeebotComponentSensor(vacbot, COMPONENT_FILTER)) + new_devices.append(DeebotComponentSensor(vacbot, LifeSpan.BRUSH)) + new_devices.append(DeebotComponentSensor(vacbot, LifeSpan.SIDE_BRUSH)) + new_devices.append(DeebotComponentSensor(vacbot, LifeSpan.FILTER)) # Stats new_devices.append(DeebotStatsSensor(vacbot, "area")) @@ -148,19 +145,20 @@ class DeebotComponentSensor(DeebotBaseSensor): _attr_native_unit_of_measurement = "%" - def __init__(self, vacuum_bot: VacuumBot, device_id: str): + def __init__(self, vacuum_bot: VacuumBot, component: LifeSpan): """Initialize the Sensor.""" + device_id = component.value super().__init__(vacuum_bot, device_id) self._attr_icon = ( - "mdi:air-filter" if device_id == COMPONENT_FILTER else "mdi:broom" + "mdi:air-filter" if component == LifeSpan.FILTER else "mdi:broom" ) - self._id = device_id + self._id: str = device_id async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_event(event: Dict[str, float]) -> None: + async def on_event(event: LifeSpanEvent) -> None: value = event.get(self._id, None) if value: self._attr_native_value = value diff --git a/custom_components/deebot/vacuum.py b/custom_components/deebot/vacuum.py index 291bd31..3a7f99e 100644 --- a/custom_components/deebot/vacuum.py +++ b/custom_components/deebot/vacuum.py @@ -1,31 +1,24 @@ """Support for Deebot Vaccums.""" +import dataclasses import logging from typing import Any, Dict, List, Mapping, Optional import voluptuous as vol from deebotozmo.commands import ( Charge, - CleanCustomArea, - CleanPause, - CleanResume, - CleanSpotArea, - CleanStart, - CleanStop, - Command, + Clean, + FanSpeedLevel, PlaySound, - Relocate, SetFanSpeed, - SetWaterLevel, -) -from deebotozmo.constants import ( - FAN_SPEED_MAX, - FAN_SPEED_MAXPLUS, - FAN_SPEED_NORMAL, - FAN_SPEED_QUIET, + SetRelocationState, + SetWaterInfo, ) +from deebotozmo.commands.clean import CleanAction, CleanArea, CleanMode +from deebotozmo.commands.custom import CustomCommand from deebotozmo.event_emitter import EventListener from deebotozmo.events import ( BatteryEvent, + CustomCommandEvent, ErrorEvent, FanSpeedEvent, RoomsEvent, @@ -58,6 +51,7 @@ DOMAIN, EVENT_BATTERY, EVENT_CLEAN_LOGS, + EVENT_CUSTOM_COMMAND, EVENT_ERROR, EVENT_FAN_SPEED, EVENT_LIFE_SPAN, @@ -187,12 +181,16 @@ async def on_error(event: ErrorEvent) -> None: self._last_error = event self.async_write_ha_state() + async def on_custom_command(event: CustomCommandEvent) -> None: + self.hass.bus.fire(EVENT_CUSTOM_COMMAND, dataclasses.asdict(event)) + listeners: List[EventListener] = [ self._device.events.status.subscribe(on_status), self._device.events.battery.subscribe(on_battery), self._device.events.rooms.subscribe(on_rooms), self._device.events.fan_speed.subscribe(on_fan_speed), self._device.events.error.subscribe(on_error), + self._device.events.custom_command.subscribe(on_custom_command), ] self.async_on_remove(lambda: _unsubscribe_listeners(listeners)) @@ -220,7 +218,7 @@ def fan_speed(self) -> Optional[str]: @property def fan_speed_list(self) -> List[str]: """Get the list of available fan speed steps of the vacuum cleaner.""" - return [FAN_SPEED_QUIET, FAN_SPEED_NORMAL, FAN_SPEED_MAX, FAN_SPEED_MAXPLUS] + return [level.display_name for level in FanSpeedLevel] @property def extra_state_attributes(self) -> Optional[Mapping[str, Any]]: @@ -264,18 +262,15 @@ async def async_return_to_base(self, **kwargs: Any) -> None: async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self._device.execute_command(CleanStop()) + await self._device.execute_command(Clean(CleanAction.STOP)) async def async_pause(self) -> None: """Pause the vacuum cleaner.""" - await self._device.execute_command(CleanPause()) + await self._device.execute_command(Clean(CleanAction.PAUSE)) async def async_start(self) -> None: """Start the vacuum cleaner.""" - if self._device.status.state == VacuumState.STATE_PAUSED: - await self._device.execute_command(CleanResume()) - else: - await self._device.execute_command(CleanStart()) + await self._device.execute_command(Clean(CleanAction.START)) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" @@ -287,32 +282,37 @@ async def async_send_command( """Send a command to a vacuum cleaner.""" _LOGGER.debug("async_send_command %s with %s", command, params) - if command == "relocate": - await self._device.execute_command(Relocate()) + if command in ["relocate", SetRelocationState.name]: + await self._device.execute_command(SetRelocationState()) elif command == "auto_clean": clean_type = params.get("type", "auto") if params else "auto" - await self._device.execute_command(CleanStart(clean_type)) + if clean_type == "auto": + _LOGGER.warning('DEPRECATED! Please use "vacuum.start" instead.') + await self.async_start() elif command in ["spot_area", "custom_area", "set_water"]: if params is None: raise RuntimeError("Params are required!") - if command == "spot_area": + if command in "spot_area": await self._device.execute_command( - CleanSpotArea( - area=str(params["rooms"]), cleanings=params.get("cleanings", 1) + CleanArea( + mode=CleanMode.SPOT_AREA, + area=str(params["rooms"]), + cleanings=params.get("cleanings", 1), ) ) elif command == "custom_area": await self._device.execute_command( - CleanCustomArea( - map_position=str(params["coordinates"]), + CleanArea( + mode=CleanMode.CUSTOM_AREA, + area=str(params["coordinates"]), cleanings=params.get("cleanings", 1), ) ) elif command == "set_water": - await self._device.execute_command(SetWaterLevel(params["amount"])) + await self._device.execute_command(SetWaterInfo(params["amount"])) else: - await self._device.execute_command(Command(command, params)) + await self._device.execute_command(CustomCommand(command, params)) async def _service_refresh(self, part: str) -> None: """Service to manually refresh.""" diff --git a/requirements.txt b/requirements.txt index f11cc9d..6f7522c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -deebotozmo==3.0.0b1 +deebotozmo==3.0.0 homeassistant==2021.9.7 # Test requirements