Controlling the TP-LINK HS100 Wi-Fi smart plug

[update 27 January 2017] TP-LINK HS100 project on github: https://github.com/ggeorgovassilis/linuxscripts

[update 24 October 2016] TL;DR; This is about a shell script which controls the TP-LINK HS100, HS110, HS200 Wi-Fi smart power plugs. 

I was scurrying down the home automation isle at the local electronics discounter, firm in my determination to make it without distractions to the computer section, when one of those fancy new Wi-Fi power sockets caught my attention. I haven’t quite caught on with the home automation hype yet, but with everything going on I felt left behind and it seemed like a good idea to use the long weekend ahead and try to catch up a bit. And so it came that I left the shop 40€ poorer and with a TP-LINK HS100 Wi-Fi smart plug [1] in the pocket.

The installation is rather easy and unspectacular: a smart phone app helps the HS100 connect to the home WLAN and henceforth controls the power plug. The app being the only way to control the plug bothered me soon, since it meant that the plug could not be controlled by any programme, which, I think, defies the purpose of automation.
Admittedly this doesn’t happen often, but searching turned up nothing regarding my problem; people were happily controlling the HS100 through the app or Amazon Echo –  TP-Link publishes no technical documentation (though they do offer the GPL part of their code for download) and no thrifty hackers had reverse-engineered the plug. I was left alone, bearing grudges.
Not willing to give up my 40€ without a fight, I set up a WLAN access point with a Raspberry Pi (that’s another post coming soon) and armed with tcpdump, Wireshark and patience I listened.
The first thing to notice is that the plug contacts a public ntp server in France (fr.pool.ntp.org)… whatever. Then it looks up devs.tplinkcloud.com on AWS in Ireland from which it receives about 3K, containing what appears to be among others a security certificate.
Then the app polls the plug at regular 2 second intervals when the phone is not sleeping, which, while a bit too often, shouldn’t impact phone battery too much in practice.

Now the juicy stuff: the app controls the plug by connecting via TCP to port 9999 of the plug and exchanging a 55 byte payload. I was absolutely delighted to find that the payload varies only at offset 43 between the on and off commands; it doesn’t vary over time, contains no check sums and is not encrypted. I was able to extract the payload and put it into a simple shell script [2] (update 26 June 2016: thanks to Thomas Baust for figuring out the querying commands).

Usage is simple: hs100.sh , e.g.

switch plug on:

hs100.sh 192.168.1.20 9999 on
check whether plug is switched on:

hs100.sh 192.168.1.20 9999 check


#!/bin/bash

##
# Switch the TP-LINK HS100 wlan smart plug on and off, query for status
# Tested with firmware 1.0.8
#
# Credits to Thomas Baust for the query/status/emeter commands
#
# Author George Georgovassilis, https://github.com/ggeorgovassilis/linuxscripts

ip=$1
port=$2
cmd=$3

check_binaries() {
command -v nc >/dev/null 2>&1 || { echo >&2 "The nc programme for sending data over the network isn't in the path, communication with the plug will fail"; exit 2; }
command -v base64 >/dev/null 2>&1 || { echo >&2 "The base64 programme for decoding base64 encoded strings isn't in the path, decoding of payloads will fail"; exit 2; }
command -v od >/dev/null 2>&1 || { echo >&2 "The od programme for converting binary data to numbers isn't in the path, the status and emeter commands will fail";}
command -v read >/dev/null 2>&1 || { echo >&2 "The read programme for splitting text into tokens isn't in the path, the status and emeter commands will fail";}
command -v printf >/dev/null 2>&1 || { echo >&2 "The printf programme for converting numbers into binary isn't in the path, the status and emeter commands will fail";}
}

# base64 encoded data to send to the plug to switch it on
payload_on="AAAAKtDygfiL/5r31e+UtsWg1Iv5nPCR6LfEsNGlwOLYo4HyhueT9tTu36Lfog=="

# base64 encoded data to send to the plug to switch it off
payload_off="AAAAKtDygfiL/5r31e+UtsWg1Iv5nPCR6LfEsNGlwOLYo4HyhueT9tTu3qPeow=="

# base64 encoded data to send to the plug to query it
payload_query="AAAAI9Dw0qHYq9+61/XPtJS20bTAn+yV5o/hh+jK8J7rh+vLtpbr"

# base64 encoded data to query emeter - hs100 doesn't seem to support this in hardware, but the API seems to be there...
payload_emeter="AAAAJNDw0rfav8uu3P7Ev5+92r/LlOaD4o76k/6buYPtmPSYuMXlmA=="

usage() {
echo Usage:
echo $0 ip port on/off/check/status/emeter
exit 1
}

