Compare commits

..

41 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
24 changed files with 7014 additions and 311 deletions

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
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"
# 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.
# 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"

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"
]
}

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)
<a href='https://ko-fi.com/O5O3O08PA' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></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
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

@ -24,12 +24,12 @@ from .const import CONF_VACS, DOMAIN
from .tuyalocaldiscovery import TuyaLocalDiscovery
PLATFORM = Platform.VACUUM
PLATFORMS = [Platform.VACUUM, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass, entry) -> bool:
hass.data.setdefault(DOMAIN, {})
hass.data.setdefault(DOMAIN, {CONF_VACS:{}})
async def update_device(device):
entry = async_get_config_entry_for_device(hass, device["gwId"])
@ -41,19 +41,18 @@ async def async_setup(hass, entry) -> bool:
return
hass_data = entry.data.copy()
if device["gwId"] in hass_data[CONF_VACS] and device.get(CONF_IP_ADDRESS) is not None:
if (
hass_data[CONF_VACS][device["gwId"]]["ip_address"]
!= device[CONF_IP_ADDRESS]
):
hass_data[CONF_VACS][device["gwId"]]["ip_address"] = device[
CONF_IP_ADDRESS
]
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[CONF_IP_ADDRESS]
device["gwId"], device["ip"]
)
)
@ -71,15 +70,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Eufy Robovac from a config entry."""
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setup(entry, PLATFORM)
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_forward_entry_unload(
entry, PLATFORM
if unload_ok := await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
):
"""Nothing"""
return unload_ok
@ -87,7 +86,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def update_listener(hass, entry):
"""Handle options update."""
hass.config_entries.async_reload(entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)
def async_get_config_entry_for_device(hass, device_id):

View File

@ -14,6 +14,7 @@
"""Config flow for Eufy Robovac integration."""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
@ -39,13 +40,20 @@ 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 .const import DOMAIN, CONF_VACS
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 .eufywebapi import EufyLogon
@ -88,36 +96,68 @@ def get_eufy_vacuums(self):
settings_response = response.json()
self[CONF_CLIENT_ID] = user_response["user_info"]["id"]
self[CONF_REGION] = settings_response["setting"]["home_setting"]["tuya_home"][
"tuya_region_code"
]
self[CONF_TIME_ZONE] = user_response["user_info"]["timezone"]
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_VACS] = {}
items = device_response["items"]
allvacs = {}
for item in items:
if item["device"]["product"]["appliance"] == "Cleaning":
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
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],
)
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"]
items = device_response["items"]
self[CONF_VACS] = {}
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
@ -147,8 +187,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
except Exception as e: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception: {}".format(e))
errors["base"] = "unknown"
else:
await self.async_set_unique_id(unique_id)
@ -159,6 +199,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
@ -166,3 +212,71 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handles options flow for the component."""
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):
"""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(
{
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,
}
)
return self.async_show_form(
step_id="edit", data_schema=options_schema, errors=errors
)

View File

@ -2,3 +2,7 @@
DOMAIN = "robovac"
CONF_VACS = "vacuums"
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,6 +1,7 @@
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",

View File

@ -37,6 +37,7 @@ ROBOVAC_SERIES = {
"T2251",
"T2252",
"T2253",
"T2254",
"T2150",
"T2255",
"T2256",

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": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
"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"
}
}
}
},
"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%]"
"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"
}
}
}
}
}

View File

@ -15,23 +15,26 @@
"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 IPs",
"data": {
"vacuum": "Select the Vacuum to edit",
"ip_address": "IP address of vacuum"
"init": {
"title": "Manage vacuums",
"data": {
"selected_vacuum": "Select the Vacuum to edit"
}
},
"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"
}
}
}
}
}
}

View File

@ -46,13 +46,17 @@ import socket
import struct
import sys
import time
import traceback
from typing import Callable, Coroutine
from cryptography.hazmat.backends.openssl import backend as openssl_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.hashes import Hash, MD5
from cryptography.hazmat.primitives.padding import PKCS7
INITIAL_BACKOFF = 5
INITIAL_QUEUE_TIME = 0.1
BACKOFF_MULTIPLIER = 1.70224
_LOGGER = logging.getLogger(__name__)
MESSAGE_PREFIX_FORMAT = ">IIII"
MESSAGE_SUFFIX_FORMAT = ">II"
@ -347,6 +351,14 @@ class RequestResponseCommandMismatch(TuyaException):
"""The command in the response didn't match the one from the request."""
class ResponseTimeoutException(TuyaException):
"""Did not recieve a response to the request within the timeout"""
class BackoffException(TuyaException):
"""Backoff time not reached"""
class TuyaCipher:
"""Tuya cryptographic helpers."""
@ -440,21 +452,34 @@ class Message:
SET_COMMAND = 0x07
GRATUITOUS_UPDATE = 0x08
def __init__(self, command, payload=None, sequence=None, encrypt_for=None):
def __init__(
self,
command,
payload=None,
sequence=None,
encrypt=False,
device=None,
expect_response=True,
ttl=5,
):
if payload is None:
payload = b""
self.payload = payload
self.command = command
self.original_sequence = sequence
if sequence is None:
# Use millisecond process time as the sequence number. Not ideal,
# but good for one month's continuous connection time though.
sequence = int(time.perf_counter() * 1000) & 0xFFFFFFFF
self.sequence = sequence
self.encrypt = False
self.device = None
if encrypt_for is not None:
self.device = encrypt_for
self.encrypt = True
self.set_sequence()
else:
self.sequence = sequence
self.encrypt = encrypt
self.device = device
self.expiry = int(time.time()) + ttl
self.expect_response = expect_response
self.listener = None
if expect_response is True:
self.listener = asyncio.Semaphore(0)
if device is not None:
device._listeners[self.sequence] = self.listener
def __repr__(self):
return "{}({}, {!r}, {!r}, {})".format(
@ -465,6 +490,9 @@ class Message:
"<Device {}>".format(self.device) if self.device else None,
)
def set_sequence(self):
self.sequence = int(time.perf_counter() * 1000) & 0xFFFFFFFF
def hex(self):
return self.bytes().hex()
@ -496,39 +524,11 @@ class Message:
__bytes__ = bytes
class AsyncWrappedCallback:
def __init__(self, request, callback):
self.request = request
self.callback = callback
self.devices = []
def register(self, device):
self.devices.append(device)
device._handlers.setdefault(self.request.command, [])
device._handlers[self.request.command].append(self)
def unregister(self, device):
self.devices.remove(device)
device._handlers[self.request.command].remove(self)
def unregister_all(self):
while self.devices:
device = self.devices.pop()
device._handlers[self.request.command].remove(self)
async def __call__(self, response, device):
if response.sequence == self.request.sequence:
asyncio.ensure_future(self.callback(response, device))
self.unregister(device)
async def async_send(self, device, callback=None):
if callback is not None:
wrapped = self.AsyncWrappedCallback(self, callback)
wrapped.register(device)
await device._async_send(self)
async def async_send(self):
await self.device._async_send(self)
@classmethod
def from_bytes(cls, data, cipher=None):
def from_bytes(cls, device, data, cipher=None):
try:
prefix, sequence, command, payload_size = struct.unpack_from(
MESSAGE_PREFIX_FORMAT, data
@ -585,53 +585,37 @@ class Message:
try:
payload_text = payload_data.decode("utf8")
except UnicodeDecodeError as e:
_LOGGER.debug(payload_data.hex())
_LOGGER.error(e)
device._LOGGER.debug(payload_data.hex())
device._LOGGER.error(e)
raise MessageDecodeFailed() from e
try:
payload = json.loads(payload_text)
except json.decoder.JSONDecodeError as e:
# data may be encrypted
_LOGGER.debug(payload_data.hex())
_LOGGER.error(e)
device._LOGGER.debug(payload_data.hex())
device._LOGGER.error(e)
raise MessageDecodeFailed() from e
return cls(command, payload, sequence)
def _call_async(fn, *args):
loop = None
if sys.version_info >= (3, 7):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
pass
loop = asyncio.get_event_loop()
def wrapper(fn, *args):
asyncio.ensure_future(fn(*args))
loop.call_soon(wrapper, fn, *args)
class TuyaDevice:
"""Represents a generic Tuya device."""
# PING_INTERVAL = 10
def __init__(
self,
device_id,
host,
timeout,
ping_interval,
update_entity_state,
local_key=None,
port=6668,
gateway_id=None,
version=(3, 3),
):
"""Initialize the device."""
self._LOGGER = _LOGGER.getChild(device_id)
self.device_id = device_id
self.host = host
self.port = port
@ -642,19 +626,30 @@ class TuyaDevice:
self.timeout = timeout
self.last_pong = 0
self.ping_interval = ping_interval
self.update_entity_state_cb = update_entity_state
if len(local_key) != 16:
raise InvalidKey("Local key should be a 16-character string")
self.cipher = TuyaCipher(local_key, self.version)
self.writer = None
self._handlers = {
Message.GET_COMMAND: [self.async_update_state],
Message.GRATUITOUS_UPDATE: [self.async_update_state],
Message.PING_COMMAND: [self._async_pong_received],
self._response_task = None
self._recieve_task = None
self._ping_task = None
self._handlers: dict[int, Callable[[Message], Coroutine]] = {
Message.GRATUITOUS_UPDATE: self.async_gratuitous_update_state,
Message.PING_COMMAND: self._async_pong_received,
}
self._dps = {}
self._connected = False
self._enabled = True
self._queue = []
self._listeners = {}
self._backoff = False
self._queue_interval = INITIAL_QUEUE_TIME
self._failures = 0
asyncio.create_task(self.process_queue())
def __repr__(self):
return "{}({!r}, {!r}, {!r}, {!r})".format(
@ -668,62 +663,149 @@ class TuyaDevice:
def __str__(self):
return "{} ({}:{})".format(self.device_id, self.host, self.port)
async def async_connect(self, callback=None):
if self._connected:
async def process_queue(self):
if self._enabled is False:
return
self.clean_queue()
if len(self._queue) > 0:
self._LOGGER.debug(
"Processing queue. Current length: {}".format(len(self._queue))
)
try:
message = self._queue.pop(0)
await message.async_send()
self._failures = 0
self._queue_interval = INITIAL_QUEUE_TIME
self._backoff = False
except Exception as e:
self._failures += 1
self._LOGGER.debug(
"{} failures. Most recent: {}".format(self._failures, e)
)
if self._failures > 3:
self._backoff = True
self._queue_interval = min(
INITIAL_BACKOFF * (BACKOFF_MULTIPLIER ** (self._failures - 4)),
600,
)
self._LOGGER.warn(
"{} failures, backing off for {} seconds".format(
self._failures, self._queue_interval
)
)
await asyncio.sleep(self._queue_interval)
asyncio.create_task(self.process_queue())
def clean_queue(self):
cleaned_queue = []
now = int(time.time())
for item in self._queue:
if item.expiry > now:
cleaned_queue.append(item)
self._queue = cleaned_queue
async def async_connect(self):
if self._connected is True or self._enabled is False:
return
sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
sock.settimeout(self.timeout)
_LOGGER.debug("Connecting to {}".format(self))
self._LOGGER.debug("Connecting to {}".format(self))
try:
sock.connect((self.host, self.port))
except socket.timeout as e:
except (socket.timeout, TimeoutError) as e:
self._dps["106"] = "CONNECTION_FAILED"
raise ConnectionTimeoutException("Connection timed out") from e
raise ConnectionTimeoutException("Connection timed out")
loop = asyncio.get_running_loop()
loop.create_connection
self.reader, self.writer = await asyncio.open_connection(sock=sock)
self._connected = True
if self._ping_task is None:
self._ping_task = asyncio.create_task(self.async_ping(self.ping_interval))
asyncio.create_task(self._async_handle_message())
async def async_disable(self):
self._enabled = False
await self.async_disconnect()
async def async_disconnect(self):
_LOGGER.debug("Disconnected from {}".format(self))
if self._connected is False:
return
self._LOGGER.debug("Disconnected from {}".format(self))
self._connected = False
self.last_pong = 0
if self.writer is not None:
self.writer.close()
async def async_get(self, callback=None):
payload = {"gwId": self.gateway_id, "devId": self.device_id}
maybe_self = None if self.version < (3, 3) else self
message = Message(Message.GET_COMMAND, payload, encrypt_for=maybe_self)
return await message.async_send(self, callback)
if self.reader is not None and not self.reader.at_eof():
self.reader.feed_eof()
async def async_set(self, dps, callback=None):
async def async_get(self):
payload = {"gwId": self.gateway_id, "devId": self.device_id}
encrypt = False if self.version < (3, 3) else True
message = Message(Message.GET_COMMAND, payload, encrypt=encrypt, device=self)
self._queue.append(message)
response = await self.async_recieve(message)
asyncio.create_task(self.async_update_state(response))
async def async_set(self, dps):
t = int(time.time())
payload = {"devId": self.device_id, "uid": "", "t": t, "dps": dps}
message = Message(Message.SET_COMMAND, payload, encrypt_for=self)
await message.async_send(self, callback)
message = Message(
Message.SET_COMMAND,
payload,
encrypt=True,
device=self,
expect_response=False,
)
self._queue.append(message)
def set(self, dps):
_call_async(self.async_set, dps)
async def _async_ping(self, ping_interval):
if not self._connected:
async def async_ping(self, ping_interval):
if self._enabled is False:
return
self.last_ping = time.time()
maybe_self = None if self.version < (3, 3) else self
message = Message(Message.PING_COMMAND, sequence=0, encrypt_for=maybe_self)
await self._async_send(message)
if self._backoff is True:
self._LOGGER.debug("Currently in backoff, not adding ping to queue")
else:
self.last_ping = time.time()
encrypt = False if self.version < (3, 3) else True
message = Message(
Message.PING_COMMAND,
sequence=0,
encrypt=encrypt,
device=self,
expect_response=False,
)
self._queue.append(message)
await asyncio.sleep(ping_interval)
self._ping_task = asyncio.create_task(self.async_ping(self.ping_interval))
if self.last_pong < self.last_ping:
await self.async_disconnect()
else:
asyncio.ensure_future(self._async_ping(self.ping_interval))
async def _async_pong_received(self, message, device):
async def _async_pong_received(self, message):
self.last_pong = time.time()
async def async_update_state(self, state_message, _):
self._dps.update(state_message.payload["dps"])
_LOGGER.info("Received updated state {}: {}".format(self, self._dps))
async def async_gratuitous_update_state(self, state_message):
await self.async_update_state(state_message)
await self.update_entity_state_cb()
async def async_update_state(self, state_message, _=None):
if (
state_message is not None
and state_message.payload
and state_message.payload["dps"]
):
self._dps.update(state_message.payload["dps"])
self._LOGGER.debug("Received updated state {}: {}".format(self, self._dps))
@property
def state(self):
@ -731,56 +813,113 @@ class TuyaDevice:
@state.setter
def state_setter(self, new_values):
asyncio.ensure_future(self.async_set(new_values))
asyncio.create_task(self.async_set(new_values))
async def _async_handle_message(self):
response_data = await self.reader.readuntil(MAGIC_SUFFIX_BYTES)
if self._enabled is False or self._connected is False:
return
try:
message = Message.from_bytes(response_data, self.cipher)
except InvalidMessage as e:
_LOGGER.error("Invalid message from {}: {}".format(self, e))
except MessageDecodeFailed as e:
_LOGGER.error("Failed to decrypt message from {}".format(self))
else:
_LOGGER.debug("Received message from {}: {}".format(self, message))
for c in self._handlers.get(message.command, []):
asyncio.ensure_future(c(message, self))
self._response_task = asyncio.create_task(
self.reader.readuntil(MAGIC_SUFFIX_BYTES)
)
await self._response_task
response_data = self._response_task.result()
message = Message.from_bytes(self, response_data, self.cipher)
except Exception as e:
if isinstance(e, InvalidMessage):
self._LOGGER.debug("Invalid message from {}: {}".format(self, e))
elif isinstance(e, MessageDecodeFailed):
self._LOGGER.debug("Failed to decrypt message from {}".format(self))
elif isinstance(e, asyncio.IncompleteReadError):
if self._connected:
self._LOGGER.debug("Incomplete read")
elif isinstance(e, ConnectionResetError):
self._LOGGER.debug(
"Connection reset: {}\n{}".format(e, traceback.format_exc())
)
await self.async_disconnect()
async def _async_send(self, message, retries=4):
else:
self._LOGGER.debug("Received message from {}: {}".format(self, message))
if message.sequence in self._listeners:
sem = self._listeners[message.sequence]
if isinstance(sem, asyncio.Semaphore):
self._listeners[message.sequence] = message
sem.release()
else:
handler = self._handlers.get(message.command, None)
if handler is not None:
asyncio.create_task(handler(message))
self._response_task = None
asyncio.create_task(self._async_handle_message())
async def _async_send(self, message, retries=2):
self._LOGGER.debug("Sending to {}: {}".format(self, message))
try:
await self.async_connect()
_LOGGER.debug("Sending to {}: {}".format(self, message))
self.writer.write(message.bytes())
await self.writer.drain()
await self._async_handle_message()
except Exception as e:
if retries == 0:
if isinstance(e, socket.error):
_LOGGER.error("Connection to {} failed: {}".format(self, e))
self._dps["106"] = "CONNECTION_FAILED"
asyncio.ensure_future(self.async_disconnect())
elif isinstance(e, asyncio.IncompleteReadError):
_LOGGER.error("Incomplete read from: {} : {}".format(self, e))
else:
_LOGGER.error("Failed to send data to {}".format(self))
await self.async_disconnect()
return
raise ConnectionException(
"Connection to {} failed: {}".format(self, e)
)
elif isinstance(e, asyncio.IncompleteReadError):
raise InvalidMessage(
"Incomplete read from: {} : {}".format(self, e)
)
else:
raise TuyaException("Failed to send data to {}".format(self))
if isinstance(e, socket.error):
_LOGGER.debug(
self._LOGGER.debug(
"Retrying send due to error. Connection to {} failed: {}".format(
self, e
)
)
elif isinstance(e, asyncio.IncompleteReadError):
_LOGGER.debug(
"Retrying send due to error.Incomplete read from: {} : {}".format(
self, e
self._LOGGER.debug(
"Retrying send due to error. Incomplete read from: {} : {}. Partial data recieved: {}".format(
self, e, e.partial
)
)
else:
_LOGGER.debug(
self._LOGGER.debug(
"Retrying send due to error. Failed to send data to {}".format(self)
)
await asyncio.sleep(0.25)
await self._async_send(message, retries=retries - 1)
async def async_recieve(self, message):
if self._connected is False:
return
if message.expect_response is True:
try:
self._recieve_task = asyncio.create_task(
asyncio.wait_for(message.listener.acquire(), timeout=self.timeout)
)
await self._recieve_task
response = self._listeners.pop(message.sequence)
if isinstance(response, Exception):
raise response
return response
except Exception as e:
del self._listeners[message.sequence]
await self.async_disconnect()
if isinstance(e, TimeoutError):
raise ResponseTimeoutException(
"Timed out waiting for response to sequence number {}".format(
message.sequence
)
)
raise e

View File

@ -11,6 +11,10 @@ _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 = {}
@ -26,8 +30,13 @@ class TuyaLocalDiscovery(asyncio.DatagramProtocol):
lambda: self, local_addr=("0.0.0.0", 6667), reuse_port=True
)
self._listeners = await asyncio.gather(listener, encrypted_listener)
_LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667")
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:

View File

@ -90,17 +90,20 @@ class TuyaAPISession:
country_code = None
session_id = None
def __init__(self, username, region, timezone):
def __init__(self, username, region, timezone, phone_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 = self.getCountryCode(region)
self.country_code = phone_code
self.base_url = {
"EU": "https://a1.tuyaeu.com",
"AZ": "https://a1.tuyaus.com",
"AY": "https://a1.tuyacn.com",
}.get(region, "https://a1.tuyaus.com")
"IN": "https://a1.tuyain.com",
"EU": "https://a1.tuyaeu.com",
}.get(region, "https://a1.tuyaeu.com")
DEFAULT_TUYA_QUERY_PARAMS["timeZoneId"] = timezone
@staticmethod
@ -233,12 +236,7 @@ class TuyaAPISession:
def list_homes(self):
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(
action="tuya.m.my.group.device.list",
version="1.0",
query_params={"gid": home_id},
action="tuya.m.device.get", version="1.0", data={"devId": devId}
)
def getCountryCode(self, region_code):
return {"EU": "44", "AY": "86"}.get(region_code, "1")

View File

@ -55,10 +55,16 @@ from homeassistant.const import (
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 .robovac import SUPPORTED_ROBOVAC_MODELS, RoboVac, RoboVacEntityFeature
from .robovac import (
SUPPORTED_ROBOVAC_MODELS,
ModelNotSupportedException,
RoboVac,
RoboVacEntityFeature,
)
from homeassistant.const import ATTR_BATTERY_LEVEL
@ -78,8 +84,8 @@ ATTR_CONSUMABLES = "consumables"
ATTR_MODE = "mode"
_LOGGER = logging.getLogger(__name__)
REFRESH_RATE = 20
SCAN_INTERVAL = timedelta(seconds=REFRESH_RATE)
UPDATE_RETRIES = 3
class TUYA_CODES(StrEnum):
@ -93,8 +99,9 @@ class TUYA_CODES(StrEnum):
AUTO_RETURN = "135"
DO_NOT_DISTURB = "107"
BOOST_IQ = "118"
G_CONSUMABLES = "142"
X_CONSUMABLES = "116"
TUYA_CONSUMABLES_CODES = ["142", "116"]
async def async_setup_entry(
@ -107,8 +114,8 @@ async def async_setup_entry(
for item in vacuums:
item = vacuums[item]
entity = RoboVacEntity(item)
await entity.vacuum.async_connect()
async_add_entities([entity], update_before_add=True)
hass.data[DOMAIN][CONF_VACS][item[CONF_ID]] = entity
async_add_entities([entity])
class RoboVacEntity(StateVacuumEntity):
@ -187,10 +194,20 @@ class RoboVacEntity(StateVacuumEntity):
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 not in [
0,
"no_error",
]:
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
elif self.tuya_state == "Charging" or self.tuya_state == "completed":
return STATE_DOCKED
@ -249,14 +266,20 @@ class RoboVacEntity(StateVacuumEntity):
self._attr_ip_address = item[CONF_IP_ADDRESS]
self._attr_access_token = item[CONF_ACCESS_TOKEN]
self.vacuum = RoboVac(
device_id=self.unique_id,
host=self.ip_address,
local_key=self.access_token,
timeout=2,
ping_interval=REFRESH_RATE,
model_code=self.model_code[0:5],
)
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._attr_supported_features = self.vacuum.getHomeAssistantFeatures()
self._attr_robovac_supported = self.vacuum.getRoboVacFeatures()
@ -280,11 +303,32 @@ 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
await self.vacuum.async_get()
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
# for 15C
@ -308,44 +352,41 @@ class RoboVacEntity(StateVacuumEntity):
# self.map_data = self.tuyastatus.get("121")
# self.erro_msg? = self.tuyastatus.get("124")
if self.robovac_supported & RoboVacEntityFeature.CONSUMABLES:
robovac_series = self.vacuum.getRoboVacSeries()
if (
self.tuyastatus.get(TUYA_CODES["{}_CONSUMABLES".format(robovac_series)])
is not None
):
self._attr_consumables = ast.literal_eval(
base64.b64decode(
self.tuyastatus.get(
TUYA_CODES["{}_CONSUMABLES".format(robovac_series)]
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"
)
).decode("ascii")
)["consumable"]["duration"]
)
if (
"consumable" in consumables
and "duration" in consumables["consumable"]
):
self._attr_consumables = consumables["consumable"]["duration"]
async def async_locate(self, **kwargs):
"""Locate the vacuum cleaner."""
_LOGGER.info("Locate Pressed")
if self.tuyastatus.get("103"):
await self.vacuum.async_set({"103": False}, None)
await self.vacuum.async_set({"103": False})
else:
await self.vacuum.async_set({"103": True}, None)
await self.vacuum.async_set({"103": True})
async def async_return_to_base(self, **kwargs):
"""Set the vacuum cleaner to return to the dock."""
_LOGGER.info("Return home Pressed")
await self.vacuum.async_set({"101": True}, None)
await asyncio.sleep(1)
self.async_update
await self.vacuum.async_set({"101": True})
async def async_start(self, **kwargs):
self._attr_mode = "auto"
await self.vacuum.async_set({"5": self.mode}, None)
await asyncio.sleep(1)
self.async_update
await self.vacuum.async_set({"5": self.mode})
async def async_pause(self, **kwargs):
await self.vacuum.async_set({"2": False}, None)
await asyncio.sleep(1)
self.async_update
await self.vacuum.async_set({"2": False})
async def async_stop(self, **kwargs):
await self.async_return_to_base()
@ -353,9 +394,7 @@ class RoboVacEntity(StateVacuumEntity):
async def async_clean_spot(self, **kwargs):
"""Perform a spot clean-up."""
_LOGGER.info("Spot Clean Pressed")
await self.vacuum.async_set({"5": "Spot"}, None)
await asyncio.sleep(1)
self.async_update
await self.vacuum.async_set({"5": "Spot"})
async def async_set_fan_speed(self, fan_speed, **kwargs):
"""Set fan speed."""
@ -366,9 +405,7 @@ class RoboVacEntity(StateVacuumEntity):
fan_speed = "Boost_IQ"
elif fan_speed == "Pure":
fan_speed = "Quiet"
await self.vacuum.async_set({"102": fan_speed}, None)
await asyncio.sleep(1)
self.async_update
await self.vacuum.async_set({"102": fan_speed})
async def async_send_command(
self, command: str, params: dict | list | None = None, **kwargs
@ -376,28 +413,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"}, None)
await self.vacuum.async_set({"5": "Edge"})
elif command == "smallRoomClean":
await self.vacuum.async_set({"5": "SmallRoom"}, None)
await self.vacuum.async_set({"5": "SmallRoom"})
elif command == "autoClean":
await self.vacuum.async_set({"5": "auto"}, None)
await self.vacuum.async_set({"5": "auto"})
elif command == "autoReturn":
if self.auto_return:
await self.vacuum.async_set({"135": False}, None)
await self.vacuum.async_set({"135": False})
else:
await self.vacuum.async_set({"135": True}, None)
await self.vacuum.async_set({"135": True})
elif command == "doNotDisturb":
if self.do_not_disturb:
await self.vacuum.async_set({"139": "MEQ4MDAwMDAw"}, None)
await self.vacuum.async_set({"107": False}, None)
await self.vacuum.async_set({"139": "MEQ4MDAwMDAw"})
await self.vacuum.async_set({"107": False})
else:
await self.vacuum.async_set({"139": "MTAwMDAwMDAw"}, None)
await self.vacuum.async_set({"107": True}, None)
await self.vacuum.async_set({"139": "MTAwMDAwMDAw"})
await self.vacuum.async_set({"107": True})
elif command == "boostIQ":
if self.boost_iq:
await self.vacuum.async_set({"118": False}, None)
await self.vacuum.async_set({"118": False})
else:
await self.vacuum.async_set({"118": True}, None)
await self.vacuum.async_set({"118": True})
elif command == "roomClean":
roomIds = params.get("roomIds", [1])
count = params.get("count", 1)
@ -410,9 +447,7 @@ 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}, None)
await asyncio.sleep(1)
self.async_update
await self.vacuum.async_set({"124": base64_str})
async def async_will_remove_from_hass(self):
await self.vacuum.async_disconnect()
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 = []
setup(
name='robovac',
name="robovac",
version="1.0",
author='Luke Bonaccorsi',
url='http://github.com/codefoodpixels/robovac',
author="Luke Morrigan",
url="http://github.com/codefoodpixels/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",
],
)