Add TP-Link Tapo Charger (P100,P110) (#3005)

This commit is contained in:
Markus Thierolf 2022-03-27 17:54:16 +02:00 • committed by GitHub
parent d8a6efc35a
commit 4460ed59dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 612 additions and 0 deletions

170
charger/tapo.go Normal file
View file

@ -0,0 +1,170 @@
package charger
import (
"errors"
"fmt"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger/tapo"
"github.com/evcc-io/evcc/util"
)
// TP-Link Tapo charger implementation
type Tapo struct {
conn *tapo.Connection
standbypower float64
updated time.Time
lasttodayenergy int64
energy int64
}
func init() {
registry.Add("tapo", NewTapoFromConfig)
}
// NewTapoFromConfig creates a Tapo charger from generic config
func NewTapoFromConfig(other map[string]interface{}) (api.Charger, error) {
cc := struct {
URI string
User string
Password string
StandbyPower float64
}{}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.URI == "" {
return nil, errors.New("missing uri")
}
return NewTapo(cc.URI, cc.User, cc.Password, cc.StandbyPower)
}
// NewTapo creates Tapo charger
func NewTapo(uri, user, password string, standbypower float64) (*Tapo, error) {
for _, suffix := range []string{"/", "/app"} {
uri = strings.TrimSuffix(uri, suffix)
}
conn := tapo.NewConnection(uri, user, password)
tapo := &Tapo{
conn: conn,
standbypower: standbypower,
}
if user == "" || password == "" {
return tapo, fmt.Errorf("missing user/password")
}
return tapo, nil
}
// Enabled implements the api.Charger interface
func (c *Tapo) Enabled() (bool, error) {
resp, err := c.execTapoCmd("get_device_info", false)
if err != nil {
return false, err
}
return resp.Result.DeviceON, nil
}
// Enable implements the api.Charger interface
func (c *Tapo) Enable(enable bool) error {
_, err := c.execTapoCmd("set_device_info", enable)
return err
}
// MaxCurrent implements the api.Charger interface
func (c *Tapo) MaxCurrent(current int64) error {
return nil
}
// Status implements the api.Charger interface
func (c *Tapo) Status() (api.ChargeStatus, error) {
res := api.StatusB
on, err := c.Enabled()
if err != nil {
return res, err
}
power, err := c.CurrentPower()
if err != nil {
return res, err
}
// static mode || standby power mode condition
if on && (c.standbypower < 0 || power > c.standbypower) {
res = api.StatusC
}
return res, nil
}
var _ api.Meter = (*Tapo)(nil)
// CurrentPower implements the api.Meter interface
func (c *Tapo) CurrentPower() (float64, error) {
resp, err := c.execTapoCmd("get_energy_usage", false)
if err != nil {
return 0, err
}
power := float64(resp.Result.Current_Power) / 1000
// ignore power in standby mode
if c.standbypower >= 0 && power <= c.standbypower {
power = 0
}
// set fix static power in static mode
if c.standbypower < 0 {
on, err := c.Enabled()
if err != nil {
return 0, err
}
if on {
power = c.standbypower * -1
} else {
power = 0
}
}
return power, nil
}
var _ api.ChargeRater = (*Vestel)(nil)
// ChargedEnergy implements the api.ChargeRater interface
func (c *Tapo) ChargedEnergy() (float64, error) {
resp, err := c.execTapoCmd("get_energy_usage", false)
if err != nil {
return 0, err
}
if resp.Result.Today_Energy > c.lasttodayenergy {
c.energy = c.energy + (resp.Result.Today_Energy - c.lasttodayenergy)
}
c.lasttodayenergy = resp.Result.Today_Energy
return float64(c.energy) / 1000, nil
}
// execTapoCmd executes a Tapo api command and provides the response
func (c *Tapo) execTapoCmd(method string, enable bool) (*tapo.DeviceResponse, error) {
// refresh Tapo session id
if time.Since(c.updated) >= 600*time.Minute {
err := c.conn.Login()
if err != nil {
return nil, err
}
// update session timestamp
c.updated = time.Now()
}
return c.conn.ExecMethod(method, enable)
}

352
charger/tapo/connection.go Normal file
View file

@ -0,0 +1,352 @@
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
}
// 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 {
log := util.NewLogger("tapo")
//lint:ignore
h := sha1.New()
_, _ = 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
}
// 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
}
// 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) {
treq, err := json.Marshal(taporequest)
if err != nil {
return nil, err
}
d.log.TRACE.Printf("request: %s\n", string(treq))
encryptedRequest, err := d.Cipher.Encrypt(treq)
if err != nil {
return nil, err
}
securedReq := map[string]interface{}{
"method": "securePassthrough",
"params": map[string]interface{}{
"request": base64.StdEncoding.EncodeToString(encryptedRequest),
},
}
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(securedReq), 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
}
b64decodedResp, err := base64.StdEncoding.DecodeString(res.Result.Response)
if err != nil {
return nil, err
}
decryptedResponse, err := d.Cipher.Decrypt(b64decodedResp)
if err != nil {
return nil, err
}
d.log.TRACE.Printf("decrypted result: %v\n", string(decryptedResponse))
var deviceResp *DeviceResponse
if err = json.Unmarshal(decryptedResponse, &deviceResp); err != nil {
return deviceResp, err
}
return deviceResp, nil
}
// Tapo helper functions
func (d *Connection) CheckErrorCode(errorCode int) error {
errorDesc := map[int]string{
0: "Success",
-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 errors.New(fmt.Sprintf("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)
unpaddedPayload, err := pkcs7.Unpad(decryptedPayload, aes.BlockSize)
if err != nil {
return nil, err
}
return unpaddedPayload, nil
}
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
}

52
charger/tapo/types.go Normal file
View file

@ -0,0 +1,52 @@
package tapo
// 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
// Tapo connection cipher
type ConnectionCipher struct {
Key []byte
Iv []byte
}
// Tapo device response
type DeviceResponse struct {
Result struct {
DeviceID string `json:"device_id"`
FWVersion string `json:"fw_ver"`
HWVersion string `json:"hw_ver"`
Type string `json:"type"`
Model string `json:"model"`
MAC string `json:"mac"`
HWID string `json:"hw_id"`
FWID string `json:"fw_id"`
OEMID string `json:"oem_id"`
Specs string `json:"specs"`
DeviceON bool `json:"device_on"`
OnTime int64 `json:"on_time"`
OverHeated bool `json:"overheated"`
Nickname string `json:"nickname"`
Location string `json:"location"`
Avatar string `json:"avatar"`
Longitude int64 `json:"longitude"`
Latitude int64 `json:"latitude"`
HasSetLocationInfo bool `json:"has_set_location_info"`
IP string `json:"ip"`
SSID string `json:"ssid"`
SignalLevel int64 `json:"signal_level"`
RSSI int64 `json:"rssi"`
Region string `json:"Europe/Kiev"`
TimeDiff int64 `json:"time_diff"`
Lang string `json:"lang"`
Key string `json:"key"`
Response string `json:"response"`
Token string `json:"token"`
Current_Power int64 `json:"current_power"`
Today_Energy int64 `json:"today_energy"`
} `json:"result"`
ErrorCode int `json:"error_code"`
}

1
go.mod
View file

@ -122,6 +122,7 @@ require (
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/miekg/dns v1.1.45 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect

2
go.sum
View file

@ -586,6 +586,8 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9 h1:j6boLfPkcFlRVaKbc0hf5PVh3jJrdHv9n6SIPOdVKaU=
github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9/go.mod h1:GH7jtq102ZiRB7LEKgqP54akN7GOVaNpCJrDWTeWSMY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=

View file

@ -0,0 +1,22 @@
template: tapo
products:
- brand: TP-Link
description:
generic: Tapo
group: switchsockets
params:
- name: uri
required: true
- name: user
required: true
- name: password
required: true
mask: true
- name: standbypower
default: 15
render: |
type: tapo
uri: {{ .uri }}
user: {{ .user }}
password: {{ .password }}
standbypower: {{ .standbypower }} # treat as charging above this power

View file

@ -0,0 +1,13 @@
product:
brand: TP-Link
description: Tapo
group: Schaltbare Steckdosen
render:
- default: |
type: template
template: tapo
uri:
user: # Benutzername um auf das Gerät zuzugreifen
password: # Passwort des Benutzerkontos
standbypower: 15 # Leistung oberhalb des angegebenen Wertes wird als Ladeleistung gewertet # Optional