checkarg() {
name="$1"
value="$2"

if [ -z "$value" ]; then
echo "missing argument $name"
usage
fi
}

checkargs() {
checkarg "ip" $ip
checkarg "port" $port
checkarg "command" $cmd
}

sendtoplug() {
ip="$1"
port="$2"
payload="$3"
echo -n "$payload" | base64 -d | nc -v $ip $port || echo couldn''t connect to $ip:$port, nc failed with exit code $?
}

check(){
output=`sendtoplug $ip $port "$payload_query" | base64`
if [[ $output == AAACJ* ]] ;
then
echo OFF
fi
if [[ $output == AAACK* ]] ;
then
echo ON
fi
}

status(){
payload="$1"
code=171
offset=4
input_num=`sendtoplug $ip $port "$payload" | od --skip-bytes=$offset --address-radix=n -t u1 --width=9999`
IFS=' ' read -r -a array <<< "$input_num"
for element in "${array[@]}"
do
output=$(( $element ^ $code ))
printf "\x$(printf %x $output)"
code=$element
done
}

##
# Main programme
##
check_binaries
checkargs
case "$cmd" in
on)
sendtoplug $ip $port "$payload_on" > /dev/null
;;
off)
sendtoplug $ip $port "$payload_off" > /dev/null
;;
check)
check
;;
status)
status "$payload_query"
;;
emeter)
status "$payload_emeter"
;;
*)
usage
;;
esac
exit 0

Resources

[1] TP-LINK HS100 Wi-Fi smart plug

