Compare commits

...

67 Commits

Author SHA1 Message Date
Luke Bonaccorsi f397319070 fix: log eufy device info when device not found on tuya 2024-03-22 23:45:02 +00:00
Luke Morrigan 7e60ecd2b4
Revert "Fix the missing error key" (#69)
* Revert "Fix set fan speed"

This reverts commit 8676cb3fad.

* Revert "Add debug"

This reverts commit 94ae2b55ea.

* Revert "Add debug infos"

This reverts commit 4f1bdb3bac.

* Revert "Fix the missing error key"

This reverts commit 8e6e311bfc.

* fix: re-add consumables code
2024-03-22 00:36:38 +00:00
Luke Bonaccorsi c687f111eb fix: fix reference to incorrect key 2024-03-20 01:17:01 +00:00
Luke Bonaccorsi b180896a9c chore: fix releaserc 2024-03-08 14:30:54 +00:00
Luke Bonaccorsi 2968d722f8 fix: add more routes for trying to find the tuya region 2024-03-08 14:28:03 +00:00
Luke Bonaccorsi cdcad837b7 chore: add ability to create beta releases [skip ci] 2024-03-06 12:35:55 +00:00
Luke Bonaccorsi 2c741fe32e fix: print stack trace on connection reset 2024-03-04 01:07:44 +00:00
Luke Bonaccorsi 3dd4a7b0e0 fix: add extra logging 2024-03-03 04:18:14 +00:00
Luke Bonaccorsi c251501aed fix: force disconnect on connection reset 2024-03-01 16:53:48 +00:00
Luke Bonaccorsi 0b18494fb1 fix: send eof to reader when disconnecting 2024-03-01 14:28:23 +00:00
Luke Bonaccorsi ac0dbdd11a fix: missed underscores 🤦‍♂️ 2024-02-28 18:32:21 +00:00
Luke Bonaccorsi 96a155d378 fix: adjust times for refresh, ping and timeout 2024-02-27 14:39:01 +00:00
Luke Bonaccorsi 3e2b923255 fix: fixed issue with multiple ping loops running 2024-02-26 15:26:23 +00:00
Luke Bonaccorsi 79892aa98f fix: add better logging when unexpected error is recieved during config 2024-02-25 01:59:12 +00:00
Luke Bonaccorsi a8601bbe90 chore: add release workflow [skip ci] 2024-02-25 01:39:35 +00:00
Luke Bonaccorsi 88ef4a6e25 feat: rewrite network code to make it more linear and handle being offline better 2024-02-25 01:13:15 +00:00
Dennis Melzer 8676cb3fad Fix set fan speed 2024-01-13 23:10:17 +00:00
Dennis Melzer 94ae2b55ea Add debug 2024-01-13 23:10:17 +00:00
Dennis Melzer 4f1bdb3bac Add debug infos 2024-01-13 23:10:17 +00:00
Dennis Melzer 8e6e311bfc Fix the missing error key 2024-01-13 23:10:17 +00:00
Rusty Myers 19aefa8e65
Merge pull request #35 from jonathanrobichaud4/main
Update to support G35 (T2254)
2024-01-13 13:47:26 -05:00
Jonathan Robichaud 715be42d93 Update to support G35 (T2254)
The G35 is the same as the G35+ (T2270) Minus the automatic empty station. I Haven't added it to HAS_CONSUMABLES or HAS_MAP_FEATURE as I'm not sure if it supports them but it does support basic operation and has been working stably for me for around a month!
2023-10-16 20:04:39 -03:00
Luke Bonaccorsi ae189ce422 Add check for if payload is set on state message 2023-10-05 16:29:22 +01:00
dependabot[bot] abb8285e2f Bump cryptography from 41.0.3 to 41.0.4
Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.3 to 41.0.4.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/41.0.3...41.0.4)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-29 18:11:50 +01:00
Luke Bonaccorsi fa9009e43b Add sensor for battery 2023-09-29 18:10:20 +01:00
Luke Bonaccorsi c342eefc16 Ensure translations are in strings.json 2023-09-29 17:54:14 +01:00
Luke Bonaccorsi bea3817c5e Add retry logic to recieving messages 2023-09-28 12:49:14 +01:00
Luke Bonaccorsi 40480cc319 Improve network handling 2023-09-28 10:15:45 +01:00
Luke Bonaccorsi b48d12e05e Switch to different device api 2023-09-12 16:40:55 +01:00
Luke Bonaccorsi 5b787b9820 Remove update before add 2023-09-11 18:02:59 +01:00
Luke Bonaccorsi 29a69215ca Write state when async_update is called 2023-09-11 17:25:23 +01:00
Luke Bonaccorsi 216daccd8d Fix incorrect bracket 2023-09-11 16:52:35 +01:00
Luke Bonaccorsi f8a2e3ca2e Add paypal and monzo links 2023-09-11 14:57:56 +01:00
Luke Bonaccorsi 949edc6b41 Allow some failures of updates before reporting an error 2023-09-11 12:09:22 +01:00
Luke Bonaccorsi 7f5cc8c30f Log debug message when state changes to error 2023-09-07 17:14:49 +01:00
Luke Bonaccorsi 8d8428a359 Add some checks around vacuums from tuya vs eufy, and error on empty local key 2023-09-07 11:42:29 +01:00
Luke Bonaccorsi 71c87f460c Add github sponsors 2023-09-06 12:03:41 +01:00
Luke Bonaccorsi 8256940aa0 Move consumables codes to separate variable, add new config flow 2023-09-06 10:55:52 +01:00
Luke Bonaccorsi 3625657886 Try various consumables codes rather than just relying on fixed series ones 2023-09-04 12:37:58 +01:00
Luke Bonaccorsi 0874e359d4 Add error message for autodiscovery ports being used 2023-09-04 12:37:11 +01:00
Luke Bonaccorsi 01024d8e56 Fix autodiscovery IP key and add error message for unsupported model 2023-09-01 12:34:28 +01:00
Luke Bonaccorsi 7f344d38bf Move message handling call to inside the send command so that we can retry if necessary 2023-08-31 11:06:52 +01:00
Luke Bonaccorsi bba9febbb3 Allow port reuse 2023-08-24 16:35:26 +01:00
Luke Bonaccorsi 7b26f4a22e Don't attempt to update the IP if it's not set 2023-08-24 16:35:13 +01:00
Luke Bonaccorsi 89a578c87b remove manual IP config 2023-08-22 23:21:19 +01:00
Luke Bonaccorsi 1bc4b99160 Update local discovery to reload correctly 2023-08-21 16:48:09 +01:00
Luke Bonaccorsi 711997cb13 Add errors for no IP and no connection 2023-08-18 12:13:01 +01:00
Luke Bonaccorsi edd93b1469 Early return if current entries is empty 2023-08-17 17:10:41 +01:00
Luke Bonaccorsi 26cbc26af1 Separate consumables into it's own feature 2023-08-17 17:00:40 +01:00
Luke Bonaccorsi 59c7dc8033 Reduce frequency of pings, disconnect on unload and stop pings when disconnected 2023-08-17 14:56:32 +01:00
Luke Bonaccorsi b25acad570 Use most recent version of entry 2023-08-17 13:34:05 +01:00
Luke Bonaccorsi 4f111af975 Fix unpadding of datagram 2023-08-17 12:25:52 +01:00
Luke Bonaccorsi 255f164e6d Add local discovery of devices 2023-08-17 11:20:14 +01:00
Luke Bonaccorsi f4e4a647ef Add LR20 and X9 Pro 2023-08-15 12:29:27 +01:00
Razseal 7c57572a09 Update robovac.py
Adding L series support starting with L35 hybrid+ model number T2182
2023-08-14 18:02:35 +01:00
Luke Bonaccorsi 96fd1563a0 Add fallback password to config flow 2023-08-14 17:43:40 +01:00
Luke Bonaccorsi 18868a5ffb Remove print statements 2023-08-08 16:38:05 +01:00
Luke Bonaccorsi 6a1040ad3f Sort manifest keys 2023-08-08 16:35:01 +01:00
Luke Bonaccorsi 8cc04e7ba2 Add version to manifest 2023-08-08 16:32:37 +01:00
Luke Bonaccorsi fa2c390748 Forking 2023-08-08 16:29:23 +01:00
Luke Bonaccorsi c1731e407e
Merge pull request #1 from pbuckley4192/main
Minor Fixes to allow code to run
2023-08-08 15:48:08 +01:00
Luke Bonaccorsi f72f204b86
Merge branch 'main' into main 2023-08-08 15:47:37 +01:00
Paul 7c6ff91b67
Update vacuum.py 2023-08-08 10:16:42 +01:00
Paul dd0c080ff8 Fix for bad array loop 2023-08-08 10:00:37 +01:00
Paul 036fba8caa Fix for bad naming on variable 2023-08-08 09:27:48 +01:00
Paul 485306c6fe Fix Tabs and Whitespace 2023-08-08 09:23:07 +01:00
Paul 42c10ada98
Update robovac.py 2023-08-08 09:18:16 +01:00
28 changed files with 7937 additions and 1079 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: CodeFoodPixels
ko_fi: codefoodpixels

View File

@ -4,38 +4,28 @@ name: CI
# Controls when the workflow will run # Controls when the workflow will run
on: on:
# Triggers the workflow on push or pull request events but only for the "main" branch # Triggers the workflow on push or pull request events but only for the "main" branch
push: push:
branches: [ "main" ] branches: ["main"]
pull_request: pull_request:
branches: [ "main" ] branches: ["main"]
# Allows you to run this workflow manually from the Actions tab # Allows you to run this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel # A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs: jobs:
# This workflow contains a single job called "build" # This workflow contains a single job called "build"
build: build:
# The type of runner that the job will run on # The type of runner that the job will run on
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job # Steps represent a sequence of tasks that will be executed as part of the job
steps: steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: "home-assistant/actions/hassfest@master" - uses: "home-assistant/actions/hassfest@master"
- name: HACS Action - name: HACS Action
uses: "hacs/action@main" uses: "hacs/action@main"
with: with:
category: "integration" category: "integration"
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!
# Runs a set of commands using the runners shell
- name: Run a multi-line script
run: |
echo Add other actions to build,
echo test, and deploy your project.

19
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,19 @@
on:
push:
branches:
- main
workflow_dispatch:
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/node_modules

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
20

20
.releaserc Normal file
View File

@ -0,0 +1,20 @@
{
"branches": [
"main",
{
"name": "*",
"channel": "beta",
"prerelease": true
}
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
"@semantic-release/release-notes-generator",
"@semantic-release/github"
]
}

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"ms-python.black-formatter",
"ms-python.python"
]
}

View File

