Adapt this repo to my needs

- remove homeassistant stuff
- flesh out a featureful cli script
This commit is contained in:
2025-09-09 02:06:13 -07:00
parent e08db60e0c
commit 504edf23e9
15 changed files with 224 additions and 395 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
build
dist
__pycache__
*.lock
*.spec

5
Dockerfile Normal file
View File

@@ -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

13
LICENSE
View File

@@ -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.

13
Pipfile Normal file
View File

@@ -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"

View File

@@ -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
```

8
compile Executable file
View File

@@ -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"

View File

@@ -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:])

View File

@@ -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

View File

@@ -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()

View File

@@ -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'],
}
)

View File

@@ -15,8 +15,3 @@
# limitations under the License.
from .robovac import Robovac
try:
from .platform import *
except ImportError:
pass

View File

@@ -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)

187
src/vac.py Executable file
View File

@@ -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:])