34 thoughts on “Controlling the TP-LINK HS100 Wi-Fi smart plug

  1. Ha great!!!, thank you George.

    I was afraid for a second that this was encrypted data and would only work in your wifi. However I just tried it and it works great.

    Thanks,
    Paul

    Like

  2. George,

    Would you have the base64 string for polling as well? I tried to capture/convert it from the 2nd picture, but I was able to successfully make a working base64 string out of that.

    Further I wanted to share this PHP snippet of sendtoplug, which might be helpful for someone else.
    function sendtoplug ($ip, $port, $payload, $timeout) {
    $client = stream_socket_client('tcp://'.$ip.':'.$port, $errno, $errorMessage, $timeout);
    if ($client === false) {
    echo “Failed to connect: $errno $errorMessage”;
    } else {
    stream_set_timeout($client, $timeout);
    fwrite($client, base64_decode($payload));
    echo base64_encode(stream_get_line ( $client , 1024 ));
    fclose($client);
    }
    }
    // Result coming back here is always, irrespective of switching on or off.
    // AAAALdDygfiL/5r31e+UtsWg1Iv5nPCR6LfEsNGlwOLYo4HkluS72LfTtpSunuOe4w==

    Thanks,
    Paul

    Like

  3. Thanks 🙂
    Polling is tricky: the poller opens an UDP socket, then tells the plug the address and port of that UDP socket (somehow, haven't found out how) and then the plug sends periodically to that UDP socket a heartbeat.

    Like

  4. I wrote some Java code using your command strings and it works correctly. Using Wireshark, I can see the traffic from my code to the device. However, I can't see traffic (except the UDP heartbeat) via the android app. I guess your “WLAN access point with a Raspberry Pi” is the key to sniffing-out the command strings? I ask because I am interested in reverse engineering other similar devices (ex. Ankuoo NEO).

    Like

  5. I am running Wireshark on a Win10 laptop and can see the phone app sending the UDP messages to port 9999. I don't see any TCP messages from the phone.

    Like

  6. Hi George,
    your payload_off sequence is to long, just use
    AAAAKtDygfiL/5r31e+UtsWg1Iv5nPCR6LfEsNGlwOLYo4HyhueT9tTu3qPeow==

    Btw: The “encryption” is just xor every Byte with the previous one. As result you will get a Json message with 5bytes Header and maybe one char padding at the end 😉

    Like

  7. …now I got it – the first 4 bytes is the message length (not encryted, big endian), then the encryption sequence starts (at the fifth byte) with 0xAB as initial key (every JSON messages starts with an open bracket 😉

    btw: query for system information is “AAAAI9Dw0qHYq9+61/XPtJS20bTAn+yV5o/hh+jK8J7rh+vLtpbr”
    and query emeter (HS110) “AAAAJNDw0rfav8uu3P7Ev5+92r/LlOaD4o76k/6buYPtmPSYuMXlmA==”

    Like

  8. Hi George,

    This little c-prgramm can decode the messages – just pipe the raw data to it like:
    echo AAAAI9Dw0qHYq9+61/XPtJS20bTAn+yV5o/hh+jK8J7rh+vLtpbr|base64 -d|./hs1xdec

    #include

    int main(int argc, char *argv[])
    {
    int c=getchar();
    int skip=4;
    int code=0xAB;
    while(c!=EOF)
    {
    if (skip>0)
    {
    skip–;
    c=getchar();
    }
    else
    {
    putchar(c^code);
    code=c;
    c=getchar();
    }
    }
    printf(“\n”);
    }

    Like

  9. Thank you for this! Just like you, I tried looking for API when I bought this a couple of months ago but didn't find any. Searched for it again and you had done the research and published it!

    Like

  10. Hi George,

    i have managed to implement a wireshark dissector – just save it in your plugin directory (with .lua as exension):
    — declare the protocol
    hs1x0_proto_TCP = Proto (“HS1x0U”, “TP Link HS1x0 (TCP”)
    hs1x0_proto_UDP = Proto (“HS1x0T”, “TP Link HS1x0 (UDP)”)

    function common_dissector(buffer,start,tree)
    local code = 171
    local size = buffer:len()
    local decoded = “”

    — Apply XOR cipher and save the decoded string
    for i=start,size-1 do
    data = bit.bxor(buffer(i,1):uint(), code)
    decoded = decoded .. string.char(data)
    code=buffer(i,1):uint()
    end

    return decoded
    end

    function hs1x0_proto_TCP.dissector(buffer,pinfo,tree)
    pinfo.cols.protocol = “TP HS1x0”
    local decoded = common_dissector(buffer,4,tree)

    — Make wireshark display our results
    local subtree = tree:add(hs1x0_proto_TCP, buffer() ,”HS1x0 Protocol (TCP)”)
    subtree:add(buffer(0,size), “Decoded: ” .. decoded)
    end

    function hs1x0_proto_UDP.dissector(buffer,pinfo,tree)
    pinfo.cols.protocol = “TP HS1x0”
    local decoded = common_dissector(buffer,0,tree)

    — Make wireshark display our results
    local subtree = tree:add(hs1x0_proto_UDP, buffer() ,”HS1x0 Protocol (UDP)”)
    subtree:add(buffer(0,size), “Decoded: ” .. decoded)
    end

    — load the tcp port table
    tcp_table = DissectorTable.get (“tcp.port”)
    udp_table = DissectorTable.get (“udp.port”)

    — register the protocol to port 9999
    tcp_table:add (9999, hs1x0_proto_TCP)
    udp_table:add (9999, hs1x0_proto_UDP)

    Like

  11. Thanks! Works great in the Bash shell in Windows 10 (TP-Link does not provide a Windows app of any sort). So now I just need to figure out how to do the same use C# and .NET or Javascript & HTML (I’m not much of a programmer! -Hello, World! is about my speed).

    Liked by 1 person

    1. If you care only about switching the plug on and off and don’t care about querying the status then you can just dump the binary commands into the plug over a socket connection; that should be reasonably easy in C#, but I doubt it’s possible with Javascript from a browser since it doesn’t send UDP packages

      Like

      1. Thanks for the suggestion. Apparently C# async socket connections have become a lot easier in .NET 4.5 so I’ll give it a try. Knowing that I can communicate with my device (a TP-Link HS200 switch) via your script gives me a great jumping-off point. Since I’d eventually like to do it outside my LAN, I might need to learn a little something about secure IOT connections over the Internet, too!

        Like

  12. While further researching how to communicate with TP-Link Smart Home devices, I came across this in-depth softScheck security analysis of the HS-110 that goes nicely with and extends what George and Thomas Baust have found out: https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ On GitHub, Stroetmann and Esser provide several modules to interact with the HS-100 or HS-110 including a WireShark plug-in to decrypt TCP packets from the devices and a Python client to issue commands to the devices. According to the authors, there are some security vulnerabilities in the devices. Stroetmann and Esser did their work several months after George first experimented with the HS-100.

    Like

  13. Well, this is very cool. I copied your script onto a Raspberry Pi, along with the ha-bridge program. Ha-bridge is a Philips Hue Bridge software emulator, and allows devices like old X10 smart plugs and switches, for example, to appear as Hue devices and be controlled by voice via Amazon Echo. It also allows Echo on, off, and dim voice commands meant for Hue hardware to trigger scripts, like yours. So, by inserting your TP-Link on and off commands into ha-bridge, I can now control my smart switches using Alexa, but without going through TP-Link’s Kasa Skill or using their cloud server. Doing everything locally through the Pi is both faster and more dependable, as TP-Link’s cloud server has been down or very slow for long periods of time in the past. Nice, thank you!

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s