Add files via upload
This commit is contained in:
parent
5c5f273f17
commit
ff6184af42
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Copyright 2022 Brendan McCluskey
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
"""The Eufy Robovac integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.VACUUM]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Eufy Robovac from a config entry."""
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
|
# hass_data = dict(entry.data)
|
||||||
|
# Registers update listener to update config entry when options are updated.
|
||||||
|
# unsub_options_update_listener = entry.add_update_listener(options_update_listener)
|
||||||
|
# Store a reference to the unsubscribe function to cleanup if an entry is unloaded.
|
||||||
|
# hass_data["unsub_options_update_listener"] = unsub_options_update_listener
|
||||||
|
# hass.data[DOMAIN][entry.entry_id] = hass_data
|
||||||
|
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
"""Nothing"""
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
async def update_listener(hass, entry):
|
||||||
|
"""Handle options update."""
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
# Copyright 2022 Brendan McCluskey
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Config flow for Eufy Robovac integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.selector import selector
|
||||||
|
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_ACCESS_TOKEN,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_ID,
|
||||||
|
CONF_MODEL,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_IP_ADDRESS,
|
||||||
|
CONF_DESCRIPTION,
|
||||||
|
CONF_MAC,
|
||||||
|
CONF_LOCATION,
|
||||||
|
CONF_CLIENT_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import DOMAIN, CONF_VACS, CONF_PHONE_CODE
|
||||||
|
|
||||||
|
from .tuyawebapi import TuyaAPISession
|
||||||
|
from .eufywebapi import EufyLogon
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USER_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USERNAME): cv.string,
|
||||||
|
vol.Required(CONF_PASSWORD): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_eufy_vacuums(self):
|
||||||
|
"""Login to Eufy and get the vacuum details"""
|
||||||
|
|
||||||
|
eufy_session = EufyLogon(self["username"], self["password"])
|
||||||
|
response = eufy_session.get_user_info()
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise CannotConnect
|
||||||
|
|
||||||
|
user_response = response.json()
|
||||||
|
if user_response["res_code"] != 1:
|
||||||
|
raise InvalidAuth
|
||||||
|
|
||||||
|
response = eufy_session.get_device_info(
|
||||||
|
user_response["user_info"]["request_host"],
|
||||||
|
user_response["user_info"]["id"],
|
||||||
|
user_response["access_token"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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] = {}
|
||||||
|
items = device_response["items"]
|
||||||
|
allvacs = {}
|
||||||
|
for item in items:
|
||||||
|
if item["device"]["product"]["appliance"] == "Cleaning":
|
||||||
|
vac_details = {
|
||||||
|
CONF_ID: item["device"]["id"],
|
||||||
|
CONF_MODEL: item["device"]["product"]["product_code"],
|
||||||
|
CONF_NAME: item["device"]["alias_name"],
|
||||||
|
CONF_DESCRIPTION: item["device"]["name"],
|
||||||
|
CONF_MAC: item["device"]["wifi"]["mac"],
|
||||||
|
CONF_IP_ADDRESS: "",
|
||||||
|
}
|
||||||
|
allvacs[item["device"]["id"]] = vac_details
|
||||||
|
self[CONF_VACS] = allvacs
|
||||||
|
|
||||||
|
tuya_client = TuyaAPISession(
|
||||||
|
username="eh-" + self[CONF_CLIENT_ID], country_code=self[CONF_PHONE_CODE]
|
||||||
|
)
|
||||||
|
for home in tuya_client.list_homes():
|
||||||
|
for device in tuya_client.list_devices(home["groupId"]):
|
||||||
|
self[CONF_VACS][device["devId"]][CONF_ACCESS_TOKEN] = device["localKey"]
|
||||||
|
self[CONF_VACS][device["devId"]][CONF_LOCATION] = home["groupId"]
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate the user input allows us to connect."""
|
||||||
|
await hass.async_add_executor_job(get_eufy_vacuums, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Eufy Robovac."""
|
||||||
|
|
||||||
|
data: Optional[dict[str, Any]]
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(step_id="user", data_schema=USER_SCHEMA)
|
||||||
|
errors = {}
|
||||||
|
try:
|
||||||
|
unique_id = user_input[CONF_USERNAME]
|
||||||
|
valid_data = await validate_input(self.hass, user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except InvalidAuth:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(unique_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
# return await self.async_step_repo(valid_data)
|
||||||
|
return self.async_create_entry(title=unique_id, data=valid_data)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=USER_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry):
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return OptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class CannotConnect(HomeAssistantError):
|
||||||
|
"""Error to indicate we cannot connect."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidAuth(HomeAssistantError):
|
||||||
|
"""Error to indicate there is invalid auth."""
|
||||||
|
|
||||||
|
|
||||||
|
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
|
"""Handles options flow for the component."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||||
|
self.config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init(
|
||||||
|
self, user_input: dict[str, Any] = None
|
||||||
|
) -> 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:
|
||||||
|
for item in vacuums:
|
||||||
|
item_settings = vacuums[item]
|
||||||
|
if item_settings["name"] == user_input["vacuum"]:
|
||||||
|
item_settings[CONF_IP_ADDRESS] = user_input[CONF_IP_ADDRESS]
|
||||||
|
updated_repos = deepcopy(self.config_entry.data[CONF_VACS])
|
||||||
|
|
||||||
|
# print("Updated", updated_repos)
|
||||||
|
if not errors:
|
||||||
|
# Value of data will be set on the options property of our config_entry
|
||||||
|
# instance.
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="",
|
||||||
|
data={CONF_VACS: updated_repos},
|
||||||
|
)
|
||||||
|
|
||||||
|
options_schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("vacuum", default=1): selector(
|
||||||
|
{
|
||||||
|
"select": {
|
||||||
|
"options": vac_names,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_IP_ADDRESS): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init", data_schema=options_schema, errors=errors
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Constants for the Eufy Robovac integration."""
|
||||||
|
|
||||||
|
DOMAIN = "robovac"
|
||||||
|
CONF_VACS = "vacuums"
|
||||||
|
CONF_PHONE_CODE = "phone_code"
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Original Work from here: Andre Borie https://gitlab.com/Rjevski/eufy-device-id-and-local-key-grabber"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
eufyheaders = {
|
||||||
|
"User-Agent": "EufyHome-Android-2.4.0",
|
||||||
|
"timezone": "Europe/London",
|
||||||
|
"category": "Home",
|
||||||
|
"token": "",
|
||||||
|
"uid": "",
|
||||||
|
"openudid": "sdk_gphone64_arm64",
|
||||||
|
"clientType": "2",
|
||||||
|
"language": "en",
|
||||||
|
"country": "US",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EufyLogon:
|
||||||
|
def __init__(self, username, password):
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
|
||||||
|
def get_user_info(self):
|
||||||
|
login_url = "https://home-api.eufylife.com/v1/user/email/login"
|
||||||
|
login_auth = {
|
||||||
|
"client_Secret": "GQCpr9dSp3uQpsOMgJ4xQ",
|
||||||
|
"client_id": "eufyhome-app",
|
||||||
|
"email": self.username,
|
||||||
|
"password": self.password,
|
||||||
|
}
|
||||||
|
|
||||||
|
return requests.post(
|
||||||
|
login_url, json=login_auth, headers=eufyheaders, timeout=1.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_device_info(self, url, userid, token):
|
||||||
|
device_url = url + "/v1/device/list/devices-and-groups"
|
||||||
|
eufyheaders["token"] = token
|
||||||
|
eufyheaders["id"] = userid
|
||||||
|
return requests.request("GET", device_url, headers=eufyheaders, timeout=1.5)
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"domain": "robovac",
|
||||||
|
"name": "Eufy Robovac",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/robovac",
|
||||||
|
"requirements": [],
|
||||||
|
"ssdp": [],
|
||||||
|
"zeroconf": [],
|
||||||
|
"homekit": {},
|
||||||
|
"dependencies": [],
|
||||||
|
"codeowners": ["@bmccluskey"],
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"version": "1"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "Host",
|
||||||
|
"password": "Password",
|
||||||
|
"username": "Username"
|
||||||
|
},
|
||||||
|
"description": "Enter your Eufy account details"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"error": {
|
||||||
|
"invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository."
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Manage IPs",
|
||||||
|
"data": {
|
||||||
|
"vacuum": "Select the Vacuum to edit",
|
||||||
|
"ip_address": "IP address of vacuum"
|
||||||
|
},
|
||||||
|
"description": "Add or update a vacuums IP address"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,772 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright 2019 Richard Mitchell
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# Based on portions of https://github.com/codetheweb/tuyapi/
|
||||||
|
#
|
||||||
|
# MIT License
|
||||||
|
#
|
||||||
|
# Copyright (c) 2017 Max Isom
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
|
# copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
# SOFTWARE.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from cryptography.hazmat.backends.openssl import backend as openssl_backend
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.hashes import Hash, MD5
|
||||||
|
from cryptography.hazmat.primitives.padding import PKCS7
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
MESSAGE_PREFIX_FORMAT = ">IIII"
|
||||||
|
MESSAGE_SUFFIX_FORMAT = ">II"
|
||||||
|
MAGIC_PREFIX = 0x000055AA
|
||||||
|
MAGIC_SUFFIX = 0x0000AA55
|
||||||
|
MAGIC_SUFFIX_BYTES = struct.pack(">I", MAGIC_SUFFIX)
|
||||||
|
CRC_32_TABLE = [
|
||||||
|
0x00000000,
|
||||||
|
0x77073096,
|
||||||
|
0xEE0E612C,
|
||||||
|
0x990951BA,
|
||||||
|
0x076DC419,
|
||||||
|
0x706AF48F,
|
||||||
|
0xE963A535,
|
||||||
|
0x9E6495A3,
|
||||||
|
0x0EDB8832,
|
||||||
|
0x79DCB8A4,
|
||||||
|
0xE0D5E91E,
|
||||||
|
0x97D2D988,
|
||||||
|
0x09B64C2B,
|
||||||
|
0x7EB17CBD,
|
||||||
|
0xE7B82D07,
|
||||||
|
0x90BF1D91,
|
||||||
|
0x1DB71064,
|
||||||
|
0x6AB020F2,
|
||||||
|
0xF3B97148,
|
||||||
|
0x84BE41DE,
|
||||||
|
0x1ADAD47D,
|
||||||
|
0x6DDDE4EB,
|
||||||
|
0xF4D4B551,
|
||||||
|
0x83D385C7,
|
||||||
|
0x136C9856,
|
||||||
|
0x646BA8C0,
|
||||||
|
0xFD62F97A,
|
||||||
|
0x8A65C9EC,
|
||||||
|
0x14015C4F,
|
||||||
|
0x63066CD9,
|
||||||
|
0xFA0F3D63,
|
||||||
|
0x8D080DF5,
|
||||||
|
0x3B6E20C8,
|
||||||
|
0x4C69105E,
|
||||||
|
0xD56041E4,
|
||||||
|
0xA2677172,
|
||||||
|
0x3C03E4D1,
|
||||||
|
0x4B04D447,
|
||||||
|
0xD20D85FD,
|
||||||
|
0xA50AB56B,
|
||||||
|
0x35B5A8FA,
|
||||||
|
0x42B2986C,
|
||||||
|
0xDBBBC9D6,
|
||||||
|
0xACBCF940,
|
||||||
|
0x32D86CE3,
|
||||||
|
0x45DF5C75,
|
||||||
|
0xDCD60DCF,
|
||||||
|
0xABD13D59,
|
||||||
|
0x26D930AC,
|
||||||
|
0x51DE003A,
|
||||||
|
0xC8D75180,
|
||||||
|
0xBFD06116,
|
||||||
|
0x21B4F4B5,
|
||||||
|
0x56B3C423,
|
||||||
|
0xCFBA9599,
|
||||||
|
0xB8BDA50F,
|
||||||
|
0x2802B89E,
|
||||||
|
0x5F058808,
|
||||||
|
0xC60CD9B2,
|
||||||
|
0xB10BE924,
|
||||||
|
0x2F6F7C87,
|
||||||
|
0x58684C11,
|
||||||
|
0xC1611DAB,
|
||||||
|
0xB6662D3D,
|
||||||
|
0x76DC4190,
|
||||||
|
0x01DB7106,
|
||||||
|
0x98D220BC,
|
||||||
|
0xEFD5102A,
|
||||||
|
0x71B18589,
|
||||||
|
0x06B6B51F,
|
||||||
|
0x9FBFE4A5,
|
||||||
|
0xE8B8D433,
|
||||||
|
0x7807C9A2,
|
||||||
|
0x0F00F934,
|
||||||
|
0x9609A88E,
|
||||||
|
0xE10E9818,
|
||||||
|
0x7F6A0DBB,
|
||||||
|
0x086D3D2D,
|
||||||
|
0x91646C97,
|
||||||
|
0xE6635C01,
|
||||||
|
0x6B6B51F4,
|
||||||
|
0x1C6C6162,
|
||||||
|
0x856530D8,
|
||||||
|
0xF262004E,
|
||||||
|
0x6C0695ED,
|
||||||
|
0x1B01A57B,
|
||||||
|
0x8208F4C1,
|
||||||
|
0xF50FC457,
|
||||||
|
0x65B0D9C6,
|
||||||
|
0x12B7E950,
|
||||||
|
0x8BBEB8EA,
|
||||||
|
0xFCB9887C,
|
||||||
|
0x62DD1DDF,
|
||||||
|
0x15DA2D49,
|
||||||
|
0x8CD37CF3,
|
||||||
|
0xFBD44C65,
|
||||||
|
0x4DB26158,
|
||||||
|
0x3AB551CE,
|
||||||
|
0xA3BC0074,
|
||||||
|
0xD4BB30E2,
|
||||||
|
0x4ADFA541,
|
||||||
|
0x3DD895D7,
|
||||||
|
0xA4D1C46D,
|
||||||
|
0xD3D6F4FB,
|
||||||
|
0x4369E96A,
|
||||||
|
0x346ED9FC,
|
||||||
|
0xAD678846,
|
||||||
|
0xDA60B8D0,
|
||||||
|
0x44042D73,
|
||||||
|
0x33031DE5,
|
||||||
|
0xAA0A4C5F,
|
||||||
|
0xDD0D7CC9,
|
||||||
|
0x5005713C,
|
||||||
|
0x270241AA,
|
||||||
|
0xBE0B1010,
|
||||||
|
0xC90C2086,
|
||||||
|
0x5768B525,
|
||||||
|
0x206F85B3,
|
||||||
|
0xB966D409,
|
||||||
|
0xCE61E49F,
|
||||||
|
0x5EDEF90E,
|
||||||
|
0x29D9C998,
|
||||||
|
0xB0D09822,
|
||||||
|
0xC7D7A8B4,
|
||||||
|
0x59B33D17,
|
||||||
|
0x2EB40D81,
|
||||||
|
0xB7BD5C3B,
|
||||||
|
0xC0BA6CAD,
|
||||||
|
0xEDB88320,
|
||||||
|
0x9ABFB3B6,
|
||||||
|
0x03B6E20C,
|
||||||
|
0x74B1D29A,
|
||||||
|
0xEAD54739,
|
||||||
|
0x9DD277AF,
|
||||||
|
0x04DB2615,
|
||||||
|
0x73DC1683,
|
||||||
|
0xE3630B12,
|
||||||
|
0x94643B84,
|
||||||
|
0x0D6D6A3E,
|
||||||
|
0x7A6A5AA8,
|
||||||
|
0xE40ECF0B,
|
||||||
|
0x9309FF9D,
|
||||||
|
0x0A00AE27,
|
||||||
|
0x7D079EB1,
|
||||||
|
0xF00F9344,
|
||||||
|
0x8708A3D2,
|
||||||
|
0x1E01F268,
|
||||||
|
0x6906C2FE,
|
||||||
|
0xF762575D,
|
||||||
|
0x806567CB,
|
||||||
|
0x196C3671,
|
||||||
|
0x6E6B06E7,
|
||||||
|
0xFED41B76,
|
||||||
|
0x89D32BE0,
|
||||||
|
0x10DA7A5A,
|
||||||
|
0x67DD4ACC,
|
||||||
|
0xF9B9DF6F,
|
||||||
|
0x8EBEEFF9,
|
||||||
|
0x17B7BE43,
|
||||||
|
0x60B08ED5,
|
||||||
|
0xD6D6A3E8,
|
||||||
|
0xA1D1937E,
|
||||||
|
0x38D8C2C4,
|
||||||
|
0x4FDFF252,
|
||||||
|
0xD1BB67F1,
|
||||||
|
0xA6BC5767,
|
||||||
|
0x3FB506DD,
|
||||||
|
0x48B2364B,
|
||||||
|
0xD80D2BDA,
|
||||||
|
0xAF0A1B4C,
|
||||||
|
0x36034AF6,
|
||||||
|
0x41047A60,
|
||||||
|
0xDF60EFC3,
|
||||||
|
0xA867DF55,
|
||||||
|
0x316E8EEF,
|
||||||
|
0x4669BE79,
|
||||||
|
0xCB61B38C,
|
||||||
|
0xBC66831A,
|
||||||
|
0x256FD2A0,
|
||||||
|
0x5268E236,
|
||||||
|
0xCC0C7795,
|
||||||
|
0xBB0B4703,
|
||||||
|
0x220216B9,
|
||||||
|
0x5505262F,
|
||||||
|
0xC5BA3BBE,
|
||||||
|
0xB2BD0B28,
|
||||||
|
0x2BB45A92,
|
||||||
|
0x5CB36A04,
|
||||||
|
0xC2D7FFA7,
|
||||||
|
0xB5D0CF31,
|
||||||
|
0x2CD99E8B,
|
||||||
|
0x5BDEAE1D,
|
||||||
|
0x9B64C2B0,
|
||||||
|
0xEC63F226,
|
||||||
|
0x756AA39C,
|
||||||
|
0x026D930A,
|
||||||
|
0x9C0906A9,
|
||||||
|
0xEB0E363F,
|
||||||
|
0x72076785,
|
||||||
|
0x05005713,
|
||||||
|
0x95BF4A82,
|
||||||
|
0xE2B87A14,
|
||||||
|
0x7BB12BAE,
|
||||||
|
0x0CB61B38,
|
||||||
|
0x92D28E9B,
|
||||||
|
0xE5D5BE0D,
|
||||||
|
0x7CDCEFB7,
|
||||||
|
0x0BDBDF21,
|
||||||
|
0x86D3D2D4,
|
||||||
|
0xF1D4E242,
|
||||||
|
0x68DDB3F8,
|
||||||
|
0x1FDA836E,
|
||||||
|
0x81BE16CD,
|
||||||
|
0xF6B9265B,
|
||||||
|
0x6FB077E1,
|
||||||
|
0x18B74777,
|
||||||
|
0x88085AE6,
|
||||||
|
0xFF0F6A70,
|
||||||
|
0x66063BCA,
|
||||||
|
0x11010B5C,
|
||||||
|
0x8F659EFF,
|
||||||
|
0xF862AE69,
|
||||||
|
0x616BFFD3,
|
||||||
|
0x166CCF45,
|
||||||
|
0xA00AE278,
|
||||||
|
0xD70DD2EE,
|
||||||
|
0x4E048354,
|
||||||
|
0x3903B3C2,
|
||||||
|
0xA7672661,
|
||||||
|
0xD06016F7,
|
||||||
|
0x4969474D,
|
||||||
|
0x3E6E77DB,
|
||||||
|
0xAED16A4A,
|
||||||
|
0xD9D65ADC,
|
||||||
|
0x40DF0B66,
|
||||||
|
0x37D83BF0,
|
||||||
|
0xA9BCAE53,
|
||||||
|
0xDEBB9EC5,
|
||||||
|
0x47B2CF7F,
|
||||||
|
0x30B5FFE9,
|
||||||
|
0xBDBDF21C,
|
||||||
|
0xCABAC28A,
|
||||||
|
0x53B39330,
|
||||||
|
0x24B4A3A6,
|
||||||
|
0xBAD03605,
|
||||||
|
0xCDD70693,
|
||||||
|
0x54DE5729,
|
||||||
|
0x23D967BF,
|
||||||
|
0xB3667A2E,
|
||||||
|
0xC4614AB8,
|
||||||
|
0x5D681B02,
|
||||||
|
0x2A6F2B94,
|
||||||
|
0xB40BBE37,
|
||||||
|
0xC30C8EA1,
|
||||||
|
0x5A05DF1B,
|
||||||
|
0x2D02EF8D,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TuyaException(Exception):
|
||||||
|
"""Base for Tuya exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidKey(TuyaException):
|
||||||
|
"""The local key is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidMessage(TuyaException):
|
||||||
|
"""The message received is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class MessageDecodeFailed(TuyaException):
|
||||||
|
"""The message received cannot be decoded as JSON."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionException(TuyaException):
|
||||||
|
"""The socket connection failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTimeoutException(ConnectionException):
|
||||||
|
"""The socket connection timed out."""
|
||||||
|
|
||||||
|
|
||||||
|
class RequestResponseCommandMismatch(TuyaException):
|
||||||
|
"""The command in the response didn't match the one from the request."""
|
||||||
|
|
||||||
|
|
||||||
|
class TuyaCipher:
|
||||||
|
"""Tuya cryptographic helpers."""
|
||||||
|
|
||||||
|
def __init__(self, key, version):
|
||||||
|
"""Initialize the cipher."""
|
||||||
|
self.version = version
|
||||||
|
self.key = key
|
||||||
|
self.cipher = Cipher(
|
||||||
|
algorithms.AES(key.encode("ascii")), modes.ECB(), backend=openssl_backend
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_prefix_size_and_validate(self, command, encrypted_data):
|
||||||
|
try:
|
||||||
|
version = tuple(map(int, encrypted_data[:3].decode("utf8").split(".")))
|
||||||
|
except ValueError:
|
||||||
|
version = (0, 0)
|
||||||
|
if version != self.version:
|
||||||
|
return 0
|
||||||
|
if version < (3, 3):
|
||||||
|
hash = encrypted_data[3:19].decode("ascii")
|
||||||
|
expected_hash = self.hash(encrypted_data[19:])
|
||||||
|
if hash != expected_hash:
|
||||||
|
return 0
|
||||||
|
return 19
|
||||||
|
else:
|
||||||
|
if command in (Message.SET_COMMAND, Message.GRATUITOUS_UPDATE):
|
||||||
|
_, sequence, __, ___ = struct.unpack_from(">IIIH", encrypted_data, 3)
|
||||||
|
return 15
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def decrypt(self, command, data):
|
||||||
|
prefix_size = self.get_prefix_size_and_validate(command, data)
|
||||||
|
data = data[prefix_size:]
|
||||||
|
decryptor = self.cipher.decryptor()
|
||||||
|
if self.version < (3, 3):
|
||||||
|
data = base64.b64decode(data)
|
||||||
|
decrypted_data = decryptor.update(data)
|
||||||
|
decrypted_data += decryptor.finalize()
|
||||||
|
unpadder = PKCS7(128).unpadder()
|
||||||
|
unpadded_data = unpadder.update(decrypted_data)
|
||||||
|
unpadded_data += unpadder.finalize()
|
||||||
|
|
||||||
|
return unpadded_data
|
||||||
|
|
||||||
|
def encrypt(self, command, data):
|
||||||
|
encrypted_data = b""
|
||||||
|
if data:
|
||||||
|
padder = PKCS7(128).padder()
|
||||||
|
padded_data = padder.update(data)
|
||||||
|
padded_data += padder.finalize()
|
||||||
|
encryptor = self.cipher.encryptor()
|
||||||
|
encrypted_data = encryptor.update(padded_data)
|
||||||
|
encrypted_data += encryptor.finalize()
|
||||||
|
|
||||||
|
prefix = ".".join(map(str, self.version)).encode("utf8")
|
||||||
|
if self.version < (3, 3):
|
||||||
|
payload = base64.b64encode(encrypted_data)
|
||||||
|
hash = self.hash(payload)
|
||||||
|
prefix += hash.encode("utf8")
|
||||||
|
else:
|
||||||
|
payload = encrypted_data
|
||||||
|
if command in (Message.SET_COMMAND, Message.GRATUITOUS_UPDATE):
|
||||||
|
prefix += b"\x00" * 12
|
||||||
|
else:
|
||||||
|
prefix = b""
|
||||||
|
|
||||||
|
return prefix + payload
|
||||||
|
|
||||||
|
def hash(self, data):
|
||||||
|
digest = Hash(MD5(), backend=openssl_backend)
|
||||||
|
to_hash = "data={}||lpv={}||{}".format(
|
||||||
|
data.decode("ascii"), ".".join(map(str, self.version)), self.key
|
||||||
|
)
|
||||||
|
digest.update(to_hash.encode("utf8"))
|
||||||
|
intermediate = digest.finalize().hex()
|
||||||
|
return intermediate[8:24]
|
||||||
|
|
||||||
|
|
||||||
|
def crc(data):
|
||||||
|
"""Calculate the Tuya-flavored CRC of some data."""
|
||||||
|
c = 0xFFFFFFFF
|
||||||
|
for b in data:
|
||||||
|
c = (c >> 8) ^ CRC_32_TABLE[(c ^ b) & 255]
|
||||||
|
|
||||||
|
return c ^ 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
class Message:
|
||||||
|
|
||||||
|
PING_COMMAND = 0x09
|
||||||
|
GET_COMMAND = 0x0A
|
||||||
|
SET_COMMAND = 0x07
|
||||||
|
GRATUITOUS_UPDATE = 0x08
|
||||||
|
|
||||||
|
def __init__(self, command, payload=None, sequence=None, encrypt_for=None):
|
||||||
|
if payload is None:
|
||||||
|
payload = b""
|
||||||
|
self.payload = payload
|
||||||
|
self.command = command
|
||||||
|
if sequence is None:
|
||||||
|
# Use millisecond process time as the sequence number. Not ideal,
|
||||||
|
# but good for one month's continuous connection time though.
|
||||||
|
sequence = int(time.perf_counter() * 1000) & 0xFFFFFFFF
|
||||||
|
self.sequence = sequence
|
||||||
|
self.encrypt = False
|
||||||
|
self.device = None
|
||||||
|
if encrypt_for is not None:
|
||||||
|
self.device = encrypt_for
|
||||||
|
self.encrypt = True
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "{}({}, {!r}, {!r}, {})".format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
hex(self.command),
|
||||||
|
self.payload,
|
||||||
|
self.sequence,
|
||||||
|
"<Device {}>".format(self.device) if self.device else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def hex(self):
|
||||||
|
return self.bytes().hex()
|
||||||
|
|
||||||
|
def bytes(self):
|
||||||
|
payload_data = self.payload
|
||||||
|
if isinstance(payload_data, dict):
|
||||||
|
payload_data = json.dumps(payload_data, separators=(",", ":"))
|
||||||
|
if not isinstance(payload_data, bytes):
|
||||||
|
payload_data = payload_data.encode("utf8")
|
||||||
|
|
||||||
|
if self.encrypt:
|
||||||
|
payload_data = self.device.cipher.encrypt(self.command, payload_data)
|
||||||
|
|
||||||
|
payload_size = len(payload_data) + struct.calcsize(MESSAGE_SUFFIX_FORMAT)
|
||||||
|
|
||||||
|
header = struct.pack(
|
||||||
|
MESSAGE_PREFIX_FORMAT,
|
||||||
|
MAGIC_PREFIX,
|
||||||
|
self.sequence,
|
||||||
|
self.command,
|
||||||
|
payload_size,
|
||||||
|
)
|
||||||
|
if self.device and self.device.version >= (3, 3):
|
||||||
|
checksum = crc(header + payload_data)
|
||||||
|
else:
|
||||||
|
checksum = crc(payload_data)
|
||||||
|
footer = struct.pack(MESSAGE_SUFFIX_FORMAT, checksum, MAGIC_SUFFIX)
|
||||||
|
return header + payload_data + footer
|
||||||
|
|
||||||
|
__bytes__ = bytes
|
||||||
|
|
||||||
|
class AsyncWrappedCallback:
|
||||||
|
def __init__(self, request, callback):
|
||||||
|
self.request = request
|
||||||
|
self.callback = callback
|
||||||
|
self.devices = []
|
||||||
|
|
||||||
|
def register(self, device):
|
||||||
|
self.devices.append(device)
|
||||||
|
device._handlers.setdefault(self.request.command, [])
|
||||||
|
device._handlers[self.request.command].append(self)
|
||||||
|
|
||||||
|
def unregister(self, device):
|
||||||
|
self.devices.remove(device)
|
||||||
|
device._handlers[self.request.command].remove(self)
|
||||||
|
|
||||||
|
def unregister_all(self):
|
||||||
|
while self.devices:
|
||||||
|
device = self.devices.pop()
|
||||||
|
device._handlers[self.request.command].remove(self)
|
||||||
|
|
||||||
|
async def __call__(self, response, device):
|
||||||
|
if response.sequence == self.request.sequence:
|
||||||
|
asyncio.ensure_future(self.callback(response, device))
|
||||||
|
self.unregister(device)
|
||||||
|
|
||||||
|
async def async_send(self, device, callback=None):
|
||||||
|
if callback is not None:
|
||||||
|
wrapped = self.AsyncWrappedCallback(self, callback)
|
||||||
|
wrapped.register(device)
|
||||||
|
await device._async_send(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data, cipher=None):
|
||||||
|
try:
|
||||||
|
prefix, sequence, command, payload_size = struct.unpack_from(
|
||||||
|
MESSAGE_PREFIX_FORMAT, data
|
||||||
|
)
|
||||||
|
except struct.error as e:
|
||||||
|
raise InvalidMessage("Invalid message header format.") from e
|
||||||
|
if prefix != MAGIC_PREFIX:
|
||||||
|
raise InvalidMessage("Magic prefix missing from message.")
|
||||||
|
|
||||||
|
# check for an optional return code
|
||||||
|
header_size = struct.calcsize(MESSAGE_PREFIX_FORMAT)
|
||||||
|
try:
|
||||||
|
(return_code,) = struct.unpack_from(">I", data, header_size)
|
||||||
|
except struct.error as e:
|
||||||
|
raise InvalidMessage("Unable to unpack return code.") from e
|
||||||
|
if return_code >> 8:
|
||||||
|
payload_data = data[
|
||||||
|
header_size : header_size
|
||||||
|
+ payload_size
|
||||||
|
- struct.calcsize(MESSAGE_SUFFIX_FORMAT)
|
||||||
|
]
|
||||||
|
return_code = None
|
||||||
|
else:
|
||||||
|
payload_data = data[
|
||||||
|
header_size
|
||||||
|
+ struct.calcsize(">I") : header_size
|
||||||
|
+ payload_size
|
||||||
|
- struct.calcsize(MESSAGE_SUFFIX_FORMAT)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
expected_crc, suffix = struct.unpack_from(
|
||||||
|
MESSAGE_SUFFIX_FORMAT,
|
||||||
|
data,
|
||||||
|
header_size + payload_size - struct.calcsize(MESSAGE_SUFFIX_FORMAT),
|
||||||
|
)
|
||||||
|
except struct.error as e:
|
||||||
|
raise InvalidMessage("Invalid message suffix format.") from e
|
||||||
|
if suffix != MAGIC_SUFFIX:
|
||||||
|
raise InvalidMessage("Magic suffix missing from message")
|
||||||
|
|
||||||
|
actual_crc = crc(
|
||||||
|
data[: header_size + payload_size - struct.calcsize(MESSAGE_SUFFIX_FORMAT)]
|
||||||
|
)
|
||||||
|
if expected_crc != actual_crc:
|
||||||
|
raise InvalidMessage("CRC check failed")
|
||||||
|
|
||||||
|
payload = None
|
||||||
|
if payload_data:
|
||||||
|
try:
|
||||||
|
payload_data = cipher.decrypt(command, payload_data)
|
||||||
|
except ValueError as e:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
payload_text = payload_data.decode("utf8")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
_LOGGER.debug(payload_data.hex())
|
||||||
|
_LOGGER.error(e)
|
||||||
|
raise MessageDecodeFailed() from e
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_text)
|
||||||
|
except json.decoder.JSONDecodeError as e:
|
||||||
|
# data may be encrypted
|
||||||
|
_LOGGER.debug(payload_data.hex())
|
||||||
|
_LOGGER.error(e)
|
||||||
|
raise MessageDecodeFailed() from e
|
||||||
|
|
||||||
|
return cls(command, payload, sequence)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_async(fn, *args):
|
||||||
|
loop = None
|
||||||
|
if sys.version_info >= (3, 7):
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
def wrapper(fn, *args):
|
||||||
|
asyncio.ensure_future(fn(*args))
|
||||||
|
|
||||||
|
loop.call_soon(wrapper, fn, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class TuyaDevice:
|
||||||
|
"""Represents a generic Tuya device."""
|
||||||
|
|
||||||
|
# PING_INTERVAL = 10
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_id,
|
||||||
|
host,
|
||||||
|
timeout,
|
||||||
|
ping_interval,
|
||||||
|
local_key=None,
|
||||||
|
port=6668,
|
||||||
|
gateway_id=None,
|
||||||
|
version=(3, 3),
|
||||||
|
):
|
||||||
|
"""Initialize the device."""
|
||||||
|
self.device_id = device_id
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
if not gateway_id:
|
||||||
|
gateway_id = self.device_id
|
||||||
|
self.gateway_id = gateway_id
|
||||||
|
self.version = version
|
||||||
|
self.timeout = timeout
|
||||||
|
self.last_pong = 0
|
||||||
|
self.ping_interval = ping_interval
|
||||||
|
|
||||||
|
if len(local_key) != 16:
|
||||||
|
raise InvalidKey("Local key should be a 16-character string")
|
||||||
|
|
||||||
|
self.cipher = TuyaCipher(local_key, self.version)
|
||||||
|
self.writer = None
|
||||||
|
self._handlers = {
|
||||||
|
Message.GET_COMMAND: [self.async_update_state],
|
||||||
|
Message.GRATUITOUS_UPDATE: [self.async_update_state],
|
||||||
|
Message.PING_COMMAND: [self._async_pong_received],
|
||||||
|
}
|
||||||
|
self._dps = {}
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "{}({!r}, {!r}, {!r}, {!r})".format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.device_id,
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
self.cipher.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{} ({}:{})".format(self.device_id, self.host, self.port)
|
||||||
|
|
||||||
|
async def async_connect(self, callback=None):
|
||||||
|
if self._connected:
|
||||||
|
return
|
||||||
|
sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
_LOGGER.debug("Connecting to {}".format(self))
|
||||||
|
try:
|
||||||
|
sock.connect((self.host, self.port))
|
||||||
|
except socket.timeout as e:
|
||||||
|
raise ConnectionTimeoutException("Connection timed out") from e
|
||||||
|
self.reader, self.writer = await asyncio.open_connection(sock=sock)
|
||||||
|
self._connected = True
|
||||||
|
asyncio.ensure_future(self._async_handle_message())
|
||||||
|
asyncio.ensure_future(self._async_ping(self.ping_interval))
|
||||||
|
asyncio.ensure_future(self.async_get(callback))
|
||||||
|
|
||||||
|
async def async_disconnect(self):
|
||||||
|
_LOGGER.debug("Disconnected from {}".format(self))
|
||||||
|
self._connected = False
|
||||||
|
self.last_pong = 0
|
||||||
|
if self.writer is not None:
|
||||||
|
self.writer.close()
|
||||||
|
|
||||||
|
async def async_get(self, callback=None):
|
||||||
|
payload = {"gwId": self.gateway_id, "devId": self.device_id}
|
||||||
|
maybe_self = None if self.version < (3, 3) else self
|
||||||
|
message = Message(Message.GET_COMMAND, payload, encrypt_for=maybe_self)
|
||||||
|
return await message.async_send(self, callback)
|
||||||
|
|
||||||
|
async def async_set(self, dps, callback=None):
|
||||||
|
t = int(time.time())
|
||||||
|
payload = {"devId": self.device_id, "uid": "", "t": t, "dps": dps}
|
||||||
|
message = Message(Message.SET_COMMAND, payload, encrypt_for=self)
|
||||||
|
await message.async_send(self, callback)
|
||||||
|
|
||||||
|
def set(self, dps):
|
||||||
|
_call_async(self.async_set, dps)
|
||||||
|
|
||||||
|
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)
|
||||||
|
if self.last_pong < self.last_ping:
|
||||||
|
await self.async_disconnect()
|
||||||
|
else:
|
||||||
|
asyncio.ensure_future(self._async_ping(self.ping_interval))
|
||||||
|
|
||||||
|
async def _async_pong_received(self, message, device):
|
||||||
|
self.last_pong = time.time()
|
||||||
|
|
||||||
|
async def async_update_state(self, state_message, _):
|
||||||
|
self._dps.update(state_message.payload["dps"])
|
||||||
|
_LOGGER.info("Received updated state {}: {}".format(self, self._dps))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
return dict(self._dps)
|
||||||
|
|
||||||
|
@state.setter
|
||||||
|
def state_setter(self, new_values):
|
||||||
|
asyncio.ensure_future(self.async_set(new_values))
|
||||||
|
|
||||||
|
async def _async_handle_message(self):
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = Message.from_bytes(response_data, self.cipher)
|
||||||
|
except InvalidMessage as e:
|
||||||
|
_LOGGER.error("Invalid message from {}: {}".format(self, e))
|
||||||
|
except MessageDecodeFailed as e:
|
||||||
|
_LOGGER.error("Failed to decrypt message from {}".format(self))
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Received message from {}: {}".format(self, message))
|
||||||
|
for c in self._handlers.get(message.command, []):
|
||||||
|
asyncio.ensure_future(c(message, self))
|
||||||
|
|
||||||
|
asyncio.ensure_future(self._async_handle_message())
|
||||||
|
|
||||||
|
async def _async_send(self, message, retries=4):
|
||||||
|
try:
|
||||||
|
await self.async_connect()
|
||||||
|
except (socket.timeout, socket.error, OSError) as e:
|
||||||
|
if retries == 0:
|
||||||
|
raise ConnectionException(
|
||||||
|
"Failed to send data to {}".format(self)
|
||||||
|
) from e
|
||||||
|
await self.async_connect()
|
||||||
|
await self._async_send(message, retries=retries - 1)
|
||||||
|
_LOGGER.debug("Sending to {}: {}".format(self, message))
|
||||||
|
self.writer.write(message.bytes())
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""Original Work from here: Andre Borie https://gitlab.com/Rjevski/eufy-device-id-and-local-key-grabber"""
|
||||||
|
|
||||||
|
from hashlib import md5, sha256
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from cryptography.hazmat.backends.openssl import backend as openssl_backend
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TUYA_INITIAL_BASE_URL = "https://a1.tuyaeu.com"
|
||||||
|
|
||||||
|
EUFY_HMAC_KEY = (
|
||||||
|
"A_cepev5pfnhua4dkqkdpmnrdxx378mpjr_s8x78u7xwymasd9kqa7a73pjhxqsedaj".encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unpadded_rsa(key_exponent: int, key_n: int, plaintext: bytes) -> bytes:
|
||||||
|
keylength = math.ceil(key_n.bit_length() / 8)
|
||||||
|
input_nr = int.from_bytes(plaintext, byteorder="big")
|
||||||
|
crypted_nr = pow(input_nr, key_exponent, key_n)
|
||||||
|
return crypted_nr.to_bytes(keylength, byteorder="big")
|
||||||
|
|
||||||
|
|
||||||
|
def shuffled_md5(value: str) -> str:
|
||||||
|
_hash = md5(value.encode("utf-8")).hexdigest()
|
||||||
|
return _hash[8:16] + _hash[0:8] + _hash[24:32] + _hash[16:24]
|
||||||
|
|
||||||
|
|
||||||
|
TUYA_PASSWORD_INNER_CIPHER = Cipher(
|
||||||
|
algorithms.AES(
|
||||||
|
bytearray(
|
||||||
|
[36, 78, 109, 138, 86, 172, 135, 145, 36, 67, 45, 139, 108, 188, 162, 196]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
modes.CBC(
|
||||||
|
bytearray(
|
||||||
|
[119, 36, 86, 242, 167, 102, 76, 243, 57, 44, 53, 151, 233, 62, 87, 71]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
backend=openssl_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_TUYA_HEADERS = {"User-Agent": "TY-UA=APP/Android/2.4.0/SDK/null"}
|
||||||
|
|
||||||
|
SIGNATURE_RELEVANT_PARAMETERS = {
|
||||||
|
"a",
|
||||||
|
"v",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"lang",
|
||||||
|
"deviceId",
|
||||||
|
"appVersion",
|
||||||
|
"ttid",
|
||||||
|
"isH5",
|
||||||
|
"h5Token",
|
||||||
|
"os",
|
||||||
|
"clientId",
|
||||||
|
"postData",
|
||||||
|
"time",
|
||||||
|
"requestId",
|
||||||
|
"et",
|
||||||
|
"n4h5",
|
||||||
|
"sid",
|
||||||
|
"sp",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TUYA_QUERY_PARAMS = {
|
||||||
|
"appVersion": "2.4.0",
|
||||||
|
"deviceId": "",
|
||||||
|
"platform": "sdk_gphone64_arm64",
|
||||||
|
"clientId": "yx5v9uc3ef9wg3v9atje",
|
||||||
|
"lang": "en",
|
||||||
|
"osSystem": "12",
|
||||||
|
"os": "Android",
|
||||||
|
"timeZoneId": "Europe/London",
|
||||||
|
"ttid": "android",
|
||||||
|
"et": "0.0.1",
|
||||||
|
"sdkVersion": "3.0.8cAnker",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TuyaAPISession:
|
||||||
|
|
||||||
|
username = None
|
||||||
|
country_code = None
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
def __init__(self, username, country_code):
|
||||||
|
self.session = requests.session()
|
||||||
|
self.session.headers = DEFAULT_TUYA_HEADERS.copy()
|
||||||
|
self.default_query_params = DEFAULT_TUYA_QUERY_PARAMS.copy()
|
||||||
|
self.default_query_params["deviceId"] = self.generate_new_device_id()
|
||||||
|
self.username = username
|
||||||
|
self.country_code = country_code
|
||||||
|
self.base_url = TUYA_INITIAL_BASE_URL
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_new_device_id():
|
||||||
|
expected_length = 44
|
||||||
|
base64_characters = string.ascii_letters + string.digits
|
||||||
|
device_id_dependent_part = "8534c8ec0ed0"
|
||||||
|
return device_id_dependent_part + "".join(
|
||||||
|
random.choice(base64_characters)
|
||||||
|
for _ in range(expected_length - len(device_id_dependent_part))
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_signature(query_params: dict, encoded_post_data: str):
|
||||||
|
query_params = query_params.copy()
|
||||||
|
if encoded_post_data:
|
||||||
|
query_params["postData"] = encoded_post_data
|
||||||
|
sorted_pairs = sorted(query_params.items())
|
||||||
|
filtered_pairs = filter(
|
||||||
|
lambda p: p[0] and p[0] in SIGNATURE_RELEVANT_PARAMETERS, sorted_pairs
|
||||||
|
)
|
||||||
|
mapped_pairs = map(
|
||||||
|
# postData is pre-emptively hashed (for performance reasons?), everything else is included as-is
|
||||||
|
lambda p: p[0] + "=" + (shuffled_md5(p[1]) if p[0] == "postData" else p[1]),
|
||||||
|
filtered_pairs,
|
||||||
|
)
|
||||||
|
message = "||".join(mapped_pairs)
|
||||||
|
return hmac.HMAC(
|
||||||
|
key=EUFY_HMAC_KEY, msg=message.encode("utf-8"), digestmod=sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
version="1.0",
|
||||||
|
data: dict = None,
|
||||||
|
query_params: dict = None,
|
||||||
|
_requires_session=True,
|
||||||
|
):
|
||||||
|
if not self.session_id and _requires_session:
|
||||||
|
self.acquire_session()
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
request_id = uuid.uuid4()
|
||||||
|
extra_query_params = {
|
||||||
|
"time": str(int(current_time)),
|
||||||
|
"requestId": str(request_id),
|
||||||
|
"a": action,
|
||||||
|
"v": version,
|
||||||
|
**(query_params or {}),
|
||||||
|
}
|
||||||
|
query_params = {**self.default_query_params, **extra_query_params}
|
||||||
|
encoded_post_data = json.dumps(data, separators=(",", ":")) if data else ""
|
||||||
|
resp = self.session.post(
|
||||||
|
self.base_url + "/api.json",
|
||||||
|
params={
|
||||||
|
**query_params,
|
||||||
|
"sign": self.get_signature(query_params, encoded_post_data),
|
||||||
|
},
|
||||||
|
data={"postData": encoded_post_data} if encoded_post_data else None,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if "result" not in data:
|
||||||
|
raise Exception(
|
||||||
|
f"No 'result' key in the response - the entire response is {data}."
|
||||||
|
)
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
def request_token(self, username, country_code):
|
||||||
|
return self._request(
|
||||||
|
action="tuya.m.user.uid.token.create",
|
||||||
|
data={"uid": username, "countryCode": country_code},
|
||||||
|
_requires_session=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def determine_password(self, username: str):
|
||||||
|
new_uid = username
|
||||||
|
padded_size = 16 * math.ceil(len(new_uid) / 16)
|
||||||
|
password_uid = new_uid.zfill(padded_size)
|
||||||
|
encryptor = TUYA_PASSWORD_INNER_CIPHER.encryptor()
|
||||||
|
encrypted_uid = encryptor.update(password_uid.encode("utf8"))
|
||||||
|
encrypted_uid += encryptor.finalize()
|
||||||
|
return md5(encrypted_uid.hex().upper().encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def request_session(self, username, country_code):
|
||||||
|
password = self.determine_password(username)
|
||||||
|
token_response = self.request_token(username, country_code)
|
||||||
|
encrypted_password = unpadded_rsa(
|
||||||
|
key_exponent=int(token_response["exponent"]),
|
||||||
|
key_n=int(token_response["publicKey"]),
|
||||||
|
plaintext=password.encode("utf-8"),
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uid": username,
|
||||||
|
"createGroup": True,
|
||||||
|
"ifencrypt": 1,
|
||||||
|
"passwd": encrypted_password.hex(),
|
||||||
|
"countryCode": country_code,
|
||||||
|
"options": '{"group": 1}',
|
||||||
|
"token": token_response["token"],
|
||||||
|
}
|
||||||
|
session_response = self._request(
|
||||||
|
action="tuya.m.user.uid.password.login.reg",
|
||||||
|
data=data,
|
||||||
|
_requires_session=False,
|
||||||
|
)
|
||||||
|
return session_response
|
||||||
|
|
||||||
|
def acquire_session(self):
|
||||||
|
session_response = self.request_session(self.username, self.country_code)
|
||||||
|
self.session_id = self.default_query_params["sid"] = session_response["sid"]
|
||||||
|
self.base_url = session_response["domain"]["mobileApiUrl"]
|
||||||
|
|
||||||
|
def list_homes(self):
|
||||||
|
return self._request(action="tuya.m.location.list", version="2.1")
|
||||||
|
|
||||||
|
def list_devices(self, home_id: str):
|
||||||
|
return self._request(
|
||||||
|
action="tuya.m.my.group.device.list",
|
||||||
|
version="1.0",
|
||||||
|
query_params={"gid": home_id},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,475 @@
|
||||||
|
# Copyright 2022 Brendan McCluskey
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Eufy Robovac sensor platform."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import ast
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import (
|
||||||
|
CONNECTION_NETWORK_MAC,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_ACCESS_TOKEN,
|
||||||
|
CONF_MODEL,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_ID,
|
||||||
|
CONF_IP_ADDRESS,
|
||||||
|
CONF_DESCRIPTION,
|
||||||
|
CONF_MAC,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import CONF_VACS, DOMAIN
|
||||||
|
|
||||||
|
from .tuyalocalapi import TuyaDevice
|
||||||
|
|
||||||
|
from homeassistant.const import ATTR_BATTERY_LEVEL
|
||||||
|
|
||||||
|
ATTR_BATTERY_ICON = "battery_icon"
|
||||||
|
ATTR_FAN_SPEED = "fan_speed"
|
||||||
|
ATTR_FAN_SPEED_LIST = "fan_speed_list"
|
||||||
|
ATTR_STATUS = "status"
|
||||||
|
ATTR_ERROR_CODE = "error_code"
|
||||||
|
ATTR_MODEL_CODE = "model_code"
|
||||||
|
ATTR_CLEANING_AREA = "cleaning_area"
|
||||||
|
ATTR_CLEANING_TIME = "cleaning_time"
|
||||||
|
ATTR_AUTO_RETURN = "auto_return"
|
||||||
|
ATTR_DO_NOT_DISTURB = "do_not_disturb"
|
||||||
|
ATTR_BOOST_IQ = "boost_iq"
|
||||||
|
ATTR_CONSUMABLES = "consumables"
|
||||||
|
ATTR_MODE = "mode"
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
# Time between updating data from GitHub
|
||||||
|
REFRESH_RATE = 20
|
||||||
|
SCAN_INTERVAL = timedelta(seconds=REFRESH_RATE)
|
||||||
|
|
||||||
|
|
||||||
|
class robovac(TuyaDevice):
|
||||||
|
""""""
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize my test integration 2 config entry."""
|
||||||
|
# print("vacuum:async_setup_entry")
|
||||||
|
vacuums = config_entry.data[CONF_VACS]
|
||||||
|
# print("Vac:", vacuums)
|
||||||
|
for item in vacuums:
|
||||||
|
item = vacuums[item]
|
||||||
|
# print("item")
|
||||||
|
async_add_entities([RoboVacEntity(item)])
|
||||||
|
|
||||||
|
|
||||||
|
class RoboVacEntity(StateVacuumEntity):
|
||||||
|
"""Eufy Robovac version of a Vacuum entity"""
|
||||||
|
|
||||||
|
_attr_should_poll = True
|
||||||
|
|
||||||
|
_attr_access_token: str | None = None
|
||||||
|
_attr_ip_address: str | None = None
|
||||||
|
_attr_model_code: str | None = None
|
||||||
|
_attr_cleaning_area: str | None = None
|
||||||
|
_attr_cleaning_time: str | None = None
|
||||||
|
_attr_auto_return: str | None = None
|
||||||
|
_attr_do_not_disturb: str | None = None
|
||||||
|
_attr_boost_iq: str | None = None
|
||||||
|
_attr_consumables: str | None = None
|
||||||
|
_attr_mode: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str | None:
|
||||||
|
"""Return the cleaning mode of the vacuum cleaner."""
|
||||||
|
return self._attr_mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def consumables(self) -> str | None:
|
||||||
|
"""Return the consumables status of the vacuum cleaner."""
|
||||||
|
return self._attr_consumables
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cleaning_area(self) -> str | None:
|
||||||
|
"""Return the cleaning area of the vacuum cleaner."""
|
||||||
|
return self._attr_cleaning_area
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cleaning_time(self) -> str | None:
|
||||||
|
"""Return the cleaning time of the vacuum cleaner."""
|
||||||
|
return self._attr_cleaning_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auto_return(self) -> str | None:
|
||||||
|
"""Return the auto_return mode of the vacuum cleaner."""
|
||||||
|
return self._attr_auto_return
|
||||||
|
|
||||||
|
@property
|
||||||
|
def do_not_disturb(self) -> str | None:
|
||||||
|
"""Return the do not disturb mode of the vacuum cleaner."""
|
||||||
|
return self._attr_do_not_disturb
|
||||||
|
|
||||||
|
@property
|
||||||
|
def boost_iq(self) -> str | None:
|
||||||
|
"""Return the boost iq mode of the vacuum cleaner."""
|
||||||
|
return self._attr_boost_iq
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_code(self) -> str | None:
|
||||||
|
"""Return the model code of the vacuum cleaner."""
|
||||||
|
return self._attr_model_code
|
||||||
|
|
||||||
|
@property
|
||||||
|
def access_token(self) -> str | None:
|
||||||
|
"""Return the fan speed of the vacuum cleaner."""
|
||||||
|
return self._attr_access_token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ip_address(self) -> str | None:
|
||||||
|
"""Return the ip address of the vacuum cleaner."""
|
||||||
|
return self._attr_ip_address
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_attributes(self) -> dict[str, Any]:
|
||||||
|
"""Return the state attributes of the vacuum cleaner."""
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
if self.supported_features & VacuumEntityFeature.BATTERY:
|
||||||
|
data[ATTR_BATTERY_LEVEL] = self.battery_level
|
||||||
|
data[ATTR_BATTERY_ICON] = self.battery_icon
|
||||||
|
if self.supported_features & VacuumEntityFeature.FAN_SPEED:
|
||||||
|
data[ATTR_FAN_SPEED] = self.fan_speed
|
||||||
|
if self.supported_features & VacuumEntityFeature.STATUS:
|
||||||
|
data[ATTR_STATUS] = self.status
|
||||||
|
data[ATTR_CLEANING_AREA] = self.cleaning_area
|
||||||
|
data[ATTR_CLEANING_TIME] = self.cleaning_time
|
||||||
|
data[ATTR_AUTO_RETURN] = self.auto_return
|
||||||
|
data[ATTR_DO_NOT_DISTURB] = self.do_not_disturb
|
||||||
|
data[ATTR_BOOST_IQ] = self.boost_iq
|
||||||
|
data[ATTR_CONSUMABLES] = self.consumables
|
||||||
|
data[ATTR_MODE] = self.mode
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capability_attributes(self) -> Mapping[str, Any] | None:
|
||||||
|
"""Return capability attributes."""
|
||||||
|
if self.supported_features & VacuumEntityFeature.FAN_SPEED:
|
||||||
|
return {
|
||||||
|
ATTR_FAN_SPEED_LIST: self.fan_speed_list,
|
||||||
|
# CONF_ACCESS_TOKEN: self.access_token,
|
||||||
|
CONF_IP_ADDRESS: self.ip_address,
|
||||||
|
ATTR_MODEL_CODE: self.model_code,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
# CONF_ACCESS_TOKEN: self.access_token,
|
||||||
|
CONF_IP_ADDRESS: self.ip_address,
|
||||||
|
ATTR_MODEL_CODE: self.model_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, item) -> None:
|
||||||
|
# print("vacuum:RoboVacEntity")
|
||||||
|
# print("init_item", item)
|
||||||
|
"""Initialize mytest2 Sensor."""
|
||||||
|
super().__init__()
|
||||||
|
self._attr_name = item[CONF_NAME]
|
||||||
|
self._attr_unique_id = item[CONF_ID]
|
||||||
|
self._attr_supported_features = 4084
|
||||||
|
self._attr_model_code = item[CONF_MODEL]
|
||||||
|
self._attr_ip_address = item[CONF_IP_ADDRESS]
|
||||||
|
self._attr_access_token = item[CONF_ACCESS_TOKEN]
|
||||||
|
if self.model_code in [
|
||||||
|
"T2103",
|
||||||
|
"T2117",
|
||||||
|
"T2118",
|
||||||
|
"T2119",
|
||||||
|
"T2120",
|
||||||
|
"T2123",
|
||||||
|
"T2128",
|
||||||
|
"T2130",
|
||||||
|
]: # C
|
||||||
|
self._attr_fan_speed_list = ["No Suction", "Standard", "Boost IQ", "Max"]
|
||||||
|
elif self.model_code in ["T1250", "T2250", "T2251", "T2252", "T2253"]: # G
|
||||||
|
self._attr_fan_speed_list = ["Standard", "Turbo", "Max", "Boost IQ"]
|
||||||
|
elif self.model_code in ["T2262"]: # X
|
||||||
|
self._attr_fan_speed_list = ["Pure", "Standard", "Turbo", "Max"]
|
||||||
|
else:
|
||||||
|
self._attr_fan_speed_list = ["Standard"]
|
||||||
|
self._attr_mode = None
|
||||||
|
self._attr_consumables = None
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, item[CONF_ID])},
|
||||||
|
name=item[CONF_NAME],
|
||||||
|
manufacturer="Eufy",
|
||||||
|
model=item[CONF_DESCRIPTION],
|
||||||
|
connections=[
|
||||||
|
(CONNECTION_NETWORK_MAC, item[CONF_MAC]),
|
||||||
|
],
|
||||||
|
access_token=item[CONF_ACCESS_TOKEN],
|
||||||
|
ip_address=item[CONF_IP_ADDRESS],
|
||||||
|
)
|
||||||
|
self.vacuum = robovac(
|
||||||
|
device_id=self.unique_id,
|
||||||
|
host=self.ip_address,
|
||||||
|
local_key=self.access_token,
|
||||||
|
timeout=2,
|
||||||
|
ping_interval=10
|
||||||
|
# ping_interval=REFRESH_RATE / 2,
|
||||||
|
)
|
||||||
|
self.error_code = None
|
||||||
|
self.tuya_state = None
|
||||||
|
self.tuyastatus = None
|
||||||
|
print("vac:", self.vacuum)
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Synchronise state from the vacuum."""
|
||||||
|
print("update:", self.name)
|
||||||
|
if self.ip_address == "":
|
||||||
|
return
|
||||||
|
await self.vacuum.async_get()
|
||||||
|
self.tuyastatus = self.vacuum._dps
|
||||||
|
print("Tuya local API Result:", self.tuyastatus)
|
||||||
|
# for 15C
|
||||||
|
self._attr_battery_level = self.tuyastatus.get("104")
|
||||||
|
self.tuya_state = self.tuyastatus.get("15")
|
||||||
|
self.error_code = self.tuyastatus.get("106")
|
||||||
|
self._attr_mode = self.tuyastatus.get("5")
|
||||||
|
self._attr_fan_speed = self.tuyastatus.get("102")
|
||||||
|
if self.fan_speed == "No_suction":
|
||||||
|
self._attr_fan_speed = "No Suction"
|
||||||
|
elif self.fan_speed == "Boost_IQ":
|
||||||
|
self._attr_fan_speed = "Boost IQ"
|
||||||
|
elif self.fan_speed == "Quiet":
|
||||||
|
self._attr_fan_speed = "Pure"
|
||||||
|
# for G30
|
||||||
|
self._attr_cleaning_area = self.tuyastatus.get("110")
|
||||||
|
self._attr_cleaning_time = self.tuyastatus.get("109")
|
||||||
|
self._attr_auto_return = self.tuyastatus.get("135")
|
||||||
|
self._attr_do_not_disturb = self.tuyastatus.get("107")
|
||||||
|
if self.tuyastatus.get("142") is not None:
|
||||||
|
self._attr_consumables = ast.literal_eval(
|
||||||
|
base64.b64decode(self.tuyastatus.get("142")).decode("ascii")
|
||||||
|
)["consumable"]["duration"]
|
||||||
|
print(self.consumables)
|
||||||
|
# For X8
|
||||||
|
self._attr_boost_iq = self.tuyastatus.get("118")
|
||||||
|
# self.map_data = self.tuyastatus.get("121")
|
||||||
|
# self.erro_msg? = self.tuyastatus.get("124")
|
||||||
|
if self.tuyastatus.get("116") is not None:
|
||||||
|
self._attr_consumables = ast.literal_eval(
|
||||||
|
base64.b64decode(self.tuyastatus.get("116")).decode("ascii")
|
||||||
|
)["consumable"]["duration"]
|
||||||
|
print(self.consumables)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
"""Return the status of the vacuum cleaner."""
|
||||||
|
print("status:", self.error_code, self.tuya_state)
|
||||||
|
if self.ip_address == "":
|
||||||
|
return "Error: Set the IP Address"
|
||||||
|
if type(self.error_code) is not None and self.error_code not in [0, "no_error"]:
|
||||||
|
if self.error_code == 1:
|
||||||
|
return "Error: Front bumper stuck"
|
||||||
|
elif self.error_code == 2:
|
||||||
|
return "Error: Wheel stuck"
|
||||||
|
elif self.error_code == 3:
|
||||||
|
return "Error: Side brush"
|
||||||
|
elif self.error_code == 4:
|
||||||
|
return "Error: Rolling brush bar stuck"
|
||||||
|
elif self.error_code == 5:
|
||||||
|
return "Error: Device trapped"
|
||||||
|
elif self.error_code == 6:
|
||||||
|
return "Error: Device trapped"
|
||||||
|
elif self.error_code == 7:
|
||||||
|
return "Error: Wheel suspended"
|
||||||
|
elif self.error_code == 8:
|
||||||
|
return "Error: Low battery"
|
||||||
|
elif self.error_code == 9:
|
||||||
|
return "Error: Magnetic boundary"
|
||||||
|
elif self.error_code == 12:
|
||||||
|
return "Error: Right wall sensor"
|
||||||
|
elif self.error_code == 13:
|
||||||
|
return "Error: Device tilted"
|
||||||
|
elif self.error_code == 14:
|
||||||
|
return "Error: Insert dust collector"
|
||||||
|
elif self.error_code == 17:
|
||||||
|
return "Error: Restricted area detected"
|
||||||
|
elif self.error_code == 18:
|
||||||
|
return "Error: Laser cover stuck"
|
||||||
|
elif self.error_code == 19:
|
||||||
|
return "Error: Laser sesor stuck"
|
||||||
|
elif self.error_code == 20:
|
||||||
|
return "Error: Laser sensor blocked"
|
||||||
|
elif self.error_code == 21:
|
||||||
|
return "Error: Base blocked"
|
||||||
|
elif self.error_code == "S1":
|
||||||
|
return "Error: Battery"
|
||||||
|
elif self.error_code == "S2":
|
||||||
|
return "Error: Wheel Module"
|
||||||
|
elif self.error_code == "S3":
|
||||||
|
return "Error: Side Brush"
|
||||||
|
elif self.error_code == "S4":
|
||||||
|
return "Error: Suction Fan"
|
||||||
|
elif self.error_code == "S5":
|
||||||
|
return "Error: Rolling Brush"
|
||||||
|
elif self.error_code == "S8":
|
||||||
|
return "Error: Path Tracking Sensor"
|
||||||
|
elif self.error_code == "Wheel_stuck":
|
||||||
|
return "Error: Wheel stuck"
|
||||||
|
elif self.error_code == "R_brush_stuck":
|
||||||
|
return "Error: Rolling brush stuck"
|
||||||
|
elif self.error_code == "Crash_bar_stuck":
|
||||||
|
return "Error: Front bumper stuck"
|
||||||
|
elif self.error_code == "sensor_dirty":
|
||||||
|
return "Error: Sensor dirty"
|
||||||
|
elif self.error_code == "N_enough_pow":
|
||||||
|
return "Error: Low battery"
|
||||||
|
elif self.error_code == "Stuck_5_min":
|
||||||
|
return "Error: Device trapped"
|
||||||
|
elif self.error_code == "Fan_stuck":
|
||||||
|
return "Error: Fan stuck"
|
||||||
|
elif self.error_code == "S_brush_stuck":
|
||||||
|
return "Error: Side brush stuck"
|
||||||
|
else:
|
||||||
|
return "Error: " + str(self.error_code)
|
||||||
|
elif self.tuya_state == "Running":
|
||||||
|
return "Cleaning"
|
||||||
|
elif self.tuya_state == "Locating":
|
||||||
|
return "Locating"
|
||||||
|
elif self.tuya_state == "remote":
|
||||||
|
return "Cleaning"
|
||||||
|
elif self.tuya_state == "Charging":
|
||||||
|
return "Charging"
|
||||||
|
elif self.tuya_state == "completed":
|
||||||
|
return "Docked"
|
||||||
|
elif self.tuya_state == "Recharge":
|
||||||
|
return "Returning"
|
||||||
|
elif self.tuya_state == "Sleeping":
|
||||||
|
return "Sleeping"
|
||||||
|
elif self.tuya_state == "standby":
|
||||||
|
return "Standby"
|
||||||
|
else:
|
||||||
|
return "Cleaning"
|
||||||
|
|
||||||
|
async def async_locate(self, **kwargs):
|
||||||
|
"""Locate the vacuum cleaner."""
|
||||||
|
print("Locate Pressed")
|
||||||
|
_LOGGER.info("Locate Pressed")
|
||||||
|
if self.tuyastatus.get("103"):
|
||||||
|
await self.vacuum.async_set({"103": False}, None)
|
||||||
|
else:
|
||||||
|
await self.vacuum.async_set({"103": True}, None)
|
||||||
|
|
||||||
|
async def async_return_to_base(self, **kwargs):
|
||||||
|
"""Set the vacuum cleaner to return to the dock."""
|
||||||
|
print("Return home Pressed")
|
||||||
|
_LOGGER.info("Return home Pressed")
|
||||||
|
await self.vacuum.async_set({"101": True}, None)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
self.async_update
|
||||||
|
|
||||||
|
async def async_start_pause(self, **kwargs):
|
||||||
|
"""Pause the cleaning task or resume it."""
|
||||||
|
print("Start/Pause Pressed")
|
||||||
|
_LOGGER.info("Start/Pause Pressed")
|
||||||
|
if self.tuyastatus.get("2") or self.tuya_state == "Recharge":
|
||||||
|
await self.vacuum.async_set({"2": False}, None)
|
||||||
|
else:
|
||||||
|
if self.mode == "Nosweep":
|
||||||
|
self._attr_mode = "auto"
|
||||||
|
elif self.mode == "room" and (
|
||||||
|
self.status == "Charging" or self.status == "completed"
|
||||||
|
):
|
||||||
|
self._attr_mode = "auto"
|
||||||
|
await self.vacuum.async_set({"5": self.mode}, None)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
self.async_update
|
||||||
|
|
||||||
|
async def async_clean_spot(self, **kwargs):
|
||||||
|
"""Perform a spot clean-up."""
|
||||||
|
print("Spot Clean Pressed")
|
||||||
|
_LOGGER.info("Spot Clean Pressed")
|
||||||
|
await self.vacuum.async_set({"5": "Spot"}, None)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
self.async_update
|
||||||
|
|
||||||
|
async def async_set_fan_speed(self, fan_speed, **kwargs):
|
||||||
|
"""Set fan speed."""
|
||||||
|
print("Fan Speed Selected", fan_speed)
|
||||||
|
_LOGGER.info("Fan Speed Selected")
|
||||||
|
if fan_speed == "No Suction":
|
||||||
|
fan_speed = "No_suction"
|
||||||
|
elif fan_speed == "Boost IQ":
|
||||||
|
fan_speed = "Boost_IQ"
|
||||||
|
elif fan_speed == "Pure":
|
||||||
|
fan_speed = "Quiet"
|
||||||
|
await self.vacuum.async_set({"102": fan_speed}, None)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
self.async_update
|
||||||
|
|
||||||
|
async def async_send_command(
|
||||||
|
self, command: str, params: dict | list | None = None, **kwargs
|
||||||
|
) -> None:
|
||||||
|
"""Send a command to a vacuum cleaner."""
|
||||||
|
_LOGGER.info("Send Command %s Pressed", command)
|
||||||
|
if command == "edgeClean":
|
||||||
|
await self.vacuum.async_set({"5": "Edge"}, None)
|
||||||
|
elif command == "smallRoomClean":
|
||||||
|
await self.vacuum.async_set({"5": "SmallRoom"}, None)
|
||||||
|
elif command == "autoClean":
|
||||||
|
await self.vacuum.async_set({"5": "auto"}, None)
|
||||||
|
elif command == "autoReturn":
|
||||||
|
if self.auto_return:
|
||||||
|
await self.vacuum.async_set({"135": False}, None)
|
||||||
|
else:
|
||||||
|
await self.vacuum.async_set({"135": True}, None)
|
||||||
|
elif command == "doNotDisturb":
|
||||||
|
if self.do_not_disturb:
|
||||||
|
await self.vacuum.async_set({"139": "MEQ4MDAwMDAw"}, None)
|
||||||
|
await self.vacuum.async_set({"107": False}, None)
|
||||||
|
else:
|
||||||
|
await self.vacuum.async_set({"139": "MTAwMDAwMDAw"}, None)
|
||||||
|
await self.vacuum.async_set({"107": True}, None)
|
||||||
|
elif command == "boostIQ":
|
||||||
|
if self.boost_iq:
|
||||||
|
await self.vacuum.async_set({"118": False}, None)
|
||||||
|
else:
|
||||||
|
await self.vacuum.async_set({"118": True}, None)
|
||||||
|
elif command == "roomClean":
|
||||||
|
roomIds = params.get("roomIds", [1])
|
||||||
|
count = params.get("count", 1)
|
||||||
|
clean_request = {"roomIds": roomIds, "cleanTimes": count}
|
||||||
|
method_call = {
|
||||||
|
"method": "selectRoomsClean",
|
||||||
|
"data": clean_request,
|
||||||
|
"timestamp": round(time.time() * 1000),
|
||||||
|
}
|
||||||
|
json_str = json.dumps(method_call, separators=(",", ":"))
|
||||||
|
base64_str = base64.b64encode(json_str.encode("utf8")).decode("utf8")
|
||||||
|
_LOGGER.info("roomClean call %s", json_str)
|
||||||
|
await self.vacuum.async_set({"124": base64_str}, None)
|
||||||
Loading…
Reference in New Issue