@ -1,5 +1,10 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs)
<a href="https://www.buymeacoffee.com/bmccluskey" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
[![Sponsor me on Github Sponsors](https://img.shields.io/badge/Sponsor-ea4aaa?style=for-the-badge&logo=github-sponsors&logoColor=%23EA4AAA&labelColor=white)](https://github.com/sponsors/CodeFoodPixels)
[![Tip me through ko-fi](https://img.shields.io/badge/KoFi-FF5E5B?style=for-the-badge&logo=kofi&logoColor=%23FF5E5B&labelColor=white)](https://ko-fi.com/O5O3O08PA)
[![Tip me through PayPal](https://img.shields.io/badge/Paypal.me-00457C?style=for-the-badge&logo=paypal&logoColor=%2300457C&labelColor=white)](https://paypal.me/CodeFoodPixels)
[![Tip me through Monzo](https://img.shields.io/badge/Monzo.me-14233C?style=for-the-badge&logo=monzo&logoColor=%2314233C&labelColor=white)](https://monzo.me/codefoodpixels)
# Eufy RobovVac control for Home Assistant # Eufy RobovVac control for Home Assistant
A brand new version Eufy RoboVac integration for Home Assistant that includes a Config Flow to add your RoboVac(s) and the local key and ID required. All you need to do is enter your Eufy app credentials and the Config Flow will look up the details for you. After the initial config use the configuration button on the Integration to enter the RoboVac IP address when prompted. A brand new version Eufy RoboVac integration for Home Assistant that includes a Config Flow to add your RoboVac(s) and the local key and ID required. All you need to do is enter your Eufy app credentials and the Config Flow will look up the details for you. After the initial config use the configuration button on the Integration to enter the RoboVac IP address when prompted.

View File

@ -15,39 +15,83 @@
"""The Eufy Robovac integration.""" """The Eufy Robovac integration."""
from __future__ import annotations from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform, CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import DOMAIN from .const import CONF_VACS, DOMAIN
PLATFORMS = [Platform.VACUUM] from .tuyalocaldiscovery import TuyaLocalDiscovery
PLATFORMS = [Platform.VACUUM, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass, entry) -> bool:
hass.data.setdefault(DOMAIN, {CONF_VACS:{}})
async def update_device(device):
entry = async_get_config_entry_for_device(hass, device["gwId"])
if entry == None:
return
if not entry.state.recoverable:
return
hass_data = entry.data.copy()
if (
device["gwId"] in hass_data[CONF_VACS]
and device.get("ip") is not None
and hass_data[CONF_VACS][device["gwId"]].get("autodiscovery", True)
):
if hass_data[CONF_VACS][device["gwId"]][CONF_IP_ADDRESS] != device["ip"]:
hass_data[CONF_VACS][device["gwId"]][CONF_IP_ADDRESS] = device["ip"]
hass.config_entries.async_update_entry(entry, data=hass_data)
await hass.config_entries.async_reload(entry.entry_id)
_LOGGER.debug(
"Updated ip address of {} to {}".format(
device["gwId"], device["ip"]
)
)
tuyalocaldiscovery = TuyaLocalDiscovery(update_device)
try:
await tuyalocaldiscovery.start()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, tuyalocaldiscovery.close)
except Exception:
_LOGGER.exception("failed to set up discovery")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Eufy Robovac from a config entry.""" """Set up Eufy Robovac from a config entry."""
hass.data.setdefault(DOMAIN, {})
# hass_data = dict(entry.data)
# Registers update listener to update config entry when options are updated.
# unsub_options_update_listener = entry.add_update_listener(options_update_listener)
# Store a reference to the unsubscribe function to cleanup if an entry is unloaded.
# hass_data["unsub_options_update_listener"] = unsub_options_update_listener
# hass.data[DOMAIN][entry.entry_id] = hass_data
entry.async_on_unload(entry.add_update_listener(update_listener)) entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
):
"""Nothing""" """Nothing"""
return unload_ok return unload_ok
async def update_listener(hass, entry): async def update_listener(hass, entry):
"""Handle options update.""" """Handle options update."""
await async_unload_entry(hass, entry) await hass.config_entries.async_reload(entry.entry_id)
await async_setup_entry(hass, entry)
def async_get_config_entry_for_device(hass, device_id):
current_entries = hass.config_entries.async_entries(DOMAIN)
for entry in current_entries:
if device_id in entry.data[CONF_VACS]:
return entry
return None

View File

@ -14,6 +14,7 @@
"""Config flow for Eufy Robovac integration.""" """Config flow for Eufy Robovac integration."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import Any, Optional from typing import Any, Optional
@ -39,11 +40,20 @@ from homeassistant.const import (
CONF_IP_ADDRESS, CONF_IP_ADDRESS,
CONF_DESCRIPTION, CONF_DESCRIPTION,
CONF_MAC, CONF_MAC,
CONF_LOCATION,
CONF_CLIENT_ID, CONF_CLIENT_ID,
CONF_REGION,
CONF_TIME_ZONE,
CONF_COUNTRY_CODE,
) )
from .const import DOMAIN, CONF_VACS, CONF_PHONE_CODE from .countries import (
get_phone_code_by_country_code,
get_phone_code_by_region,
get_region_by_country_code,
get_region_by_phone_code,
)
from .const import CONF_AUTODISCOVERY, DOMAIN, CONF_VACS
from .tuyawebapi import TuyaAPISession from .tuyawebapi import TuyaAPISession
from .eufywebapi import EufyLogon from .eufywebapi import EufyLogon
@ -77,32 +87,77 @@ def get_eufy_vacuums(self):
) )
device_response = response.json() device_response = response.json()
self[CONF_CLIENT_ID] = user_response["user_info"]["id"]
self[CONF_PHONE_CODE] = user_response["user_info"]["phone_code"]
# self[CONF_VACS] = {} response = eufy_session.get_user_settings(
items = device_response["items"] user_response["user_info"]["request_host"],
allvacs = {} user_response["user_info"]["id"],
for item in items: user_response["access_token"],
if item["device"]["product"]["appliance"] == "Cleaning": )
vac_details = { settings_response = response.json()
CONF_ID: item["device"]["id"],
CONF_MODEL: item["device"]["product"]["product_code"], self[CONF_CLIENT_ID] = user_response["user_info"]["id"]
CONF_NAME: item["device"]["alias_name"], if (
CONF_DESCRIPTION: item["device"]["name"], "tuya_home" in settings_response["setting"]["home_setting"]
CONF_MAC: item["device"]["wifi"]["mac"], and "tuya_region_code"
CONF_IP_ADDRESS: "", in settings_response["setting"]["home_setting"]["tuya_home"]
} ):
allvacs[item["device"]["id"]] = vac_details self[CONF_REGION] = settings_response["setting"]["home_setting"]["tuya_home"][
self[CONF_VACS] = allvacs "tuya_region_code"
]
if user_response["user_info"]["phone_code"]:
self[CONF_COUNTRY_CODE] = user_response["user_info"]["phone_code"]
else:
self[CONF_COUNTRY_CODE] = get_phone_code_by_region(self[CONF_REGION])
elif user_response["user_info"]["phone_code"]:
self[CONF_REGION] = get_region_by_phone_code(
user_response["user_info"]["phone_code"]
)
self[CONF_COUNTRY_CODE] = user_response["user_info"]["phone_code"]
elif user_response["user_info"]["country"]:
self[CONF_REGION] = get_region_by_country_code(
user_response["user_info"]["country"]
)
self[CONF_COUNTRY_CODE] = get_phone_code_by_country_code(
user_response["user_info"]["country"]
)
else:
self[CONF_REGION] = "EU"
self[CONF_COUNTRY_CODE] = "44"
self[CONF_TIME_ZONE] = user_response["user_info"]["timezone"]
tuya_client = TuyaAPISession( tuya_client = TuyaAPISession(
username="eh-" + self[CONF_CLIENT_ID], country_code=self[CONF_PHONE_CODE] username="eh-" + self[CONF_CLIENT_ID],
region=self[CONF_REGION],
timezone=self[CONF_TIME_ZONE],
phone_code=self[CONF_COUNTRY_CODE],
) )
for home in tuya_client.list_homes():
for device in tuya_client.list_devices(home["groupId"]): items = device_response["items"]
self[CONF_VACS][device["devId"]][CONF_ACCESS_TOKEN] = device["localKey"] self[CONF_VACS] = {}
self[CONF_VACS][device["devId"]][CONF_LOCATION] = home["groupId"] for item in items:
if item["device"]["product"]["appliance"] == "Cleaning":
try:
device = tuya_client.get_device(item["device"]["id"])
vac_details = {
CONF_ID: item["device"]["id"],
CONF_MODEL: item["device"]["product"]["product_code"],
CONF_NAME: item["device"]["alias_name"],
CONF_DESCRIPTION: item["device"]["name"],
CONF_MAC: item["device"]["wifi"]["mac"],
CONF_IP_ADDRESS: "",
CONF_AUTODISCOVERY: True,
CONF_ACCESS_TOKEN: device["localKey"],
}
self[CONF_VACS][item["device"]["id"]] = vac_details
except:
_LOGGER.debug(
"Skipping vacuum {}: found on Eufy but not on Tuya. Eufy details:".format(
item["device"]["id"]
)
)
_LOGGER.debug(json.dumps(item["device"], indent=2))
return response return response
@ -132,8 +187,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except except Exception as e: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception: {}".format(e))
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
@ -164,47 +219,64 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None: def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self.config_entry = config_entry self.config_entry = config_entry
self.selected_vacuum = None
async def async_step_init( async def async_step_init(self, user_input=None):
self, user_input: dict[str, Any] = None errors = {}
) -> dict[str, Any]:
"""Manage the options for the custom component."""
errors: dict[str, str] = {}
vac_names = []
vacuums = self.config_entry.data[CONF_VACS]
for item in vacuums:
item_settings = vacuums[item]
vac_names.append(item_settings["name"])
if user_input is not None: if user_input is not None:
for item in vacuums: self.selected_vacuum = user_input["selected_vacuum"]
item_settings = vacuums[item] return await self.async_step_edit()
if item_settings["name"] == user_input["vacuum"]:
item_settings[CONF_IP_ADDRESS] = user_input[CONF_IP_ADDRESS]
updated_repos = deepcopy(self.config_entry.data[CONF_VACS])
# print("Updated", updated_repos) vacuums_config = self.config_entry.data[CONF_VACS]
if not errors: vacuum_list = {}
# Value of data will be set on the options property of our config_entry for vacuum_id in vacuums_config:
# instance. vacuum_list[vacuum_id] = vacuums_config[vacuum_id]["name"]
return self.async_create_entry(
title="", devices_schema = vol.Schema(
data={CONF_VACS: updated_repos}, {vol.Required("selected_vacuum"): vol.In(vacuum_list)}
) )
return self.async_show_form(
step_id="init", data_schema=devices_schema, errors=errors
)
async def async_step_edit(self, user_input=None):
"""Manage the options for the custom component."""
errors = {}
vacuums = self.config_entry.data[CONF_VACS]
if user_input is not None:
updated_vacuums = deepcopy(vacuums)
updated_vacuums[self.selected_vacuum][CONF_AUTODISCOVERY] = user_input[
CONF_AUTODISCOVERY
]
if user_input[CONF_IP_ADDRESS]:
updated_vacuums[self.selected_vacuum][CONF_IP_ADDRESS] = user_input[
CONF_IP_ADDRESS
]
self.hass.config_entries.async_update_entry(
self.config_entry,
data={CONF_VACS: updated_vacuums},
)
return self.async_create_entry(title="", data={})
options_schema = vol.Schema( options_schema = vol.Schema(
{ {
vol.Optional("vacuum", default=1): selector( vol.Required(
{ CONF_AUTODISCOVERY,
"select": { default=vacuums[self.selected_vacuum].get(CONF_AUTODISCOVERY, True),
"options": vac_names, ): bool,
} vol.Optional(
} CONF_IP_ADDRESS,
), default=vacuums[self.selected_vacuum].get(CONF_IP_ADDRESS),
vol.Optional(CONF_IP_ADDRESS): cv.string, ): str,
} }
) )
return self.async_show_form( return self.async_show_form(
step_id="init", data_schema=options_schema, errors=errors step_id="edit", data_schema=options_schema, errors=errors
) )

View File

@ -2,4 +2,7 @@
DOMAIN = "robovac" DOMAIN = "robovac"
CONF_VACS = "vacuums" CONF_VACS = "vacuums"
CONF_PHONE_CODE = "phone_code" CONF_AUTODISCOVERY = "autodiscovery"
REFRESH_RATE = 60
PING_RATE = 10
TIMEOUT = 5

View File

@ -0,0 +1,228 @@
COUNTRIES = [
{"country_code": "AF", "phone_code": "93", "tuya_region": "EU"},
{"country_code": "AL", "phone_code": "355", "tuya_region": "EU"},
{"country_code": "DZ", "phone_code": "213", "tuya_region": "EU"},
{"country_code": "AO", "phone_code": "244", "tuya_region": "EU"},
{"country_code": "AR", "phone_code": "54", "tuya_region": "AZ"},
{"country_code": "AM", "phone_code": "374", "tuya_region": "EU"},
{"country_code": "AU", "phone_code": "61", "tuya_region": "AZ"},
{"country_code": "AT", "phone_code": "43", "tuya_region": "EU"},
{"country_code": "AZ", "phone_code": "994", "tuya_region": "EU"},
{"country_code": "BH", "phone_code": "973", "tuya_region": "EU"},
{"country_code": "BD", "phone_code": "880", "tuya_region": "EU"},
{"country_code": "BY", "phone_code": "375", "tuya_region": "EU"},
{"country_code": "BE", "phone_code": "32", "tuya_region": "EU"},
{"country_code": "BZ", "phone_code": "501", "tuya_region": "EU"},
{"country_code": "BJ", "phone_code": "229", "tuya_region": "EU"},
{"country_code": "BT", "phone_code": "975", "tuya_region": "EU"},
{"country_code": "BO", "phone_code": "591", "tuya_region": "AZ"},
{"country_code": "BA", "phone_code": "387", "tuya_region": "EU"},
{"country_code": "BW", "phone_code": "267", "tuya_region": "EU"},
{"country_code": "BR", "phone_code": "55", "tuya_region": "AZ"},
{"country_code": "VG", "phone_code": "1284", "tuya_region": "EU"},
{"country_code": "BN", "phone_code": "673", "tuya_region": "EU"},
{"country_code": "BG", "phone_code": "359", "tuya_region": "EU"},
{"country_code": "BF", "phone_code": "226", "tuya_region": "EU"},
{"country_code": "BI", "phone_code": "257", "tuya_region": "EU"},
{"country_code": "KH", "phone_code": "855", "tuya_region": "EU"},
{"country_code": "CM", "phone_code": "237", "tuya_region": "EU"},
{"country_code": "US", "phone_code": "1", "tuya_region": "AZ"},
{"country_code": "CA", "phone_code": "1", "tuya_region": "AZ"},
{"country_code": "CV", "phone_code": "238", "tuya_region": "EU"},
{"country_code": "KY", "phone_code": "1345", "tuya_region": "EU"},
{"country_code": "CF", "phone_code": "236", "tuya_region": "EU"},
{"country_code": "TD", "phone_code": "235", "tuya_region": "EU"},
{"country_code": "CL", "phone_code": "56", "tuya_region": "AZ"},
{"country_code": "CN", "phone_code": "86", "tuya_region": "AY"},
{"country_code": "CO", "phone_code": "57", "tuya_region": "AZ"},
{"country_code": "KM", "phone_code": "269", "tuya_region": "EU"},
{"country_code": "CG", "phone_code": "242", "tuya_region": "EU"},
{"country_code": "CD", "phone_code": "243", "tuya_region": "EU"},
{"country_code": "CR", "phone_code": "506", "tuya_region": "EU"},
{"country_code": "HR", "phone_code": "385", "tuya_region": "EU"},
{"country_code": "CY", "phone_code": "357", "tuya_region": "EU"},
{"country_code": "CZ", "phone_code": "420", "tuya_region": "EU"},
{"country_code": "DK", "phone_code": "45", "tuya_region": "EU"},
{"country_code": "DJ", "phone_code": "253", "tuya_region": "EU"},
{"country_code": "DO", "phone_code": "1809", "tuya_region": "EU"},
{"country_code": "DO", "phone_code": "1829", "tuya_region": "EU"},
{"country_code": "DO", "phone_code": "1849", "tuya_region": "EU"},
{"country_code": "EC", "phone_code": "593", "tuya_region": "AZ"},
{"country_code": "EG", "phone_code": "20", "tuya_region": "EU"},
{"country_code": "SV", "phone_code": "503", "tuya_region": "EU"},
{"country_code": "GQ", "phone_code": "240", "tuya_region": "EU"},
{"country_code": "ER", "phone_code": "291", "tuya_region": "EU"},
{"country_code": "EE", "phone_code": "372", "tuya_region": "EU"},
{"country_code": "ET", "phone_code": "251", "tuya_region": "EU"},
{"country_code": "FJ", "phone_code": "679", "tuya_region": "EU"},
{"country_code": "FI", "phone_code": "358", "tuya_region": "EU"},
{"country_code": "FR", "phone_code": "33", "tuya_region": "EU"},
{"country_code": "GA", "phone_code": "241", "tuya_region": "EU"},
{"country_code": "GM", "phone_code": "220", "tuya_region": "EU"},
{"country_code": "GE", "phone_code": "995", "tuya_region": "EU"},
{"country_code": "DE", "phone_code": "49", "tuya_region": "EU"},
{"country_code": "GH", "phone_code": "233", "tuya_region": "EU"},
{"country_code": "GR", "phone_code": "30", "tuya_region": "EU"},
{"country_code": "GL", "phone_code": "299", "tuya_region": "EU"},
{"country_code": "GT", "phone_code": "502", "tuya_region": "AZ"},
{"country_code": "GN", "phone_code": "224", "tuya_region": "EU"},
{"country_code": "GY", "phone_code": "592", "tuya_region": "EU"},
{"country_code": "HT", "phone_code": "509", "tuya_region": "EU"},
{"country_code": "HN", "phone_code": "504", "tuya_region": "EU"},
{"country_code": "HK", "phone_code": "852", "tuya_region": "AZ"},
{"country_code": "HU", "phone_code": "36", "tuya_region": "EU"},
{"country_code": "IS", "phone_code": "354", "tuya_region": "EU"},
{"country_code": "IN", "phone_code": "91", "tuya_region": "IN"},
{"country_code": "ID", "phone_code": "62", "tuya_region": "AZ"},
{"country_code": "IR", "phone_code": "98", "tuya_region": "EU"},
{"country_code": "IQ", "phone_code": "964", "tuya_region": "EU"},
{"country_code": "IE", "phone_code": "353", "tuya_region": "EU"},
{"country_code": "IM", "phone_code": "44", "tuya_region": "EU"},
{"country_code": "IL", "phone_code": "972", "tuya_region": "EU"},
{"country_code": "IT", "phone_code": "39", "tuya_region": "AZ"},
{"country_code": "CI", "phone_code": "225", "tuya_region": "EU"},
{"country_code": "JM", "phone_code": "1876", "tuya_region": "EU"},
{"country_code": "JP", "phone_code": "81", "tuya_region": "AZ"},
{"country_code": "JO", "phone_code": "962", "tuya_region": "EU"},
{"country_code": "KZ", "phone_code": "7", "tuya_region": "EU"},
{"country_code": "KE", "phone_code": "254", "tuya_region": "EU"},
{"country_code": "KR", "phone_code": "82", "tuya_region": "AZ"},
{"country_code": "KW", "phone_code": "965", "tuya_region": "EU"},
{"country_code": "KG", "phone_code": "996", "tuya_region": "EU"},
{"country_code": "LA", "phone_code": "856", "tuya_region": "EU"},
{"country_code": "LV", "phone_code": "371", "tuya_region": "EU"},
{"country_code": "LB", "phone_code": "961", "tuya_region": "EU"},
{"country_code": "LS", "phone_code": "266", "tuya_region": "EU"},
{"country_code": "LR", "phone_code": "231", "tuya_region": "EU"},
{"country_code": "LY", "phone_code": "218", "tuya_region": "EU"},
{"country_code": "LT", "phone_code": "370", "tuya_region": "EU"},
{"country_code": "LU", "phone_code": "352", "tuya_region": "EU"},
{"country_code": "MO", "phone_code": "853", "tuya_region": "AZ"},
{"country_code": "MK", "phone_code": "389", "tuya_region": "EU"},
{"country_code": "MG", "phone_code": "261", "tuya_region": "EU"},
{"country_code": "MW", "phone_code": "265", "tuya_region": "EU"},
{"country_code": "MY", "phone_code": "60", "tuya_region": "AZ"},
{"country_code": "MV", "phone_code": "960", "tuya_region": "EU"},
{"country_code": "ML", "phone_code": "223", "tuya_region": "EU"},
{"country_code": "MT", "phone_code": "356", "tuya_region": "EU"},
{"country_code": "MR", "phone_code": "222", "tuya_region": "EU"},
{"country_code": "MU", "phone_code": "230", "tuya_region": "EU"},
{"country_code": "MX", "phone_code": "52", "tuya_region": "AZ"},
{"country_code": "MD", "phone_code": "373", "tuya_region": "EU"},
{"country_code": "MC", "phone_code": "377", "tuya_region": "EU"},
{"country_code": "MN", "phone_code": "976", "tuya_region": "EU"},
{"country_code": "ME", "phone_code": "382", "tuya_region": "EU"},
{"country_code": "MA", "phone_code": "212", "tuya_region": "EU"},
{"country_code": "MZ", "phone_code": "258", "tuya_region": "EU"},
{"country_code": "MM", "phone_code": "95", "tuya_region": "AZ"},
{"country_code": "NA", "phone_code": "264", "tuya_region": "EU"},
{"country_code": "NP", "phone_code": "977", "tuya_region": "EU"},
{"country_code": "NL", "phone_code": "31", "tuya_region": "EU"},
{"country_code": "NZ", "phone_code": "64", "tuya_region": "AZ"},
{"country_code": "NI", "phone_code": "505", "tuya_region": "AZ"},
{"country_code": "NE", "phone_code": "227", "tuya_region": "EU"},
{"country_code": "NG", "phone_code": "234", "tuya_region": "EU"},
{"country_code": "KP", "phone_code": "850", "tuya_region": "EU"},
{"country_code": "NO", "phone_code": "47", "tuya_region": "EU"},
{"country_code": "OM", "phone_code": "968", "tuya_region": "EU"},
{"country_code": "PK", "phone_code": "92", "tuya_region": "EU"},
{"country_code": "PA", "phone_code": "507", "tuya_region": "EU"},
{"country_code": "PY", "phone_code": "595", "tuya_region": "AZ"},
{"country_code": "PE", "phone_code": "51", "tuya_region": "AZ"},
{"country_code": "PH", "phone_code": "63", "tuya_region": "AZ"},
{"country_code": "PL", "phone_code": "48", "tuya_region": "EU"},
{"country_code": "PF", "phone_code": "689", "tuya_region": "EU"},
{"country_code": "PT", "phone_code": "351", "tuya_region": "EU"},
{"country_code": "PR", "phone_code": "1787", "tuya_region": "AZ"},
{"country_code": "QA", "phone_code": "974", "tuya_region": "EU"},
{"country_code": "RE", "phone_code": "262", "tuya_region": "EU"},
{"country_code": "RO", "phone_code": "40", "tuya_region": "EU"},
{"country_code": "RU", "phone_code": "7", "tuya_region": "EU"},
{"country_code": "RW", "phone_code": "250", "tuya_region": "EU"},
{"country_code": "SM", "phone_code": "378", "tuya_region": "EU"},
{"country_code": "SA", "phone_code": "966", "tuya_region": "EU"},
{"country_code": "SN", "phone_code": "221", "tuya_region": "EU"},
{"country_code": "RS", "phone_code": "381", "tuya_region": "EU"},
{"country_code": "SL", "phone_code": "232", "tuya_region": "EU"},
{"country_code": "SG", "phone_code": "65", "tuya_region": "EU"},
{"country_code": "SK", "phone_code": "421", "tuya_region": "EU"},
{"country_code": "SI", "phone_code": "386", "tuya_region": "EU"},
{"country_code": "SO", "phone_code": "252", "tuya_region": "EU"},
{"country_code": "ZA", "phone_code": "27", "tuya_region": "EU"},
{"country_code": "ES", "phone_code": "34", "tuya_region": "EU"},
{"country_code": "LK", "phone_code": "94", "tuya_region": "EU"},
{"country_code": "SD", "phone_code": "249", "tuya_region": "EU"},
{"country_code": "SR", "phone_code": "597", "tuya_region": "AZ"},
{"country_code": "SZ", "phone_code": "268", "tuya_region": "EU"},
{"country_code": "SE", "phone_code": "46", "tuya_region": "EU"},
{"country_code": "CH", "phone_code": "41", "tuya_region": "EU"},
{"country_code": "SY", "phone_code": "963", "tuya_region": "EU"},
{"country_code": "TW", "phone_code": "886", "tuya_region": "AZ"},
{"country_code": "TJ", "phone_code": "992", "tuya_region": "EU"},
{"country_code": "TZ", "phone_code": "255", "tuya_region": "EU"},
{"country_code": "TH", "phone_code": "66", "tuya_region": "AZ"},
{"country_code": "TG", "phone_code": "228", "tuya_region": "EU"},
{"country_code": "TO", "phone_code": "676", "tuya_region": "EU"},
{"country_code": "TT", "phone_code": "1868", "tuya_region": "EU"},
{"country_code": "TN", "phone_code": "216", "tuya_region": "EU"},
{"country_code": "TR", "phone_code": "90", "tuya_region": "EU"},
{"country_code": "TM", "phone_code": "993", "tuya_region": "EU"},
{"country_code": "VI", "phone_code": "1340", "tuya_region": "EU"},
{"country_code": "UG", "phone_code": "256", "tuya_region": "EU"},
{"country_code": "UA", "phone_code": "380", "tuya_region": "EU"},
{"country_code": "AE", "phone_code": "971", "tuya_region": "EU"},
{"country_code": "GB", "phone_code": "44", "tuya_region": "EU"},
{"country_code": "UY", "phone_code": "598", "tuya_region": "AZ"},
{"country_code": "UZ", "phone_code": "998", "tuya_region": "EU"},
{"country_code": "VA", "phone_code": "379", "tuya_region": "EU"},
{"country_code": "VE", "phone_code": "58", "tuya_region": "AZ"},
{"country_code": "VN", "phone_code": "84", "tuya_region": "AZ"},
{"country_code": "YE", "phone_code": "967", "tuya_region": "EU"},
{"country_code": "ZR", "phone_code": "243", "tuya_region": "EU"},
{"country_code": "ZM", "phone_code": "260", "tuya_region": "EU"},
{"country_code": "ZW", "phone_code": "263", "tuya_region": "EU"},
{"country_code": "NCL", "phone_code": "687", "tuya_region": "EU"},
{"country_code": "MQ", "phone_code": "596", "tuya_region": "EU"},
]
def get_region_by_country_code(country_code):
country = next(
(item for item in COUNTRIES if item["country_code"] == country_code), None
)
if country is None:
return "EU"
return country["tuya_region"]
def get_region_by_phone_code(phone_code):
country = next(
(item for item in COUNTRIES if item["phone_code"] == phone_code), None
)
if country is None:
return "EU"
return country["tuya_region"]
def get_phone_code_by_region(region):
country = next((item for item in COUNTRIES if item["tuya_region"] == region), None)
if country is None:
return "44"
return country["phone_code"]
def get_phone_code_by_country_code(country_code):
country = next(
(item for item in COUNTRIES if item["country_code"] == country_code), None
)
if country is None:
return "44"
return country["phone_code"]

View File

@ -1,37 +1,39 @@
ERROR_MESSAGES = { ERROR_MESSAGES = {
"IP_ADDRESS": "IP Address not set", "IP_ADDRESS": "IP Address not set",
"CONNECTION_FAILED": "Connection to the vacuum failed",
"UNSUPPORTED_MODEL": "This model is not supported",
"no_error": "None", "no_error": "None",
1:"Error: Front bumper stuck", 1:"Front bumper stuck",
2:"Error: Wheel stuck", 2:"Wheel stuck",
3:"Error: Side brush", 3:"Side brush",
4:"Error: Rolling brush bar stuck", 4:"Rolling brush bar stuck",
5:"Error: Device trapped", 5:"Device trapped",
6:"Error: Device trapped", 6:"Device trapped",
7:"Error: Wheel suspended", 7:"Wheel suspended",
8:"Error: Low battery", 8:"Low battery",
9:"Error: Magnetic boundary", 9:"Magnetic boundary",
12:"Error: Right wall sensor", 12:"Right wall sensor",
13:"Error: Device tilted", 13:"Device tilted",
14:"Error: Insert dust collector", 14:"Insert dust collector",
17:"Error: Restricted area detected", 17:"Restricted area detected",
18:"Error: Laser cover stuck", 18:"Laser cover stuck",
19:"Error: Laser sesor stuck", 19:"Laser sesor stuck",
20:"Error: Laser sensor blocked", 20:"Laser sensor blocked",
21:"Error: Base blocked", 21:"Base blocked",
"S1":"Error: Battery", "S1":"Battery",
"S2":"Error: Wheel Module", "S2":"Wheel Module",
"S3":"Error: Side Brush", "S3":"Side Brush",
"S4":"Error: Suction Fan", "S4":"Suction Fan",
"S5":"Error: Rolling Brush", "S5":"Rolling Brush",
"S8":"Error: Path Tracking Sensor", "S8":"Path Tracking Sensor",
"Wheel_stuck":"Error: Wheel stuck", "Wheel_stuck":"Wheel stuck",
"R_brush_stuck":"Error: Rolling brush stuck", "R_brush_stuck":"Rolling brush stuck",
"Crash_bar_stuck":"Error: Front bumper stuck", "Crash_bar_stuck":"Front bumper stuck",
"sensor_dirty":"Error: Sensor dirty", "sensor_dirty":"Sensor dirty",
"N_enough_pow":"Error: Low battery", "N_enough_pow":"Low battery",
"Stuck_5_min":"Error: Device trapped", "Stuck_5_min":"Device trapped",
"Fan_stuck":"Error: Fan stuck", "Fan_stuck":"Fan stuck",
"S_brush_stuck":"Error: Side brush stuck", "S_brush_stuck":"Side brush stuck",
} }
def getErrorMessage(code): def getErrorMessage(code):

View File

@ -30,12 +30,16 @@ class EufyLogon:
"password": self.password, "password": self.password,
} }
return requests.post( return requests.post(login_url, json=login_auth, headers=eufyheaders)
login_url, json=login_auth, headers=eufyheaders, timeout=1.5
) def get_user_settings(self, url, userid, token):
setting_url = url + "/v1/user/setting"
eufyheaders["token"] = token
eufyheaders["id"] = userid
return requests.request("GET", setting_url, headers=eufyheaders, timeout=1.5)
def get_device_info(self, url, userid, token): def get_device_info(self, url, userid, token):
device_url = url + "/v1/device/list/devices-and-groups" device_url = url + "/v1/device/list/devices-and-groups"
eufyheaders["token"] = token eufyheaders["token"] = token
eufyheaders["id"] = userid eufyheaders["id"] = userid
return requests.request("GET", device_url, headers=eufyheaders, timeout=1.5) return requests.request("GET", device_url, headers=eufyheaders)

View File

@ -1,15 +1,13 @@
{ {
"domain": "robovac", "domain": "robovac",
"name": "Eufy Robovac", "name": "Eufy Robovac",
"config_flow": true, "codeowners": ["@codefoodpixels"],
"documentation": "https://github.com/bmccluskey/robovac", "config_flow": true,
"issue_tracker": "https://github.com/bmccluskey/robovac/issues", "dependencies": [],
"requirements": [], "documentation": "https://github.com/codefoodpixels/robovac",
"ssdp": [], "integration_type": "device",
"zeroconf": [], "iot_class": "local_polling",
"homekit": {}, "issue_tracker": "https://github.com/codefoodpixels/robovac/issues",
"dependencies": [], "requirements": [],
"codeowners": ["@bmccluskey"], "version": "1.0.0"
"iot_class": "local_polling",
"version": "1"
} }

View File

@ -19,8 +19,6 @@ class RoboVacEntityFeature(IntEnum):
BOOST_IQ = 1024 BOOST_IQ = 1024
HAS_MAP_FEATURE = ["T2261", "T2262"]
ROBOVAC_SERIES = { ROBOVAC_SERIES = {
"C": [ "C": [
"T2103", "T2103",
@ -31,6 +29,7 @@ ROBOVAC_SERIES = {
"T2123", "T2123",
"T2128", "T2128",
"T2130", "T2130",
"T2132",
], ],
"G": [ "G": [
"T1250", "T1250",
@ -38,34 +37,65 @@ ROBOVAC_SERIES = {
"T2251", "T2251",
"T2252", "T2252",
"T2253", "T2253",
"T2254",
"T2150", "T2150",
"T2255", "T2255",
"T2256",
"T2257",
"T2258",
"T2259",
"T2270",
"T2272",
"T2273",
], ],
"X": ["T2261", "T2262"], "L": ["T2181", "T2182", "T2190", "T2192", "T2193", "T2194"],
"X": ["T2261", "T2262", "T2320"],
} }
HAS_MAP_FEATURE = ["T2253", *ROBOVAC_SERIES["L"], *ROBOVAC_SERIES["X"]]
HAS_CONSUMABLES = [
"T1250",
"T2181",
"T2182",
"T2190",
"T2193",
"T2194",
"T2253",
"T2256",
"T2258",
"T2261",
"T2273",
"T2320",
]
ROBOVAC_SERIES_FEATURES = { ROBOVAC_SERIES_FEATURES = {
"C": RoboVacEntityFeature.EDGE | RoboVacEntityFeature.SMALL_ROOM, "C": RoboVacEntityFeature.EDGE | RoboVacEntityFeature.SMALL_ROOM,
"G": RoboVacEntityFeature.CLEANING_TIME "G": RoboVacEntityFeature.CLEANING_TIME
| RoboVacEntityFeature.CLEANING_AREA | RoboVacEntityFeature.CLEANING_AREA
| RoboVacEntityFeature.DO_NOT_DISTURB | RoboVacEntityFeature.DO_NOT_DISTURB
| RoboVacEntityFeature.AUTO_RETURN,
"L": RoboVacEntityFeature.CLEANING_TIME
| RoboVacEntityFeature.CLEANING_AREA
| RoboVacEntityFeature.DO_NOT_DISTURB
| RoboVacEntityFeature.AUTO_RETURN | RoboVacEntityFeature.AUTO_RETURN
| RoboVacEntityFeature.CONSUMABLES, | RoboVacEntityFeature.ROOM
| RoboVacEntityFeature.ZONE
| RoboVacEntityFeature.BOOST_IQ,
"X": RoboVacEntityFeature.CLEANING_TIME "X": RoboVacEntityFeature.CLEANING_TIME
| RoboVacEntityFeature.CLEANING_AREA | RoboVacEntityFeature.CLEANING_AREA
| RoboVacEntityFeature.DO_NOT_DISTURB | RoboVacEntityFeature.DO_NOT_DISTURB
| RoboVacEntityFeature.AUTO_RETURN | RoboVacEntityFeature.AUTO_RETURN
| RoboVacEntityFeature.CONSUMABLES
| RoboVacEntityFeature.ROOM | RoboVacEntityFeature.ROOM
| RoboVacEntityFeature.ZONE | RoboVacEntityFeature.ZONE
| RoboVacEntityFeature.MAP
| RoboVacEntityFeature.BOOST_IQ, | RoboVacEntityFeature.BOOST_IQ,
} }
ROBOVAC_SERIES_FAN_SPEEDS = { ROBOVAC_SERIES_FAN_SPEEDS = {
"C": ["No Suction", "Standard", "Boost IQ", "Max"], "C": ["No Suction", "Standard", "Boost IQ", "Max"],
"G": ["Standard", "Turbo", "Max", "Boost IQ"], "G": ["Standard", "Turbo", "Max", "Boost IQ"],
"X": ["Pure", "Standard", "Turbo", "Max"], "L": ["Quiet", "Standard", "Turbo", "Max"],
"X": ["Pure", "Standard", "Turbo", "Max"],
} }
@ -73,6 +103,7 @@ SUPPORTED_ROBOVAC_MODELS = list(
set([item for sublist in ROBOVAC_SERIES.values() for item in sublist]) set([item for sublist in ROBOVAC_SERIES.values() for item in sublist])
) )
class ModelNotSupportedException(Exception): class ModelNotSupportedException(Exception):
"""This model is not supported""" """This model is not supported"""
@ -109,13 +140,20 @@ class RoboVac(TuyaDevice):
return supportedFeatures return supportedFeatures
def getRoboVacFeatures(self): def getRoboVacFeatures(self):
return ROBOVAC_SERIES_FEATURES[self.getRoboVacSeries()] supportedFeatures = ROBOVAC_SERIES_FEATURES[self.getRoboVacSeries()]
if self.model_code in HAS_MAP_FEATURE:
supportedFeatures |= RoboVacEntityFeature.MAP
if self.model_code in HAS_CONSUMABLES:
supportedFeatures |= RoboVacEntityFeature.CONSUMABLES
return supportedFeatures
def getRoboVacSeries(self): def getRoboVacSeries(self):
for series, models in ROBOVAC_SERIES.items(): for series, models in ROBOVAC_SERIES.items():
if self.model_code in models: if self.model_code in models:
return series return series
def getFanSpeeds(self): def getFanSpeeds(self):
return ROBOVAC_SERIES_FAN_SPEEDS[self.getRoboVacSeries()] return ROBOVAC_SERIES_FAN_SPEEDS[self.getRoboVacSeries()]

View File

@ -0,0 +1,63 @@
import logging
from datetime import timedelta
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, CONF_NAME, CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.device_registry import DeviceInfo
from .const import CONF_VACS, DOMAIN, REFRESH_RATE
_LOGGER = logging.getLogger(__name__)
BATTERY = "Battery"
SCAN_INTERVAL = timedelta(seconds=REFRESH_RATE)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize my test integration 2 config entry."""
vacuums = config_entry.data[CONF_VACS]
for item in vacuums:
item = vacuums[item]
entity = RobovacSensorEntity(item)
async_add_entities([entity])
class RobovacSensorEntity(SensorEntity):
_attr_has_entity_name = True
_attr_device_class = SensorDeviceClass.BATTERY
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = PERCENTAGE
_attr_available = False
def __init__(self, item):
self.robovac = item
self.robovac_id = item[CONF_ID]
self._attr_unique_id = item[CONF_ID]
self._battery_level = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, item[CONF_ID])},
name=item[CONF_NAME]
)
def update(self):
try:
self._battery_level = self.hass.data[DOMAIN][CONF_VACS][self.robovac_id].battery_level
self._attr_available = True
except:
_LOGGER.debug("Failed to get battery level for {}".format(self.robovac_id))
self._battery_level = None
self._attr_available = False
@property
def native_value(self) -> str | None:
"""Return the state."""
if self._battery_level is not None:
return self._battery_level
return None

View File

@ -1,21 +1,40 @@
{ {
"config": { "config": {
"step": { "abort": {
"user": { "already_configured": "Device is already configured"
"data": { },
"username": "[%key:common::config_flow::data::username%]", "error": {
"password": "[%key:common::config_flow::data::password%]" "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"host": "Host",
"password": "Password",
"username": "Username"
},
"description": "Enter your Eufy account details"
}
} }
}
}, },
"error": { "options": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "step": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "init": {
"unknown": "[%key:common::config_flow::error::unknown%]" "title": "Manage vacuums",
}, "data": {
"abort": { "selected_vacuum": "Select the Vacuum to edit"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }
},
"edit": {
"title": "Edit vacuum",
"data": {
"autodiscovery": "Enable autodiscovery",
"ip_address": "IP Address"
},
"description": "Autodiscovery will automatically update the IP address"
}
}
} }
}
} }

View File

@ -15,23 +15,26 @@
"password": "Password", "password": "Password",
"username": "Username" "username": "Username"
}, },
"description": "Enter your Eufy account details" "description": "Enter your Eufy account details"
} }
} }
}, },
"options": { "options": {
"error": {
"invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository."
},
"step": { "step": {
"init": { "init": {
"title": "Manage IPs", "title": "Manage vacuums",
"data": { "data": {
"vacuum": "Select the Vacuum to edit", "selected_vacuum": "Select the Vacuum to edit"
"ip_address": "IP address of vacuum" }
}, },
"description": "Add or update a vacuums IP address" "edit": {
} "title": "Edit vacuum",
"data": {
"autodiscovery": "Enable autodiscovery",
"ip_address": "IP Address"
},
"description": "Autodiscovery will automatically update the IP address"
}
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,57 @@
import asyncio
import json
import logging
from hashlib import md5
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_LOGGER = logging.getLogger(__name__)
UDP_KEY = md5(b"yGAdlopoPVldABfn").digest()
class DiscoveryPortsNotAvailableException(Exception):
"""This model is not supported"""
class TuyaLocalDiscovery(asyncio.DatagramProtocol):
def __init__(self, callback):
self.devices = {}
self._listeners = []
self.discovered_callback = callback
async def start(self):
loop = asyncio.get_running_loop()
listener = loop.create_datagram_endpoint(
lambda: self, local_addr=("0.0.0.0", 6666), reuse_port=True
)
encrypted_listener = loop.create_datagram_endpoint(
lambda: self, local_addr=("0.0.0.0", 6667), reuse_port=True
)
try:
self._listeners = await asyncio.gather(listener, encrypted_listener)
_LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667")
except Exception as e:
raise DiscoveryPortsNotAvailableException(
"Ports 6666 and 6667 are needed for autodiscovery but are unavailable. This may be due to having the localtuya integration installed and it not allowing other integrations to use the same ports. A pull request has been raised to address this: https://github.com/rospogrigio/localtuya/pull/1481"
)
def close(self, *args, **kwargs):
for transport, _ in self._listeners:
transport.close()
def datagram_received(self, data, addr):
data = data[20:-8]
try:
cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend())
decryptor = cipher.decryptor()
padded_data = decryptor.update(data) + decryptor.finalize()
data = padded_data[: -ord(padded_data[len(padded_data) - 1 :])]
except Exception:
data = data.decode()
decoded = json.loads(data)
asyncio.ensure_future(self.discovered_callback(decoded))

View File

@ -78,7 +78,7 @@ DEFAULT_TUYA_QUERY_PARAMS = {
"lang": "en", "lang": "en",
"osSystem": "12", "osSystem": "12",
"os": "Android", "os": "Android",
"timeZoneId": "Europe/London", "timeZoneId": "",
"ttid": "android", "ttid": "android",
"et": "0.0.1", "et": "0.0.1",
"sdkVersion": "3.0.8cAnker", "sdkVersion": "3.0.8cAnker",
@ -86,19 +86,25 @@ DEFAULT_TUYA_QUERY_PARAMS = {
class TuyaAPISession: class TuyaAPISession:
username = None username = None
country_code = None country_code = None
session_id = None session_id = None
def __init__(self, username, country_code): def __init__(self, username, region, timezone, phone_code):
self.session = requests.session() self.session = requests.session()
self.session.headers = DEFAULT_TUYA_HEADERS.copy() self.session.headers = DEFAULT_TUYA_HEADERS.copy()
self.default_query_params = DEFAULT_TUYA_QUERY_PARAMS.copy() self.default_query_params = DEFAULT_TUYA_QUERY_PARAMS.copy()
self.default_query_params["deviceId"] = self.generate_new_device_id() self.default_query_params["deviceId"] = self.generate_new_device_id()
self.username = username self.username = username
self.country_code = country_code self.country_code = phone_code
self.base_url = TUYA_INITIAL_BASE_URL self.base_url = {
"AZ": "https://a1.tuyaus.com",
"AY": "https://a1.tuyacn.com",
"IN": "https://a1.tuyain.com",
"EU": "https://a1.tuyaeu.com",
}.get(region, "https://a1.tuyaeu.com")
DEFAULT_TUYA_QUERY_PARAMS["timeZoneId"] = timezone
@staticmethod @staticmethod
def generate_new_device_id(): def generate_new_device_id():
@ -183,8 +189,7 @@ class TuyaAPISession:
encrypted_uid += encryptor.finalize() encrypted_uid += encryptor.finalize()
return md5(encrypted_uid.hex().upper().encode("utf-8")).hexdigest() return md5(encrypted_uid.hex().upper().encode("utf-8")).hexdigest()
def request_session(self, username, country_code): def request_session(self, username, password, country_code):
password = self.determine_password(username)
token_response = self.request_token(username, country_code) token_response = self.request_token(username, country_code)
encrypted_password = unpadded_rsa( encrypted_password = unpadded_rsa(
key_exponent=int(token_response["exponent"]), key_exponent=int(token_response["exponent"]),
@ -200,24 +205,38 @@ class TuyaAPISession:
"options": '{"group": 1}', "options": '{"group": 1}',
"token": token_response["token"], "token": token_response["token"],
} }
session_response = self._request(
action="tuya.m.user.uid.password.login.reg", try:
data=data, return self._request(
_requires_session=False, action="tuya.m.user.uid.password.login.reg",
) data=data,
return session_response _requires_session=False,
)
except Exception as e:
error_password = md5("12345678".encode("utf8")).hexdigest()
if password != error_password:
return self.request_session(username, error_password, country_code)
else:
raise e
def acquire_session(self): def acquire_session(self):
session_response = self.request_session(self.username, self.country_code) password = self.determine_password(self.username)
session_response = self.request_session(
self.username, password, self.country_code
)
self.session_id = self.default_query_params["sid"] = session_response["sid"] self.session_id = self.default_query_params["sid"] = session_response["sid"]
self.base_url = session_response["domain"]["mobileApiUrl"] self.base_url = session_response["domain"]["mobileApiUrl"]
self.country_code = (
session_response["phoneCode"]
if session_response["phoneCode"]
else self.getCountryCode(session_response["domain"]["regionCode"])
)
def list_homes(self): def list_homes(self):
return self._request(action="tuya.m.location.list", version="2.1") return self._request(action="tuya.m.location.list", version="2.1")
def list_devices(self, home_id: str): def get_device(self, devId):
return self._request( return self._request(
action="tuya.m.my.group.device.list", action="tuya.m.device.get", version="1.0", data={"devId": devId}
version="1.0",
query_params={"gid": home_id},
) )

View File

@ -52,13 +52,19 @@ from homeassistant.const import (
CONF_IP_ADDRESS, CONF_IP_ADDRESS,
CONF_DESCRIPTION, CONF_DESCRIPTION,
CONF_MAC, CONF_MAC,
STATE_ON, STATE_UNAVAILABLE,
) )
from .const import CONF_VACS, DOMAIN from .tuyalocalapi import TuyaException
from .const import CONF_VACS, DOMAIN, REFRESH_RATE, PING_RATE, TIMEOUT
from .errors import getErrorMessage from .errors import getErrorMessage
from .robovac import SUPPORTED_ROBOVAC_MODELS, RoboVac, RoboVacEntityFeature from .robovac import (
SUPPORTED_ROBOVAC_MODELS,
ModelNotSupportedException,
RoboVac,
RoboVacEntityFeature,
)
from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.const import ATTR_BATTERY_LEVEL
@ -78,9 +84,8 @@ ATTR_CONSUMABLES = "consumables"
ATTR_MODE = "mode" ATTR_MODE = "mode"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Time between updating data from GitHub
REFRESH_RATE = 20
SCAN_INTERVAL = timedelta(seconds=REFRESH_RATE) SCAN_INTERVAL = timedelta(seconds=REFRESH_RATE)
UPDATE_RETRIES = 3
class TUYA_CODES(StrEnum): class TUYA_CODES(StrEnum):
@ -94,8 +99,9 @@ class TUYA_CODES(StrEnum):
AUTO_RETURN = "135" AUTO_RETURN = "135"
DO_NOT_DISTURB = "107" DO_NOT_DISTURB = "107"
BOOST_IQ = "118" BOOST_IQ = "118"
G_CONSUMABLES = "142"
X_CONSUMABLES = "116"
TUYA_CONSUMABLES_CODES = ["142", "116"]
async def async_setup_entry( async def async_setup_entry(
@ -107,7 +113,9 @@ async def async_setup_entry(
vacuums = config_entry.data[CONF_VACS] vacuums = config_entry.data[CONF_VACS]
for item in vacuums: for item in vacuums:
item = vacuums[item] item = vacuums[item]
async_add_entities([RoboVacEntity(item)]) entity = RoboVacEntity(item)
hass.data[DOMAIN][CONF_VACS][item[CONF_ID]] = entity
async_add_entities([entity])
class RoboVacEntity(StateVacuumEntity): class RoboVacEntity(StateVacuumEntity):
@ -184,9 +192,22 @@ class RoboVacEntity(StateVacuumEntity):
@property @property
def state(self) -> str | None: def state(self) -> str | None:
if self.tuya_state is None or ( if self.tuya_state is None:
type(self.error_code) is not None and self.error_code not in [0, "no_error"] return STATE_UNAVAILABLE
elif (
type(self.error_code) is not None
and self.error_code
and self.error_code
not in [
0,
"no_error",
]
): ):
_LOGGER.debug(
"State changed to error. Error message: {}".format(
getErrorMessage(self.error_code)
)
)
return STATE_ERROR return STATE_ERROR
elif self.tuya_state == "Charging" or self.tuya_state == "completed": elif self.tuya_state == "Charging" or self.tuya_state == "completed":
return STATE_DOCKED return STATE_DOCKED
@ -201,23 +222,38 @@ class RoboVacEntity(StateVacuumEntity):
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""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] = getErrorMessage(self.error_code)
if self.supported_features & VacuumEntityFeature.STATUS: if type(self.error_code) is not None and self.error_code not in [0, "no_error"]:
data[ATTR_STATUS] = self.status data[ATTR_ERROR] = getErrorMessage(self.error_code)
if self.robovac_supported & RoboVacEntityFeature.CLEANING_AREA: if (
self.robovac_supported & RoboVacEntityFeature.CLEANING_AREA
and self.cleaning_area
):
data[ATTR_CLEANING_AREA] = self.cleaning_area data[ATTR_CLEANING_AREA] = self.cleaning_area
if self.robovac_supported & RoboVacEntityFeature.CLEANING_TIME: if (
self.robovac_supported & RoboVacEntityFeature.CLEANING_TIME
and self.cleaning_time
):
data[ATTR_CLEANING_TIME] = self.cleaning_time data[ATTR_CLEANING_TIME] = self.cleaning_time
if self.robovac_supported & RoboVacEntityFeature.AUTO_RETURN: if (
self.robovac_supported & RoboVacEntityFeature.AUTO_RETURN
and self.auto_return
):
data[ATTR_AUTO_RETURN] = self.auto_return data[ATTR_AUTO_RETURN] = self.auto_return
if self.robovac_supported & RoboVacEntityFeature.DO_NOT_DISTURB: if (
self.robovac_supported & RoboVacEntityFeature.DO_NOT_DISTURB
and self.do_not_disturb
):
data[ATTR_DO_NOT_DISTURB] = self.do_not_disturb data[ATTR_DO_NOT_DISTURB] = self.do_not_disturb
if self.robovac_supported & RoboVacEntityFeature.BOOST_IQ: if self.robovac_supported & RoboVacEntityFeature.BOOST_IQ and self.boost_iq:
data[ATTR_BOOST_IQ] = self.boost_iq data[ATTR_BOOST_IQ] = self.boost_iq
if self.robovac_supported & RoboVacEntityFeature.CONSUMABLES: if (
self.robovac_supported & RoboVacEntityFeature.CONSUMABLES
and self.consumables
):
data[ATTR_CONSUMABLES] = self.consumables data[ATTR_CONSUMABLES] = self.consumables
data[ATTR_MODE] = self.mode if self.mode:
data[ATTR_MODE] = self.mode
return data return data
def __init__(self, item) -> None: def __init__(self, item) -> None:
@ -230,14 +266,20 @@ class RoboVacEntity(StateVacuumEntity):
self._attr_ip_address = item[CONF_IP_ADDRESS] self._attr_ip_address = item[CONF_IP_ADDRESS]
self._attr_access_token = item[CONF_ACCESS_TOKEN] self._attr_access_token = item[CONF_ACCESS_TOKEN]
self.vacuum = RoboVac( self.update_failures = 0
device_id=self.unique_id,
host=self.ip_address, try:
local_key=self.access_token, self.vacuum = RoboVac(
timeout=2, device_id=self.unique_id,
ping_interval=10, host=self.ip_address,
model_code=self.model_code[0:5], local_key=self.access_token,
) timeout=TIMEOUT,
ping_interval=PING_RATE,
model_code=self.model_code[0:5],
update_entity_state=self.pushed_update_handler,
)
except ModelNotSupportedException:
self.error_code = "UNSUPPORTED_MODEL"
self._attr_supported_features = self.vacuum.getHomeAssistantFeatures() self._attr_supported_features = self.vacuum.getHomeAssistantFeatures()
self._attr_robovac_supported = self.vacuum.getRoboVacFeatures() self._attr_robovac_supported = self.vacuum.getRoboVacFeatures()
@ -261,12 +303,34 @@ class RoboVacEntity(StateVacuumEntity):
async def async_update(self): async def async_update(self):
"""Synchronise state from the vacuum.""" """Synchronise state from the vacuum."""
self.async_write_ha_state() if self.error_code == "UNSUPPORTED_MODEL":
if self.ip_address == "":
return return
await self.vacuum.async_get()
if self.ip_address == "":
self.error_code = "IP_ADDRESS"
return
try:
await self.vacuum.async_get()
self.update_failures = 0
self.update_entity_values()
except TuyaException as e:
self.update_failures += 1
_LOGGER.warn(
"Update errored. Current update failure count: {}. Reason: {}".format(
self.update_failures, e
)
)
if self.update_failures >= UPDATE_RETRIES:
self.error_code = "CONNECTION_FAILED"
async def pushed_update_handler(self):
self.update_entity_values()
self.async_write_ha_state()
def update_entity_values(self):
self.tuyastatus = self.vacuum._dps self.tuyastatus = self.vacuum._dps
print("Tuya local API Result:", self.tuyastatus)
# for 15C # for 15C
self._attr_battery_level = self.tuyastatus.get(TUYA_CODES.BATTERY_LEVEL) self._attr_battery_level = self.tuyastatus.get(TUYA_CODES.BATTERY_LEVEL)
self.tuya_state = self.tuyastatus.get(TUYA_CODES.STATE) self.tuya_state = self.tuyastatus.get(TUYA_CODES.STATE)
@ -284,72 +348,56 @@ class RoboVacEntity(StateVacuumEntity):
self._attr_cleaning_time = self.tuyastatus.get(TUYA_CODES.CLEANING_TIME) self._attr_cleaning_time = self.tuyastatus.get(TUYA_CODES.CLEANING_TIME)
self._attr_auto_return = self.tuyastatus.get(TUYA_CODES.AUTO_RETURN) self._attr_auto_return = self.tuyastatus.get(TUYA_CODES.AUTO_RETURN)
self._attr_do_not_disturb = self.tuyastatus.get(TUYA_CODES.DO_NOT_DISTURB) self._attr_do_not_disturb = self.tuyastatus.get(TUYA_CODES.DO_NOT_DISTURB)
if self.tuyastatus.get(TUYA_CODES.G_CONSUMABLES) is not None:
self._attr_consumables = ast.literal_eval(
base64.b64decode(self.tuyastatus.get(TUYA_CODES.G_CONSUMABLES)).decode(
"ascii"
)
)["consumable"]["duration"]
print(self.consumables)
# For X8
self._attr_boost_iq = self.tuyastatus.get(TUYA_CODES.BOOST_IQ) self._attr_boost_iq = self.tuyastatus.get(TUYA_CODES.BOOST_IQ)
# self.map_data = self.tuyastatus.get("121") # self.map_data = self.tuyastatus.get("121")
# self.erro_msg? = self.tuyastatus.get("124") # self.erro_msg? = self.tuyastatus.get("124")
if self.tuyastatus.get(TUYA_CODES.X_CONSUMABLES) is not None: if self.robovac_supported & RoboVacEntityFeature.CONSUMABLES:
self._attr_consumables = ast.literal_eval( for CONSUMABLE_CODE in TUYA_CONSUMABLES_CODES:
base64.b64decode(self.tuyastatus.get(TUYA_CODES.X_CONSUMABLES)).decode( if (
"ascii" CONSUMABLE_CODE in self.tuyastatus
) and self.tuyastatus.get(CONSUMABLE_CODE) is not None
)["consumable"]["duration"] ):
print(self.consumables) consumables = ast.literal_eval(
base64.b64decode(self.tuyastatus.get(CONSUMABLE_CODE)).decode(
"ascii"
)
)
if (
"consumable" in consumables
and "duration" in consumables["consumable"]
):
self._attr_consumables = consumables["consumable"]["duration"]
async def async_locate(self, **kwargs): async def async_locate(self, **kwargs):
"""Locate the vacuum cleaner.""" """Locate the vacuum cleaner."""
print("Locate Pressed")
_LOGGER.info("Locate Pressed") _LOGGER.info("Locate Pressed")
if self.tuyastatus.get("103"): if self.tuyastatus.get("103"):
await self.vacuum.async_set({"103": False}, None) await self.vacuum.async_set({"103": False})
else: else:
await self.vacuum.async_set({"103": True}, None) await self.vacuum.async_set({"103": True})
async def async_return_to_base(self, **kwargs): async def async_return_to_base(self, **kwargs):
"""Set the vacuum cleaner to return to the dock.""" """Set the vacuum cleaner to return to the dock."""
print("Return home Pressed")
_LOGGER.info("Return home Pressed") _LOGGER.info("Return home Pressed")
await self.vacuum.async_set({"101": True}, None) await self.vacuum.async_set({"101": True})
await asyncio.sleep(1)
self.async_update
async def async_start(self, **kwargs): async def async_start(self, **kwargs):
if self.mode == "Nosweep": self._attr_mode = "auto"
self._attr_mode = "auto" await self.vacuum.async_set({"5": self.mode})
elif self.mode == "room" and (
self.status == "Charging" or self.status == "completed"
):
self._attr_mode = "auto"
await self.vacuum.async_set({"5": self.mode}, None)
await asyncio.sleep(1)
self.async_update
async def async_pause(self, **kwargs): async def async_pause(self, **kwargs):
await self.vacuum.async_set({"2": False}, None) await self.vacuum.async_set({"2": False})
await asyncio.sleep(1)
self.async_update
async def async_stop(self, **kwargs): async def async_stop(self, **kwargs):
await self.async_return_to_base() await self.async_return_to_base()
async def async_clean_spot(self, **kwargs): async def async_clean_spot(self, **kwargs):
"""Perform a spot clean-up.""" """Perform a spot clean-up."""
print("Spot Clean Pressed")
_LOGGER.info("Spot Clean Pressed") _LOGGER.info("Spot Clean Pressed")
await self.vacuum.async_set({"5": "Spot"}, None) await self.vacuum.async_set({"5": "Spot"})
await asyncio.sleep(1)
self.async_update
async def async_set_fan_speed(self, fan_speed, **kwargs): async def async_set_fan_speed(self, fan_speed, **kwargs):
"""Set fan speed.""" """Set fan speed."""
print("Fan Speed Selected", fan_speed)
_LOGGER.info("Fan Speed Selected") _LOGGER.info("Fan Speed Selected")
if fan_speed == "No Suction": if fan_speed == "No Suction":
fan_speed = "No_suction" fan_speed = "No_suction"
@ -357,9 +405,7 @@ class RoboVacEntity(StateVacuumEntity):
fan_speed = "Boost_IQ" fan_speed = "Boost_IQ"
elif fan_speed == "Pure": elif fan_speed == "Pure":
fan_speed = "Quiet" fan_speed = "Quiet"
await self.vacuum.async_set({"102": fan_speed}, None) await self.vacuum.async_set({"102": fan_speed})
await asyncio.sleep(1)
self.async_update
async def async_send_command( async def async_send_command(
self, command: str, params: dict | list | None = None, **kwargs self, command: str, params: dict | list | None = None, **kwargs
@ -367,28 +413,28 @@ class RoboVacEntity(StateVacuumEntity):
"""Send a command to a vacuum cleaner.""" """Send a command to a vacuum cleaner."""
_LOGGER.info("Send Command %s Pressed", command) _LOGGER.info("Send Command %s Pressed", command)
if command == "edgeClean": if command == "edgeClean":
await self.vacuum.async_set({"5": "Edge"}, None) await self.vacuum.async_set({"5": "Edge"})
elif command == "smallRoomClean": elif command == "smallRoomClean":
await self.vacuum.async_set({"5": "SmallRoom"}, None) await self.vacuum.async_set({"5": "SmallRoom"})
elif command == "autoClean": elif command == "autoClean":
await self.vacuum.async_set({"5": "auto"}, None) await self.vacuum.async_set({"5": "auto"})
elif command == "autoReturn": elif command == "autoReturn":
if self.auto_return: if self.auto_return:
await self.vacuum.async_set({"135": False}, None) await self.vacuum.async_set({"135": False})
else: else:
await self.vacuum.async_set({"135": True}, None) await self.vacuum.async_set({"135": True})
elif command == "doNotDisturb": elif command == "doNotDisturb":
if self.do_not_disturb: if self.do_not_disturb:
await self.vacuum.async_set({"139": "MEQ4MDAwMDAw"}, None) await self.vacuum.async_set({"139": "MEQ4MDAwMDAw"})
await self.vacuum.async_set({"107": False}, None) await self.vacuum.async_set({"107": False})
else: else:
await self.vacuum.async_set({"139": "MTAwMDAwMDAw"}, None) await self.vacuum.async_set({"139": "MTAwMDAwMDAw"})
await self.vacuum.async_set({"107": True}, None) await self.vacuum.async_set({"107": True})
elif command == "boostIQ": elif command == "boostIQ":
if self.boost_iq: if self.boost_iq:
await self.vacuum.async_set({"118": False}, None) await self.vacuum.async_set({"118": False})
else: else:
await self.vacuum.async_set({"118": True}, None) await self.vacuum.async_set({"118": True})
elif command == "roomClean": elif command == "roomClean":
roomIds = params.get("roomIds", [1]) roomIds = params.get("roomIds", [1])
count = params.get("count", 1) count = params.get("count", 1)
@ -401,6 +447,7 @@ class RoboVacEntity(StateVacuumEntity):
json_str = json.dumps(method_call, separators=(",", ":")) json_str = json.dumps(method_call, separators=(",", ":"))
base64_str = base64.b64encode(json_str.encode("utf8")).decode("utf8") base64_str = base64.b64encode(json_str.encode("utf8")).decode("utf8")
_LOGGER.info("roomClean call %s", json_str) _LOGGER.info("roomClean call %s", json_str)
await self.vacuum.async_set({"124": base64_str}, None) await self.vacuum.async_set({"124": base64_str})
await asyncio.sleep(1)
self.async_update async def async_will_remove_from_hass(self):
await self.vacuum.async_disable()

6041
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"devDependencies": {
"conventional-changelog-conventionalcommits": "^7.0.2",
"semantic-release": "^23.0.2"
}
}

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
cryptography==41.0.4
homeassistant==2023.8.4
Requests==2.31.0
setuptools==68.0.0
voluptuous==0.13.1

View File

@ -24,18 +24,18 @@ import warnings
dynamic_requires = [] dynamic_requires = []
setup( setup(
name='robovac', name="robovac",
version="1.0", version="1.0",
author='Brendan McCluskey', author="Luke Morrigan",
url='http://github.com/bmccluskey/robovac', url="http://github.com/codefoodpixels/robovac",
packages=find_packages(), packages=find_packages(),
scripts=[], scripts=[],
description='Python API for controlling Eufy Robovac vacuum cleaners', description="Python API for controlling Eufy Robovac vacuum cleaners",
classifiers=[ classifiers=[
'Development Status :: 4 - Beta', "Development Status :: 4 - Beta",
'Intended Audience :: Developers', "Intended Audience :: Developers",
'License :: OSI Approved :: Apache Software License', "License :: OSI Approved :: Apache Software License",
'Operating System :: OS Independent', "Operating System :: OS Independent",
'Programming Language :: Python', "Programming Language :: Python",
], ],
) )