mirror of
https://github.com/mitchellrj/eufy_robovac.git
synced 2025-11-25 12:42:40 +00:00
Tuya 3.3 support (fixes #3)
* support tuya 3.3 protocol * Switch argument order * Debug logging for demo * Home Assistant config changes * Vacuum has a 'power' property * Formatting changes * Improve Tuya message repr * Include socket error in error output.
This commit is contained in:
@@ -15,7 +15,7 @@ bin/pip install -e .
|
|||||||
|
|
||||||
## Demo usage
|
## Demo usage
|
||||||
```
|
```
|
||||||
bin/demo DEVICE_ID LOCAL_KEY IP
|
bin/demo DEVICE_ID IP LOCAL_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
The demo:
|
The demo:
|
||||||
|
|||||||
@@ -15,12 +15,16 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import pprint
|
import pprint
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from eufy_robovac.robovac import Robovac
|
from eufy_robovac.robovac import Robovac
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
async def connected_callback(message, device):
|
async def connected_callback(message, device):
|
||||||
print("Connected. Current device state:")
|
print("Connected. Current device state:")
|
||||||
pprint.pprint(device.state)
|
pprint.pprint(device.state)
|
||||||
@@ -30,8 +34,8 @@ async def cleaning_started_callback(message, device):
|
|||||||
print("Cleaning started.")
|
print("Cleaning started.")
|
||||||
|
|
||||||
|
|
||||||
async def async_main(device_id, local_key, ip, *args, **kwargs):
|
async def async_main(device_id, ip, local_key=None, *args, **kwargs):
|
||||||
r = Robovac(device_id, local_key, ip, *args, **kwargs)
|
r = Robovac(device_id, ip, local_key, *args, **kwargs)
|
||||||
await r.async_connect(connected_callback)
|
await r.async_connect(connected_callback)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
print("Starting cleaning...")
|
print("Starting cleaning...")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ DOMAIN = 'eufy_vacuum'
|
|||||||
|
|
||||||
DEVICE_SCHEMA = vol.Schema({
|
DEVICE_SCHEMA = vol.Schema({
|
||||||
vol.Required(CONF_ADDRESS): cv.string,
|
vol.Required(CONF_ADDRESS): cv.string,
|
||||||
vol.Required(CONF_ACCESS_TOKEN): cv.string,
|
vol.Optional(CONF_ACCESS_TOKEN): cv.string,
|
||||||
vol.Required(CONF_ID): cv.string,
|
vol.Required(CONF_ID): cv.string,
|
||||||
vol.Required(CONF_TYPE): cv.string,
|
vol.Required(CONF_TYPE): cv.string,
|
||||||
vol.Optional(CONF_NAME): cv.string
|
vol.Optional(CONF_NAME): cv.string
|
||||||
@@ -36,9 +36,9 @@ def setup(hass, config):
|
|||||||
for device_info in config.get(DOMAIN, {}).get(CONF_DEVICES, []):
|
for device_info in config.get(DOMAIN, {}).get(CONF_DEVICES, []):
|
||||||
device = {}
|
device = {}
|
||||||
device['address'] = device_info[CONF_ADDRESS]
|
device['address'] = device_info[CONF_ADDRESS]
|
||||||
device['local_key'] = device_info[CONF_ACCESS_TOKEN]
|
device['local_key'] = device_info.get(CONF_ACCESS_TOKEN)
|
||||||
device['device_id'] = device_info[CONF_ID]
|
device['device_id'] = device_info[CONF_ID]
|
||||||
device['name'] = device_info[CONF_NAME]
|
device['name'] = device_info.get(CONF_NAME)
|
||||||
device['model'] = device_info[CONF_TYPE]
|
device['model'] = device_info[CONF_TYPE]
|
||||||
discovery.load_platform(hass, 'vacuum', DOMAIN, device, config)
|
discovery.load_platform(hass, 'vacuum', DOMAIN, device, config)
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class ErrorCode(StringEnum):
|
|||||||
class Robovac(TuyaDevice):
|
class Robovac(TuyaDevice):
|
||||||
"""Represents a generic Eufy Robovac."""
|
"""Represents a generic Eufy Robovac."""
|
||||||
|
|
||||||
|
POWER = '1'
|
||||||
PLAY_PAUSE = '2'
|
PLAY_PAUSE = '2'
|
||||||
DIRECTION = '3'
|
DIRECTION = '3'
|
||||||
WORK_MODE = '5'
|
WORK_MODE = '5'
|
||||||
@@ -85,6 +86,7 @@ class Robovac(TuyaDevice):
|
|||||||
BATTERY_LEVEL = '104'
|
BATTERY_LEVEL = '104'
|
||||||
ERROR_CODE = '106'
|
ERROR_CODE = '106'
|
||||||
|
|
||||||
|
power = DeviceProperty(POWER)
|
||||||
play_pause = DeviceProperty(PLAY_PAUSE)
|
play_pause = DeviceProperty(PLAY_PAUSE)
|
||||||
direction = DeviceProperty(DIRECTION)
|
direction = DeviceProperty(DIRECTION)
|
||||||
work_mode = DeviceProperty(WORK_MODE, WorkMode)
|
work_mode = DeviceProperty(WORK_MODE, WorkMode)
|
||||||
|
|||||||
@@ -166,23 +166,34 @@ class TuyaCipher:
|
|||||||
self.cipher = Cipher(algorithms.AES(key.encode('ascii')), modes.ECB(),
|
self.cipher = Cipher(algorithms.AES(key.encode('ascii')), modes.ECB(),
|
||||||
backend=openssl_backend)
|
backend=openssl_backend)
|
||||||
|
|
||||||
def has_prefix(self, encrypted_data):
|
def get_prefix_size_and_validate(self, command, encrypted_data):
|
||||||
version = tuple(map(int, encrypted_data[:3].decode('utf8').split('.')))
|
try:
|
||||||
|
version = tuple(map(int, encrypted_data[:3].decode('utf8').split('.')))
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
version = (0, 0)
|
||||||
if version != self.version:
|
if version != self.version:
|
||||||
return False
|
return 0
|
||||||
hash = encrypted_data[3:19].decode('ascii')
|
if version < (3, 3):
|
||||||
expected_hash = self.hash(encrypted_data[19:])
|
hash = encrypted_data[3:19].decode('ascii')
|
||||||
if hash != expected_hash:
|
expected_hash = self.hash(encrypted_data[19:])
|
||||||
return False
|
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
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def decrypt(self, data):
|
def decrypt(self, command, data):
|
||||||
# Strip version and MD5 hash
|
prefix_size = self.get_prefix_size_and_validate(command, data)
|
||||||
if self.has_prefix(data):
|
data = data[prefix_size:]
|
||||||
data = data[19:]
|
|
||||||
decryptor = self.cipher.decryptor()
|
decryptor = self.cipher.decryptor()
|
||||||
decrypted_data = decryptor.update(base64.b64decode(data))
|
if self.version < (3, 3):
|
||||||
|
data = base64.b64decode(data)
|
||||||
|
decrypted_data = decryptor.update(data)
|
||||||
decrypted_data += decryptor.finalize()
|
decrypted_data += decryptor.finalize()
|
||||||
unpadder = PKCS7(128).unpadder()
|
unpadder = PKCS7(128).unpadder()
|
||||||
unpadded_data = unpadder.update(decrypted_data)
|
unpadded_data = unpadder.update(decrypted_data)
|
||||||
@@ -190,23 +201,29 @@ class TuyaCipher:
|
|||||||
|
|
||||||
return unpadded_data
|
return unpadded_data
|
||||||
|
|
||||||
def encrypt(self, data):
|
def encrypt(self, command, data):
|
||||||
padder = PKCS7(128).padder()
|
encrypted_data = b''
|
||||||
padded_data = padder.update(data)
|
if data:
|
||||||
padded_data += padder.finalize()
|
padder = PKCS7(128).padder()
|
||||||
encryptor = self.cipher.encryptor()
|
padded_data = padder.update(data)
|
||||||
encrypted_data = encryptor.update(padded_data)
|
padded_data += padder.finalize()
|
||||||
encrypted_data += encryptor.finalize()
|
encryptor = self.cipher.encryptor()
|
||||||
|
encrypted_data = encryptor.update(padded_data)
|
||||||
|
encrypted_data += encryptor.finalize()
|
||||||
|
|
||||||
payload = base64.b64encode(encrypted_data)
|
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''
|
||||||
|
|
||||||
hash = self.hash(payload)
|
return prefix + payload
|
||||||
result = "{}{}".format(
|
|
||||||
'.'.join(map(str, self.version)),
|
|
||||||
hash
|
|
||||||
).encode('utf8') + payload
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def hash(self, data):
|
def hash(self, data):
|
||||||
digest = Hash(MD5(), backend=openssl_backend)
|
digest = Hash(MD5(), backend=openssl_backend)
|
||||||
@@ -253,11 +270,13 @@ class Message:
|
|||||||
self.encrypt = True
|
self.encrypt = True
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "{}({}, {}, {})".format(
|
return "{}({}, {!r}, {!r}, {})".format(
|
||||||
self.__class__.__name__,
|
self.__class__.__name__,
|
||||||
hex(self.command),
|
hex(self.command),
|
||||||
repr(self.payload),
|
self.payload,
|
||||||
self.sequence)
|
self.sequence,
|
||||||
|
"<Device {}>".format(self.device) if self.device else None
|
||||||
|
)
|
||||||
|
|
||||||
def hex(self):
|
def hex(self):
|
||||||
return self.bytes().hex()
|
return self.bytes().hex()
|
||||||
@@ -273,9 +292,9 @@ class Message:
|
|||||||
payload_data = payload_data.encode('utf8')
|
payload_data = payload_data.encode('utf8')
|
||||||
|
|
||||||
if self.encrypt:
|
if self.encrypt:
|
||||||
payload_data = self.device.cipher.encrypt(payload_data)
|
payload_data = self.device.cipher.encrypt(self.command, payload_data)
|
||||||
|
|
||||||
payload_size = len(payload_data) + 8
|
payload_size = len(payload_data) + struct.calcsize(MESSAGE_SUFFIX_FORMAT)
|
||||||
|
|
||||||
header = struct.pack(
|
header = struct.pack(
|
||||||
MESSAGE_PREFIX_FORMAT,
|
MESSAGE_PREFIX_FORMAT,
|
||||||
@@ -284,9 +303,13 @@ class Message:
|
|||||||
self.command,
|
self.command,
|
||||||
payload_size,
|
payload_size,
|
||||||
)
|
)
|
||||||
|
if self.device and self.device.version >= (3, 3):
|
||||||
|
checksum = crc(header + payload_data)
|
||||||
|
else:
|
||||||
|
checksum = crc(payload_data)
|
||||||
footer = struct.pack(
|
footer = struct.pack(
|
||||||
MESSAGE_SUFFIX_FORMAT,
|
MESSAGE_SUFFIX_FORMAT,
|
||||||
crc(payload_data),
|
checksum,
|
||||||
MAGIC_SUFFIX
|
MAGIC_SUFFIX
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
@@ -295,6 +318,8 @@ class Message:
|
|||||||
footer
|
footer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
__bytes__ = bytes
|
||||||
|
|
||||||
class AsyncWrappedCallback:
|
class AsyncWrappedCallback:
|
||||||
def __init__(self, request, callback):
|
def __init__(self, request, callback):
|
||||||
self.request = request
|
self.request = request
|
||||||
@@ -345,54 +370,45 @@ class Message:
|
|||||||
except struct.error as e:
|
except struct.error as e:
|
||||||
raise InvalidMessage("Unable to unpack return code.") from e
|
raise InvalidMessage("Unable to unpack return code.") from e
|
||||||
if return_code >> 8:
|
if return_code >> 8:
|
||||||
payload_data = data[header_size:header_size + payload_size - 8]
|
payload_data = data[header_size:header_size + payload_size - struct.calcsize(MESSAGE_SUFFIX_FORMAT)]
|
||||||
return_code = None
|
return_code = None
|
||||||
else:
|
else:
|
||||||
payload_data = data[header_size + 4:header_size + payload_size - 8]
|
payload_data = data[header_size + struct.calcsize('>I'):header_size + payload_size - struct.calcsize(MESSAGE_SUFFIX_FORMAT)]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
expected_crc, suffix = struct.unpack_from(
|
expected_crc, suffix = struct.unpack_from(
|
||||||
MESSAGE_SUFFIX_FORMAT,
|
MESSAGE_SUFFIX_FORMAT,
|
||||||
data,
|
data,
|
||||||
header_size + payload_size - 8
|
header_size + payload_size - struct.calcsize(MESSAGE_SUFFIX_FORMAT)
|
||||||
)
|
)
|
||||||
except struct.error as e:
|
except struct.error as e:
|
||||||
raise InvalidMessage("Invalid message suffix format.") from e
|
raise InvalidMessage("Invalid message suffix format.") from e
|
||||||
if suffix != MAGIC_SUFFIX:
|
if suffix != MAGIC_SUFFIX:
|
||||||
raise InvalidMessage("Magic suffix missing from message")
|
raise InvalidMessage("Magic suffix missing from message")
|
||||||
|
|
||||||
actual_crc = crc(data[:header_size + payload_size - 8])
|
actual_crc = crc(data[:header_size + payload_size - struct.calcsize(MESSAGE_SUFFIX_FORMAT)])
|
||||||
if expected_crc != actual_crc:
|
if expected_crc != actual_crc:
|
||||||
raise InvalidMessage("CRC check failed")
|
raise InvalidMessage("CRC check failed")
|
||||||
|
|
||||||
payload = None
|
payload = None
|
||||||
if payload_data:
|
if payload_data:
|
||||||
try_decrypt = False
|
try:
|
||||||
|
payload_data = cipher.decrypt(command, payload_data)
|
||||||
|
except ValueError as e:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
payload_text = payload_data.decode('utf8')
|
payload_text = payload_data.decode('utf8')
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError as e:
|
||||||
try_decrypt = True
|
_LOGGER.debug(payload_data.hex())
|
||||||
else:
|
_LOGGER.error(e)
|
||||||
try:
|
raise MessageDecodeFailed() from e
|
||||||
payload = json.loads(payload_text)
|
|
||||||
except json.decoder.JSONDecodeError:
|
|
||||||
# data may be encrypted
|
|
||||||
try_decrypt = True
|
|
||||||
|
|
||||||
if try_decrypt:
|
|
||||||
try:
|
|
||||||
payload_data = cipher.decrypt(payload_data)
|
|
||||||
except ValueError as e:
|
|
||||||
_LOGGER.debug(payload_data.hex())
|
|
||||||
_LOGGER.error(e)
|
|
||||||
raise MessageDecodeFailed() from e
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = payload_data.decode('utf8')
|
payload = json.loads(payload_text)
|
||||||
payload = json.loads(payload)
|
except json.decoder.JSONDecodeError as e:
|
||||||
except (UnicodeDecodeError, json.decoder.JSONDecodeError):
|
# data may be encrypted
|
||||||
# keep it as bytes
|
_LOGGER.debug(payload_data.hex())
|
||||||
pass
|
_LOGGER.error(e)
|
||||||
|
raise MessageDecodeFailed() from e
|
||||||
|
|
||||||
return cls(command, payload, sequence)
|
return cls(command, payload, sequence)
|
||||||
|
|
||||||
@@ -418,8 +434,8 @@ class TuyaDevice:
|
|||||||
|
|
||||||
PING_INTERVAL = 10
|
PING_INTERVAL = 10
|
||||||
|
|
||||||
def __init__(self, device_id, local_key, host, port=6668, gateway_id=None,
|
def __init__(self, device_id, host, local_key=None, port=6668,
|
||||||
version=(3, 1), timeout=10):
|
gateway_id=None, version=(3, 3), timeout=10):
|
||||||
"""Initialize the device."""
|
"""Initialize the device."""
|
||||||
self.device_id = device_id
|
self.device_id = device_id
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -445,12 +461,12 @@ class TuyaDevice:
|
|||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "{}({}, {}, {}, {})".format(
|
return "{}({!r}, {!r}, {!r}, {!r})".format(
|
||||||
self.__class__.__name__,
|
self.__class__.__name__,
|
||||||
self.device_id,
|
self.device_id,
|
||||||
self.host,
|
self.host,
|
||||||
self.port,
|
self.port,
|
||||||
self.local_key
|
self.cipher.key
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -485,13 +501,13 @@ class TuyaDevice:
|
|||||||
'gwId': self.gateway_id,
|
'gwId': self.gateway_id,
|
||||||
'devId': self.device_id
|
'devId': self.device_id
|
||||||
}
|
}
|
||||||
message = Message(Message.GET_COMMAND, payload)
|
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)
|
return await message.async_send(self, callback)
|
||||||
|
|
||||||
async def async_set(self, dps, callback=None):
|
async def async_set(self, dps, callback=None):
|
||||||
t = int(time.time())
|
t = int(time.time())
|
||||||
payload = {
|
payload = {
|
||||||
'gwId': self.gateway_id,
|
|
||||||
'devId': self.device_id,
|
'devId': self.device_id,
|
||||||
'uid': '',
|
'uid': '',
|
||||||
't': t,
|
't': t,
|
||||||
@@ -505,7 +521,9 @@ class TuyaDevice:
|
|||||||
|
|
||||||
async def _async_ping(self):
|
async def _async_ping(self):
|
||||||
self.last_ping = time.time()
|
self.last_ping = time.time()
|
||||||
message = Message(Message.PING_COMMAND, sequence=0)
|
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 self._async_send(message)
|
||||||
await asyncio.sleep(self.PING_INTERVAL)
|
await asyncio.sleep(self.PING_INTERVAL)
|
||||||
if self.last_pong < self.last_ping:
|
if self.last_pong < self.last_ping:
|
||||||
@@ -532,7 +550,7 @@ class TuyaDevice:
|
|||||||
try:
|
try:
|
||||||
response_data = await self.reader.readuntil(MAGIC_SUFFIX_BYTES)
|
response_data = await self.reader.readuntil(MAGIC_SUFFIX_BYTES)
|
||||||
except socket.error as e:
|
except socket.error as e:
|
||||||
_LOGGER.error("Connection to {} failed".format(self))
|
_LOGGER.error("Connection to {} failed: {}".format(self, e))
|
||||||
asyncio.ensure_future(self.async_disconnect())
|
asyncio.ensure_future(self.async_disconnect())
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ class EufyVacuum(VacuumDevice):
|
|||||||
v: k for k, v in self._config['fan_speeds'].items()}
|
v: k for k, v in self._config['fan_speeds'].items()}
|
||||||
self._device_id = device_config['device_id']
|
self._device_id = device_config['device_id']
|
||||||
self.robovac = robovac.Robovac(
|
self.robovac = robovac.Robovac(
|
||||||
device_config['device_id'], device_config['local_key'],
|
device_config['device_id'], device_config['address'],
|
||||||
device_config['address'])
|
device_config['local_key'])
|
||||||
self._name = device_config['name']
|
self._name = device_config['name']
|
||||||
|
|
||||||
async def async_update(self):
|
async def async_update(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user