401 lines
9.1 KiB
Go
401 lines
9.1 KiB
Go
package tapo
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha1"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/evcc-io/evcc/util/request"
|
|
"github.com/mergermarket/go-pkcs7"
|
|
)
|
|
|
|
// Tapo homepage + api reverse engineering results
|
|
// https://www.tapo.com/de/
|
|
// Credits to & inspired by:
|
|
// https://k4czp3r.xyz/reverse-engineering/tp-link/tapo/2020/10/15/reverse-engineering-tp-link-tapo.html
|
|
// https://github.com/fishbigger/TapoP100
|
|
// https://github.com/artemvang/p100-go
|
|
|
|
const Timeout = time.Second * 15
|
|
|
|
// Connection is the Tapo connection
|
|
type Connection struct {
|
|
*request.Helper
|
|
log *util.Logger
|
|
URI string
|
|
EncodedUser string
|
|
EncodedPassword string
|
|
Cipher *ConnectionCipher
|
|
SessionID string
|
|
Token string
|
|
TerminalUUID string
|
|
updated time.Time
|
|
lasttodayenergy int64
|
|
energy int64
|
|
}
|
|
|
|
// NewConnection creates a new Tapo device connection.
|
|
// User is encoded by using MessageDigest of SHA1 which is afterwards B64 encoded.
|
|
// Password is directly B64 encoded.
|
|
func NewConnection(uri, user, password string) (*Connection, error) {
|
|
if uri == "" {
|
|
return nil, errors.New("missing uri")
|
|
}
|
|
|
|
if user == "" || password == "" {
|
|
return nil, fmt.Errorf("missing user or password")
|
|
}
|
|
|
|
for _, suffix := range []string{"/", "/app"} {
|
|
uri = strings.TrimSuffix(uri, suffix)
|
|
}
|
|
|
|
log := util.NewLogger("tapo")
|
|
|
|
// nosemgrep:go.lang.security.audit.crypto.use_of_weak_crypto.use-of-sha1
|
|
h := sha1.New()
|
|
_, err := h.Write([]byte(user))
|
|
userhash := hex.EncodeToString(h.Sum(nil))
|
|
|
|
conn := &Connection{
|
|
log: log,
|
|
Helper: request.NewHelper(log),
|
|
URI: fmt.Sprintf("%s/app", util.DefaultScheme(uri, "http")),
|
|
EncodedUser: base64.StdEncoding.EncodeToString([]byte(userhash)),
|
|
EncodedPassword: base64.StdEncoding.EncodeToString([]byte(password)),
|
|
}
|
|
|
|
conn.Client.Timeout = Timeout
|
|
|
|
return conn, err
|
|
}
|
|
|
|
// Login provides the Tapo device session token and MAC address (TerminalUUID).
|
|
func (d *Connection) Login() error {
|
|
err := d.Handshake()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := map[string]interface{}{
|
|
"method": "login_device",
|
|
"params": map[string]interface{}{
|
|
"username": d.EncodedUser,
|
|
"password": d.EncodedPassword,
|
|
},
|
|
}
|
|
|
|
res, err := d.DoSecureRequest(d.URI, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.Token = res.Result.Token
|
|
|
|
deviceResponse, err := d.ExecMethod("get_device_info", false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.TerminalUUID = deviceResponse.Result.MAC
|
|
|
|
return nil
|
|
}
|
|
|
|
// Handshake provides the Tapo device session cookie and encryption cipher.
|
|
func (d *Connection) Handshake() error {
|
|
privKey, pubKey, err := GenerateRSAKeys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pubPEM, err := DumpRSAPEM(pubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := json.Marshal(map[string]interface{}{
|
|
"method": "handshake",
|
|
"params": map[string]interface{}{
|
|
"key": string(pubPEM),
|
|
"requestTimeMils": 0,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := http.Post(d.URI, "application/json", bytes.NewBuffer(req))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var res DeviceResponse
|
|
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = d.CheckErrorCode(res.ErrorCode); err != nil {
|
|
return err
|
|
}
|
|
|
|
encryptedEncryptionKey, err := base64.StdEncoding.DecodeString(res.Result.Key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
encryptionKey, err := rsa.DecryptPKCS1v15(rand.Reader, privKey, encryptedEncryptionKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.Cipher = &ConnectionCipher{
|
|
Key: encryptionKey[:16],
|
|
Iv: encryptionKey[16:],
|
|
}
|
|
|
|
cookie := strings.Split(resp.Header.Get("Set-Cookie"), ";")
|
|
if len(cookie) == 0 {
|
|
return errors.New("missing session cookie")
|
|
}
|
|
|
|
d.SessionID = cookie[0]
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExecMethod executes a Tapo device command method and provides the corresponding response.
|
|
func (d *Connection) ExecMethod(method string, deviceOn bool) (*DeviceResponse, error) {
|
|
var req map[string]interface{}
|
|
|
|
switch method {
|
|
case "set_device_info":
|
|
req = map[string]interface{}{
|
|
"method": method,
|
|
"params": map[string]interface{}{
|
|
"device_on": deviceOn,
|
|
},
|
|
"requestTimeMils": int(time.Now().Unix() * 1000),
|
|
"terminalUUID": d.TerminalUUID,
|
|
}
|
|
default:
|
|
req = map[string]interface{}{
|
|
"method": method,
|
|
"requestTimeMils": int(time.Now().Unix() * 1000),
|
|
}
|
|
}
|
|
|
|
res, err := d.DoSecureRequest(fmt.Sprintf("%s?token=%s", d.URI, d.Token), req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if method == "get_device_info" {
|
|
res.Result.Nickname, err = base64Decode(res.Result.Nickname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res.Result.SSID, err = base64Decode(res.Result.SSID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// ExecCmd executes a Tapo api command and provides the response
|
|
func (d *Connection) ExecCmd(method string, enable bool) (*DeviceResponse, error) {
|
|
// refresh session id
|
|
if time.Since(d.updated) >= 600*time.Minute {
|
|
if err := d.Login(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d.updated = time.Now()
|
|
}
|
|
|
|
return d.ExecMethod(method, enable)
|
|
}
|
|
|
|
// CurrentPower provides current power consuption
|
|
func (d *Connection) CurrentPower() (float64, error) {
|
|
resp, err := d.ExecCmd("get_energy_usage", false)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return float64(resp.Result.Current_Power) / 1e3, nil
|
|
}
|
|
|
|
// ChargedEnergy collects the daily charged energy
|
|
func (d *Connection) ChargedEnergy() (float64, error) {
|
|
resp, err := d.ExecCmd("get_energy_usage", false)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if resp.Result.Today_Energy > d.lasttodayenergy {
|
|
d.energy = d.energy + (resp.Result.Today_Energy - d.lasttodayenergy)
|
|
}
|
|
d.lasttodayenergy = resp.Result.Today_Energy
|
|
|
|
return float64(d.energy) / 1000, nil
|
|
}
|
|
|
|
// DoSecureRequest executes a Tapo device request by encding the request and decoding its response.
|
|
func (d *Connection) DoSecureRequest(uri string, taporequest map[string]interface{}) (*DeviceResponse, error) {
|
|
payload, err := json.Marshal(taporequest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d.log.TRACE.Printf("request: %s", string(payload))
|
|
|
|
encryptedRequest, err := d.Cipher.Encrypt(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"method": "securePassthrough",
|
|
"params": map[string]interface{}{
|
|
"request": base64.StdEncoding.EncodeToString(encryptedRequest),
|
|
},
|
|
}
|
|
|
|
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
|
|
"Cookie": d.SessionID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var res *DeviceResponse
|
|
if err := d.DoJSON(req, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := d.CheckErrorCode(res.ErrorCode); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
decodedResponse, err := base64.StdEncoding.DecodeString(res.Result.Response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
decryptedResponse, err := d.Cipher.Decrypt(decodedResponse)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d.log.TRACE.Printf("decrypted result: %v", string(decryptedResponse))
|
|
|
|
var deviceResp *DeviceResponse
|
|
err = json.Unmarshal(decryptedResponse, &deviceResp)
|
|
|
|
return deviceResp, err
|
|
}
|
|
|
|
// Tapo helper functions
|
|
|
|
func (d *Connection) CheckErrorCode(errorCode int) error {
|
|
errorDesc := map[int]string{
|
|
0: "Success",
|
|
9999: "Login failed, invalid user or password",
|
|
-1002: "Incorrect Request/Method",
|
|
-1003: "JSON formatting error ",
|
|
-1010: "Invalid Public Key Length",
|
|
-1012: "Invalid terminalUUID",
|
|
-1501: "Invalid Request or Credentials",
|
|
}
|
|
|
|
if errorCode != 0 {
|
|
return fmt.Errorf("tapo error %d: %s", errorCode, errorDesc[errorCode])
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *ConnectionCipher) Encrypt(payload []byte) ([]byte, error) {
|
|
paddedPayload, err := pkcs7.Pad(payload, aes.BlockSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
block, err := aes.NewCipher(c.Key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
encrypter := cipher.NewCBCEncrypter(block, c.Iv)
|
|
encryptedPayload := make([]byte, len(paddedPayload))
|
|
encrypter.CryptBlocks(encryptedPayload, paddedPayload)
|
|
|
|
return encryptedPayload, nil
|
|
}
|
|
|
|
func (c *ConnectionCipher) Decrypt(payload []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(c.Key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
encrypter := cipher.NewCBCDecrypter(block, c.Iv)
|
|
decryptedPayload := make([]byte, len(payload))
|
|
|
|
encrypter.CryptBlocks(decryptedPayload, payload)
|
|
|
|
return pkcs7.Unpad(decryptedPayload, aes.BlockSize)
|
|
}
|
|
|
|
func DumpRSAPEM(pubKey *rsa.PublicKey) ([]byte, error) {
|
|
pubKeyPKIX, err := x509.MarshalPKIXPublicKey(pubKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pubPEM := pem.EncodeToMemory(
|
|
&pem.Block{
|
|
Type: "PUBLIC KEY",
|
|
Bytes: pubKeyPKIX,
|
|
},
|
|
)
|
|
|
|
return pubPEM, nil
|
|
}
|
|
|
|
func GenerateRSAKeys() (*rsa.PrivateKey, *rsa.PublicKey, error) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return key, key.Public().(*rsa.PublicKey), nil
|
|
}
|
|
|
|
func base64Decode(base64String string) (string, error) {
|
|
decodedString, err := base64.StdEncoding.DecodeString(base64String)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(decodedString), nil
|
|
}
|