diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd52c5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build +dist +__pycache__ +*.lock +*.spec \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7576ca3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:latest +ARG DEBIAN_FRONTEND=noninteractive +RUN apt update && apt upgrade -y +RUN apt-get -qq install python3.12-full pipenv -y + diff --git a/LICENSE b/LICENSE deleted file mode 100644 index b192417..0000000 --- a/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2019 Richard Mitchell - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..9db04f6 --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +cryptograpy = "*" +pyinstaller = "*" + +[dev-packages] + +[requires] +python_version = "3.12" diff --git a/README.md b/README.md deleted file mode 100644 index 1085ff5..0000000 --- a/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Eufy Robovac control for Python - -Work in progress! - -## Installation -Pre-requisites: -* openssl (used for encryption) - -``` -git clone https://github.com/mitchellrj/eufy_robovac.git -cd eufy_robovac -python3 -m venv . -bin/pip install -e . -``` - -## Demo usage -``` -bin/demo DEVICE_ID IP LOCAL_KEY -``` - -The demo: -* connects to your device, -* prints its state out, -* starts cleaning, -* waits 30 seconds, -* sends the device home, -* waits 10 seconds, -* disconnects & exits - -## Home Assistant integration - -**EXPERIMENTAL!** - -Copy the contents of the `eufy_robovac` folder to `custom_components/eufy_vacuum` in your home assistant configuration directory. Then add the following to your configuration file: - -``` -eufy_vacuum: - devices: - - name: Robovac - address: 192.168.1.80 - access_token: YOUR LOCAL KEY HERE - id: YOUR DEVICE ID HERE - type: T2118 -``` diff --git a/compile b/compile new file mode 100755 index 0000000..ff73221 --- /dev/null +++ b/compile @@ -0,0 +1,8 @@ +#!/bin/bash + + +NAME="compile" + + +docker buildx build . -t $NAME +docker run -v $PWD:/opt/compile --rm -w "/opt/compile" $NAME bash -c "pipenv install;pipenv run pyinstaller -F src/vac.py" \ No newline at end of file diff --git a/eufy_robovac/demo.py b/eufy_robovac/demo.py deleted file mode 100644 index e822dd1..0000000 --- a/eufy_robovac/demo.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright 2019 Richard Mitchell -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import logging -import pprint -import sys - -from eufy_robovac.robovac import Robovac - - -logging.basicConfig(level=logging.DEBUG) - - -async def connected_callback(message, device): - print("Connected. Current device state:") - pprint.pprint(device.state) - - -async def cleaning_started_callback(message, device): - print("Cleaning started.") - - -async def async_main(device_id, ip, local_key=None, *args, **kwargs): - r = Robovac(device_id, ip, local_key, *args, **kwargs) - await r.async_connect(connected_callback) - await asyncio.sleep(1) - print("Starting cleaning...") - await r.async_start_cleaning(cleaning_started_callback) - await asyncio.sleep(5) - print("Pausing...") - r.play_pause = False - await asyncio.sleep(1) - print("Disconnecting...") - await r.async_disconnect() - - -def main(*args, **kwargs): - if not args: - args = sys.argv[1:] - asyncio.run(async_main(*args, **kwargs)) - - -if __name__ == '__main__': - main(*sys.argv[1:]) diff --git a/eufy_robovac/platform.py b/eufy_robovac/platform.py deleted file mode 100644 index ca3a145..0000000 --- a/eufy_robovac/platform.py +++ /dev/null @@ -1,45 +0,0 @@ -### HA support - -"""Support for Eufy devices.""" -import logging - -import voluptuous as vol - -from homeassistant.const import ( - CONF_ACCESS_TOKEN, CONF_ADDRESS, CONF_DEVICES, CONF_NAME, - CONF_ID, CONF_TYPE) -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'eufy_vacuum' - -DEVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_ADDRESS): cv.string, - vol.Optional(CONF_ACCESS_TOKEN): cv.string, - vol.Required(CONF_ID): cv.string, - vol.Required(CONF_TYPE): cv.string, - vol.Optional(CONF_NAME): cv.string -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_DEVICES, default=[]): - vol.All(cv.ensure_list, [DEVICE_SCHEMA]), - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up Eufy devices.""" - for device_info in config.get(DOMAIN, {}).get(CONF_DEVICES, []): - device = {} - device['address'] = device_info[CONF_ADDRESS] - device['local_key'] = device_info.get(CONF_ACCESS_TOKEN) - device['device_id'] = device_info[CONF_ID] - device['name'] = device_info.get(CONF_NAME) - device['model'] = device_info[CONF_TYPE] - discovery.load_platform(hass, 'vacuum', DOMAIN, device, config) - - return True diff --git a/eufy_robovac/vacuum.py b/eufy_robovac/vacuum.py deleted file mode 100644 index 0e328fd..0000000 --- a/eufy_robovac/vacuum.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Support for Eufy vacuum cleaners.""" -import logging - -from homeassistant.components.vacuum import ( - PLATFORM_SCHEMA, - STATE_CLEANING, STATE_DOCKED, STATE_IDLE, STATE_PAUSED, STATE_RETURNING, - STATE_ERROR, - SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, - SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_STATUS, SUPPORT_START, - SUPPORT_TURN_ON, SUPPORT_TURN_OFF, - VacuumEntity) - - -from . import robovac - -_LOGGER = logging.getLogger(__name__) - - -FAN_SPEED_OFF = 'Off' -FAN_SPEED_STANDARD = 'Standard' -FAN_SPEED_BOOST_IQ = 'Boost IQ' -FAN_SPEED_MAX = 'Max' -FAN_SPEEDS = { - robovac.CleanSpeed.NO_SUCTION: FAN_SPEED_OFF, - robovac.CleanSpeed.STANDARD: FAN_SPEED_STANDARD, - robovac.CleanSpeed.BOOST_IQ: FAN_SPEED_BOOST_IQ, - robovac.CleanSpeed.MAX: FAN_SPEED_MAX, -} - - -SUPPORT_ROBOVAC_T2118 = ( - SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT | SUPPORT_FAN_SPEED | SUPPORT_LOCATE | - SUPPORT_PAUSE | SUPPORT_RETURN_HOME | SUPPORT_START | SUPPORT_STATUS | - SUPPORT_TURN_OFF | SUPPORT_TURN_ON -) - - -MODEL_CONFIG = { - 'T2118': { - 'fan_speeds': FAN_SPEEDS, - 'support': SUPPORT_ROBOVAC_T2118 - } -} - - -def setup_platform(hass, config, add_entities, device_config=None): - """Set up Eufy vacuum cleaners.""" - if device_config is None: - return - add_entities([EufyVacuum(device_config)], True) - - -class EufyVacuum(VacuumEntity): - """Representation of a Eufy vacuum cleaner.""" - - def __init__(self, device_config): - """Initialize the light.""" - - try: - self._config = MODEL_CONFIG[device_config['model'].upper()] - except KeyError: - raise RuntimeError("Unsupported model {}".format( - device_config['model'])) - - self._fan_speed_reverse_mapping = { - v: k for k, v in self._config['fan_speeds'].items()} - self._device_id = device_config['device_id'] - self.robovac = robovac.Robovac( - device_config['device_id'], device_config['address'], - device_config['local_key']) - self._name = device_config['name'] - - async def async_update(self): - """Synchronise state from the vacuum.""" - await self.robovac.async_get() - - @property - def unique_id(self): - """Return the ID of this vacuum.""" - return self._device_id - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self.robovac.work_status == robovac.WorkStatus.RUNNING - - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return self._config['support'] - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - return self._config['fan_speeds'].get( - self.robovac.clean_speed, FAN_SPEED_OFF) - - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return list(self._config['fan_speeds'].values()) - - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self.robovac.battery_level - - @property - def status(self): - """Return the status of the vacuum cleaner.""" - if self.robovac.error_code != robovac.ErrorCode.NO_ERROR: - return STATE_ERROR - elif self.robovac.go_home: - return STATE_RETURNING - elif self.robovac.work_status == robovac.WorkStatus.RUNNING: - return STATE_CLEANING - elif self.robovac.work_status == robovac.WorkStatus.CHARGING: - return STATE_DOCKED - elif self.robovac.work_status == robovac.WorkStatus.RECHARGE_NEEDED: - # Should be captured by `go_home` above, but just in case - return STATE_RETURNING - elif self.robovac.work_status == robovac.WorkStatus.SLEEPING: - return STATE_IDLE - elif self.robovac.work_status == robovac.WorkStatus.STAND_BY: - return STATE_IDLE - elif self.robovac.work_status == robovac.WorkStatus.COMPLETED: - return STATE_DOCKED - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return True - - async def async_return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - await self.robovac.async_go_home() - - async def async_clean_spot(self, **kwargs): - """Perform a spot clean-up.""" - await self.robovac.async_set_work_mode(robovac.WorkMode.SPOT) - - async def async_locate(self, **kwargs): - """Locate the vacuum cleaner.""" - await self.robovac.async_find_robot() - - async def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - clean_speed = self._fan_speed_reverse_mapping[fan_speed] - await self.robovac.async_set_clean_speed(clean_speed) - - async def async_turn_on(self, **kwargs): - """Turn the vacuum on.""" - await self.robovac.async_set_work_mode(robovac.WorkMode.AUTO) - - async def async_turn_off(self, **kwargs): - """Turn the vacuum off and return to home.""" - await self.async_return_to_base() - - async def async_start(self, **kwargs): - """Resume the cleaning cycle.""" - await self.async_turn_on() - - async def async_resume(self, **kwargs): - """Resume the cleaning cycle.""" - await self.robovac.async_play() - - async def async_pause(self, **kwargs): - """Pause the cleaning cycle.""" - await self.robovac.async_pause() - - async def async_start_pause(self, **kwargs): - """Pause the cleaning task or resume it.""" - if self.robovac.play_pause: - await self.async_pause() - else: - await self.async_play() diff --git a/setup.py b/setup.py deleted file mode 100644 index acd49f4..0000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright 2019 Richard Mitchell -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -import re -from setuptools import setup, find_packages -import sys -import warnings - -dynamic_requires = [] - -setup( - name='eufy_robovac', - version="0.0", - author='Richard Mitchell', - author_email='eufy-robovac@mitch.org.uk', - url='http://github.com/mitchellrj/eufy_robovac', - packages=find_packages(), - scripts=[], - description='Python API for controlling Eufy robotic vacuum cleaners', - classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - ], - install_requires=[ - 'cryptography' - ], - entry_points = { - 'console_scripts': ['demo=eufy_robovac.demo:main'], - } -) diff --git a/eufy_robovac/__init__.py b/src/robovac/__init__.py similarity index 86% rename from eufy_robovac/__init__.py rename to src/robovac/__init__.py index 0393db0..74403b4 100644 --- a/eufy_robovac/__init__.py +++ b/src/robovac/__init__.py @@ -14,9 +14,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .robovac import Robovac - -try: - from .platform import * -except ImportError: - pass +from .robovac import Robovac \ No newline at end of file diff --git a/eufy_robovac/property.py b/src/robovac/property.py similarity index 100% rename from eufy_robovac/property.py rename to src/robovac/property.py diff --git a/eufy_robovac/robovac.py b/src/robovac/robovac.py similarity index 96% rename from eufy_robovac/robovac.py rename to src/robovac/robovac.py index 6e67271..0731d53 100644 --- a/eufy_robovac/robovac.py +++ b/src/robovac/robovac.py @@ -15,6 +15,7 @@ # limitations under the License. import logging +import asyncio from .property import DeviceProperty, StringEnum from .tuya import TuyaDevice @@ -86,6 +87,7 @@ class Robovac(TuyaDevice): BATTERY_LEVEL = '104' ERROR_CODE = '106' + power = DeviceProperty(POWER) play_pause = DeviceProperty(PLAY_PAUSE) direction = DeviceProperty(DIRECTION) @@ -117,3 +119,6 @@ class Robovac(TuyaDevice): async def async_set_clean_speed(self, clean_speed, callback=None): await self.async_set({self.CLEAN_SPEED: str(clean_speed)}, callback) + + async def async_move(self, direction, callback=None): + await self.async_set({self.DIRECTION: str(direction)}, callback) diff --git a/eufy_robovac/tuya.py b/src/robovac/tuya.py similarity index 100% rename from eufy_robovac/tuya.py rename to src/robovac/tuya.py diff --git a/src/vac.py b/src/vac.py new file mode 100755 index 0000000..83d0f43 --- /dev/null +++ b/src/vac.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +import asyncio +import signal +import logging +import pprint +import sys +import argparse +from enum import Enum +import os + + +from robovac.robovac import Robovac + + +stop_event = asyncio.Event() + + +def signal_handler(): + print(f'\nExiting...') + stop_event.set() + + +def parse_robovac_config(path="/home/freyja/Desktop/robovac.conf"): + config = {} + + + if not os.path.exists(path): + raise FileNotFoundError(f"Config file not found at {path}") + + + with open(path, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): # Skip empty lines and comments + if '=' in line: + key, value = map(str.strip, line.split('=', 1)) + config[key] = value + + + required_keys = ['device_id', 'ip', 'local_code'] + if not all(key in config for key in required_keys): + missing = [key for key in required_keys if key not in config] + raise ValueError(f"Missing keys in config: {', '.join(missing)}") + + + class credentials(Enum): + device_id = config['device_id'] + ip = config['ip'] + local_code = config['local_code'] + + + return credentials + + +async def callback(message,device): + print(message) + pprint.pprint(device.state) + + +async def stepper(time): + total_seconds = time * 60 + step = 1 + elapsed = 0 + while elapsed < total_seconds and not stop_event.is_set(): + await asyncio.sleep(step) + elapsed += step + return True + + +async def async_auto_clean(r: Robovac,time): + await r.async_start_cleaning(callback) + await stepper(time) + await r.async_pause() + + +async def async_go_home(r: Robovac): + await r.async_go_home(callback) + + +async def async_pause(r: Robovac): + await r.async_pause(callback) + + +async def async_main(device_id,ip,local_code,time,go_home,debug,pause): + asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, signal_handler) + asyncio.get_event_loop().add_signal_handler(signal.SIGINT, signal_handler) + r = Robovac(device_id,ip,local_code) + await r.async_connect(callback) + await asyncio.sleep(2) + + + if debug: + while not stop_event.is_set(): + if not go_home: + print("Auto cleaning") + await stepper(time) + if go_home: + print("Go home") + if pause: + print("Pause") + else: + while not stop_event.is_set(): + if not go_home: + await async_auto_clean(r, time = int(time)) + await async_go_home(r) + if go_home: + await async_go_home(r) + if pause: + await async_pause(r) + + + if not debug: + if stop_event.is_set() and not r.go_home and not pause: await async_go_home(r) + if r._connected: await r.async_disconnect() + + +def main(*args, **kwargs): + early_parser = argparse.ArgumentParser(add_help=False) + early_parser.add_argument('-c', '--config', help="Path to config file", default="/etc/robovac.conf") + early_parser.add_argument('--device_id') + early_parser.add_argument('--ip') + early_parser.add_argument('--local_code') + + + early_args, remaining_args = early_parser.parse_known_args() + + + use_config = not (early_args.device_id or early_args.ip or early_args.local_code) + + + defaults = {} + if use_config: + try: + creds = parse_robovac_config(early_args.config) + defaults = { + "device_id": creds.device_id.value, + "ip": creds.ip.value, + "local_code": creds.local_code.value + } + except Exception: + defaults = {} + + + if not use_config: + print("Configuration skipped") + + + parser = argparse.ArgumentParser(description="Control a Robovac device.") + parser.add_argument('-c', '--config', help="Path to config file", default=early_args.config) + parser.add_argument('--device_id', help="Device ID", default=None if early_args.device_id else defaults.get('device_id')) + parser.add_argument('--ip', help="Device IP address", default=None if early_args.ip else defaults.get('ip')) + parser.add_argument('--local_code', help="Secret key obtained from eufy", default=None if early_args.local_code else defaults.get('local_code')) + parser.add_argument('--time', '-t', type=int, default=20, help="Cleaning time in minutes") + parser.add_argument('--pause','-p', action='store_true', dest="pause", default=False, help="Pause vacuum") + parser.add_argument('--home', '-b', action='store_true', dest="go_home", default=False, help="Go home") + parser.add_argument('--debug','-d', action='store_true', dest="debug", default=False, help="Enter debugging mode (won't send commands to vacuum)") + parser.add_argument('--verbose','-v', action='store_true', dest="verbose", default=False, help="Enable verbose logs") + parser.add_argument('--quiet','-q', action='store_true', dest="quiet", default=False, help="Quiet logs") + args = parser.parse_args() + + + if args.quiet and args.verbose: + parser.error("Cannot set quiet and verbose mode simultaneously.") + elif args.verbose: + logging.basicConfig(level=logging.DEBUG) + elif args.quiet: + logging.basicConfig(level=logging.CRITICAL) + else: + logging.basicConfig(level=logging.INFO) + + + missing = [key for key in ['device_id', 'ip', 'local_code'] if getattr(args, key) is None] + if missing: + parser.error(f"Missing required argument(s): {', '.join(missing)}") + + + try: + asyncio.run(async_main(args.device_id, args.ip, args.local_code, args.time, args.go_home,args.debug,args.pause)) + except Exception as e: + if args.debug or args.verbose: + print(e) + else: + print("An error occured.") + + +if __name__ == "__main__": + main(*sys.argv[1:]) \ No newline at end of file