Building a Tapo Smart Plug Client from Scratch
This is a practical companion to the Tapo KLAP Protocol deep dive. It focuses on implementation details and gotchas when building a real client.
Prerequisites
require 'socket'
require 'openssl'
require 'json'
require 'digest' # For SHA1, SHA256
require 'digest/crc32'
require 'net/http'
require 'timeout'
require 'base64'
Step 1: Device Discovery
Implementation
def discover_tapo_devices(timeout_seconds = 5)
socket = UDPSocket.new
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
socket.bind('0.0.0.0', 0)
payload = build_discovery_payload
socket.send(payload, 0, '255.255.255.255', 20002)
discovered_ips = []
begin
Timeout.timeout(timeout_seconds) do
loop do
ready = IO.select([socket])
next unless ready
response_data, addr_info = socket.recvfrom_nonblock(2048)
device_ip = addr_info[2]
discovered_ips << device_ip unless discovered_ips.include?(device_ip)
end
end
rescue Timeout::Error
# Expected - stop listening after timeout
ensure
socket&.close
end
discovered_ips
end
def build_discovery_payload
# Generate RSA key pair
rsa_key = OpenSSL::PKey::RSA.new(1024)
# Create JSON body
json_body = {
params: {
rsa_key: rsa_key.public_key.to_pem
}
}.to_json
# Build binary header
version = 2
msg_type = 0
op_code = 1
msg_size = json_body.bytesize
flags = 17
padding = 0
device_serial = rand(0..0xFFFFFFFF)
crc_placeholder = 0x5A6B7C8D
header = [
version, msg_type, op_code, msg_size, flags, padding,
device_serial, crc_placeholder
].pack('CCnnCCNN')
# Calculate real CRC
full_message = header + json_body
real_crc = Digest::CRC32.checksum(full_message)
# Replace CRC at offset 12
header[12, 4] = [real_crc].pack('N')
header + json_body
end
Common Issues
Issue: "No devices found"
- Ensure your computer and Tapo device are on the same network
- Check if your firewall is blocking UDP port 20002
- Some routers block broadcast packets - try from a different machine
Issue: "Getting responses from routers/other devices"
- Filter responses by checking the response format or trying to authenticate
- Not all devices that respond are Tapo devices
Step 2: Protocol Detection
def discover_protocol(device_ip)
uri = URI("http://#{device_ip}/")
http = Net::HTTP.new(uri.host, uri.port)
http.read_timeout = 5
request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json')
request.body = { method: 'get_device_info' }.to_json
response = http.request(request)
# KLAP responds with 401
return :klap if response.code == '401'
# Parse JSON response
begin
json_response = JSON.parse(response.body)
error_code = json_response['error_code']
# Error 1003 = method not found (KLAP device)
return :klap if error_code == 1003
# Other errors = Passthrough device
return :passthrough
rescue JSON::ParserError
# HTML/XML response = not a Tapo device
return nil
end
rescue => e
puts "Error: #{e.message}"
nil
end
Common Issues
Issue: "Getting HTML responses"
- This is normal for non-Tapo devices (routers, etc.)
- Return
nilto indicate not a Tapo device
Issue: "401 vs 1003 confusion"
- Newer devices return 401 immediately
- Older/some firmware versions return 200 with error_code 1003
- Check for both to support all devices
Step 3: KLAP Authentication
The KlapCipher Class
class KlapCipher
attr_reader :seq
def initialize(local_seed, remote_seed, auth_hash)
combined = local_seed + remote_seed + auth_hash
# Derive keys
@key = Digest::SHA256.digest("lsk" + combined)[0, 16]
iv_hash = Digest::SHA256.digest("iv" + combined)
@iv_base = iv_hash[0, 12]
@seq = iv_hash[-4..-1].unpack1('N').to_i
@signature_key = Digest::SHA256.digest("ldk" + combined)[0, 28]
end
def encrypt(plaintext)
@seq += 1
iv = @iv_base + [@seq].pack('N')
cipher = OpenSSL::Cipher.new('AES-128-CBC')
cipher.encrypt
cipher.key = @key
cipher.iv = iv
cipher.padding = 1
ciphertext = cipher.update(plaintext) + cipher.final
signature = Digest::SHA256.digest(@signature_key + [@seq].pack('N') + ciphertext)
[signature + ciphertext, @seq]
end
def decrypt(encrypted_data, seq)
ciphertext = encrypted_data[32..-1] # Skip signature
iv = @iv_base + [seq].pack('N')
decipher = OpenSSL::Cipher.new('AES-128-CBC')
decipher.decrypt
decipher.key = @key
decipher.iv = iv
decipher.padding = 1
decipher.update(ciphertext) + decipher.final
end
end
Authentication Function
class KlapError < StandardError; end
def authenticate_klap(device_ip, username, password)
base_url = "http://#{device_ip}/app"
uri = URI(base_url)
http = Net::HTTP.new(uri.host, uri.port)
http.read_timeout = 5
# Generate auth_hash
username_hash = Digest::SHA1.digest(username)
password_hash = Digest::SHA1.digest(password)
auth_hash = Digest::SHA256.digest(username_hash + password_hash)
# Handshake 1
local_seed = OpenSSL::Random.random_bytes(16)
handshake1_req = Net::HTTP::Post.new('/app/handshake1', 'Content-Type' => 'application/octet-stream')
handshake1_req.body = local_seed
handshake1_res = http.request(handshake1_req)
raise KlapError, "Handshake1 failed: #{handshake1_res.code}" if handshake1_res.code != '200'
# Extract cookie
cookie_header = handshake1_res['set-cookie']
cookie = nil
if cookie_header && cookie_header =~ /TP_SESSIONID=([^;]+)/
cookie = "TP_SESSIONID=#{$1}"
end
# Parse response
response_body = handshake1_res.body
raise KlapError, "Response too small" if response_body.bytesize < 48
response_body = response_body[0, 48] if response_body.bytesize > 48
remote_seed = response_body[0, 16]
server_hash = response_body[16, 32]
# Verify server
local_hash = Digest::SHA256.digest(local_seed + remote_seed + auth_hash)
raise KlapError, "Authentication failed - wrong credentials" if local_hash != server_hash
# Handshake 2
client_hash = Digest::SHA256.digest(remote_seed + local_seed + auth_hash)
handshake2_req = Net::HTTP::Post.new('/app/handshake2', 'Content-Type' => 'application/octet-stream')
handshake2_req['Cookie'] = cookie if cookie
handshake2_req.body = client_hash
handshake2_res = http.request(handshake2_req)
raise KlapError, "Handshake2 failed: #{handshake2_res.code}" if handshake2_res.code != '200'
# Create cipher
cipher = KlapCipher.new(local_seed, remote_seed, auth_hash)
{
cookie: cookie,
cipher: cipher,
device_ip: device_ip,
http: http # Reuse connection
}
end
Common Issues
Issue: "Server hash verification failed"
- Most common: Using username instead of full email address
- ❌ Wrong:
'ashishrao2598' - ✅ Correct:
'ashishrao2598@gmail.com'
- ❌ Wrong:
- Check password is correct
- Ensure device firmware is up to date
Issue: "Handshake1 returns HTML"
- You're hitting the wrong endpoint
- ✅ Correct:
/app/handshake1 - ❌ Wrong:
/handshake1
Issue: "Handshake2 returns 400"
- Cookie not being sent properly
- Extract just
TP_SESSIONID=value, not the full cookie header - Check cookie is included in the request
Issue: "Response body is 49 bytes instead of 48"
- Some devices add a newline character
- Solution: Take first 48 bytes:
response_body[0, 48]
Step 4: Making Requests
def klap_request(session, request_data)
http = session[:http]
json_payload = request_data.to_json
# Encrypt
encrypted_payload, seq = session[:cipher].encrypt(json_payload)
# Send
request = Net::HTTP::Post.new("/app/request?seq=#{seq}", 'Content-Type' => 'application/octet-stream')
request['Cookie'] = session[:cookie]
request.body = encrypted_payload
response = http.request(request)
raise KlapError, "Request failed: #{response.code}" if response.code != '200'
# Decrypt and parse
decrypted_response = session[:cipher].decrypt(response.body, seq)
JSON.parse(decrypted_response)
end
Common Commands
Get device info:
info = klap_request(session, { method: 'get_device_info' })
puts "Device: #{Base64.decode64(info['result']['nickname'])}"
puts "State: #{info['result']['device_on'] ? 'ON' : 'OFF'}"
Turn ON:
result = klap_request(session, {
method: 'set_device_info',
params: { device_on: true }
})
Turn OFF:
result = klap_request(session, {
method: 'set_device_info',
params: { device_on: false }
})
Get energy usage (P110 only):
energy = klap_request(session, { method: 'get_energy_usage' })
current_power_watts = energy['result']['current_power'] / 1000.0
puts "Current power: #{current_power_watts} W"
Common Issues
Issue: "Request failed with status 403"
- Session expired (timeout ~5 minutes)
- Solution: Re-authenticate
- Better: Reuse HTTP connection from session
Issue: "Can't make request right after handshake"
- Need to reuse the same HTTP connection
- Solution: Store
httpobject in session hash - Return it from
authenticate_klap
Issue: "Sequence number out of sync"
- Don't create multiple KlapCipher instances for same session
- Each request must increment the sequence atomically
- The cipher maintains state - don't clone it
Step 5: Putting It All Together
# Discover devices
ips = discover_tapo_devices
ips.each do |ip|
protocol = discover_protocol(ip)
next unless protocol == :klap
begin
# Authenticate
session = authenticate_klap(ip, 'your@email.com', 'your_password')
# Get info
info = klap_request(session, { method: 'get_device_info' })
device_on = info['result']['device_on']
# Toggle
klap_request(session, {
method: 'set_device_info',
params: { device_on: !device_on }
})
puts "✓ Toggled device at #{ip}"
rescue KlapError => e
puts "✗ Failed for #{ip}: #{e.message}"
end
end
Best Practices
1. Reuse Connections
# ❌ Bad: Creates new connection each time
def klap_request(session, request_data)
http = Net::HTTP.new(session[:device_ip], 80)
# ...
end
# ✅ Good: Reuse connection from session
def klap_request(session, request_data)
http = session[:http]
# ...
end
2. Handle Timeouts
def authenticate_klap(device_ip, username, password)
# Set reasonable timeout
http.read_timeout = 5
http.open_timeout = 5
# Handle timeout errors
begin
# ... handshake code ...
rescue Net::OpenTimeout, Net::ReadTimeout
raise KlapError, "Device not responding"
end
end
3. Validate Responses
result = klap_request(session, { method: 'set_device_info', params: { device_on: true }})
if result['error_code'] != 0
raise KlapError, "Command failed: error #{result['error_code']}"
end
4. Don't Hardcode Credentials
# ❌ Bad
TAPO_USERNAME = 'myemail@gmail.com'
TAPO_PASSWORD = 'mypassword123'
# ✅ Good
TAPO_USERNAME = ENV['TAPO_USERNAME']
TAPO_PASSWORD = ENV['TAPO_PASSWORD']
Next Steps
Now that you have a working client, you could:
- Build a TapoClient class - Encapsulate all this logic
- Add session persistence - Save/restore sessions to avoid re-auth
- Implement auto-reconnect - Handle session timeouts gracefully
- Create a CLI tool -
tapo on,tapo off,tapo status - Add more commands - Schedules, timers, LED control
- Build a web interface - Sinatra/Rails app for control
- Package as a gem - Share with the Ruby community
Troubleshooting Checklist
When things don't work:
- [ ] Using full email address, not just username?
- [ ] Device and computer on same network?
- [ ] Firewall allowing UDP 20002 and HTTP 80?
- [ ] Using
/app/handshake1not/handshake1? - [ ] Extracting just
TP_SESSIONID=valuefrom cookie? - [ ] Reusing HTTP connection from session?
- [ ] Handling 48/49 byte response body?
- [ ] Checking for error_code in responses?
Resources
Conclusion
You now have all the pieces to build a fully functional Tapo client! The key takeaways:
- UDP broadcast makes discovery simple
- KLAP uses mutual authentication - both sides prove identity
- AES-128-CBC encrypts all commands
- Session management is critical for reliability
- Small details (email vs username, endpoint paths) matter
Happy hacking! 🔌⚡