Files
fingerbot-cli/tests/test_lan_session.py
2026-07-05 16:39:15 -05:00

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