diff --git a/charger/tapo.go b/charger/tapo.go new file mode 100644 index 000000000..b9b71f2fe --- /dev/null +++ b/charger/tapo.go @@ -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) +} diff --git a/charger/tapo/connection.go b/charger/tapo/connection.go new file mode 100644 index 000000000..30a8dde11 --- /dev/null +++ b/charger/tapo/connection.go @@ -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 +} diff --git a/charger/tapo/types.go b/charger/tapo/types.go new file mode 100644 index 000000000..4a8027ed0 --- /dev/null +++ b/charger/tapo/types.go @@ -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"` +} diff --git a/go.mod b/go.mod index dd31293f9..2b727081e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 526700bd3..ef2f1ad68 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/templates/definition/charger/tapo.yaml b/templates/definition/charger/tapo.yaml new file mode 100644 index 000000000..d364ad759 --- /dev/null +++ b/templates/definition/charger/tapo.yaml @@ -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 diff --git a/templates/docs/charger/tapo_0.yaml b/templates/docs/charger/tapo_0.yaml new file mode 100644 index 000000000..392ca4b03 --- /dev/null +++ b/templates/docs/charger/tapo_0.yaml @@ -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 \ No newline at end of file