Compare commits

..

No commits in common. "main" and "merge-to-upstream" have entirely different histories.

28 changed files with 1075 additions and 7933 deletions

View File

@ -1,12 +0,0 @@
# 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
View File

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

View File

@ -4,28 +4,38 @@ name: CI
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "main" branch
push:
branches: ["main"]
pull_request:
branches: ["main"]
# Triggers the workflow on push or pull request events but only for the "main" branch
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3
- uses: "home-assistant/actions/hassfest@master"
- name: HACS Action
uses: "hacs/action@main"
with:
category: "integration"
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3
- uses: "home-assistant/actions/hassfest@master"
- name: HACS Action
uses: "hacs/action@main"
with:
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.

View File

@ -1,19 +0,0 @@
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
View File

@ -1 +0,0 @@
/node_modules

1
.nvmrc
View File

@ -1 +0,0 @@
20

View File

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

View File

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

View File

@ -1,10 +1,5 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs)
[![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)
<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>
# 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.

View File

@ -15,83 +15,39 @@
"""The Eufy Robovac integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform, CONF_IP_ADDRESS
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONF_VACS, DOMAIN
from .const import DOMAIN
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
PLATFORMS = [Platform.VACUUM]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""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))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""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"""
return unload_ok
async def update_listener(hass, entry):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
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
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,63 +0,0 @@
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,40 +1,21 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"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"
}
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"options": {
"step": {
"init": {
"title": "Manage vacuums",
"data": {
"selected_vacuum": "Select the Vacuum to edit"
}
},
"edit": {
"title": "Edit vacuum",
"data": {
"autodiscovery": "Enable autodiscovery",
"ip_address": "IP Address"
},
"description": "Autodiscovery will automatically update the IP address"
}
}
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -15,26 +15,23 @@
"password": "Password",
"username": "Username"
},
"description": "Enter your Eufy account details"
"description": "Enter your Eufy account details"
}
}
},
"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": {
"init": {
"title": "Manage vacuums",
"data": {
"selected_vacuum": "Select the Vacuum to edit"
}
"init": {
"title": "Manage IPs",
"data": {
"vacuum": "Select the Vacuum to edit",
"ip_address": "IP address of vacuum"
},
"edit": {
"title": "Edit vacuum",
"data": {
"autodiscovery": "Enable autodiscovery",
"ip_address": "IP Address"
},
"description": "Autodiscovery will automatically update the IP address"
}
"description": "Add or update a vacuums IP address"
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,57 +0,0 @@
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",
"osSystem": "12",
"os": "Android",
"timeZoneId": "",
"timeZoneId": "Europe/London",
"ttid": "android",
"et": "0.0.1",
"sdkVersion": "3.0.8cAnker",
@ -86,25 +86,19 @@ DEFAULT_TUYA_QUERY_PARAMS = {
class TuyaAPISession:
username = None
country_code = None
session_id = None
def __init__(self, username, region, timezone, phone_code):
def __init__(self, username, country_code):
self.session = requests.session()
self.session.headers = DEFAULT_TUYA_HEADERS.copy()
self.default_query_params = DEFAULT_TUYA_QUERY_PARAMS.copy()
self.default_query_params["deviceId"] = self.generate_new_device_id()
self.username = username
self.country_code = phone_code
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
self.country_code = country_code
self.base_url = TUYA_INITIAL_BASE_URL
@staticmethod
def generate_new_device_id():
@ -189,7 +183,8 @@ class TuyaAPISession:
encrypted_uid += encryptor.finalize()
return md5(encrypted_uid.hex().upper().encode("utf-8")).hexdigest()
def request_session(self, username, password, country_code):
def request_session(self, username, country_code):
password = self.determine_password(username)
token_response = self.request_token(username, country_code)
encrypted_password = unpadded_rsa(
key_exponent=int(token_response["exponent"]),
@ -205,38 +200,24 @@ class TuyaAPISession:
"options": '{"group": 1}',
"token": token_response["token"],
}
try:
return self._request(
action="tuya.m.user.uid.password.login.reg",
data=data,
_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
session_response = self._request(
action="tuya.m.user.uid.password.login.reg",
data=data,
_requires_session=False,
)
return session_response
def acquire_session(self):
password = self.determine_password(self.username)
session_response = self.request_session(
self.username, password, self.country_code
)
session_response = self.request_session(self.username, self.country_code)
self.session_id = self.default_query_params["sid"] = session_response["sid"]
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):
return self._request(action="tuya.m.location.list", version="2.1")
def get_device(self, devId):
def list_devices(self, home_id: str):
return self._request(
action="tuya.m.device.get", version="1.0", data={"devId": devId}
action="tuya.m.my.group.device.list",
version="1.0",
query_params={"gid": home_id},
)

View File

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

6041
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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