← Back to Garden
seedling ·
iot ruby smart-home tapo networking

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 nil to 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'
  • 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 http object 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:

  1. Build a TapoClient class - Encapsulate all this logic
  2. Add session persistence - Save/restore sessions to avoid re-auth
  3. Implement auto-reconnect - Handle session timeouts gracefully
  4. Create a CLI tool - tapo on, tapo off, tapo status
  5. Add more commands - Schedules, timers, LED control
  6. Build a web interface - Sinatra/Rails app for control
  7. 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/handshake1 not /handshake1?
  • [ ] Extracting just TP_SESSIONID=value from 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:

  1. UDP broadcast makes discovery simple
  2. KLAP uses mutual authentication - both sides prove identity
  3. AES-128-CBC encrypts all commands
  4. Session management is critical for reliability
  5. Small details (email vs username, endpoint paths) matter

Happy hacking! 🔌⚡