Compare commits
67 Commits
merge-to-u
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
f397319070 | |
|
|
7e60ecd2b4 | |
|
|
c687f111eb | |
|
|
b180896a9c | |
|
|
2968d722f8 | |
|
|
cdcad837b7 | |
|
|
2c741fe32e | |
|
|
3dd4a7b0e0 | |
|
|
c251501aed | |
|
|
0b18494fb1 | |
|
|
ac0dbdd11a | |
|
|
96a155d378 | |
|
|
3e2b923255 | |
|
|
79892aa98f | |
|
|
a8601bbe90 | |
|
|
88ef4a6e25 | |
|
|
8676cb3fad | |
|
|
94ae2b55ea | |
|
|
4f1bdb3bac | |
|
|
8e6e311bfc | |
|
|
19aefa8e65 | |
|
|
715be42d93 | |
|
|
ae189ce422 | |
|
|
abb8285e2f | |
|
|
fa9009e43b | |
|
|
c342eefc16 | |
|
|
bea3817c5e | |
|
|
40480cc319 | |
|
|
b48d12e05e | |
|
|
5b787b9820 | |
|
|
29a69215ca | |
|
|
216daccd8d | |
|
|
f8a2e3ca2e | |
|
|
949edc6b41 | |
|
|
7f5cc8c30f | |
|
|
8d8428a359 | |
|
|
71c87f460c | |
|
|
8256940aa0 | |
|
|
3625657886 | |
|
|
0874e359d4 | |
|
|
01024d8e56 | |
|
|
7f344d38bf | |
|
|
bba9febbb3 | |
|
|
7b26f4a22e | |
|
|
89a578c87b | |
|
|
1bc4b99160 | |
|
|
711997cb13 | |
|
|
edd93b1469 | |
|
|
26cbc26af1 | |
|
|
59c7dc8033 | |
|
|
b25acad570 | |
|
|
4f111af975 | |
|
|
255f164e6d | |
|
|
f4e4a647ef | |
|
|
7c57572a09 | |
|
|
96fd1563a0 | |
|
|
18868a5ffb | |
|
|
6a1040ad3f | |
|
|
8cc04e7ba2 | |
|
|
fa2c390748 | |
|
|
c1731e407e | |
|
|
f72f204b86 | |
|
|
7c6ff91b67 | |
|
|
dd0c080ff8 | |
|
|
036fba8caa | |
|
|
485306c6fe | |
|
|
42c10ada98 |
|
|
@ -0,0 +1,12 @@
|
||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = true
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
github: CodeFoodPixels
|
||||||
|
ko_fi: codefoodpixels
|
||||||
|
|
@ -4,38 +4,28 @@ name: CI
|
||||||
|
|
||||||
# Controls when the workflow will run
|
# Controls when the workflow will run
|
||||||
on:
|
on:
|
||||||
# Triggers the workflow on push or pull request events but only for the "main" branch
|
# Triggers the workflow on push or pull request events but only for the "main" branch
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: ["main"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: ["main"]
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||||
jobs:
|
jobs:
|
||||||
# This workflow contains a single job called "build"
|
# This workflow contains a single job called "build"
|
||||||
build:
|
build:
|
||||||
# The type of runner that the job will run on
|
# The type of runner that the job will run on
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||||
steps:
|
steps:
|
||||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: "home-assistant/actions/hassfest@master"
|
- uses: "home-assistant/actions/hassfest@master"
|
||||||
- name: HACS Action
|
- name: HACS Action
|
||||||
uses: "hacs/action@main"
|
uses: "hacs/action@main"
|
||||||
with:
|
with:
|
||||||
category: "integration"
|
category: "integration"
|
||||||
|
|
||||||
# Runs a single command using the runners shell
|
|
||||||
- name: Run a one-line script
|
|
||||||
run: echo Hello, world!
|
|
||||||
|
|
||||||
# Runs a set of commands using the runners shell
|
|
||||||
- name: Run a multi-line script
|
|
||||||
run: |
|
|
||||||
echo Add other actions to build,
|
|
||||||
echo test, and deploy your project.
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/node_modules
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"ms-python.black-formatter",
|
||||||
|
"ms-python.python"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
[](https://github.com/custom-components/hacs)
|
[](https://github.com/custom-components/hacs)
|
||||||
<a href="https://www.buymeacoffee.com/bmccluskey" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
|
||||||
|
[](https://github.com/sponsors/CodeFoodPixels)
|
||||||
|
[](https://ko-fi.com/O5O3O08PA)
|
||||||
|
[](https://paypal.me/CodeFoodPixels)
|
||||||
|
[](https://monzo.me/codefoodpixels)
|
||||||
|
|
||||||
# Eufy RobovVac control for Home Assistant
|
# Eufy RobovVac control for Home Assistant
|
||||||
|
|
||||||
A brand new version Eufy RoboVac integration for Home Assistant that includes a Config Flow to add your RoboVac(s) and the local key and ID required. All you need to do is enter your Eufy app credentials and the Config Flow will look up the details for you. After the initial config use the configuration button on the Integration to enter the RoboVac IP address when prompted.
|
A brand new version Eufy RoboVac integration for Home Assistant that includes a Config Flow to add your RoboVac(s) and the local key and ID required. All you need to do is enter your Eufy app credentials and the Config Flow will look up the details for you. After the initial config use the configuration button on the Integration to enter the RoboVac IP address when prompted.
|
||||||
|
|
|
||||||
|
|
@ -15,39 +15,83 @@
|
||||||
|
|
||||||
"""The Eufy Robovac integration."""
|
"""The Eufy Robovac integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform, CONF_IP_ADDRESS
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from .const import DOMAIN
|
from .const import CONF_VACS, DOMAIN
|
||||||
|
|
||||||
PLATFORMS = [Platform.VACUUM]
|
from .tuyalocaldiscovery import TuyaLocalDiscovery
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.VACUUM, Platform.SENSOR]
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass, entry) -> bool:
|
||||||
|
hass.data.setdefault(DOMAIN, {CONF_VACS:{}})
|
||||||
|
|
||||||
|
async def update_device(device):
|
||||||
|
entry = async_get_config_entry_for_device(hass, device["gwId"])
|
||||||
|
|
||||||
|
if entry == None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not entry.state.recoverable:
|
||||||
|
return
|
||||||
|
|
||||||
|
hass_data = entry.data.copy()
|
||||||
|
if (
|
||||||
|
device["gwId"] in hass_data[CONF_VACS]
|
||||||
|
and device.get("ip") is not None
|
||||||
|
and hass_data[CONF_VACS][device["gwId"]].get("autodiscovery", True)
|
||||||
|
):
|
||||||
|
if hass_data[CONF_VACS][device["gwId"]][CONF_IP_ADDRESS] != device["ip"]:
|
||||||
|
hass_data[CONF_VACS][device["gwId"]][CONF_IP_ADDRESS] = device["ip"]
|
||||||
|
hass.config_entries.async_update_entry(entry, data=hass_data)
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Updated ip address of {} to {}".format(
|
||||||
|
device["gwId"], device["ip"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
tuyalocaldiscovery = TuyaLocalDiscovery(update_device)
|
||||||
|
try:
|
||||||
|
await tuyalocaldiscovery.start()
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, tuyalocaldiscovery.close)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("failed to set up discovery")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Eufy Robovac from a config entry."""
|
"""Set up Eufy Robovac from a config entry."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
|
||||||
|
|
||||||
# hass_data = dict(entry.data)
|
|
||||||
# Registers update listener to update config entry when options are updated.
|
|
||||||
# unsub_options_update_listener = entry.add_update_listener(options_update_listener)
|
|
||||||
# Store a reference to the unsubscribe function to cleanup if an entry is unloaded.
|
|
||||||
# hass_data["unsub_options_update_listener"] = unsub_options_update_listener
|
|
||||||
# hass.data[DOMAIN][entry.entry_id] = hass_data
|
|
||||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||||
|
entry, PLATFORMS
|
||||||
|
):
|
||||||
"""Nothing"""
|
"""Nothing"""
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def update_listener(hass, entry):
|
async def update_listener(hass, entry):
|
||||||
"""Handle options update."""
|
"""Handle options update."""
|
||||||
await async_unload_entry(hass, entry)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
await async_setup_entry(hass, entry)
|
|
||||||
|
|
||||||
|
def async_get_config_entry_for_device(hass, device_id):
|
||||||
|
current_entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
for entry in current_entries:
|
||||||
|
if device_id in entry.data[CONF_VACS]:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
"""Config flow for Eufy Robovac integration."""
|
"""Config flow for Eufy Robovac integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
@ -39,11 +40,20 @@ from homeassistant.const import (
|
||||||
CONF_IP_ADDRESS,
|
CONF_IP_ADDRESS,
|
||||||
CONF_DESCRIPTION,
|
CONF_DESCRIPTION,
|
||||||
CONF_MAC,
|
CONF_MAC,
|
||||||
CONF_LOCATION,
|
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
|
CONF_REGION,
|
||||||
|
CONF_TIME_ZONE,
|
||||||
|
CONF_COUNTRY_CODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .const import DOMAIN, CONF_VACS, CONF_PHONE_CODE
|
from .countries import (
|
||||||
|
get_phone_code_by_country_code,
|
||||||
|
get_phone_code_by_region,
|
||||||
|
get_region_by_country_code,
|
||||||
|
get_region_by_phone_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import CONF_AUTODISCOVERY, DOMAIN, CONF_VACS
|
||||||
|
|
||||||
from .tuyawebapi import TuyaAPISession
|
from .tuyawebapi import TuyaAPISession
|
||||||
from .eufywebapi import EufyLogon
|
from .eufywebapi import EufyLogon
|
||||||
|
|
@ -77,32 +87,77 @@ def get_eufy_vacuums(self):
|
||||||
)
|
)
|
||||||
|
|
||||||
device_response = response.json()
|
device_response = response.json()
|
||||||
self[CONF_CLIENT_ID] = user_response["user_info"]["id"]
|
|
||||||
self[CONF_PHONE_CODE] = user_response["user_info"]["phone_code"]
|
|
||||||
|
|
||||||
# self[CONF_VACS] = {}
|
response = eufy_session.get_user_settings(
|
||||||
items = device_response["items"]
|
user_response["user_info"]["request_host"],
|
||||||
allvacs = {}
|
user_response["user_info"]["id"],
|
||||||
for item in items:
|
user_response["access_token"],
|
||||||
if item["device"]["product"]["appliance"] == "Cleaning":
|
)
|
||||||
vac_details = {
|
settings_response = response.json()
|
||||||
CONF_ID: item["device"]["id"],
|
|
||||||
CONF_MODEL: item["device"]["product"]["product_code"],
|
self[CONF_CLIENT_ID] = user_response["user_info"]["id"]
|
||||||
CONF_NAME: item["device"]["alias_name"],
|
if (
|
||||||
CONF_DESCRIPTION: item["device"]["name"],
|
"tuya_home" in settings_response["setting"]["home_setting"]
|
||||||
CONF_MAC: item["device"]["wifi"]["mac"],
|
and "tuya_region_code"
|
||||||
CONF_IP_ADDRESS: "",
|
in settings_response["setting"]["home_setting"]["tuya_home"]
|
||||||
}
|
):
|
||||||
allvacs[item["device"]["id"]] = vac_details
|
self[CONF_REGION] = settings_response["setting"]["home_setting"]["tuya_home"][
|
||||||
self[CONF_VACS] = allvacs
|
"tuya_region_code"
|
||||||
|
]
|
||||||
|
if user_response["user_info"]["phone_code"]:
|
||||||
|
self[CONF_COUNTRY_CODE] = user_response["user_info"]["phone_code"]
|
||||||
|
else:
|
||||||
|
self[CONF_COUNTRY_CODE] = get_phone_code_by_region(self[CONF_REGION])
|
||||||
|
elif user_response["user_info"]["phone_code"]:
|
||||||
|
self[CONF_REGION] = get_region_by_phone_code(
|
||||||
|
user_response["user_info"]["phone_code"]
|
||||||
|
)
|
||||||
|
self[CONF_COUNTRY_CODE] = user_response["user_info"]["phone_code"]
|
||||||
|
elif user_response["user_info"]["country"]:
|
||||||
|
self[CONF_REGION] = get_region_by_country_code(
|
||||||
|
user_response["user_info"]["country"]
|
||||||
|
)
|
||||||
|
self[CONF_COUNTRY_CODE] = get_phone_code_by_country_code(
|
||||||
|
user_response["user_info"]["country"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self[CONF_REGION] = "EU"
|
||||||
|
self[CONF_COUNTRY_CODE] = "44"
|
||||||
|
|
||||||
|
self[CONF_TIME_ZONE] = user_response["user_info"]["timezone"]
|
||||||
|
|
||||||
tuya_client = TuyaAPISession(
|
tuya_client = TuyaAPISession(
|
||||||
username="eh-" + self[CONF_CLIENT_ID], country_code=self[CONF_PHONE_CODE]
|
username="eh-" + self[CONF_CLIENT_ID],
|
||||||
|
region=self[CONF_REGION],
|
||||||
|
timezone=self[CONF_TIME_ZONE],
|
||||||
|
phone_code=self[CONF_COUNTRY_CODE],
|
||||||
)
|
)
|
||||||
for home in tuya_client.list_homes():
|
|
||||||
for device in tuya_client.list_devices(home["groupId"]):
|
items = device_response["items"]
|
||||||
self[CONF_VACS][device["devId"]][CONF_ACCESS_TOKEN] = device["localKey"]
|
self[CONF_VACS] = {}
|
||||||
self[CONF_VACS][device["devId"]][CONF_LOCATION] = home["groupId"]
|
for item in items:
|
||||||
|
if item["device"]["product"]["appliance"] == "Cleaning":
|
||||||
|
try:
|
||||||
|
device = tuya_client.get_device(item["device"]["id"])
|
||||||
|
|
||||||
|
vac_details = {
|
||||||
|
CONF_ID: item["device"]["id"],
|
||||||
|
CONF_MODEL: item["device"]["product"]["product_code"],
|
||||||
|
CONF_NAME: item["device"]["alias_name"],
|
||||||
|
CONF_DESCRIPTION: item["device"]["name"],
|
||||||
|
CONF_MAC: item["device"]["wifi"]["mac"],
|
||||||
|
CONF_IP_ADDRESS: "",
|
||||||
|
CONF_AUTODISCOVERY: True,
|
||||||
|
CONF_ACCESS_TOKEN: device["localKey"],
|
||||||
|
}
|
||||||
|
self[CONF_VACS][item["device"]["id"]] = vac_details
|
||||||
|
except:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Skipping vacuum {}: found on Eufy but not on Tuya. Eufy details:".format(
|
||||||
|
item["device"]["id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_LOGGER.debug(json.dumps(item["device"], indent=2))
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -132,8 +187,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception as e: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception: {}".format(e))
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(unique_id)
|
await self.async_set_unique_id(unique_id)
|
||||||
|
|
@ -164,47 +219,64 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
|
|
||||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
|
self.selected_vacuum = None
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(self, user_input=None):
|
||||||
self, user_input: dict[str, Any] = None
|
errors = {}
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Manage the options for the custom component."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
vac_names = []
|
|
||||||
vacuums = self.config_entry.data[CONF_VACS]
|
|
||||||
for item in vacuums:
|
|
||||||
item_settings = vacuums[item]
|
|
||||||
vac_names.append(item_settings["name"])
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
for item in vacuums:
|
self.selected_vacuum = user_input["selected_vacuum"]
|
||||||
item_settings = vacuums[item]
|
return await self.async_step_edit()
|
||||||
if item_settings["name"] == user_input["vacuum"]:
|
|
||||||
item_settings[CONF_IP_ADDRESS] = user_input[CONF_IP_ADDRESS]
|
|
||||||
updated_repos = deepcopy(self.config_entry.data[CONF_VACS])
|
|
||||||
|
|
||||||
# print("Updated", updated_repos)
|
vacuums_config = self.config_entry.data[CONF_VACS]
|
||||||
if not errors:
|
vacuum_list = {}
|
||||||
# Value of data will be set on the options property of our config_entry
|
for vacuum_id in vacuums_config:
|
||||||
# instance.
|
vacuum_list[vacuum_id] = vacuums_config[vacuum_id]["name"]
|
||||||
return self.async_create_entry(
|
|
||||||
title="",
|
devices_schema = vol.Schema(
|
||||||
data={CONF_VACS: updated_repos},
|
{vol.Required("selected_vacuum"): vol.In(vacuum_list)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init", data_schema=devices_schema, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_edit(self, user_input=None):
|
||||||
|
"""Manage the options for the custom component."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
vacuums = self.config_entry.data[CONF_VACS]
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
updated_vacuums = deepcopy(vacuums)
|
||||||
|
updated_vacuums[self.selected_vacuum][CONF_AUTODISCOVERY] = user_input[
|
||||||
|
CONF_AUTODISCOVERY
|
||||||
|
]
|
||||||
|
if user_input[CONF_IP_ADDRESS]:
|
||||||
|
updated_vacuums[self.selected_vacuum][CONF_IP_ADDRESS] = user_input[
|
||||||
|
CONF_IP_ADDRESS
|
||||||
|
]
|
||||||
|
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.config_entry,
|
||||||
|
data={CONF_VACS: updated_vacuums},
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_create_entry(title="", data={})
|
||||||
|
|
||||||
options_schema = vol.Schema(
|
options_schema = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional("vacuum", default=1): selector(
|
vol.Required(
|
||||||
{
|
CONF_AUTODISCOVERY,
|
||||||
"select": {
|
default=vacuums[self.selected_vacuum].get(CONF_AUTODISCOVERY, True),
|
||||||
"options": vac_names,
|
): bool,
|
||||||
}
|
vol.Optional(
|
||||||
}
|
CONF_IP_ADDRESS,
|
||||||
),
|
default=vacuums[self.selected_vacuum].get(CONF_IP_ADDRESS),
|
||||||
vol.Optional(CONF_IP_ADDRESS): cv.string,
|
): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init", data_schema=options_schema, errors=errors
|
step_id="edit", data_schema=options_schema, errors=errors
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,7 @@
|
||||||
|
|
||||||
DOMAIN = "robovac"
|
DOMAIN = "robovac"
|
||||||
CONF_VACS = "vacuums"
|
CONF_VACS = "vacuums"
|
||||||
CONF_PHONE_CODE = "phone_code"
|
CONF_AUTODISCOVERY = "autodiscovery"
|
||||||
|
REFRESH_RATE = 60
|
||||||
|
PING_RATE = 10
|
||||||
|
TIMEOUT = 5
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -1,37 +1,39 @@
|
||||||
ERROR_MESSAGES = {
|
ERROR_MESSAGES = {
|
||||||
"IP_ADDRESS": "IP Address not set",
|
"IP_ADDRESS": "IP Address not set",
|
||||||
|
"CONNECTION_FAILED": "Connection to the vacuum failed",
|
||||||
|
"UNSUPPORTED_MODEL": "This model is not supported",
|
||||||
"no_error": "None",
|
"no_error": "None",
|
||||||
1:"Error: Front bumper stuck",
|
1:"Front bumper stuck",
|
||||||
2:"Error: Wheel stuck",
|
2:"Wheel stuck",
|
||||||
3:"Error: Side brush",
|
3:"Side brush",
|
||||||
4:"Error: Rolling brush bar stuck",
|
4:"Rolling brush bar stuck",
|
||||||
5:"Error: Device trapped",
|
5:"Device trapped",
|
||||||
6:"Error: Device trapped",
|
6:"Device trapped",
|
||||||
7:"Error: Wheel suspended",
|
7:"Wheel suspended",
|
||||||
8:"Error: Low battery",
|
8:"Low battery",
|
||||||
9:"Error: Magnetic boundary",
|
9:"Magnetic boundary",
|
||||||
12:"Error: Right wall sensor",
|
12:"Right wall sensor",
|
||||||
13:"Error: Device tilted",
|
13:"Device tilted",
|
||||||
14:"Error: Insert dust collector",
|
14:"Insert dust collector",
|
||||||
17:"Error: Restricted area detected",
|
17:"Restricted area detected",
|
||||||
18:"Error: Laser cover stuck",
|
18:"Laser cover stuck",
|
||||||
19:"Error: Laser sesor stuck",
|
19:"Laser sesor stuck",
|
||||||
20:"Error: Laser sensor blocked",
|
20:"Laser sensor blocked",
|
||||||
21:"Error: Base blocked",
|
21:"Base blocked",
|
||||||
"S1":"Error: Battery",
|
"S1":"Battery",
|
||||||
"S2":"Error: Wheel Module",
|
"S2":"Wheel Module",
|
||||||
"S3":"Error: Side Brush",
|
"S3":"Side Brush",
|
||||||
"S4":"Error: Suction Fan",
|
"S4":"Suction Fan",
|
||||||
"S5":"Error: Rolling Brush",
|
"S5":"Rolling Brush",
|
||||||
"S8":"Error: Path Tracking Sensor",
|
"S8":"Path Tracking Sensor",
|
||||||
"Wheel_stuck":"Error: Wheel stuck",
|
"Wheel_stuck":"Wheel stuck",
|
||||||
"R_brush_stuck":"Error: Rolling brush stuck",
|
"R_brush_stuck":"Rolling brush stuck",
|
||||||
"Crash_bar_stuck":"Error: Front bumper stuck",
|
"Crash_bar_stuck":"Front bumper stuck",
|
||||||
"sensor_dirty":"Error: Sensor dirty",
|
"sensor_dirty":"Sensor dirty",
|
||||||
"N_enough_pow":"Error: Low battery",
|
"N_enough_pow":"Low battery",
|
||||||
"Stuck_5_min":"Error: Device trapped",
|
"Stuck_5_min":"Device trapped",
|
||||||
"Fan_stuck":"Error: Fan stuck",
|
"Fan_stuck":"Fan stuck",
|
||||||
"S_brush_stuck":"Error: Side brush stuck",
|
"S_brush_stuck":"Side brush stuck",
|
||||||
}
|
}
|
||||||
|
|
||||||
def getErrorMessage(code):
|
def getErrorMessage(code):
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,16 @@ class EufyLogon:
|
||||||
"password": self.password,
|
"password": self.password,
|
||||||
}
|
}
|
||||||
|
|
||||||
return requests.post(
|
return requests.post(login_url, json=login_auth, headers=eufyheaders)
|
||||||
login_url, json=login_auth, headers=eufyheaders, timeout=1.5
|
|
||||||
)
|
def get_user_settings(self, url, userid, token):
|
||||||
|
setting_url = url + "/v1/user/setting"
|
||||||
|
eufyheaders["token"] = token
|
||||||
|
eufyheaders["id"] = userid
|
||||||
|
return requests.request("GET", setting_url, headers=eufyheaders, timeout=1.5)
|
||||||
|
|
||||||
def get_device_info(self, url, userid, token):
|
def get_device_info(self, url, userid, token):
|
||||||
device_url = url + "/v1/device/list/devices-and-groups"
|
device_url = url + "/v1/device/list/devices-and-groups"
|
||||||
eufyheaders["token"] = token
|
eufyheaders["token"] = token
|
||||||
eufyheaders["id"] = userid
|
eufyheaders["id"] = userid
|
||||||
return requests.request("GET", device_url, headers=eufyheaders, timeout=1.5)
|
return requests.request("GET", device_url, headers=eufyheaders)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
{
|
{
|
||||||
"domain": "robovac",
|
"domain": "robovac",
|
||||||
"name": "Eufy Robovac",
|
"name": "Eufy Robovac",
|
||||||
"config_flow": true,
|
"codeowners": ["@codefoodpixels"],
|
||||||
"documentation": "https://github.com/bmccluskey/robovac",
|
"config_flow": true,
|
||||||
"issue_tracker": "https://github.com/bmccluskey/robovac/issues",
|
"dependencies": [],
|
||||||
"requirements": [],
|
"documentation": "https://github.com/codefoodpixels/robovac",
|
||||||
"ssdp": [],
|
"integration_type": "device",
|
||||||
"zeroconf": [],
|
"iot_class": "local_polling",
|
||||||
"homekit": {},
|
"issue_tracker": "https://github.com/codefoodpixels/robovac/issues",
|
||||||
"dependencies": [],
|
"requirements": [],
|
||||||
"codeowners": ["@bmccluskey"],
|
"version": "1.0.0"
|
||||||
"iot_class": "local_polling",
|
|
||||||
"version": "1"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ class RoboVacEntityFeature(IntEnum):
|
||||||
BOOST_IQ = 1024
|
BOOST_IQ = 1024
|
||||||
|
|
||||||
|
|
||||||
HAS_MAP_FEATURE = ["T2261", "T2262"]
|
|
||||||
|
|
||||||
ROBOVAC_SERIES = {
|
ROBOVAC_SERIES = {
|
||||||
"C": [
|
"C": [
|
||||||
"T2103",
|
"T2103",
|
||||||
|
|
@ -31,6 +29,7 @@ ROBOVAC_SERIES = {
|
||||||
"T2123",
|
"T2123",
|
||||||
"T2128",
|
"T2128",
|
||||||
"T2130",
|
"T2130",
|
||||||
|
"T2132",
|
||||||
],
|
],
|
||||||
"G": [
|
"G": [
|
||||||
"T1250",
|
"T1250",
|
||||||
|
|
@ -38,34 +37,65 @@ ROBOVAC_SERIES = {
|
||||||
"T2251",
|
"T2251",
|
||||||
"T2252",
|
"T2252",
|
||||||
"T2253",
|
"T2253",
|
||||||
|
"T2254",
|
||||||
"T2150",
|
"T2150",
|
||||||
"T2255",
|
"T2255",
|
||||||
|
"T2256",
|
||||||
|
"T2257",
|
||||||
|
"T2258",
|
||||||
|
"T2259",
|
||||||
|
"T2270",
|
||||||
|
"T2272",
|
||||||
|
"T2273",
|
||||||
],
|
],
|
||||||
"X": ["T2261", "T2262"],
|
"L": ["T2181", "T2182", "T2190", "T2192", "T2193", "T2194"],
|
||||||
|
"X": ["T2261", "T2262", "T2320"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HAS_MAP_FEATURE = ["T2253", *ROBOVAC_SERIES["L"], *ROBOVAC_SERIES["X"]]
|
||||||
|
|
||||||
|
HAS_CONSUMABLES = [
|
||||||
|
"T1250",
|
||||||
|
"T2181",
|
||||||
|
"T2182",
|
||||||
|
"T2190",
|
||||||
|
"T2193",
|
||||||
|
"T2194",
|
||||||
|
"T2253",
|
||||||
|
"T2256",
|
||||||
|
"T2258",
|
||||||
|
"T2261",
|
||||||
|
"T2273",
|
||||||
|
"T2320",
|
||||||
|
]
|
||||||
|
|
||||||
ROBOVAC_SERIES_FEATURES = {
|
ROBOVAC_SERIES_FEATURES = {
|
||||||
"C": RoboVacEntityFeature.EDGE | RoboVacEntityFeature.SMALL_ROOM,
|
"C": RoboVacEntityFeature.EDGE | RoboVacEntityFeature.SMALL_ROOM,
|
||||||
"G": RoboVacEntityFeature.CLEANING_TIME
|
"G": RoboVacEntityFeature.CLEANING_TIME
|
||||||
| RoboVacEntityFeature.CLEANING_AREA
|
| RoboVacEntityFeature.CLEANING_AREA
|
||||||
| RoboVacEntityFeature.DO_NOT_DISTURB
|
| RoboVacEntityFeature.DO_NOT_DISTURB
|
||||||
|
| RoboVacEntityFeature.AUTO_RETURN,
|
||||||
|
"L": RoboVacEntityFeature.CLEANING_TIME
|
||||||
|
| RoboVacEntityFeature.CLEANING_AREA
|
||||||
|
| RoboVacEntityFeature.DO_NOT_DISTURB
|
||||||
| RoboVacEntityFeature.AUTO_RETURN
|
| RoboVacEntityFeature.AUTO_RETURN
|
||||||
| RoboVacEntityFeature.CONSUMABLES,
|
| RoboVacEntityFeature.ROOM
|
||||||
|
| RoboVacEntityFeature.ZONE
|
||||||
|
| RoboVacEntityFeature.BOOST_IQ,
|
||||||
"X": RoboVacEntityFeature.CLEANING_TIME
|
"X": RoboVacEntityFeature.CLEANING_TIME
|
||||||
| RoboVacEntityFeature.CLEANING_AREA
|
| RoboVacEntityFeature.CLEANING_AREA
|
||||||
| RoboVacEntityFeature.DO_NOT_DISTURB
|
| RoboVacEntityFeature.DO_NOT_DISTURB
|
||||||
| RoboVacEntityFeature.AUTO_RETURN
|
| RoboVacEntityFeature.AUTO_RETURN
|
||||||
| RoboVacEntityFeature.CONSUMABLES
|
|
||||||
| RoboVacEntityFeature.ROOM
|
| RoboVacEntityFeature.ROOM
|
||||||
| RoboVacEntityFeature.ZONE
|
| RoboVacEntityFeature.ZONE
|
||||||
| RoboVacEntityFeature.MAP
|
|
||||||
| RoboVacEntityFeature.BOOST_IQ,
|
| RoboVacEntityFeature.BOOST_IQ,
|
||||||
}
|
}
|
||||||
|
|
||||||
ROBOVAC_SERIES_FAN_SPEEDS = {
|
ROBOVAC_SERIES_FAN_SPEEDS = {
|
||||||
"C": ["No Suction", "Standard", "Boost IQ", "Max"],
|
"C": ["No Suction", "Standard", "Boost IQ", "Max"],
|
||||||
"G": ["Standard", "Turbo", "Max", "Boost IQ"],
|
"G": ["Standard", "Turbo", "Max", "Boost IQ"],
|
||||||
"X": ["Pure", "Standard", "Turbo", "Max"],
|
"L": ["Quiet", "Standard", "Turbo", "Max"],
|
||||||
|
"X": ["Pure", "Standard", "Turbo", "Max"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -73,6 +103,7 @@ SUPPORTED_ROBOVAC_MODELS = list(
|
||||||
set([item for sublist in ROBOVAC_SERIES.values() for item in sublist])
|
set([item for sublist in ROBOVAC_SERIES.values() for item in sublist])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelNotSupportedException(Exception):
|
class ModelNotSupportedException(Exception):
|
||||||
"""This model is not supported"""
|
"""This model is not supported"""
|
||||||
|
|
||||||
|
|
@ -109,13 +140,20 @@ class RoboVac(TuyaDevice):
|
||||||
return supportedFeatures
|
return supportedFeatures
|
||||||
|
|
||||||
def getRoboVacFeatures(self):
|
def getRoboVacFeatures(self):
|
||||||
return ROBOVAC_SERIES_FEATURES[self.getRoboVacSeries()]
|
supportedFeatures = ROBOVAC_SERIES_FEATURES[self.getRoboVacSeries()]
|
||||||
|
|
||||||
|
if self.model_code in HAS_MAP_FEATURE:
|
||||||
|
supportedFeatures |= RoboVacEntityFeature.MAP
|
||||||
|
|
||||||
|
if self.model_code in HAS_CONSUMABLES:
|
||||||
|
supportedFeatures |= RoboVacEntityFeature.CONSUMABLES
|
||||||
|
|
||||||
|
return supportedFeatures
|
||||||
|
|
||||||
def getRoboVacSeries(self):
|
def getRoboVacSeries(self):
|
||||||
for series, models in ROBOVAC_SERIES.items():
|
for series, models in ROBOVAC_SERIES.items():
|
||||||
if self.model_code in models:
|
if self.model_code in models:
|
||||||
return series
|
return series
|
||||||
|
|
||||||
|
|
||||||
def getFanSpeeds(self):
|
def getFanSpeeds(self):
|
||||||
return ROBOVAC_SERIES_FAN_SPEEDS[self.getRoboVacSeries()]
|
return ROBOVAC_SERIES_FAN_SPEEDS[self.getRoboVacSeries()]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -1,21 +1,40 @@
|
||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"abort": {
|
||||||
"user": {
|
"already_configured": "Device is already configured"
|
||||||
"data": {
|
},
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"error": {
|
||||||
"password": "[%key:common::config_flow::data::password%]"
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "Host",
|
||||||
|
"password": "Password",
|
||||||
|
"username": "Username"
|
||||||
|
},
|
||||||
|
"description": "Enter your Eufy account details"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"error": {
|
"options": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"step": {
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
"init": {
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"title": "Manage vacuums",
|
||||||
},
|
"data": {
|
||||||
"abort": {
|
"selected_vacuum": "Select the Vacuum to edit"
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Edit vacuum",
|
||||||
|
"data": {
|
||||||
|
"autodiscovery": "Enable autodiscovery",
|
||||||
|
"ip_address": "IP Address"
|
||||||
|
},
|
||||||
|
"description": "Autodiscovery will automatically update the IP address"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,26 @@
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"username": "Username"
|
"username": "Username"
|
||||||
},
|
},
|
||||||
"description": "Enter your Eufy account details"
|
"description": "Enter your Eufy account details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"error": {
|
|
||||||
"invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository."
|
|
||||||
},
|
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Manage IPs",
|
"title": "Manage vacuums",
|
||||||
"data": {
|
"data": {
|
||||||
"vacuum": "Select the Vacuum to edit",
|
"selected_vacuum": "Select the Vacuum to edit"
|
||||||
"ip_address": "IP address of vacuum"
|
}
|
||||||
},
|
},
|
||||||
"description": "Add or update a vacuums IP address"
|
"edit": {
|
||||||
}
|
"title": "Edit vacuum",
|
||||||
|
"data": {
|
||||||
|
"autodiscovery": "Enable autodiscovery",
|
||||||
|
"ip_address": "IP Address"
|
||||||
|
},
|
||||||
|
"description": "Autodiscovery will automatically update the IP address"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,13 +46,17 @@ import socket
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Callable, Coroutine
|
||||||
|
|
||||||
from cryptography.hazmat.backends.openssl import backend as openssl_backend
|
from cryptography.hazmat.backends.openssl import backend as openssl_backend
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
from cryptography.hazmat.primitives.hashes import Hash, MD5
|
from cryptography.hazmat.primitives.hashes import Hash, MD5
|
||||||
from cryptography.hazmat.primitives.padding import PKCS7
|
from cryptography.hazmat.primitives.padding import PKCS7
|
||||||
|
|
||||||
|
INITIAL_BACKOFF = 5
|
||||||
|
INITIAL_QUEUE_TIME = 0.1
|
||||||
|
BACKOFF_MULTIPLIER = 1.70224
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
MESSAGE_PREFIX_FORMAT = ">IIII"
|
MESSAGE_PREFIX_FORMAT = ">IIII"
|
||||||
MESSAGE_SUFFIX_FORMAT = ">II"
|
MESSAGE_SUFFIX_FORMAT = ">II"
|
||||||
|
|
@ -347,6 +351,14 @@ class RequestResponseCommandMismatch(TuyaException):
|
||||||
"""The command in the response didn't match the one from the request."""
|
"""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:
|
class TuyaCipher:
|
||||||
"""Tuya cryptographic helpers."""
|
"""Tuya cryptographic helpers."""
|
||||||
|
|
||||||
|
|
@ -435,27 +447,39 @@ def crc(data):
|
||||||
|
|
||||||
|
|
||||||
class Message:
|
class Message:
|
||||||
|
|
||||||
PING_COMMAND = 0x09
|
PING_COMMAND = 0x09
|
||||||
GET_COMMAND = 0x0A
|
GET_COMMAND = 0x0A
|
||||||
SET_COMMAND = 0x07
|
SET_COMMAND = 0x07
|
||||||
GRATUITOUS_UPDATE = 0x08
|
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:
|
if payload is None:
|
||||||
payload = b""
|
payload = b""
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.command = command
|
self.command = command
|
||||||
|
self.original_sequence = sequence
|
||||||
if sequence is None:
|
if sequence is None:
|
||||||
# Use millisecond process time as the sequence number. Not ideal,
|
self.set_sequence()
|
||||||
# but good for one month's continuous connection time though.
|
else:
|
||||||
sequence = int(time.perf_counter() * 1000) & 0xFFFFFFFF
|
self.sequence = sequence
|
||||||
self.sequence = sequence
|
self.encrypt = encrypt
|
||||||
self.encrypt = False
|
self.device = device
|
||||||
self.device = None
|
self.expiry = int(time.time()) + ttl
|
||||||
if encrypt_for is not None:
|
self.expect_response = expect_response
|
||||||
self.device = encrypt_for
|
self.listener = None
|
||||||
self.encrypt = True
|
if expect_response is True:
|
||||||
|
self.listener = asyncio.Semaphore(0)
|
||||||
|
if device is not None:
|
||||||
|
device._listeners[self.sequence] = self.listener
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "{}({}, {!r}, {!r}, {})".format(
|
return "{}({}, {!r}, {!r}, {})".format(
|
||||||
|
|
@ -466,6 +490,9 @@ class Message:
|
||||||
"<Device {}>".format(self.device) if self.device else None,
|
"<Device {}>".format(self.device) if self.device else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_sequence(self):
|
||||||
|
self.sequence = int(time.perf_counter() * 1000) & 0xFFFFFFFF
|
||||||
|
|
||||||
def hex(self):
|
def hex(self):
|
||||||
return self.bytes().hex()
|
return self.bytes().hex()
|
||||||
|
|
||||||
|
|
@ -497,39 +524,11 @@ class Message:
|
||||||
|
|
||||||
__bytes__ = bytes
|
__bytes__ = bytes
|
||||||
|
|
||||||
class AsyncWrappedCallback:
|
async def async_send(self):
|
||||||
def __init__(self, request, callback):
|
await self.device._async_send(self)
|
||||||
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)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, data, cipher=None):
|
def from_bytes(cls, device, data, cipher=None):
|
||||||
try:
|
try:
|
||||||
prefix, sequence, command, payload_size = struct.unpack_from(
|
prefix, sequence, command, payload_size = struct.unpack_from(
|
||||||
MESSAGE_PREFIX_FORMAT, data
|
MESSAGE_PREFIX_FORMAT, data
|
||||||
|
|
@ -586,53 +585,37 @@ class Message:
|
||||||
try:
|
try:
|
||||||
payload_text = payload_data.decode("utf8")
|
payload_text = payload_data.decode("utf8")
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError as e:
|
||||||
_LOGGER.debug(payload_data.hex())
|
device._LOGGER.debug(payload_data.hex())
|
||||||
_LOGGER.error(e)
|
device._LOGGER.error(e)
|
||||||
raise MessageDecodeFailed() from e
|
raise MessageDecodeFailed() from e
|
||||||
try:
|
try:
|
||||||
payload = json.loads(payload_text)
|
payload = json.loads(payload_text)
|
||||||
except json.decoder.JSONDecodeError as e:
|
except json.decoder.JSONDecodeError as e:
|
||||||
# data may be encrypted
|
# data may be encrypted
|
||||||
_LOGGER.debug(payload_data.hex())
|
device._LOGGER.debug(payload_data.hex())
|
||||||
_LOGGER.error(e)
|
device._LOGGER.error(e)
|
||||||
raise MessageDecodeFailed() from e
|
raise MessageDecodeFailed() from e
|
||||||
|
|
||||||
return cls(command, payload, sequence)
|
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:
|
class TuyaDevice:
|
||||||
"""Represents a generic Tuya device."""
|
"""Represents a generic Tuya device."""
|
||||||
|
|
||||||
# PING_INTERVAL = 10
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device_id,
|
device_id,
|
||||||
host,
|
host,
|
||||||
timeout,
|
timeout,
|
||||||
ping_interval,
|
ping_interval,
|
||||||
|
update_entity_state,
|
||||||
local_key=None,
|
local_key=None,
|
||||||
port=6668,
|
port=6668,
|
||||||
gateway_id=None,
|
gateway_id=None,
|
||||||
version=(3, 3),
|
version=(3, 3),
|
||||||
):
|
):
|
||||||
"""Initialize the device."""
|
"""Initialize the device."""
|
||||||
|
self._LOGGER = _LOGGER.getChild(device_id)
|
||||||
self.device_id = device_id
|
self.device_id = device_id
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
|
|
@ -643,19 +626,30 @@ class TuyaDevice:
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.last_pong = 0
|
self.last_pong = 0
|
||||||
self.ping_interval = ping_interval
|
self.ping_interval = ping_interval
|
||||||
|
self.update_entity_state_cb = update_entity_state
|
||||||
|
|
||||||
if len(local_key) != 16:
|
if len(local_key) != 16:
|
||||||
raise InvalidKey("Local key should be a 16-character string")
|
raise InvalidKey("Local key should be a 16-character string")
|
||||||
|
|
||||||
self.cipher = TuyaCipher(local_key, self.version)
|
self.cipher = TuyaCipher(local_key, self.version)
|
||||||
self.writer = None
|
self.writer = None
|
||||||
self._handlers = {
|
self._response_task = None
|
||||||
Message.GET_COMMAND: [self.async_update_state],
|
self._recieve_task = None
|
||||||
Message.GRATUITOUS_UPDATE: [self.async_update_state],
|
self._ping_task = None
|
||||||
Message.PING_COMMAND: [self._async_pong_received],
|
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._dps = {}
|
||||||
self._connected = False
|
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):
|
def __repr__(self):
|
||||||
return "{}({!r}, {!r}, {!r}, {!r})".format(
|
return "{}({!r}, {!r}, {!r}, {!r})".format(
|
||||||
|
|
@ -669,62 +663,149 @@ class TuyaDevice:
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{} ({}:{})".format(self.device_id, self.host, self.port)
|
return "{} ({}:{})".format(self.device_id, self.host, self.port)
|
||||||
|
|
||||||
async def async_connect(self, callback=None):
|
async def process_queue(self):
|
||||||
if self._connected:
|
if self._enabled is False:
|
||||||
return
|
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 = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
|
||||||
sock.settimeout(self.timeout)
|
sock.settimeout(self.timeout)
|
||||||
_LOGGER.debug("Connecting to {}".format(self))
|
self._LOGGER.debug("Connecting to {}".format(self))
|
||||||
try:
|
try:
|
||||||
sock.connect((self.host, self.port))
|
sock.connect((self.host, self.port))
|
||||||
except socket.timeout as e:
|
except (socket.timeout, TimeoutError) as e:
|
||||||
raise ConnectionTimeoutException("Connection timed out") from e
|
self._dps["106"] = "CONNECTION_FAILED"
|
||||||
|
raise ConnectionTimeoutException("Connection timed out")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_connection
|
||||||
self.reader, self.writer = await asyncio.open_connection(sock=sock)
|
self.reader, self.writer = await asyncio.open_connection(sock=sock)
|
||||||
self._connected = True
|
self._connected = True
|
||||||
asyncio.ensure_future(self._async_handle_message())
|
|
||||||
asyncio.ensure_future(self._async_ping(self.ping_interval))
|
if self._ping_task is None:
|
||||||
asyncio.ensure_future(self.async_get(callback))
|
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):
|
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._connected = False
|
||||||
self.last_pong = 0
|
self.last_pong = 0
|
||||||
|
|
||||||
if self.writer is not None:
|
if self.writer is not None:
|
||||||
self.writer.close()
|
self.writer.close()
|
||||||
|
|
||||||
async def async_get(self, callback=None):
|
if self.reader is not None and not self.reader.at_eof():
|
||||||
payload = {"gwId": self.gateway_id, "devId": self.device_id}
|
self.reader.feed_eof()
|
||||||
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)
|
|
||||||
|
|
||||||
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())
|
t = int(time.time())
|
||||||
payload = {"devId": self.device_id, "uid": "", "t": t, "dps": dps}
|
payload = {"devId": self.device_id, "uid": "", "t": t, "dps": dps}
|
||||||
message = Message(Message.SET_COMMAND, payload, encrypt_for=self)
|
message = Message(
|
||||||
await message.async_send(self, callback)
|
Message.SET_COMMAND,
|
||||||
|
payload,
|
||||||
|
encrypt=True,
|
||||||
|
device=self,
|
||||||
|
expect_response=False,
|
||||||
|
)
|
||||||
|
self._queue.append(message)
|
||||||
|
|
||||||
def set(self, dps):
|
async def async_ping(self, ping_interval):
|
||||||
_call_async(self.async_set, dps)
|
if self._enabled is False:
|
||||||
|
return
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
async def _async_ping(self, ping_interval):
|
|
||||||
# print("ping")
|
|
||||||
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)
|
|
||||||
await asyncio.sleep(ping_interval)
|
await asyncio.sleep(ping_interval)
|
||||||
|
self._ping_task = asyncio.create_task(self.async_ping(self.ping_interval))
|
||||||
if self.last_pong < self.last_ping:
|
if self.last_pong < self.last_ping:
|
||||||
await self.async_disconnect()
|
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()
|
self.last_pong = time.time()
|
||||||
|
|
||||||
async def async_update_state(self, state_message, _):
|
async def async_gratuitous_update_state(self, state_message):
|
||||||
self._dps.update(state_message.payload["dps"])
|
await self.async_update_state(state_message)
|
||||||
_LOGGER.info("Received updated state {}: {}".format(self, self._dps))
|
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
|
@property
|
||||||
def state(self):
|
def state(self):
|
||||||
|
|
@ -732,41 +813,113 @@ class TuyaDevice:
|
||||||
|
|
||||||
@state.setter
|
@state.setter
|
||||||
def state_setter(self, new_values):
|
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):
|
async def _async_handle_message(self):
|
||||||
try:
|
if self._enabled is False or self._connected is False:
|
||||||
response_data = await self.reader.readuntil(MAGIC_SUFFIX_BYTES)
|
|
||||||
except socket.error as e:
|
|
||||||
_LOGGER.error("Connection to {} failed: {}".format(self, e))
|
|
||||||
asyncio.ensure_future(self.async_disconnect())
|
|
||||||
return
|
|
||||||
except asyncio.IncompleteReadError as e:
|
|
||||||
_LOGGER.error("Incomplete read from: {} : {}".format(self, e))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message = Message.from_bytes(response_data, self.cipher)
|
self._response_task = asyncio.create_task(
|
||||||
except InvalidMessage as e:
|
self.reader.readuntil(MAGIC_SUFFIX_BYTES)
|
||||||
_LOGGER.error("Invalid message from {}: {}".format(self, e))
|
)
|
||||||
except MessageDecodeFailed as e:
|
await self._response_task
|
||||||
_LOGGER.error("Failed to decrypt message from {}".format(self))
|
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()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
_LOGGER.debug("Received message from {}: {}".format(self, message))
|
self._LOGGER.debug("Received message from {}: {}".format(self, message))
|
||||||
for c in self._handlers.get(message.command, []):
|
if message.sequence in self._listeners:
|
||||||
asyncio.ensure_future(c(message, self))
|
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))
|
||||||
|
|
||||||
asyncio.ensure_future(self._async_handle_message())
|
self._response_task = None
|
||||||
|
asyncio.create_task(self._async_handle_message())
|
||||||
|
|
||||||
async def _async_send(self, message, retries=4):
|
async def _async_send(self, message, retries=2):
|
||||||
|
self._LOGGER.debug("Sending to {}: {}".format(self, message))
|
||||||
try:
|
try:
|
||||||
await self.async_connect()
|
await self.async_connect()
|
||||||
except (socket.timeout, socket.error, OSError) as e:
|
self.writer.write(message.bytes())
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception as e:
|
||||||
if retries == 0:
|
if retries == 0:
|
||||||
raise ConnectionException(
|
if isinstance(e, socket.error):
|
||||||
"Failed to send data to {}".format(self)
|
await self.async_disconnect()
|
||||||
) from e
|
|
||||||
await self.async_connect()
|
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):
|
||||||
|
self._LOGGER.debug(
|
||||||
|
"Retrying send due to error. Connection to {} failed: {}".format(
|
||||||
|
self, e
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif isinstance(e, asyncio.IncompleteReadError):
|
||||||
|
self._LOGGER.debug(
|
||||||
|
"Retrying send due to error. Incomplete read from: {} : {}. Partial data recieved: {}".format(
|
||||||
|
self, e, e.partial
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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)
|
await self._async_send(message, retries=retries - 1)
|
||||||
_LOGGER.debug("Sending to {}: {}".format(self, message))
|
|
||||||
self.writer.write(message.bytes())
|
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from hashlib import md5
|
||||||
|
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UDP_KEY = md5(b"yGAdlopoPVldABfn").digest()
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryPortsNotAvailableException(Exception):
|
||||||
|
"""This model is not supported"""
|
||||||
|
|
||||||
|
|
||||||
|
class TuyaLocalDiscovery(asyncio.DatagramProtocol):
|
||||||
|
def __init__(self, callback):
|
||||||
|
self.devices = {}
|
||||||
|
self._listeners = []
|
||||||
|
self.discovered_callback = callback
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
listener = loop.create_datagram_endpoint(
|
||||||
|
lambda: self, local_addr=("0.0.0.0", 6666), reuse_port=True
|
||||||
|
)
|
||||||
|
encrypted_listener = loop.create_datagram_endpoint(
|
||||||
|
lambda: self, local_addr=("0.0.0.0", 6667), reuse_port=True
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._listeners = await asyncio.gather(listener, encrypted_listener)
|
||||||
|
_LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667")
|
||||||
|
except Exception as e:
|
||||||
|
raise DiscoveryPortsNotAvailableException(
|
||||||
|
"Ports 6666 and 6667 are needed for autodiscovery but are unavailable. This may be due to having the localtuya integration installed and it not allowing other integrations to use the same ports. A pull request has been raised to address this: https://github.com/rospogrigio/localtuya/pull/1481"
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self, *args, **kwargs):
|
||||||
|
for transport, _ in self._listeners:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
def datagram_received(self, data, addr):
|
||||||
|
data = data[20:-8]
|
||||||
|
try:
|
||||||
|
cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend())
|
||||||
|
decryptor = cipher.decryptor()
|
||||||
|
padded_data = decryptor.update(data) + decryptor.finalize()
|
||||||
|
data = padded_data[: -ord(padded_data[len(padded_data) - 1 :])]
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
data = data.decode()
|
||||||
|
|
||||||
|
decoded = json.loads(data)
|
||||||
|
asyncio.ensure_future(self.discovered_callback(decoded))
|
||||||
|
|
@ -78,7 +78,7 @@ DEFAULT_TUYA_QUERY_PARAMS = {
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
"osSystem": "12",
|
"osSystem": "12",
|
||||||
"os": "Android",
|
"os": "Android",
|
||||||
"timeZoneId": "Europe/London",
|
"timeZoneId": "",
|
||||||
"ttid": "android",
|
"ttid": "android",
|
||||||
"et": "0.0.1",
|
"et": "0.0.1",
|
||||||
"sdkVersion": "3.0.8cAnker",
|
"sdkVersion": "3.0.8cAnker",
|
||||||
|
|
@ -86,19 +86,25 @@ DEFAULT_TUYA_QUERY_PARAMS = {
|
||||||
|
|
||||||
|
|
||||||
class TuyaAPISession:
|
class TuyaAPISession:
|
||||||
|
|
||||||
username = None
|
username = None
|
||||||
country_code = None
|
country_code = None
|
||||||
session_id = None
|
session_id = None
|
||||||
|
|
||||||
def __init__(self, username, country_code):
|
def __init__(self, username, region, timezone, phone_code):
|
||||||
self.session = requests.session()
|
self.session = requests.session()
|
||||||
self.session.headers = DEFAULT_TUYA_HEADERS.copy()
|
self.session.headers = DEFAULT_TUYA_HEADERS.copy()
|
||||||
self.default_query_params = DEFAULT_TUYA_QUERY_PARAMS.copy()
|
self.default_query_params = DEFAULT_TUYA_QUERY_PARAMS.copy()
|
||||||
self.default_query_params["deviceId"] = self.generate_new_device_id()
|
self.default_query_params["deviceId"] = self.generate_new_device_id()
|
||||||
self.username = username
|
self.username = username
|
||||||
self.country_code = country_code
|
self.country_code = phone_code
|
||||||
self.base_url = TUYA_INITIAL_BASE_URL
|
self.base_url = {
|
||||||
|
"AZ": "https://a1.tuyaus.com",
|
||||||
|
"AY": "https://a1.tuyacn.com",
|
||||||
|
"IN": "https://a1.tuyain.com",
|
||||||
|
"EU": "https://a1.tuyaeu.com",
|
||||||
|
}.get(region, "https://a1.tuyaeu.com")
|
||||||
|
|
||||||
|
DEFAULT_TUYA_QUERY_PARAMS["timeZoneId"] = timezone
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_new_device_id():
|
def generate_new_device_id():
|
||||||
|
|
@ -183,8 +189,7 @@ class TuyaAPISession:
|
||||||
encrypted_uid += encryptor.finalize()
|
encrypted_uid += encryptor.finalize()
|
||||||
return md5(encrypted_uid.hex().upper().encode("utf-8")).hexdigest()
|
return md5(encrypted_uid.hex().upper().encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
def request_session(self, username, country_code):
|
def request_session(self, username, password, country_code):
|
||||||
password = self.determine_password(username)
|
|
||||||
token_response = self.request_token(username, country_code)
|
token_response = self.request_token(username, country_code)
|
||||||
encrypted_password = unpadded_rsa(
|
encrypted_password = unpadded_rsa(
|
||||||
key_exponent=int(token_response["exponent"]),
|
key_exponent=int(token_response["exponent"]),
|
||||||
|
|
@ -200,24 +205,38 @@ class TuyaAPISession:
|
||||||
"options": '{"group": 1}',
|
"options": '{"group": 1}',
|
||||||
"token": token_response["token"],
|
"token": token_response["token"],
|
||||||
}
|
}
|
||||||
session_response = self._request(
|
|
||||||
action="tuya.m.user.uid.password.login.reg",
|
try:
|
||||||
data=data,
|
return self._request(
|
||||||
_requires_session=False,
|
action="tuya.m.user.uid.password.login.reg",
|
||||||
)
|
data=data,
|
||||||
return session_response
|
_requires_session=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
error_password = md5("12345678".encode("utf8")).hexdigest()
|
||||||
|
|
||||||
|
if password != error_password:
|
||||||
|
return self.request_session(username, error_password, country_code)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
def acquire_session(self):
|
def acquire_session(self):
|
||||||
session_response = self.request_session(self.username, self.country_code)
|
password = self.determine_password(self.username)
|
||||||
|
session_response = self.request_session(
|
||||||
|
self.username, password, self.country_code
|
||||||
|
)
|
||||||
self.session_id = self.default_query_params["sid"] = session_response["sid"]
|
self.session_id = self.default_query_params["sid"] = session_response["sid"]
|
||||||
self.base_url = session_response["domain"]["mobileApiUrl"]
|
self.base_url = session_response["domain"]["mobileApiUrl"]
|
||||||
|
self.country_code = (
|
||||||
|
session_response["phoneCode"]
|
||||||
|
if session_response["phoneCode"]
|
||||||
|
else self.getCountryCode(session_response["domain"]["regionCode"])
|
||||||
|
)
|
||||||
|
|
||||||
def list_homes(self):
|
def list_homes(self):
|
||||||
return self._request(action="tuya.m.location.list", version="2.1")
|
return self._request(action="tuya.m.location.list", version="2.1")
|
||||||
|
|
||||||
def list_devices(self, home_id: str):
|
def get_device(self, devId):
|
||||||
return self._request(
|
return self._request(
|
||||||
action="tuya.m.my.group.device.list",
|
action="tuya.m.device.get", version="1.0", data={"devId": devId}
|
||||||
version="1.0",
|
|
||||||
query_params={"gid": home_id},
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,19 @@ from homeassistant.const import (
|
||||||
CONF_IP_ADDRESS,
|
CONF_IP_ADDRESS,
|
||||||
CONF_DESCRIPTION,
|
CONF_DESCRIPTION,
|
||||||
CONF_MAC,
|
CONF_MAC,
|
||||||
STATE_ON,
|
STATE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .const import CONF_VACS, DOMAIN
|
from .tuyalocalapi import TuyaException
|
||||||
|
from .const import CONF_VACS, DOMAIN, REFRESH_RATE, PING_RATE, TIMEOUT
|
||||||
|
|
||||||
from .errors import getErrorMessage
|
from .errors import getErrorMessage
|
||||||
from .robovac import SUPPORTED_ROBOVAC_MODELS, RoboVac, RoboVacEntityFeature
|
from .robovac import (
|
||||||
|
SUPPORTED_ROBOVAC_MODELS,
|
||||||
|
ModelNotSupportedException,
|
||||||
|
RoboVac,
|
||||||
|
RoboVacEntityFeature,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.const import ATTR_BATTERY_LEVEL
|
from homeassistant.const import ATTR_BATTERY_LEVEL
|
||||||
|
|
||||||
|
|
@ -78,9 +84,8 @@ ATTR_CONSUMABLES = "consumables"
|
||||||
ATTR_MODE = "mode"
|
ATTR_MODE = "mode"
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
# Time between updating data from GitHub
|
|
||||||
REFRESH_RATE = 20
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=REFRESH_RATE)
|
SCAN_INTERVAL = timedelta(seconds=REFRESH_RATE)
|
||||||
|
UPDATE_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
class TUYA_CODES(StrEnum):
|
class TUYA_CODES(StrEnum):
|
||||||
|
|
@ -94,8 +99,9 @@ class TUYA_CODES(StrEnum):
|
||||||
AUTO_RETURN = "135"
|
AUTO_RETURN = "135"
|
||||||
DO_NOT_DISTURB = "107"
|
DO_NOT_DISTURB = "107"
|
||||||
BOOST_IQ = "118"
|
BOOST_IQ = "118"
|
||||||
G_CONSUMABLES = "142"
|
|
||||||
X_CONSUMABLES = "116"
|
|
||||||
|
TUYA_CONSUMABLES_CODES = ["142", "116"]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
|
|
@ -107,7 +113,9 @@ async def async_setup_entry(
|
||||||
vacuums = config_entry.data[CONF_VACS]
|
vacuums = config_entry.data[CONF_VACS]
|
||||||
for item in vacuums:
|
for item in vacuums:
|
||||||
item = vacuums[item]
|
item = vacuums[item]
|
||||||
async_add_entities([RoboVacEntity(item)])
|
entity = RoboVacEntity(item)
|
||||||
|
hass.data[DOMAIN][CONF_VACS][item[CONF_ID]] = entity
|
||||||
|
async_add_entities([entity])
|
||||||
|
|
||||||
|
|
||||||
class RoboVacEntity(StateVacuumEntity):
|
class RoboVacEntity(StateVacuumEntity):
|
||||||
|
|
@ -184,9 +192,22 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> str | None:
|
def state(self) -> str | None:
|
||||||
if self.tuya_state is None or (
|
if self.tuya_state is None:
|
||||||
type(self.error_code) is not None and self.error_code not in [0, "no_error"]
|
return STATE_UNAVAILABLE
|
||||||
|
elif (
|
||||||
|
type(self.error_code) is not None
|
||||||
|
and self.error_code
|
||||||
|
and self.error_code
|
||||||
|
not in [
|
||||||
|
0,
|
||||||
|
"no_error",
|
||||||
|
]
|
||||||
):
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"State changed to error. Error message: {}".format(
|
||||||
|
getErrorMessage(self.error_code)
|
||||||
|
)
|
||||||
|
)
|
||||||
return STATE_ERROR
|
return STATE_ERROR
|
||||||
elif self.tuya_state == "Charging" or self.tuya_state == "completed":
|
elif self.tuya_state == "Charging" or self.tuya_state == "completed":
|
||||||
return STATE_DOCKED
|
return STATE_DOCKED
|
||||||
|
|
@ -201,23 +222,38 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
"""Return the device-specific state attributes of this vacuum."""
|
"""Return the device-specific state attributes of this vacuum."""
|
||||||
data: dict[str, Any] = {}
|
data: dict[str, Any] = {}
|
||||||
data[ATTR_ERROR] = getErrorMessage(self.error_code)
|
|
||||||
|
|
||||||
if self.supported_features & VacuumEntityFeature.STATUS:
|
if type(self.error_code) is not None and self.error_code not in [0, "no_error"]:
|
||||||
data[ATTR_STATUS] = self.status
|
data[ATTR_ERROR] = getErrorMessage(self.error_code)
|
||||||
if self.robovac_supported & RoboVacEntityFeature.CLEANING_AREA:
|
if (
|
||||||
|
self.robovac_supported & RoboVacEntityFeature.CLEANING_AREA
|
||||||
|
and self.cleaning_area
|
||||||
|
):
|
||||||
data[ATTR_CLEANING_AREA] = self.cleaning_area
|
data[ATTR_CLEANING_AREA] = self.cleaning_area
|
||||||
if self.robovac_supported & RoboVacEntityFeature.CLEANING_TIME:
|
if (
|
||||||
|
self.robovac_supported & RoboVacEntityFeature.CLEANING_TIME
|
||||||
|
and self.cleaning_time
|
||||||
|
):
|
||||||
data[ATTR_CLEANING_TIME] = self.cleaning_time
|
data[ATTR_CLEANING_TIME] = self.cleaning_time
|
||||||
if self.robovac_supported & RoboVacEntityFeature.AUTO_RETURN:
|
if (
|
||||||
|
self.robovac_supported & RoboVacEntityFeature.AUTO_RETURN
|
||||||
|
and self.auto_return
|
||||||
|
):
|
||||||
data[ATTR_AUTO_RETURN] = self.auto_return
|
data[ATTR_AUTO_RETURN] = self.auto_return
|
||||||
if self.robovac_supported & RoboVacEntityFeature.DO_NOT_DISTURB:
|
if (
|
||||||
|
self.robovac_supported & RoboVacEntityFeature.DO_NOT_DISTURB
|
||||||
|
and self.do_not_disturb
|
||||||
|
):
|
||||||
data[ATTR_DO_NOT_DISTURB] = self.do_not_disturb
|
data[ATTR_DO_NOT_DISTURB] = self.do_not_disturb
|
||||||
if self.robovac_supported & RoboVacEntityFeature.BOOST_IQ:
|
if self.robovac_supported & RoboVacEntityFeature.BOOST_IQ and self.boost_iq:
|
||||||
data[ATTR_BOOST_IQ] = self.boost_iq
|
data[ATTR_BOOST_IQ] = self.boost_iq
|
||||||
if self.robovac_supported & RoboVacEntityFeature.CONSUMABLES:
|
if (
|
||||||
|
self.robovac_supported & RoboVacEntityFeature.CONSUMABLES
|
||||||
|
and self.consumables
|
||||||
|
):
|
||||||
data[ATTR_CONSUMABLES] = self.consumables
|
data[ATTR_CONSUMABLES] = self.consumables
|
||||||
data[ATTR_MODE] = self.mode
|
if self.mode:
|
||||||
|
data[ATTR_MODE] = self.mode
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def __init__(self, item) -> None:
|
def __init__(self, item) -> None:
|
||||||
|
|
@ -230,14 +266,20 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
self._attr_ip_address = item[CONF_IP_ADDRESS]
|
self._attr_ip_address = item[CONF_IP_ADDRESS]
|
||||||
self._attr_access_token = item[CONF_ACCESS_TOKEN]
|
self._attr_access_token = item[CONF_ACCESS_TOKEN]
|
||||||
|
|
||||||
self.vacuum = RoboVac(
|
self.update_failures = 0
|
||||||
device_id=self.unique_id,
|
|
||||||
host=self.ip_address,
|
try:
|
||||||
local_key=self.access_token,
|
self.vacuum = RoboVac(
|
||||||
timeout=2,
|
device_id=self.unique_id,
|
||||||
ping_interval=10,
|
host=self.ip_address,
|
||||||
model_code=self.model_code[0:5],
|
local_key=self.access_token,
|
||||||
)
|
timeout=TIMEOUT,
|
||||||
|
ping_interval=PING_RATE,
|
||||||
|
model_code=self.model_code[0:5],
|
||||||
|
update_entity_state=self.pushed_update_handler,
|
||||||
|
)
|
||||||
|
except ModelNotSupportedException:
|
||||||
|
self.error_code = "UNSUPPORTED_MODEL"
|
||||||
|
|
||||||
self._attr_supported_features = self.vacuum.getHomeAssistantFeatures()
|
self._attr_supported_features = self.vacuum.getHomeAssistantFeatures()
|
||||||
self._attr_robovac_supported = self.vacuum.getRoboVacFeatures()
|
self._attr_robovac_supported = self.vacuum.getRoboVacFeatures()
|
||||||
|
|
@ -261,12 +303,34 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
|
|
||||||
async def async_update(self):
|
async def async_update(self):
|
||||||
"""Synchronise state from the vacuum."""
|
"""Synchronise state from the vacuum."""
|
||||||
self.async_write_ha_state()
|
if self.error_code == "UNSUPPORTED_MODEL":
|
||||||
if self.ip_address == "":
|
|
||||||
return
|
return
|
||||||
await self.vacuum.async_get()
|
|
||||||
|
if self.ip_address == "":
|
||||||
|
self.error_code = "IP_ADDRESS"
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.vacuum.async_get()
|
||||||
|
self.update_failures = 0
|
||||||
|
self.update_entity_values()
|
||||||
|
except TuyaException as e:
|
||||||
|
self.update_failures += 1
|
||||||
|
_LOGGER.warn(
|
||||||
|
"Update errored. Current update failure count: {}. Reason: {}".format(
|
||||||
|
self.update_failures, e
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.update_failures >= UPDATE_RETRIES:
|
||||||
|
self.error_code = "CONNECTION_FAILED"
|
||||||
|
|
||||||
|
async def pushed_update_handler(self):
|
||||||
|
self.update_entity_values()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def update_entity_values(self):
|
||||||
self.tuyastatus = self.vacuum._dps
|
self.tuyastatus = self.vacuum._dps
|
||||||
print("Tuya local API Result:", self.tuyastatus)
|
|
||||||
# for 15C
|
# for 15C
|
||||||
self._attr_battery_level = self.tuyastatus.get(TUYA_CODES.BATTERY_LEVEL)
|
self._attr_battery_level = self.tuyastatus.get(TUYA_CODES.BATTERY_LEVEL)
|
||||||
self.tuya_state = self.tuyastatus.get(TUYA_CODES.STATE)
|
self.tuya_state = self.tuyastatus.get(TUYA_CODES.STATE)
|
||||||
|
|
@ -284,72 +348,56 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
self._attr_cleaning_time = self.tuyastatus.get(TUYA_CODES.CLEANING_TIME)
|
self._attr_cleaning_time = self.tuyastatus.get(TUYA_CODES.CLEANING_TIME)
|
||||||
self._attr_auto_return = self.tuyastatus.get(TUYA_CODES.AUTO_RETURN)
|
self._attr_auto_return = self.tuyastatus.get(TUYA_CODES.AUTO_RETURN)
|
||||||
self._attr_do_not_disturb = self.tuyastatus.get(TUYA_CODES.DO_NOT_DISTURB)
|
self._attr_do_not_disturb = self.tuyastatus.get(TUYA_CODES.DO_NOT_DISTURB)
|
||||||
if self.tuyastatus.get(TUYA_CODES.G_CONSUMABLES) is not None:
|
|
||||||
self._attr_consumables = ast.literal_eval(
|
|
||||||
base64.b64decode(self.tuyastatus.get(TUYA_CODES.G_CONSUMABLES)).decode(
|
|
||||||
"ascii"
|
|
||||||
)
|
|
||||||
)["consumable"]["duration"]
|
|
||||||
print(self.consumables)
|
|
||||||
# For X8
|
|
||||||
self._attr_boost_iq = self.tuyastatus.get(TUYA_CODES.BOOST_IQ)
|
self._attr_boost_iq = self.tuyastatus.get(TUYA_CODES.BOOST_IQ)
|
||||||
# self.map_data = self.tuyastatus.get("121")
|
# self.map_data = self.tuyastatus.get("121")
|
||||||
# self.erro_msg? = self.tuyastatus.get("124")
|
# self.erro_msg? = self.tuyastatus.get("124")
|
||||||
if self.tuyastatus.get(TUYA_CODES.X_CONSUMABLES) is not None:
|
if self.robovac_supported & RoboVacEntityFeature.CONSUMABLES:
|
||||||
self._attr_consumables = ast.literal_eval(
|
for CONSUMABLE_CODE in TUYA_CONSUMABLES_CODES:
|
||||||
base64.b64decode(self.tuyastatus.get(TUYA_CODES.X_CONSUMABLES)).decode(
|
if (
|
||||||
"ascii"
|
CONSUMABLE_CODE in self.tuyastatus
|
||||||
)
|
and self.tuyastatus.get(CONSUMABLE_CODE) is not None
|
||||||
)["consumable"]["duration"]
|
):
|
||||||
print(self.consumables)
|
consumables = ast.literal_eval(
|
||||||
|
base64.b64decode(self.tuyastatus.get(CONSUMABLE_CODE)).decode(
|
||||||
|
"ascii"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
"consumable" in consumables
|
||||||
|
and "duration" in consumables["consumable"]
|
||||||
|
):
|
||||||
|
self._attr_consumables = consumables["consumable"]["duration"]
|
||||||
|
|
||||||
async def async_locate(self, **kwargs):
|
async def async_locate(self, **kwargs):
|
||||||
"""Locate the vacuum cleaner."""
|
"""Locate the vacuum cleaner."""
|
||||||
print("Locate Pressed")
|
|
||||||
_LOGGER.info("Locate Pressed")
|
_LOGGER.info("Locate Pressed")
|
||||||
if self.tuyastatus.get("103"):
|
if self.tuyastatus.get("103"):
|
||||||
await self.vacuum.async_set({"103": False}, None)
|
await self.vacuum.async_set({"103": False})
|
||||||
else:
|
else:
|
||||||
await self.vacuum.async_set({"103": True}, None)
|
await self.vacuum.async_set({"103": True})
|
||||||
|
|
||||||
async def async_return_to_base(self, **kwargs):
|
async def async_return_to_base(self, **kwargs):
|
||||||
"""Set the vacuum cleaner to return to the dock."""
|
"""Set the vacuum cleaner to return to the dock."""
|
||||||
print("Return home Pressed")
|
|
||||||
_LOGGER.info("Return home Pressed")
|
_LOGGER.info("Return home Pressed")
|
||||||
await self.vacuum.async_set({"101": True}, None)
|
await self.vacuum.async_set({"101": True})
|
||||||
await asyncio.sleep(1)
|
|
||||||
self.async_update
|
|
||||||
|
|
||||||
async def async_start(self, **kwargs):
|
async def async_start(self, **kwargs):
|
||||||
if self.mode == "Nosweep":
|
self._attr_mode = "auto"
|
||||||
self._attr_mode = "auto"
|
await self.vacuum.async_set({"5": self.mode})
|
||||||
elif self.mode == "room" and (
|
|
||||||
self.status == "Charging" or self.status == "completed"
|
|
||||||
):
|
|
||||||
self._attr_mode = "auto"
|
|
||||||
await self.vacuum.async_set({"5": self.mode}, None)
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
self.async_update
|
|
||||||
|
|
||||||
async def async_pause(self, **kwargs):
|
async def async_pause(self, **kwargs):
|
||||||
await self.vacuum.async_set({"2": False}, None)
|
await self.vacuum.async_set({"2": False})
|
||||||
await asyncio.sleep(1)
|
|
||||||
self.async_update
|
|
||||||
|
|
||||||
async def async_stop(self, **kwargs):
|
async def async_stop(self, **kwargs):
|
||||||
await self.async_return_to_base()
|
await self.async_return_to_base()
|
||||||
|
|
||||||
async def async_clean_spot(self, **kwargs):
|
async def async_clean_spot(self, **kwargs):
|
||||||
"""Perform a spot clean-up."""
|
"""Perform a spot clean-up."""
|
||||||
print("Spot Clean Pressed")
|
|
||||||
_LOGGER.info("Spot Clean Pressed")
|
_LOGGER.info("Spot Clean Pressed")
|
||||||
await self.vacuum.async_set({"5": "Spot"}, None)
|
await self.vacuum.async_set({"5": "Spot"})
|
||||||
await asyncio.sleep(1)
|
|
||||||
self.async_update
|
|
||||||
|
|
||||||
async def async_set_fan_speed(self, fan_speed, **kwargs):
|
async def async_set_fan_speed(self, fan_speed, **kwargs):
|
||||||
"""Set fan speed."""
|
"""Set fan speed."""
|
||||||
print("Fan Speed Selected", fan_speed)
|
|
||||||
_LOGGER.info("Fan Speed Selected")
|
_LOGGER.info("Fan Speed Selected")
|
||||||
if fan_speed == "No Suction":
|
if fan_speed == "No Suction":
|
||||||
fan_speed = "No_suction"
|
fan_speed = "No_suction"
|
||||||
|
|
@ -357,9 +405,7 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
fan_speed = "Boost_IQ"
|
fan_speed = "Boost_IQ"
|
||||||
elif fan_speed == "Pure":
|
elif fan_speed == "Pure":
|
||||||
fan_speed = "Quiet"
|
fan_speed = "Quiet"
|
||||||
await self.vacuum.async_set({"102": fan_speed}, None)
|
await self.vacuum.async_set({"102": fan_speed})
|
||||||
await asyncio.sleep(1)
|
|
||||||
self.async_update
|
|
||||||
|
|
||||||
async def async_send_command(
|
async def async_send_command(
|
||||||
self, command: str, params: dict | list | None = None, **kwargs
|
self, command: str, params: dict | list | None = None, **kwargs
|
||||||
|
|
@ -367,28 +413,28 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
"""Send a command to a vacuum cleaner."""
|
"""Send a command to a vacuum cleaner."""
|
||||||
_LOGGER.info("Send Command %s Pressed", command)
|
_LOGGER.info("Send Command %s Pressed", command)
|
||||||
if command == "edgeClean":
|
if command == "edgeClean":
|
||||||
await self.vacuum.async_set({"5": "Edge"}, None)
|
await self.vacuum.async_set({"5": "Edge"})
|
||||||
elif command == "smallRoomClean":
|
elif command == "smallRoomClean":
|
||||||
await self.vacuum.async_set({"5": "SmallRoom"}, None)
|
await self.vacuum.async_set({"5": "SmallRoom"})
|
||||||
elif command == "autoClean":
|
elif command == "autoClean":
|
||||||
await self.vacuum.async_set({"5": "auto"}, None)
|
await self.vacuum.async_set({"5": "auto"})
|
||||||
elif command == "autoReturn":
|
elif command == "autoReturn":
|
||||||
if self.auto_return:
|
if self.auto_return:
|
||||||
await self.vacuum.async_set({"135": False}, None)
|
await self.vacuum.async_set({"135": False})
|
||||||
else:
|
else:
|
||||||
await self.vacuum.async_set({"135": True}, None)
|
await self.vacuum.async_set({"135": True})
|
||||||
elif command == "doNotDisturb":
|
elif command == "doNotDisturb":
|
||||||
if self.do_not_disturb:
|
if self.do_not_disturb:
|
||||||
await self.vacuum.async_set({"139": "MEQ4MDAwMDAw"}, None)
|
await self.vacuum.async_set({"139": "MEQ4MDAwMDAw"})
|
||||||
await self.vacuum.async_set({"107": False}, None)
|
await self.vacuum.async_set({"107": False})
|
||||||
else:
|
else:
|
||||||
await self.vacuum.async_set({"139": "MTAwMDAwMDAw"}, None)
|
await self.vacuum.async_set({"139": "MTAwMDAwMDAw"})
|
||||||
await self.vacuum.async_set({"107": True}, None)
|
await self.vacuum.async_set({"107": True})
|
||||||
elif command == "boostIQ":
|
elif command == "boostIQ":
|
||||||
if self.boost_iq:
|
if self.boost_iq:
|
||||||
await self.vacuum.async_set({"118": False}, None)
|
await self.vacuum.async_set({"118": False})
|
||||||
else:
|
else:
|
||||||
await self.vacuum.async_set({"118": True}, None)
|
await self.vacuum.async_set({"118": True})
|
||||||
elif command == "roomClean":
|
elif command == "roomClean":
|
||||||
roomIds = params.get("roomIds", [1])
|
roomIds = params.get("roomIds", [1])
|
||||||
count = params.get("count", 1)
|
count = params.get("count", 1)
|
||||||
|
|
@ -401,6 +447,7 @@ class RoboVacEntity(StateVacuumEntity):
|
||||||
json_str = json.dumps(method_call, separators=(",", ":"))
|
json_str = json.dumps(method_call, separators=(",", ":"))
|
||||||
base64_str = base64.b64encode(json_str.encode("utf8")).decode("utf8")
|
base64_str = base64.b64encode(json_str.encode("utf8")).decode("utf8")
|
||||||
_LOGGER.info("roomClean call %s", json_str)
|
_LOGGER.info("roomClean call %s", json_str)
|
||||||
await self.vacuum.async_set({"124": base64_str}, None)
|
await self.vacuum.async_set({"124": base64_str})
|
||||||
await asyncio.sleep(1)
|
|
||||||
self.async_update
|
async def async_will_remove_from_hass(self):
|
||||||
|
await self.vacuum.async_disable()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"conventional-changelog-conventionalcommits": "^7.0.2",
|
||||||
|
"semantic-release": "^23.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
cryptography==41.0.4
|
||||||
|
homeassistant==2023.8.4
|
||||||
|
Requests==2.31.0
|
||||||
|
setuptools==68.0.0
|
||||||
|
voluptuous==0.13.1
|
||||||
18
setup.py
18
setup.py
|
|
@ -24,18 +24,18 @@ import warnings
|
||||||
dynamic_requires = []
|
dynamic_requires = []
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='robovac',
|
name="robovac",
|
||||||
version="1.0",
|
version="1.0",
|
||||||
author='Brendan McCluskey',
|
author="Luke Morrigan",
|
||||||
url='http://github.com/bmccluskey/robovac',
|
url="http://github.com/codefoodpixels/robovac",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
scripts=[],
|
scripts=[],
|
||||||
description='Python API for controlling Eufy Robovac vacuum cleaners',
|
description="Python API for controlling Eufy Robovac vacuum cleaners",
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Development Status :: 4 - Beta',
|
"Development Status :: 4 - Beta",
|
||||||
'Intended Audience :: Developers',
|
"Intended Audience :: Developers",
|
||||||
'License :: OSI Approved :: Apache Software License',
|
"License :: OSI Approved :: Apache Software License",
|
||||||
'Operating System :: OS Independent',
|
"Operating System :: OS Independent",
|
||||||
'Programming Language :: Python',
|
"Programming Language :: Python",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue