I have a couple of fans around the apartment to keep us cool. Being the lazy type I found that using their manual switch is terrible; I really needed to be able to turn them on or off without getting out of bed or getting off the couch.
To add on/off control to them I decided to buy a couple of TP-Link WiFi-controlled plugs at Amazon. These aren’t bad, but the only way to control them is with an app that TP-Link produces–it’s cumbersome to pick up your phone, unlock, swipe to find the app, then touch the fan for the room you’re in, especially in the middle of the night.
What I needed was a really simple controller. Just a single button. If the fan is on, pushing the button turns it off. If the fan is off, pushing the button turns it on.
After some quick research it became clear that local network control of these WiFi plugs is really trivial–you make a TCP socket connection to their IP at port 9999 and then send a set of XOR’d JSON commands. If you poke around online you’ll find the details easily enough.
I have some Particle Photon’s (WiFi enabled Arduino’s), so I wrote up a quick sketch that works fairly well, but it ends up being a switch hung off of hook-up wire, a breadboard, and a 5V power supply–the control may be simple, but the rest is bulky and delicate. It’s also not cheap, the Photon’s alone are $20.
Then Amazon Prime Day happened and they put their Dash buttons up for 99¢ … that’s the perfect controller for this project! — It’s compact, easy to use (just one button), battery powered, and now it’s under $1 each.
Amazon only allowed buying of one of each brand, so I picked eight random products and ordered some Dash buttons. At under $1 each Amazon is certainly losing money sending me these, but I figured I spend so much there anyway they owe me one (or eight).
There are plenty of posts on the Internet about hardware teardowns of Dash buttons and all the potential from the hardware packed into these guys, but most people have settled on a very simple and practical way of using these in “off label” ways — The gist of it is that each time the Dash button gets pressed the Dash will connect to your WiFi network and send an ARP broadcast to make sure it’s OK to keep using the IP that it has. That ARP is associated to a mostly-unique MAC address which you can then sniff out using another computer on the network. Using this technique you can determine when a Dash button is pressed without having to make any changes to the Dash button hardware or software.
The key here of course is that you must get your Dash button on your WiFi network without completing the setup to Amazon–otherwise you’ll be ordering stuff each time the button is pushed! Luckily it’s surprisingly easy to do this, you pretty much follow all the usual instructions for setup, but then near the final step you simply don’t select a product to associate to the button, instead you quit the app/setup. This leaves your Dash setup to get on your network and make its request to Amazon, but Amazon will reject it because this Dash is not associated to an order or product.
There are a variety of ways to accomplish the sniffing, some people online are using NodeJS, but I’m more comfortable with Python. Python also makes quick work of the TP-Link half of this project.
The script below is a quick and lazy cobbling of some Dash button sniffer logic and TP-Link HS-105 controlling logic. In summary, it sits and listens for an ARP broadcast from the hard-coded MAC addresses, once one is detected it polls the current state of the appropriate TP-Link plug and then sets that plug’s relay to the opposite state.
You can modify the script to attach any number of Dash button MAC addresses to any number of TP-Link plugs–even controlling multiple plugs with a single Dash button press. In the script I have hardcoded everything for simplicity, but you could get fancy about the associations if you wanted.
First note I’ll make is that the Dash buttons will sometimes broadcast an extra ARP beacon or two, to counter this I added in a short 5-sec back-off time to prevent toggling an outlet more than one time in those cases.
I should also note that at this stage of development the error handling is virtually non-existent, in fact it’ll quit if it can’t reach a plug on the network.
That said, it’s good enough for now!
from scapy.all import * import json import socket import time onCmd = '{"system":{"set_relay_state":{"state":1}}}' offCmd = '{"system":{"set_relay_state":{"state":0}}}' infoCmd = '{"system":{"get_sysinfo":{}}}' port = 9999 brMAC = '68:37:e9:e6:33:f0' # Airheads lrMAC = 'ac:63:be:b8:31:d4' # Tide brIP = '10.0.1.44' lrIP = '10.0.1.39' brLC = 0 lrLC = 0 def arp_display(pkt): global lrLC, brLC if pkt[ARP].op == 1: #who-has (request) if pkt[ARP].hwsrc == lrMAC: print "Pushed Tide - Living Room" if (time.time() - lrLC > 5): #print "Executing..." lrLC = time.time() currentState(lrIP) else: print "Duplicate, skipping..." elif pkt[ARP].hwsrc == brMAC: print "Pushed Airheads - Bedroom" if (time.time() - brLC > 5): #print "Executing..." brLC = time.time() currentState(brIP) else: print "Duplicate, skipping..." #else: # print "ARP Probe from unknown device: " + pkt[ARP].hwsrc def encrypt(string): key = 171 result = "\0\0\0\0" for i in string: a = key ^ ord(i) key = a result += chr(a) return result def decrypt(string): key = 171 result = "" for i in string: a = key ^ ord(i) key = ord(i) result += chr(a) return result def currentState(string): try: sock_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock_tcp.connect((string, port)) eString = encrypt(infoCmd) sock_tcp.send(eString) data = sock_tcp.recv(2048) sock_tcp.close() rawJson = decrypt(data[4:]) #print rawJson decodedJson = json.loads(rawJson) currentState = decodedJson["system"]["get_sysinfo"]["relay_state"] if (currentState == 1): print "Currently ON, turning OFF" turnOff(string) else: print "Currently OFF, turning ON" turnOn(string) except socket.error: quit("Cound not connect to host") def turnOff(string): try: sock_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock_tcp.connect((string, port)) eString = encrypt('{"system":{"set_relay_state":{"state":0}}}') sock_tcp.send(eString) data = sock_tcp.recv(2048) sock_tcp.close() except socket.error: quit("Cound not connect to host") def turnOn(string): try: sock_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock_tcp.connect((string, port)) eString = encrypt('{"system":{"set_relay_state":{"state":1}}}') sock_tcp.send(eString) data = sock_tcp.recv(2048) sock_tcp.close() except socket.error: quit("Cound not connect to host") print sniff(prn=arp_display, filter="arp", store=0)