Private
Public Access
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
import asyncio
|
|
import hashlib
|
|
import hmac as _hmac
|
|
import struct
|
|
|
|
import pytest
|
|
|
|
from tuya_re import crypto, lan_protocol as lp
|
|
from tuya_re.const import LanCommand
|
|
from tuya_re.lan_session import LanSwitch
|
|
|
|
KEY = b"0123456789abcdef"
|
|
KEY_STR = KEY.decode() # "0123456789abcdef" is ascii
|
|
|
|
|
|
async def _read_frame(reader):
|
|
header = await reader.readexactly(16)
|
|
length = struct.unpack(">4I", header)[3]
|
|
return header + await reader.readexactly(length)
|
|
|
|
|
|
class FakePlug:
|
|
def __init__(self, state):
|
|
self.state = state
|
|
self.server = None
|
|
self.host = self.port = None
|
|
|
|
async def start(self):
|
|
self.server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
|
|
sock = self.server.sockets[0].getsockname()
|
|
self.host, self.port = sock[0], sock[1]
|
|
|
|
async def _handle(self, reader, writer):
|
|
try:
|
|
while True:
|
|
frame = await _read_frame(reader)
|
|
msg = lp.unpack_frame(frame)
|
|
req = lp.decode_v33_payload(KEY, msg.payload)
|
|
if msg.command == LanCommand.DP_QUERY:
|
|
body = lp.encode_v33_command(
|
|
KEY, LanCommand.DP_QUERY, {"dps": {"1": self.state}}
|
|
)
|
|
writer.write(
|
|
lp.pack_frame(msg.seqno, LanCommand.DP_QUERY, body, retcode=0)
|
|
)
|
|
elif msg.command == LanCommand.CONTROL:
|
|
self.state = bool(req["dps"]["1"])
|
|
body = lp.encode_v33_command(KEY, LanCommand.CONTROL, {})
|
|
writer.write(
|
|
lp.pack_frame(msg.seqno, LanCommand.CONTROL, body, retcode=0)
|
|
)
|
|
await writer.drain()
|
|
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
async def plug():
|
|
p = FakePlug(state=False)
|
|
await p.start()
|
|
yield p
|
|
p.server.close()
|
|
|
|
|
|
async def test_status_reads_dp1(plug):
|
|
plug.state = True
|
|
s = LanSwitch(
|
|
device_id="d", local_key=KEY_STR, version="3.3", host=plug.host, port=plug.port
|
|
)
|
|
assert await s.status() is True
|
|
|
|
|
|
async def test_on_off_toggle(plug):
|
|
s = LanSwitch("d", KEY_STR, "3.3", plug.host, plug.port)
|
|
await s.turn_on()
|
|
assert plug.state is True
|
|
await s.turn_off()
|
|
assert plug.state is False
|
|
await s.toggle() # was off -> on
|
|
assert plug.state is True
|
|
|
|
|
|
async def test_wrong_key_raises(plug):
|
|
s = LanSwitch("d", "ffffffffffffffff", "3.3", plug.host, plug.port)
|
|
with pytest.raises(lp.LanFrameError):
|
|
await s.status()
|
|
|
|
|
|
async def test_unsupported_version_raises(plug):
|
|
s = LanSwitch("d", KEY_STR, "3.5", plug.host, plug.port)
|
|
with pytest.raises(NotImplementedError):
|
|
await s.status()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# v3.4 fake server and tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
REMOTE_NONCE = b"abcdef0123456789"
|
|
|
|
|
|
class FakePlugV34:
|
|
def __init__(self, state):
|
|
self.state = state
|
|
self.server = None
|
|
self.host = self.port = None
|
|
|
|
async def start(self):
|
|
self.server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
|
|
s = self.server.sockets[0].getsockname()
|
|
self.host, self.port = s[0], s[1]
|
|
|
|
async def _handle(self, reader, writer):
|
|
try:
|
|
# negotiation
|
|
start = lp.unpack_frame(await _read_frame(reader), hmac_key=KEY)
|
|
local_nonce = crypto.aes_ecb_decrypt(KEY, start.payload)[:16]
|
|
resp_plain = (
|
|
REMOTE_NONCE + _hmac.new(KEY, local_nonce, hashlib.sha256).digest()
|
|
)
|
|
writer.write(
|
|
lp.pack_frame(
|
|
2,
|
|
LanCommand.SESS_KEY_NEG_RESP,
|
|
crypto.aes_ecb_encrypt(KEY, resp_plain),
|
|
hmac_key=KEY,
|
|
)
|
|
)
|
|
await writer.drain()
|
|
_finish = lp.unpack_frame(await _read_frame(reader), hmac_key=KEY)
|
|
session = lp.derive_session_key_v34(KEY, local_nonce, REMOTE_NONCE)
|
|
# commands
|
|
while True:
|
|
msg = lp.unpack_frame(await _read_frame(reader), hmac_key=session)
|
|
req = lp.decode_payload(session, msg.payload, version="3.4")
|
|
if msg.command == LanCommand.DP_QUERY:
|
|
body = lp.encode_command(
|
|
session,
|
|
LanCommand.DP_QUERY,
|
|
{"dps": {"1": self.state}},
|
|
version="3.4",
|
|
)
|
|
writer.write(
|
|
lp.pack_frame(
|
|
msg.seqno,
|
|
LanCommand.DP_QUERY,
|
|
body,
|
|
retcode=0,
|
|
hmac_key=session,
|
|
)
|
|
)
|
|
elif msg.command == LanCommand.CONTROL_NEW:
|
|
self.state = bool(req["data"]["dps"]["1"])
|
|
body = lp.encode_command(
|
|
session, LanCommand.CONTROL_NEW, {}, version="3.4"
|
|
)
|
|
writer.write(
|
|
lp.pack_frame(
|
|
msg.seqno,
|
|
LanCommand.CONTROL_NEW,
|
|
body,
|
|
retcode=0,
|
|
hmac_key=session,
|
|
)
|
|
)
|
|
await writer.drain()
|
|
except (asyncio.IncompleteReadError, ConnectionResetError, lp.LanFrameError):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
async def plug34():
|
|
p = FakePlugV34(state=False)
|
|
await p.start()
|
|
yield p
|
|
p.server.close()
|
|
|
|
|
|
async def test_v34_status_and_toggle(plug34):
|
|
s = LanSwitch("d", KEY_STR, "3.4", plug34.host, plug34.port)
|
|
plug34.state = True
|
|
assert await s.status() is True
|
|
await s.turn_off()
|
|
assert plug34.state is False
|
|
await s.turn_on()
|
|
assert plug34.state is True
|
|
await s.toggle()
|
|
assert plug34.state is False
|