New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for v3.4 devices #179
Conversation
@uzlonewolf this is brilliant! I just ordered what I hope to be a 3.4 device to help with testing (send me the one you found). I also love the bits you cleaned up. |
I just picked up the one linked in #178 as well as a couple others listed as "new model" from that same brand. I have no idea what they actually are, I'll post links if they show up with v3.4 on them. |
Ha! I finally got around to opening the relays I got in last week, and... they're v3.4. I got the 4-channel version: https://www.aliexpress.com/item/3256803462490229.html There were 2 problems with this PR code: 1) the AES decrypt was blowing up because it was trying to .decode() binary data, and 2) session key generation cannot be done in _get_socket() because the payload is already encrypted by this point. I already got 1) fixed but am still working on a clean way of fixing 2). |
Oh yeah, a 3rd issue was the device rejects seqno 0, so that was updated to start at 1. I ended up moving the session key generation into _generate_message() (which is called from generate_payload()) and made it also open the socket when the version is 3.4. Not the prettiest, but I don't see any better option with the key needing to be negotiated before the payload is encrypted. This also means I had to disable socket closing/reopening in _send_receive() as it has no way of re-encrypting the payload. Moving the encryption into _send_receive() may be something to think about for a future update. |
When I updated the version bytes detection in _decode_payload() I originally removed the device22 check as I didn't think it was needed, but as I have no device22 devices I don't know for sure and so I added it back in with a slight modification so it only triggers if it also has a non-mod-16 payload length. With that, I think it's ready. Closes #178 and #148
|
Well that thought was short-lived. Seems it does not like setting DPS values. Looking into it... |
I ended up writing a decoder to decode packet captures from between the app and device in local mode. To use:
import sys
import yaml
import tinytuya
lkey = b'0123456789abcdef'
if( len(sys.argv) is not 2 ):
print( 'Usage:', sys.argv[0], 'somefile.yaml' )
else:
with open( sys.argv[1] ) as f:
data = yaml.load(f)#, Loader=yaml.FullLoader)
skey = lkey
for pid in data:
k = pid.split('_')
packet_id = int(k[1])
peer = int(k[0][-1])
pkts = data[pid].split(b'\x00\x00U\xaa')
for pkt in pkts:
if len(pkt) == 0:
continue
pkt = b'\x00\x00U\xaa' + pkt
if peer == 0:
msg = tinytuya.unpack_message(pkt, hmac_key=skey, no_retcode=True)
else:
msg = tinytuya.unpack_message(pkt, hmac_key=skey)
if len(msg.payload) == 0:
#print('empty payload')
continue
if msg.cmd == 5:
print('peer %d cmd %d seqno %d len %d - cmd 5 ignored' % (peer,msg.cmd,msg.seqno,len(msg.payload)))
continue
payload = msg.payload
extra = len(payload) & 0x0F
if extra != 0:
print('payload len wrong!', len(payload), payload )
print(pkt)
payload = payload[:-extra]
#payload += b'\0' * (16-extra)
cipher = tinytuya.AESCipher(skey)
payload = cipher.decrypt(payload, False, decode_text=False)
print('peer %d cmd %d seqno %d raw len %d decoded len %d crc %r payload %r' % (peer,msg.cmd,msg.seqno,len(msg.payload),len(payload), msg.crc_good, payload))
if msg.cmd == 3:
skey = lkey
lnonce = payload
elif msg.cmd == 4:
rnonce = payload[:16]
skey = bytes( [ a^b for (a,b) in zip(lnonce,rnonce) ] )
cipher = tinytuya.AESCipher(lkey)
skey = cipher.encrypt(skey, False, pad=False)
print( 'local nonce: %r remote nonce: %r session key: %r' % (lnonce, rnonce, skey) )
else:
pass When run on a capture I made it said:
|
…ps results are often in {data:{dps:{...}}
This was a friggin can-o-worms. I think I got everything now though. Unlike 3.3 devices, 3.4 devices encrypt the version header in addition to the payload, and if you send them a bad command they drop the connection and you need to close and re-open it (and renegotiate the session key) before you can send anything else. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @uzlonewolf ! I'm running full test on the 3.4 device I just got (this EDIT). So far so good!
Only problem so far is that the scanner.py functions do not work with 3.4 - taking a look now. I suspect your draft #177 is working?
I see the problem... this works: d = tinytuya.OutletDevice('id', '10.0.1.90', 'key')
d.set_version(3.4)
d.set_socketPersistent(True)
print(d.status()) But if you don't set_socketPersistent() it fails: d = tinytuya.OutletDevice('id', '10.0.1.90', 'key')
d.set_version(3.4)
print(d.status())
|
Correct me if I'm wrong, but we really can't have a 3.4 device that is non persistent. It negotiates a session based key so there is no point in a non-persistent version. I need to work through implications or what I'm missing, but this is a quick fix in I confirmed that this change also fixes scanner.py functions for 3.4 devices too. I'm pushing change up for now and staging it and release notes as version 1.7.0 (not pushing to PyPI yet). |
I don't see why it couldn't be non-persistent, it's just instead of "connect -> send request -> disconnect" it will "connect -> negotiate key -> send request ->disconnect". In fact if someone has a program that waits more than ~30 seconds between requests then it must do this as otherwise the socket will be closed due to inactivity and the next _send_receive() will fail because it has no way of re-opening the socket or re-encrypting the payload. The more I think about it the less I like how generate_payload() / _generate_message() do the encrypting. I think I'm going to fix persistence for the current code and then rework it so the encryption is done in _send_receive(). This may cause issues if people are calling _send_receive() with something other than what generate_payload() returns, but I think ultimately it will be the better solution. |
Finally got a 3.4 device and got this working. I modeled it after harryzz's code at harryzz/tuyapi@ef4473b . The modifications were a lot more extensive than I initially anticipated due to the switch from CRC32 to HMAC checksums.
Closes #148
Closes #178